The per-connector "enabled/disabled model" settings lived under a
global key in the settings key-value table
(`disabled_models:${connectorId}`). No company dimension. Deleting a
company left the pool choices behind, and creating a new company
silently inherited them — the visible symptom was "onboarding's team-
model configuration survives company deletion and pollutes the next
company's CEO model list."
Root fix, not a cleanup hook: the storage becomes a real relational
table with `(company_id, connector_id)` primary key and an
`ON DELETE CASCADE` FK from `companies`. Company deletion wipes pool
config by construction; no cleanup step in `deleteCompany()` can
drift.
Schema (migration v42):
- CREATE TABLE connector_model_pool_settings (company_id, connector_id,
disabled_models_json, updated_at), PK (company_id, connector_id),
FK companies(id) ON DELETE CASCADE.
- Drop every `settings.disabled_models:*` key — these were the bug's
residue; there is no right company to attribute them to, so the
migration clears them. Users re-select on next onboarding.
API surgery — every reader/writer now takes companyId:
- db.getDisabledModels(companyId, connectorId)
- db.setDisabledModels(companyId, connectorId, models)
- db.hasModelPoolSettings(companyId, connectorId)
- connector-model-policy: getConnectorModelPolicy(deps, companyId,
connectorId); validateConnectorModelSelection(deps, companyId,
connectorId, model)
- model-dispatch: listEnabledModels(companyId, deps);
pickModelForAgent resolves companyId from agent.companyId
- orchestration heartbeat/task-runner/turn-context/run-dispatch pass
the agent's or run's companyId through to the policy deps
- IPC schema: connectorDisabledModelsSchema gains companyId; new
connectorModelPoolQuerySchema pairs companyId+connectorId for reads
Onboarding becomes commit-on-launch:
- useConnectorModelSelection no longer persists via IPC during
onboarding. Toggles mutate local state; it exposes `poolSelections`
to the wizard.
- bootstrapOnboardingSchema gains connectorPoolSelections. The
onboarding service saves the company first, then persists each
per-connector disabled list scoped to the new company id — CEO
model validation runs against the company's freshly-persisted pool,
not some stale global.
- IPC setConnectorDisabledModels now requires companyId; the only
consumer was onboarding, which no longer calls it at all.
- Main-side listConnectorModels returns the raw provider catalog
(no pool filtering), since that IPC is used from onboarding where
no company exists yet; pool filtering happens at dispatch time.
saveConnector IPC drops the per-company pool validation — connector
config is a global hint, company-scoped enforcement belongs at run
dispatch where the company is known.
Tests: 603/603 green. Typecheck clean. Integration tests rewritten
to set the pool via connectorPoolSelections on bootstrap input, not
a pre-bootstrap global setDisabledModels call.
537 lines
38 KiB
TypeScript
537 lines
38 KiB
TypeScript
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<typeof buildThreadMethods>;
|
|
public readonly company: ReturnType<typeof buildCompanyMethods>;
|
|
public readonly agents: ReturnType<typeof buildAgentMethods>;
|
|
public readonly tasks: ReturnType<typeof buildTaskMethods>;
|
|
public readonly messages: ReturnType<typeof buildMessageMethods>;
|
|
public readonly runs: ReturnType<typeof buildRunMethods>;
|
|
public readonly snap: ReturnType<typeof buildSnapshotMethods>;
|
|
public readonly settings: ReturnType<typeof buildSettingsMethods>;
|
|
public readonly connectorsCrud: ReturnType<typeof buildConnectorMethods>;
|
|
public readonly workspacesMod: ReturnType<typeof buildWorkspaceMethods>;
|
|
public readonly approvalsMod: ReturnType<typeof buildApprovalMethods>;
|
|
public readonly social: ReturnType<typeof buildSocialMethods>;
|
|
public readonly activityMod: ReturnType<typeof buildActivityMethods>;
|
|
public readonly skills: ReturnType<typeof buildSkillMethods>;
|
|
public readonly budget: ReturnType<typeof buildBudgetMethods>;
|
|
public readonly entityCrud: ReturnType<typeof buildEntityCrudMethods>;
|
|
public readonly documentsMod: ReturnType<typeof buildDocumentMethods>;
|
|
public readonly automationMod: ReturnType<typeof buildAutomationMethods>;
|
|
public readonly knowledgeMod: ReturnType<typeof buildKnowledgeMethods>;
|
|
public readonly wakeup: ReturnType<typeof buildWakeupMethods>;
|
|
public readonly backupMod: ReturnType<typeof buildBackupMethods>;
|
|
public readonly sessionEntries: ReturnType<typeof buildSessionEntryMethods>;
|
|
public readonly credentialsTable: ReturnType<typeof buildCredentialMethods>;
|
|
public readonly modelCapabilities: ReturnType<typeof buildModelCapabilityMethods>;
|
|
|
|
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 = <T>(sql: string, ...params: SQLInputValue[]) => this.readAll<T>(sql, ...params);
|
|
const tx = <T>(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 = <T>(sql: string, ...params: SQLInputValue[]) => this.readAll<T>(sql, ...params);
|
|
const tx = <T>(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<string, unknown>) { 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<string, unknown> | 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<string, unknown> | 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<T>(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<string>([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<T>(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<string, unknown>; configAfter: Record<string, unknown>; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); }
|
|
}
|