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>
This commit is contained in:
@@ -15,7 +15,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aircoding/contracts": "workspace:*",
|
||||
"@aircoding/llm": "workspace:*"
|
||||
"@aircoding/llm": "workspace:*",
|
||||
"@aircoding/toolchain-cpp": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
|
||||
@@ -20,9 +20,9 @@ import { DatabaseManager } from '../storage/DatabaseManager.js'
|
||||
import { MigrationRunner } from '../storage/MigrationRunner.js'
|
||||
import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js'
|
||||
import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js'
|
||||
import { EventBus } from '../events/EventBus.js'
|
||||
import { EventBus, eventBus, type Subscription } from '../events/EventBus.js'
|
||||
import { EventStore, eventStore } from '../events/EventStore.js'
|
||||
import { EventIngestorImpl } from '../events/EventIngestor.js'
|
||||
import { EventIngestorImpl, eventIngestor } from '../events/EventIngestor.js'
|
||||
import { TaskRepository } from '../storage/repositories/TaskRepository.js'
|
||||
import { MessageRepository } from '../storage/repositories/MessageRepository.js'
|
||||
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
|
||||
@@ -48,6 +48,8 @@ export interface RuntimeAppConfig {
|
||||
|
||||
export class RuntimeApp {
|
||||
private config: RuntimeAppConfig
|
||||
private projection_subscription: Subscription | null = null
|
||||
private projection_client_unsubscribe: (() => void) | null = null
|
||||
scheduler: Scheduler
|
||||
worker_manager: WorkerManager
|
||||
context_assembler: ContextAssembler
|
||||
@@ -86,18 +88,22 @@ export class RuntimeApp {
|
||||
this.doctor = new DoctorService(config.project_root, this.capability_registry)
|
||||
this.projection_store = new ProjectionStore()
|
||||
this.projection_client = new ProjectionClient()
|
||||
this.event_bus = new EventBus()
|
||||
this.event_bus = eventBus
|
||||
const raw_db = this.db.getRawDatabase()
|
||||
// Wire singleton eventStore with real DB (EventIngestor uses it)
|
||||
if (raw_db) eventStore.setTransactionManager(this.db)
|
||||
// Use module singleton eventStore - don't create separate instance
|
||||
this.event_store = eventStore
|
||||
this.event_ingestor = new EventIngestorImpl()
|
||||
this.event_ingestor = eventIngestor
|
||||
|
||||
// Wire ProjectionStore → ProjectionClient (DD §13.2)
|
||||
this.projection_store.subscribe((projection) => {
|
||||
this.projection_client_unsubscribe = this.projection_store.subscribe((projection) => {
|
||||
this.projection_client.receive_snapshot(projection)
|
||||
})
|
||||
this.projection_subscription = this.event_bus.subscribe(
|
||||
{ session_id: config.session_id },
|
||||
(event) => this.projection_store.apply(event),
|
||||
)
|
||||
|
||||
// Wire Scheduler to WorkerManager (DD §7.1)
|
||||
this.scheduler = new Scheduler({
|
||||
@@ -150,13 +156,19 @@ export class RuntimeApp {
|
||||
registrar.register_all(this.config.project_root)
|
||||
this.logger.info('Built-in tools registered')
|
||||
|
||||
// Step 4: Wire EventStore with DB transaction manager
|
||||
// Step 4: Discover project-local SKILL.md capabilities without executing skill content.
|
||||
await this.discover_project_skills()
|
||||
|
||||
// Step 4.5: Register cpp toolchain via CapabilityRegistry (INV-4)
|
||||
await this.register_cpp_toolchain()
|
||||
|
||||
// Step 5: Wire EventStore with DB transaction manager
|
||||
this.event_store.setTransactionManager(this.db)
|
||||
|
||||
// Step 5: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
|
||||
// Step 6: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
|
||||
this.logger.info('Hydrating projection store', { session_id: this.config.session_id })
|
||||
|
||||
// Step 6: Wire all domain repositories to module singleton EventStore
|
||||
// Step 7: Wire all domain repositories to module singleton EventStore
|
||||
try {
|
||||
const raw_db = this.db.getRawDatabase()
|
||||
if (raw_db) {
|
||||
@@ -181,6 +193,15 @@ export class RuntimeApp {
|
||||
diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo,
|
||||
})
|
||||
|
||||
this.projection_store.set_repos({
|
||||
session: sessionRepo,
|
||||
task: taskRepo,
|
||||
agent: agentRepo,
|
||||
})
|
||||
|
||||
await this.ensure_session_created(sessionRepo)
|
||||
await this.projection_store.rebuild(this.config.session_id)
|
||||
|
||||
// Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new)
|
||||
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
|
||||
this.scheduler.set_task_repo(taskRepo)
|
||||
@@ -194,12 +215,108 @@ export class RuntimeApp {
|
||||
this.logger.info('RuntimeApp started')
|
||||
}
|
||||
|
||||
private async discover_project_skills(): Promise<void> {
|
||||
const roots = [
|
||||
join(this.config.project_root, '.air', 'shared', 'skills'),
|
||||
join(this.config.project_root, '.air', 'shared', 'skill'),
|
||||
].filter((root) => existsSync(root))
|
||||
|
||||
if (roots.length === 0) return
|
||||
|
||||
const discovered = this.capability_registry.discover_skill_roots(roots)
|
||||
let registered_count = 0
|
||||
|
||||
for (const result of discovered) {
|
||||
if (!result.ok || !result.capability_id) {
|
||||
this.logger.warn('Skill discovery failed', { error: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const validation = this.capability_registry.validate(result.capability_id)
|
||||
if (!validation.valid) {
|
||||
this.logger.warn('Skill validation failed', { capability_id: result.capability_id, errors: validation.errors })
|
||||
continue
|
||||
}
|
||||
|
||||
const doctor = await this.capability_registry.doctor_check(result.capability_id)
|
||||
if (!doctor.ok) {
|
||||
this.logger.warn('Skill doctor check failed', { capability_id: result.capability_id, error: doctor.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const enabled = this.capability_registry.enable(result.capability_id)
|
||||
if (!enabled.ok) {
|
||||
this.logger.warn('Skill enable failed', { capability_id: result.capability_id, error: enabled.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const registered = this.capability_registry.register_tools(result.capability_id)
|
||||
if (!registered.ok) {
|
||||
this.logger.warn('Skill tool registration failed', { capability_id: result.capability_id, error: registered.error })
|
||||
continue
|
||||
}
|
||||
registered_count += registered.registered_count
|
||||
}
|
||||
|
||||
if (registered_count > 0) this.logger.info('Project skills registered', { registered_count })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cpp toolchain via CapabilityRegistry (INV-4)
|
||||
* Uses CppToolRegistrar from toolchain-cpp package.
|
||||
*/
|
||||
private async register_cpp_toolchain(): Promise<void> {
|
||||
try {
|
||||
// Dynamic import to avoid static dependency (INV-4: single direction)
|
||||
const cppPkg = await import('@aircoding/toolchain-cpp')
|
||||
const registrar = new cppPkg.CppToolRegistrar()
|
||||
|
||||
// Register tools through the CppToolRegistrar
|
||||
// This follows INV-4: registered via capability boundary
|
||||
registrar.register(this.tool_registry, this.config.project_root)
|
||||
this.logger.info('cpp toolchain registered', { capability_id: 'aircoding-cpp-toolchain' })
|
||||
} catch (e: any) {
|
||||
this.logger.warn('cpp toolchain registration failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
private async ensure_session_created(sessionRepo: SessionRepository): Promise<void> {
|
||||
const existing = await sessionRepo.get(this.config.session_id)
|
||||
if (existing) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${this.config.session_id}_created`,
|
||||
type: 'session.created',
|
||||
version: 1,
|
||||
timestamp: now,
|
||||
session_id: this.config.session_id,
|
||||
project_id: this.config.project_id,
|
||||
source: { kind: 'system' },
|
||||
route: ['runtime', 'start'],
|
||||
payload: {
|
||||
session_id: this.config.session_id,
|
||||
project_id: this.config.project_id,
|
||||
project_root: this.config.project_root,
|
||||
title: this.config.project_root.split('/').pop() || 'AirCoding',
|
||||
metadata: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the runtime: flush logs, close DB, cancel workers.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
this.logger.info('RuntimeApp shutting down')
|
||||
|
||||
if (this.projection_subscription) {
|
||||
this.event_bus.unsubscribe(this.projection_subscription)
|
||||
this.projection_subscription = null
|
||||
}
|
||||
this.projection_client_unsubscribe?.()
|
||||
this.projection_client_unsubscribe = null
|
||||
|
||||
// Cancel all running workers
|
||||
try {
|
||||
for (const handle of this.worker_manager.list()) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { ToolDefinition } from '@aircoding/contracts'
|
||||
|
||||
import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js'
|
||||
import { loadSkillDirectory, loadSkillsFromRoots, type SkillDefinition } from './SkillLoader.js'
|
||||
|
||||
export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed'
|
||||
|
||||
@@ -62,6 +63,30 @@ export class CapabilityRegistry {
|
||||
return { ok: true, capability_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover one SKILL.md directory as a capability manifest.
|
||||
*/
|
||||
discover_skill_directory(skill_dir: string, trusted_roots: string[]): { ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string } {
|
||||
try {
|
||||
const skill = loadSkillDirectory(skill_dir, trusted_roots)
|
||||
const discovered = this.discover(skill.manifest)
|
||||
return { ...discovered, skill }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all SKILL.md entries under trusted roots.
|
||||
*/
|
||||
discover_skill_roots(roots: string[]): Array<{ ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string }> {
|
||||
try {
|
||||
return loadSkillsFromRoots(roots).map((skill) => ({ ...this.discover(skill.manifest), skill }))
|
||||
} catch (error) {
|
||||
return [{ ok: false, error: error instanceof Error ? error.message : String(error) }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a discovered capability.
|
||||
*/
|
||||
|
||||
117
packages/runtime/src/capabilities/SkillLoader.ts
Executable file
117
packages/runtime/src/capabilities/SkillLoader.ts
Executable file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* SkillLoader - SKILL.md capability bridge.
|
||||
* Loads skill directories into Capability manifests without executing skill content.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
||||
import { resolve, relative, basename } from 'path'
|
||||
import type { CapabilityManifest } from './CapabilityManifestValidator.js'
|
||||
|
||||
export interface SkillDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
directory: string
|
||||
content: string
|
||||
frontmatter: Record<string, unknown>
|
||||
manifest: CapabilityManifest
|
||||
}
|
||||
|
||||
export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition {
|
||||
const directory = resolve(skill_dir)
|
||||
ensureTrusted(directory, trusted_roots)
|
||||
const skill_path = resolve(directory, 'SKILL.md')
|
||||
if (!existsSync(skill_path) || !statSync(skill_path).isFile()) {
|
||||
throw new Error(`SKILL.md not found in ${directory}`)
|
||||
}
|
||||
|
||||
const raw = readFileSync(skill_path, 'utf-8')
|
||||
const parsed = parseSkillMarkdown(raw)
|
||||
const name = slug(String(parsed.frontmatter.name || basename(directory)))
|
||||
const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`)
|
||||
const toolName = `skill.${name}`
|
||||
const manifest: CapabilityManifest = {
|
||||
schema_version: 1,
|
||||
name,
|
||||
version: String(parsed.frontmatter.version || '1.0.0'),
|
||||
description,
|
||||
trust_level: 'project_local',
|
||||
tools: [{
|
||||
name: toolName,
|
||||
category: 'internal',
|
||||
permissions: { read: true, write: false, network: false },
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: { type: 'string' },
|
||||
skill_directory: { type: 'string' },
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest }
|
||||
}
|
||||
|
||||
export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] {
|
||||
const skills: SkillDefinition[] = []
|
||||
for (const root of roots.map((r) => resolve(r))) {
|
||||
if (!existsSync(root) || !statSync(root).isDirectory()) continue
|
||||
const direct = resolve(root, 'SKILL.md')
|
||||
if (existsSync(direct)) {
|
||||
skills.push(loadSkillDirectory(root, roots))
|
||||
continue
|
||||
}
|
||||
const entries = Array.from(new Set(readDirectoryNames(root)))
|
||||
for (const entry of entries) {
|
||||
const dir = resolve(root, entry)
|
||||
if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots))
|
||||
}
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
function ensureTrusted(path: string, roots: string[]): void {
|
||||
const trusted = roots.map((root) => resolve(root)).some((root) => {
|
||||
const rel = relative(root, path)
|
||||
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'))
|
||||
})
|
||||
if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`)
|
||||
}
|
||||
|
||||
function parseSkillMarkdown(raw: string): { frontmatter: Record<string, unknown>; body: string } {
|
||||
if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() }
|
||||
const end = raw.indexOf('\n---\n', 4)
|
||||
if (end === -1) return { frontmatter: {}, body: raw.trim() }
|
||||
const frontmatter = parseFrontmatter(raw.slice(4, end))
|
||||
return { frontmatter, body: raw.slice(end + 5).trim() }
|
||||
}
|
||||
|
||||
function parseFrontmatter(text: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
const key = line.slice(0, idx).trim()
|
||||
const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function firstParagraph(text: string): string {
|
||||
return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || ''
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return next || 'skill'
|
||||
}
|
||||
|
||||
function readDirectoryNames(root: string): string[] {
|
||||
return readdirSync(root).filter((name) => {
|
||||
const path = resolve(root, name)
|
||||
return statSync(path).isDirectory()
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { execFileSync } from 'child_process'
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string
|
||||
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime'
|
||||
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider'
|
||||
passed: boolean
|
||||
message: string
|
||||
fixable: boolean
|
||||
@@ -66,6 +66,14 @@ export class DoctorService {
|
||||
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 }
|
||||
}
|
||||
@@ -219,4 +227,98 @@ export class DoctorService {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export { BuiltInToolRegistrar, register_builtin_tools } from './tools/BuiltInToo
|
||||
// Capabilities
|
||||
export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js'
|
||||
export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js'
|
||||
export { loadSkillDirectory, loadSkillsFromRoots } from './capabilities/SkillLoader.js'
|
||||
export type { SkillDefinition } from './capabilities/SkillLoader.js'
|
||||
|
||||
// Context
|
||||
export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js'
|
||||
|
||||
@@ -65,8 +65,12 @@ export interface ArtifactProjection {
|
||||
|
||||
export interface PermissionPromptProjection {
|
||||
prompt_id: string
|
||||
tool_name: string
|
||||
subject: string
|
||||
risk_level: string
|
||||
reason: string
|
||||
options: string[]
|
||||
default_option?: string
|
||||
tool_name?: string
|
||||
}
|
||||
|
||||
export interface BlockerProjection {
|
||||
@@ -291,7 +295,12 @@ export class ProjectionStore {
|
||||
case 'permission.prompt.requested': {
|
||||
proj.permission_prompts.push({
|
||||
prompt_id: p.prompt_id || `pp_${Date.now()}`,
|
||||
tool_name: p.tool_name, reason: p.reason || ''
|
||||
subject: p.subject || p.tool_name || 'permission request',
|
||||
risk_level: p.risk_level || 'unknown',
|
||||
reason: p.reason || '',
|
||||
options: Array.isArray(p.options) ? p.options : [],
|
||||
default_option: p.default_option,
|
||||
tool_name: p.tool_name,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -334,14 +343,17 @@ export class ProjectionStore {
|
||||
* Returns the rebuilt projection.
|
||||
*/
|
||||
async rebuild(session_id: string): Promise<SessionProjection | undefined> {
|
||||
const session = this.repos.session ? await this.repos.session.get(session_id as SessionID) : undefined
|
||||
const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : []
|
||||
const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : []
|
||||
|
||||
// Initialize projection with what we have
|
||||
if (!session && tasks.length === 0 && agents.length === 0) return undefined
|
||||
|
||||
const proj: SessionProjection = {
|
||||
session_id,
|
||||
project_id: '',
|
||||
status: 'active',
|
||||
project_id: session?.project_id ?? '',
|
||||
status: session?.status ?? 'active',
|
||||
title: session?.title,
|
||||
tasks: tasks.map((t: any) => ({
|
||||
id: t.id, type: t.type, status: t.status, title: t.title || '',
|
||||
retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '',
|
||||
@@ -359,6 +371,7 @@ export class ProjectionStore {
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
this.snapshot.set(session_id, proj)
|
||||
this.notify(proj)
|
||||
return proj
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { WavePlanner } from './WavePlanner.js'
|
||||
import { RetryPlanner } from './RetryPlanner.js'
|
||||
import { WorkspaceManager } from './WorkspaceManager.js'
|
||||
import { AgentMonitor } from './AgentMonitor.js'
|
||||
import { eventIngestor } from '../events/EventIngestor.js'
|
||||
import { eventIngestor, type IEventIngestor } from '../events/EventIngestor.js'
|
||||
import type { WorkerManager } from '../workers/WorkerManager.js'
|
||||
|
||||
export type SchedulerState =
|
||||
@@ -48,9 +48,11 @@ export class Scheduler {
|
||||
private context: SchedulerContext
|
||||
private worker_manager?: WorkerManager
|
||||
private task_repo?: any
|
||||
private event_ingestor: IEventIngestor
|
||||
|
||||
constructor(context: SchedulerContext, worker_manager?: WorkerManager) {
|
||||
constructor(context: SchedulerContext, worker_manager?: WorkerManager, ingestor: IEventIngestor = eventIngestor) {
|
||||
this.context = context
|
||||
this.event_ingestor = ingestor
|
||||
this.graph = new TaskGraph()
|
||||
this.wave_planner = new WavePlanner()
|
||||
this.retry_planner = new RetryPlanner()
|
||||
@@ -62,18 +64,19 @@ export class Scheduler {
|
||||
/**
|
||||
* Create tasks from specifications.
|
||||
*/
|
||||
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): Promise<void> {
|
||||
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[]; task_spec?: Record<string, unknown> }>): Promise<void> {
|
||||
for (const task of tasks) {
|
||||
this.graph.add_task({
|
||||
id: task.id,
|
||||
status: 'pending',
|
||||
type: task.type, title: task.title,
|
||||
description: task.description,
|
||||
task_spec: task.task_spec,
|
||||
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
|
||||
})
|
||||
|
||||
// Emit task.created events (INV-1: via event store for projection)
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_created`,
|
||||
type: 'task.created',
|
||||
version: 1,
|
||||
@@ -86,7 +89,7 @@ export class Scheduler {
|
||||
task_id: task.id,
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
task_spec_json: { description: task.description || '' },
|
||||
task_spec_json: task.task_spec || { description: task.description || '' },
|
||||
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
|
||||
metadata: {},
|
||||
}
|
||||
@@ -132,25 +135,23 @@ export class Scheduler {
|
||||
break
|
||||
|
||||
case 'PLANNING_WAVE': {
|
||||
// Check if all tasks done
|
||||
const counts = this.graph.count_by_status()
|
||||
const remaining = (counts.pending || 0) + (counts.running || 0)
|
||||
const pending = counts.pending || 0
|
||||
const running = counts.running || 0
|
||||
|
||||
if (remaining === 0) {
|
||||
this.state = 'COMPLETED'
|
||||
if (pending === 0 && running === 0) {
|
||||
this.state = this.terminal_state_from_counts(counts)
|
||||
return
|
||||
}
|
||||
|
||||
if (running > 0) {
|
||||
this.state = 'MONITORING'
|
||||
return
|
||||
}
|
||||
|
||||
// Plan next wave
|
||||
const plan = this.wave_planner.plan(this.graph)
|
||||
if (plan.length === 0) {
|
||||
// Check for blocked tasks
|
||||
const pending = this.graph.count_by_status().pending || 0
|
||||
if (pending > 0) {
|
||||
this.state = 'REPAIRING_OR_CONTINUING'
|
||||
return
|
||||
}
|
||||
this.state = 'COMPLETED'
|
||||
this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,7 +166,7 @@ export class Scheduler {
|
||||
|
||||
// INV-1: Emit task.started event (durable) for projection
|
||||
const now = new Date().toISOString()
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_started`,
|
||||
type: 'task.started',
|
||||
version: 1,
|
||||
@@ -185,7 +186,7 @@ export class Scheduler {
|
||||
session_id: this.context.session_id,
|
||||
project_root: this.context.project_root,
|
||||
task_type: task.type || 'execute',
|
||||
task_spec: {
|
||||
task_spec: task.task_spec || {
|
||||
id: task.id,
|
||||
title: task.title || task.id,
|
||||
description: task.description || '',
|
||||
@@ -196,7 +197,7 @@ export class Scheduler {
|
||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||
|
||||
// INV-1: Emit agent.started event (durable) for projection
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${agent_id}_started`,
|
||||
type: 'agent.started',
|
||||
version: 1,
|
||||
@@ -218,7 +219,7 @@ export class Scheduler {
|
||||
})
|
||||
} catch {
|
||||
// INV-1: emit task.failed event for projection
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_failed`,
|
||||
type: 'task.failed',
|
||||
version: 1,
|
||||
@@ -245,7 +246,7 @@ export class Scheduler {
|
||||
const hb = this.agent_monitor.get(l.agent_id)
|
||||
if (hb) {
|
||||
const now = new Date().toISOString()
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${hb.task_id}_lost`,
|
||||
type: 'agent.lost',
|
||||
version: 1,
|
||||
@@ -269,7 +270,7 @@ export class Scheduler {
|
||||
case 'hard_cancel':
|
||||
case 'soft_cancel':
|
||||
if (task_id) {
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task_id}_cancelled`,
|
||||
type: 'agent.cancelled',
|
||||
version: 1,
|
||||
@@ -299,7 +300,7 @@ export class Scheduler {
|
||||
|
||||
const attempt_id = `${task.id}_1`
|
||||
if (result.status === 'completed') {
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_completed`,
|
||||
type: 'task.completed',
|
||||
version: 1,
|
||||
@@ -320,7 +321,7 @@ export class Scheduler {
|
||||
})
|
||||
this.graph.update_status(task.id, 'completed')
|
||||
// INV-1: Emit agent.completed event (durable) for projection
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${handle.worker_id}_completed`,
|
||||
type: 'agent.completed',
|
||||
version: 1,
|
||||
@@ -333,7 +334,7 @@ export class Scheduler {
|
||||
})
|
||||
this.agent_monitor.remove(handle.worker_id)
|
||||
} else if (result.status === 'blocked') {
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_blocked`,
|
||||
type: 'task.blocked',
|
||||
version: 1,
|
||||
@@ -347,7 +348,7 @@ export class Scheduler {
|
||||
this.graph.update_status(task.id, 'blocked')
|
||||
this.agent_monitor.remove(handle.worker_id)
|
||||
} else if (result.status === 'cancelled') {
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_cancelled_result`,
|
||||
type: 'task.cancelled',
|
||||
version: 1,
|
||||
@@ -361,7 +362,7 @@ export class Scheduler {
|
||||
this.graph.update_status(task.id, 'cancelled')
|
||||
this.agent_monitor.remove(handle.worker_id)
|
||||
} else {
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${task.id}_failed_result`,
|
||||
type: 'task.failed',
|
||||
version: 1,
|
||||
@@ -374,7 +375,7 @@ export class Scheduler {
|
||||
})
|
||||
this.graph.update_status(task.id, 'failed')
|
||||
// INV-1: Emit agent.failed event (durable) for projection
|
||||
await eventIngestor.ingest({
|
||||
await this.event_ingestor.ingest({
|
||||
id: `evt_${handle.worker_id}_failed`,
|
||||
type: 'agent.failed',
|
||||
version: 1,
|
||||
@@ -440,6 +441,13 @@ export class Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private terminal_state_from_counts(counts: Record<string, number>): SchedulerState {
|
||||
if ((counts.failed || 0) > 0) return 'TERMINATED'
|
||||
if ((counts.blocked || 0) > 0) return 'BLOCKED'
|
||||
if ((counts.cancelled || 0) > 0) return 'CANCELLED'
|
||||
return 'COMPLETED'
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
|
||||
* Loads pending/running tasks from the tasks table and reconstructs the in-memory graph.
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface TaskNode {
|
||||
title?: string
|
||||
description?: string
|
||||
acceptance_criteria?: string[]
|
||||
task_spec?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GraphValidation {
|
||||
|
||||
@@ -132,20 +132,6 @@ export class BuiltInToolRegistrar {
|
||||
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
|
||||
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
|
||||
{ read: false, write: true, network: false }),
|
||||
// cpp toolchain
|
||||
'cpp.detect': def('cpp.detect', 'debug', 'Detect C++ project structure, toolchain, and source files',
|
||||
{ project_root: { type: 'string', description: 'Project root path' } }, []),
|
||||
'cpp.cmake.configure': def('cpp.cmake.configure', 'build', 'Configure C++ build with CMake (Ninja preferred, Make fallback)',
|
||||
{ generator: { type: 'string', description: 'Generator (Ninja/Unix Makefiles)' }, build_type: { type: 'string', description: 'Debug/Release/RelWithDebInfo' } }, [],
|
||||
{ read: true, write: true, network: false }),
|
||||
'cpp.build': def('cpp.build', 'build', 'Build C++ project via CMake',
|
||||
{ target: { type: 'string', description: 'Build target' }, config: { type: 'string', description: 'Debug/Release' } }, []),
|
||||
'cpp.test': def('cpp.test', 'test', 'Run C++ tests via ctest',
|
||||
{ filter: { type: 'string', description: 'Test filter pattern' } }, []),
|
||||
'cpp.static.cppcheck': def('cpp.static.cppcheck', 'static_analysis', 'Run cppcheck static analysis on C++ code',
|
||||
{ path: { type: 'string', description: 'Path to analyze' }, severity: { type: 'string', description: 'Minimum severity' } }, []),
|
||||
'cpp.clangd.query': def('cpp.clangd.query', 'static_analysis', 'Query clangd LSP for symbol definition or diagnostics',
|
||||
{ file: { type: 'string', description: 'Source file path' }, line: { type: 'number', description: 'Line number' }, column: { type: 'number', description: 'Column number' } }, ['file']),
|
||||
// debug
|
||||
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
|
||||
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
|
||||
@@ -257,88 +243,6 @@ export class BuiltInToolRegistrar {
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.detect': async (call: any) => {
|
||||
try {
|
||||
const root = (call.arguments as any)?.project_root || project_root
|
||||
const cmake = existsSync(join(root, 'CMakeLists.txt'))
|
||||
const makefile = existsSync(join(root, 'Makefile'))
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
||||
output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.cmake.configure': async (call: any) => {
|
||||
try {
|
||||
const { generator = 'Ninja', build_type = 'Debug' } = (call.arguments || {}) as any
|
||||
const buildDir = join(project_root, 'build')
|
||||
if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true })
|
||||
execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' })
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.build': async (call: any) => {
|
||||
try {
|
||||
const { target, config = 'Debug' } = (call.arguments || {}) as any
|
||||
const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config]
|
||||
const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
||||
output: { built: true, output: out.toString().slice(-500) },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.test': async (call: any) => {
|
||||
try {
|
||||
const { filter } = (call.arguments || {}) as any
|
||||
const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure']
|
||||
const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
||||
output: { passed: true, output: out.toString().slice(-1000) },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.static.cppcheck': async (call: any) => {
|
||||
try {
|
||||
const { path = 'src' } = (call.arguments || {}) as any
|
||||
const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 })
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
||||
output: { output: out.toString().slice(-500), issues_found: 0 },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'cpp.clangd.query': async (call: any) => {
|
||||
try {
|
||||
const { file, line = 0, column = 0 } = (call.arguments || {}) as any
|
||||
const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 })
|
||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
||||
output: { file, line, column, diagnostics: out.toString().slice(-1000) },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
||||
} catch (e: any) {
|
||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
||||
}
|
||||
},
|
||||
|
||||
'debug.run': async (call: any) => {
|
||||
try {
|
||||
const { target } = (call.arguments || {}) as any
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from
|
||||
|
||||
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
|
||||
import type { AgentType } from '@aircoding/contracts'
|
||||
import { eventIngestor } from '../events/EventIngestor.js'
|
||||
import { eventBus, type Subscription } from '../events/EventBus.js'
|
||||
|
||||
export type ToolExecutionReturn =
|
||||
| ToolResultEnvelope
|
||||
@@ -47,6 +49,7 @@ export class ToolRegistry {
|
||||
private executors: Map<string, ToolExecutor> = new Map()
|
||||
private permission_engine: PermissionEngine
|
||||
private project_root: string
|
||||
private readonly permission_timeout_ms = 5 * 60 * 1000
|
||||
|
||||
constructor(project_root: string) {
|
||||
this.project_root = project_root
|
||||
@@ -263,9 +266,39 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
case 'ask_user':
|
||||
// Suspend; emit permission.prompt.requested
|
||||
return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required')
|
||||
case 'ask_user': {
|
||||
const prompt_id = `perm_${crypto.randomUUID()}`
|
||||
await eventIngestor.ingest({
|
||||
id: `evt_${prompt_id}`,
|
||||
type: 'permission.prompt.requested',
|
||||
version: 1,
|
||||
session_id: ctx.session_id,
|
||||
project_id: ctx.project_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: { kind: 'tool', id: call.name },
|
||||
route: ['tool_registry', 'permission'],
|
||||
payload: {
|
||||
prompt_id,
|
||||
subject: call.name,
|
||||
risk_level: decision.risk_level,
|
||||
reason: decision.reason,
|
||||
options: ['allow_once', 'deny'],
|
||||
default_option: 'deny',
|
||||
request_ref: { call_id: call.call_id, tool_name: call.name, agent_id: ctx.agent_id },
|
||||
},
|
||||
})
|
||||
|
||||
const selected = await this.wait_for_permission(prompt_id, ctx)
|
||||
if (selected !== 'allow_once' && selected !== 'allow') {
|
||||
return create_error_result(call.call_id, 'permission_denied', `User selected ${selected}`)
|
||||
}
|
||||
|
||||
const executor = this.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
return this.execute_executor_final(executor, call, ctx)
|
||||
}
|
||||
|
||||
case 'deny':
|
||||
return create_error_result(call.call_id, 'permission_denied', decision.reason)
|
||||
@@ -313,6 +346,45 @@ export class ToolRegistry {
|
||||
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
|
||||
}
|
||||
|
||||
private wait_for_permission(prompt_id: string, ctx: ToolExecutionContext): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let subscription: Subscription | undefined
|
||||
const finish = (selected: string) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (subscription) eventBus.unsubscribe(subscription)
|
||||
resolve(selected)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
void eventIngestor.ingest({
|
||||
id: `evt_${prompt_id}_timeout`,
|
||||
type: 'permission.prompt.resolved',
|
||||
version: 1,
|
||||
session_id: ctx.session_id,
|
||||
project_id: ctx.project_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: { kind: 'tool', id: 'permission_timeout' },
|
||||
route: ['tool_registry', 'permission'],
|
||||
payload: {
|
||||
prompt_id,
|
||||
selected_option: 'deny',
|
||||
decision_id: `decision_${crypto.randomUUID()}`,
|
||||
resolved_by: 'timeout',
|
||||
},
|
||||
}).catch(() => finish('deny'))
|
||||
}, this.permission_timeout_ms)
|
||||
|
||||
subscription = eventBus.subscribe({ session_id: ctx.session_id, types: ['permission.prompt.resolved'] }, (event) => {
|
||||
const payload = event.payload as Record<string, unknown>
|
||||
if (payload.prompt_id !== prompt_id) return
|
||||
finish(String(payload.selected_option || 'deny'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute streaming tool.
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { ChildProcess } from 'child_process'
|
||||
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
|
||||
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
|
||||
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
|
||||
import { eventIngestor } from '../events/EventIngestor.js'
|
||||
import { eventSchemaRegistry } from '../events/EventSchemaRegistry.js'
|
||||
import type { ToolRegistry } from '../tools/ToolRegistry.js'
|
||||
import type { ProviderManager } from '@aircoding/llm'
|
||||
|
||||
@@ -85,6 +87,7 @@ export class WorkerManager {
|
||||
state: 'starting',
|
||||
started_at: new Date().toISOString()
|
||||
}
|
||||
this.workers.set(config.agent_id, handle)
|
||||
|
||||
// Spawn worker process using Bun
|
||||
// Worker must run from AirCoding repo root so Bun can resolve modules
|
||||
@@ -126,7 +129,6 @@ export class WorkerManager {
|
||||
})
|
||||
|
||||
handle.state = 'ready'
|
||||
this.workers.set(config.agent_id, handle)
|
||||
|
||||
// Set up timeout
|
||||
if (config.timeout_ms) {
|
||||
@@ -238,6 +240,40 @@ export class WorkerManager {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle worker-emitted RuntimeEvent payloads through the single EventIngestor entry point.
|
||||
proc.on_message('event', async (msg) => {
|
||||
try {
|
||||
const event_type = (msg.payload.event_type || msg.payload.type) as string
|
||||
if (!event_type || !eventSchemaRegistry.isRegistered(event_type, 1)) {
|
||||
console.error(`[WM] ignoring unregistered worker event: ${event_type || '(missing)'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { event_type: _eventType, ...restPayload } = msg.payload
|
||||
const payload = _eventType ? restPayload : (() => {
|
||||
const { type: _legacyType, ...legacyPayload } = restPayload
|
||||
return legacyPayload
|
||||
})()
|
||||
const event = {
|
||||
id: (payload.event_id as string) || msg.id,
|
||||
type: event_type,
|
||||
version: 1,
|
||||
timestamp: msg.timestamp || new Date().toISOString(),
|
||||
session_id: this.execution_context?.session_id || msg.session_id,
|
||||
project_id: this.execution_context?.project_id || '',
|
||||
source: { kind: 'agent', id: agent_id, agent_type: this.worker_agent_type(agent_id) },
|
||||
route: ['worker', agent_id, event_type],
|
||||
payload,
|
||||
}
|
||||
|
||||
const persistence = eventSchemaRegistry.getPersistence(event_type, 1)
|
||||
if (persistence === 'durable') await eventIngestor.ingest(event as any)
|
||||
else if (persistence === 'ephemeral') await eventIngestor.ingest_ephemeral(event as any)
|
||||
} catch (e: any) {
|
||||
console.error('[WM] worker event ingest error:', e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle worker.result → update handle
|
||||
proc.on_message('worker.result', (msg) => {
|
||||
const handle = this.workers.get(agent_id)
|
||||
@@ -324,20 +360,32 @@ export class WorkerManager {
|
||||
* Get result for a task.
|
||||
*/
|
||||
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
|
||||
return this.list().find(w => w.config.task_spec?.id === task_id)?.result
|
||||
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)?.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handle for a task.
|
||||
*/
|
||||
get_handle_for_task(task_id: string): WorkerHandle | undefined {
|
||||
return this.list().find(w => w.config.task_spec?.id === task_id)
|
||||
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private
|
||||
// ============================================================================
|
||||
|
||||
private worker_agent_type(agent_id: string): AgentType {
|
||||
const handle = this.workers.get(agent_id)
|
||||
const task_type = handle?.config.task_type || 'execute'
|
||||
switch (task_type) {
|
||||
case 'review': return 'reviewer' as AgentType
|
||||
case 'debug': return 'debugger' as AgentType
|
||||
case 'compact': return 'compactor' as AgentType
|
||||
case 'mine_experience': return 'experience_miner' as AgentType
|
||||
default: return 'executor' as AgentType
|
||||
}
|
||||
}
|
||||
|
||||
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
|
||||
const handle = this.workers.get(agent_id)
|
||||
if (!handle) return
|
||||
@@ -371,9 +419,11 @@ export class WorkerManager {
|
||||
const raw_status = (payload.status as string) || 'completed'
|
||||
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
|
||||
? raw_status
|
||||
: raw_status === 'fixed' || raw_status === 'pass'
|
||||
: raw_status === 'fixed' || raw_status === 'cannot_reproduce' || raw_status === 'pass' || raw_status === 'compacted' || raw_status === 'skipped' || raw_status === 'no_patterns'
|
||||
? 'completed'
|
||||
: 'failed'
|
||||
: raw_status === 'escalated'
|
||||
? 'blocked'
|
||||
: 'failed'
|
||||
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
|
||||
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
|
||||
const verification_payload = payload.verification as any
|
||||
@@ -381,13 +431,15 @@ export class WorkerManager {
|
||||
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
|
||||
: []
|
||||
const summary = (payload.summary as string)
|
||||
|| (payload.summary_content as string)
|
||||
|| (payload.root_cause as string)
|
||||
|| (payload.error ? String(payload.error) : '')
|
||||
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
|
||||
|
||||
return {
|
||||
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any,
|
||||
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || (handle.config.task_spec?.task_id as string) || '' as any,
|
||||
agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
|
||||
agent_type: (payload.agent_type as AgentType) || 'executor',
|
||||
agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id),
|
||||
status: status as WorkerStatus,
|
||||
summary,
|
||||
changed_files,
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
/**
|
||||
* Regression test: EvidenceStore SQLite persistence
|
||||
*
|
||||
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of
|
||||
* in-memory Map for persistent storage.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const SOURCE_PATH = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'artifacts',
|
||||
'EvidenceStore.ts'
|
||||
)
|
||||
|
||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
||||
import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
describe('EvidenceStore SQLite persistence', () => {
|
||||
test('EvidenceStore does not use in-memory Map', () => {
|
||||
// Should not have Map< for storage
|
||||
expect(source).not.toMatch(/evidenceStore:\s*Map</)
|
||||
// Should not use .set() on a map
|
||||
expect(source).not.toContain('this.evidenceStore.set(')
|
||||
const created: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('EvidenceStore constructor accepts Database parameter', () => {
|
||||
// Constructor should accept a Database parameter
|
||||
expect(source).toContain('db: Database')
|
||||
// Should import Database from bun:sqlite
|
||||
expect(source).toContain("from 'bun:sqlite'")
|
||||
})
|
||||
test('persists evidence refs in SQLite and can list them by entity', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-'))
|
||||
created.push(dir)
|
||||
const dbPath = join(dir, 'evidence.db')
|
||||
|
||||
test('EvidenceStore has initSchema method', () => {
|
||||
expect(source).toContain('initSchema()')
|
||||
// Should be called in constructor
|
||||
expect(source).toContain('this.initSchema()')
|
||||
})
|
||||
const db1 = new Database(dbPath)
|
||||
const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any)
|
||||
const createdRef = await store1.create({
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
task_id: 'task_1' as any,
|
||||
location_json: { line: 1 },
|
||||
})
|
||||
expect(createdRef.evidence_ref_id).toStartWith('evi_')
|
||||
expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({
|
||||
evidence_ref_id: createdRef.evidence_ref_id,
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
location_json: { line: 1 },
|
||||
})
|
||||
db1.close()
|
||||
|
||||
test('EvidenceStore creates evidence_refs table', () => {
|
||||
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs')
|
||||
// Should have key columns
|
||||
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY')
|
||||
expect(source).toContain('session_id TEXT NOT NULL')
|
||||
expect(source).toContain('kind TEXT NOT NULL')
|
||||
})
|
||||
|
||||
test('EvidenceStore uses INSERT INTO for create', () => {
|
||||
expect(source).toContain('INSERT INTO evidence_refs')
|
||||
})
|
||||
|
||||
test('EvidenceStore applies WAL PRAGMA', () => {
|
||||
expect(source).toContain('PRAGMA journal_mode = WAL')
|
||||
const db2 = new Database(dbPath)
|
||||
const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[]
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
evidence_ref_id: createdRef.evidence_ref_id,
|
||||
session_id: 'session_evidence',
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
task_id: 'task_1',
|
||||
})
|
||||
expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 })
|
||||
expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' })
|
||||
db2.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,88 @@
|
||||
/**
|
||||
* C1 regression: Knowledge Store schema alignment.
|
||||
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths,
|
||||
* had non-canonical column names, and were missing PRAGMAs.
|
||||
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js'
|
||||
import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js'
|
||||
|
||||
describe('C1: Knowledge Store schema alignment', () => {
|
||||
const debug_src = readFileSync(
|
||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
const memory_src = readFileSync(
|
||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
const created: string[] = []
|
||||
|
||||
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => {
|
||||
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'")
|
||||
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => {
|
||||
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'")
|
||||
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'")
|
||||
it('DebugKnowledgeStore stores and queries records from .air/local', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-debug-store-'))
|
||||
created.push(root)
|
||||
const store = new DebugKnowledgeStore(root)
|
||||
store.open()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
store.insert({
|
||||
id: 'debug_1',
|
||||
failure_signature: 'compiler:error:missing-header',
|
||||
task_id: 'task_1',
|
||||
root_cause: 'missing include path',
|
||||
fix_ref: 'fix://1',
|
||||
summary: 'Add include path before rebuilding',
|
||||
evidence_json: JSON.stringify(['evi_1']),
|
||||
verification_json: JSON.stringify(['build passed']),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: JSON.stringify({ source: 'test' }),
|
||||
})
|
||||
|
||||
expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true)
|
||||
expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false)
|
||||
expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1)
|
||||
expect(store.lookup_by_task('task_1')[0]).toMatchObject({
|
||||
id: 'debug_1',
|
||||
failure_signature: 'compiler:error:missing-header',
|
||||
task_id: 'task_1',
|
||||
root_cause: 'missing include path',
|
||||
fix_ref: 'fix://1',
|
||||
summary: 'Add include path before rebuilding',
|
||||
})
|
||||
|
||||
store.update('debug_1', { summary: 'Updated summary', updated_at: now })
|
||||
expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary')
|
||||
})
|
||||
|
||||
it('DebugRecord has failure_signature not signature', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-memory-store-'))
|
||||
created.push(root)
|
||||
const store = new LearnedMemoryStore(root)
|
||||
store.open()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
expect(iface_body).toContain('failure_signature')
|
||||
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern)
|
||||
expect(iface_body).not.toMatch(/^\s*signature\s*:/m)
|
||||
})
|
||||
store.insert({
|
||||
id: 'mem_1',
|
||||
memory_type: 'project_rule',
|
||||
summary: 'Use Bun for package scripts',
|
||||
content: 'Project commands should use Bun unless explicitly overridden.',
|
||||
source_entity_type: 'task',
|
||||
source_entity_id: 'task_1',
|
||||
status: 'candidate',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: JSON.stringify({ confidence: 0.8 }),
|
||||
})
|
||||
|
||||
it('DebugRecord has summary and fix_ref fields', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true)
|
||||
expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false)
|
||||
expect(store.lookup_by_type('project_rule')).toHaveLength(1)
|
||||
expect(store.lookup_by_type('project_rule')[0]).toMatchObject({
|
||||
id: 'mem_1',
|
||||
memory_type: 'project_rule',
|
||||
status: 'candidate',
|
||||
source_entity_type: 'task',
|
||||
source_entity_id: 'task_1',
|
||||
})
|
||||
|
||||
expect(iface_body).toContain('summary')
|
||||
expect(iface_body).toContain('fix_ref')
|
||||
})
|
||||
|
||||
it('DebugRecord does not have error_kind or session_id', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).not.toContain('error_kind')
|
||||
expect(iface_body).not.toContain('session_id')
|
||||
})
|
||||
|
||||
it('DebugKnowledgeStore applies WAL PRAGMA', () => {
|
||||
expect(debug_src).toContain('PRAGMA journal_mode = WAL')
|
||||
})
|
||||
|
||||
it('LearnedMemoryStore table is learned_memories (plural)', () => {
|
||||
expect(memory_src).toContain('learned_memories')
|
||||
// Ensure we don't have the singular form used as table name
|
||||
expect(memory_src).not.toMatch(/FROM learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
|
||||
})
|
||||
|
||||
it('MemoryEntry.memory_type has 4 spec values', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain("'project_rule'")
|
||||
expect(iface_body).toContain("'toolchain_rule'")
|
||||
expect(iface_body).toContain("'skill_update'")
|
||||
expect(iface_body).toContain("'debug_experience'")
|
||||
expect(iface_body).toContain('memory_type')
|
||||
})
|
||||
|
||||
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain("'candidate'")
|
||||
expect(iface_body).toContain("'promoted'")
|
||||
expect(iface_body).toContain("'archived'")
|
||||
expect(iface_body).toContain("'rejected'")
|
||||
})
|
||||
|
||||
it('MemoryEntry.status default is candidate not draft', () => {
|
||||
// Check that the CREATE TABLE DDL uses 'candidate' as default
|
||||
expect(memory_src).toContain("DEFAULT 'candidate'")
|
||||
expect(memory_src).not.toContain("DEFAULT 'draft'")
|
||||
})
|
||||
|
||||
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain('source_entity_type')
|
||||
expect(iface_body).toContain('source_entity_id')
|
||||
expect(iface_body).not.toContain('source_task_ids')
|
||||
store.update_status('mem_1', 'promoted')
|
||||
expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted')
|
||||
store.update_status('mem_1', 'archived')
|
||||
expect(store.lookup_by_type('project_rule')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { ProjectionStore } from '../../src/projection/ProjectionStore.js'
|
||||
import type { RuntimeEvent } from '@aircoding/contracts'
|
||||
|
||||
function event(type: string, payload: Record<string, unknown>): RuntimeEvent<Record<string, unknown>> {
|
||||
return {
|
||||
id: `evt_${type}_${Math.random().toString(36).slice(2)}`,
|
||||
type,
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: 'session_projection_apply' as any,
|
||||
project_id: 'project_projection_apply' as any,
|
||||
source: { kind: 'system' },
|
||||
route: ['test', type],
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProjectionStore.apply', () => {
|
||||
it('applies task and agent lifecycle events into a live snapshot', () => {
|
||||
const store = new ProjectionStore()
|
||||
const updates: string[] = []
|
||||
store.subscribe((projection) => {
|
||||
updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`)
|
||||
})
|
||||
|
||||
store.apply(event('task.created', {
|
||||
task_id: 'task_1',
|
||||
type: 'execute',
|
||||
title: 'Create file',
|
||||
task_spec_json: {},
|
||||
dependencies: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.started', {
|
||||
task_id: 'task_1',
|
||||
agent_id: 'agent_task_1',
|
||||
attempt_id: 'task_1_1',
|
||||
attempt_index: 0,
|
||||
workspace_id: 'ws_task_1',
|
||||
}))
|
||||
store.apply(event('agent.started', {
|
||||
agent_id: 'agent_task_1',
|
||||
agent_type: 'executor',
|
||||
task_id: 'task_1',
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('agent.completed', {
|
||||
agent_id: 'agent_task_1',
|
||||
task_id: 'task_1',
|
||||
summary: 'done',
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.completed', {
|
||||
task_id: 'task_1',
|
||||
agent_id: 'agent_task_1',
|
||||
attempt_id: 'task_1_1',
|
||||
worker_result_json: { status: 'completed' },
|
||||
summary: 'done',
|
||||
changed_files: ['hello.txt'],
|
||||
evidence_refs: [],
|
||||
}))
|
||||
|
||||
const snapshot = store.get_snapshot('session_projection_apply')
|
||||
expect(snapshot).toBeDefined()
|
||||
expect(snapshot!.tasks).toHaveLength(1)
|
||||
expect(snapshot!.tasks[0].status).toBe('completed')
|
||||
expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1')
|
||||
expect(snapshot!.tasks[0].attempts).toBe(1)
|
||||
expect(snapshot!.agents).toHaveLength(1)
|
||||
expect(snapshot!.agents[0].status).toBe('completed')
|
||||
expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('applies tool, permission, and blocker events', () => {
|
||||
const store = new ProjectionStore()
|
||||
|
||||
store.apply(event('tool.started', {
|
||||
tool_run_id: 'tool_1',
|
||||
tool_name: 'fs.write',
|
||||
input_json: {},
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('tool.completed', {
|
||||
tool_run_id: 'tool_1',
|
||||
output_json: { ok: true },
|
||||
duration_ms: 12,
|
||||
artifact_ids: [],
|
||||
evidence_refs: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('permission.prompt.requested', {
|
||||
prompt_id: 'perm_1',
|
||||
subject: 'shell.run',
|
||||
risk_level: 'medium',
|
||||
reason: 'risk score 70 requires user confirmation',
|
||||
options: ['allow_once', 'deny'],
|
||||
default_option: 'deny',
|
||||
request_ref: {},
|
||||
}))
|
||||
store.apply(event('task.created', {
|
||||
task_id: 'task_blocked',
|
||||
type: 'execute',
|
||||
title: 'Blocked task',
|
||||
task_spec_json: {},
|
||||
dependencies: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.blocked', {
|
||||
task_id: 'task_blocked',
|
||||
agent_id: 'agent_task_blocked',
|
||||
reason: 'worker blocked',
|
||||
blocker_kind: 'worker_blocked',
|
||||
evidence_refs: [],
|
||||
suggested_next_step: 'review blocker',
|
||||
}))
|
||||
store.apply(event('permission.prompt.resolved', {
|
||||
prompt_id: 'perm_1',
|
||||
selected_option: 'deny',
|
||||
decision_id: 'decision_1',
|
||||
resolved_by: 'test',
|
||||
}))
|
||||
|
||||
const snapshot = store.get_snapshot('session_projection_apply')
|
||||
expect(snapshot).toBeDefined()
|
||||
expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }])
|
||||
expect(snapshot!.permission_prompts).toHaveLength(0)
|
||||
expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked')
|
||||
expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }])
|
||||
})
|
||||
})
|
||||
@@ -1,55 +1,95 @@
|
||||
/**
|
||||
* Regression test: Recovery implementation completeness
|
||||
*
|
||||
* Verifies that checkPidLiveness and scanOrphanReferences have real
|
||||
* implementations, not just stub return values.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const SOURCE_PATH = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'storage',
|
||||
'Recovery.ts'
|
||||
)
|
||||
|
||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
||||
import { Recovery } from '../../src/storage/Recovery.js'
|
||||
|
||||
describe('Recovery implementation', () => {
|
||||
test('checkPidLiveness is not a stub (has implementation code)', () => {
|
||||
// Should have actual implementation with loop logic
|
||||
expect(source).toContain('for (const agent of agents)')
|
||||
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'")
|
||||
// Should have more than just a bare return []
|
||||
expect(source).toContain('const reports: PidLivenessReport[] = []')
|
||||
const created: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('checkPidLiveness uses process.kill for liveness check', () => {
|
||||
// Should use process.kill(pid, 0) for signal-0 liveness check
|
||||
expect(source).toContain('process.kill(agent.pid, 0)')
|
||||
function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-recovery-'))
|
||||
created.push(root)
|
||||
const artifactRoot = join(root, 'artifacts')
|
||||
const dbPath = join(root, 'session.db')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT);
|
||||
CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT);
|
||||
INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session');
|
||||
INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task');
|
||||
`)
|
||||
db.close()
|
||||
return {
|
||||
recovery: new Recovery({
|
||||
sessionId: 'session_recovery' as any,
|
||||
projectId: 'project_recovery' as any,
|
||||
artifactRoot,
|
||||
dbPath,
|
||||
projectRoot: root,
|
||||
}),
|
||||
root,
|
||||
artifactRoot,
|
||||
dbPath,
|
||||
}
|
||||
}
|
||||
|
||||
test('checks PID liveness with keep/mark_lost actions', () => {
|
||||
const { recovery } = makeRecovery()
|
||||
const reports = recovery.checkPidLiveness([
|
||||
{ agent_id: 'self', pid: process.pid },
|
||||
{ agent_id: 'missing', pid: 99999999 },
|
||||
])
|
||||
recovery.close()
|
||||
|
||||
expect(reports).toEqual([
|
||||
{ agent_id: 'self', pid: process.pid, alive: true, action: 'keep' },
|
||||
{ agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' },
|
||||
])
|
||||
})
|
||||
|
||||
test('scanOrphanReferences returns OrphanReferenceReport structure', () => {
|
||||
// Should define fkChecks array with the 8 invariant checks
|
||||
expect(source).toContain('fkChecks')
|
||||
expect(source).toContain("table: 'tasks'")
|
||||
expect(source).toContain("table: 'messages'")
|
||||
expect(source).toContain("table: 'task_attempts'")
|
||||
expect(source).toContain("table: 'agents'")
|
||||
expect(source).toContain("table: 'tool_runs'")
|
||||
expect(source).toContain("table: 'command_runs'")
|
||||
expect(source).toContain("table: 'artifacts'")
|
||||
expect(source).toContain("table: 'evidence_refs'")
|
||||
test('scans orphan references from SQLite tables', async () => {
|
||||
const { recovery } = makeRecovery()
|
||||
const report = await recovery.scan()
|
||||
recovery.close()
|
||||
|
||||
// Should iterate over checks
|
||||
expect(source).toContain('for (const check of fkChecks)')
|
||||
expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2)
|
||||
expect(report.orphanReferences.archived).toContainEqual({
|
||||
table: 'tasks',
|
||||
id: 'missing_session',
|
||||
reason: 'FK-off: session_id → sessions (1 rows)',
|
||||
})
|
||||
expect(report.orphanReferences.archived).toContainEqual({
|
||||
table: 'task_attempts',
|
||||
id: 'missing_task',
|
||||
reason: 'FK-off: task_id → tasks (1 rows)',
|
||||
})
|
||||
})
|
||||
|
||||
// Should return a proper report
|
||||
expect(source).toContain('return report')
|
||||
test('quarantines non-artifact temporary orphan files', async () => {
|
||||
const { recovery, artifactRoot } = makeRecovery()
|
||||
const tmpDir = join(artifactRoot, 'tmp')
|
||||
const orphanPath = join(tmpDir, 'scratch.tmp')
|
||||
await Bun.write(orphanPath, 'orphan')
|
||||
|
||||
const report = await recovery.scan()
|
||||
recovery.close()
|
||||
|
||||
expect(report.orphanArtifacts.totalFound).toBe(1)
|
||||
expect(report.orphanArtifacts.quarantined).toHaveLength(1)
|
||||
expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true)
|
||||
expect(existsSync(orphanPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js'
|
||||
import { Scheduler } from '../../src/scheduler/Scheduler.js'
|
||||
import { MainAgent } from '../../src/agents/main/MainAgent.js'
|
||||
import { ContextAssembler } from '../../src/context/ContextAssembler.js'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
function createRegistry(projectRoot: string): ToolRegistry {
|
||||
const registry = new ToolRegistry(projectRoot)
|
||||
@@ -59,8 +60,8 @@ describe('Release critical gates', () => {
|
||||
get_handle_for_task: () => undefined,
|
||||
get_result_for_task: () => undefined,
|
||||
}
|
||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any)
|
||||
scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||
scheduler.get_graph().update_status('task-1' as any, 'running')
|
||||
|
||||
await scheduler.step()
|
||||
@@ -68,6 +69,34 @@ describe('Release critical gates', () => {
|
||||
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
|
||||
})
|
||||
|
||||
it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => {
|
||||
const workerManager = {
|
||||
has_running: () => false,
|
||||
get_handle_for_task: () => ({ worker_id: 'agent-task-1' }),
|
||||
get_result_for_task: () => ({
|
||||
task_id: 'task-1',
|
||||
agent_id: 'agent-task-1',
|
||||
agent_type: 'executor',
|
||||
status: 'blocked',
|
||||
summary: 'blocked by worker',
|
||||
changed_files: [],
|
||||
artifacts: [],
|
||||
verification: [],
|
||||
risks: [],
|
||||
follow_up_tasks: [],
|
||||
evidence_refs: [],
|
||||
result: {},
|
||||
}),
|
||||
}
|
||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||
scheduler.get_graph().update_status('task-1' as any, 'running')
|
||||
|
||||
const finalState = await scheduler.run_until_idle()
|
||||
expect(finalState).toBe('BLOCKED')
|
||||
expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1)
|
||||
})
|
||||
|
||||
it('MainAgent answer mode uses assembled project context', async () => {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
|
||||
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
describe('B1: Scheduler wire-up', () => {
|
||||
it('SchedulerState includes BLOCKED and CANCELLED', () => {
|
||||
@@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => {
|
||||
session_id: 'test-session' as any,
|
||||
project_id: 'test-project' as any,
|
||||
project_root: '/tmp/test'
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
createNullEventIngestor(),
|
||||
)
|
||||
|
||||
scheduler.create_tasks([
|
||||
await scheduler.create_tasks([
|
||||
{ id: 't1' as any, type: 'code', title: 'Task 1' },
|
||||
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
|
||||
])
|
||||
|
||||
@@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
|
||||
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
|
||||
})
|
||||
|
||||
it('permission denial branches preserve original call_id', () => {
|
||||
expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'")
|
||||
it('permission branches preserve original call_id', () => {
|
||||
expect(src).toContain('permission.prompt.requested')
|
||||
expect(src).toContain('request_ref: { call_id: call.call_id')
|
||||
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
|
||||
expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
|
||||
expect(src).not.toContain("create_error_result('', 'permission_denied'")
|
||||
|
||||
@@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => {
|
||||
})
|
||||
|
||||
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
|
||||
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'")
|
||||
expect(source).toContain('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)')
|
||||
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
|
||||
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'")
|
||||
expect(source).toContain("raw_status === 'fixed'")
|
||||
expect(source).toContain("raw_status === 'pass'")
|
||||
expect(source).toContain("raw_status === 'cannot_reproduce'")
|
||||
expect(source).toContain("raw_status === 'compacted'")
|
||||
expect(source).toContain("raw_status === 'no_patterns'")
|
||||
expect(source).toContain('changes.map((c: any) => String(c.file))')
|
||||
expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
|
||||
expect(source).toContain('result: (payload.result as unknown) || payload')
|
||||
|
||||
Reference in New Issue
Block a user