fix(P0): close 15 blockers + add 26 regression tests; fix wiring schema regression
Phase A (security red lines) — CLOSED: - B8: 3x command injection fixed (execFileSync + args array in CMake/CppBuilder/Cppcheck) - B6: ToolRegistry permission bypass fixed (real task_scope/profile passed) - B7: ACTION_BRANCHES this-binding crash fixed (instance method) - B17: DeveloperLogEncryptor hardcoded 'dev-key' removed (throws if no key) - B22: CommandRiskAnalyzer 'in' operator bug fixed (includes) - B1: EventStore.project() transaction handle now passed to all repos - B2: workspace projection illegal enum fixed (active/merged) - B4: route_prefix separator unified to '/' - B5: TaskAttempt column mapping fixed Other blockers fixed: - B3: project-level DB schema aligned to db-schema §20 (.air/local, learned_memories) - B9: cpp.* tools registered through PermissionEngine path - B11: Scheduler BLOCKED/CANCELLED states added - B18: CapabilityTrustLevel 5-level enum aligned - B19: PermissionEngine block/refuse/announce_then_run + grant_scope - B20: Worker exit code 4 = parent_cancelled - B24: project_id now randomUUID Regression fix (introduced by B3 schema refactor): - wiring.ts capture_debug_record/promote_memory_entry realigned to refactored DebugRecord/MemoryEntry interfaces (was compile-level decoupling) Tests: 128 regression/unit tests pass (22 regression + 3 unit + 3 e2e suites) Still open (tracked for next round): B10 (INV-2 outbox emit), B12 (Scheduler event projection), B13 (MainAgent LLM classify), B14 (IPC envelope fields), B15 (TUI OpenTUI), B16 (api_key strict), B21 (CLI init INV-3), B23 (e2e real), B25 (MVP tools), B26 (ContextAssembler L6-L9) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,19 @@
|
||||
|
||||
import type { SessionID, ProjectID } from '@aircoding/contracts'
|
||||
|
||||
export type MainAgentState = 'IDLE' | 'ANSWERING' | 'DELEGATING' | 'DIRECT_MODE' | 'AWAITING_CONFIRMATION' | 'SUMMARIZING'
|
||||
export type MainAgentState =
|
||||
| 'IDLE'
|
||||
| 'CLASSIFYING'
|
||||
| 'ANSWERING'
|
||||
| 'DELEGATING'
|
||||
| 'DIRECT_MODE'
|
||||
| 'SCHEDULING'
|
||||
| 'ARCHITECTURE_DESIGNING'
|
||||
| 'CONFIRMING'
|
||||
| 'EXECUTING'
|
||||
| 'INTERRUPTING'
|
||||
| 'ARCHITECTURE_REVISING'
|
||||
| 'SUMMARIZING'
|
||||
|
||||
export interface MainAgentConfig {
|
||||
session_id: SessionID
|
||||
@@ -35,6 +47,7 @@ export class MainAgent {
|
||||
response?: string
|
||||
}> {
|
||||
// Classify intent
|
||||
this.state = 'CLASSIFYING'
|
||||
const classification = this.classify(message)
|
||||
|
||||
switch (classification) {
|
||||
@@ -83,7 +96,7 @@ export class MainAgent {
|
||||
* Handle confirmation from user.
|
||||
*/
|
||||
async handle_confirmation(confirmed: boolean): Promise<void> {
|
||||
if (this.state !== 'AWAITING_CONFIRMATION') return
|
||||
if (this.state !== 'CONFIRMING') return
|
||||
|
||||
if (confirmed) {
|
||||
this.state = 'DELEGATING'
|
||||
@@ -100,4 +113,47 @@ export class MainAgent {
|
||||
// After summarization completes
|
||||
this.state = 'IDLE'
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an interruption at the specified change level.
|
||||
* 'execution' → state EXECUTING
|
||||
* 'design' → state ARCHITECTURE_REVISING
|
||||
* 'full' → state ARCHITECTURE_DESIGNING
|
||||
*/
|
||||
handle_interruption(change_level: 'execution' | 'design' | 'full'): void {
|
||||
this.state = 'INTERRUPTING'
|
||||
|
||||
switch (change_level) {
|
||||
case 'execution':
|
||||
this.state = 'EXECUTING'
|
||||
break
|
||||
case 'design':
|
||||
this.state = 'ARCHITECTURE_REVISING'
|
||||
break
|
||||
case 'full':
|
||||
this.state = 'ARCHITECTURE_DESIGNING'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to CONFIRMING state (awaiting user confirmation).
|
||||
*/
|
||||
transition_to_confirming(): void {
|
||||
this.state = 'CONFIRMING'
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to EXECUTING state.
|
||||
*/
|
||||
transition_to_executing(): void {
|
||||
this.state = 'EXECUTING'
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to INTERRUPTING state.
|
||||
*/
|
||||
transition_to_interrupting(): void {
|
||||
this.state = 'INTERRUPTING'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,22 +31,35 @@ export function createKnowledgeWiring(project_root: string): KnowledgeWiring {
|
||||
/**
|
||||
* Handle a debug capture from DebuggerRole.
|
||||
* INV-2: External write first → then emit debug.record.created via outbox.
|
||||
* Fields aligned with DebugRecord (db-schema-v1 §20.1) after the §20 schema refactor.
|
||||
*/
|
||||
export async function capture_debug_record(
|
||||
store: DebugKnowledgeStore,
|
||||
record: { id: string; signature: string; task_id: string; session_id: string; error_kind: string; root_cause?: string; fix_applied?: string }
|
||||
record: {
|
||||
id: string
|
||||
failure_signature: string
|
||||
task_id: string
|
||||
summary: string
|
||||
root_cause?: string
|
||||
fix_ref?: string
|
||||
evidence_json?: string
|
||||
verification_json?: string
|
||||
metadata_json?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
store.insert({
|
||||
id: record.id,
|
||||
signature: record.signature,
|
||||
failure_signature: record.failure_signature,
|
||||
task_id: record.task_id,
|
||||
session_id: record.session_id,
|
||||
error_kind: record.error_kind,
|
||||
summary: record.summary,
|
||||
root_cause: record.root_cause,
|
||||
fix_applied: record.fix_applied,
|
||||
status: 'open',
|
||||
created_at: new Date().toISOString(),
|
||||
resolved_at: undefined
|
||||
fix_ref: record.fix_ref,
|
||||
evidence_json: record.evidence_json,
|
||||
verification_json: record.verification_json,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: record.metadata_json,
|
||||
})
|
||||
// INV-2: emit debug.record.created event AFTER external write
|
||||
}
|
||||
@@ -54,20 +67,32 @@ export async function capture_debug_record(
|
||||
/**
|
||||
* Handle experience mining promotion.
|
||||
* INV-2: External write first → then emit memory.promoted via outbox.
|
||||
* Fields aligned with MemoryEntry (db-schema-v1 §20.2) after the §20 schema refactor.
|
||||
*/
|
||||
export async function promote_memory_entry(
|
||||
store: LearnedMemoryStore,
|
||||
entry: { id: string; type: 'pattern' | 'rule' | 'skill' | 'experience'; title: string; content: string; source_task_ids: string[]; project_id: string }
|
||||
entry: {
|
||||
id: string
|
||||
memory_type: 'project_rule' | 'toolchain_rule' | 'skill_update' | 'debug_experience'
|
||||
summary: string
|
||||
content: string
|
||||
source_entity_type?: string
|
||||
source_entity_id?: string
|
||||
metadata_json?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
store.insert({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
title: entry.title,
|
||||
memory_type: entry.memory_type,
|
||||
summary: entry.summary,
|
||||
content: entry.content,
|
||||
source_task_ids: entry.source_task_ids.join(','),
|
||||
project_id: entry.project_id,
|
||||
status: 'draft',
|
||||
created_at: new Date().toISOString()
|
||||
source_entity_type: entry.source_entity_type,
|
||||
source_entity_id: entry.source_entity_id,
|
||||
status: 'candidate',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: entry.metadata_json,
|
||||
})
|
||||
// INV-2: emit memory.promoted event AFTER external write
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
* - create: ingest evidence.created
|
||||
* - list_for_entity(entity_type, entity_id) — NOT list_for_task
|
||||
*
|
||||
* Backed by SQLite via bun:sqlite for persistent storage.
|
||||
*
|
||||
* @module packages/runtime/src/artifacts/EvidenceStore
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { Database } from 'bun:sqlite'
|
||||
|
||||
import type {
|
||||
EvidenceRefID,
|
||||
@@ -46,15 +49,45 @@ interface EvidenceRecord {
|
||||
|
||||
/**
|
||||
* EvidenceStore implements the EvidenceStore contract per DD §11.2.
|
||||
* Uses SQLite for persistent storage instead of in-memory Map.
|
||||
*/
|
||||
export class EvidenceStore implements IEvidenceStore {
|
||||
private sessionId: SessionID
|
||||
private eventIngestor: EventIngestor
|
||||
private evidenceStore: Map<EvidenceRefID, EvidenceRecord> = new Map()
|
||||
private db: Database
|
||||
|
||||
constructor(sessionId: SessionID, eventIngestor?: EventIngestor) {
|
||||
constructor(sessionId: SessionID, db: Database, eventIngestor?: EventIngestor) {
|
||||
this.sessionId = sessionId
|
||||
this.db = db
|
||||
this.eventIngestor = eventIngestor ?? new EventIngestor()
|
||||
this.initSchema()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the evidence_refs table and apply PRAGMAs.
|
||||
*/
|
||||
initSchema(): void {
|
||||
this.db.exec('PRAGMA journal_mode = WAL')
|
||||
this.db.exec('PRAGMA synchronous = NORMAL')
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS evidence_refs (
|
||||
evidence_ref_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
ref TEXT NOT NULL,
|
||||
claim TEXT NOT NULL,
|
||||
location_json TEXT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
tool_run_id TEXT,
|
||||
command_run_id TEXT,
|
||||
artifact_id TEXT,
|
||||
diagnostic_id TEXT,
|
||||
message_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
async create(input: EvidenceCreateInput): Promise<EvidenceRef> {
|
||||
@@ -81,7 +114,31 @@ export class EvidenceStore implements IEvidenceStore {
|
||||
|
||||
await this.ingestEvidenceCreated(record)
|
||||
|
||||
this.evidenceStore.set(evidenceRefId, record)
|
||||
const locationJsonStr = record.location_json != null
|
||||
? JSON.stringify(record.location_json)
|
||||
: null
|
||||
|
||||
this.db.run(
|
||||
`INSERT INTO evidence_refs (
|
||||
evidence_ref_id, session_id, kind, ref, claim, location_json,
|
||||
task_id, agent_id, tool_run_id, command_run_id, artifact_id,
|
||||
diagnostic_id, message_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.evidence_ref_id,
|
||||
record.session_id,
|
||||
record.kind,
|
||||
record.ref,
|
||||
record.claim,
|
||||
locationJsonStr,
|
||||
record.task_id ?? null,
|
||||
record.agent_id ?? null,
|
||||
record.tool_run_id ?? null,
|
||||
record.command_run_id ?? null,
|
||||
record.artifact_id ?? null,
|
||||
record.diagnostic_id ?? null,
|
||||
record.message_id ?? null,
|
||||
record.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
evidence_ref_id: evidenceRefId,
|
||||
@@ -93,46 +150,34 @@ export class EvidenceStore implements IEvidenceStore {
|
||||
}
|
||||
|
||||
async list_for_entity(entity_type: string, entity_id: string): Promise<EvidenceRef[]> {
|
||||
const columnMap: Record<string, string> = {
|
||||
task: 'task_id',
|
||||
agent: 'agent_id',
|
||||
tool_run: 'tool_run_id',
|
||||
command_run: 'command_run_id',
|
||||
artifact: 'artifact_id',
|
||||
diagnostic: 'diagnostic_id',
|
||||
message: 'message_id',
|
||||
}
|
||||
|
||||
const column = columnMap[entity_type]
|
||||
if (!column) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = this.db.query(
|
||||
`SELECT * FROM evidence_refs WHERE ${column} = ?`
|
||||
).all(entity_id) as any[]
|
||||
|
||||
const results: EvidenceRef[] = []
|
||||
|
||||
for (const record of this.evidenceStore.values()) {
|
||||
let matches = false
|
||||
|
||||
switch (entity_type) {
|
||||
case 'task':
|
||||
matches = record.task_id === entity_id
|
||||
break
|
||||
case 'agent':
|
||||
matches = record.agent_id === entity_id
|
||||
break
|
||||
case 'tool_run':
|
||||
matches = record.tool_run_id === entity_id
|
||||
break
|
||||
case 'command_run':
|
||||
matches = record.command_run_id === entity_id
|
||||
break
|
||||
case 'artifact':
|
||||
matches = record.artifact_id === entity_id
|
||||
break
|
||||
case 'diagnostic':
|
||||
matches = record.diagnostic_id === entity_id
|
||||
break
|
||||
case 'message':
|
||||
matches = record.message_id === entity_id
|
||||
break
|
||||
default:
|
||||
matches = false
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
results.push({
|
||||
evidence_ref_id: record.evidence_ref_id,
|
||||
kind: record.kind,
|
||||
ref: record.ref,
|
||||
claim: record.claim,
|
||||
location_json: record.location_json,
|
||||
})
|
||||
}
|
||||
for (const row of rows) {
|
||||
results.push({
|
||||
evidence_ref_id: row.evidence_ref_id,
|
||||
kind: row.kind,
|
||||
ref: row.ref,
|
||||
claim: row.claim,
|
||||
location_json: row.location_json ? JSON.parse(row.location_json) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -177,7 +222,8 @@ export class EvidenceStore implements IEvidenceStore {
|
||||
|
||||
export function createEvidenceStore(
|
||||
sessionId: SessionID,
|
||||
db: Database,
|
||||
eventIngestor?: EventIngestor
|
||||
): EvidenceStore {
|
||||
return new EvidenceStore(sessionId, eventIngestor)
|
||||
}
|
||||
return new EvidenceStore(sessionId, db, eventIngestor)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module packages/runtime/src/capabilities/CapabilityManifestValidator
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from '@aircoding/contracts'
|
||||
import type { ToolDefinition, CapabilityTrustLevel } from '@aircoding/contracts'
|
||||
|
||||
export interface CapabilityManifest {
|
||||
schema_version: number
|
||||
@@ -16,7 +16,7 @@ export interface CapabilityManifest {
|
||||
description?: string
|
||||
tools: CapabilityTool[]
|
||||
dependencies?: string[]
|
||||
trust_level?: 'core' | 'trusted' | 'untrusted'
|
||||
trust_level?: CapabilityTrustLevel
|
||||
}
|
||||
|
||||
export interface CapabilityTool {
|
||||
@@ -50,7 +50,7 @@ export interface ValidationWarning {
|
||||
export class CapabilityManifestValidator {
|
||||
private static readonly SUPPORTED_SCHEMA_VERSION = 1
|
||||
private static readonly REQUIRED_FIELDS = ['schema_version', 'name', 'version', 'tools']
|
||||
private static readonly TRUST_LEVELS = ['core', 'trusted', 'untrusted'] as const
|
||||
private static readonly TRUST_LEVELS: readonly CapabilityTrustLevel[] = ['built_in', 'project_local', 'user_installed', 'verified_publisher', 'untrusted'] as const
|
||||
|
||||
/**
|
||||
* Validate a capability manifest.
|
||||
|
||||
@@ -143,10 +143,37 @@ export class ContextAssembler {
|
||||
layers.push(...task_layers)
|
||||
}
|
||||
|
||||
// TODO(P3): L6 Evidence - load from EvidenceStore (read-only)
|
||||
// TODO(P3): L7 Conversation - load from SessionStore message history
|
||||
// TODO(P3): L8 Tool output - load recent tool results from SessionStore
|
||||
// TODO(P3): L9 User override - load user directives/additional layers
|
||||
// L6: Evidence - stub layer (to be loaded from EvidenceStore)
|
||||
layers.push({
|
||||
level: 'evidence',
|
||||
priority: 6,
|
||||
content: '',
|
||||
token_estimate: 0
|
||||
})
|
||||
|
||||
// L7: Conversation - stub layer (to be loaded from SessionStore message history)
|
||||
layers.push({
|
||||
level: 'conversation',
|
||||
priority: 7,
|
||||
content: '',
|
||||
token_estimate: 0
|
||||
})
|
||||
|
||||
// L8: Tool output - stub layer (to be loaded from SessionStore tool results)
|
||||
layers.push({
|
||||
level: 'tool_output',
|
||||
priority: 8,
|
||||
content: '',
|
||||
token_estimate: 0
|
||||
})
|
||||
|
||||
// L9: User override - stub layer (to be loaded from user directives/additional layers)
|
||||
layers.push({
|
||||
level: 'user_override',
|
||||
priority: 9,
|
||||
content: '',
|
||||
token_estimate: 0
|
||||
})
|
||||
|
||||
// Add any additional layers
|
||||
if (context.additional_layers) {
|
||||
|
||||
@@ -508,17 +508,17 @@ export class EventStore {
|
||||
model_provider_id: p.model_provider_id,
|
||||
model_id: p.model_id,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'session.archived': {
|
||||
const p = payload as unknown as SessionArchivedPayload
|
||||
this.sessionRepo?.update(p.session_id, { status: 'archived', updated_at: now })
|
||||
this.sessionRepo?.update(p.session_id, { status: 'archived', updated_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
case 'session.deleted': {
|
||||
const p = payload as unknown as SessionDeletedPayload
|
||||
this.sessionRepo?.update(p.session_id, { status: 'deleted', updated_at: now })
|
||||
this.sessionRepo?.update(p.session_id, { status: 'deleted', updated_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ export class EventStore {
|
||||
created_at: now,
|
||||
token_estimate: p.token_estimate,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'assistant.message.started': {
|
||||
@@ -551,7 +551,7 @@ export class EventStore {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'assistant.message.created': {
|
||||
@@ -567,13 +567,13 @@ export class EventStore {
|
||||
created_at: now,
|
||||
token_estimate: p.token_estimate,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
this.messageDraftRepo?.delete_for_message(p.message_id)
|
||||
}, _tx)
|
||||
this.messageDraftRepo?.delete_for_message(p.message_id, _tx)
|
||||
break
|
||||
}
|
||||
case 'assistant.message.failed': {
|
||||
const p = payload as unknown as AssistantMessageFailedPayload
|
||||
this.messageDraftRepo?.update(p.message_id, { status: 'error', updated_at: now })
|
||||
this.messageDraftRepo?.update(p.message_id, { status: 'error', updated_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -591,17 +591,17 @@ export class EventStore {
|
||||
model_id: p.model_id,
|
||||
started_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'agent.completed': {
|
||||
const p = payload as unknown as AgentCompletedPayload
|
||||
this.agentRepo?.update(p.agent_id, { status: 'completed', completed_at: now })
|
||||
this.agentRepo?.update(p.agent_id, { status: 'completed', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
case 'agent.failed': {
|
||||
const p = payload as unknown as AgentFailedPayload
|
||||
this.agentRepo?.update(p.agent_id, { status: 'failed', completed_at: now })
|
||||
this.agentRepo?.update(p.agent_id, { status: 'failed', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
case 'agent.lost': {
|
||||
@@ -610,12 +610,12 @@ export class EventStore {
|
||||
status: 'lost',
|
||||
last_heartbeat_at: p.last_heartbeat_at,
|
||||
completed_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'agent.cancelled': {
|
||||
const p = payload as unknown as AgentCancelledPayload
|
||||
this.agentRepo?.update(p.agent_id, { status: 'cancelled', completed_at: now })
|
||||
this.agentRepo?.update(p.agent_id, { status: 'cancelled', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@ export class EventStore {
|
||||
title: p.title,
|
||||
task_spec_json: JSON.stringify(p.task_spec_json),
|
||||
created_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
if (p.dependencies && p.dependencies.length > 0) {
|
||||
for (const dep of p.dependencies) {
|
||||
// Generate UUID without using self.crypto
|
||||
@@ -647,7 +647,7 @@ export class EventStore {
|
||||
dependency_type: dep.dependency_type,
|
||||
reason: dep.reason,
|
||||
created_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -659,7 +659,7 @@ export class EventStore {
|
||||
started_at: now,
|
||||
assigned_agent_id: p.agent_id,
|
||||
workspace_id: p.workspace_id,
|
||||
})
|
||||
}, _tx)
|
||||
this.taskAttemptRepo?.insert({
|
||||
id: p.attempt_id,
|
||||
session_id: event.session_id,
|
||||
@@ -668,7 +668,7 @@ export class EventStore {
|
||||
agent_id: p.agent_id,
|
||||
status: 'running',
|
||||
started_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'task.completed': {
|
||||
@@ -677,41 +677,41 @@ export class EventStore {
|
||||
status: 'completed',
|
||||
completed_at: now,
|
||||
worker_result_json: JSON.stringify(p.worker_result_json),
|
||||
})
|
||||
}, _tx)
|
||||
if (p.attempt_id) {
|
||||
this.taskAttemptRepo?.update(p.attempt_id, {
|
||||
status: 'completed',
|
||||
completed_at: now,
|
||||
worker_result_json: JSON.stringify(p.worker_result_json),
|
||||
})
|
||||
}, _tx)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'task.blocked': {
|
||||
const p = payload as unknown as TaskBlockedPayload
|
||||
this.taskRepo?.update(p.task_id, { status: 'blocked' })
|
||||
this.taskRepo?.update(p.task_id, { status: 'blocked' }, _tx)
|
||||
break
|
||||
}
|
||||
case 'task.failed': {
|
||||
const p = payload as unknown as TaskFailedPayload
|
||||
this.taskRepo?.update(p.task_id, { status: 'failed', completed_at: now })
|
||||
this.taskRepo?.update(p.task_id, { status: 'failed', completed_at: now }, _tx)
|
||||
if (p.attempt_id) {
|
||||
this.taskAttemptRepo?.update(p.attempt_id, {
|
||||
status: 'failed',
|
||||
completed_at: now,
|
||||
failure_summary: (p.error.message as string) ?? 'Unknown error',
|
||||
})
|
||||
}, _tx)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'task.cancelled': {
|
||||
const p = payload as unknown as TaskCancelledPayload
|
||||
this.taskRepo?.update(p.task_id, { status: 'cancelled', completed_at: now })
|
||||
this.taskRepo?.update(p.task_id, { status: 'cancelled', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
case 'task.interrupted': {
|
||||
const p = payload as unknown as TaskInterruptedPayload
|
||||
this.taskRepo?.update(p.task_id, { status: 'interrupted', completed_at: now })
|
||||
this.taskRepo?.update(p.task_id, { status: 'interrupted', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@ export class EventStore {
|
||||
input_json: JSON.stringify(p.input_json),
|
||||
started_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'tool.completed': {
|
||||
@@ -741,7 +741,7 @@ export class EventStore {
|
||||
artifacts_json: p.artifact_ids ? JSON.stringify(p.artifact_ids) : undefined,
|
||||
evidence_refs_json: p.evidence_refs ? JSON.stringify(p.evidence_refs) : undefined,
|
||||
completed_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'tool.failed': {
|
||||
@@ -751,12 +751,12 @@ export class EventStore {
|
||||
error_json: JSON.stringify(p.error),
|
||||
duration_ms: p.duration_ms,
|
||||
completed_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'tool.cancelled': {
|
||||
const p = payload as unknown as ToolCancelledPayload
|
||||
this.toolRunRepo?.update(p.tool_run_id, { status: 'cancelled', completed_at: now })
|
||||
this.toolRunRepo?.update(p.tool_run_id, { status: 'cancelled', completed_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@ export class EventStore {
|
||||
cwd: p.cwd,
|
||||
started_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'command.completed': {
|
||||
@@ -788,7 +788,7 @@ export class EventStore {
|
||||
diagnostic_ids: p.diagnostic_ids ? JSON.stringify(p.diagnostic_ids) : undefined,
|
||||
parsed_diagnostics_json: p.parsed_diagnostics_json ? JSON.stringify(p.parsed_diagnostics_json) : undefined,
|
||||
completed_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'command.failed': {
|
||||
@@ -800,7 +800,7 @@ export class EventStore {
|
||||
stderr_artifact_id: p.stderr_artifact_id,
|
||||
combined_artifact_id: p.combined_artifact_id,
|
||||
completed_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -824,7 +824,7 @@ export class EventStore {
|
||||
associated_entity_id: p.associated_entity_id,
|
||||
created_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'diagnostic.created': {
|
||||
@@ -847,7 +847,7 @@ export class EventStore {
|
||||
semantic_signature: p.semantic_signature,
|
||||
created_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'evidence.created': {
|
||||
@@ -867,7 +867,7 @@ export class EventStore {
|
||||
location_json: p.location_json ? JSON.stringify(p.location_json) : undefined,
|
||||
claim: p.claim,
|
||||
created_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -883,7 +883,7 @@ export class EventStore {
|
||||
content_json: JSON.stringify(p.content_json),
|
||||
created_at: now,
|
||||
metadata_json: p.metadata ? JSON.stringify(p.metadata) : undefined,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -897,31 +897,33 @@ export class EventStore {
|
||||
agent_id: p.agent_id,
|
||||
path: p.path,
|
||||
strategy: p.strategy,
|
||||
status: 'created',
|
||||
status: 'active',
|
||||
base_ref: p.base_ref,
|
||||
branch_name: p.branch_name,
|
||||
created_at: now,
|
||||
})
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'workspace.merge.started': {
|
||||
const p = payload as unknown as WorkspaceMergeStartedPayload
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'merging' })
|
||||
this.workspaceRepo?.update(p.workspace_id, {
|
||||
metadata_json: JSON.stringify({ merge_in_progress: true, strategy: p.strategy, target_ref: p.target_ref }),
|
||||
}, _tx)
|
||||
break
|
||||
}
|
||||
case 'workspace.merge.completed': {
|
||||
const p = payload as unknown as WorkspaceMergeCompletedPayload
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'merged', merged_at: now })
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'merged', merged_at: now }, _tx)
|
||||
break
|
||||
}
|
||||
case 'workspace.merge.conflicted': {
|
||||
const p = payload as unknown as WorkspaceMergeConflictedPayload
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'conflicted' })
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'conflicted' }, _tx)
|
||||
break
|
||||
}
|
||||
case 'workspace.cleaned': {
|
||||
const p = payload as unknown as WorkspaceCleanedPayload
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'cleaned' })
|
||||
this.workspaceRepo?.update(p.workspace_id, { status: 'cleaned' }, _tx)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ import { Database } from 'bun:sqlite'
|
||||
|
||||
export interface DebugRecord {
|
||||
id: string
|
||||
signature: string
|
||||
failure_signature: string
|
||||
task_id: string
|
||||
session_id: string
|
||||
error_kind: string
|
||||
root_cause?: string
|
||||
fix_applied?: string
|
||||
status: 'open' | 'resolved' | 'archived'
|
||||
fix_ref?: string
|
||||
summary: string
|
||||
evidence_json?: string
|
||||
verification_json?: string
|
||||
created_at: string
|
||||
resolved_at?: string
|
||||
updated_at: string
|
||||
metadata_json?: string
|
||||
}
|
||||
|
||||
export class DebugKnowledgeStore {
|
||||
@@ -27,7 +28,7 @@ export class DebugKnowledgeStore {
|
||||
private db_path: string
|
||||
|
||||
constructor(project_root: string) {
|
||||
this.db_path = join(project_root, '.air', 'shared', 'debug-records.db')
|
||||
this.db_path = join(project_root, '.air', 'local', 'debug-records.db')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,45 +39,49 @@ export class DebugKnowledgeStore {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
this.db = new Database(this.db_path)
|
||||
this.db.exec('PRAGMA journal_mode = WAL')
|
||||
this.db.exec('PRAGMA synchronous = NORMAL')
|
||||
this.db.exec('PRAGMA foreign_keys = OFF')
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS debug_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
signature TEXT NOT NULL,
|
||||
failure_signature TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
error_kind TEXT NOT NULL,
|
||||
root_cause TEXT,
|
||||
fix_applied TEXT,
|
||||
status TEXT DEFAULT 'open',
|
||||
fix_ref TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
evidence_json TEXT,
|
||||
verification_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
updated_at TEXT NOT NULL,
|
||||
metadata_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_debug_signature ON debug_records(signature);
|
||||
CREATE INDEX IF NOT EXISTS idx_debug_failure_signature ON debug_records(failure_signature);
|
||||
CREATE INDEX IF NOT EXISTS idx_debug_task ON debug_records(task_id);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a debug record.
|
||||
* INV-2: External write first → then emit debug.record.created via outbox.
|
||||
* INV-2: External write first, then emit debug.record.created via outbox.
|
||||
*/
|
||||
insert(record: DebugRecord): void {
|
||||
if (!this.db) throw new Error('Store not opened')
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO debug_records (id, signature, task_id, session_id, error_kind, root_cause, fix_applied, status, created_at, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO debug_records (id, failure_signature, task_id, root_cause, fix_ref, summary, evidence_json, verification_json, created_at, updated_at, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(record.id, record.signature, record.task_id, record.session_id, record.error_kind, record.root_cause, record.fix_applied, record.status, record.created_at, record.resolved_at)
|
||||
stmt.run(record.id, record.failure_signature, record.task_id, record.root_cause, record.fix_ref, record.summary, record.evidence_json, record.verification_json, record.created_at, record.updated_at, record.metadata_json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up records by semantic signature.
|
||||
* Look up records by failure signature.
|
||||
*/
|
||||
lookup_by_signature(signature: string): DebugRecord[] {
|
||||
lookup_by_signature(failure_signature: string): DebugRecord[] {
|
||||
if (!this.db) return []
|
||||
const stmt = this.db.prepare('SELECT * FROM debug_records WHERE signature = ? ORDER BY created_at DESC')
|
||||
return stmt.all(signature) as DebugRecord[]
|
||||
const stmt = this.db.prepare('SELECT * FROM debug_records WHERE failure_signature = ? ORDER BY created_at DESC')
|
||||
return stmt.all(failure_signature) as DebugRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +94,9 @@ export class DebugKnowledgeStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update record status.
|
||||
* Update record fields.
|
||||
*/
|
||||
update(id: string, patch: { status?: string; root_cause?: string; fix_applied?: string; resolved_at?: string }): void {
|
||||
update(id: string, patch: { root_cause?: string; fix_ref?: string; summary?: string; updated_at?: string }): void {
|
||||
if (!this.db) return
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -11,15 +11,14 @@ import { Database } from 'bun:sqlite'
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string
|
||||
type: 'pattern' | 'rule' | 'skill' | 'experience'
|
||||
title: string
|
||||
memory_type: 'project_rule' | 'toolchain_rule' | 'skill_update' | 'debug_experience'
|
||||
summary: string
|
||||
content: string
|
||||
source_task_ids: string
|
||||
project_id: string
|
||||
status: 'draft' | 'promoted' | 'archived'
|
||||
source_entity_type?: string
|
||||
source_entity_id?: string
|
||||
status: 'candidate' | 'promoted' | 'archived' | 'rejected'
|
||||
created_at: string
|
||||
promoted_at?: string
|
||||
archived_at?: string
|
||||
updated_at: string
|
||||
metadata_json?: string
|
||||
}
|
||||
|
||||
@@ -28,7 +27,7 @@ export class LearnedMemoryStore {
|
||||
private db_path: string
|
||||
|
||||
constructor(project_root: string) {
|
||||
this.db_path = join(project_root, '.air', 'shared', 'learned-memory.db')
|
||||
this.db_path = join(project_root, '.air', 'local', 'learned-memory.db')
|
||||
}
|
||||
|
||||
open(): void {
|
||||
@@ -36,52 +35,54 @@ export class LearnedMemoryStore {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
this.db = new Database(this.db_path)
|
||||
this.db.exec('PRAGMA journal_mode = WAL')
|
||||
this.db.exec('PRAGMA synchronous = NORMAL')
|
||||
this.db.exec('PRAGMA foreign_keys = OFF')
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS learned_memory (
|
||||
CREATE TABLE IF NOT EXISTS learned_memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
memory_type TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source_task_ids TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'draft',
|
||||
source_entity_type TEXT,
|
||||
source_entity_id TEXT,
|
||||
status TEXT DEFAULT 'candidate',
|
||||
created_at TEXT NOT NULL,
|
||||
promoted_at TEXT,
|
||||
archived_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
metadata_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_type ON learned_memory(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_status ON learned_memory(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_type ON learned_memories(memory_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_status ON learned_memories(status);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a memory entry.
|
||||
* INV-2: External write first → then emit memory.promoted via outbox.
|
||||
* INV-2: External write first, then emit memory.promoted via outbox.
|
||||
*/
|
||||
insert(entry: MemoryEntry): void {
|
||||
if (!this.db) throw new Error('Store not opened')
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO learned_memory (id, type, title, content, source_task_ids, project_id, status, created_at, promoted_at, archived_at, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO learned_memories (id, memory_type, summary, content, source_entity_type, source_entity_id, status, created_at, updated_at, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(entry.id, entry.type, entry.title, entry.content, entry.source_task_ids, entry.project_id, entry.status, entry.created_at, entry.promoted_at, entry.archived_at, entry.metadata_json)
|
||||
stmt.run(entry.id, entry.memory_type, entry.summary, entry.content, entry.source_entity_type, entry.source_entity_id, entry.status, entry.created_at, entry.updated_at, entry.metadata_json)
|
||||
}
|
||||
|
||||
lookup_by_type(type: string): MemoryEntry[] {
|
||||
lookup_by_type(memory_type: string): MemoryEntry[] {
|
||||
if (!this.db) return []
|
||||
return this.db.prepare('SELECT * FROM learned_memory WHERE type = ? AND status != ? ORDER BY created_at DESC').all(type, 'archived') as MemoryEntry[]
|
||||
return this.db.prepare('SELECT * FROM learned_memories WHERE memory_type = ? AND status != ? ORDER BY created_at DESC').all(memory_type, 'archived') as MemoryEntry[]
|
||||
}
|
||||
|
||||
update_status(id: string, status: 'promoted' | 'archived'): void {
|
||||
update_status(id: string, status: 'candidate' | 'promoted' | 'archived' | 'rejected'): void {
|
||||
if (!this.db) return
|
||||
const field = status === 'promoted' ? 'promoted_at' : 'archived_at'
|
||||
this.db.prepare(`UPDATE learned_memory SET status = ?, ${field} = ? WHERE id = ?`).run(status, new Date().toISOString(), id)
|
||||
const updated_at = new Date().toISOString()
|
||||
this.db.prepare('UPDATE learned_memories SET status = ?, updated_at = ? WHERE id = ?').run(status, updated_at, id)
|
||||
}
|
||||
|
||||
scan_stale(days_stale: number = 90): MemoryEntry[] {
|
||||
if (!this.db) return []
|
||||
const cutoff = new Date(Date.now() - days_stale * 86400000).toISOString()
|
||||
return this.db.prepare('SELECT * FROM learned_memory WHERE status = ? AND promoted_at < ?').all('promoted', cutoff) as MemoryEntry[]
|
||||
return this.db.prepare('SELECT * FROM learned_memories WHERE status = ? AND updated_at < ?').all('promoted', cutoff) as MemoryEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,14 @@ export class DeveloperLogEncryptor {
|
||||
|
||||
constructor(project_root: string, project_key?: string) {
|
||||
this.log_path = join(project_root, '.air', 'logs', 'air.developer.log')
|
||||
this.key = this.derive_key(project_key || process.env.AIRCODING_PROJECT_KEY || 'dev-key')
|
||||
const key_source = project_key || process.env.AIRCODING_PROJECT_KEY
|
||||
if (!key_source) {
|
||||
throw new Error(
|
||||
'DeveloperLogEncryptor requires a project key. ' +
|
||||
'Set AIRCODING_PROJECT_KEY environment variable or pass project_key parameter.'
|
||||
)
|
||||
}
|
||||
this.key = this.derive_key(key_source)
|
||||
|
||||
// Ensure log directory exists
|
||||
const dir = join(this.log_path, '..')
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WavePlanner } from './WavePlanner.js'
|
||||
import { RetryPlanner } from './RetryPlanner.js'
|
||||
import { WorkspaceManager } from './WorkspaceManager.js'
|
||||
import { AgentMonitor } from './AgentMonitor.js'
|
||||
import type { WorkerManager } from '../workers/WorkerManager.js'
|
||||
|
||||
export type SchedulerState =
|
||||
| 'IDLE'
|
||||
@@ -27,6 +28,8 @@ export type SchedulerState =
|
||||
| 'REPAIRING_OR_CONTINUING'
|
||||
| 'COMPLETED'
|
||||
| 'TERMINATED'
|
||||
| 'BLOCKED'
|
||||
| 'CANCELLED'
|
||||
|
||||
export interface SchedulerContext {
|
||||
session_id: SessionID
|
||||
@@ -42,14 +45,16 @@ export class Scheduler {
|
||||
private workspace_manager: WorkspaceManager
|
||||
private agent_monitor: AgentMonitor
|
||||
private context: SchedulerContext
|
||||
private worker_manager?: WorkerManager
|
||||
|
||||
constructor(context: SchedulerContext) {
|
||||
constructor(context: SchedulerContext, worker_manager?: WorkerManager) {
|
||||
this.context = context
|
||||
this.graph = new TaskGraph()
|
||||
this.wave_planner = new WavePlanner()
|
||||
this.retry_planner = new RetryPlanner()
|
||||
this.workspace_manager = new WorkspaceManager(context.project_root)
|
||||
this.agent_monitor = new AgentMonitor()
|
||||
this.worker_manager = worker_manager
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +77,12 @@ export class Scheduler {
|
||||
* Run until idle — drives state machine to terminal state.
|
||||
*/
|
||||
async run_until_idle(): Promise<SchedulerState> {
|
||||
while (this.state !== 'COMPLETED' && this.state !== 'TERMINATED') {
|
||||
while (
|
||||
this.state !== 'COMPLETED' &&
|
||||
this.state !== 'TERMINATED' &&
|
||||
this.state !== 'BLOCKED' &&
|
||||
this.state !== 'CANCELLED'
|
||||
) {
|
||||
await this.step()
|
||||
}
|
||||
return this.state
|
||||
@@ -125,17 +135,31 @@ export class Scheduler {
|
||||
break
|
||||
}
|
||||
|
||||
case 'DISPATCHING':
|
||||
// Transition planned tasks to 'running' and register with agent monitor
|
||||
case 'DISPATCHING': {
|
||||
const runnable = this.graph.get_runnable_tasks()
|
||||
for (const task of runnable) {
|
||||
this.graph.mark_terminal(task.id, 'running' as any)
|
||||
// Register with agent monitor for heartbeat tracking
|
||||
const agent_id = `agent_${task.id}`
|
||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||
|
||||
if (this.worker_manager) {
|
||||
try {
|
||||
await this.worker_manager.spawn({
|
||||
entrypoint: 'packages/workers/src/main.ts',
|
||||
agent_id,
|
||||
session_id: this.context.session_id,
|
||||
project_root: this.context.project_root,
|
||||
})
|
||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||
} catch {
|
||||
this.graph.mark_terminal(task.id, 'failed')
|
||||
}
|
||||
} else {
|
||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||
}
|
||||
}
|
||||
this.state = 'MONITORING'
|
||||
break
|
||||
}
|
||||
|
||||
case 'MONITORING':
|
||||
// Check agent health
|
||||
@@ -179,30 +203,33 @@ export class Scheduler {
|
||||
this.state = 'MERGING'
|
||||
break
|
||||
|
||||
case 'MERGING':
|
||||
// Merge completed workspaces
|
||||
case 'MERGING': {
|
||||
const active_ws = this.workspace_manager.get_active()
|
||||
for (const ws of active_ws) {
|
||||
await this.workspace_manager.merge_workspace(ws.id)
|
||||
}
|
||||
this.state = 'REVIEWING_WAVE'
|
||||
break
|
||||
}
|
||||
|
||||
case 'REVIEWING_WAVE':
|
||||
// After review, either continue or repair
|
||||
this.state = 'REPAIRING_OR_CONTINUING'
|
||||
break
|
||||
|
||||
case 'REPAIRING_OR_CONTINUING': {
|
||||
// Check for failed tasks that need retry
|
||||
const counts = this.graph.count_by_status()
|
||||
const failed = counts.failed || 0
|
||||
|
||||
if (failed > 0) {
|
||||
// Retry logic handled by RetryPlanner
|
||||
// Would spawn debug tasks and/or retry with backoff
|
||||
}
|
||||
|
||||
this.state = 'PLANNING_WAVE'
|
||||
break
|
||||
}
|
||||
|
||||
case 'BLOCKED':
|
||||
case 'CANCELLED':
|
||||
case 'COMPLETED':
|
||||
case 'TERMINATED':
|
||||
break
|
||||
|
||||
@@ -163,6 +163,13 @@ export class TaskGraph {
|
||||
return Array.from(this.tasks.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks filtered by status.
|
||||
*/
|
||||
get_tasks_by_status(status: string): TaskNode[] {
|
||||
return this.get_all().filter(t => t.status === status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task count by status.
|
||||
*/
|
||||
|
||||
@@ -107,6 +107,13 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active workspaces.
|
||||
*/
|
||||
get_active(): Workspace[] {
|
||||
return Array.from(this.workspaces.values()).filter(ws => ws.state === 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* GC scan — find workspaces eligible for cleanup.
|
||||
*/
|
||||
|
||||
@@ -107,7 +107,7 @@ export class CommandRiskAnalyzer {
|
||||
reasons.push(`system modification command: ${cmd}`)
|
||||
risk_score = Math.max(risk_score, 70)
|
||||
}
|
||||
flags.push('sudo_likely' in trimmed ? 'intent_sudo' : 'system_command')
|
||||
flags.push(trimmed.includes('sudo') ? 'intent_sudo' : 'system_command')
|
||||
}
|
||||
|
||||
// Check network read commands
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* PathClassifier - classifies file paths into 8 security categories
|
||||
* PathClassifier - classifies file paths into 9 security categories
|
||||
*
|
||||
* Implements DD §9.2; security-model-v1.md.
|
||||
* Realpath normalization before prefix checks; .git/ internals protected.
|
||||
@@ -11,17 +11,25 @@ import { realpathSync } from 'fs'
|
||||
import { resolve, normalize, sep } from 'path'
|
||||
|
||||
export type PathCategory =
|
||||
| 'project_source' // .ts, .js, .rs, .cpp source files
|
||||
| 'project_build' // build outputs, artifacts
|
||||
| 'project_config' // config files user edits
|
||||
| 'project_internal' // .air, .git, node_modules (protected)
|
||||
| 'system' // /etc, /usr, system directories
|
||||
| 'user_home' // home directory files
|
||||
| 'temp' // /tmp, /var/tmp
|
||||
| 'external' // outside project tree
|
||||
| 'project' // general project files
|
||||
| 'project_air_shared' // .air/shared/
|
||||
| 'project_air_local' // .air/local/
|
||||
| 'project_build' // build outputs, artifacts
|
||||
| 'project_git' // .git/ internals
|
||||
| 'project_outside_user' // project files outside user scope
|
||||
| 'system_sensitive' // /etc, /usr, system directories
|
||||
| 'credential_store' // ~/.ssh, ~/.gnupg, .env files
|
||||
| 'unknown' // fallback
|
||||
|
||||
const CREDENTIAL_PATTERNS = [
|
||||
'.ssh', '.gnupg', '.gpg', '.aws', '.azure', '.kube',
|
||||
'.env', '.env.local', '.env.production', '.env.staging',
|
||||
'.npmrc', '.pypirc', '.dockercfg', '.docker/config.json',
|
||||
'credentials.json', 'service-account', '.netrc',
|
||||
]
|
||||
|
||||
const PROJECT_INTERNAL_DIRS = ['.air', '.git', 'node_modules', '__pycache__', '.venv', 'target']
|
||||
const SYSTEM_DIRS = ['/etc', '/usr', '/bin', '/sbin', '/lib', '/var', '/boot', '/sys', '/proc']
|
||||
const BUILD_DIRS = ['dist', 'build', 'out', 'target', '.next', '.nuxt', '__pycache__']
|
||||
const HOME_PATTERN = /^\/(home|Users|root)/
|
||||
|
||||
export interface ClassificationResult {
|
||||
@@ -32,7 +40,7 @@ export interface ClassificationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a path into one of 8 security categories.
|
||||
* Classifies a path into one of 9 security categories.
|
||||
* Performs realpath normalization to detect symlink escapes.
|
||||
*/
|
||||
export class PathClassifier {
|
||||
@@ -42,9 +50,6 @@ export class PathClassifier {
|
||||
this.project_root = resolve(project_root)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a path into one of 8 categories.
|
||||
*/
|
||||
classify(raw_path: string): ClassificationResult {
|
||||
const reasons: string[] = []
|
||||
let normalized: string
|
||||
@@ -57,58 +62,50 @@ export class PathClassifier {
|
||||
reasons.push('symlink resolves outside its container')
|
||||
}
|
||||
} catch {
|
||||
// Path doesn't exist, normalize but don't resolve
|
||||
normalized = resolve(raw_path)
|
||||
}
|
||||
|
||||
// Check credential stores (highest security priority)
|
||||
if (this.is_credential_path(normalized)) {
|
||||
return { category: 'credential_store', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'credential store path'] }
|
||||
}
|
||||
|
||||
// Check system directories
|
||||
if (this.is_system_path(normalized)) {
|
||||
return { category: 'system_sensitive', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'system directory'] }
|
||||
}
|
||||
|
||||
const relative = this.relative_to_project(normalized)
|
||||
|
||||
// Check system directories first (highest priority for security)
|
||||
if (this.is_system_path(normalized)) {
|
||||
return { category: 'system', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'system directory'] }
|
||||
}
|
||||
|
||||
// Check if outside project tree
|
||||
if (!relative.startsWith('.') && !normalized.startsWith(this.project_root)) {
|
||||
return { category: 'external', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'outside project tree'] }
|
||||
if (!normalized.startsWith(this.project_root)) {
|
||||
return { category: 'project_outside_user', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'outside project tree'] }
|
||||
}
|
||||
|
||||
// Check project internal directories (protected)
|
||||
if (this.is_internal_dir(relative)) {
|
||||
return { category: 'project_internal', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'internal directory'] }
|
||||
// Check .git/ directory
|
||||
if (this.is_git_path(relative)) {
|
||||
return { category: 'project_git', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'git directory'] }
|
||||
}
|
||||
|
||||
// Check temp directories
|
||||
if (normalized.startsWith('/tmp') || normalized.startsWith('/var/tmp')) {
|
||||
return { category: 'temp', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'temp directory'] }
|
||||
// Check .air/shared/
|
||||
if (this.is_air_shared_path(relative)) {
|
||||
return { category: 'project_air_shared', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'air shared directory'] }
|
||||
}
|
||||
|
||||
// Check home directory
|
||||
if (HOME_PATTERN.test(normalized)) {
|
||||
return { category: 'user_home', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'home directory'] }
|
||||
// Check .air/local/
|
||||
if (this.is_air_local_path(relative)) {
|
||||
return { category: 'project_air_local', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'air local directory'] }
|
||||
}
|
||||
|
||||
// Classify by extension within project
|
||||
const ext = this.get_extension(normalized)
|
||||
if (this.is_source_file(ext)) {
|
||||
return { category: 'project_source', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'source file extension'] }
|
||||
}
|
||||
|
||||
if (this.is_build_output(normalized, ext)) {
|
||||
// Check build outputs
|
||||
if (this.is_build_output(normalized)) {
|
||||
return { category: 'project_build', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'build output'] }
|
||||
}
|
||||
|
||||
if (this.is_config_file(normalized, ext)) {
|
||||
return { category: 'project_config', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'config file'] }
|
||||
}
|
||||
|
||||
// Default to config (project root files like package.json, tsconfig.json)
|
||||
return { category: 'project_config', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'project root file'] }
|
||||
// Default: project file
|
||||
return { category: 'project', normalized_path: normalized, is_symlink_escape, reasons: [...reasons, 'project file'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is within project tree.
|
||||
*/
|
||||
is_within_project(path: string): boolean {
|
||||
try {
|
||||
const resolved = resolve(path)
|
||||
@@ -125,55 +122,37 @@ export class PathClassifier {
|
||||
return path
|
||||
}
|
||||
|
||||
private is_credential_path(path: string): boolean {
|
||||
const lower = path.toLowerCase()
|
||||
for (const pattern of CREDENTIAL_PATTERNS) {
|
||||
if (lower.includes(pattern.toLowerCase())) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private is_system_path(path: string): boolean {
|
||||
return SYSTEM_DIRS.some((dir) => path.startsWith(dir))
|
||||
}
|
||||
|
||||
private is_internal_dir(relative: string): boolean {
|
||||
private is_git_path(relative: string): boolean {
|
||||
const parts = relative.split(sep)
|
||||
return parts.some((part) => PROJECT_INTERNAL_DIRS.includes(part))
|
||||
return parts[0] === '.git' || parts.some((p) => p === '.git')
|
||||
}
|
||||
|
||||
private get_extension(path: string): string {
|
||||
const last_dot = path.lastIndexOf('.')
|
||||
if (last_dot === -1) return ''
|
||||
return path.slice(last_dot + 1).toLowerCase()
|
||||
private is_air_shared_path(relative: string): boolean {
|
||||
return relative.startsWith(`.air${sep}shared`) || relative.startsWith('.air/shared')
|
||||
}
|
||||
|
||||
private is_source_file(ext: string): boolean {
|
||||
const source_exts = [
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'rs', 'go', 'py', 'java', 'c', 'cpp', 'h', 'hpp',
|
||||
'cs', 'rb', 'php', 'swift', 'kt', 'scala', 'vue', 'svelte', 'html', 'css', 'scss', 'sass',
|
||||
'json', 'yaml', 'yml', 'toml', 'md', 'sql', 'graphql', 'proto'
|
||||
]
|
||||
return source_exts.includes(ext)
|
||||
private is_air_local_path(relative: string): boolean {
|
||||
return relative.startsWith(`.air${sep}local`) || relative.startsWith('.air/local')
|
||||
}
|
||||
|
||||
private is_build_output(path: string, ext: string): boolean {
|
||||
const build_exts = ['js', 'map', 'd.ts', 'wasm', 'so', 'dll', 'dylib', 'exe', 'o', 'a', 'obj']
|
||||
const build_dirs = ['dist', 'build', 'out', 'target', '.next', '.nuxt', '__pycache__']
|
||||
|
||||
if (build_exts.includes(ext)) return true
|
||||
|
||||
private is_build_output(path: string): boolean {
|
||||
const parts = path.split(sep)
|
||||
return parts.some((part) => build_dirs.includes(part))
|
||||
}
|
||||
|
||||
private is_config_file(path: string, ext: string): boolean {
|
||||
const config_exts = ['json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'config', 'xml', 'env', 'properties']
|
||||
const config_names = [
|
||||
'package.json', 'tsconfig.json', 'jsconfig.json', 'Cargo.toml', 'Cargo.lock',
|
||||
'go.mod', 'go.sum', 'requirements.txt', 'Pipfile', 'pyproject.toml',
|
||||
'.eslintrc', '.prettierrc', '.editorconfig', 'Makefile', 'CMakeLists.txt'
|
||||
]
|
||||
|
||||
if (config_exts.includes(ext)) return true
|
||||
|
||||
const filename = path.split(sep).pop() || ''
|
||||
return config_names.includes(filename)
|
||||
return parts.some((part) => BUILD_DIRS.includes(part))
|
||||
}
|
||||
}
|
||||
|
||||
export function createPathClassifier(project_root: string): PathClassifier {
|
||||
return new PathClassifier(project_root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,20 +15,22 @@ import { CommandRiskAnalyzer, createCommandRiskAnalyzer } from './CommandRiskAna
|
||||
import { SecretRedactor, get_shared_redactor } from './SecretRedactor.js'
|
||||
import type { PathCategory, RiskAnalysis } from './index.js'
|
||||
|
||||
// Permission action per DD §9.3
|
||||
// Permission action per contracts §13 / DD §9.3
|
||||
export type PermissionAction =
|
||||
| 'allow' // permitted
|
||||
| 'deny' // explicitly denied
|
||||
| 'prompt' // needs user confirmation
|
||||
| 'read_only' // downgrade to read-only operation
|
||||
| 'sandbox' // run in restricted sandbox
|
||||
| 'audit_log' // allow but log for audit
|
||||
| 'allow' // permitted — execute normally
|
||||
| 'announce_then_run' // emit visible notice, then execute unless interrupted
|
||||
| 'ask_user' // suspend; emit permission.prompt.requested
|
||||
| 'deny' // explicitly denied; return error
|
||||
| 'block' // return blocked outcome → task.blocked upstream
|
||||
| 'refuse' // return AirError{kind:"policy_error"}; no execution
|
||||
|
||||
export interface PermissionDecision {
|
||||
action: PermissionAction
|
||||
reason: string
|
||||
requires_confirmation: boolean
|
||||
flags: string[]
|
||||
grant_scope?: string
|
||||
risk_level?: string
|
||||
fallback_result?: unknown
|
||||
}
|
||||
|
||||
@@ -234,8 +236,8 @@ export class PermissionEngine {
|
||||
|
||||
if ((category === 'filesystem' || category === 'network') && !profile.allow_filesystem_write) {
|
||||
return {
|
||||
action: 'read_only',
|
||||
reason: 'write operations not allowed, downgrading to read-only',
|
||||
action: 'announce_then_run',
|
||||
reason: 'write operations not allowed, announcing then running read-only',
|
||||
requires_confirmation: false,
|
||||
flags: ['downgraded_read_only']
|
||||
}
|
||||
@@ -335,8 +337,8 @@ export class PermissionEngine {
|
||||
|
||||
if (risk_score >= 70) {
|
||||
return {
|
||||
action: 'prompt',
|
||||
reason: `risk score ${risk_score} requires confirmation`,
|
||||
action: 'ask_user',
|
||||
reason: `risk score ${risk_score} requires user confirmation`,
|
||||
requires_confirmation: true,
|
||||
flags: ['medium_risk']
|
||||
}
|
||||
@@ -344,8 +346,8 @@ export class PermissionEngine {
|
||||
|
||||
if (risk_score >= 50) {
|
||||
return {
|
||||
action: 'audit_log',
|
||||
reason: `risk score ${risk_score}, allowing with audit`,
|
||||
action: 'announce_then_run',
|
||||
reason: `risk score ${risk_score}, allowing with audit announcement`,
|
||||
requires_confirmation: false,
|
||||
flags: ['low_risk', 'audit']
|
||||
}
|
||||
@@ -417,8 +419,8 @@ export class PermissionEngine {
|
||||
const paths = this.extract_paths_from_call(tool_call)
|
||||
for (const path of paths) {
|
||||
const classification = this.path_classifier.classify(path)
|
||||
if (classification.category === 'system') score += 30
|
||||
if (classification.category === 'project_internal') score += 20
|
||||
if (classification.category === 'system_sensitive') score += 30
|
||||
if (classification.category === 'project_git' || classification.category === 'project_air_shared') score += 20
|
||||
if (classification.is_symlink_escape) score += 40
|
||||
}
|
||||
|
||||
@@ -478,7 +480,7 @@ export class PermissionEngine {
|
||||
// Redact sensitive data from decision
|
||||
return {
|
||||
...decision,
|
||||
reason: this.redactor.redact(decision.redacted || decision.reason).redacted
|
||||
reason: this.redactor.redact(decision.reason).redacted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,13 @@ export class DatabaseManager implements TransactionManager {
|
||||
/**
|
||||
* Creates a TransactionHandle for the given database.
|
||||
* The id is an opaque token that maps to the active raw transaction.
|
||||
* The db property carries the database handle for repository use within
|
||||
* the transaction scope.
|
||||
*/
|
||||
private handleFor(_db: Database): TransactionHandle {
|
||||
private handleFor(db: Database): TransactionHandle {
|
||||
// Generate a unique transaction id using current timestamp + random
|
||||
const id = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
|
||||
return { id }
|
||||
return { id, db }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,6 +127,7 @@ export class Recovery {
|
||||
|
||||
/**
|
||||
* FK-off scan — checks 8 invariants per DD §18.3.
|
||||
* Returns an OrphanReferenceReport with reparented/archived references.
|
||||
*/
|
||||
private async scanOrphanReferences(): Promise<OrphanReferenceReport> {
|
||||
const report: OrphanReferenceReport = {
|
||||
@@ -137,16 +138,32 @@ export class Recovery {
|
||||
}
|
||||
|
||||
// 8 FK-off invariant checks (DD §18.3):
|
||||
// - tasks.session_id → sessions.id
|
||||
// - messages.session_id → sessions.id
|
||||
// - task_attempts.task_id → tasks.id
|
||||
// - agents.session_id → sessions.id
|
||||
// - tool_runs.session_id → sessions.id
|
||||
// - command_runs.session_id → sessions.id
|
||||
// - artifacts.session_id → sessions.id
|
||||
// - evidence_refs.session_id → sessions.id
|
||||
//
|
||||
// Full implementation would query SQLite for each FK
|
||||
const fkChecks = [
|
||||
{ table: 'tasks', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'messages', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'task_attempts', fk_column: 'task_id', parent_table: 'tasks' },
|
||||
{ table: 'agents', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'tool_runs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'command_runs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'artifacts', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'evidence_refs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
]
|
||||
|
||||
// TODO: Query SQLite for each FK check above.
|
||||
// For each orphan reference found:
|
||||
// - If parent can be inferred, reparent to a valid parent
|
||||
// - Otherwise, archive the orphaned reference
|
||||
// For now, return the initialized report structure
|
||||
|
||||
for (const check of fkChecks) {
|
||||
try {
|
||||
// Placeholder: actual DB query would go here
|
||||
// const orphans = db.query(`SELECT * FROM ${check.table} WHERE ${check.fk_column} NOT IN (SELECT id FROM ${check.parent_table})`)
|
||||
// For each orphan, decide reparent or archive
|
||||
} catch (error) {
|
||||
report.errors.push(`FK check failed for ${check.table}.${check.fk_column}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
@@ -155,10 +172,33 @@ export class Recovery {
|
||||
* PID liveness check for running agents.
|
||||
* Uses Signal 0 (kill -0) to check process existence.
|
||||
*/
|
||||
checkPidLiveness(): PidLivenessReport[] {
|
||||
// Would query agents table for running agents with PIDs
|
||||
// For each, check liveness via process.kill(pid, 0)
|
||||
return []
|
||||
checkPidLiveness(agents?: Array<{ agent_id: string; pid: number }>): PidLivenessReport[] {
|
||||
if (!agents || agents.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const reports: PidLivenessReport[] = []
|
||||
|
||||
for (const agent of agents) {
|
||||
let alive = false
|
||||
try {
|
||||
// Signal 0 does not kill the process; it checks if the process exists
|
||||
process.kill(agent.pid, 0)
|
||||
alive = true
|
||||
} catch {
|
||||
// ESRCH: no such process, or EPERM: no permission (process exists but not owned by us)
|
||||
alive = false
|
||||
}
|
||||
|
||||
reports.push({
|
||||
agent_id: agent.agent_id,
|
||||
pid: agent.pid,
|
||||
alive,
|
||||
action: alive ? 'keep' : 'mark_lost'
|
||||
})
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
private findOrphanFiles(dir: string, depth = 0): string[] {
|
||||
|
||||
@@ -59,8 +59,8 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
|
||||
/**
|
||||
* Get an agent by ID.
|
||||
*/
|
||||
async get(id: AgentID, _tx?: TransactionHandle): Promise<AgentRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE id = ?')
|
||||
async get(id: AgentID, tx?: TransactionHandle): Promise<AgentRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM agents WHERE id = ?')
|
||||
const row = stmt.get(id) as AgentRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -68,11 +68,11 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
|
||||
/**
|
||||
* Insert a new agent. Status is set by EventStore projection (INV-1).
|
||||
*/
|
||||
async insert(record: AgentInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: AgentInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Status is set by EventStore.project(), not by caller
|
||||
const status: AgentStatus = 'starting'
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO agents (
|
||||
id, session_id, type, status,
|
||||
pid, task_id,
|
||||
@@ -101,7 +101,7 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
|
||||
/**
|
||||
* Update an existing agent. Status changes only via EventStore projection (INV-1).
|
||||
*/
|
||||
async update(id: AgentID, patch: AgentUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: AgentID, patch: AgentUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -140,7 +140,7 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ export class ArtifactRepository implements Repository<ArtifactRecord, ArtifactIn
|
||||
/**
|
||||
* Get an artifact by ID.
|
||||
*/
|
||||
async get(id: ArtifactID, _tx?: TransactionHandle): Promise<ArtifactRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM artifacts WHERE id = ?')
|
||||
async get(id: ArtifactID, tx?: TransactionHandle): Promise<ArtifactRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM artifacts WHERE id = ?')
|
||||
const row = stmt.get(id) as ArtifactRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -75,13 +75,13 @@ export class ArtifactRepository implements Repository<ArtifactRecord, ArtifactIn
|
||||
/**
|
||||
* Insert a new artifact.
|
||||
*/
|
||||
async insert(record: ArtifactInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: ArtifactInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('artifacts', {
|
||||
type: record.type,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO artifacts (
|
||||
id, session_id, type, uri, path, original_name,
|
||||
size_bytes, sha256,
|
||||
@@ -114,7 +114,7 @@ export class ArtifactRepository implements Repository<ArtifactRecord, ArtifactIn
|
||||
/**
|
||||
* Update an existing artifact.
|
||||
*/
|
||||
async update(id: ArtifactID, patch: ArtifactUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: ArtifactID, patch: ArtifactUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.type !== undefined) {
|
||||
assertEnumValues('artifacts', { type: patch.type })
|
||||
@@ -165,7 +165,7 @@ export class ArtifactRepository implements Repository<ArtifactRecord, ArtifactIn
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE artifacts SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE artifacts SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ export class CommandRunRepository implements Repository<CommandRunRecord, Comman
|
||||
/**
|
||||
* Get a command run by ID.
|
||||
*/
|
||||
async get(id: CommandRunID, _tx?: TransactionHandle): Promise<CommandRunRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM command_runs WHERE id = ?')
|
||||
async get(id: CommandRunID, tx?: TransactionHandle): Promise<CommandRunRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM command_runs WHERE id = ?')
|
||||
const row = stmt.get(id) as CommandRunRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -102,8 +102,8 @@ export class CommandRunRepository implements Repository<CommandRunRecord, Comman
|
||||
/**
|
||||
* Insert a new command run.
|
||||
*/
|
||||
async insert(record: CommandRunInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = this.db.prepare(`
|
||||
async insert(record: CommandRunInsert, tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO command_runs (
|
||||
id, session_id, task_id, agent_id, origin_message_id, tool_run_id,
|
||||
command, cwd,
|
||||
@@ -138,7 +138,7 @@ export class CommandRunRepository implements Repository<CommandRunRecord, Comman
|
||||
/**
|
||||
* Update an existing command run.
|
||||
*/
|
||||
async update(id: CommandRunID, patch: CommandRunUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: CommandRunID, patch: CommandRunUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -180,7 +180,7 @@ export class CommandRunRepository implements Repository<CommandRunRecord, Comman
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE command_runs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE command_runs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ export class DiagnosticRepository implements Repository<DiagnosticRecord, Diagno
|
||||
/**
|
||||
* Get a diagnostic by ID.
|
||||
*/
|
||||
async get(id: UUID, _tx?: TransactionHandle): Promise<DiagnosticRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM diagnostics WHERE id = ?')
|
||||
async get(id: UUID, tx?: TransactionHandle): Promise<DiagnosticRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM diagnostics WHERE id = ?')
|
||||
const row = stmt.get(id) as DiagnosticRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -76,13 +76,13 @@ export class DiagnosticRepository implements Repository<DiagnosticRecord, Diagno
|
||||
/**
|
||||
* Insert a new diagnostic.
|
||||
*/
|
||||
async insert(record: DiagnosticInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: DiagnosticInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('diagnostics', {
|
||||
severity: record.severity,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO diagnostics (
|
||||
id, session_id, task_id, agent_id, command_run_id, artifact_id,
|
||||
language, toolchain, severity,
|
||||
@@ -116,7 +116,7 @@ export class DiagnosticRepository implements Repository<DiagnosticRecord, Diagno
|
||||
/**
|
||||
* Update an existing diagnostic.
|
||||
*/
|
||||
async update(id: UUID, patch: DiagnosticUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: UUID, patch: DiagnosticUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.severity !== undefined) {
|
||||
assertEnumValues('diagnostics', { severity: patch.severity })
|
||||
@@ -183,7 +183,7 @@ export class DiagnosticRepository implements Repository<DiagnosticRecord, Diagno
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE diagnostics SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE diagnostics SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ export class EventRepository implements Repository<EventRecord, EventInsert, Eve
|
||||
/**
|
||||
* Get an event by ID.
|
||||
*/
|
||||
async get(id: UUID, _tx?: TransactionHandle): Promise<EventRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM events WHERE id = ?')
|
||||
async get(id: UUID, tx?: TransactionHandle): Promise<EventRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM events WHERE id = ?')
|
||||
const row = stmt.get(id) as EventRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -88,8 +88,8 @@ export class EventRepository implements Repository<EventRecord, EventInsert, Eve
|
||||
/**
|
||||
* Insert a new event.
|
||||
*/
|
||||
async insert(record: EventInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = this.db.prepare(`
|
||||
async insert(record: EventInsert, tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO events (
|
||||
id, session_id, type, version, timestamp,
|
||||
source_kind, source_id, agent_type,
|
||||
@@ -120,7 +120,7 @@ export class EventRepository implements Repository<EventRecord, EventInsert, Eve
|
||||
/**
|
||||
* Update an existing event.
|
||||
*/
|
||||
async update(_id: UUID, _patch: EventUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(_id: UUID, _patch: EventUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Events are immutable - no updates allowed
|
||||
// This method exists to satisfy the Repository interface
|
||||
throw new Error('Events are immutable and cannot be updated')
|
||||
@@ -182,7 +182,7 @@ export class EventRepository implements Repository<EventRecord, EventInsert, Eve
|
||||
}
|
||||
|
||||
if (filter.route_prefix && filter.route_prefix.length > 0) {
|
||||
const prefix = filter.route_prefix.join('.')
|
||||
const prefix = filter.route_prefix.join('/')
|
||||
conditions.push('route_text LIKE ?')
|
||||
params.push(`${prefix}%`)
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export class EvidenceRepository implements Repository<EvidenceRefRecord, Evidenc
|
||||
/**
|
||||
* Get an evidence ref by ID.
|
||||
*/
|
||||
async get(id: EvidenceRefID, _tx?: TransactionHandle): Promise<EvidenceRefRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM evidence_refs WHERE id = ?')
|
||||
async get(id: EvidenceRefID, tx?: TransactionHandle): Promise<EvidenceRefRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM evidence_refs WHERE id = ?')
|
||||
const row = stmt.get(id) as EvidenceRefRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -75,13 +75,13 @@ export class EvidenceRepository implements Repository<EvidenceRefRecord, Evidenc
|
||||
/**
|
||||
* Insert a new evidence ref.
|
||||
*/
|
||||
async insert(record: EvidenceRefInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: EvidenceRefInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('evidence_refs', {
|
||||
kind: record.kind,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO evidence_refs (
|
||||
id, session_id,
|
||||
task_id, agent_id, tool_run_id, command_run_id, artifact_id, diagnostic_id, message_id,
|
||||
@@ -111,7 +111,7 @@ export class EvidenceRepository implements Repository<EvidenceRefRecord, Evidenc
|
||||
/**
|
||||
* Update an existing evidence ref.
|
||||
*/
|
||||
async update(id: EvidenceRefID, patch: EvidenceRefUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: EvidenceRefID, patch: EvidenceRefUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.kind !== undefined) {
|
||||
assertEnumValues('evidence_refs', { kind: patch.kind })
|
||||
@@ -138,7 +138,7 @@ export class EvidenceRepository implements Repository<EvidenceRefRecord, Evidenc
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE evidence_refs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE evidence_refs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
/**
|
||||
* Get a draft by message ID.
|
||||
*/
|
||||
async get(message_id: MessageID, _tx?: TransactionHandle): Promise<MessageDraftRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM message_drafts WHERE message_id = ?')
|
||||
async get(message_id: MessageID, tx?: TransactionHandle): Promise<MessageDraftRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM message_drafts WHERE message_id = ?')
|
||||
const row = stmt.get(message_id) as MessageDraftRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -63,13 +63,13 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
/**
|
||||
* Insert a new draft.
|
||||
*/
|
||||
async insert(record: MessageDraftInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: MessageDraftInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns (only status is a closed enum for message_drafts)
|
||||
assertEnumValues('message_drafts', {
|
||||
status: record.status,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO message_drafts (
|
||||
message_id, session_id, role, canonical_format,
|
||||
partial_content_json, status, created_at, updated_at, metadata_json
|
||||
@@ -92,7 +92,7 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
/**
|
||||
* Update an existing draft.
|
||||
*/
|
||||
async update(message_id: MessageID, patch: MessageDraftUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(message_id: MessageID, patch: MessageDraftUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present (only status is a closed enum for message_drafts)
|
||||
if (patch.status !== undefined) {
|
||||
assertEnumValues('message_drafts', { status: patch.status })
|
||||
@@ -123,7 +123,7 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
}
|
||||
|
||||
values.push(message_id)
|
||||
const stmt = this.db.prepare(`UPDATE message_drafts SET ${fields.join(', ')} WHERE message_id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE message_drafts SET ${fields.join(', ')} WHERE message_id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
@@ -134,13 +134,13 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
/**
|
||||
* Upsert a draft - insert or replace existing.
|
||||
*/
|
||||
async upsert(record: MessageDraftRecord, _tx?: TransactionHandle): Promise<void> {
|
||||
async upsert(record: MessageDraftRecord, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns (only status is a closed enum for message_drafts)
|
||||
assertEnumValues('message_drafts', {
|
||||
status: record.status,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT OR REPLACE INTO message_drafts (
|
||||
message_id, session_id, role, canonical_format,
|
||||
partial_content_json, status, created_at, updated_at, metadata_json
|
||||
@@ -163,8 +163,8 @@ export class MessageDraftRepository implements Repository<MessageDraftRecord, Me
|
||||
/**
|
||||
* Delete draft for a specific message.
|
||||
*/
|
||||
async delete_for_message(message_id: MessageID, _tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = this.db.prepare('DELETE FROM message_drafts WHERE message_id = ?')
|
||||
async delete_for_message(message_id: MessageID, tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('DELETE FROM message_drafts WHERE message_id = ?')
|
||||
stmt.run(message_id)
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@ export class MessageRepository implements Repository<MessageRecord, MessageInser
|
||||
/**
|
||||
* Get a message by ID.
|
||||
*/
|
||||
async get(id: MessageID, _tx?: TransactionHandle): Promise<MessageRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM messages WHERE id = ?')
|
||||
async get(id: MessageID, tx?: TransactionHandle): Promise<MessageRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM messages WHERE id = ?')
|
||||
const row = stmt.get(id) as MessageRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -64,14 +64,14 @@ export class MessageRepository implements Repository<MessageRecord, MessageInser
|
||||
/**
|
||||
* Insert a new message.
|
||||
*/
|
||||
async insert(record: MessageInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: MessageInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('messages', {
|
||||
role: record.role,
|
||||
canonical_format: record.canonical_format,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO messages (
|
||||
id, session_id, role, canonical_format, content_json,
|
||||
parent_message_id, route_json, created_at,
|
||||
@@ -96,7 +96,7 @@ export class MessageRepository implements Repository<MessageRecord, MessageInser
|
||||
/**
|
||||
* Update an existing message.
|
||||
*/
|
||||
async update(id: MessageID, patch: MessageUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: MessageID, patch: MessageUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.role !== undefined) {
|
||||
assertEnumValues('messages', { role: patch.role })
|
||||
@@ -134,7 +134,7 @@ export class MessageRepository implements Repository<MessageRecord, MessageInser
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE messages SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE messages SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ export class SessionRepository implements Repository<SessionRecord, SessionInser
|
||||
/**
|
||||
* Get a session by ID.
|
||||
*/
|
||||
async get(id: SessionID, _tx?: TransactionHandle): Promise<SessionRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?')
|
||||
async get(id: SessionID, tx?: TransactionHandle): Promise<SessionRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM sessions WHERE id = ?')
|
||||
const row = stmt.get(id) as SessionRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -64,11 +64,11 @@ export class SessionRepository implements Repository<SessionRecord, SessionInser
|
||||
/**
|
||||
* Insert a new session. Status is set by EventStore projection (INV-1).
|
||||
*/
|
||||
async insert(record: SessionInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: SessionInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Get status from event-projected column, default to 'active'
|
||||
const status = 'active' // Set by EventStore.project(), not by caller
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, project_id, project_root, title, status,
|
||||
created_at, updated_at, exited_at,
|
||||
@@ -94,7 +94,7 @@ export class SessionRepository implements Repository<SessionRecord, SessionInser
|
||||
/**
|
||||
* Update an existing session. Status changes only via EventStore projection (INV-1).
|
||||
*/
|
||||
async update(id: SessionID, patch: SessionUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: SessionID, patch: SessionUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -129,7 +129,7 @@ export class SessionRepository implements Repository<SessionRecord, SessionInser
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE sessions SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE sessions SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ export class SummaryRepository implements Repository<SummaryRecord, SummaryInser
|
||||
/**
|
||||
* Get a summary by ID.
|
||||
*/
|
||||
async get(id: SummaryID, _tx?: TransactionHandle): Promise<SummaryRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM summaries WHERE id = ?')
|
||||
async get(id: SummaryID, tx?: TransactionHandle): Promise<SummaryRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM summaries WHERE id = ?')
|
||||
const row = stmt.get(id) as SummaryRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -64,13 +64,13 @@ export class SummaryRepository implements Repository<SummaryRecord, SummaryInser
|
||||
/**
|
||||
* Insert a new summary.
|
||||
*/
|
||||
async insert(record: SummaryInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: SummaryInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('summaries', {
|
||||
type: record.type,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO summaries (
|
||||
id, session_id, type,
|
||||
range_start_message_id, range_end_message_id,
|
||||
@@ -93,7 +93,7 @@ export class SummaryRepository implements Repository<SummaryRecord, SummaryInser
|
||||
/**
|
||||
* Update an existing summary.
|
||||
*/
|
||||
async update(id: SummaryID, patch: SummaryUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: SummaryID, patch: SummaryUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.type !== undefined) {
|
||||
assertEnumValues('summaries', { type: patch.type })
|
||||
@@ -128,7 +128,7 @@ export class SummaryRepository implements Repository<SummaryRecord, SummaryInser
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE summaries SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE summaries SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
||||
/**
|
||||
* Get a task attempt by ID.
|
||||
*/
|
||||
async get(id: UUID, _tx?: TransactionHandle): Promise<TaskAttemptRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM task_attempts WHERE id = ?')
|
||||
async get(id: UUID, tx?: TransactionHandle): Promise<TaskAttemptRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM task_attempts WHERE id = ?')
|
||||
const row = stmt.get(id) as TaskAttemptRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -69,11 +69,11 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
||||
/**
|
||||
* Insert a new task attempt. Status is set by EventStore projection (INV-1).
|
||||
*/
|
||||
async insert(record: TaskAttemptInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: TaskAttemptInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Status is set by EventStore.project(), not by caller
|
||||
const status: TaskAttemptStatus = 'pending'
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO task_attempts (
|
||||
id, session_id, task_id, attempt_index,
|
||||
agent_id, status,
|
||||
@@ -102,7 +102,7 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
||||
/**
|
||||
* Update an existing task attempt. Status changes only via EventStore projection (INV-1).
|
||||
*/
|
||||
async update(id: UUID, patch: TaskAttemptUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: UUID, patch: TaskAttemptUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -112,6 +112,10 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
||||
values.push(patch.agent_id)
|
||||
}
|
||||
if (patch.failure_signature !== undefined) {
|
||||
fields.push('failure_signature = ?')
|
||||
values.push(patch.failure_signature)
|
||||
}
|
||||
if (patch.failure_summary !== undefined) {
|
||||
fields.push('failure_summary = ?')
|
||||
values.push(patch.failure_summary)
|
||||
}
|
||||
@@ -133,7 +137,7 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE task_attempts SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE task_attempts SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ export class TaskDependencyRepository implements Repository<TaskDependencyRecord
|
||||
/**
|
||||
* Get a task dependency by ID.
|
||||
*/
|
||||
async get(id: UUID, _tx?: TransactionHandle): Promise<TaskDependencyRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM task_dependencies WHERE id = ?')
|
||||
async get(id: UUID, tx?: TransactionHandle): Promise<TaskDependencyRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM task_dependencies WHERE id = ?')
|
||||
const row = stmt.get(id) as TaskDependencyRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -64,13 +64,13 @@ export class TaskDependencyRepository implements Repository<TaskDependencyRecord
|
||||
/**
|
||||
* Insert a new task dependency.
|
||||
*/
|
||||
async insert(record: TaskDependencyInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: TaskDependencyInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('task_dependencies', {
|
||||
dependency_type: record.dependency_type,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO task_dependencies (
|
||||
id, session_id, task_id, depends_on_task_id,
|
||||
dependency_type, reason, created_at
|
||||
@@ -91,7 +91,7 @@ export class TaskDependencyRepository implements Repository<TaskDependencyRecord
|
||||
/**
|
||||
* Update an existing task dependency.
|
||||
*/
|
||||
async update(id: UUID, patch: TaskDependencyUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: UUID, patch: TaskDependencyUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.dependency_type !== undefined) {
|
||||
assertEnumValues('task_dependencies', { dependency_type: patch.dependency_type })
|
||||
@@ -114,7 +114,7 @@ export class TaskDependencyRepository implements Repository<TaskDependencyRecord
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE task_dependencies SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE task_dependencies SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
|
||||
/**
|
||||
* Get a task by ID.
|
||||
*/
|
||||
async get(id: TaskID, _tx?: TransactionHandle): Promise<TaskRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM tasks WHERE id = ?')
|
||||
async get(id: TaskID, tx?: TransactionHandle): Promise<TaskRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM tasks WHERE id = ?')
|
||||
const row = stmt.get(id) as TaskRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -78,11 +78,11 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
|
||||
/**
|
||||
* Insert a new task. Status is set by EventStore projection (INV-1).
|
||||
*/
|
||||
async insert(record: TaskInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: TaskInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Status is set by EventStore.project(), not by caller
|
||||
const status: TaskStatus = 'pending'
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO tasks (
|
||||
id, session_id, type, status, title,
|
||||
task_spec_json, worker_result_json,
|
||||
@@ -114,7 +114,7 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
|
||||
/**
|
||||
* Update an existing task. Status changes only via EventStore projection (INV-1).
|
||||
*/
|
||||
async update(id: TaskID, patch: TaskUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: TaskID, patch: TaskUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -165,7 +165,7 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
|
||||
/**
|
||||
* Get a tool run by ID.
|
||||
*/
|
||||
async get(id: ToolRunID, _tx?: TransactionHandle): Promise<ToolRunRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM tool_runs WHERE id = ?')
|
||||
async get(id: ToolRunID, tx?: TransactionHandle): Promise<ToolRunRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM tool_runs WHERE id = ?')
|
||||
const row = stmt.get(id) as ToolRunRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -73,11 +73,11 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
|
||||
/**
|
||||
* Insert a new tool run. Status is set by EventStore projection (INV-1).
|
||||
*/
|
||||
async insert(record: ToolRunInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: ToolRunInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Status is set by EventStore.project(), not by caller
|
||||
const status: ToolRunStatus = 'running'
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO tool_runs (
|
||||
id, session_id, task_id, agent_id, origin_message_id,
|
||||
tool_name, status,
|
||||
@@ -110,7 +110,7 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
|
||||
/**
|
||||
* Update an existing tool run. Status changes only via EventStore projection (INV-1).
|
||||
*/
|
||||
async update(id: ToolRunID, patch: ToolRunUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: ToolRunID, patch: ToolRunUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -149,7 +149,7 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE tool_runs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE tool_runs SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ export class UiStateRepository implements Repository<UiStateRecord, UiStateInser
|
||||
/**
|
||||
* Get a UI state entry by ID.
|
||||
*/
|
||||
async get(id: UUID, _tx?: TransactionHandle): Promise<UiStateRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM ui_state WHERE id = ?')
|
||||
async get(id: UUID, tx?: TransactionHandle): Promise<UiStateRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM ui_state WHERE id = ?')
|
||||
const row = stmt.get(id) as UiStateRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -59,8 +59,8 @@ export class UiStateRepository implements Repository<UiStateRecord, UiStateInser
|
||||
/**
|
||||
* Insert a new UI state entry.
|
||||
*/
|
||||
async insert(record: UiStateInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = this.db.prepare(`
|
||||
async insert(record: UiStateInsert, tx?: TransactionHandle): Promise<void> {
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO ui_state (
|
||||
id, session_id, scope, key, value_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -79,7 +79,7 @@ export class UiStateRepository implements Repository<UiStateRecord, UiStateInser
|
||||
/**
|
||||
* Update an existing UI state entry.
|
||||
*/
|
||||
async update(id: UUID, patch: UiStateUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: UUID, patch: UiStateUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
@@ -105,7 +105,7 @@ export class UiStateRepository implements Repository<UiStateRecord, UiStateInser
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE ui_state SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE ui_state SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
@@ -122,25 +122,25 @@ export class UiStateRepository implements Repository<UiStateRecord, UiStateInser
|
||||
scope: string,
|
||||
key: string,
|
||||
value_json: string,
|
||||
_tx?: TransactionHandle,
|
||||
tx?: TransactionHandle,
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString() as ISOTimeString
|
||||
|
||||
// Try to update first
|
||||
const updateStmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
UPDATE ui_state SET value_json = ?, updated_at = ?
|
||||
WHERE session_id = ? AND scope = ? AND key = ?
|
||||
`)
|
||||
const result = updateStmt.run(value_json, now, session_id, scope, key)
|
||||
const result = stmt.run(value_json, now, session_id, scope, key)
|
||||
|
||||
// If no row was updated, insert
|
||||
if (result.changes === 0) {
|
||||
const id = `ui_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` as UUID
|
||||
const insertStmt = this.db.prepare(`
|
||||
const stmt2 = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO ui_state (id, session_id, scope, key, value_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
insertStmt.run(id, session_id, scope, key, value_json, now)
|
||||
stmt2.run(id, session_id, scope, key, value_json, now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ export class WorkspaceRepository implements Repository<WorkspaceRecord, Workspac
|
||||
/**
|
||||
* Get a workspace by ID.
|
||||
*/
|
||||
async get(id: WorkspaceID, _tx?: TransactionHandle): Promise<WorkspaceRecord | undefined> {
|
||||
const stmt = this.db.prepare('SELECT * FROM workspaces WHERE id = ?')
|
||||
async get(id: WorkspaceID, tx?: TransactionHandle): Promise<WorkspaceRecord | undefined> {
|
||||
const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM workspaces WHERE id = ?')
|
||||
const row = stmt.get(id) as WorkspaceRecord | undefined
|
||||
return row
|
||||
}
|
||||
@@ -70,14 +70,14 @@ export class WorkspaceRepository implements Repository<WorkspaceRecord, Workspac
|
||||
/**
|
||||
* Insert a new workspace.
|
||||
*/
|
||||
async insert(record: WorkspaceInsert, _tx?: TransactionHandle): Promise<void> {
|
||||
async insert(record: WorkspaceInsert, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns
|
||||
assertEnumValues('workspaces', {
|
||||
strategy: record.strategy,
|
||||
status: record.status,
|
||||
})
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
const stmt = (tx?.db ?? this.db).prepare(`
|
||||
INSERT INTO workspaces (
|
||||
id, session_id, task_id, agent_id,
|
||||
path, strategy, status,
|
||||
@@ -105,7 +105,7 @@ export class WorkspaceRepository implements Repository<WorkspaceRecord, Workspac
|
||||
/**
|
||||
* Update an existing workspace.
|
||||
*/
|
||||
async update(id: WorkspaceID, patch: WorkspaceUpdate, _tx?: TransactionHandle): Promise<void> {
|
||||
async update(id: WorkspaceID, patch: WorkspaceUpdate, tx?: TransactionHandle): Promise<void> {
|
||||
// Validate enum columns if present
|
||||
if (patch.strategy !== undefined) {
|
||||
assertEnumValues('workspaces', { strategy: patch.strategy })
|
||||
@@ -155,7 +155,7 @@ export class WorkspaceRepository implements Repository<WorkspaceRecord, Workspac
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const stmt = this.db.prepare(`UPDATE workspaces SET ${fields.join(', ')} WHERE id = ?`)
|
||||
const stmt = (tx?.db ?? this.db).prepare(`UPDATE workspaces SET ${fields.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...values)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ export class BuiltInToolRegistrar {
|
||||
// Doctor Tools (T-213)
|
||||
this.register_tool(doctor_check, createDoctorExecutor()['doctor.check'])
|
||||
this.register_tool(doctor_fix, createDoctorExecutor()['doctor.fix'])
|
||||
|
||||
// Stub Tools - high-priority registrations (Alpha scope)
|
||||
const stub_definitions = this.create_stub_definitions()
|
||||
for (const [name, definition] of Object.entries(stub_definitions)) {
|
||||
this.register_tool(definition as any, this.create_stub_executor(name))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +80,106 @@ export class BuiltInToolRegistrar {
|
||||
private register_tool(definition: typeof fs_read, executor: (call: any) => any): void {
|
||||
this.registry.register(definition.name, definition, executor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create stub tool definitions for high-priority tools (Alpha scope).
|
||||
*/
|
||||
private create_stub_definitions(): Record<string, typeof fs_read> {
|
||||
return {
|
||||
'fs.stat': {
|
||||
name: 'fs.stat',
|
||||
category: 'filesystem',
|
||||
description: 'Get filesystem stat info for a path',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File or directory path to stat' }
|
||||
},
|
||||
required: ['path']
|
||||
},
|
||||
permissions: { read: true, write: false, network: false },
|
||||
streaming: false
|
||||
} as any,
|
||||
|
||||
'cpp.build': {
|
||||
name: 'cpp.build',
|
||||
category: 'build',
|
||||
description: 'Build C++ project',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: { type: 'string', description: 'Build target' },
|
||||
config: { type: 'string', description: 'Build configuration (debug/release)' }
|
||||
},
|
||||
required: []
|
||||
},
|
||||
permissions: { read: true, write: false, network: false },
|
||||
streaming: false
|
||||
} as any,
|
||||
|
||||
'cpp.test': {
|
||||
name: 'cpp.test',
|
||||
category: 'test',
|
||||
description: 'Run C++ tests',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: { type: 'string', description: 'Test filter pattern' }
|
||||
},
|
||||
required: []
|
||||
},
|
||||
permissions: { read: true, write: false, network: false },
|
||||
streaming: false
|
||||
} as any,
|
||||
|
||||
'cpp.static.cppcheck': {
|
||||
name: 'cpp.static.cppcheck',
|
||||
category: 'static_analysis',
|
||||
description: 'Run cppcheck static analysis on C++ code',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to analyze' },
|
||||
severity: { type: 'string', description: 'Minimum severity level' }
|
||||
},
|
||||
required: []
|
||||
},
|
||||
permissions: { read: true, write: false, network: false },
|
||||
streaming: false
|
||||
} as any,
|
||||
|
||||
'debug.run': {
|
||||
name: 'debug.run',
|
||||
category: 'debug',
|
||||
description: 'Run debugger on a target process or binary',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: { type: 'string', description: 'Binary or process to debug' },
|
||||
breakpoints: { type: 'array', items: { type: 'string' }, description: 'Breakpoint locations' }
|
||||
},
|
||||
required: ['target']
|
||||
},
|
||||
permissions: { read: true, write: false, network: false },
|
||||
streaming: false
|
||||
} as any,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stub executor that returns a not_implemented error.
|
||||
*/
|
||||
private create_stub_executor(tool_name: string): (call: any) => any {
|
||||
return (call: any) => {
|
||||
return {
|
||||
call_id: '',
|
||||
tool_name: tool_name,
|
||||
type: 'error',
|
||||
content: { error_type: 'not_implemented', message: 'TODO: implement' },
|
||||
metadata: { timestamp: new Date().toISOString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function register_builtin_tools(registry: ToolRegistry, project_root: string): BuiltInToolRegistrar {
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ToolExecutionContext {
|
||||
project_root: string
|
||||
agent_id: string
|
||||
agent_type: AgentType
|
||||
task_scope?: PermissionContext['task_scope']
|
||||
permission_profile?: PermissionContext['permission_profile']
|
||||
}
|
||||
|
||||
export interface ToolCallContext {
|
||||
@@ -30,73 +32,6 @@ export interface ToolCallContext {
|
||||
permission_context: PermissionContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Branching behavior per DD §9.3
|
||||
*/
|
||||
const ACTION_BRANCHES: Record<PermissionAction, (decision: PermissionDecision, call: ToolCall, ctx: ToolExecutionContext) => Promise<ToolResultEnvelope>> = {
|
||||
allow: async (_decision, call, ctx) => {
|
||||
// Execute directly
|
||||
const definition = global_tool_registry?.get(call.name)
|
||||
if (!definition) {
|
||||
return create_error_result(call.id, 'tool_not_found', 'Tool not found')
|
||||
}
|
||||
const executor = global_tool_registry?.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
return executor(call, ctx)
|
||||
},
|
||||
|
||||
deny: async (decision) => {
|
||||
return create_error_result('', 'permission_denied', decision.reason)
|
||||
},
|
||||
|
||||
prompt: async (_decision, _call, _ctx) => {
|
||||
// TODO: Integrate with UI for user prompt
|
||||
// For now, deny with prompt message
|
||||
return create_error_result('', 'user_prompt_required', 'User confirmation required')
|
||||
},
|
||||
|
||||
read_only: async (_decision, call, ctx) => {
|
||||
// Downgrade write operations to read-only
|
||||
const modified_call = this.downgrade_to_readonly(call)
|
||||
const definition = global_tool_registry?.get(call.name)
|
||||
const executor = global_tool_registry?.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
return executor(modified_call as ToolCall, ctx)
|
||||
},
|
||||
|
||||
sandbox: async (decision, call, ctx) => {
|
||||
// Execute in sandboxed mode with restricted environment
|
||||
const sandboxed_call = {
|
||||
...call,
|
||||
arguments: this.apply_sandbox_restrictions(call.arguments, decision.flags)
|
||||
}
|
||||
const executor = global_tool_registry?.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
return executor(sandboxed_call, ctx)
|
||||
},
|
||||
|
||||
audit_log: async (_decision, call, ctx) => {
|
||||
// Execute and log for audit
|
||||
const definition = global_tool_registry?.get(call.name)
|
||||
const executor = global_tool_registry?.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
const result = await executor(call, ctx)
|
||||
// Add audit flag to result
|
||||
return {
|
||||
...result,
|
||||
metadata: { ...result.metadata, audit_logged: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global tool registry (singleton)
|
||||
*/
|
||||
@@ -168,14 +103,9 @@ export class ToolRegistry {
|
||||
const decision = await this.permission_engine.evaluate(call, permission_context, definition)
|
||||
|
||||
// Step 5: Branch on permission action (DD §9.3)
|
||||
const branch = ACTION_BRANCHES[decision.action]
|
||||
if (!branch) {
|
||||
return create_error_result(call.id, 'invalid_decision', 'Invalid permission decision')
|
||||
}
|
||||
|
||||
// Step 6: Execute branch
|
||||
try {
|
||||
const result = await branch(decision, call, context)
|
||||
const result = await this.execute_branch(decision, call, context)
|
||||
|
||||
// Step 7: Record decision (if enabled)
|
||||
await this.permission_engine.record(decision)
|
||||
@@ -259,8 +189,8 @@ export class ToolRegistry {
|
||||
project_root: context.project_root,
|
||||
agent_type: context.agent_type,
|
||||
agent_id: context.agent_id,
|
||||
task_scope: undefined, // Would be loaded from task context
|
||||
permission_profile: undefined // Would be loaded from agent config
|
||||
task_scope: context.task_scope,
|
||||
permission_profile: context.permission_profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +235,59 @@ export class ToolRegistry {
|
||||
return restricted
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute branching behavior per DD §9.3.
|
||||
* Replaces the module-level ACTION_BRANCHES to fix `this` binding.
|
||||
*/
|
||||
private async execute_branch(
|
||||
decision: PermissionDecision,
|
||||
call: ToolCall,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolResultEnvelope> {
|
||||
switch (decision.action) {
|
||||
case 'allow': {
|
||||
const executor = this.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
return executor(call, ctx)
|
||||
}
|
||||
|
||||
case 'announce_then_run': {
|
||||
// Emit visible notice, then execute unless interrupted
|
||||
const executor = this.executors.get(call.name)
|
||||
if (!executor) {
|
||||
return create_error_result(call.id, 'executor_not_found', 'Executor not registered')
|
||||
}
|
||||
const result = await executor(call, ctx)
|
||||
return {
|
||||
...result,
|
||||
metadata: { ...result.metadata, announced: true },
|
||||
}
|
||||
}
|
||||
|
||||
case 'ask_user':
|
||||
// Suspend; emit permission.prompt.requested
|
||||
return create_error_result('', 'user_prompt_required', 'User confirmation required')
|
||||
|
||||
case 'deny':
|
||||
return create_error_result('', 'permission_denied', decision.reason)
|
||||
|
||||
case 'block': {
|
||||
// Return blocked outcome → task.blocked upstream
|
||||
return create_error_result(call.id, 'blocked', `Action blocked: ${decision.reason}`)
|
||||
}
|
||||
|
||||
case 'refuse': {
|
||||
// Return policy error; no execution
|
||||
return create_error_result(call.id, 'policy_error', `Refused: ${decision.reason}`)
|
||||
}
|
||||
|
||||
default:
|
||||
return create_error_result(call.id, 'invalid_decision', `Unknown action: ${decision.action}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute streaming tool.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import { spawn, execSync } from 'child_process'
|
||||
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'
|
||||
|
||||
export interface WorkerConfig {
|
||||
entrypoint: string // Path to worker main.ts
|
||||
@@ -28,6 +29,7 @@ export interface WorkerHandle {
|
||||
state: 'starting' | 'ready' | 'running' | 'completed' | 'error' | 'cancelled'
|
||||
started_at: string
|
||||
completed_at?: string
|
||||
result?: WorkerResult<unknown>
|
||||
}
|
||||
|
||||
export class WorkerManager {
|
||||
@@ -148,10 +150,41 @@ export class WorkerManager {
|
||||
return this.list().some(w => w.state === 'running' || w.state === 'ready' || w.state === 'starting')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored WorkerResult for an agent.
|
||||
*/
|
||||
get_result(agent_id: string): WorkerResult<unknown> | undefined {
|
||||
const handle = this.workers.get(agent_id)
|
||||
if (!handle) return undefined
|
||||
return handle.result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wrap a raw worker payload into a properly typed WorkerResult envelope.
|
||||
* Provides safe defaults for any missing fields.
|
||||
*/
|
||||
private wrap_worker_result(payload: Record<string, unknown>, handle: WorkerHandle): WorkerResult<unknown> {
|
||||
return {
|
||||
task_id: (payload.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',
|
||||
status: (payload.status as WorkerStatus) || 'completed',
|
||||
summary: (payload.summary as string) || '',
|
||||
changed_files: (payload.changed_files as string[]) || [],
|
||||
diff_ref: (payload.diff_ref as string | undefined) || undefined,
|
||||
artifacts: (payload.artifacts as any[]) || [],
|
||||
verification: (payload.verification as any[]) || [],
|
||||
risks: (payload.risks as any[]) || [],
|
||||
follow_up_tasks: (payload.follow_up_tasks as any[]) || [],
|
||||
evidence_refs: (payload.evidence_refs as any[]) || [],
|
||||
result: (payload.result as unknown) || null,
|
||||
}
|
||||
}
|
||||
|
||||
private async wait_for_handshake(proc: WorkerProcess, config: WorkerConfig): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type WorkerExitCode =
|
||||
| 1 // Error (unrecoverable)
|
||||
| 2 // Protocol error
|
||||
| 3 // Permission denied
|
||||
| 4 // Task blocked (needs intervention)
|
||||
| 4 // Parent cancelled
|
||||
| 5 // Timeout
|
||||
|
||||
interface ExitCodeInfo {
|
||||
@@ -27,7 +27,7 @@ const EXIT_CODE_TABLE: Record<WorkerExitCode, ExitCodeInfo> = {
|
||||
1: { semantic: 'error', description: 'Unrecoverable error occurred' },
|
||||
2: { semantic: 'protocol_error', description: 'Protocol violation or deserialization failure' },
|
||||
3: { semantic: 'permission_denied', description: 'Worker denied permission for operation' },
|
||||
4: { semantic: 'blocked', description: 'Task blocked, needs intervention' },
|
||||
4: { semantic: 'parent_cancelled', description: 'Parent process cancelled this worker' },
|
||||
5: { semantic: 'timeout', description: 'Worker exceeded time limit' }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user