Files
AirCoding/packages/runtime/src/doctor/DoctorService.ts
AirCoding 5e282a39b4 feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复
Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:13:16 +08:00

325 lines
12 KiB
TypeScript
Executable File

/**
* DoctorService - Diagnostic and repair service
* DD §16.1. Self-bootstrap before capability checks.
* INV-4: dependency installs originate here.
*
* @module packages/runtime/src/doctor/DoctorService
*/
import { existsSync, accessSync, constants, mkdirSync } from 'fs'
import { join } from 'path'
import { execFileSync } from 'child_process'
export interface DoctorCheck {
name: string
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider'
passed: boolean
message: string
fixable: boolean
fix?: string
}
export interface DoctorReport {
checks: DoctorCheck[]
all_passed: boolean
bootstrap_passed: boolean
fixable_count: number
}
export class DoctorService {
private project_root: string
private capability_registry?: any
constructor(project_root: string, capability_registry?: any) {
this.project_root = project_root
this.capability_registry = capability_registry
}
/**
* Run all diagnostic checks.
*/
async run_diagnostics(scope: 'all' | 'self_bootstrap' | 'capability' = 'all'): Promise<DoctorReport> {
const checks: DoctorCheck[] = []
// Self-bootstrap checks (always run first)
checks.push(this.check_bun())
checks.push(this.check_sqlite())
checks.push(this.check_shell())
checks.push(this.check_air_writability())
const bootstrap_passed = checks.every(c => c.passed)
if (!bootstrap_passed) {
return { checks, all_passed: false, bootstrap_passed, fixable_count: checks.filter(c => c.fixable).length }
}
if (scope === 'self_bootstrap') {
return { checks, all_passed: bootstrap_passed, bootstrap_passed, fixable_count: 0 }
}
// Capability checks
checks.push(this.check_git())
checks.push(this.check_node())
checks.push(this.check_project_structure())
// INV-4: Capability registry health — verify capability dependencies
if (this.capability_registry) {
checks.push(this.check_capability_deps())
}
// FR-018/§6.12: toolchain / display / network / provider checks
if (scope === 'all') {
checks.push(...this.check_cpp_toolchain())
checks.push(this.check_display())
checks.push(await this.check_network())
checks.push(...await this.check_provider())
}
const all_passed = checks.every(c => c.passed)
return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length }
}
/**
* Attempt to fix an issue.
* INV-4: dependency installs originate here.
*/
async fix(check_name: string): Promise<{ ok: boolean; message: string }> {
// Implement self-repair logic per DD §16.1
switch (check_name) {
case 'bun': {
return { ok: false, message: 'Bun installation requires manual setup. Run: curl -fsSL https://bun.sh/install | bash' }
}
case 'git': {
return { ok: false, message: 'Git installation requires manual setup. Run: apt install git (Debian/Ubuntu)' }
}
case 'node': {
return { ok: false, message: 'Node.js installation requires manual setup. Run: https://nodejs.org' }
}
case 'air_writability': {
try {
const air_dir = join(this.project_root, '.air')
if (!existsSync(air_dir)) {
mkdirSync(air_dir, { recursive: true })
}
mkdirSync(join(air_dir, 'shared'), { recursive: true })
mkdirSync(join(air_dir, 'local'), { recursive: true })
mkdirSync(join(air_dir, 'sessions'), { recursive: true })
mkdirSync(join(air_dir, 'logs'), { recursive: true })
return { ok: true, message: 'Created .air directory structure' }
} catch (e) {
return { ok: false, message: `Failed to create .air directory: ${e}` }
}
}
case 'project_structure': {
return { ok: false, message: 'Run air init to create project structure' }
}
default:
return { ok: false, message: `Fix for ${check_name} not implemented` }
}
}
private check_bun(): DoctorCheck {
// Search bun in common paths, not just PATH
const candidates = [
'bun', // PATH
`${process.env.HOME || '/root'}/.bun/bin/bun`,
'/usr/local/bin/bun',
'/usr/bin/bun',
]
for (const bun of candidates) {
try {
const version = execFileSync(bun, ['--version'], { stdio: 'pipe', timeout: 5000 }).toString().trim()
return { name: 'bun', category: 'self_bootstrap', passed: true, message: `Bun ${version} found`, fixable: false }
} catch { /* try next */ }
}
return { name: 'bun', category: 'self_bootstrap', passed: false, message: 'Bun not found', fixable: true, fix: 'Install Bun: curl -fsSL https://bun.sh/install | bash' }
}
private check_sqlite(): DoctorCheck {
return { name: 'sqlite', category: 'self_bootstrap', passed: true, message: 'SQLite via Bun built-in', fixable: false }
}
private check_shell(): DoctorCheck {
return { name: 'shell', category: 'self_bootstrap', passed: true, message: 'Shell available', fixable: false }
}
private check_air_writability(): DoctorCheck {
const air_dir = join(this.project_root, '.air')
try {
if (!existsSync(air_dir)) {
return { name: 'air_writability', category: 'self_bootstrap', passed: false, message: '.air directory does not exist', fixable: true, fix: 'Run project.initialize()' }
}
accessSync(air_dir, constants.W_OK)
return { name: 'air_writability', category: 'self_bootstrap', passed: true, message: '.air directory is writable', fixable: false }
} catch {
return { name: 'air_writability', category: 'self_bootstrap', passed: false, message: '.air directory is not writable', fixable: true }
}
}
private check_git(): DoctorCheck {
try {
execFileSync('git', ['--version'], { stdio: 'pipe', timeout: 5000 })
return { name: 'git', category: 'capability', passed: true, message: 'Git available', fixable: false }
} catch {
return { name: 'git', category: 'capability', passed: false, message: 'Git not found', fixable: true, fix: 'Install Git: apt install git' }
}
}
private check_node(): DoctorCheck {
try {
const version = execFileSync('node', ['--version'], { stdio: 'pipe', timeout: 5000 }).toString().trim()
return { name: 'node', category: 'capability', passed: true, message: `Node.js ${version} available`, fixable: false }
} catch {
return { name: 'node', category: 'capability', passed: false, message: 'Node.js not found', fixable: true, fix: 'Install Node.js: https://nodejs.org' }
}
}
private check_project_structure(): DoctorCheck {
const required = ['package.json', 'tsconfig.json']
const missing: string[] = []
for (const file of required) {
if (!existsSync(join(this.project_root, file))) {
missing.push(file)
}
}
if (missing.length > 0) {
return { name: 'project_structure', category: 'project', passed: false, message: `Missing: ${missing.join(', ')}`, fixable: true, fix: 'Run air init to create project structure' }
}
return { name: 'project_structure', category: 'project', passed: true, message: 'Project structure valid', fixable: false }
}
/**
* INV-4: Check capability registry health — verify capability dependencies
* are installed and accessible. Bridges CapabilityRegistry → DoctorService.
*/
private check_capability_deps(): DoctorCheck {
try {
const capabilities = this.capability_registry?.list?.() || []
if (capabilities.length === 0) {
return { name: 'capability_deps', category: 'capability', passed: true, message: 'No capabilities registered — nothing to check', fixable: false }
}
const missing_deps: string[] = []
for (const cap of capabilities) {
const entry = this.capability_registry?.get?.(cap.id) || cap
const deps = entry?.manifest?.dependencies || []
for (const dep of deps) {
try {
const { execFileSync } = require('child_process')
execFileSync('which', [dep], { stdio: 'pipe', timeout: 3000 })
} catch {
missing_deps.push(`${cap.name || cap.id}:${dep}`)
}
}
}
if (missing_deps.length > 0) {
return {
name: 'capability_deps',
category: 'capability',
passed: false,
message: `Missing capability dependencies: ${missing_deps.join(', ')}`,
fixable: true,
fix: 'Install missing tools: apt install ' + missing_deps.map(d => d.split(':')[1]).join(' ')
}
}
return { name: 'capability_deps', category: 'capability', passed: true, message: `All ${capabilities.length} capabilities healthy`, fixable: false }
} catch (e: any) {
return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true }
}
}
// ===== FR-018/§6.12: 5 new categories =====
private check_cpp_toolchain(): DoctorCheck[] {
const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++']
const reports: DoctorCheck[] = []
for (const t of tools) {
try {
const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim()
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false })
} catch {
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` })
}
}
return reports
}
private check_display(): DoctorCheck {
const display = process.env.DISPLAY
const wayland = process.env.WAYLAND_DISPLAY
if (!display && !wayland) {
return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false }
}
try {
execFileSync('which', ['import'], { stdio: 'pipe' })
return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false }
} catch {
return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' }
}
}
private async check_network(): Promise<DoctorCheck> {
try {
const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) })
return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false }
} catch (e: any) {
return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false }
}
}
private async check_provider(): Promise<DoctorCheck[]> {
const reports: DoctorCheck[] = []
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY
const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL
const model = process.env.AIRCODING_MODEL
reports.push({
name: 'provider.api_key',
category: 'provider',
passed: Boolean(apiKey),
message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set',
fixable: false,
})
reports.push({
name: 'provider.base_url',
category: 'provider',
passed: Boolean(baseUrl),
message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set',
fixable: false,
})
reports.push({
name: 'provider.model',
category: 'provider',
passed: Boolean(model),
message: model || 'No model set',
fixable: false,
})
if (apiKey && baseUrl) {
try {
const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(5000),
})
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: r.ok || r.status > 0,
message: `HTTP ${r.status}`,
fixable: false,
})
} catch (e: any) {
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: false,
message: e.message,
fixable: false,
})
}
}
return reports
}
}