import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { expertCreateSchema } from '../../shared/assistant-contracts' import type { AssistantArtifact, AssistantExpert, AssistantHeartbeatConfig, AssistantHeartbeatEntry, AssistantHeartbeatRun, AssistantMemory, AssistantProject, AssistantSchedule, AssistantTask, ConversationSnapshot, ExpertCreateInput, ExpertUpdateInput, HeartbeatCreateInput, HeartbeatSummaryOutput, HeartbeatUpdateInput, MemoryCreateInput, ModelUsageCallInput, ProjectCreateInput, ScheduleCreateInput, TokenUsageRecord, TokenUsageSummary } from '../../shared/assistant-contracts' import { computerControlErrorCodeSchema, computerControlRiskSchema, type ComputerControlErrorCode, type ComputerControlRisk } from '../../shared/computer-control-contracts' import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, repairAgentRuntimeSelection, type RuntimeSelectionRepairSettings } from '../../shared/runtime-selection-contracts' import type { ComputerControlAuditEvent } from '../computer-control/audit' import { computeNextHeartbeatRun } from './heartbeat-recurrence' type ProjectRow = { id: string name: string description: string root_path: string default_work_mode: ProjectCreateInput['defaultWorkMode'] status: AssistantProject['status'] created_at: string updated_at: string } type TaskRow = { id: string project_id: string | null conversation_id: string | null parent_task_id: string | null expert_id: string | null routing_mode: AssistantTask['routingMode'] | null title: string instructions: string origin: AssistantTask['origin'] status: AssistantTask['status'] progress: number | null created_at: string started_at: string | null completed_at: string | null error: string | null } type ConversationRow = { id: string project_id: string | null runtime_selection_json: string | null title: string updated_at: string } type MessageRow = { id: string conversation_id: string role: ConversationSnapshot['messages'][number]['role'] content: string state: ConversationSnapshot['messages'][number]['state'] metadata_json: string created_at: string } type MessageMetadata = { createdAt?: number status?: string tools?: ConversationSnapshot['messages'][number]['tools'] sources?: string[] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] artifactIds?: string[] attachments?: ConversationSnapshot['messages'][number]['attachments'] } function parseRuntimeSelection(value: string | null): | ConversationSnapshot['runtimeSelection'] | undefined { if (!value) { return undefined } try { const parsed = agentRuntimeSelectionSchema.safeParse( JSON.parse(value) ) return parsed.success ? parsed.data : undefined } catch { return undefined } } type ArtifactRow = { id: string project_id: string | null task_id: string | null kind: AssistantArtifact['kind'] title: string mime_type: string inline_content: string | null byte_size: number created_at: string updated_at: string } type MemoryRow = { id: string scope: AssistantMemory['scope'] scope_id: string | null type: AssistantMemory['type'] content: string confidence: number salience: number status: AssistantMemory['status'] created_at: string updated_at: string } type ScheduleRow = { id: string project_id: string | null task_template_json: string recurrence_json: string next_run_at: string enabled: number last_run_at: string | null created_at: string updated_at: string } type ExpertRow = { id: string name: string description: string system_instructions: string capability_policy_json: string model_policy_json: string enabled: number created_at: string updated_at: string } type HeartbeatConfigRow = { id: string project_id: string | null name: string timezone: string recurrence_json: string lookback_hours: number retention_days: number enabled: number next_run_at: string last_run_at: string | null last_status: AssistantHeartbeatRun['status'] | null created_at: string updated_at: string } type HeartbeatRunRow = { id: string config_id: string trigger: AssistantHeartbeatRun['trigger'] scheduled_for: string idempotency_key: string status: AssistantHeartbeatRun['status'] attempt_count: number next_attempt_at: string | null lease_owner: string | null lease_expires_at: string | null started_at: string | null completed_at: string | null error: string | null entry_id: string | null created_at: string updated_at: string } type HeartbeatEntryRow = { id: string config_id: string run_id: string scheduled_for: string summary: string highlights_json: string artifact_id: string | null proposed_memory_ids_json: string follow_up_task_ids_json: string created_at: string } type TokenUsageRecordRow = { request_id: string project_id: string | null project_name: string | null conversation_id: string | null conversation_title: string | null runtime: string provider: string model: string call_count: number input_tokens: number output_tokens: number cache_read_tokens: number cache_write_tokens: number } type ComputerControlActionRow = { command_id: string task_id: string conversation_id: string lease_id: string action: ComputerControlAuditEvent['action'] risk: ComputerControlRisk outcome: ComputerControlAuditEvent['outcome'] error_code: ComputerControlErrorCode | null occurred_at: number text_length: number | null text_digest: string | null } export type PersistedComputerControlAuditEvent = ComputerControlAuditEvent export type ClaimedHeartbeatRun = { config: AssistantHeartbeatConfig run: AssistantHeartbeatRun leaseOwner: string acquired: boolean } export type HeartbeatInputSnapshot = { conversations: Array<{ id: string title: string updatedAt: string messages: Array<{ role: 'user' | 'assistant' content: string createdAt: string }> }> tasks: Array<{ id: string title: string status: AssistantTask['status'] createdAt: string completedAt?: string }> confirmedMemories: Array<{ id: string type: AssistantMemory['type'] content: string scope: AssistantMemory['scope'] }> } function toProject(row: ProjectRow): AssistantProject { return { id: row.id, name: row.name, description: row.description, rootPath: row.root_path, defaultWorkMode: row.default_work_mode, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at } } function toTask(row: TaskRow): AssistantTask { return { id: row.id, projectId: row.project_id ?? undefined, conversationId: row.conversation_id ?? undefined, parentTaskId: row.parent_task_id ?? undefined, expertId: row.expert_id ?? undefined, routingMode: row.routing_mode ?? undefined, title: row.title, instructions: row.instructions, origin: row.origin, status: row.status, progress: row.progress ?? undefined, createdAt: row.created_at, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, error: row.error ?? undefined } } function toArtifact(row: ArtifactRow): AssistantArtifact { return { id: row.id, projectId: row.project_id ?? undefined, taskId: row.task_id ?? undefined, kind: row.kind, title: row.title, mimeType: row.mime_type, content: row.inline_content ?? undefined, byteSize: row.byte_size, createdAt: row.created_at, updatedAt: row.updated_at } } function toMemory(row: MemoryRow): AssistantMemory { return { id: row.id, scope: row.scope, scopeId: row.scope_id ?? undefined, type: row.type, content: row.content, confidence: row.confidence, salience: row.salience, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at } } function toSchedule(row: ScheduleRow): AssistantSchedule { const template = JSON.parse(row.task_template_json) as { title: string prompt: string workMode: AssistantSchedule['workMode'] } const recurrence = JSON.parse(row.recurrence_json) as { type: AssistantSchedule['recurrence'] } return { id: row.id, projectId: row.project_id ?? undefined, title: template.title, prompt: template.prompt, workMode: template.workMode, recurrence: recurrence.type, nextRunAt: row.next_run_at, enabled: row.enabled === 1, lastRunAt: row.last_run_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at } } function toExpert(row: ExpertRow): AssistantExpert { let routingKeywords: string[] let modelProfileId: string | undefined try { const policy = JSON.parse(row.capability_policy_json) as { routingKeywords?: unknown } routingKeywords = expertCreateSchema.parse({ name: row.name, description: row.description, systemInstructions: row.system_instructions, routingKeywords: Array.isArray(policy.routingKeywords) ? policy.routingKeywords : [] }).routingKeywords } catch { routingKeywords = [] } try { const policy = JSON.parse(row.model_policy_json) as { modelProfileId?: unknown } modelProfileId = expertCreateSchema .pick({ modelProfileId: true }) .parse({ modelProfileId: policy.modelProfileId }).modelProfileId } catch { modelProfileId = undefined } return { id: row.id, name: row.name, description: row.description, systemInstructions: row.system_instructions, ...(modelProfileId ? { modelProfileId } : {}), routingKeywords, enabled: row.enabled === 1, createdAt: row.created_at, updatedAt: row.updated_at } } function toHeartbeatConfig( row: HeartbeatConfigRow ): AssistantHeartbeatConfig { return { id: row.id, projectId: row.project_id ?? undefined, name: row.name, timezone: row.timezone, recurrence: JSON.parse( row.recurrence_json ) as AssistantHeartbeatConfig['recurrence'], enabled: row.enabled === 1, lookbackHours: row.lookback_hours, retentionDays: row.retention_days, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at ?? undefined, lastStatus: row.last_status ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at } } function toHeartbeatRun(row: HeartbeatRunRow): AssistantHeartbeatRun { return { id: row.id, configId: row.config_id, trigger: row.trigger, scheduledFor: row.scheduled_for, status: row.status, attemptCount: row.attempt_count, nextAttemptAt: row.next_attempt_at ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, error: row.error ?? undefined, entryId: row.entry_id ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at } } function toHeartbeatEntry( row: HeartbeatEntryRow ): AssistantHeartbeatEntry { return { id: row.id, configId: row.config_id, runId: row.run_id, scheduledFor: row.scheduled_for, summary: row.summary, highlights: JSON.parse(row.highlights_json) as string[], artifactId: row.artifact_id ?? undefined, proposedMemoryIds: JSON.parse( row.proposed_memory_ids_json ) as string[], followUpTaskIds: JSON.parse( row.follow_up_task_ids_json ) as string[], createdAt: row.created_at } } function validateUsageText( value: string, label: string, maximumLength: number ): string { if (typeof value !== 'string') { throw new TypeError(`${label} must be a string`) } const normalized = value.trim() if ( normalized.length === 0 || normalized.length > maximumLength ) { throw new RangeError( `${label} must contain between 1 and ${maximumLength} characters` ) } return normalized } function validateTokenCount(value: number, label: string): number { if (!Number.isSafeInteger(value) || value < 0) { throw new RangeError( `${label} must be a nonnegative safe integer` ) } return value } const computerControlActions = [ 'observe', 'activate', 'replace_text', 'select_option', 'scroll' ] as const const computerControlOutcomes = [ 'completed', 'denied', 'failed', 'outcome_unknown' ] as const function validateComputerControlId( value: string, label: string, maximumLength: number, opaque = false ): string { if (typeof value !== 'string') { throw new TypeError(`${label} must be a string`) } if ( value.length < (opaque ? 16 : 1) || value.length > maximumLength || (opaque ? !/^[A-Za-z0-9_-]+$/.test(value) : [...value].some((character) => { const code = character.charCodeAt(0) return code <= 31 || code === 127 })) ) { throw new RangeError(`Invalid ${label}`) } return value } function validateComputerControlEnum( value: string, label: string, allowed: readonly T[] ): T { if (!allowed.includes(value as T)) { throw new RangeError(`Invalid ${label}`) } return value as T } function toComputerControlAuditEvent( row: ComputerControlActionRow ): PersistedComputerControlAuditEvent { return { timestamp: row.occurred_at, taskId: row.task_id, conversationId: row.conversation_id, leaseId: row.lease_id, commandId: row.command_id, action: row.action, risk: row.risk, outcome: row.outcome, ...(row.error_code ? { errorCode: row.error_code } : {}), ...(row.text_length === null ? {} : { textLength: row.text_length }), ...(row.text_digest === null ? {} : { textDigest: row.text_digest }) } } const interruptedTaskError = '应用退出时任务仍在运行' const interruptedMessageStatus = '上次运行意外中断,可以重新发送问题' function interruptActiveTools( tools: MessageMetadata['tools'] ): MessageMetadata['tools'] { return tools?.map((tool) => tool.state === 'pending' || tool.state === 'running' ? { ...tool, state: 'interrupted' as const } : tool ) } export class AssistantDatabase { private database?: DatabaseSync constructor(private readonly databasePath: string) {} initialize(defaultRootPath: string): void { if (this.database) { return } const database = new DatabaseSync(this.databasePath, { enableForeignKeyConstraints: true, timeout: 5_000 }) try { database.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; `) this.migrate(database) this.database = database const count = database .prepare('SELECT COUNT(*) AS count FROM projects') .get() as { count: number } if (count.count === 0) { this.createProject({ name: '默认项目', description: 'GoodBuddy 默认工作区', rootPath: defaultRootPath, defaultWorkMode: 'ask' }) } const expertCount = database .prepare('SELECT COUNT(*) AS count FROM experts') .get() as { count: number } if (expertCount.count === 0) { this.createExpert({ name: '研究分析专家', description: '负责资料分析、证据整理和结论验证', systemInstructions: 'Act as a rigorous research analyst. Separate evidence, assumptions, and conclusions. Cite provided sources and identify uncertainty.', routingKeywords: [ '研究', '调研', '分析证据', '资料分析', 'research', 'evidence', 'investigate' ] }) this.createExpert({ name: '文档写作专家', description: '负责结构化写作、编辑和内容润色', systemInstructions: 'Act as a professional document editor. Produce clear structure, concise language, and actionable content appropriate to the user context.', routingKeywords: [ '写作', '撰写', '润色', '文档', 'write', 'draft', 'edit' ] }) this.createExpert({ name: '项目规划专家', description: '负责目标拆解、风险分析和执行计划', systemInstructions: 'Act as a project planning specialist. Decompose goals into verifiable steps, dependencies, risks, owners, and acceptance criteria.', routingKeywords: [ '规划', '计划', '拆解', '里程碑', 'plan', 'roadmap', 'milestone' ] }) } const recoveredAt = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { const interruptedTasks = database .prepare( `SELECT id, error FROM tasks WHERE status IN ('running', 'waiting_approval')` ) .all() as Array<{ id: string; error: string | null }> const updateTask = database.prepare( `UPDATE tasks SET status = 'interrupted', completed_at = ?, error = ? WHERE id = ?` ) const insertTaskEvent = database.prepare( `INSERT INTO task_events (task_id, run_id, kind, payload_json, created_at) VALUES (?, NULL, 'status', ?, ?)` ) for (const task of interruptedTasks) { const error = task.error ?? interruptedTaskError updateTask.run(recoveredAt, error, task.id) insertTaskEvent.run( task.id, JSON.stringify({ status: 'interrupted', error }), recoveredAt ) } const recoverableMessages = database .prepare( `SELECT id, state, metadata_json FROM messages` ) .all() as Array<{ id: string state: MessageRow['state'] metadata_json: string }> const updateMessage = database.prepare( `UPDATE messages SET state = ?, metadata_json = ? WHERE id = ?` ) for (const message of recoverableMessages) { const metadata = JSON.parse( message.metadata_json ) as MessageMetadata const hasActiveTool = Boolean( metadata.tools?.some( (tool) => tool.state === 'pending' || tool.state === 'running' ) ) if (message.state !== 'streaming' && !hasActiveTool) { continue } updateMessage.run( message.state === 'streaming' ? 'error' : message.state, JSON.stringify({ ...metadata, status: message.state === 'streaming' ? interruptedMessageStatus : metadata.status, tools: interruptActiveTools(metadata.tools) }), message.id ) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } catch (error) { database.close() throw error } } close(): void { this.database?.close() this.database = undefined } clearAssistantData(): void { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') try { for (const table of [ 'heartbeat_configs', 'delegation_outbox', 'delegations', 'notifications', 'schedule_runs', 'schedules', 'memory_items', 'artifacts', 'computer_control_actions', 'task_events', 'runs', 'model_usage_calls', 'tasks', 'messages', 'conversations' ]) { database.exec(`DELETE FROM ${table}`) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listProjects(includeArchived = false): AssistantProject[] { const database = this.requireDatabase() const rows = database .prepare( includeArchived ? 'SELECT * FROM projects ORDER BY updated_at DESC' : `SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC` ) .all() as ProjectRow[] return rows.map(toProject) } createProject(input: ProjectCreateInput): AssistantProject { const database = this.requireDatabase() const id = randomUUID() const now = new Date().toISOString() database .prepare( `INSERT INTO projects (id, name, description, root_path, default_work_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` ) .run( id, input.name, input.description, input.rootPath, input.defaultWorkMode, now, now ) return this.getProject(id) } updateProject( projectId: string, input: ProjectCreateInput ): AssistantProject { const database = this.requireDatabase() const result = database .prepare( `UPDATE projects SET name = ?, description = ?, root_path = ?, default_work_mode = ?, updated_at = ? WHERE id = ?` ) .run( input.name, input.description, input.rootPath, input.defaultWorkMode, new Date().toISOString(), projectId ) if (result.changes !== 1) { throw new Error('项目不存在') } return this.getProject(projectId) } setProjectArchived(projectId: string, archived: boolean): void { const database = this.requireDatabase() const result = database .prepare( `UPDATE projects SET status = ?, updated_at = ? WHERE id = ?` ) .run( archived ? 'archived' : 'active', new Date().toISOString(), projectId ) if (result.changes !== 1) { throw new Error('项目不存在') } } listConversations(): ConversationSnapshot[] { const database = this.requireDatabase() const conversations = database .prepare( `SELECT id, project_id, runtime_selection_json, title, updated_at FROM conversations WHERE status = 'active' ORDER BY updated_at DESC LIMIT 100` ) .all() as ConversationRow[] const messageStatement = database.prepare( `SELECT id, conversation_id, role, content, state, metadata_json, created_at FROM messages WHERE conversation_id = ? ORDER BY sequence ASC LIMIT 500` ) return conversations.map((conversation) => ({ id: conversation.id, projectId: conversation.project_id ?? undefined, runtimeSelection: parseRuntimeSelection( conversation.runtime_selection_json ), title: conversation.title, updatedAt: Date.parse(conversation.updated_at), messages: ( messageStatement.all(conversation.id) as MessageRow[] ).map((message) => { const metadata = JSON.parse( message.metadata_json ) as MessageMetadata const interrupted = message.state === 'streaming' return { id: message.id, role: message.role, content: message.content, createdAt: metadata.createdAt ?? Date.parse(message.created_at), state: interrupted ? ('error' as const) : message.state, status: interrupted ? interruptedMessageStatus : metadata.status, tools: interrupted ? interruptActiveTools(metadata.tools) : metadata.tools, sources: metadata.sources, sourceReferences: metadata.sourceReferences, artifactIds: metadata.artifactIds, attachments: metadata.attachments } }) })) } repairConversationRuntimeSelections( settings: RuntimeSelectionRepairSettings ): number { const database = this.requireDatabase() const conversations = database .prepare( `SELECT id, runtime_selection_json FROM conversations WHERE runtime_selection_json IS NOT NULL` ) .all() as Array<{ id: string runtime_selection_json: string }> const update = database.prepare( `UPDATE conversations SET runtime_selection_json = ? WHERE id = ?` ) let repaired = 0 database.exec('BEGIN IMMEDIATE') try { for (const conversation of conversations) { const current = parseRuntimeSelection( conversation.runtime_selection_json ) if (!current) { continue } const next = repairAgentRuntimeSelection(current, settings) if ( agentRuntimeSelectionKey(next) === agentRuntimeSelectionKey(current) ) { continue } update.run(JSON.stringify(next), conversation.id) repaired += 1 } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return repaired } replaceConversations( conversations: ConversationSnapshot[] ): void { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') try { database.exec('DELETE FROM messages; DELETE FROM conversations;') const insertConversation = database.prepare( `INSERT INTO conversations (id, project_id, runtime_selection_json, work_mode, title, status, created_at, updated_at) VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)` ) const insertMessage = database.prepare( `INSERT INTO messages (id, conversation_id, request_id, role, content, state, sequence, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)` ) for (const conversation of conversations.slice(0, 100)) { const updatedAt = new Date(conversation.updatedAt).toISOString() insertConversation.run( conversation.id, conversation.projectId ?? null, conversation.runtimeSelection ? JSON.stringify(conversation.runtimeSelection) : null, conversation.title, updatedAt, updatedAt ) for (const [sequence, message] of conversation.messages .slice(-500) .entries()) { insertMessage.run( message.id, conversation.id, message.role, message.content, message.state, sequence, JSON.stringify({ createdAt: message.createdAt, status: message.status, tools: message.tools, sources: message.sources, sourceReferences: message.sourceReferences, artifactIds: message.artifactIds, attachments: message.attachments }), new Date(message.createdAt).toISOString() ) } } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listPendingDelegationResults(): Array<{ taskId: string result: { status: 'completed' | 'failed' output?: string error?: string } }> { const rows = this.requireDatabase() .prepare( `SELECT task_id, result_json FROM delegation_outbox WHERE status = 'pending' ORDER BY created_at LIMIT 100` ) .all() as Array<{ task_id: string; result_json: string }> return rows.map((row) => ({ taskId: row.task_id, result: JSON.parse(row.result_json) as { status: 'completed' | 'failed' output?: string error?: string } })) } getDelegationDeliveryStatus( taskId: string ): 'pending' | 'delivered' | undefined { const row = this.requireDatabase() .prepare( `SELECT status FROM delegation_outbox WHERE task_id = ?` ) .get(taskId) as { status: 'pending' | 'delivered' } | undefined return row?.status } saveDelegationResult( taskId: string, result: { status: 'completed' | 'failed' output?: string error?: string } ): void { const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO delegation_outbox (task_id, result_json, status, created_at, updated_at) VALUES (?, ?, 'pending', ?, ?) ON CONFLICT(task_id) DO UPDATE SET result_json = excluded.result_json, status = 'pending', updated_at = excluded.updated_at` ) .run(taskId, JSON.stringify(result), now, now) } markDelegationDelivered(taskId: string): void { const database = this.requireDatabase() database .prepare( `UPDATE delegation_outbox SET status = 'delivered', result_json = '{}', updated_at = ? WHERE task_id = ?` ) .run(new Date().toISOString(), taskId) database .prepare( `DELETE FROM delegation_outbox WHERE task_id IN ( SELECT task_id FROM delegation_outbox WHERE status = 'delivered' ORDER BY updated_at DESC LIMIT -1 OFFSET 1000 )` ) .run() } listTasks(limit = 100): AssistantTask[] { const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit))) const rows = this.requireDatabase() .prepare( `SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?` ) .all(safeLimit) as TaskRow[] return rows.map(toTask) } createTask(input: { id: string projectId?: string conversationId?: string parentTaskId?: string expertId?: string routingMode?: AssistantTask['routingMode'] title: string instructions: string workMode: 'ask' | 'plan' | 'execute' origin?: AssistantTask['origin'] status?: 'queued' | 'running' }): AssistantTask { const now = new Date().toISOString() const status = input.status ?? 'running' this.requireDatabase() .prepare( `INSERT INTO tasks (id, project_id, conversation_id, parent_task_id, expert_id, routing_mode, title, instructions, origin, status, priority, work_mode, progress, created_at, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)` ) .run( input.id, input.projectId ?? null, input.conversationId ?? null, input.parentTaskId ?? null, input.expertId ?? null, input.routingMode ?? null, input.title, input.instructions, input.origin ?? 'user', status, input.workMode, now, status === 'running' ? now : null ) this.appendTaskEvent(input.id, status, { workMode: input.workMode }) return this.getTask(input.id) } upsertModelUsageCall(input: ModelUsageCallInput): void { const requestId = validateUsageText( input.requestId, 'requestId', 256 ) const callId = validateUsageText(input.callId, 'callId', 256) const runtime = validateUsageText(input.runtime, 'runtime', 100) const provider = validateUsageText( input.provider, 'provider', 100 ) const model = validateUsageText(input.model, 'model', 500) const inputTokens = validateTokenCount(input.input, 'input') const outputTokens = validateTokenCount(input.output, 'output') const cacheReadTokens = validateTokenCount( input.cacheRead, 'cacheRead' ) const cacheWriteTokens = validateTokenCount( input.cacheWrite, 'cacheWrite' ) const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO model_usage_calls (request_id, call_id, runtime, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(request_id, call_id) DO UPDATE SET runtime = excluded.runtime, provider = excluded.provider, model = excluded.model, input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens, cache_read_tokens = excluded.cache_read_tokens, cache_write_tokens = excluded.cache_write_tokens, updated_at = excluded.updated_at` ) .run( requestId, callId, runtime, provider, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, now, now ) } getTokenUsageSummary(): TokenUsageSummary { const rows = this.requireDatabase() .prepare( `SELECT usage.request_id, tasks.project_id, projects.name AS project_name, tasks.conversation_id, conversations.title AS conversation_title, usage.runtime, usage.provider, usage.model, COUNT(*) AS call_count, SUM(usage.input_tokens) AS input_tokens, SUM(usage.output_tokens) AS output_tokens, SUM(usage.cache_read_tokens) AS cache_read_tokens, SUM(usage.cache_write_tokens) AS cache_write_tokens FROM model_usage_calls usage JOIN tasks ON tasks.id = usage.request_id LEFT JOIN projects ON projects.id = tasks.project_id LEFT JOIN conversations ON conversations.id = tasks.conversation_id GROUP BY usage.request_id, tasks.project_id, projects.name, tasks.conversation_id, conversations.title, usage.runtime, usage.provider, usage.model ORDER BY MAX(usage.updated_at) DESC LIMIT 500` ) .all() as TokenUsageRecordRow[] const records: TokenUsageRecord[] = rows.map((row) => ({ requestId: row.request_id, projectId: row.project_id ?? undefined, projectName: row.project_name ?? undefined, conversationId: row.conversation_id ?? undefined, conversationTitle: row.conversation_title ?? undefined, runtime: row.runtime, provider: row.provider, model: row.model, callCount: row.call_count, input: row.input_tokens, output: row.output_tokens, cacheRead: row.cache_read_tokens, cacheWrite: row.cache_write_tokens, totalTokens: row.input_tokens + row.output_tokens })) const totalRow = this.requireDatabase() .prepare( `SELECT COUNT(*) AS call_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens FROM model_usage_calls` ) .get() as { call_count: number input_tokens: number output_tokens: number cache_read_tokens: number cache_write_tokens: number } const totals = { callCount: totalRow.call_count, input: totalRow.input_tokens, output: totalRow.output_tokens, cacheRead: totalRow.cache_read_tokens, cacheWrite: totalRow.cache_write_tokens, totalTokens: totalRow.input_tokens + totalRow.output_tokens } return { totals, records } } updateTaskStatus( taskId: string, status: AssistantTask['status'], error?: string ): void { const terminal = [ 'completed', 'failed', 'cancelled', 'interrupted' ].includes(status) const result = this.requireDatabase() .prepare( `UPDATE tasks SET status = ?, error = ?, started_at = CASE WHEN ? = 'running' AND started_at IS NULL THEN ? ELSE started_at END, completed_at = CASE WHEN ? THEN ? ELSE completed_at END WHERE id = ?` ) .run( status, error ?? null, status, new Date().toISOString(), terminal ? 1 : 0, new Date().toISOString(), taskId ) if (result.changes !== 1) { throw new Error('任务不存在') } this.appendTaskEvent(taskId, 'status', { status, error }) } resolveAssistantSuggestionTask( taskId: string, status: 'completed' | 'cancelled' ): void { const completedAt = new Date().toISOString() const result = this.requireDatabase() .prepare( `UPDATE tasks SET status = ?, error = NULL, completed_at = ? WHERE id = ? AND origin = 'assistant' AND status = 'paused'` ) .run(status, completedAt, taskId) if (result.changes !== 1) { throw new Error('待处理的智能心跳建议不存在或状态已变化') } this.appendTaskEvent(taskId, 'status', { status }) } appendTaskEvent( taskId: string, kind: string, payload: unknown ): void { this.requireDatabase() .prepare( `INSERT INTO task_events (task_id, run_id, kind, payload_json, created_at) VALUES (?, NULL, ?, ?, ?)` ) .run( taskId, kind.slice(0, 64), JSON.stringify(payload), new Date().toISOString() ) } persistComputerControlAudit( event: ComputerControlAuditEvent ): void { if (!event || typeof event !== 'object') { throw new TypeError('Computer control audit event is required') } const taskId = validateComputerControlId( event.taskId, 'taskId', 128 ) const conversationId = validateComputerControlId( event.conversationId, 'conversationId', 128 ) const leaseId = validateComputerControlId( event.leaseId, 'leaseId', 160, true ) const commandId = validateComputerControlId( event.commandId, 'commandId', 160, true ) const action = validateComputerControlEnum( event.action, 'action', computerControlActions ) const risk = computerControlRiskSchema.parse(event.risk) const outcome = validateComputerControlEnum( event.outcome, 'outcome', computerControlOutcomes ) if ( !Number.isSafeInteger(event.timestamp) || event.timestamp < 0 ) { throw new RangeError('Invalid timestamp') } const errorCode = event.errorCode === undefined ? undefined : computerControlErrorCodeSchema.parse(event.errorCode) if ( (outcome === 'completed' && errorCode !== undefined) || (outcome !== 'completed' && errorCode === undefined) ) { throw new RangeError('Invalid outcome and errorCode combination') } const hasTextMetadata = event.textLength !== undefined || event.textDigest !== undefined if ( (action === 'replace_text') !== hasTextMetadata || (hasTextMetadata && (!Number.isInteger(event.textLength) || event.textLength! < 0 || event.textLength! > 4_096 || typeof event.textDigest !== 'string' || !/^[0-9a-f]{64}$/.test(event.textDigest))) ) { throw new RangeError('Invalid redacted text metadata') } const database = this.requireDatabase() const createdAt = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { const inserted = database .prepare( `INSERT OR IGNORE INTO computer_control_actions (command_id, task_id, conversation_id, lease_id, action, risk, outcome, error_code, occurred_at, text_length, text_digest, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( commandId, taskId, conversationId, leaseId, action, risk, outcome, errorCode ?? null, event.timestamp, event.textLength ?? null, event.textDigest ?? null, createdAt ) if (inserted.changes === 1) { database .prepare( `INSERT INTO task_events (task_id, run_id, kind, payload_json, created_at) VALUES (?, NULL, 'computer_control', ?, ?)` ) .run( taskId, JSON.stringify({ commandId, action, risk, outcome, ...(errorCode ? { errorCode } : {}), ...(event.textLength === undefined ? {} : { textLength: event.textLength }), ...(event.textDigest === undefined ? {} : { textDigest: event.textDigest }) }), createdAt ) database .prepare( `DELETE FROM computer_control_actions WHERE command_id IN ( SELECT command_id FROM computer_control_actions ORDER BY occurred_at DESC, created_at DESC LIMIT -1 OFFSET 10000 )` ) .run() } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listRecentComputerControlAudit( limit = 100 ): PersistedComputerControlAuditEvent[] { if (!Number.isInteger(limit) || limit < 1 || limit > 500) { throw new RangeError( 'Computer control audit limit must be between 1 and 500' ) } const rows = this.requireDatabase() .prepare( `SELECT command_id, task_id, conversation_id, lease_id, action, risk, outcome, error_code, occurred_at, text_length, text_digest FROM computer_control_actions ORDER BY occurred_at DESC, created_at DESC LIMIT ?` ) .all(limit) as ComputerControlActionRow[] return rows.map(toComputerControlAuditEvent) } listArtifacts(projectId?: string, limit = 100): AssistantArtifact[] { const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit))) const columns = `id, project_id, task_id, kind, title, mime_type, CASE WHEN kind = 'image' THEN NULL ELSE inline_content END AS inline_content, byte_size, created_at, updated_at` const rows = projectId ? this.requireDatabase() .prepare( `SELECT ${columns} FROM artifacts WHERE project_id = ? ORDER BY created_at DESC LIMIT ?` ) .all(projectId, safeLimit) : this.requireDatabase() .prepare( `SELECT ${columns} FROM artifacts ORDER BY created_at DESC LIMIT ?` ) .all(safeLimit) return (rows as ArtifactRow[]).map(toArtifact) } getArtifact(artifactId: string): AssistantArtifact { const row = this.requireDatabase() .prepare( `SELECT id, project_id, task_id, kind, title, mime_type, inline_content, byte_size, created_at, updated_at FROM artifacts WHERE id = ?` ) .get(artifactId) as ArtifactRow | undefined if (!row) { throw new Error('成果不存在') } return toArtifact(row) } createTextArtifact(input: { projectId?: string taskId?: string title: string content: string }): AssistantArtifact { return this.createInlineArtifact({ ...input, kind: 'markdown', mimeType: 'text/markdown' }) } createImageArtifact(input: { projectId?: string taskId?: string title: string mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' base64: string }): AssistantArtifact { return this.createInlineArtifact({ projectId: input.projectId, taskId: input.taskId, kind: 'image', title: input.title, mimeType: input.mimeType, content: `data:${input.mimeType};base64,${input.base64}` }) } createInlineArtifact(input: { projectId?: string taskId?: string kind: AssistantArtifact['kind'] title: string mimeType: string content: string }): AssistantArtifact { const id = randomUUID() const now = new Date().toISOString() const byteSize = Buffer.byteLength(input.content) if (byteSize > 5 * 1024 * 1024) { throw new Error('成果内容超过 5MB 限制') } this.requireDatabase() .prepare( `INSERT INTO artifacts (id, project_id, task_id, run_id, kind, title, mime_type, storage_kind, storage_path, inline_content, checksum, byte_size, preview_json, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?, ?, 'inline', NULL, ?, NULL, ?, '{}', ?, ?)` ) .run( id, input.projectId ?? null, input.taskId ?? null, input.kind, input.title.slice(0, 240), input.mimeType.slice(0, 128), input.content, byteSize, now, now ) const row = this.requireDatabase() .prepare('SELECT * FROM artifacts WHERE id = ?') .get(id) as ArtifactRow return toArtifact(row) } listMemories(scopeId?: string): AssistantMemory[] { const rows = scopeId ? this.requireDatabase() .prepare( `SELECT * FROM memory_items WHERE scope = 'global' OR scope_id = ? ORDER BY status = 'confirmed' DESC, updated_at DESC LIMIT 500` ) .all(scopeId) : this.requireDatabase() .prepare( `SELECT * FROM memory_items ORDER BY status = 'confirmed' DESC, updated_at DESC LIMIT 500` ) .all() return (rows as MemoryRow[]).map(toMemory) } createMemory(input: MemoryCreateInput): AssistantMemory { if (input.scope !== 'global' && !input.scopeId) { throw new Error('项目或会话记忆必须指定作用域') } const id = randomUUID() const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO memory_items (id, scope, scope_id, type, content, source_conversation_id, source_message_id, confidence, salience, status, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NULL, NULL, 1, 1, 'confirmed', NULL, ?, ?)` ) .run( id, input.scope, input.scopeId ?? null, input.type, input.content, now, now ) return this.getMemory(id) } setMemoryStatus( memoryId: string, status: AssistantMemory['status'] ): void { const result = this.requireDatabase() .prepare( `UPDATE memory_items SET status = ?, updated_at = ? WHERE id = ?` ) .run(status, new Date().toISOString(), memoryId) if (result.changes !== 1) { throw new Error('记忆不存在') } } removeMemory(memoryId: string): void { const result = this.requireDatabase() .prepare('DELETE FROM memory_items WHERE id = ?') .run(memoryId) if (result.changes !== 1) { throw new Error('记忆不存在') } } listSchedules(projectId?: string): AssistantSchedule[] { const rows = projectId ? this.requireDatabase() .prepare( `SELECT * FROM schedules WHERE project_id = ? ORDER BY next_run_at` ) .all(projectId) : this.requireDatabase() .prepare('SELECT * FROM schedules ORDER BY next_run_at') .all() return (rows as ScheduleRow[]).map(toSchedule) } createSchedule(input: ScheduleCreateInput): AssistantSchedule { const id = randomUUID() const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO schedules (id, project_id, task_template_json, timezone, recurrence_json, next_run_at, missed_run_policy, enabled, last_run_at, created_at, updated_at) VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)` ) .run( id, input.projectId ?? null, JSON.stringify({ title: input.title, prompt: input.prompt, workMode: input.workMode }), JSON.stringify({ type: input.recurrence }), input.nextRunAt, now, now ) return this.getSchedule(id) } setScheduleEnabled(scheduleId: string, enabled: boolean): void { const result = this.requireDatabase() .prepare( `UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?` ) .run(enabled ? 1 : 0, new Date().toISOString(), scheduleId) if (result.changes !== 1) { throw new Error('定时任务不存在') } } removeSchedule(scheduleId: string): void { const result = this.requireDatabase() .prepare('DELETE FROM schedules WHERE id = ?') .run(scheduleId) if (result.changes !== 1) { throw new Error('定时任务不存在') } } claimDueSchedules(now = new Date()): AssistantSchedule[] { const database = this.requireDatabase() const due = ( database .prepare( `SELECT * FROM schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at LIMIT 1` ) .all(now.toISOString()) as ScheduleRow[] ).map(toSchedule) for (const schedule of due) { const next = new Date(schedule.nextRunAt) if (schedule.recurrence === 'daily') { const intervals = Math.floor( (now.getTime() - next.getTime()) / (24 * 60 * 60 * 1_000) ) + 1 next.setUTCDate(next.getUTCDate() + intervals) } else if (schedule.recurrence === 'weekly') { const intervals = Math.floor( (now.getTime() - next.getTime()) / (7 * 24 * 60 * 60 * 1_000) ) + 1 next.setUTCDate(next.getUTCDate() + intervals * 7) } database .prepare( `UPDATE schedules SET enabled = ?, next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ? AND next_run_at = ?` ) .run( schedule.recurrence === 'once' ? 0 : 1, schedule.recurrence === 'once' ? schedule.nextRunAt : next.toISOString(), now.toISOString(), now.toISOString(), schedule.id, schedule.nextRunAt ) } return due } claimScheduleNow(scheduleId: string): AssistantSchedule { const schedule = this.getSchedule(scheduleId) const now = new Date() this.requireDatabase() .prepare( `UPDATE schedules SET last_run_at = ?, updated_at = ? WHERE id = ?` ) .run(now.toISOString(), now.toISOString(), scheduleId) return schedule } listHeartbeatConfigs(projectId?: string): AssistantHeartbeatConfig[] { const rows = projectId ? this.requireDatabase() .prepare( `SELECT * FROM heartbeat_configs WHERE project_id = ? ORDER BY created_at DESC LIMIT 100` ) .all(projectId) : this.requireDatabase() .prepare( `SELECT * FROM heartbeat_configs ORDER BY created_at DESC LIMIT 100` ) .all() return (rows as HeartbeatConfigRow[]).map(toHeartbeatConfig) } getHeartbeatConfig(configId: string): AssistantHeartbeatConfig { const row = this.requireDatabase() .prepare('SELECT * FROM heartbeat_configs WHERE id = ?') .get(configId) as HeartbeatConfigRow | undefined if (!row) { throw new Error('Heartbeat configuration not found') } return toHeartbeatConfig(row) } createHeartbeatConfig( input: HeartbeatCreateInput, now = new Date() ): AssistantHeartbeatConfig { const id = randomUUID() const timestamp = now.toISOString() const nextRunAt = computeNextHeartbeatRun( input.recurrence, input.timezone, now ).toISOString() this.requireDatabase() .prepare( `INSERT INTO heartbeat_configs (id, project_id, name, timezone, recurrence_json, lookback_hours, retention_days, enabled, next_run_at, last_run_at, last_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)` ) .run( id, input.projectId ?? null, input.name, input.timezone, JSON.stringify(input.recurrence), input.lookbackHours, input.retentionDays, input.enabled ? 1 : 0, nextRunAt, timestamp, timestamp ) return this.getHeartbeatConfig(id) } updateHeartbeatConfig( configId: string, input: HeartbeatUpdateInput, now = new Date() ): AssistantHeartbeatConfig { const timestamp = now.toISOString() const nextRunAt = computeNextHeartbeatRun( input.recurrence, input.timezone, now ).toISOString() const result = this.requireDatabase() .prepare( `UPDATE heartbeat_configs SET project_id = ?, name = ?, timezone = ?, recurrence_json = ?, lookback_hours = ?, retention_days = ?, enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?` ) .run( input.projectId ?? null, input.name, input.timezone, JSON.stringify(input.recurrence), input.lookbackHours, input.retentionDays, input.enabled ? 1 : 0, nextRunAt, timestamp, configId ) if (result.changes !== 1) { throw new Error('Heartbeat configuration not found') } return this.getHeartbeatConfig(configId) } setHeartbeatPaused(configId: string, paused: boolean): void { const result = this.requireDatabase() .prepare( `UPDATE heartbeat_configs SET enabled = ?, updated_at = ? WHERE id = ?` ) .run(paused ? 0 : 1, new Date().toISOString(), configId) if (result.changes !== 1) { throw new Error('Heartbeat configuration not found') } } removeHeartbeatConfig(configId: string): void { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') try { const artifactRows = database .prepare( `SELECT artifact_id FROM heartbeat_entries WHERE config_id = ? AND artifact_id IS NOT NULL` ) .all(configId) as Array<{ artifact_id: string }> const result = database .prepare('DELETE FROM heartbeat_configs WHERE id = ?') .run(configId) if (result.changes !== 1) { throw new Error('Heartbeat configuration not found') } const deleteArtifact = database.prepare( 'DELETE FROM artifacts WHERE id = ?' ) for (const row of artifactRows) { deleteArtifact.run(row.artifact_id) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listHeartbeatRuns( configId?: string, limit = 50 ): AssistantHeartbeatRun[] { const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit))) const rows = configId ? this.requireDatabase() .prepare( `SELECT * FROM heartbeat_runs WHERE config_id = ? ORDER BY created_at DESC LIMIT ?` ) .all(configId, safeLimit) : this.requireDatabase() .prepare( `SELECT * FROM heartbeat_runs ORDER BY created_at DESC LIMIT ?` ) .all(safeLimit) return (rows as HeartbeatRunRow[]).map(toHeartbeatRun) } getHeartbeatRun(runId: string): AssistantHeartbeatRun { const row = this.requireDatabase() .prepare('SELECT * FROM heartbeat_runs WHERE id = ?') .get(runId) as HeartbeatRunRow | undefined if (!row) { throw new Error('Heartbeat run not found') } return toHeartbeatRun(row) } listHeartbeatEntries( configId?: string, limit = 50 ): AssistantHeartbeatEntry[] { const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit))) const rows = configId ? this.requireDatabase() .prepare( `SELECT * FROM heartbeat_entries WHERE config_id = ? ORDER BY created_at DESC LIMIT ?` ) .all(configId, safeLimit) : this.requireDatabase() .prepare( `SELECT * FROM heartbeat_entries ORDER BY created_at DESC LIMIT ?` ) .all(safeLimit) return (rows as HeartbeatEntryRow[]).map(toHeartbeatEntry) } claimDueHeartbeats( leaseOwner: string, now = new Date(), leaseMilliseconds = 5 * 60_000 ): ClaimedHeartbeatRun[] { const database = this.requireDatabase() const nowIso = now.toISOString() const leaseExpiresAt = new Date( now.getTime() + leaseMilliseconds ).toISOString() const claimed: ClaimedHeartbeatRun[] = [] database.exec('BEGIN IMMEDIATE') try { const retryRows = database .prepare( `SELECT r.id AS run_id, r.config_id, r.attempt_count FROM heartbeat_runs r JOIN heartbeat_configs c ON c.id = r.config_id WHERE c.enabled = 1 AND r.attempt_count < 3 AND ( (r.status = 'failed' AND r.next_attempt_at <= ?) OR (r.status = 'claimed' AND r.lease_expires_at <= ?) ) ORDER BY COALESCE(r.next_attempt_at, r.lease_expires_at) LIMIT 1` ) .all(nowIso, nowIso) as Array<{ run_id: string config_id: string attempt_count: number }> for (const joined of retryRows) { const result = database .prepare( `UPDATE heartbeat_runs SET status = 'claimed', attempt_count = attempt_count + 1, next_attempt_at = NULL, lease_owner = ?, lease_expires_at = ?, started_at = ?, completed_at = NULL, error = NULL, updated_at = ? WHERE id = ? AND attempt_count = ?` ) .run( leaseOwner, leaseExpiresAt, nowIso, nowIso, joined.run_id, joined.attempt_count ) if (result.changes === 1) { const run = database .prepare('SELECT * FROM heartbeat_runs WHERE id = ?') .get(joined.run_id) as HeartbeatRunRow const config = database .prepare('SELECT * FROM heartbeat_configs WHERE id = ?') .get(joined.config_id) as HeartbeatConfigRow claimed.push({ run: toHeartbeatRun(run), config: toHeartbeatConfig(config), leaseOwner, acquired: true }) } } const remaining = Math.max(0, 1 - claimed.length) const configRows = database .prepare( `SELECT * FROM heartbeat_configs WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at LIMIT ?` ) .all(nowIso, remaining) as HeartbeatConfigRow[] for (const row of configRows) { const scheduledFor = row.next_run_at const lag = now.getTime() - Date.parse(scheduledFor) const nextRunAt = computeNextHeartbeatRun( JSON.parse( row.recurrence_json ) as AssistantHeartbeatConfig['recurrence'], row.timezone, now ).toISOString() if (lag > 2 * 60 * 60_000) { database .prepare( `INSERT OR IGNORE INTO heartbeat_runs (id, config_id, trigger, scheduled_for, idempotency_key, status, attempt_count, next_attempt_at, lease_owner, lease_expires_at, started_at, completed_at, error, entry_id, created_at, updated_at) VALUES (?, ?, 'scheduled', ?, ?, 'skipped', 0, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?)` ) .run( randomUUID(), row.id, scheduledFor, `scheduled:${row.id}:${scheduledFor}`, nowIso, 'Missed by more than 2 hours', nowIso, nowIso ) database .prepare( `UPDATE heartbeat_configs SET next_run_at = ?, last_run_at = ?, last_status = 'skipped', updated_at = ? WHERE id = ? AND next_run_at = ?` ) .run(nextRunAt, nowIso, nowIso, row.id, scheduledFor) continue } const runId = randomUUID() const insert = database .prepare( `INSERT OR IGNORE INTO heartbeat_runs (id, config_id, trigger, scheduled_for, idempotency_key, status, attempt_count, next_attempt_at, lease_owner, lease_expires_at, started_at, completed_at, error, entry_id, created_at, updated_at) VALUES (?, ?, 'scheduled', ?, ?, 'claimed', 1, NULL, ?, ?, ?, NULL, NULL, NULL, ?, ?)` ) .run( runId, row.id, scheduledFor, `scheduled:${row.id}:${scheduledFor}`, leaseOwner, leaseExpiresAt, nowIso, nowIso, nowIso ) database .prepare( `UPDATE heartbeat_configs SET next_run_at = ?, last_run_at = ?, last_status = 'claimed', updated_at = ? WHERE id = ? AND next_run_at = ?` ) .run(nextRunAt, nowIso, nowIso, row.id, scheduledFor) if (insert.changes === 1) { const run = database .prepare('SELECT * FROM heartbeat_runs WHERE id = ?') .get(runId) as HeartbeatRunRow claimed.push({ run: toHeartbeatRun(run), config: toHeartbeatConfig({ ...row, next_run_at: nextRunAt, last_run_at: nowIso, last_status: 'claimed', updated_at: nowIso }), leaseOwner, acquired: true }) } } database.exec('COMMIT') return claimed } catch (error) { database.exec('ROLLBACK') throw error } } claimHeartbeatNow( configId: string, idempotencyKey: string, leaseOwner: string, now = new Date(), leaseMilliseconds = 5 * 60_000 ): ClaimedHeartbeatRun { const database = this.requireDatabase() const config = this.getHeartbeatConfig(configId) const nowIso = now.toISOString() const runId = randomUUID() const scopedIdempotencyKey = `manual:${configId}:${idempotencyKey}` database.exec('BEGIN IMMEDIATE') try { const duplicate = database .prepare( `SELECT * FROM heartbeat_runs WHERE config_id = ? AND idempotency_key = ?` ) .get(configId, scopedIdempotencyKey) as | HeartbeatRunRow | undefined if (duplicate) { database.exec('COMMIT') return { config, run: toHeartbeatRun(duplicate), leaseOwner, acquired: false } } database .prepare( `UPDATE heartbeat_runs SET status = 'failed', next_attempt_at = NULL, lease_owner = NULL, lease_expires_at = NULL, completed_at = ?, error = ?, updated_at = ? WHERE config_id = ? AND status = 'claimed' AND lease_expires_at <= ?` ) .run( nowIso, 'Expired run superseded by a new manual heartbeat', nowIso, configId, nowIso ) const active = database .prepare( `SELECT * FROM heartbeat_runs WHERE config_id = ? AND status = 'claimed' AND lease_expires_at > ? ORDER BY created_at DESC LIMIT 1` ) .get(configId, nowIso) as HeartbeatRunRow | undefined if (active) { database.exec('COMMIT') return { config, run: toHeartbeatRun(active), leaseOwner, acquired: false } } database .prepare( `INSERT INTO heartbeat_runs (id, config_id, trigger, scheduled_for, idempotency_key, status, attempt_count, next_attempt_at, lease_owner, lease_expires_at, started_at, completed_at, error, entry_id, created_at, updated_at) VALUES (?, ?, 'manual', ?, ?, 'claimed', 1, NULL, ?, ?, ?, NULL, NULL, NULL, ?, ?)` ) .run( runId, configId, nowIso, scopedIdempotencyKey, leaseOwner, new Date( now.getTime() + leaseMilliseconds ).toISOString(), nowIso, nowIso, nowIso ) database .prepare( `UPDATE heartbeat_configs SET last_run_at = ?, last_status = 'claimed', updated_at = ? WHERE id = ?` ) .run(nowIso, nowIso, configId) const run = database .prepare('SELECT * FROM heartbeat_runs WHERE id = ?') .get(runId) as HeartbeatRunRow database.exec('COMMIT') return { config, run: toHeartbeatRun(run), leaseOwner, acquired: true } } catch (error) { database.exec('ROLLBACK') throw error } } buildHeartbeatInput( config: AssistantHeartbeatConfig, now = new Date() ): HeartbeatInputSnapshot { const database = this.requireDatabase() const since = new Date( now.getTime() - config.lookbackHours * 60 * 60_000 ).toISOString() const conversations = ( config.projectId ? database .prepare( `SELECT id, title, updated_at FROM conversations WHERE status = 'active' AND project_id = ? AND updated_at >= ? ORDER BY updated_at DESC LIMIT 20` ) .all(config.projectId, since) : database .prepare( `SELECT id, title, updated_at FROM conversations WHERE status = 'active' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 20` ) .all(since) ) as Array<{ id: string; title: string; updated_at: string }> const messageStatement = database.prepare( `SELECT role, content, created_at FROM ( SELECT role, content, created_at, sequence FROM messages WHERE conversation_id = ? AND created_at >= ? AND role IN ('user', 'assistant') ORDER BY sequence DESC LIMIT 20 ) ORDER BY sequence` ) const tasks = ( config.projectId ? database .prepare( `SELECT id, title, status, created_at, completed_at FROM tasks WHERE project_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 100` ) .all(config.projectId, since) : database .prepare( `SELECT id, title, status, created_at, completed_at FROM tasks WHERE created_at >= ? ORDER BY created_at DESC LIMIT 100` ) .all(since) ) as Array<{ id: string title: string status: AssistantTask['status'] created_at: string completed_at: string | null }> const memories = ( config.projectId ? database .prepare( `SELECT id, type, content, scope FROM memory_items WHERE status = 'confirmed' AND (scope = 'global' OR (scope = 'project' AND scope_id = ?)) ORDER BY updated_at DESC LIMIT 100` ) .all(config.projectId) : database .prepare( `SELECT id, type, content, scope FROM memory_items WHERE status = 'confirmed' AND scope = 'global' ORDER BY updated_at DESC LIMIT 100` ) .all() ) as Array<{ id: string type: AssistantMemory['type'] content: string scope: AssistantMemory['scope'] }> return { conversations: conversations.map((conversation) => ({ id: conversation.id, title: conversation.title, updatedAt: conversation.updated_at, messages: ( messageStatement.all(conversation.id, since) as Array<{ role: 'user' | 'assistant' content: string created_at: string }> ).map((message) => ({ role: message.role, content: message.content, createdAt: message.created_at })) })), tasks: tasks.map((task) => ({ id: task.id, title: task.title, status: task.status, createdAt: task.created_at, completedAt: task.completed_at ?? undefined })), confirmedMemories: memories } } completeHeartbeatRun( claim: ClaimedHeartbeatRun, output: HeartbeatSummaryOutput, now = new Date() ): AssistantHeartbeatRun { const database = this.requireDatabase() const timestamp = now.toISOString() database.exec('BEGIN IMMEDIATE') try { const active = database .prepare( `SELECT id FROM heartbeat_runs WHERE id = ? AND status = 'claimed' AND lease_owner = ? AND lease_expires_at > ?` ) .get( claim.run.id, claim.leaseOwner, timestamp ) as { id: string } | undefined if (!active) { throw new Error('Heartbeat lease is no longer active') } const artifactId = randomUUID() const entryId = randomUUID() const summaryContent = [ `# ${claim.config.name}`, '', output.summary, ...(output.highlights.length ? ['', '## Highlights', ...output.highlights.map((item) => `- ${item}`)] : []) ].join('\n') database .prepare( `INSERT INTO artifacts (id, project_id, task_id, run_id, kind, title, mime_type, storage_kind, storage_path, inline_content, checksum, byte_size, preview_json, created_at, updated_at) VALUES (?, ?, NULL, NULL, 'markdown', ?, 'text/markdown', 'inline', NULL, ?, NULL, ?, '{}', ?, ?)` ) .run( artifactId, claim.config.projectId ?? null, `Heartbeat: ${claim.config.name}`.slice(0, 240), summaryContent, Buffer.byteLength(summaryContent), timestamp, timestamp ) const proposedMemoryIds: string[] = [] const insertMemory = database.prepare( `INSERT INTO memory_items (id, scope, scope_id, type, content, source_conversation_id, source_message_id, confidence, salience, status, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'proposed', NULL, ?, ?)` ) const findExistingMemory = database.prepare( `SELECT id FROM memory_items WHERE scope = ? AND ((? IS NULL AND scope_id IS NULL) OR scope_id = ?) AND content = ? COLLATE NOCASE AND status IN ('proposed', 'confirmed') LIMIT 1` ) for (const memory of output.proposedMemories) { const scopeId = memory.scope === 'project' ? (claim.config.projectId ?? null) : null const existing = findExistingMemory.get( memory.scope, scopeId, scopeId, memory.content ) as { id: string } | undefined if (existing) { continue } const memoryId = randomUUID() insertMemory.run( memoryId, memory.scope, scopeId, memory.type, memory.content, memory.confidence, memory.salience, timestamp, timestamp ) proposedMemoryIds.push(memoryId) } const followUpTaskIds: string[] = [] const insertTask = database.prepare( `INSERT INTO tasks (id, project_id, conversation_id, schedule_id, title, instructions, origin, status, priority, work_mode, progress, created_at, started_at, completed_at, error) VALUES (?, ?, NULL, NULL, ?, ?, 'assistant', 'paused', 0, 'plan', NULL, ?, NULL, NULL, NULL)` ) for (const task of output.followUpTasks) { const taskId = randomUUID() insertTask.run( taskId, claim.config.projectId ?? null, task.title, task.instructions, timestamp ) followUpTaskIds.push(taskId) } database .prepare( `INSERT INTO heartbeat_entries (id, config_id, run_id, scheduled_for, summary, highlights_json, artifact_id, proposed_memory_ids_json, follow_up_task_ids_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( entryId, claim.config.id, claim.run.id, claim.run.scheduledFor, output.summary, JSON.stringify(output.highlights), artifactId, JSON.stringify(proposedMemoryIds), JSON.stringify(followUpTaskIds), timestamp ) database .prepare( `UPDATE heartbeat_runs SET status = 'completed', completed_at = ?, error = NULL, entry_id = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ? WHERE id = ?` ) .run(timestamp, entryId, timestamp, claim.run.id) database .prepare( `UPDATE heartbeat_configs SET last_status = 'completed', updated_at = ? WHERE id = ?` ) .run(timestamp, claim.config.id) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } this.pruneHeartbeatHistory(claim.config.id, now) return this.getHeartbeatRun(claim.run.id) } failHeartbeatRun( claim: ClaimedHeartbeatRun, error: string, now = new Date() ): AssistantHeartbeatRun { const database = this.requireDatabase() const timestamp = now.toISOString() const retryDelays = [60_000, 5 * 60_000] const nextAttemptAt = claim.run.attemptCount < 3 ? new Date( now.getTime() + retryDelays[ Math.min( claim.run.attemptCount - 1, retryDelays.length - 1 ) ]! ).toISOString() : null const result = database .prepare( `UPDATE heartbeat_runs SET status = 'failed', next_attempt_at = ?, completed_at = ?, error = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ? WHERE id = ? AND status = 'claimed' AND lease_owner = ?` ) .run( nextAttemptAt, timestamp, error.slice(0, 2_000), timestamp, claim.run.id, claim.leaseOwner ) if (result.changes !== 1) { throw new Error('Heartbeat lease is no longer active') } database .prepare( `UPDATE heartbeat_configs SET last_status = 'failed', updated_at = ? WHERE id = ?` ) .run(timestamp, claim.config.id) return this.getHeartbeatRun(claim.run.id) } pruneHeartbeatHistory(configId: string, now = new Date()): void { const database = this.requireDatabase() const config = this.getHeartbeatConfig(configId) const cutoff = new Date( now.getTime() - config.retentionDays * 24 * 60 * 60_000 ).toISOString() database.exec('BEGIN IMMEDIATE') try { const artifacts = database .prepare( `SELECT artifact_id FROM heartbeat_entries WHERE config_id = ? AND created_at < ? AND artifact_id IS NOT NULL` ) .all(configId, cutoff) as Array<{ artifact_id: string }> database .prepare( `DELETE FROM heartbeat_entries WHERE config_id = ? AND created_at < ?` ) .run(configId, cutoff) database .prepare( `DELETE FROM heartbeat_runs WHERE config_id = ? AND created_at < ? AND status IN ('completed', 'failed', 'skipped')` ) .run(configId, cutoff) const deleteArtifact = database.prepare( 'DELETE FROM artifacts WHERE id = ?' ) for (const artifact of artifacts) { deleteArtifact.run(artifact.artifact_id) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listExperts(): AssistantExpert[] { return ( this.requireDatabase() .prepare( `SELECT * FROM experts WHERE enabled = 1 ORDER BY name` ) .all() as ExpertRow[] ).map(toExpert) } createExpert(input: ExpertCreateInput): AssistantExpert { const normalized = expertCreateSchema.parse(input) const id = randomUUID() const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO experts (id, name, description, system_instructions, capability_policy_json, model_policy_json, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)` ) .run( id, normalized.name, normalized.description, normalized.systemInstructions, JSON.stringify({ routingKeywords: normalized.routingKeywords }), JSON.stringify({ modelProfileId: normalized.modelProfileId }), now, now ) return this.getExpert(id) } updateExpert( expertId: string, input: ExpertUpdateInput ): AssistantExpert { const normalized = expertCreateSchema.parse(input) const result = this.requireDatabase() .prepare( `UPDATE experts SET name = ?, description = ?, system_instructions = ?, capability_policy_json = ?, model_policy_json = ?, updated_at = ? WHERE id = ? AND enabled = 1` ) .run( normalized.name, normalized.description, normalized.systemInstructions, JSON.stringify({ routingKeywords: normalized.routingKeywords }), JSON.stringify({ modelProfileId: normalized.modelProfileId }), new Date().toISOString(), expertId ) if (result.changes === 0) { throw new Error('专家不存在或已停用') } return this.getExpert(expertId) } removeExpert(expertId: string): void { const result = this.requireDatabase() .prepare( `UPDATE experts SET enabled = 0, updated_at = ? WHERE id = ? AND enabled = 1` ) .run(new Date().toISOString(), expertId) if (result.changes === 0) { throw new Error('专家不存在或已停用') } } getExpert(expertId: string): AssistantExpert { const row = this.requireDatabase() .prepare('SELECT * FROM experts WHERE id = ? AND enabled = 1') .get(expertId) as ExpertRow | undefined if (!row) { throw new Error('专家不存在或已停用') } return toExpert(row) } getProject(projectId: string): AssistantProject { const row = this.requireDatabase() .prepare('SELECT * FROM projects WHERE id = ?') .get(projectId) as ProjectRow | undefined if (!row) { throw new Error('项目不存在') } return toProject(row) } private getTask(taskId: string): AssistantTask { const row = this.requireDatabase() .prepare('SELECT * FROM tasks WHERE id = ?') .get(taskId) as TaskRow | undefined if (!row) { throw new Error('任务不存在') } return toTask(row) } private getMemory(memoryId: string): AssistantMemory { const row = this.requireDatabase() .prepare('SELECT * FROM memory_items WHERE id = ?') .get(memoryId) as MemoryRow | undefined if (!row) { throw new Error('记忆不存在') } return toMemory(row) } private getSchedule(scheduleId: string): AssistantSchedule { const row = this.requireDatabase() .prepare('SELECT * FROM schedules WHERE id = ?') .get(scheduleId) as ScheduleRow | undefined if (!row) { throw new Error('定时任务不存在') } return toSchedule(row) } private migrate(database: DatabaseSync): void { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } if (version.user_version > 8) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } if (version.user_version === 8) { return } if (version.user_version < 1) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', root_path TEXT NOT NULL DEFAULT '', default_work_mode TEXT NOT NULL CHECK(default_work_mode IN ('ask', 'plan', 'execute')), status TEXT NOT NULL CHECK(status IN ('active', 'archived')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE conversations ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, runtime_selection_json TEXT, work_mode TEXT NOT NULL DEFAULT 'ask' CHECK(work_mode IN ('ask', 'plan', 'execute')), title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, request_id TEXT, role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), content TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('streaming', 'complete', 'error')), sequence INTEGER NOT NULL, metadata_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, UNIQUE(conversation_id, sequence) ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, conversation_id TEXT, schedule_id TEXT, title TEXT NOT NULL, instructions TEXT NOT NULL, origin TEXT NOT NULL CHECK(origin IN ('user', 'assistant', 'schedule', 'delegation', 'subagent')), status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'waiting_approval', 'paused', 'completed', 'failed', 'cancelled', 'interrupted')), priority INTEGER NOT NULL DEFAULT 0, work_mode TEXT NOT NULL DEFAULT 'execute' CHECK(work_mode IN ('ask', 'plan', 'execute')), progress REAL, created_at TEXT NOT NULL, started_at TEXT, completed_at TEXT, error TEXT ); CREATE TABLE runs ( id TEXT PRIMARY KEY, task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, parent_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK(kind IN ('interactive', 'background', 'scheduled', 'delegated', 'subagent')), status TEXT NOT NULL, prompt TEXT NOT NULL, execution_snapshot_json TEXT NOT NULL, checkpoint_json TEXT, created_at TEXT NOT NULL, started_at TEXT, completed_at TEXT, error TEXT ); CREATE TABLE task_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, kind TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX task_events_task_idx ON task_events(task_id, id); CREATE TABLE artifacts ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, run_id TEXT REFERENCES runs(id) ON DELETE SET NULL, kind TEXT NOT NULL, title TEXT NOT NULL, mime_type TEXT NOT NULL, storage_kind TEXT NOT NULL CHECK(storage_kind IN ('inline', 'managed_file', 'reference')), storage_path TEXT, inline_content TEXT, checksum TEXT, byte_size INTEGER NOT NULL, preview_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE memory_items ( id TEXT PRIMARY KEY, scope TEXT NOT NULL CHECK(scope IN ('global', 'project', 'conversation')), scope_id TEXT, type TEXT NOT NULL CHECK(type IN ('preference', 'fact', 'summary', 'procedure')), content TEXT NOT NULL, source_conversation_id TEXT, source_message_id TEXT, confidence REAL NOT NULL, salience REAL NOT NULL, status TEXT NOT NULL CHECK(status IN ('proposed', 'confirmed', 'rejected')), expires_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE schedules ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, task_template_json TEXT NOT NULL, timezone TEXT NOT NULL, recurrence_json TEXT NOT NULL, next_run_at TEXT NOT NULL, missed_run_policy TEXT NOT NULL CHECK(missed_run_policy IN ('skip', 'run_once', 'catch_up')), enabled INTEGER NOT NULL, last_run_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE schedule_runs ( id TEXT PRIMARY KEY, schedule_id TEXT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE, scheduled_for TEXT NOT NULL, task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, status TEXT NOT NULL, UNIQUE(schedule_id, scheduled_for) ); CREATE TABLE notifications ( id TEXT PRIMARY KEY, task_id TEXT, schedule_id TEXT, title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending', 'shown', 'dismissed', 'opened')), created_at TEXT NOT NULL, shown_at TEXT ); CREATE TABLE experts ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, system_instructions TEXT NOT NULL, capability_policy_json TEXT NOT NULL, model_policy_json TEXT NOT NULL, enabled INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE delegations ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, endpoint_id TEXT NOT NULL, remote_job_id TEXT, status TEXT NOT NULL, request_digest TEXT NOT NULL, result_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); PRAGMA user_version = 1; COMMIT; `) } if (version.user_version < 2) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS delegation_outbox ( task_id TEXT PRIMARY KEY, result_json TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending', 'delivered')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS delegation_outbox_status_idx ON delegation_outbox(status, updated_at); PRAGMA user_version = 2; COMMIT; `) } if (version.user_version < 3) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS heartbeat_configs ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, name TEXT NOT NULL, timezone TEXT NOT NULL, recurrence_json TEXT NOT NULL, lookback_hours INTEGER NOT NULL CHECK(lookback_hours BETWEEN 1 AND 720), retention_days INTEGER NOT NULL CHECK(retention_days BETWEEN 1 AND 365), enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)), next_run_at TEXT NOT NULL, last_run_at TEXT, last_status TEXT CHECK(last_status IN ('claimed', 'completed', 'failed', 'skipped')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS heartbeat_configs_due_idx ON heartbeat_configs(enabled, next_run_at); CREATE TABLE IF NOT EXISTS heartbeat_runs ( id TEXT PRIMARY KEY, config_id TEXT NOT NULL REFERENCES heartbeat_configs(id) ON DELETE CASCADE, trigger TEXT NOT NULL CHECK(trigger IN ('scheduled', 'manual')), scheduled_for TEXT NOT NULL, idempotency_key TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('claimed', 'completed', 'failed', 'skipped')), attempt_count INTEGER NOT NULL CHECK(attempt_count BETWEEN 0 AND 3), next_attempt_at TEXT, lease_owner TEXT, lease_expires_at TEXT, started_at TEXT, completed_at TEXT, error TEXT, entry_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(config_id, idempotency_key) ); CREATE INDEX IF NOT EXISTS heartbeat_runs_claim_idx ON heartbeat_runs(status, next_attempt_at, lease_expires_at); CREATE INDEX IF NOT EXISTS heartbeat_runs_history_idx ON heartbeat_runs(config_id, created_at DESC); CREATE TABLE IF NOT EXISTS heartbeat_entries ( id TEXT PRIMARY KEY, config_id TEXT NOT NULL REFERENCES heartbeat_configs(id) ON DELETE CASCADE, run_id TEXT NOT NULL UNIQUE REFERENCES heartbeat_runs(id) ON DELETE CASCADE, scheduled_for TEXT NOT NULL, summary TEXT NOT NULL, highlights_json TEXT NOT NULL, artifact_id TEXT REFERENCES artifacts(id) ON DELETE SET NULL, proposed_memory_ids_json TEXT NOT NULL, follow_up_task_ids_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS heartbeat_entries_history_idx ON heartbeat_entries(config_id, created_at DESC); PRAGMA user_version = 3; COMMIT; `) } if (version.user_version < 4) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS model_usage_calls ( request_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, call_id TEXT NOT NULL, runtime TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, input_tokens INTEGER NOT NULL CHECK(input_tokens >= 0), output_tokens INTEGER NOT NULL CHECK(output_tokens >= 0), cache_read_tokens INTEGER NOT NULL CHECK(cache_read_tokens >= 0), cache_write_tokens INTEGER NOT NULL CHECK(cache_write_tokens >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(request_id, call_id) ); CREATE INDEX IF NOT EXISTS model_usage_calls_request_idx ON model_usage_calls(request_id); CREATE INDEX IF NOT EXISTS model_usage_calls_dimensions_idx ON model_usage_calls(runtime, provider, model); PRAGMA user_version = 4; COMMIT; `) } if (version.user_version < 5) { database.exec(` BEGIN IMMEDIATE; CREATE INDEX IF NOT EXISTS tasks_status_idx ON tasks(status); CREATE INDEX IF NOT EXISTS messages_state_idx ON messages(state); PRAGMA user_version = 5; COMMIT; `) } if (version.user_version < 6) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS computer_control_actions ( command_id TEXT PRIMARY KEY CHECK(length(command_id) BETWEEN 16 AND 160), task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE CHECK(length(task_id) BETWEEN 1 AND 128), conversation_id TEXT NOT NULL CHECK(length(conversation_id) BETWEEN 1 AND 128), lease_id TEXT NOT NULL CHECK(length(lease_id) BETWEEN 16 AND 160), action TEXT NOT NULL CHECK(action IN ('observe', 'activate', 'replace_text', 'select_option', 'scroll')), risk TEXT NOT NULL CHECK(risk IN ('observe', 'navigate', 'input', 'commit', 'forbidden')), outcome TEXT NOT NULL CHECK(outcome IN ('completed', 'denied', 'failed', 'outcome_unknown')), error_code TEXT CHECK(error_code IS NULL OR error_code IN ( 'invalid_request', 'driver_unavailable', 'driver_timeout', 'lease_not_found', 'lease_expired', 'lease_mismatch', 'observation_not_found', 'observation_stale', 'observation_consumed', 'element_not_found', 'window_not_foreground', 'element_identity_changed', 'focus_failed', 'forbidden', 'approval_denied', 'approval_timeout', 'cancelled', 'command_id_conflict', 'outcome_unknown', 'internal_error')), occurred_at INTEGER NOT NULL CHECK(occurred_at BETWEEN 0 AND 9007199254740991), text_length INTEGER CHECK(text_length IS NULL OR text_length BETWEEN 0 AND 4096), text_digest TEXT CHECK(text_digest IS NULL OR ( length(text_digest) = 64 AND text_digest NOT GLOB '*[^0-9a-f]*')), created_at TEXT NOT NULL, CHECK( (outcome = 'completed' AND error_code IS NULL) OR (outcome <> 'completed' AND error_code IS NOT NULL) ), CHECK( (action = 'replace_text' AND text_length IS NOT NULL AND text_digest IS NOT NULL) OR (action <> 'replace_text' AND text_length IS NULL AND text_digest IS NULL) ) ); CREATE INDEX IF NOT EXISTS computer_control_actions_recent_idx ON computer_control_actions(occurred_at DESC, created_at DESC); CREATE INDEX IF NOT EXISTS computer_control_actions_task_idx ON computer_control_actions(task_id, occurred_at DESC); PRAGMA user_version = 6; COMMIT; `) } if (version.user_version < 7) { const taskColumns = new Set( (database.prepare('PRAGMA table_info(tasks)').all() as Array<{ name: string }>).map((column) => column.name) ) database.exec('BEGIN IMMEDIATE') try { if (!taskColumns.has('parent_task_id')) { database.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE`) } if (!taskColumns.has('expert_id')) { database.exec(`ALTER TABLE tasks ADD COLUMN expert_id TEXT REFERENCES experts(id) ON DELETE SET NULL`) } if (!taskColumns.has('routing_mode')) { database.exec(`ALTER TABLE tasks ADD COLUMN routing_mode TEXT CHECK(routing_mode IS NULL OR routing_mode IN ('manual', 'smart'))`) } database.exec(` CREATE INDEX IF NOT EXISTS tasks_parent_task_idx ON tasks(parent_task_id, created_at); CREATE INDEX IF NOT EXISTS tasks_expert_idx ON tasks(expert_id, created_at); PRAGMA user_version = 7; COMMIT; `) } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 8) { const conversationColumns = new Set( ( database.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }> ).map((column) => column.name) ) database.exec('BEGIN IMMEDIATE') try { if (!conversationColumns.has('runtime_selection_json')) { database.exec( 'ALTER TABLE conversations ADD COLUMN runtime_selection_json TEXT' ) } database.exec('PRAGMA user_version = 8; COMMIT;') } catch (error) { database.exec('ROLLBACK') throw error } } } private requireDatabase(): DatabaseSync { if (!this.database) { throw new Error('助手数据库尚未初始化') } return this.database } }