import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync, type SQLInputValue } from "node:sqlite"; import { buildThreadMethods } from "./database/threads"; import { buildCompanyMethods } from "./database/company"; import { buildAgentMethods } from "./database/agents"; import { buildTaskMethods } from "./database/tasks"; import { buildMessageMethods } from "./database/messages"; import { buildRunMethods } from "./database/runs"; import { buildSnapshotMethods } from "./database/snapshot"; import { buildSettingsMethods } from "./database/settings"; import { buildConnectorMethods } from "./database/connectors-crud"; import { buildWorkspaceMethods } from "./database/workspaces"; import { buildApprovalMethods } from "./database/approvals"; import { buildSocialMethods } from "./database/social"; import { buildActivityMethods } from "./database/activity"; import { buildSkillMethods } from "./database/skills"; import { buildBudgetMethods } from "./database/budget"; import { buildEntityCrudMethods } from "./database/entity-crud"; import { buildDocumentMethods } from "./database/documents"; import { buildAutomationMethods } from "./database/automation"; import { buildKnowledgeMethods } from "./database/knowledge"; import { buildWakeupMethods } from "./database/wakeup"; import { buildBackupMethods } from "./database/backup"; import { buildSessionEntryMethods } from "./database/session-entries"; import { buildCredentialMethods } from "./database/credentials"; import { buildModelCapabilityMethods } from "./database/model-capabilities-crud"; import { MODEL_CAPABILITY_SEED } from "./model-capability-seed"; import { initializeDatabase } from "./database/schema"; import { SecretVault } from "./vault"; import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types"; function nowIso() { return new Date().toISOString(); } export const STALE_LOCK_TIMEOUT_MS = 15 * 60 * 1000; export class AppDatabaseBase { readonly profileDir: string; readonly dbPath: string; readonly logsDir: string; readonly artifactsDir: string; readonly backupsDir: string; readonly vault: SecretVault; protected db: DatabaseSync; // Domain modules. New code should prefer `db.tasks.saveTask(...)` over the // legacy pass-through methods on AppDatabase. Pass-through remains for // back-compat until all call sites migrate. public readonly threads: ReturnType; public readonly company: ReturnType; public readonly agents: ReturnType; public readonly tasks: ReturnType; public readonly messages: ReturnType; public readonly runs: ReturnType; public readonly snap: ReturnType; public readonly settings: ReturnType; public readonly connectorsCrud: ReturnType; public readonly workspacesMod: ReturnType; public readonly approvalsMod: ReturnType; public readonly social: ReturnType; public readonly activityMod: ReturnType; public readonly skills: ReturnType; public readonly budget: ReturnType; public readonly entityCrud: ReturnType; public readonly documentsMod: ReturnType; public readonly automationMod: ReturnType; public readonly knowledgeMod: ReturnType; public readonly wakeup: ReturnType; public readonly backupMod: ReturnType; public readonly sessionEntries: ReturnType; public readonly credentialsTable: ReturnType; public readonly modelCapabilities: ReturnType; constructor(rootDir: string, options: { isPackaged?: boolean } = {}) { this.profileDir = rootDir; this.dbPath = join(rootDir, "profile.sqlite"); this.logsDir = join(rootDir, "logs"); this.artifactsDir = join(rootDir, "artifacts"); this.backupsDir = join(rootDir, "backups"); mkdirSync(rootDir, { recursive: true }); mkdirSync(this.logsDir, { recursive: true }); mkdirSync(this.artifactsDir, { recursive: true }); mkdirSync(this.backupsDir, { recursive: true }); this.vault = new SecretVault(rootDir, { isPackaged: options.isPackaged }); this.db = this.openConnection(); const getDb = () => this.db; const ra = (sql: string, ...params: SQLInputValue[]) => this.readAll(sql, ...params); const tx = (op: () => T) => this.transaction(op); this.activityMod = buildActivityMethods(getDb, ra); this.settings = buildSettingsMethods(getDb, ra, { profileDir: this.profileDir, logsDir: this.logsDir, artifactsDir: this.artifactsDir, backupsDir: this.backupsDir, }); this.connectorsCrud = buildConnectorMethods(getDb, ra); this.snap = buildSnapshotMethods(getDb, ra, { getCurrentCompanyId: () => this.settings.getCurrentCompanyId(), getSetting: (key) => this.getSetting(key), readConnectors: () => this.connectorsCrud.readConnectors(), profileDir: this.profileDir, vaultBackendLabel: () => this.vault.getBackendLabel(), }); this.threads = buildThreadMethods(getDb, ra); this.company = buildCompanyMethods(getDb, ra, tx, { getSetting: (key) => this.getSetting(key), setSetting: (key, value) => this.setSetting(key, value), listSnapshot: (options) => this.listSnapshot(options), addComment: (input) => this.addComment(input), }); this.agents = buildAgentMethods(getDb, ra, tx, { getAgent: (id) => this.getAgent(id), wouldCreateAgentCycle: (agentId, reportsTo) => this.wouldCreateAgentCycle(agentId, reportsTo), recordConfigRevision: (input) => this.recordConfigRevision(input), cancelRunForDeletion: (runId, taskId, workspaceId, summary) => this.runs.cancelRunForDeletion(runId, taskId, workspaceId, summary), addActivity: (input) => this.addActivity(input), }); this.tasks = buildTaskMethods(getDb, ra, tx, { addComment: (input) => this.addComment(input), addActivity: (input) => this.addActivity(input), }); this.messages = buildMessageMethods(getDb, ra, { getAgent: (id) => this.getAgent(id), addActivity: (input) => this.addActivity(input), transaction: tx, }); this.runs = buildRunMethods(getDb, ra, tx, { logsDir: this.logsDir }); this.workspacesMod = buildWorkspaceMethods(getDb, ra, tx); this.approvalsMod = buildApprovalMethods(getDb, ra, tx, { getAgent: (id) => this.getAgent(id), }); this.social = buildSocialMethods(getDb, ra, tx); this.skills = buildSkillMethods(getDb, ra); this.budget = buildBudgetMethods(getDb, ra, tx); this.entityCrud = buildEntityCrudMethods(getDb, ra, tx, { addActivity: (input) => this.addActivity(input), }); this.documentsMod = buildDocumentMethods(getDb, ra, tx); this.automationMod = buildAutomationMethods(getDb, ra, { addActivity: (input) => this.addActivity(input), }); this.knowledgeMod = buildKnowledgeMethods(getDb, ra); this.wakeup = buildWakeupMethods(getDb, ra, tx); this.sessionEntries = buildSessionEntryMethods(getDb, ra, tx); this.credentialsTable = buildCredentialMethods(getDb, ra, tx); this.modelCapabilities = buildModelCapabilityMethods(getDb, ra, tx); this.backupMod = buildBackupMethods({ getDb, setDb: (db) => { this.db = db; }, openConnection: () => this.openConnection(), init: () => this.init(), close: () => this.close(), profileDir: this.profileDir, dbPath: this.dbPath, logsDir: this.logsDir, artifactsDir: this.artifactsDir, backupsDir: this.backupsDir, }); } // ─── Lifecycle ───────────────────────────────────────────────────────────── init() { const ra = (sql: string, ...params: SQLInputValue[]) => this.readAll(sql, ...params); const tx = (op: () => T) => this.transaction(op); initializeDatabase(this.db, ra, tx); this.connectorsCrud.seedConnectors(); this.modelCapabilities.seedModelCapabilities(MODEL_CAPABILITY_SEED); this._lastInterruptedCount = this.interruptStaleRuns(); this._lastOrphanResetCount = this.reconcileOrphanedTasks(); this._lastUnreviewableResetCount = this.reconcileUnreviewableTasks(); this.settings.purgeExpiredRunLogs(); this.wakeup.purgeExpiredWakeupRequests(); } /** * Reset tasks stuck at `in_progress` with no live run. * * Invariant: `task.status = 'in_progress'` ⇒ there exists a run `r` * where `r.status IN ('queued', 'running')` referenced by this task * (via checkout_run_id or active_run_id). * * Whenever the invariant breaks — because a dispatch failed, a worker * crashed, or a pre-fix finishRun cleared the run refs but not the * status — the task becomes un-reclaimable: `checkoutTask`'s guard * requires `status IN ('todo','backlog')`, and heartbeat loops forever * logging "lost checkout race". One query at boot drains every such * orphan, regardless of which of the two reference columns happens to * be null. The runtime `finishRun` path enforces the same invariant * going forward, so this should stabilise at zero rows changed. */ protected reconcileOrphanedTasks(): number { const result = this.db .prepare( `UPDATE tasks SET status = 'todo', checkout_run_id = NULL, execution_locked_at = NULL, active_run_id = NULL, updated_at = ? WHERE status = 'in_progress' AND NOT EXISTS ( SELECT 1 FROM runs r WHERE (r.id = tasks.checkout_run_id OR r.id = tasks.active_run_id) AND r.status IN ('queued', 'running') )`, ) .run(new Date().toISOString()); const changes = Number(result.changes); if (changes > 0) { console.info(`[init] Reconciled ${changes} orphaned in_progress task(s) → todo.`); } return changes; } /** * Reset tasks stuck at `in_review` with no reachable reviewer. * * A task enters `in_review` when an assignee submits work for approval. * The reviewer is the assignee's manager (walk up via `reports_to`), * or the human operator when the task is flagged * `requires_user_review = 1`. When the assignee is a top-level agent * (CEO/board, `reports_to IS NULL`) and the task does not require * user review, *no one* can review it — the task becomes a dead * letter, and the CEO heartbeat falls into a loop re-proposing its * `approve_ceo_strategy` without ever recovering. * * This observed pattern cost us a full debugging session: CEO * self-assigned a large task, moved it to `in_review`, then spent * every heartbeat cycle re-emitting the same approval. Demoting the * task to `todo` breaks the loop — the CEO sees un-reviewable work * and correctly falls through to the hire path instead. */ protected reconcileUnreviewableTasks(): number { const result = this.db .prepare( `UPDATE tasks SET status = 'todo', updated_at = ? WHERE status = 'in_review' AND requires_user_review = 0 AND assignee_agent_id IS NOT NULL AND assignee_agent_id IN ( SELECT id FROM agents WHERE reports_to IS NULL )`, ) .run(new Date().toISOString()); const changes = Number(result.changes); if (changes > 0) { console.info(`[init] Reconciled ${changes} un-reviewable in_review task(s) → todo.`); } return changes; } close() { this.db.close(); } // ─── Settings ────────────────────────────────────────────────────────────── getCurrentCompanyId() { return this.settings.getCurrentCompanyId(); } setCurrentCompany(companyId: string) { this.settings.setCurrentCompany(companyId); } hasCompany(companyId: string) { return this.settings.hasCompany(companyId); } belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) { return this.settings.belongsToCompany(table, id, companyId); } updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) { this.settings.updateSettings(input); } getLogRetentionDays() { return this.settings.getLogRetentionDays(); } purgeExpiredRunLogs() { return this.settings.purgeExpiredRunLogs(); } canOpenPath(targetPath: string) { return this.settings.canOpenPath(targetPath); } getDisabledModels(companyId: string, connectorId: string) { return this.settings.getDisabledModels(companyId, connectorId); } setDisabledModels(companyId: string, connectorId: string, models: Array<{ id: string; label: string }>) { this.settings.setDisabledModels(companyId, connectorId, models); } hasModelPoolSettings(companyId: string, connectorId: string) { return this.settings.hasModelPoolSettings(companyId, connectorId); } getLastBudgetResetMonth(companyId: string) { return this.settings.getLastBudgetResetMonth(companyId); } setLastBudgetResetMonth(companyId: string, month: string) { this.settings.setLastBudgetResetMonth(companyId, month); } // ─── Snapshot ────────────────────────────────────────────────────────────── listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot { return this.snap.listSnapshot(options); } listRendererSnapshot(): ProfileSnapshot { return this.snap.listRendererSnapshot(); } // ─── Company ─────────────────────────────────────────────────────────────── saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean; executionMode?: string }) { return this.company.saveCompany(input); } deleteCompany(id: string) { return this.company.deleteCompany(id); } getCompanyStatus(companyId: string) { return this.company.getCompanyStatus(companyId); } getCompanyBasic(companyId: string) { return this.company.getCompanyBasic(companyId); } getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); } getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); } listActiveCompanyIds() { return this.company.listActiveCompanyIds(); } getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); } getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); } getDashboardSnapshot(companyId: string) { return this.company.getDashboardSnapshot(companyId); } // ─── Connectors ──────────────────────────────────────────────────────────── saveConnector(input: { id: ConnectorId; command: string; model: string | null; envBindingText: string; notes: string }) { this.connectorsCrud.saveConnector(input); } updateConnectorHealth(input: { id: ConnectorId; status: string; authState: string; version: string | null; lastError: string | null }) { this.connectorsCrud.updateConnectorHealth(input); } getConnector(id: ConnectorId) { return this.connectorsCrud.getConnector(id); } findConnector(id: ConnectorId) { return this.connectorsCrud.findConnector(id); } // ─── Agents ──────────────────────────────────────────────────────────────── saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number; roleLevel?: import("@shared/types").AgentRoleLevel; modelStrategy?: import("@shared/types").AgentModelStrategy }) { return this.agents.saveAgent(input); } deleteAgent(id: string, companyId: string) { return this.agents.deleteAgent(id, companyId); } getAgent(id: string) { return this.entityCrud.getAgent(id); } findAgent(id: string) { return this.entityCrud.findAgent(id); } setHeartbeat(agentId: string, enabled: boolean, intervalSec: number) { return this.agents.setHeartbeat(agentId, enabled, intervalSec); } markHeartbeatAt(agentId: string) { return this.agents.markHeartbeatAt(agentId); } resetMonthlyBudgets(companyId: string) { return this.agents.resetMonthlyBudgets(companyId); } listHeartbeatEligibleAgents(companyId: string) { return this.agents.listHeartbeatEligibleAgents(companyId); } getChainOfCommand(agentId: string) { return this.agents.getChainOfCommand(agentId); } getDirectReports(agentId: string, companyId: string) { return this.agents.getDirectReports(agentId, companyId); } getCeoForCompany(companyId: string) { return this.agents.getCeoForCompany(companyId); } listActiveAgentsForCompany(companyId: string) { return this.agents.listActiveAgentsForCompany(companyId); } listAgentsForApi(companyId: string) { return this.agents.listAgentsForApi(companyId); } listAgentsForStandup(companyId: string) { return this.agents.listAgentsForStandup(companyId); } listDirectReportsForStandup(companyId: string, managerId: string) { return this.agents.listDirectReportsForStandup(companyId, managerId); } listReportStatusesForStandup(companyId: string, managerId: string) { return this.agents.listReportStatusesForStandup(companyId, managerId); } listExistingAgentsForHireCheck(companyId: string) { return this.agents.listExistingAgentsForHireCheck(companyId); } hasAgentAssignedTasks(agentId: string, companyId: string) { return this.agents.hasAgentAssignedTasks(agentId, companyId); } updateAgentStatus(agentId: string, status: string) { return this.agents.updateAgentStatus(agentId, status); } isAgentPendingApproval(agentId: string) { return this.agents.isAgentPendingApproval(agentId); } findMentionedAgentIds(companyId: string, text: string) { return this.agents.findMentionedAgentIds(companyId, text); } getIdleAgentsForDepartment(companyId: string, department: string) { return this.agents.getIdleAgentsForDepartment(companyId, department); } getManagerForAgent(agentId: string) { return this.agents.getManagerForAgent(agentId); } getAgentPermissions(agentId: string) { return this.agents.getAgentPermissions(agentId); } setAgentPermission(agentId: string, companyId: string, permissionKey: string, enabled: boolean) { return this.agents.setAgentPermission(agentId, companyId, permissionKey, enabled); } hasAgentPermission(agentId: string, permissionKey: string) { return this.agents.hasAgentPermission(agentId, permissionKey); } getRuntimeState(agentId: string) { return this.agents.getRuntimeState(agentId); } setRuntimeState(agentId: string, companyId: string, state: Record) { return this.agents.setRuntimeState(agentId, companyId, state); } getHeartbeatPolicy(agentId: string) { return this.agents.getHeartbeatPolicy(agentId); } setHeartbeatPolicy(agentId: string, companyId: string, policy: { enabled?: boolean; intervalSec?: number; cooldownSec?: number; maxConcurrentRuns?: number; wakeOnDemand?: boolean }) { return this.agents.setHeartbeatPolicy(agentId, companyId, policy); } countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); } listDepartmentAgentIds(companyId: string, department: string) { return this.agents.listDepartmentAgentIds(companyId, department); } listTopLevelAgentIds(companyId: string) { return this.agents.listTopLevelAgentIds(companyId); } listIncidentPingableAgentIds(companyId: string) { return this.agents.listIncidentPingableAgentIds(companyId); } findAgentName(agentId: string) { return this.agents.findAgentName(agentId); } listAgentNamesByIds(companyId: string, agentIds: readonly string[]) { return this.agents.listAgentNamesByIds(companyId, agentIds); } listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]) { return this.agents.listActiveAgentIdsFromIds(companyId, agentIds); } // ─── Tasks ───────────────────────────────────────────────────────────────── saveTask(input: { id?: string | null; companyId: string; projectId?: string | null; goalId?: string | null; parentId?: string | null; title: string; description: string; assigneeAgentId?: string | null; workspaceId?: string | null; priority: string; status: string }) { return this.tasks.saveTask(input); } getTask(id: string) { return this.entityCrud.getTask(id); } deleteTask(id: string, companyId: string) { return this.tasks.deleteTask(id, companyId); } updateSessionParams(sessionId: string, sessionParams: Record | null, sessionDisplayId: string | null) { return this.tasks.updateSessionParams(sessionId, sessionParams, sessionDisplayId); } getSessionById(sessionId: string) { return this.tasks.getSessionById(sessionId); } incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string) { return this.tasks.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId); } archiveSession(sessionId: string, reason: string, handoffMarkdown: string) { return this.tasks.archiveSession(sessionId, reason, handoffMarkdown); } createNewSession(input: { companyId: string; agentId: string; adapterType: string; sessionParamsJson: string | null; sessionDisplayId: string | null }) { return this.tasks.createNewSession(input); } ensureAgentSession(input: { agentId: string; companyId: string; adapterType: string }) { return this.tasks.ensureAgentSession(input); } setAgentSessionId(agentId: string, sessionId: string) { return this.tasks.setAgentSessionId(agentId, sessionId); } getRecentRunsForTask(taskId: string, limit: number) { return this.tasks.getRecentRunsForTask(taskId, limit); } getRecentActivityForTask(taskId: string, limit: number) { return this.tasks.getRecentActivityForTask(taskId, limit); } checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); } releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); } renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); } listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); } listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); } listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); } listAssigneeTasksForStandup(companyId: string, agentId: string) { return this.tasks.listAssigneeTasksForStandup(companyId, agentId); } listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); } listReviewTasksForManager(managerId: string, companyId: string) { return this.tasks.listReviewTasksForManager(managerId, companyId); } countActiveTasksForCompany(companyId: string) { return this.tasks.countActiveTasksForCompany(companyId); } listChildTasks(parentId: string, companyId: string) { return this.tasks.listChildTasks(parentId, companyId); } advanceTaskStatus(taskId: string, newStatus: string, comment?: string) { return this.tasks.advanceTaskStatus(taskId, newStatus, comment); } setTaskRequiresUserReview(taskId: string, value: boolean) { return this.tasks.setTaskRequiresUserReview(taskId, value); } hasPendingDeliverableReviews(companyId: string) { return this.tasks.hasPendingDeliverableReviews(companyId); } hasAgentPendingDeliverableReview(agentId: string, companyId: string) { return this.tasks.hasAgentPendingDeliverableReview(agentId, companyId); } hasAgentTaskAwaitingReview(agentId: string, companyId: string) { return this.tasks.hasAgentTaskAwaitingReview(agentId, companyId); } autoAssignTask(taskId: string, companyId: string) { return this.tasks.autoAssignTask(taskId, companyId); } getTaskRetryCount(taskId: string) { return this.tasks.getTaskRetryCount(taskId); } incrementTaskRetryCount(taskId: string) { return this.tasks.incrementTaskRetryCount(taskId); } resetTaskRetryCount(taskId: string) { return this.tasks.resetTaskRetryCount(taskId); } retryInterruptedTask(taskId: string, _agentId: string, _companyId: string) { return this.tasks.retryInterruptedTask(taskId, _agentId, _companyId); } dismissInterruptedTask(taskId: string) { return this.tasks.dismissInterruptedTask(taskId); } markInterruptedTaskFailed(taskId: string) { return this.tasks.markInterruptedTaskFailed(taskId); } setTaskType(taskId: string, taskType: string) { return this.tasks.setTaskType(taskId, taskType); } listProjectTaskAssigneeIds(projectId: string, companyId: string) { return this.tasks.listProjectTaskAssigneeIds(projectId, companyId); } listReviewTasksForSweep(companyId: string) { return this.tasks.listReviewTasksForSweep(companyId); } getTaskType(taskId: string) { return this.tasks.getTaskType(taskId); } // ─── Runs ────────────────────────────────────────────────────────────────── getRun(id: string) { return this.runs.getRun(id); } getRunAuthState(runId: string) { return this.runs.getRunAuthState(runId); } createRun(input: { id?: string; companyId: string; taskId: string | null; agentId: string; workspaceId: string | null; connectorId: ConnectorId }) { return this.runs.createRun(input); } appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) { return this.runs.appendRunLog(runId, stream, chunk); } readRunLog(runId: string) { return this.runs.readRunLog(runId); } readRunLogChunk(runId: string, offset = 0, limit = 65_536) { return this.runs.readRunLogChunk(runId, offset, limit); } markRunStatus(runId: string, status: RunStatus, message: string) { return this.runs.markRunStatus(runId, status, message); } finishRun(input: { runId: string; status: RunStatus; summary: string | null; errorMessage: string | null; exitCode: number | null; signal: string | null; model: string | null; sessionDisplayId: string | null; unattributedCost: boolean }) { return this.runs.finishRun(input); } recordCostEntry(input: { runId: string; companyId: string; agentId: string; taskId: string | null; connectorId: string; amountUsd: number | null; unattributed: boolean }) { return this.runs.recordCostEntry(input); } hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) { return this.runs.hasActiveRun(filter); } listQueuedRuns() { return this.runs.listQueuedRuns(); } getInterruptedRunsForRecovery() { return this.runs.getInterruptedRunsForRecovery(); } appendRunEvent(input: { companyId: string; runId: string; agentId: string; eventType: string; stream?: string | null; level?: string | null; message?: string | null; payload?: Record | null }) { return this.runs.appendRunEvent(input); } listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }) { return this.runs.listRunEvents(runId, companyId, opts); } listRunEventsAfter(runId: string, companyId: string, afterSeq: number, limit: number) { return this.runs.listRunEventsAfter(runId, companyId, afterSeq, limit); } updateRunActor(runId: string, actorType: string, actorId: string | null) { return this.runs.updateRunActor(runId, actorType, actorId); } // ─── Approvals ───────────────────────────────────────────────────────────── requestApproval(input: { companyId: string; relatedTaskId?: string | null; requestedByAgentId?: string | null; relatedAgentId?: string | null; type: string; payloadSummary: string; impactSummary: string; payloadJson?: string | null }) { return this.approvalsMod.requestApproval(input); } decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) { return this.approvalsMod.decideApproval(input); } getApprovalRecord(id: string) { return this.approvalsMod.getApprovalRecord(id); } findApprovalById(id: string) { return this.approvalsMod.findApprovalById(id); } deleteApproval(id: string, companyId: string) { this.approvalsMod.deleteApproval(id, companyId); } getBlockingApproval(companyId: string, agentId: string, taskId: string | null) { return this.approvalsMod.getBlockingApproval(companyId, agentId, taskId); } resubmitApproval(approvalId: string, companyId: string) { this.approvalsMod.resubmitApproval(approvalId, companyId); } addApprovalComment(input: { companyId: string; approvalId: string; authorAgentId?: string | null; authorUserId?: string | null; body: string }) { return this.approvalsMod.addApprovalComment(input); } listApprovalComments(approvalId: string, companyId: string) { return this.approvalsMod.listApprovalComments(approvalId, companyId); } linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null) { this.approvalsMod.linkTaskToApproval(taskId, approvalId, companyId, linkedByAgentId); } unlinkTaskFromApproval(taskId: string, approvalId: string) { this.approvalsMod.unlinkTaskFromApproval(taskId, approvalId); } listApprovalsForTask(taskId: string) { return this.approvalsMod.listApprovalsForTask(taskId); } listTasksForApproval(approvalId: string) { return this.approvalsMod.listTasksForApproval(approvalId); } linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null) { this.approvalsMod.linkManyTasksToApproval(approvalId, taskIds, companyId, linkedByAgentId); } listApprovalsForStandup(companyId: string) { return this.approvalsMod.listApprovalsForStandup(companyId); } listAgentPendingApprovals(companyId: string, agentId: string) { return this.approvalsMod.listAgentPendingApprovals(companyId, agentId); } listAgentResolvedApprovals(companyId: string, agentId: string, limit?: number) { return this.approvalsMod.listAgentResolvedApprovals(companyId, agentId, limit); } listPendingHireApprovalPayloads(companyId: string) { return this.approvalsMod.listPendingHireApprovalPayloads(companyId); } listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); } findPendingApprovalForTask(companyId: string, taskId: string) { return this.approvalsMod.findPendingApprovalForTask(companyId, taskId); } hasPendingReviewApprovalForAgent(companyId: string, agentId: string) { return this.approvalsMod.hasPendingReviewApprovalForAgent(companyId, agentId); } // ─── Workspaces ──────────────────────────────────────────────────────────── saveWorkspace(input: { id?: string | null; companyId: string; name: string; localPath: string; repoUrl: string; repoRef: string; projectId?: string | null; isPrimary: boolean }) { return this.workspacesMod.saveWorkspace(input); } getWorkspace(id: string | null | undefined) { return this.workspacesMod.getWorkspace(id); } getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string) { return this.workspacesMod.getProjectPrimaryWorkspace(projectId, companyId); } listWorkspaceLocalPaths(companyId: string) { return this.workspacesMod.listWorkspaceLocalPaths(companyId); } getAnyCompanyWorkspace(companyId: string) { return this.workspacesMod.getAnyCompanyWorkspace(companyId); } deleteWorkspace(id: string, companyId: string) { this.workspacesMod.deleteWorkspace(id, companyId); } acquireWorkspaceLock(workspaceId: string, runId: string) { return this.workspacesMod.acquireWorkspaceLock(workspaceId, runId); } claimWorkspaceLock(workspaceId: string, runId: string) { return this.workspacesMod.claimWorkspaceLock(workspaceId, runId); } releaseWorkspaceLock(workspaceId: string, runId: string) { this.workspacesMod.releaseWorkspaceLock(workspaceId, runId); } releaseAllWorkspaceLocks() { this.workspacesMod.releaseAllWorkspaceLocks(); } releaseStaleWorkspaceLocks() { return this.workspacesMod.releaseStaleWorkspaceLocks(); } isWorkspaceLocked(workspaceId: string) { return this.workspacesMod.isWorkspaceLocked(workspaceId); } // ─── Internal infrastructure ─────────────────────────────────────────────── protected _lastInterruptedCount = 0; protected _lastOrphanResetCount = 0; protected _lastUnreviewableResetCount = 0; get lastInterruptedCount(): number { return this._lastInterruptedCount; } get lastOrphanResetCount(): number { return this._lastOrphanResetCount; } get lastUnreviewableResetCount(): number { return this._lastUnreviewableResetCount; } interruptStaleRuns(): number { const ts = nowIso(); let interruptedCount = 0; this.transaction(() => { const result = this.db .prepare("update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'") .run(ts, ts); interruptedCount = Number(result.changes); if (interruptedCount > 0) { this.db.prepare( `update tasks set status = 'blocked', updated_at = ? where id in (select task_id from runs where status = 'interrupted' and finished_at = ?) and status in ('in_progress', 'todo')`, ).run(ts, ts); } this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run(); this.db.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'").run(ts); this.releaseAllWorkspaceLocks(); }); return interruptedCount; } recoverStaleLocks(): Array<{ taskId: string; companyId: string; title: string }> { const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString(); const stale = this.readAll<{ id: string; companyId: string; title: string }>( `SELECT id, company_id AS companyId, title FROM tasks WHERE execution_locked_at IS NOT NULL AND execution_locked_at < ? AND status = 'in_progress' AND checkout_run_id NOT IN (SELECT id FROM runs WHERE status = 'running')`, cutoff, ); const ts = nowIso(); for (const task of stale) { this.db.prepare(`UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, status = 'todo', updated_at = ? WHERE id = ?`).run(ts, task.id); } return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title })); } protected incrementActiveSprintVelocity(companyId: string, delta: number) { this.entityCrud.incrementActiveSprintVelocity(companyId, delta); } protected getSetting(key: string): string | null { const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; } protected setSetting(key: string, value: string) { this.db.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value").run(key, value); } protected readAll(sql: string, ...params: SQLInputValue[]): T[] { return this.db.prepare(sql).all(...params) as T[]; } protected wouldCreateAgentCycle(agentId: string, reportsTo: string | null) { if (!reportsTo) return false; if (reportsTo === agentId) return true; const visited = new Set([agentId]); let currentId: string | null = reportsTo; while (currentId) { if (visited.has(currentId)) return true; visited.add(currentId); const current = this.db.prepare("select reports_to as reportsTo from agents where id = ?").get(currentId) as { reportsTo: string | null } | undefined; currentId = current?.reportsTo ?? null; } return false; } protected openConnection(): DatabaseSync { const db = new DatabaseSync(this.dbPath, { timeout: 5000 }); db.exec("pragma journal_mode = WAL;"); db.exec("pragma busy_timeout = 5000;"); db.exec("pragma synchronous = NORMAL;"); db.exec("pragma foreign_keys = ON;"); return db; } protected transaction(operation: () => T): T { this.db.exec("BEGIN IMMEDIATE"); try { const result = operation(); this.db.exec("COMMIT"); return result; } catch (error) { this.db.exec("ROLLBACK"); throw error; } } // ─── Methods used by subclass constructor callbacks ──────────────────────── addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { this.activityMod.addActivity(input); } addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) { return this.activityMod.addComment(input); } listRecentTaskComments(taskIds: string[], sinceIso: string, limit: number) { return this.activityMod.listRecentTaskComments(taskIds, sinceIso, limit); } recordConfigRevision(input: { companyId: string; agentId: string; changedKeys: string[]; configBefore: Record; configAfter: Record; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); } }