Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
This commit is contained in:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -1,65 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CommentRecord } from "@shared/types";
|
||||
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
export function buildActivityMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
return {
|
||||
addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) {
|
||||
getDb()
|
||||
.prepare("insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(randomUUID(), input.companyId, input.actor, input.action, input.entityType, input.entityId, input.detail, nowIso());
|
||||
},
|
||||
|
||||
addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }): CommentRecord {
|
||||
const taskRow = getDb().prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined;
|
||||
if (!taskRow) throw new Error("Task not found.");
|
||||
if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company.");
|
||||
const body = normalizeMarkdownText(input.body);
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, body, ts);
|
||||
return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body, createdAt: ts };
|
||||
},
|
||||
|
||||
listComments(taskId: string): CommentRecord[] {
|
||||
return readAll<CommentRecord>(
|
||||
"select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments where task_id = ? order by created_at asc",
|
||||
taskId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the `limit` most recent task comments (author_name + body +
|
||||
* createdAt) for a given set of task ids since `sinceIso`. Used by
|
||||
* GET /api/agents/me/standup to build the "recent comments" feed.
|
||||
*/
|
||||
listRecentTaskComments(
|
||||
taskIds: string[],
|
||||
sinceIso: string,
|
||||
limit: number,
|
||||
): Array<{
|
||||
id: string;
|
||||
taskId: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}> {
|
||||
if (taskIds.length === 0) return [];
|
||||
const placeholders = taskIds.map(() => "?").join(",");
|
||||
return readAll(
|
||||
`select id, task_id as taskId, author_name as authorName, body, created_at as createdAt from task_comments where task_id in (${placeholders}) and created_at > ? order by created_at desc limit ?`,
|
||||
...taskIds,
|
||||
sinceIso,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { type ReadAll, type Transaction, nowIso, parseJson, toBoolean } from "./helpers";
|
||||
|
||||
/**
|
||||
* Agent permission, runtime-state, and heartbeat-policy helpers extracted
|
||||
* from agents-hierarchy.ts. Every method is spread into the final
|
||||
* `buildAgentMethods` result so AppDatabase call-sites remain unchanged.
|
||||
*/
|
||||
export function buildAgentPermissionMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
getAgentPermissions(agentId: string): Record<string, boolean> {
|
||||
const row = getDb()
|
||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { permissions_json: string } | undefined;
|
||||
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||
},
|
||||
|
||||
setAgentPermission(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
permissionKey: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const row = getDb()
|
||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { permissions_json: string } | undefined;
|
||||
const current = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||
if (enabled) {
|
||||
current[permissionKey] = true;
|
||||
} else {
|
||||
delete current[permissionKey];
|
||||
}
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.run(JSON.stringify(current), ts, agentId, companyId);
|
||||
},
|
||||
|
||||
hasAgentPermission(agentId: string, permissionKey: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { permissions_json: string } | undefined;
|
||||
const perms = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||
return perms[permissionKey] === true;
|
||||
},
|
||||
|
||||
getRuntimeState(agentId: string): Record<string, unknown> {
|
||||
const row = getDb()
|
||||
.prepare("SELECT runtime_state_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { runtime_state_json: string | null } | undefined;
|
||||
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
||||
},
|
||||
|
||||
setRuntimeState(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
state: Record<string, unknown>,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.run(JSON.stringify(state), ts, agentId, companyId);
|
||||
},
|
||||
|
||||
getHeartbeatPolicy(agentId: string): {
|
||||
enabled: boolean;
|
||||
intervalSec: number;
|
||||
cooldownSec: number;
|
||||
maxConcurrentRuns: number;
|
||||
wakeOnDemand: boolean;
|
||||
} {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
||||
)
|
||||
.get(agentId) as
|
||||
| {
|
||||
heartbeat_enabled: number;
|
||||
heartbeat_interval_sec: number;
|
||||
heartbeat_policy_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
||||
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
||||
return {
|
||||
enabled: toBoolean(row.heartbeat_enabled),
|
||||
intervalSec: row.heartbeat_interval_sec ?? 120,
|
||||
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
|
||||
maxConcurrentRuns:
|
||||
typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
|
||||
wakeOnDemand: policy.wakeOnDemand !== false,
|
||||
};
|
||||
},
|
||||
|
||||
setHeartbeatPolicy(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
policy: {
|
||||
enabled?: boolean;
|
||||
intervalSec?: number;
|
||||
cooldownSec?: number;
|
||||
maxConcurrentRuns?: number;
|
||||
wakeOnDemand?: boolean;
|
||||
},
|
||||
): void {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
||||
)
|
||||
.get(agentId) as
|
||||
| {
|
||||
heartbeat_enabled: number;
|
||||
heartbeat_interval_sec: number;
|
||||
heartbeat_policy_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
||||
const current = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
||||
const merged = {
|
||||
enabled: policy.enabled ?? toBoolean(row.heartbeat_enabled),
|
||||
intervalSec: policy.intervalSec ?? row.heartbeat_interval_sec ?? 120,
|
||||
cooldownSec:
|
||||
policy.cooldownSec ??
|
||||
(typeof current.cooldownSec === "number" ? current.cooldownSec : 10),
|
||||
maxConcurrentRuns:
|
||||
policy.maxConcurrentRuns ??
|
||||
(typeof current.maxConcurrentRuns === "number" ? current.maxConcurrentRuns : 1),
|
||||
wakeOnDemand: policy.wakeOnDemand ?? (current.wakeOnDemand !== false),
|
||||
};
|
||||
const policyJson = JSON.stringify({
|
||||
cooldownSec: merged.cooldownSec,
|
||||
maxConcurrentRuns: merged.maxConcurrentRuns,
|
||||
wakeOnDemand: merged.wakeOnDemand,
|
||||
});
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentModelStrategy, AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
ensureId,
|
||||
normalizeOptionalId,
|
||||
nowIso,
|
||||
} from "./helpers";
|
||||
export function buildAgentCrudMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
wouldCreateAgentCycle: (agentId: string, reportsTo: string | null) => boolean;
|
||||
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;
|
||||
}) => string;
|
||||
cancelRunForDeletion: (runId: string, taskId: string | null, workspaceId: string | null, summary: string) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
saveAgent(input: {
|
||||
id?: string | null;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department?: string | null;
|
||||
status: AgentRecord["status"];
|
||||
reportsTo?: string | null;
|
||||
connectorId: ConnectorId;
|
||||
workspaceId?: string | null;
|
||||
model?: string | null;
|
||||
metadataJson?: string;
|
||||
capabilities: string;
|
||||
budgetMonthlyUsd: number;
|
||||
/** Optional explicit seniority; inferred from role/title when omitted. */
|
||||
roleLevel?: AgentRoleLevel;
|
||||
/**
|
||||
* Model routing strategy (v39+). `explicit` pins the agent to
|
||||
* `connectorId + model`; `auto` lets the dispatcher pick at run
|
||||
* time. Omitting this leaves the existing column untouched on
|
||||
* update, and uses the DB default ('explicit') on insert.
|
||||
*/
|
||||
modelStrategy?: AgentModelStrategy;
|
||||
}): string {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const metadataJson = input.metadataJson ?? "{}";
|
||||
const department = input.department ?? null;
|
||||
const reportsTo = normalizeOptionalId(input.reportsTo);
|
||||
if (deps.wouldCreateAgentCycle(id, reportsTo)) {
|
||||
throw new Error("Agent reporting relationship would create a cycle.");
|
||||
}
|
||||
const existing = getDb()
|
||||
.prepare("select id from agents where id = ? and company_id = ?")
|
||||
.get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
// Snapshot before-config for revision tracking
|
||||
const beforeAgent = getDb()
|
||||
.prepare(
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, model_strategy, role_level, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.get(id, input.companyId) as Record<string, unknown> | undefined;
|
||||
// Preserve existing modelStrategy and roleLevel when the caller omits
|
||||
// them, so non-related updates (e.g. updateAgentWorkspaceBinding) do
|
||||
// not accidentally flip routing or seniority.
|
||||
const effectiveStrategy = input.modelStrategy ?? (beforeAgent?.model_strategy as string | undefined) ?? "explicit";
|
||||
const effectiveRoleLevel = input.roleLevel ?? (beforeAgent?.role_level as string | undefined) ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, model_strategy = ?, role_level = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(
|
||||
input.name,
|
||||
input.role,
|
||||
input.title,
|
||||
department,
|
||||
input.status,
|
||||
reportsTo,
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
effectiveStrategy,
|
||||
effectiveRoleLevel,
|
||||
metadataJson,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
ts,
|
||||
id,
|
||||
input.companyId,
|
||||
);
|
||||
// Record config revision if fields changed
|
||||
if (beforeAgent) {
|
||||
const afterConfig = {
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
department,
|
||||
capabilities: input.capabilities,
|
||||
connectorId: input.connectorId,
|
||||
model: normalizeOptionalId(input.model),
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
metadataJson,
|
||||
};
|
||||
const beforeConfig = {
|
||||
name: beforeAgent.name,
|
||||
role: beforeAgent.role,
|
||||
title: beforeAgent.title,
|
||||
department: beforeAgent.department,
|
||||
capabilities: beforeAgent.capabilities,
|
||||
connectorId: beforeAgent.connector_id,
|
||||
model: beforeAgent.model,
|
||||
budgetMonthlyUsd: beforeAgent.budget_monthly_usd,
|
||||
metadataJson: beforeAgent.metadata_json,
|
||||
};
|
||||
const changedKeys = Object.keys(afterConfig).filter(
|
||||
(k) =>
|
||||
JSON.stringify((afterConfig as Record<string, unknown>)[k]) !==
|
||||
JSON.stringify((beforeConfig as Record<string, unknown>)[k]),
|
||||
);
|
||||
if (changedKeys.length > 0) {
|
||||
try {
|
||||
deps.recordConfigRevision({
|
||||
companyId: input.companyId,
|
||||
agentId: id,
|
||||
changedKeys,
|
||||
configBefore: beforeConfig,
|
||||
configAfter: afterConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(`[database/agents] recordConfigRevision failed for agent ${id}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const insertStrategy = input.modelStrategy ?? "explicit";
|
||||
const insertRoleLevel = input.roleLevel ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, model_strategy, role_level, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.companyId,
|
||||
input.name,
|
||||
input.role,
|
||||
input.title,
|
||||
department,
|
||||
input.status,
|
||||
reportsTo,
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
insertStrategy,
|
||||
insertRoleLevel,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
metadataJson,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteAgent(id: string, companyId: string): void {
|
||||
const activeAgentRuns = readAll<{ id: string; taskId: string | null; workspaceId: string | null }>(
|
||||
"select id, task_id as taskId, workspace_id as workspaceId from runs where agent_id = ? and company_id = ? and status in ('queued', 'running')",
|
||||
id,
|
||||
companyId,
|
||||
);
|
||||
transaction(() => {
|
||||
for (const run of activeAgentRuns) {
|
||||
deps.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Agent deleted.");
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set assignee_agent_id = null where assignee_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update goals set owner_agent_id = null where owner_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update projects set lead_agent_id = null where lead_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare("update agents set reports_to = null where reports_to = ? and company_id = ?")
|
||||
.run(id, companyId);
|
||||
// Clean up orphaned approvals referencing this agent
|
||||
getDb()
|
||||
.prepare(
|
||||
"update approvals set requested_by_agent_id = null where requested_by_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update approvals set related_agent_id = null where related_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
// Clean up meetings organized by this agent
|
||||
getDb()
|
||||
.prepare(
|
||||
"update meetings set organizer_agent_id = null where organizer_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
// Clean up documents authored/reviewed by this agent
|
||||
getDb()
|
||||
.prepare(
|
||||
"update documents set author_agent_id = null where author_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update documents set reviewer_agent_id = null where reviewer_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
// Clean up knowledge entries authored by this agent
|
||||
getDb()
|
||||
.prepare(
|
||||
"update knowledge_base set author_agent_id = null where author_agent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
// Remove wakeup requests for this agent
|
||||
getDb()
|
||||
.prepare("DELETE FROM wakeup_requests WHERE agent_id = ? AND company_id = ?")
|
||||
.run(id, companyId);
|
||||
getDb().prepare("delete from agents where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
updateAgentStatus(agentId: string, status: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
getDb()
|
||||
.prepare("update agents set status = ?, updated_at = ? where id = ?")
|
||||
.run(status, ts, agentId);
|
||||
},
|
||||
|
||||
hasAgentAssignedTasks(agentId: string, companyId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select 1 from tasks where assignee_agent_id = ? and company_id = ? and status in ('todo', 'in_progress', 'blocked') limit 1",
|
||||
)
|
||||
.get(agentId, companyId);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
isAgentPendingApproval(agentId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare("select status from agents where id = ?")
|
||||
.get(agentId) as { status: string } | undefined;
|
||||
return row?.status === "pending_approval";
|
||||
},
|
||||
|
||||
findMentionedAgentIds(companyId: string, text: string): string[] {
|
||||
if (!text.includes("@")) return [];
|
||||
const mentionTokens = new Set<string>();
|
||||
const mentionRegex = /@([\p{L}\p{N} _-]{1,80})(?=[\s,!?.;:\])}>]|$)/gu;
|
||||
for (const match of text.toLowerCase().matchAll(mentionRegex)) {
|
||||
const token = match[1]?.trim();
|
||||
if (token) mentionTokens.add(token);
|
||||
}
|
||||
if (mentionTokens.size === 0) return [];
|
||||
|
||||
const patterns = Array.from(mentionTokens).flatMap((token) => {
|
||||
const words = token.split(/\s+/).filter(Boolean);
|
||||
return words.length > 1 ? [token, words[0]!] : [token];
|
||||
});
|
||||
const placeholders = patterns.map(() => "lower(name) = ? or lower(role) = ? or lower(title) = ?").join(" or ");
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
for (const p of patterns) {
|
||||
params.push(p, p, p);
|
||||
}
|
||||
const agents = readAll<{ id: string; name: string; role: string; title: string }>(
|
||||
`SELECT id, name, role, title FROM agents WHERE company_id = ? AND (${placeholders})`,
|
||||
...params,
|
||||
);
|
||||
if (agents.length === 0) return [];
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
// Per-alias agent set. Storing a `Set` (not an array) means the
|
||||
// same agent contributing the same string via multiple fields
|
||||
// (e.g. name === role === "CEO") collapses to one entry. Before
|
||||
// this fix the duplicate push made the alias look non-unique, the
|
||||
// unique-alias filter dropped it, and every @mention in a scaffold
|
||||
// where role mirrored name silently missed.
|
||||
const aliasMap = new Map<string, Set<string>>();
|
||||
const registerAlias = (alias: string, agentId: string) => {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
const existing = aliasMap.get(normalized) ?? new Set<string>();
|
||||
existing.add(agentId);
|
||||
aliasMap.set(normalized, existing);
|
||||
};
|
||||
for (const agent of agents) {
|
||||
registerAlias(agent.name, agent.id);
|
||||
registerAlias(agent.role, agent.id);
|
||||
registerAlias(agent.title, agent.id);
|
||||
}
|
||||
|
||||
const matched = new Set<string>();
|
||||
// Only aliases that unambiguously resolve to a single agent are
|
||||
// treated as mentions. Two agents sharing a role ("CEO" appearing
|
||||
// twice in the company) make `@ceo` ambiguous and we refuse to
|
||||
// pick one silently.
|
||||
const uniqueAliases = [...aliasMap.entries()]
|
||||
.filter(([, ids]) => ids.size === 1)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [alias, ids] of uniqueAliases) {
|
||||
const pattern = `@${alias}`;
|
||||
const idx = lowerText.indexOf(pattern);
|
||||
if (idx === -1) continue;
|
||||
const afterIdx = idx + pattern.length;
|
||||
if (afterIdx >= lowerText.length || /[\s,!?.;:\])}>]/.test(lowerText[afterIdx]!)) {
|
||||
const [onlyId] = ids;
|
||||
if (onlyId) matched.add(onlyId);
|
||||
}
|
||||
}
|
||||
return [...matched];
|
||||
},
|
||||
|
||||
countActiveRunsForAgent(agentId: string, companyId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as cnt FROM runs WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'running')",
|
||||
)
|
||||
.get(agentId, companyId) as { cnt: number } | undefined;
|
||||
return row?.cnt ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
nowIso,
|
||||
toBoolean,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* Agent row shape returned by listAgentsForApi. Mirrors the historical
|
||||
* inline SQL projection in routes-agents.ts so HTTP response bodies
|
||||
* stay byte-stable. heartbeatEnabled is exposed as a boolean.
|
||||
*/
|
||||
export type AgentApiRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string | null;
|
||||
department: string | null;
|
||||
status: string;
|
||||
reportsTo: string | null;
|
||||
connectorId: string;
|
||||
workspaceId: string | null;
|
||||
model: string | null;
|
||||
capabilities: string | null;
|
||||
budgetMonthlyUsd: number | null;
|
||||
spentMonthlyUsd: number | null;
|
||||
metadataJson: string;
|
||||
heartbeatEnabled: boolean;
|
||||
heartbeatIntervalSec: number | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
export function buildAgentHierarchyMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
_deps: {
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
getChainOfCommand(agentId: string): AgentRecord[] {
|
||||
const rows = readAll<AgentRecord & { depth: number }>(
|
||||
`WITH RECURSIVE chain AS (
|
||||
SELECT id, company_id as companyId, name, role, title, department, status,
|
||||
reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId,
|
||||
model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd,
|
||||
spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson,
|
||||
heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec,
|
||||
last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt,
|
||||
0 as depth
|
||||
FROM agents WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT a.id, a.company_id, a.name, a.role, a.title, a.department, a.status,
|
||||
a.reports_to, a.connector_id, a.workspace_id,
|
||||
a.model, a.capabilities, a.role_level, a.model_strategy, a.budget_monthly_usd,
|
||||
a.spent_monthly_usd, a.metadata_json,
|
||||
a.heartbeat_enabled, a.heartbeat_interval_sec,
|
||||
a.last_heartbeat_at, a.session_id, a.created_at, a.updated_at,
|
||||
c.depth + 1
|
||||
FROM agents a JOIN chain c ON a.id = c.reportsTo
|
||||
WHERE c.depth < 20
|
||||
)
|
||||
SELECT * FROM chain ORDER BY depth`,
|
||||
agentId,
|
||||
);
|
||||
// Skip self (depth 0); return ancestors from direct manager upward.
|
||||
return rows.slice(1).map((record) => ({
|
||||
...record,
|
||||
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
||||
})) as AgentRecord[];
|
||||
},
|
||||
|
||||
getDirectReports(agentId: string, companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?",
|
||||
companyId,
|
||||
agentId,
|
||||
).map((record) => ({
|
||||
...record,
|
||||
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
||||
})) as AgentRecord[];
|
||||
},
|
||||
|
||||
setHeartbeat(agentId: string, enabled: boolean, intervalSec: number): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set heartbeat_enabled = ?, heartbeat_interval_sec = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(enabled ? 1 : 0, intervalSec, nowIso(), agentId);
|
||||
},
|
||||
|
||||
markHeartbeatAt(agentId: string): void {
|
||||
getDb()
|
||||
.prepare("update agents set last_heartbeat_at = ? where id = ?")
|
||||
.run(nowIso(), agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all agents for a company with the full API projection used
|
||||
* by GET /api/companies/:companyId/agents. Mirrors the historical
|
||||
* inline SQL and maps heartbeat_enabled into a boolean.
|
||||
*/
|
||||
listAgentsForApi(companyId: string): AgentApiRow[] {
|
||||
return readAll<AgentApiRow & { heartbeatEnabled: number | boolean }>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
|
||||
companyId,
|
||||
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim direct-reports projection used by
|
||||
* GET /api/agents/me/standup — only (id, name, role, status).
|
||||
*/
|
||||
listDirectReportsForStandup(
|
||||
companyId: string,
|
||||
managerId: string,
|
||||
): Array<{ id: string; name: string; role: string; status: string }> {
|
||||
return readAll(
|
||||
"select id, name, role, status from agents where company_id = ? and reports_to = ? order by created_at asc",
|
||||
companyId,
|
||||
managerId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns per-report task-status counts for the standup aggregation.
|
||||
* Joins agents with their tasks and aggregates statuses (in_progress,
|
||||
* in_review, blocked, done).
|
||||
*/
|
||||
listReportStatusesForStandup(companyId: string, managerId: string): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
tasksInProgress: number;
|
||||
tasksInReview: number;
|
||||
tasksBlocked: number;
|
||||
tasksDone: number;
|
||||
}> {
|
||||
return readAll(
|
||||
`select agents.id as id, agents.name as name, agents.role as role, agents.status as status,
|
||||
coalesce(sum(case when tasks.status = 'in_progress' then 1 else 0 end), 0) as tasksInProgress,
|
||||
coalesce(sum(case when tasks.status = 'in_review' then 1 else 0 end), 0) as tasksInReview,
|
||||
coalesce(sum(case when tasks.status = 'blocked' then 1 else 0 end), 0) as tasksBlocked,
|
||||
coalesce(sum(case when tasks.status = 'done' then 1 else 0 end), 0) as tasksDone
|
||||
from agents
|
||||
left join tasks on tasks.assignee_agent_id = agents.id and tasks.company_id = agents.company_id
|
||||
where agents.company_id = ? and agents.reports_to = ?
|
||||
group by agents.id, agents.name, agents.role, agents.status
|
||||
order by agents.created_at asc`,
|
||||
companyId,
|
||||
managerId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the duplicate-detection projection used by
|
||||
* POST /api/companies/:id/agents to reject hires with a name that
|
||||
* already belongs to a non-terminated agent.
|
||||
*/
|
||||
listExistingAgentsForHireCheck(
|
||||
companyId: string,
|
||||
): Array<{ id: string; name: string; role: string }> {
|
||||
return readAll<{ id: string; name: string; role: string }>(
|
||||
"select id, name, role from agents where company_id = ? and status != 'terminated'",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
listHeartbeatEligibleAgents(companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
companyId,
|
||||
).map((record) => ({
|
||||
...record,
|
||||
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
||||
})) as AgentRecord[];
|
||||
},
|
||||
|
||||
getCeoForCompany(
|
||||
companyId: string,
|
||||
): { id: string; name: string; lastHeartbeatAt: string | null } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, name, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and reports_to is null and status != 'terminated' limit 1",
|
||||
)
|
||||
.get(companyId) as
|
||||
| { id: string; name: string; lastHeartbeatAt: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
listActiveAgentsForCompany(
|
||||
companyId: string,
|
||||
): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
department: string | null;
|
||||
heartbeatIntervalSec: number | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
}> {
|
||||
return readAll<{
|
||||
id: string;
|
||||
name: string;
|
||||
department: string | null;
|
||||
heartbeatIntervalSec: number | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
}>(
|
||||
"select id, name, department, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
getIdleAgentsForDepartment(
|
||||
companyId: string,
|
||||
department: string,
|
||||
): Array<{ id: string; name: string }> {
|
||||
return readAll(
|
||||
"select id, name from agents where company_id = ? and department = ? and status in ('idle', 'active') order by spent_monthly_usd asc",
|
||||
companyId,
|
||||
department,
|
||||
);
|
||||
},
|
||||
|
||||
getManagerForAgent(
|
||||
agentId: string,
|
||||
): { id: string; name: string; companyId: string } | null {
|
||||
const agent = getDb()
|
||||
.prepare("select reports_to, company_id from agents where id = ?")
|
||||
.get(agentId) as { reports_to: string | null; company_id: string } | undefined;
|
||||
if (!agent || !agent.reports_to) return null;
|
||||
const manager = getDb()
|
||||
.prepare("select id, name from agents where id = ?")
|
||||
.get(agent.reports_to) as { id: string; name: string } | undefined;
|
||||
return manager ? { ...manager, companyId: agent.company_id } : null;
|
||||
},
|
||||
|
||||
resetMonthlyBudgets(companyId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set spent_monthly_usd = 0, updated_at = ? where company_id = ? and spent_monthly_usd > 0",
|
||||
)
|
||||
.run(nowIso(), companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of non-terminated agents in a department. Used by
|
||||
* `resolveMessageWakeTargets` to fan out "department" channel messages.
|
||||
*/
|
||||
listDepartmentAgentIds(companyId: string, department: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
|
||||
companyId,
|
||||
department,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of every agent that is eligible to receive a routine
|
||||
* company-wide broadcast. Paused, pending-approval, and terminated
|
||||
* agents are excluded — they should not be woken by normal chatter;
|
||||
* only incidents reach the broader pingable set. Used by
|
||||
* `resolveMessageWakeTargets` for the "company" channel so the
|
||||
* semantic matches Slack (all-hands) rather than board-only.
|
||||
*/
|
||||
listCompanyBroadcastAgentIds(companyId: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')",
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of agents that can receive incident pings: everybody not
|
||||
* terminated or still pending approval.
|
||||
*/
|
||||
listIncidentPingableAgentIds(companyId: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the agent's display name, or null if the agent no longer
|
||||
* exists. Used for notification subject lines.
|
||||
*/
|
||||
findAgentName(agentId: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare("select name from agents where id = ? limit 1")
|
||||
.get(agentId) as { name: string } | undefined;
|
||||
return row?.name ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns (id, name) pairs for the given ids, scoped to company. Used
|
||||
* for batched name resolution when fanning out message wake targets.
|
||||
*/
|
||||
listAgentNamesByIds(companyId: string, agentIds: readonly string[]): Array<{ id: string; name: string }> {
|
||||
if (agentIds.length === 0) return [];
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
return readAll<{ id: string; name: string }>(
|
||||
`select id, name from agents where id in (${placeholders}) and company_id = ?`,
|
||||
...agentIds,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of active agents with the given ids, filtered to the
|
||||
* company scope. Used by project-channel wake targeting to drop
|
||||
* terminated members before pinging.
|
||||
*/
|
||||
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]): string[] {
|
||||
if (agentIds.length === 0) return [];
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
return readAll<{ id: string }>(
|
||||
`select id from agents where id in (${placeholders}) and company_id = ? and status != 'terminated'`,
|
||||
...agentIds,
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { buildAgentCrudMethods } from "./agents-crud";
|
||||
import { buildAgentHierarchyMethods } from "./agents-hierarchy";
|
||||
import { buildAgentPermissionMethods } from "./agent-permissions";
|
||||
|
||||
export function buildAgentMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
wouldCreateAgentCycle: (agentId: string, reportsTo: string | null) => boolean;
|
||||
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;
|
||||
}) => string;
|
||||
cancelRunForDeletion: (runId: string, taskId: string | null, workspaceId: string | null, summary: string) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
...buildAgentCrudMethods(getDb, readAll, transaction, deps),
|
||||
...buildAgentHierarchyMethods(getDb, readAll, transaction, { getAgent: deps.getAgent }),
|
||||
...buildAgentPermissionMethods(getDb, readAll, transaction),
|
||||
};
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ApprovalRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso, parseJson, toBoolean } from "./helpers";
|
||||
|
||||
export interface ApprovalPolicyRecord {
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: boolean;
|
||||
requiredMode: "any" | "ai_ceo" | "ai_cofounder";
|
||||
autoApprovable: boolean;
|
||||
}
|
||||
|
||||
export function buildApprovalMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getAgent: (id: string) => { companyId: string; status: string };
|
||||
},
|
||||
) {
|
||||
return {
|
||||
getApprovalRecord(id: string) {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?",
|
||||
)
|
||||
.get(id) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined;
|
||||
if (!row) throw new Error(`Approval not found: ${id}`);
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* Non-throwing lookup by id. Returns null when the approval does not
|
||||
* exist. Used by approval routes that need to branch on 404 rather
|
||||
* than catching an exception.
|
||||
*/
|
||||
findApprovalById(id: string): (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?",
|
||||
)
|
||||
.get(id) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
requestApproval(input: {
|
||||
companyId: string;
|
||||
relatedTaskId?: string | null;
|
||||
requestedByAgentId?: string | null;
|
||||
relatedAgentId?: string | null;
|
||||
type: string;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson?: string | null;
|
||||
}) {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
let relatedAgentId = normalizeOptionalId(input.relatedAgentId);
|
||||
if (!relatedAgentId && input.type === "hire_agent" && input.payloadJson) {
|
||||
try {
|
||||
const payload = JSON.parse(input.payloadJson) as { agentId?: string };
|
||||
relatedAgentId = payload.agentId ?? null;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[db/approvals] malformed payloadJson on hire_agent request for company ${input.companyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.type === "hire_agent") {
|
||||
if (!relatedAgentId) {
|
||||
throw new Error("hire_agent approvals require a linked pending agent.");
|
||||
}
|
||||
const agent = deps.getAgent(relatedAgentId);
|
||||
if (agent.companyId !== input.companyId) {
|
||||
throw new Error(`Agent ${relatedAgentId} does not belong to company ${input.companyId}.`);
|
||||
}
|
||||
if (agent.status !== "pending_approval") {
|
||||
throw new Error(`hire_agent approvals can only target agents pending approval. Current status: ${agent.status}.`);
|
||||
}
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'pending', ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id, input.companyId, normalizeOptionalId(input.relatedTaskId),
|
||||
normalizeOptionalId(input.requestedByAgentId), relatedAgentId,
|
||||
input.type, input.payloadSummary, input.impactSummary,
|
||||
input.payloadJson ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) {
|
||||
const approval = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?",
|
||||
)
|
||||
.get(input.approvalId) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined;
|
||||
if (!approval) throw new Error(`Approval not found: ${input.approvalId}`);
|
||||
if (input.companyId && approval.companyId !== input.companyId) {
|
||||
throw new Error(`Approval ${input.approvalId} does not belong to company ${input.companyId}.`);
|
||||
}
|
||||
if (!["pending", "revision_requested"].includes(approval.state)) {
|
||||
throw new Error(`Approval ${input.approvalId} is already ${approval.state} and cannot be re-decided.`);
|
||||
}
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare("update approvals set state = ?, decision_note = ?, updated_at = ? where id = ?")
|
||||
.run(input.state, input.decisionNote, ts, input.approvalId);
|
||||
if (approval.type === "hire_agent") {
|
||||
const agentId = approval.relatedAgentId
|
||||
?? parseJson<{ agentId?: string } | null>(approval.payloadJson, null)?.agentId
|
||||
?? null;
|
||||
if (!agentId) {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`);
|
||||
}
|
||||
if (input.state === "approved") {
|
||||
const result = getDb()
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
|
||||
.run(ts, agentId, approval.companyId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} could not activate agent ${agentId}; the agent is missing or no longer pending approval.`);
|
||||
}
|
||||
}
|
||||
if (input.state === "rejected") {
|
||||
const result = getDb()
|
||||
.prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
|
||||
.run(ts, agentId, approval.companyId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} could not reject agent ${agentId}; the agent is missing or no longer pending approval.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteApproval(id: string, companyId: string) {
|
||||
getDb().prepare("delete from approvals where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the requesting agent's pending approvals for standup
|
||||
* aggregation (id, type, state, payloadSummary, decisionNote).
|
||||
*/
|
||||
listAgentPendingApprovals(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
state: string;
|
||||
payloadSummary: string | null;
|
||||
decisionNote: string | null;
|
||||
}> {
|
||||
return readAll(
|
||||
"select id, type, state, payload_summary as payloadSummary, decision_note as decisionNote from approvals where company_id = ? and requested_by_agent_id = ? and state = 'pending' order by created_at desc",
|
||||
companyId,
|
||||
agentId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns up to `limit` of the requesting agent's resolved approvals
|
||||
* for standup aggregation (state != 'pending').
|
||||
*/
|
||||
listAgentResolvedApprovals(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
limit = 5,
|
||||
): Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
state: string;
|
||||
payloadSummary: string | null;
|
||||
decisionNote: string | null;
|
||||
}> {
|
||||
return readAll(
|
||||
"select id, type, state, payload_summary as payloadSummary, decision_note as decisionNote from approvals where company_id = ? and requested_by_agent_id = ? and state != 'pending' order by created_at desc limit ?",
|
||||
companyId,
|
||||
agentId,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns pending `hire_agent` approval payloads for a company, used
|
||||
* during hire-request creation to detect duplicate names across
|
||||
* concurrent requests.
|
||||
*/
|
||||
listPendingHireApprovalPayloads(
|
||||
companyId: string,
|
||||
): Array<{ payloadJson: string | null }> {
|
||||
return readAll<{ payloadJson: string | null }>(
|
||||
"select payload_json as payloadJson from approvals where company_id = ? and type = 'hire_agent' and state = 'pending'",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the full approval row projection used by
|
||||
* GET /api/companies/:id/approvals. Optional filters match the
|
||||
* historical inline SQL behaviour.
|
||||
*/
|
||||
listApprovalsForApi(companyId: string, filters: {
|
||||
requestedByAgentId?: string;
|
||||
relatedTaskId?: string;
|
||||
statuses?: string[] | null;
|
||||
}): Array<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
relatedTaskId: string | null;
|
||||
relatedAgentId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
type: string;
|
||||
payloadSummary: string | null;
|
||||
impactSummary: string | null;
|
||||
payloadJson: string | null;
|
||||
decisionNote: string | null;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}> {
|
||||
let sql = "select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where company_id = ?";
|
||||
const args: SQLInputValue[] = [companyId];
|
||||
if (filters.requestedByAgentId) {
|
||||
sql += " and requested_by_agent_id = ?";
|
||||
args.push(filters.requestedByAgentId);
|
||||
}
|
||||
if (filters.relatedTaskId) {
|
||||
sql += " and related_task_id = ?";
|
||||
args.push(filters.relatedTaskId);
|
||||
}
|
||||
if (filters.statuses && filters.statuses.length > 0) {
|
||||
sql += ` and state in (${filters.statuses.map(() => "?").join(", ")})`;
|
||||
args.push(...filters.statuses);
|
||||
}
|
||||
sql += " order by created_at desc";
|
||||
return readAll(sql, ...args);
|
||||
},
|
||||
|
||||
getApprovalPolicy(approvalType: string): ApprovalPolicyRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies where approval_type = ?",
|
||||
)
|
||||
.get(approvalType) as {
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: number;
|
||||
requiredMode: "any" | "ai_ceo" | "ai_cofounder";
|
||||
autoApprovable: number;
|
||||
} | undefined;
|
||||
return row
|
||||
? {
|
||||
approvalType: row.approvalType,
|
||||
scopeType: row.scopeType,
|
||||
blocksExecution: toBoolean(row.blocksExecution),
|
||||
requiredMode: row.requiredMode,
|
||||
autoApprovable: toBoolean(row.autoApprovable),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
|
||||
getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null {
|
||||
const company = getDb()
|
||||
.prepare("select execution_mode as executionMode from companies where id = ?")
|
||||
.get(companyId) as { executionMode: "ai_ceo" | "ai_cofounder" } | undefined;
|
||||
if (!company) {
|
||||
throw new Error(`Company not found for approval gate: ${companyId}`);
|
||||
}
|
||||
const rows = readAll<ApprovalRecord>(
|
||||
`select id, company_id as companyId, related_task_id as relatedTaskId,
|
||||
requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId,
|
||||
a.type, payload_summary as payloadSummary, impact_summary as impactSummary,
|
||||
payload_json as payloadJson, decision_note as decisionNote, state,
|
||||
a.created_at as createdAt, a.updated_at as updatedAt
|
||||
from approvals a
|
||||
inner join approval_policies p on p.approval_type = a.type
|
||||
where a.company_id = ?
|
||||
and a.state = 'pending'
|
||||
and p.blocks_execution = 1
|
||||
and (p.required_mode = 'any' or p.required_mode = ?)
|
||||
and (
|
||||
(p.scope_type = 'related_agent' and a.related_agent_id = ?)
|
||||
or (
|
||||
p.scope_type = 'requester_or_task'
|
||||
and (
|
||||
a.requested_by_agent_id = ?
|
||||
or (? is not null and a.related_task_id = ?)
|
||||
)
|
||||
)
|
||||
)
|
||||
order by a.created_at asc limit 1`,
|
||||
companyId,
|
||||
company.executionMode,
|
||||
agentId,
|
||||
agentId,
|
||||
taskId,
|
||||
taskId,
|
||||
);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
},
|
||||
|
||||
resubmitApproval(approvalId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE approvals SET state = 'pending', decision_note = '', updated_at = ? WHERE id = ? AND company_id = ? AND state = 'revision_requested'",
|
||||
).run(ts, approvalId, companyId);
|
||||
},
|
||||
|
||||
addApprovalComment(input: {
|
||||
companyId: string; approvalId: string; authorAgentId?: string | null;
|
||||
authorUserId?: string | null; body: string;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"INSERT INTO approval_comments (id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.approvalId, input.authorAgentId ?? null, input.authorUserId ?? null, input.body, ts, ts);
|
||||
return id;
|
||||
},
|
||||
|
||||
listApprovalComments(approvalId: string, companyId: string) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; approval_id: string; author_agent_id: string | null;
|
||||
author_user_id: string | null; body: string; created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at FROM approval_comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC",
|
||||
approvalId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, approvalId: r.approval_id,
|
||||
authorAgentId: r.author_agent_id, authorUserId: r.author_user_id,
|
||||
body: r.body, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null): void {
|
||||
getDb().prepare(
|
||||
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
).run(taskId, approvalId, companyId, linkedByAgentId ?? null, nowIso());
|
||||
},
|
||||
|
||||
unlinkTaskFromApproval(taskId: string, approvalId: string): void {
|
||||
getDb().prepare(
|
||||
"DELETE FROM task_approval_links WHERE task_id = ? AND approval_id = ?",
|
||||
).run(taskId, approvalId);
|
||||
},
|
||||
|
||||
listApprovalsForTask(taskId: string): string[] {
|
||||
return readAll<{ approval_id: string }>(
|
||||
"SELECT approval_id FROM task_approval_links WHERE task_id = ?", taskId,
|
||||
).map((r) => r.approval_id);
|
||||
},
|
||||
|
||||
listTasksForApproval(approvalId: string): string[] {
|
||||
return readAll<{ task_id: string }>(
|
||||
"SELECT task_id FROM task_approval_links WHERE approval_id = ?", approvalId,
|
||||
).map((r) => r.task_id);
|
||||
},
|
||||
|
||||
linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null): void {
|
||||
if (taskIds.length === 0) return;
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
const stmt = getDb().prepare(
|
||||
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
for (const taskId of taskIds) {
|
||||
stmt.run(taskId, approvalId, companyId, linkedByAgentId ?? null, ts);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the first pending approval linked to a task (id + type),
|
||||
* or null when none exists. Used by task-quality-gates to decide
|
||||
* whether an in-review task still has a blocking approval.
|
||||
*/
|
||||
findPendingApprovalForTask(companyId: string, taskId: string): { id: string; type: string } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT id, type FROM approvals
|
||||
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(companyId, taskId) as { id: string; type: string } | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns every approval in the company whose `type` matches exactly.
|
||||
* Used by the approvals POST route to enforce ordering invariants
|
||||
* (e.g. "approve_ceo_strategy must trail hire_agent").
|
||||
*/
|
||||
listCompanyApprovalsByType(companyId: string, type: string): Array<{ id: string; state: string }> {
|
||||
return readAll<{ id: string; state: string }>(
|
||||
`SELECT id, state FROM approvals WHERE company_id = ? AND type = ? ORDER BY created_at ASC`,
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true when the given agent has a pending review-type approval
|
||||
* against one of their tasks. Used by checkpoint-engine to decide
|
||||
* whether heartbeat should pause in co-founder mode.
|
||||
*/
|
||||
hasPendingReviewApprovalForAgent(companyId: string, agentId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT 1 as hit FROM approvals a
|
||||
JOIN tasks t ON a.related_task_id = t.id
|
||||
JOIN approval_policies p ON p.approval_type = a.type
|
||||
WHERE a.company_id = ? AND a.state = 'pending'
|
||||
AND t.assignee_agent_id = ?
|
||||
AND p.blocks_execution = 1
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(companyId, agentId) as { hit: number } | undefined;
|
||||
return row !== undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { WorkflowPipelineRecord } from "@shared/types";
|
||||
import type { ReadAll, SqliteRow } from "./helpers";
|
||||
import { ensureId, nowIso } from "./helpers";
|
||||
|
||||
export function buildAutomationMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
deps: {
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
saveAutomationRule(input: {
|
||||
id?: string | null; companyId: string; name: string; description?: string;
|
||||
trigger: string; conditionsJson?: string; action: string; actionConfigJson?: string;
|
||||
sourceDepartment?: string | null; targetDepartment?: string | null;
|
||||
priority?: number; status?: string;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from automation_rules where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Automation rule ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update automation_rules set name = ?, description = ?, trigger = ?, conditions_json = ?, action = ?, action_config_json = ?, source_department = ?, target_department = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into automation_rules (id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
).run(id, input.companyId, input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, ts);
|
||||
}
|
||||
deps.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "automation_rule", entityId: id, detail: `${existing ? "Updated" : "Created"} automation rule: ${input.name}` });
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteAutomationRule(id: string, companyId: string) {
|
||||
getDb().prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
listAutomationRuleNames(companyId: string): Set<string> {
|
||||
return new Set(
|
||||
readAll<{ name: string }>(
|
||||
"select name from automation_rules where company_id = ?",
|
||||
companyId,
|
||||
).map((row) => row.name),
|
||||
);
|
||||
},
|
||||
|
||||
toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") {
|
||||
getDb().prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId);
|
||||
},
|
||||
|
||||
recordAutomationExecution(ruleId: string, companyId: string, ruleName: string, trigger: string, action: string, detail: string) {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb().prepare("insert into automation_log (id, company_id, rule_id, rule_name, trigger, action, detail, executed_at) values (?, ?, ?, ?, ?, ?, ?, ?)").run(id, companyId, ruleId, ruleName, trigger, action, detail, ts);
|
||||
getDb().prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ? and company_id = ?").run(ts, ruleId, companyId);
|
||||
},
|
||||
|
||||
getAutomationLog(companyId: string) {
|
||||
return readAll<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>(
|
||||
"select rule_id as ruleId, rule_name as ruleName, trigger, action, executed_at as executedAt from automation_log where company_id = ? order by executed_at desc limit 100",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
listActiveAutomationRules(companyId: string, trigger: string): Array<{
|
||||
id: string; name: string; trigger: string; conditionsJson: string;
|
||||
action: string; actionConfigJson: string; sourceDepartment: string | null;
|
||||
targetDepartment: string | null; priority: number;
|
||||
}> {
|
||||
return readAll(
|
||||
"select id, name, trigger, conditions_json as conditionsJson, action, action_config_json as actionConfigJson, source_department as sourceDepartment, target_department as targetDepartment, priority from automation_rules where company_id = ? and trigger = ? and status = 'active' order by priority desc",
|
||||
companyId, trigger,
|
||||
);
|
||||
},
|
||||
|
||||
saveWorkflow(input: {
|
||||
id?: string | null; companyId: string; name: string; description?: string;
|
||||
stepsJson?: string; triggerType: string; triggerConfigJson?: string; status?: string;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from workflow_pipelines where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Workflow ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into workflow_pipelines (id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)",
|
||||
).run(id, input.companyId, input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, ts);
|
||||
}
|
||||
deps.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "workflow", entityId: id, detail: `${existing ? "Updated" : "Created"} workflow: ${input.name}` });
|
||||
return id;
|
||||
},
|
||||
|
||||
getWorkflowRecord(id: string, companyId: string): WorkflowPipelineRecord | null {
|
||||
const row = getDb().prepare(
|
||||
"select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where id = ? and company_id = ?",
|
||||
).get(id, companyId) as WorkflowPipelineRecord | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
updateWorkflowExecution(input: {
|
||||
id: string; companyId: string; status: string;
|
||||
currentStepIndex: number; stepsJson: string;
|
||||
incrementRunCount?: boolean; lastRunAt?: string | null;
|
||||
}) {
|
||||
const ts = nowIso();
|
||||
const runCountClause = input.incrementRunCount ? ", run_count = run_count + 1" : "";
|
||||
const lastRunAt = input.lastRunAt ?? null;
|
||||
getDb().prepare(
|
||||
`update workflow_pipelines
|
||||
set status = ?, current_step_index = ?, steps_json = ?, last_run_at = ?, updated_at = ?${runCountClause}
|
||||
where id = ? and company_id = ?`,
|
||||
).run(input.status, input.currentStepIndex, input.stepsJson, lastRunAt, ts, input.id, input.companyId);
|
||||
},
|
||||
|
||||
deleteWorkflow(id: string, companyId: string) {
|
||||
getDb().prepare("delete from workflow_pipelines where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] {
|
||||
return readAll<SqliteRow>(
|
||||
"select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc",
|
||||
companyId, triggerType,
|
||||
) as WorkflowPipelineRecord[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { copyFile, cp, rm } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync, backup as backupDatabase } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
|
||||
function readSchemaVersion(db: DatabaseSync): number {
|
||||
const row = db
|
||||
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
||||
.get() as { v: number } | undefined;
|
||||
return row?.v ?? 0;
|
||||
}
|
||||
|
||||
function readGitSha(): string | null {
|
||||
try {
|
||||
const out = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
cwd: process.cwd(),
|
||||
timeout: 1000,
|
||||
});
|
||||
const sha = out.toString("utf8").trim();
|
||||
return sha.length === 40 ? sha : null;
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
"[database/backup] git rev-parse HEAD failed; manifest.gitSha will be null",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function timestampSegment(): string {
|
||||
// Filesystem-safe variant of ISO 8601 (YYYYMMDD-HHmmss-SSS).
|
||||
const now = new Date();
|
||||
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
|
||||
return (
|
||||
`${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-` +
|
||||
`${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}-` +
|
||||
`${pad(now.getUTCMilliseconds(), 3)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function copyIfExists(source: string, destination: string) {
|
||||
if (!existsSync(source)) {
|
||||
return;
|
||||
}
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
|
||||
export function buildBackupMethods(
|
||||
deps: {
|
||||
getDb: () => DatabaseSync;
|
||||
setDb: (db: DatabaseSync) => void;
|
||||
openConnection: () => DatabaseSync;
|
||||
init: () => void;
|
||||
close: () => void;
|
||||
profileDir: string;
|
||||
dbPath: string;
|
||||
logsDir: string;
|
||||
artifactsDir: string;
|
||||
backupsDir: string;
|
||||
appVersion: string;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Take a synchronous snapshot of the profile before a destructive or
|
||||
* risky operation (currently: pre-migration). Named so the operator
|
||||
* can tell snapshots apart by reason and target schema version.
|
||||
*
|
||||
* Uses `VACUUM INTO` + `cpSync` rather than the async `exportBackup`
|
||||
* path so the caller — `runMigrations`, which is synchronous — can
|
||||
* block on the backup without any async plumbing. The returned path
|
||||
* is absolute so a failed migration can surface the recovery point
|
||||
* in logs verbatim.
|
||||
*
|
||||
* Failure modes are reported by throwing. There is no fallback: if
|
||||
* we cannot take a snapshot we must not touch the schema.
|
||||
*/
|
||||
createAutomaticBackup(reason: string): string {
|
||||
if (!reason || reason.includes("/") || reason.includes("\\")) {
|
||||
throw new Error(`createAutomaticBackup: invalid reason "${reason}"`);
|
||||
}
|
||||
const db = deps.getDb();
|
||||
const schemaVersion = readSchemaVersion(db);
|
||||
const segment = `${reason}-v${schemaVersion}-${timestampSegment()}`;
|
||||
const targetDir = join(deps.backupsDir, segment);
|
||||
mkdirSync(deps.backupsDir, { recursive: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// VACUUM INTO takes a single consistent snapshot of the live DB
|
||||
// while the connection is held open — no WAL checkpoint dance, no
|
||||
// torn writes, works on sqlite3 ≥ 3.27. The destination must not
|
||||
// exist; `mkdirSync` above only created the parent directory.
|
||||
//
|
||||
// Safety invariant under WAL mode: VACUUM INTO opens an implicit
|
||||
// read transaction that sees every committed frame including those
|
||||
// still in profile.sqlite-wal. DO NOT add PRAGMA wal_checkpoint
|
||||
// here — a TRUNCATE checkpoint would fight concurrent writers and
|
||||
// buys nothing for the snapshot; a PASSIVE checkpoint is redundant.
|
||||
const dbTarget = join(targetDir, "profile.sqlite").replace(/'/g, "''");
|
||||
db.exec(`VACUUM INTO '${dbTarget}'`);
|
||||
|
||||
// Side-car files (logs + artifacts) ride along so operators
|
||||
// restoring from this snapshot see the same run history the
|
||||
// migration saw. `cpSync` is recursive and force-overwrites.
|
||||
if (existsSync(deps.logsDir)) {
|
||||
cpSync(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true });
|
||||
}
|
||||
if (existsSync(deps.artifactsDir)) {
|
||||
cpSync(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
reason,
|
||||
schemaVersion,
|
||||
appVersion: deps.appVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
gitSha: readGitSha(),
|
||||
};
|
||||
writeFileSync(
|
||||
join(targetDir, "backup-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
log.info(`[database/backup] automatic snapshot "${reason}" written to ${targetDir}`);
|
||||
return targetDir;
|
||||
},
|
||||
|
||||
exportBackup(targetDir: string) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
const copyJobs = [
|
||||
backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")),
|
||||
cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
|
||||
cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
|
||||
copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
|
||||
copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
|
||||
];
|
||||
return Promise.all(copyJobs);
|
||||
},
|
||||
|
||||
async restoreFromBackup(sourceDir: string) {
|
||||
const sourceDbPath = join(sourceDir, "profile.sqlite");
|
||||
if (!existsSync(sourceDbPath)) {
|
||||
throw new Error("Backup directory is missing profile.sqlite.");
|
||||
}
|
||||
|
||||
// Validate
|
||||
const currentSchemaVersion =
|
||||
(deps.getDb()
|
||||
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
||||
.get() as { v: number } | undefined)?.v ?? 0;
|
||||
const validationDb = new DatabaseSync(sourceDbPath);
|
||||
try {
|
||||
const requiredTables = ["settings", "companies", "connectors"] as const;
|
||||
for (const table of requiredTables) {
|
||||
const row = validationDb
|
||||
.prepare("select name from sqlite_master where type = 'table' and name = ?")
|
||||
.get(table) as { name: string } | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`Backup is missing the required table "${table}".`);
|
||||
}
|
||||
}
|
||||
const hasMigrationsTable = validationDb
|
||||
.prepare("select name from sqlite_master where type = 'table' and name = 'schema_migrations'")
|
||||
.get() as { name: string } | undefined;
|
||||
if (!hasMigrationsTable) {
|
||||
throw new Error(
|
||||
`Backup is missing schema_migrations. Expected schema version ${currentSchemaVersion}.`,
|
||||
);
|
||||
}
|
||||
const backupVersion =
|
||||
(validationDb
|
||||
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
||||
.get() as { v: number } | undefined)?.v ?? 0;
|
||||
if (backupVersion < currentSchemaVersion) {
|
||||
throw new Error(
|
||||
`Backup schema version ${backupVersion} is older than current ${currentSchemaVersion}. Restore would lose migrations.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
validationDb.close();
|
||||
}
|
||||
|
||||
const recoveryDir = join(deps.backupsDir, `pre-restore-${Date.now()}`);
|
||||
mkdirSync(recoveryDir, { recursive: true });
|
||||
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
await Promise.all([
|
||||
backupDatabase(deps.getDb(), join(recoveryDir, "profile.sqlite")),
|
||||
cp(deps.logsDir, join(recoveryDir, "logs"), { recursive: true, force: true }),
|
||||
cp(deps.artifactsDir, join(recoveryDir, "artifacts"), { recursive: true, force: true }),
|
||||
copyIfExists(join(deps.profileDir, "vault.key"), join(recoveryDir, "vault.key")),
|
||||
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(recoveryDir, "agent-api-signing.key")),
|
||||
copyIfExists(`${deps.dbPath}-wal`, join(recoveryDir, "profile.sqlite-wal")),
|
||||
copyIfExists(`${deps.dbPath}-shm`, join(recoveryDir, "profile.sqlite-shm")),
|
||||
]);
|
||||
|
||||
deps.close();
|
||||
try {
|
||||
await applyBackup(sourceDir);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
try {
|
||||
await applyBackup(recoveryDir);
|
||||
} catch (recoveryError) {
|
||||
const recoveryMessage =
|
||||
recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
|
||||
try {
|
||||
deps.setDb(deps.openConnection());
|
||||
deps.init();
|
||||
} catch (reopenError) {
|
||||
log.error(`[database/backup] Reopen failed after restore/recovery failure:`, reopenError instanceof Error ? reopenError.message : String(reopenError));
|
||||
}
|
||||
throw new Error(
|
||||
`Restore failed: ${message}. Recovery also failed: ${recoveryMessage}. Previous profile is at ${recoveryDir}.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Restore failed. Previous profile was recovered from ${recoveryDir}. ${message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function applyBackup(sourceDir: string) {
|
||||
await rm(deps.dbPath, { force: true });
|
||||
await rm(`${deps.dbPath}-wal`, { force: true });
|
||||
await rm(`${deps.dbPath}-shm`, { force: true });
|
||||
|
||||
await copyFile(join(sourceDir, "profile.sqlite"), deps.dbPath);
|
||||
await copyIfExists(join(sourceDir, "profile.sqlite-wal"), `${deps.dbPath}-wal`);
|
||||
await copyIfExists(join(sourceDir, "profile.sqlite-shm"), `${deps.dbPath}-shm`);
|
||||
|
||||
await rm(deps.logsDir, { recursive: true, force: true });
|
||||
await rm(deps.artifactsDir, { recursive: true, force: true });
|
||||
mkdirSync(deps.logsDir, { recursive: true });
|
||||
mkdirSync(deps.artifactsDir, { recursive: true });
|
||||
|
||||
if (existsSync(join(sourceDir, "logs"))) {
|
||||
await cp(join(sourceDir, "logs"), deps.logsDir, { recursive: true, force: true });
|
||||
}
|
||||
if (existsSync(join(sourceDir, "artifacts"))) {
|
||||
await cp(join(sourceDir, "artifacts"), deps.artifactsDir, { recursive: true, force: true });
|
||||
}
|
||||
await copyIfExists(join(sourceDir, "vault.key"), join(deps.profileDir, "vault.key"));
|
||||
await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(deps.profileDir, "agent-api-signing.key"));
|
||||
if (existsSync(join(deps.profileDir, "vault.key"))) {
|
||||
chmodSync(join(deps.profileDir, "vault.key"), 0o600);
|
||||
}
|
||||
if (existsSync(join(deps.profileDir, "agent-api-signing.key"))) {
|
||||
chmodSync(join(deps.profileDir, "agent-api-signing.key"), 0o600);
|
||||
}
|
||||
|
||||
deps.setDb(deps.openConnection());
|
||||
deps.init();
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { BudgetIncidentRecord, BudgetPolicyRecord } from "@shared/types";
|
||||
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
function mapPolicyRow(r: SqliteRow): BudgetPolicyRecord {
|
||||
return {
|
||||
id: r.id,
|
||||
companyId: r.company_id,
|
||||
scopeType: r.scope_type,
|
||||
scopeId: r.scope_id,
|
||||
metric: r.metric,
|
||||
windowKind: r.window_kind,
|
||||
amount: r.amount,
|
||||
warnPercent: r.warn_percent,
|
||||
hardStopEnabled: !!r.hard_stop_enabled,
|
||||
notifyEnabled: !!r.notify_enabled,
|
||||
isActive: !!r.is_active,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBudgetMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
function getCalendarMonthWindow(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year, month, 1)).toISOString();
|
||||
const end = new Date(Date.UTC(year, month + 1, 1)).toISOString();
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
return {
|
||||
saveBudgetPolicy(input: {
|
||||
id?: string | null;
|
||||
companyId: string;
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
metric?: string;
|
||||
windowKind?: string;
|
||||
amount: number;
|
||||
warnPercent?: number;
|
||||
hardStopEnabled?: boolean;
|
||||
notifyEnabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}): string {
|
||||
const ts = nowIso();
|
||||
const metric = input.metric ?? "billed_cents";
|
||||
const windowKind = input.windowKind ?? "calendar_month_utc";
|
||||
const scopedExisting = input.id
|
||||
? []
|
||||
: readAll<{ id: string; createdAt: string }>(
|
||||
`SELECT id, created_at as createdAt
|
||||
FROM budget_policies
|
||||
WHERE company_id = ? AND scope_type = ? AND scope_id = ? AND metric = ? AND window_kind = ?
|
||||
ORDER BY created_at ASC`,
|
||||
input.companyId,
|
||||
input.scopeType,
|
||||
input.scopeId,
|
||||
metric,
|
||||
windowKind,
|
||||
);
|
||||
const existing = input.id
|
||||
? getDb()
|
||||
.prepare("SELECT id, created_at as createdAt FROM budget_policies WHERE id = ?")
|
||||
.get(input.id) as { id: string; createdAt: string } | undefined
|
||||
: scopedExisting[0];
|
||||
const staleIds = input.id ? [] : scopedExisting.slice(1).map((policy) => policy.id);
|
||||
const id = existing?.id ?? input.id ?? randomUUID();
|
||||
const createdAt = existing?.createdAt ?? ts;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO budget_policies
|
||||
(id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent,
|
||||
hard_stop_enabled, notify_enabled, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
id, input.companyId, input.scopeType, input.scopeId,
|
||||
metric, windowKind,
|
||||
input.amount, input.warnPercent ?? 80,
|
||||
(input.hardStopEnabled ?? true) ? 1 : 0,
|
||||
(input.notifyEnabled ?? true) ? 1 : 0,
|
||||
(input.isActive ?? true) ? 1 : 0, createdAt, ts,
|
||||
);
|
||||
if (staleIds.length > 0) {
|
||||
const placeholders = staleIds.map(() => "?").join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE budget_policies SET is_active = 0, updated_at = ? WHERE id IN (${placeholders})`)
|
||||
.run(ts, ...staleIds);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteBudgetPolicy(companyId: string, policyId: string): void {
|
||||
transaction(() => {
|
||||
getDb().prepare("DELETE FROM budget_incidents WHERE policy_id = ?").run(policyId);
|
||||
getDb().prepare("DELETE FROM budget_policies WHERE id = ? AND company_id = ?").run(policyId, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
listBudgetPolicies(companyId: string): BudgetPolicyRecord[] {
|
||||
return readAll<SqliteRow>(
|
||||
"SELECT * FROM budget_policies WHERE company_id = ? ORDER BY scope_type, created_at",
|
||||
companyId,
|
||||
).map(mapPolicyRow);
|
||||
},
|
||||
|
||||
getBudgetPolicy(policyId: string): BudgetPolicyRecord | null {
|
||||
const r = getDb().prepare("SELECT * FROM budget_policies WHERE id = ?").get(policyId) as SqliteRow | undefined;
|
||||
if (!r) return null;
|
||||
return mapPolicyRow(r);
|
||||
},
|
||||
|
||||
getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null): BudgetPolicyRecord[] {
|
||||
const sql = projectId
|
||||
? `SELECT * FROM budget_policies
|
||||
WHERE company_id = ? AND is_active = 1
|
||||
AND (
|
||||
(scope_type = 'company' AND scope_id = ?)
|
||||
OR (scope_type = 'agent' AND scope_id = ?)
|
||||
OR (scope_type = 'project' AND scope_id = ?)
|
||||
)`
|
||||
: `SELECT * FROM budget_policies
|
||||
WHERE company_id = ? AND is_active = 1
|
||||
AND (
|
||||
(scope_type = 'company' AND scope_id = ?)
|
||||
OR (scope_type = 'agent' AND scope_id = ?)
|
||||
)`;
|
||||
const params: SQLInputValue[] = projectId
|
||||
? [companyId, companyId, agentId, projectId]
|
||||
: [companyId, companyId, agentId];
|
||||
return readAll<SqliteRow>(sql, ...params).map(mapPolicyRow);
|
||||
},
|
||||
|
||||
getCalendarMonthWindow,
|
||||
|
||||
getSpentInWindow(policy: BudgetPolicyRecord): number {
|
||||
const { start, end } = getCalendarMonthWindow();
|
||||
let sql: string;
|
||||
let params: SQLInputValue[];
|
||||
if (policy.scopeType === "agent") {
|
||||
sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND agent_id = ?";
|
||||
params = [policy.companyId, start, end, policy.scopeId];
|
||||
} else if (policy.scopeType === "project") {
|
||||
sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND project_id = ?";
|
||||
params = [policy.companyId, start, end, policy.scopeId];
|
||||
} else {
|
||||
sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ?";
|
||||
params = [policy.companyId, start, end];
|
||||
}
|
||||
const row = getDb().prepare(sql).get(...params) as { total_cents: number } | undefined;
|
||||
return Math.round(row?.total_cents ?? 0);
|
||||
},
|
||||
|
||||
upsertBudgetIncident(
|
||||
policy: BudgetPolicyRecord,
|
||||
thresholdType: "warn_threshold" | "hard_stop",
|
||||
amountObserved: number,
|
||||
): string {
|
||||
const { start, end } = getCalendarMonthWindow();
|
||||
const existing = getDb()
|
||||
.prepare(
|
||||
"SELECT id FROM budget_incidents WHERE policy_id = ? AND window_start = ? AND threshold_type = ? AND status = 'open'",
|
||||
)
|
||||
.get(policy.id, start, thresholdType) as { id: string } | undefined;
|
||||
|
||||
const ts = nowIso();
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare("UPDATE budget_incidents SET amount_observed = ?, updated_at = ? WHERE id = ?")
|
||||
.run(amountObserved, ts, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO budget_incidents
|
||||
(id, company_id, policy_id, scope_type, scope_id, metric, window_kind,
|
||||
window_start, window_end, threshold_type, amount_limit, amount_observed,
|
||||
status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
id, policy.companyId, policy.id, policy.scopeType, policy.scopeId,
|
||||
policy.metric, policy.windowKind, start, end, thresholdType,
|
||||
policy.amount, amountObserved, ts, ts,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
listBudgetIncidents(companyId: string, statusFilter: string = "all"): BudgetIncidentRecord[] {
|
||||
const sql = statusFilter === "all"
|
||||
? "SELECT * FROM budget_incidents WHERE company_id = ? ORDER BY created_at DESC"
|
||||
: "SELECT * FROM budget_incidents WHERE company_id = ? AND status = ? ORDER BY created_at DESC";
|
||||
const rows = statusFilter === "all"
|
||||
? readAll<SqliteRow>(sql, companyId)
|
||||
: readAll<SqliteRow>(sql, companyId, statusFilter);
|
||||
return rows.map((r) => ({
|
||||
id: r.id, companyId: r.company_id, policyId: r.policy_id,
|
||||
scopeType: r.scope_type, scopeId: r.scope_id, metric: r.metric,
|
||||
windowKind: r.window_kind, windowStart: r.window_start, windowEnd: r.window_end,
|
||||
thresholdType: r.threshold_type, amountLimit: r.amount_limit,
|
||||
amountObserved: r.amount_observed, status: r.status,
|
||||
resolvedAt: r.resolved_at, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
resolveBudgetIncident(incidentId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE budget_incidents SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ? AND company_id = ? AND status = 'open'",
|
||||
)
|
||||
.run(ts, ts, incidentId, companyId);
|
||||
},
|
||||
|
||||
resolveIncidentsForPolicy(policyId: string, resolutionStatus: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open'",
|
||||
)
|
||||
.run(resolutionStatus, ts, ts, policyId);
|
||||
return Number(result.changes);
|
||||
},
|
||||
|
||||
resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open' AND threshold_type = 'warn_threshold'",
|
||||
)
|
||||
.run(resolutionStatus, ts, ts, policyId);
|
||||
return Number(result.changes);
|
||||
},
|
||||
|
||||
autoCloseExpiredIncidents(companyId: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"UPDATE budget_incidents SET status = 'auto_closed', resolved_at = ?, updated_at = ? WHERE company_id = ? AND status = 'open' AND window_end <= ?",
|
||||
)
|
||||
.run(ts, ts, companyId, ts);
|
||||
return Number(result.changes);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
nowIso,
|
||||
} from "./helpers";
|
||||
|
||||
export function buildCompanyCrudMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getSetting: (key: string) => string | null;
|
||||
setSetting: (key: string, value: string) => void;
|
||||
listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot;
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
saveCompany(input: {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
autoApproveHires?: boolean;
|
||||
reviewDeliverables?: boolean;
|
||||
executionMode?: string;
|
||||
}): string {
|
||||
const id = input.id && input.id.length > 0 ? input.id : randomUUID();
|
||||
const ts = nowIso();
|
||||
const autoApprove = input.autoApproveHires ? 1 : 0;
|
||||
const reviewDeliverables = input.reviewDeliverables ? 1 : 0;
|
||||
const executionMode = input.executionMode ?? "ai_ceo";
|
||||
const existing = getDb()
|
||||
.prepare("select id from companies where id = ?")
|
||||
.get(id) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, execution_mode = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, id);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, execution_mode, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, ts);
|
||||
if (!deps.getSetting("current_company_id")) {
|
||||
deps.setSetting("current_company_id", id);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteCompany(companyId: string): void {
|
||||
transaction(() => {
|
||||
try {
|
||||
getDb()
|
||||
.prepare(
|
||||
"delete from agent_message_reads where message_id in (select id from agent_messages where company_id = ?)",
|
||||
)
|
||||
.run(companyId);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
||||
}
|
||||
try {
|
||||
getDb()
|
||||
.prepare("delete from agent_message_mentions where company_id = ?")
|
||||
.run(companyId);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
||||
}
|
||||
// New table cleanup (budget_incidents before budget_policies due to FK)
|
||||
getDb().prepare("delete from budget_incidents where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from budget_policies where company_id = ?").run(companyId);
|
||||
// agent_skill_assignments references company_skills -- delete before skills
|
||||
try {
|
||||
getDb()
|
||||
.prepare(
|
||||
"delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)",
|
||||
)
|
||||
.run(companyId);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
||||
}
|
||||
getDb().prepare("delete from company_skills where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from task_sessions where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from wakeup_requests where company_id = ?").run(companyId);
|
||||
// Thread runtime tables (events + participants cascade from threads via FK)
|
||||
getDb().prepare("delete from thread_wait_states where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from conversation_threads where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from cost_entries where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from activity where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from task_comments where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from runs where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from approvals where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from tasks where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from projects where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from goals where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from agents where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from secrets where company_id = ?").run(companyId);
|
||||
getDb().prepare("delete from workspaces where company_id = ?").run(companyId);
|
||||
for (const table of [
|
||||
"automation_rules",
|
||||
"automation_log",
|
||||
"agent_messages",
|
||||
"workflow_pipelines",
|
||||
"meetings",
|
||||
"documents",
|
||||
"knowledge_base",
|
||||
"sprints",
|
||||
"browser_actions",
|
||||
"social_accounts",
|
||||
]) {
|
||||
try {
|
||||
getDb().prepare(`delete from ${table} where company_id = ?`).run(companyId);
|
||||
} catch (err) {
|
||||
// Only tolerate "no such table" — re-throw anything else (disk error, etc.)
|
||||
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
||||
}
|
||||
}
|
||||
getDb().prepare("delete from companies where id = ?").run(companyId);
|
||||
if (deps.getSetting("current_company_id") === companyId) {
|
||||
const next = getDb()
|
||||
.prepare("select id from companies order by created_at asc limit 1")
|
||||
.get() as { id: string } | undefined;
|
||||
if (next) {
|
||||
deps.setSetting("current_company_id", next.id);
|
||||
} else {
|
||||
deps.setSetting("current_company_id", "");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getCompanyStatus(companyId: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare("select status from companies where id = ?")
|
||||
.get(companyId) as { status: string } | undefined;
|
||||
return row?.status ?? null;
|
||||
},
|
||||
|
||||
getCompanyBasic(companyId: string): { name: string; description: string } | null {
|
||||
const row = getDb()
|
||||
.prepare("select name, description from companies where id = ?")
|
||||
.get(companyId) as { name: string; description: string } | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
listActiveCompanyIds(): Array<{ id: string; name: string }> {
|
||||
return readAll<{ id: string; name: string }>(
|
||||
"select id, name from companies where status = 'active'",
|
||||
);
|
||||
},
|
||||
|
||||
getCompanyExecutionMode(companyId: string): string {
|
||||
const row = getDb()
|
||||
.prepare("SELECT execution_mode FROM companies WHERE id = ? LIMIT 1")
|
||||
.get(companyId) as { execution_mode: string } | undefined;
|
||||
return row?.execution_mode ?? "ai_ceo";
|
||||
},
|
||||
|
||||
getCompanyApprovalSettings(companyId: string): { autoApproveHires: boolean; reviewDeliverables: boolean } | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT auto_approve_hires, review_deliverables FROM companies WHERE id = ? LIMIT 1")
|
||||
.get(companyId) as { auto_approve_hires: number; review_deliverables: number } | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
autoApproveHires: row.auto_approve_hires === 1,
|
||||
reviewDeliverables: row.review_deliverables === 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CompanyMetrics,
|
||||
AgentMetrics,
|
||||
RunStatus,
|
||||
StandupReport,
|
||||
} from "@shared/types";
|
||||
import { type ReadAll, type Transaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Dashboard-aggregate read queries extracted from company-settings.ts.
|
||||
* Covers getCompanyMetrics and getStandupReport. Every method is spread
|
||||
* into the final `buildCompanyMethods` result so AppDatabase call-sites
|
||||
* remain unchanged.
|
||||
*/
|
||||
export function buildCompanyMetricsMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const db = getDb();
|
||||
|
||||
const agentRows = readAll<{
|
||||
id: string; name: string; status: string;
|
||||
}>(
|
||||
`select id, name, status from agents where company_id = ? order by created_at asc`,
|
||||
companyId,
|
||||
);
|
||||
const activeAgentRows = agentRows.filter(
|
||||
(a) => a.status !== "pending_approval" && a.status !== "terminated",
|
||||
);
|
||||
|
||||
const totalAgents = activeAgentRows.length;
|
||||
const activeAgents = agentRows.filter(
|
||||
(a) => a.status === "active" || a.status === "running",
|
||||
).length;
|
||||
|
||||
const totalTasks = (db.prepare(
|
||||
"select count(*) as c from tasks where company_id = ?",
|
||||
).get(companyId) as { c: number }).c;
|
||||
const completedTasks = (db.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status = 'done'",
|
||||
).get(companyId) as { c: number }).c;
|
||||
const blockedTasks = (db.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status = 'blocked'",
|
||||
).get(companyId) as { c: number }).c;
|
||||
|
||||
const recentRunStats = db.prepare(
|
||||
`select
|
||||
count(*) as totalRuns,
|
||||
sum(case when status = 'succeeded' then 1 else 0 end) as successful,
|
||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||
from runs where company_id = ? and created_at > ?`,
|
||||
).get(companyId, cutoff) as { totalRuns: number; successful: number | null; failed: number | null };
|
||||
const totalRuns = recentRunStats.totalRuns ?? 0;
|
||||
const successfulRuns = recentRunStats.successful ?? 0;
|
||||
const failedRuns = recentRunStats.failed ?? 0;
|
||||
|
||||
const totalCost = (db.prepare(
|
||||
"select coalesce(sum(amount_usd), 0) as s from cost_entries where company_id = ?",
|
||||
).get(companyId) as { s: number }).s;
|
||||
|
||||
const recentTasksDone = (db.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
||||
).get(companyId, cutoff) as { c: number }).c;
|
||||
|
||||
const taskAggRows = readAll<{
|
||||
agentId: string;
|
||||
done: number;
|
||||
assigned: number;
|
||||
cancelled: number;
|
||||
}>(
|
||||
`select assignee_agent_id as agentId,
|
||||
sum(case when status = 'done' then 1 else 0 end) as done,
|
||||
sum(case when status != 'done' and status != 'cancelled' then 1 else 0 end) as assigned,
|
||||
sum(case when status = 'cancelled' then 1 else 0 end) as cancelled
|
||||
from tasks
|
||||
where company_id = ? and assignee_agent_id is not null
|
||||
group by assignee_agent_id`,
|
||||
companyId,
|
||||
);
|
||||
const taskAggMap = new Map(taskAggRows.map((row) => [row.agentId, row]));
|
||||
|
||||
const runRecentAggRows = readAll<{
|
||||
agentId: string;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
}>(
|
||||
`select agent_id as agentId,
|
||||
count(*) as total,
|
||||
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||
from runs
|
||||
where company_id = ? and created_at > ?
|
||||
group by agent_id`,
|
||||
companyId,
|
||||
cutoff,
|
||||
);
|
||||
const runRecentAggMap = new Map(runRecentAggRows.map((row) => [row.agentId, row]));
|
||||
|
||||
const runDurationRows = readAll<{
|
||||
agentId: string;
|
||||
avgDurationSec: number | null;
|
||||
lastActiveAt: string | null;
|
||||
}>(
|
||||
`select agent_id as agentId,
|
||||
avg((julianday(finished_at) - julianday(started_at)) * 86400.0) as avgDurationSec,
|
||||
max(created_at) as lastActiveAt
|
||||
from runs
|
||||
where company_id = ? and started_at is not null and finished_at is not null
|
||||
group by agent_id`,
|
||||
companyId,
|
||||
);
|
||||
const runDurationMap = new Map(runDurationRows.map((row) => [row.agentId, row]));
|
||||
|
||||
const lastActiveRows = readAll<{ agentId: string; lastActiveAt: string }>(
|
||||
`select agent_id as agentId, max(created_at) as lastActiveAt
|
||||
from runs where company_id = ? group by agent_id`,
|
||||
companyId,
|
||||
);
|
||||
const lastActiveMap = new Map(lastActiveRows.map((row) => [row.agentId, row.lastActiveAt]));
|
||||
|
||||
const costAggRows = readAll<{ agentId: string; totalCost: number }>(
|
||||
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as totalCost
|
||||
from cost_entries where company_id = ? and agent_id is not null
|
||||
group by agent_id`,
|
||||
companyId,
|
||||
);
|
||||
const costAggMap = new Map(costAggRows.map((row) => [row.agentId, row.totalCost]));
|
||||
|
||||
const reviewCycleRows = readAll<{ actor: string; cycles: number }>(
|
||||
`select actor, count(*) as cycles
|
||||
from activity
|
||||
where company_id = ?
|
||||
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
||||
group by actor`,
|
||||
companyId,
|
||||
);
|
||||
const reviewCycleMap = new Map(reviewCycleRows.map((row) => [row.actor, row.cycles]));
|
||||
|
||||
const agentMetrics: AgentMetrics[] = activeAgentRows.map((agent) => {
|
||||
const taskAgg = taskAggMap.get(agent.id);
|
||||
const runRecent = runRecentAggMap.get(agent.id);
|
||||
const runDur = runDurationMap.get(agent.id);
|
||||
const succeeded = runRecent?.succeeded ?? 0;
|
||||
const total = runRecent?.total ?? 0;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
tasksCompleted: taskAgg?.done ?? 0,
|
||||
tasksAssigned: taskAgg?.assigned ?? 0,
|
||||
tasksFailed: taskAgg?.cancelled ?? 0,
|
||||
successRate: total > 0 ? Math.round((succeeded / total) * 100) : 0,
|
||||
avgRunDurationSec: Math.round(runDur?.avgDurationSec ?? 0),
|
||||
totalCostUsd: costAggMap.get(agent.id) ?? 0,
|
||||
runsTotal: total,
|
||||
runsSucceeded: succeeded,
|
||||
runsFailed: runRecent?.failed ?? 0,
|
||||
reviewCycles: reviewCycleMap.get(agent.id) ?? 0,
|
||||
lastActiveAt: lastActiveMap.get(agent.id) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalAgents,
|
||||
activeAgents,
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
blockedTasks,
|
||||
totalRuns,
|
||||
successfulRuns,
|
||||
failedRuns,
|
||||
overallSuccessRate: totalRuns > 0 ? Math.round((successfulRuns / totalRuns) * 100) : 0,
|
||||
totalCostUsd: totalCost,
|
||||
avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0,
|
||||
throughputTasksPerDay: days > 0 ? recentTasksDone / days : 0,
|
||||
agentMetrics,
|
||||
};
|
||||
},
|
||||
|
||||
getStandupReport(companyId: string): StandupReport {
|
||||
const db = getDb();
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
type AgentRow = {
|
||||
id: string; name: string; role: string; status: StandupReport["agentReports"][number]["status"];
|
||||
budgetMonthlyUsd: number; spentMonthlyUsd: number;
|
||||
};
|
||||
const agents = readAll<AgentRow>(
|
||||
`select id, name, role, status,
|
||||
budget_monthly_usd as budgetMonthlyUsd,
|
||||
spent_monthly_usd as spentMonthlyUsd
|
||||
from agents
|
||||
where company_id = ? and status != 'terminated' and status != 'pending_approval'
|
||||
order by created_at asc`,
|
||||
companyId,
|
||||
);
|
||||
|
||||
const recentCompletedTasksCount = (db.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
||||
).get(companyId, oneDayAgo) as { c: number }).c;
|
||||
|
||||
const recentRunStats = db.prepare(
|
||||
`select
|
||||
count(*) as total,
|
||||
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||
from runs where company_id = ? and created_at > ?`,
|
||||
).get(companyId, oneDayAgo) as { total: number; succeeded: number | null; failed: number | null };
|
||||
const recentRunsTotal = recentRunStats.total ?? 0;
|
||||
const successfulRuns = recentRunStats.succeeded ?? 0;
|
||||
const failedRuns = recentRunStats.failed ?? 0;
|
||||
|
||||
const blockedTaskRows = readAll<{ id: string; title: string; assigneeAgentId: string | null }>(
|
||||
`select id, title, assignee_agent_id as assigneeAgentId
|
||||
from tasks where company_id = ? and status = 'blocked'`,
|
||||
companyId,
|
||||
);
|
||||
|
||||
const pendingApprovals = (db.prepare(
|
||||
"select count(*) as c from approvals where company_id = ? and state = 'pending'",
|
||||
).get(companyId) as { c: number }).c;
|
||||
|
||||
const activeSprint = db.prepare(
|
||||
`select name, velocity_points as velocityPoints, completed_points as completedPoints
|
||||
from sprints where company_id = ? and status = 'active' order by start_date desc limit 1`,
|
||||
).get(companyId) as { name: string; velocityPoints: number; completedPoints: number } | undefined;
|
||||
|
||||
const urgentUnread = (db.prepare(
|
||||
`select count(*) as c
|
||||
from agent_messages messages
|
||||
left join agent_message_reads reads
|
||||
on reads.message_id = messages.id and reads.agent_id = '__board__'
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.priority = 'urgent' or messages.channel = 'incident')`,
|
||||
).get(companyId) as { c: number }).c;
|
||||
|
||||
const tasksByAgentRows = readAll<{
|
||||
agentId: string; inProgress: number; blocked: number; completed24h: number;
|
||||
}>(
|
||||
`select assignee_agent_id as agentId,
|
||||
sum(case when status = 'in_progress' then 1 else 0 end) as inProgress,
|
||||
sum(case when status = 'blocked' then 1 else 0 end) as blocked,
|
||||
sum(case when status = 'done' and updated_at > ? then 1 else 0 end) as completed24h
|
||||
from tasks
|
||||
where company_id = ? and assignee_agent_id is not null
|
||||
group by assignee_agent_id`,
|
||||
oneDayAgo,
|
||||
companyId,
|
||||
);
|
||||
const tasksByAgent = new Map(tasksByAgentRows.map((row) => [row.agentId, row]));
|
||||
|
||||
const recentRunsByAgent = readAll<{
|
||||
agentId: string; runCount: number; lastStatus: string | null; lastAt: string | null;
|
||||
}>(
|
||||
`select agent_id as agentId,
|
||||
count(*) as runCount,
|
||||
(select status from runs r2 where r2.agent_id = runs.agent_id and r2.company_id = runs.company_id and r2.created_at > ? order by created_at desc limit 1) as lastStatus,
|
||||
max(created_at) as lastAt
|
||||
from runs
|
||||
where company_id = ? and created_at > ?
|
||||
group by agent_id`,
|
||||
oneDayAgo,
|
||||
companyId,
|
||||
oneDayAgo,
|
||||
);
|
||||
const runsByAgent = new Map(recentRunsByAgent.map((row) => [row.agentId, row]));
|
||||
|
||||
const costByAgentRows = readAll<{ agentId: string; cost24h: number }>(
|
||||
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as cost24h
|
||||
from cost_entries
|
||||
where company_id = ? and agent_id is not null and created_at > ?
|
||||
group by agent_id`,
|
||||
companyId,
|
||||
oneDayAgo,
|
||||
);
|
||||
const costByAgent = new Map(costByAgentRows.map((row) => [row.agentId, row.cost24h]));
|
||||
|
||||
const reviewByAgentRows = readAll<{ actor: string; cycles: number }>(
|
||||
`select actor, count(*) as cycles
|
||||
from activity
|
||||
where company_id = ? and created_at > ?
|
||||
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
||||
group by actor`,
|
||||
companyId,
|
||||
oneDayAgo,
|
||||
);
|
||||
const reviewByAgent = new Map(reviewByAgentRows.map((row) => [row.actor, row.cycles]));
|
||||
|
||||
const agentNameMap = new Map(agents.map((a) => [a.id, a.name]));
|
||||
|
||||
const companyHighlights: string[] = [];
|
||||
const blockers: string[] = [];
|
||||
if (recentCompletedTasksCount > 0) {
|
||||
companyHighlights.push(`${recentCompletedTasksCount} task(s) completed in last 24h`);
|
||||
}
|
||||
if (recentRunsTotal > 0) {
|
||||
companyHighlights.push(
|
||||
`${recentRunsTotal} runs (${successfulRuns} succeeded, ${failedRuns} failed)`,
|
||||
);
|
||||
}
|
||||
for (const task of blockedTaskRows) {
|
||||
const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null;
|
||||
blockers.push(`${task.title} — blocked (${assigneeName ?? "unassigned"})`);
|
||||
}
|
||||
if (activeSprint) {
|
||||
const pct = activeSprint.velocityPoints > 0
|
||||
? Math.round((activeSprint.completedPoints / activeSprint.velocityPoints) * 100)
|
||||
: 0;
|
||||
companyHighlights.push(
|
||||
`Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`,
|
||||
);
|
||||
}
|
||||
if (urgentUnread > 0) {
|
||||
blockers.push(`${urgentUnread} urgent unread message(s) need attention`);
|
||||
}
|
||||
|
||||
const totalBudget = agents.reduce((sum, a) => sum + a.budgetMonthlyUsd, 0);
|
||||
const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0);
|
||||
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||
|
||||
const agentReports = agents.map((agent) => {
|
||||
const taskAgg = tasksByAgent.get(agent.id);
|
||||
const runAgg = runsByAgent.get(agent.id);
|
||||
const completed24h = taskAgg?.completed24h ?? 0;
|
||||
const runs24h = runAgg?.runCount ?? 0;
|
||||
const reviewedTasks = reviewByAgent.get(agent.id) ?? 0;
|
||||
const highlights: string[] = [];
|
||||
if (completed24h > 0) highlights.push(`Completed ${completed24h} task(s)`);
|
||||
if (runs24h > 0) highlights.push(`${runs24h} run(s) in last 24h`);
|
||||
if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`);
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
role: agent.role,
|
||||
status: agent.status,
|
||||
tasksInProgress: taskAgg?.inProgress ?? 0,
|
||||
tasksCompleted24h: completed24h,
|
||||
tasksBlocked: taskAgg?.blocked ?? 0,
|
||||
lastRunStatus: (runAgg?.lastStatus ?? null) as RunStatus | null,
|
||||
lastRunAt: runAgg?.lastAt ?? null,
|
||||
costLast24h: costByAgent.get(agent.id) ?? 0,
|
||||
highlights,
|
||||
};
|
||||
});
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
if (agents.length > 0) summaryParts.push(`${agents.length} active agents`);
|
||||
summaryParts.push(`${recentCompletedTasksCount} tasks done`);
|
||||
if (blockedTaskRows.length > 0) summaryParts.push(`${blockedTaskRows.length} blocked`);
|
||||
if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`);
|
||||
|
||||
return {
|
||||
companyId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: summaryParts.join(" · "),
|
||||
agentReports,
|
||||
companyHighlights,
|
||||
blockers,
|
||||
pendingApprovals,
|
||||
budgetUtilization,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Company-scoped read endpoints that do not belong to a single domain
|
||||
* CRUD module.
|
||||
*
|
||||
* Sidebar badges used to live here as a server-side aggregation
|
||||
* (`getSidebarBadges`) — it has been deleted. The desktop app is
|
||||
* local-first and the renderer already holds every row the badges
|
||||
* would count via `ProfileSnapshot`, so the aggregation became a
|
||||
* second source of truth that drifted from the inbox query whenever
|
||||
* messages were read between snapshot refreshes. Derivation now lives
|
||||
* in `src/shared/sidebar-badges.ts::deriveSidebarBadges`.
|
||||
*/
|
||||
export function buildCompanySettingsMethods(
|
||||
_getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
_deps: {
|
||||
listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Returns the raw inputs for the GET /api/companies/:id/dashboard
|
||||
* endpoint: agent statuses, task statuses, and the last 20 activity rows.
|
||||
* Route code aggregates counts by status on top of this raw data.
|
||||
*/
|
||||
getDashboardSnapshot(companyId: string): {
|
||||
agents: Array<{ id: string; status: string }>;
|
||||
tasks: Array<{ id: string; status: string }>;
|
||||
recentActivity: Array<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
detail: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
agents: readAll<{ id: string; status: string }>(
|
||||
"select id, status from agents where company_id = ?",
|
||||
companyId,
|
||||
),
|
||||
tasks: readAll<{ id: string; status: string }>(
|
||||
"select id, status from tasks where company_id = ?",
|
||||
companyId,
|
||||
),
|
||||
recentActivity: readAll<{
|
||||
id: string; companyId: string; actor: string; action: string;
|
||||
entityType: string; entityId: string; detail: string | null; createdAt: string;
|
||||
}>(
|
||||
"select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity where company_id = ? order by created_at desc limit 20",
|
||||
companyId,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { buildCompanyCrudMethods } from "./company-crud";
|
||||
import { buildCompanyMetricsMethods } from "./company-metrics";
|
||||
import { buildCompanySettingsMethods } from "./company-settings";
|
||||
|
||||
export function buildCompanyMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getSetting: (key: string) => string | null;
|
||||
setSetting: (key: string, value: string) => void;
|
||||
listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot;
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
...buildCompanyCrudMethods(getDb, readAll, transaction, deps),
|
||||
...buildCompanySettingsMethods(getDb, readAll, transaction, { listSnapshot: deps.listSnapshot }),
|
||||
...buildCompanyMetricsMethods(getDb, readAll, transaction),
|
||||
};
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
||||
import { nowIso, parseJson } from "./helpers";
|
||||
|
||||
/**
|
||||
* Agent config-revision helpers extracted from documents.ts. Spread into
|
||||
* the final `buildDocumentMethods` result so AppDatabase call-sites remain
|
||||
* unchanged.
|
||||
*/
|
||||
export function buildConfigRevisionMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
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;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const lastRev = getDb().prepare(
|
||||
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
|
||||
).get(input.agentId) as { maxRev: number | null } | undefined;
|
||||
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
|
||||
getDb().prepare(
|
||||
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
id, input.companyId, input.agentId, revisionNumber,
|
||||
JSON.stringify(input.changedKeys), JSON.stringify(input.configBefore),
|
||||
JSON.stringify(input.configAfter), input.source ?? "patch",
|
||||
input.rolledBackFromRevisionId ?? null, input.createdByAgentId ?? null,
|
||||
input.createdByUserId ?? null, ts
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
listConfigRevisions(agentId: string, companyId: string): Array<{
|
||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||
source: string; rolledBackFromRevisionId: string | null;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||
}> {
|
||||
return readAll<SqliteRow>(
|
||||
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
|
||||
agentId, companyId
|
||||
).map((row) => ({
|
||||
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||
createdByAgentId: row.created_by_agent_id ?? null,
|
||||
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
||||
}));
|
||||
},
|
||||
|
||||
getConfigRevision(revisionId: string, companyId: string): {
|
||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||
source: string; rolledBackFromRevisionId: string | null;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||
} {
|
||||
const row = getDb().prepare(
|
||||
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
|
||||
).get(revisionId, companyId) as {
|
||||
id: string; agent_id: string; revision_number: number; changed_keys: string;
|
||||
config_before: string; config_after: string; source: string;
|
||||
rolled_back_from_revision_id: string | null;
|
||||
created_by_agent_id: string | null; created_by_user_id: string | null; created_at: string;
|
||||
} | undefined;
|
||||
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
|
||||
return {
|
||||
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
|
||||
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
|
||||
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||
createdByAgentId: row.created_by_agent_id ?? null,
|
||||
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
||||
};
|
||||
},
|
||||
|
||||
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
|
||||
getConfigRevision: (revisionId: string, companyId: string) => {
|
||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||
source: string; rolledBackFromRevisionId: string | null;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||
};
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
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;
|
||||
}) => string;
|
||||
}): string {
|
||||
return transaction(() => {
|
||||
const revision = selfDeps.getConfigRevision(revisionId, companyId);
|
||||
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
|
||||
const before = revision.configBefore as Record<string, string | number | null>;
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(
|
||||
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
|
||||
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
|
||||
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
|
||||
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
|
||||
);
|
||||
const agent = selfDeps.getAgent(agentId);
|
||||
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: agent.department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
|
||||
return selfDeps.recordConfigRevision({
|
||||
companyId, agentId,
|
||||
changedKeys: revision.changedKeys,
|
||||
configBefore: currentConfig,
|
||||
configAfter: before,
|
||||
source: "rollback",
|
||||
rolledBackFromRevisionId: revisionId,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { installSchema } from "./schema";
|
||||
|
||||
export class DatabaseConnection {
|
||||
readonly db: DatabaseSync;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
this.db.exec("pragma journal_mode = WAL");
|
||||
this.db.exec("pragma foreign_keys = ON");
|
||||
installSchema(this.db);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
import {
|
||||
CONNECTORS,
|
||||
getConnectorDefinition,
|
||||
buildDefaultConnectorRows,
|
||||
renderConnectorConfigurationDoc,
|
||||
PI_MONO_CAPABILITIES,
|
||||
} from "../connectors";
|
||||
|
||||
|
||||
export function buildConnectorMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
function readConnectors(): ConnectorRecord[] {
|
||||
const rows = readAll<{
|
||||
id: string; label: string; description: string; status: ConnectorRecord["status"];
|
||||
version: string | null; auth_state: ConnectorRecord["authState"];
|
||||
last_checked_at: string | null; last_error: string | null; model: string | null;
|
||||
env_binding_text: string; notes: string;
|
||||
}>(
|
||||
"select id, label, description, status, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes from connectors order by label asc",
|
||||
);
|
||||
const result: ConnectorRecord[] = [];
|
||||
for (const row of rows) {
|
||||
let definition;
|
||||
try {
|
||||
definition = getConnectorDefinition(row.id as ConnectorId);
|
||||
} catch (err) {
|
||||
log.warn(`[database/connectors] Unknown connector id ${row.id}; skipping row:`, err instanceof Error ? err.message : String(err));
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: row.id as ConnectorId,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
version: row.version,
|
||||
authState: row.auth_state,
|
||||
lastCheckedAt: row.last_checked_at,
|
||||
lastError: row.last_error,
|
||||
model: row.model,
|
||||
envBindingText: row.env_binding_text,
|
||||
notes: row.notes,
|
||||
configurationDoc: renderConnectorConfigurationDoc(definition),
|
||||
capabilityMatrix: PI_MONO_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
readConnectors,
|
||||
|
||||
saveConnector(input: {
|
||||
id: ConnectorId;
|
||||
model: string | null;
|
||||
envBindingText: string;
|
||||
notes: string;
|
||||
}) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update connectors set model = ?, env_binding_text = ?, notes = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(input.model, input.envBindingText, input.notes, nowIso(), input.id);
|
||||
},
|
||||
|
||||
updateConnectorHealth(input: {
|
||||
id: ConnectorId;
|
||||
status: string;
|
||||
authState: string;
|
||||
version: string | null;
|
||||
lastError: string | null;
|
||||
}) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update connectors set status = ?, auth_state = ?, version = ?, last_checked_at = ?, last_error = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(input.status, input.authState, input.version, nowIso(), input.lastError, nowIso(), input.id);
|
||||
},
|
||||
|
||||
findConnector(id: ConnectorId): ConnectorRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, label, description, status, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes from connectors where id = ?",
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: ConnectorRecord["status"];
|
||||
version: string | null;
|
||||
auth_state: ConnectorRecord["authState"];
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
model: string | null;
|
||||
env_binding_text: string;
|
||||
notes: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
let definition;
|
||||
try {
|
||||
definition = getConnectorDefinition(row.id as ConnectorId);
|
||||
} catch (err) {
|
||||
log.warn(`[database/connectors] Unknown connector id ${row.id} in findConnector:`, err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id as ConnectorId,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
version: row.version,
|
||||
authState: row.auth_state,
|
||||
lastCheckedAt: row.last_checked_at,
|
||||
lastError: row.last_error,
|
||||
model: row.model,
|
||||
envBindingText: row.env_binding_text,
|
||||
notes: row.notes,
|
||||
configurationDoc: renderConnectorConfigurationDoc(definition),
|
||||
capabilityMatrix: PI_MONO_CAPABILITIES,
|
||||
};
|
||||
},
|
||||
|
||||
getConnector(id: ConnectorId): ConnectorRecord {
|
||||
const result = this.findConnector(id);
|
||||
if (!result) throw new Error(`Connector not found: ${id}`);
|
||||
return result;
|
||||
},
|
||||
|
||||
seedConnectors() {
|
||||
// Drop any connector row that is not declared in the pi-mono catalog.
|
||||
const validIds = CONNECTORS.map((c) => `'${c.id}'`).join(",");
|
||||
getDb().prepare(`delete from connectors where id not in (${validIds})`).run();
|
||||
|
||||
const currentRows = readAll<Partial<ConnectorRecord>>(
|
||||
"select id, label, description, status, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors",
|
||||
);
|
||||
|
||||
const merged = buildDefaultConnectorRows(currentRows);
|
||||
// `command` column stays in the schema as NOT NULL for legacy reasons;
|
||||
// pi-mono has no CLI so we always write an empty string. Column is
|
||||
// never read by runtime code.
|
||||
const statement = getDb().prepare(
|
||||
"insert or replace into connectors (id, label, description, status, command, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes, updated_at) values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const updatedAt = nowIso();
|
||||
for (const row of merged) {
|
||||
statement.run(
|
||||
row.id, row.label, row.description, row.status,
|
||||
row.version, row.authState, row.lastCheckedAt,
|
||||
row.lastError, row.model, row.envBindingText, row.notes, updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// One-time migration: mark connectors actively used by agents as
|
||||
// model-pool-managed so they remain visible to the CEO after the opt-in
|
||||
// policy change. Connectors not used by any agent stay unmanaged and
|
||||
// will not appear in CEO prompts until the user explicitly enables
|
||||
// models for them in Settings > Model Management.
|
||||
const migrationKey = "migration:model_pool_opt_in_v1";
|
||||
const migrated = readAll<{ value: string }>("SELECT value FROM settings WHERE key = ?", migrationKey);
|
||||
if (migrated.length === 0) {
|
||||
const usedIds = new Set(
|
||||
readAll<{ connector_id: string }>(
|
||||
"SELECT DISTINCT connector_id FROM agents WHERE status NOT IN ('terminated')",
|
||||
).map((r) => r.connector_id),
|
||||
);
|
||||
const upsert = getDb().prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
);
|
||||
for (const id of usedIds) {
|
||||
const hasSettings = readAll<{ key: string }>(
|
||||
"SELECT key FROM settings WHERE key = ?",
|
||||
`disabled_models:${id}`,
|
||||
);
|
||||
if (hasSettings.length === 0) {
|
||||
upsert.run(`disabled_models:${id}`, "[]");
|
||||
}
|
||||
}
|
||||
upsert.run(migrationKey, "1");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { type ReadAll, type Transaction, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* cost_entries writer extracted from runs.ts. Spread into the final
|
||||
* `buildRunMethods` result so AppDatabase call-sites remain unchanged.
|
||||
*
|
||||
* Note: finishRun still reads SUM(cost_entries.amount_usd) inline. The
|
||||
* SUM query stays in runs.ts to keep the run-aggregate read path local to
|
||||
* the rest of finishRun.
|
||||
*/
|
||||
export function buildCostEntryMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Single writer for `cost_entries`. CostWriter calls this per
|
||||
* `message_end`; tests also call it to simulate per-turn cost.
|
||||
* finishRun reads SUM(cost_entries) for the run-level aggregate,
|
||||
* so this is the authoritative path for cost attribution.
|
||||
*/
|
||||
recordCostEntry(input: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
taskId: string | null;
|
||||
connectorId: string;
|
||||
amountUsd: number | null;
|
||||
unattributed: boolean;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const projectRow = input.taskId
|
||||
? getDb().prepare("SELECT project_id FROM tasks WHERE id = ?").get(input.taskId) as { project_id: string | null } | undefined
|
||||
: undefined;
|
||||
const projectId = projectRow?.project_id ?? null;
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into cost_entries (id, company_id, run_id, task_id, agent_id, connector_id, amount_usd, unattributed, project_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id, input.companyId, input.runId, input.taskId, input.agentId,
|
||||
input.connectorId, input.amountUsd,
|
||||
input.unattributed ? 1 : 0, projectId, ts,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for crash-recovery logic, extracted for testability.
|
||||
*
|
||||
* These encode the behavioral contract of the corresponding AppDatabase methods:
|
||||
* - interruptStaleRuns
|
||||
* - retryInterruptedTask
|
||||
* - markInterruptedTaskFailed
|
||||
*
|
||||
* The helpers take narrowly-typed "statement runner" interfaces so they can be
|
||||
* exercised in unit tests without a live SQLite connection.
|
||||
*/
|
||||
|
||||
export interface StaleRunDb {
|
||||
/** Update all running runs to 'interrupted' and return number of rows changed. */
|
||||
interruptRunningRuns(ts: string): number;
|
||||
/** Mark tasks associated with interrupted runs as 'blocked'. */
|
||||
blockInterruptedTasks(ts: string): void;
|
||||
/** Delete task lease rows whose run is now interrupted. */
|
||||
clearInterruptedTaskLeases(): void;
|
||||
/** Reset all running agents to idle. */
|
||||
resetAgentsToIdle(ts: string): void;
|
||||
/** Release all workspace locks. */
|
||||
releaseAllWorkspaceLocks(): void;
|
||||
}
|
||||
|
||||
export interface RetryTaskDb {
|
||||
/** Set task status to 'todo'. */
|
||||
setTaskTodo(ts: string, taskId: string): void;
|
||||
/** Reset retry_count and last_failure_at. */
|
||||
resetTaskRetryFields(ts: string, taskId: string): void;
|
||||
}
|
||||
|
||||
export interface FailTaskDb {
|
||||
/** Set task status to 'cancelled'. */
|
||||
setTaskCancelled(ts: string, taskId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactionally marks all running runs as interrupted and associated tasks
|
||||
* as blocked. Returns the number of interrupted runs.
|
||||
*/
|
||||
export function interruptStaleRunsLogic(
|
||||
db: StaleRunDb,
|
||||
transaction: (fn: () => void) => void,
|
||||
nowIso: () => string,
|
||||
): number {
|
||||
let interruptedCount = 0;
|
||||
transaction(() => {
|
||||
const ts = nowIso();
|
||||
interruptedCount = db.interruptRunningRuns(ts);
|
||||
if (interruptedCount > 0) {
|
||||
db.blockInterruptedTasks(ts);
|
||||
}
|
||||
db.clearInterruptedTaskLeases();
|
||||
db.resetAgentsToIdle(ts);
|
||||
db.releaseAllWorkspaceLocks();
|
||||
});
|
||||
return interruptedCount;
|
||||
}
|
||||
|
||||
/** Resets a task to 'todo' and clears its retry state. */
|
||||
export function retryInterruptedTaskLogic(
|
||||
db: RetryTaskDb,
|
||||
taskId: string,
|
||||
nowIso: () => string,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
db.setTaskTodo(ts, taskId);
|
||||
db.resetTaskRetryFields(ts, taskId);
|
||||
}
|
||||
|
||||
/** Marks a task as 'cancelled'. */
|
||||
export function markInterruptedTaskFailedLogic(
|
||||
db: FailTaskDb,
|
||||
taskId: string,
|
||||
nowIso: () => string,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
db.setTaskCancelled(ts, taskId);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
export type CredentialKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc" | "local";
|
||||
export type CredentialStatus = "active" | "expired" | "revoked" | "error";
|
||||
export type CredentialBackend = "safe_storage" | "vault";
|
||||
|
||||
export interface CredentialRow {
|
||||
connectorId: string;
|
||||
kind: CredentialKind;
|
||||
backend: CredentialBackend;
|
||||
ciphertext: Uint8Array;
|
||||
displayHint: string;
|
||||
status: CredentialStatus;
|
||||
expiresAt: string | null;
|
||||
lastRefreshedAt: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpsertCredentialInput {
|
||||
connectorId: string;
|
||||
kind: CredentialKind;
|
||||
backend: CredentialBackend;
|
||||
ciphertext: Uint8Array;
|
||||
displayHint?: string;
|
||||
status?: CredentialStatus;
|
||||
expiresAt?: string | null;
|
||||
lastRefreshedAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
function rowToRecord(row: {
|
||||
connector_id: string;
|
||||
kind: string;
|
||||
backend: string;
|
||||
ciphertext: Uint8Array;
|
||||
display_hint: string;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
last_refreshed_at: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): CredentialRow {
|
||||
return {
|
||||
connectorId: row.connector_id,
|
||||
kind: row.kind as CredentialKind,
|
||||
backend: row.backend as CredentialBackend,
|
||||
ciphertext: row.ciphertext,
|
||||
displayHint: row.display_hint,
|
||||
status: row.status as CredentialStatus,
|
||||
expiresAt: row.expires_at,
|
||||
lastRefreshedAt: row.last_refreshed_at,
|
||||
lastError: row.last_error,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCredentialMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
const COLUMNS =
|
||||
"connector_id, kind, backend, ciphertext, display_hint, status, expires_at, last_refreshed_at, last_error, created_at, updated_at";
|
||||
type RawRow = {
|
||||
connector_id: string;
|
||||
kind: string;
|
||||
backend: string;
|
||||
ciphertext: Uint8Array;
|
||||
display_hint: string;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
last_refreshed_at: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function list(): CredentialRow[] {
|
||||
const rows = readAll<RawRow>(
|
||||
`select ${COLUMNS} from credentials order by connector_id asc`,
|
||||
);
|
||||
return rows.map(rowToRecord);
|
||||
}
|
||||
|
||||
function get(connectorId: string): CredentialRow | null {
|
||||
const rows = readAll<RawRow>(
|
||||
`select ${COLUMNS} from credentials where connector_id = ?`,
|
||||
connectorId,
|
||||
);
|
||||
return rows[0] ? rowToRecord(rows[0]) : null;
|
||||
}
|
||||
|
||||
function upsert(input: UpsertCredentialInput): CredentialRow {
|
||||
return transaction(() => {
|
||||
const existing = get(input.connectorId);
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update credentials set kind = ?, backend = ?, ciphertext = ?, display_hint = ?, status = ?, expires_at = ?, last_refreshed_at = ?, last_error = ?, updated_at = ? where connector_id = ?",
|
||||
)
|
||||
.run(
|
||||
input.kind,
|
||||
input.backend,
|
||||
input.ciphertext,
|
||||
input.displayHint ?? existing.displayHint,
|
||||
input.status ?? existing.status,
|
||||
input.expiresAt === undefined ? existing.expiresAt : input.expiresAt,
|
||||
input.lastRefreshedAt === undefined ? existing.lastRefreshedAt : input.lastRefreshedAt,
|
||||
input.lastError === undefined ? existing.lastError : input.lastError,
|
||||
now,
|
||||
input.connectorId,
|
||||
);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into credentials (connector_id, kind, backend, ciphertext, display_hint, status, expires_at, last_refreshed_at, last_error, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
input.connectorId,
|
||||
input.kind,
|
||||
input.backend,
|
||||
input.ciphertext,
|
||||
input.displayHint ?? "",
|
||||
input.status ?? "active",
|
||||
input.expiresAt ?? null,
|
||||
input.lastRefreshedAt ?? null,
|
||||
input.lastError ?? null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
const record = get(input.connectorId);
|
||||
if (!record) throw new Error(`Failed to persist credential: ${input.connectorId}`);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function remove(connectorId: string): void {
|
||||
transaction(() => {
|
||||
getDb().prepare("delete from credentials where connector_id = ?").run(connectorId);
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(
|
||||
connectorId: string,
|
||||
status: CredentialStatus,
|
||||
lastError: string | null = null,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update credentials set status = ?, last_error = ?, updated_at = ? where connector_id = ?",
|
||||
)
|
||||
.run(status, lastError, nowIso(), connectorId);
|
||||
}
|
||||
|
||||
function markRefreshed(
|
||||
connectorId: string,
|
||||
backend: CredentialBackend,
|
||||
ciphertext: Uint8Array,
|
||||
expiresAt: string | null,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"update credentials set backend = ?, ciphertext = ?, expires_at = ?, last_refreshed_at = ?, status = 'active', last_error = null, updated_at = ? where connector_id = ?",
|
||||
)
|
||||
.run(backend, ciphertext, expiresAt, ts, ts, connectorId);
|
||||
}
|
||||
|
||||
return { list, get, upsert, remove, setStatus, markRefreshed };
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Document-lifecycle event row shape returned by listers.
|
||||
* `status` moves `pending -> running -> (succeeded | failed)`. `attempts`
|
||||
* tracks how many processor passes have tried this hook so a sweeper can
|
||||
* bound retries without losing the ledger.
|
||||
*/
|
||||
export interface DocumentLifecycleEventRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
documentId: string;
|
||||
hookType: string;
|
||||
status: "pending" | "running" | "succeeded" | "failed";
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
payloadJson: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EnqueueInput {
|
||||
companyId: string;
|
||||
documentId: string;
|
||||
hookType: string;
|
||||
payloadJson?: string;
|
||||
}
|
||||
|
||||
export interface ClaimBatchInput {
|
||||
/** Upper bound on rows claimed in one pass. */
|
||||
limit: number;
|
||||
/** Rows whose `attempts` have already reached this cap stay `pending` and are not claimed here; a dead-letter sweep surfaces them separately. */
|
||||
maxAttempts: number;
|
||||
/** When provided, only claim rows for the given document so the post-commit immediate drain stays scoped. */
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
export function buildDocumentLifecycleEventMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
return {
|
||||
enqueue(input: EnqueueInput): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
`INSERT INTO document_lifecycle_events
|
||||
(id, company_id, document_id, hook_type, status, attempts, last_error, payload_json, created_at, updated_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, NULL, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
id,
|
||||
input.companyId,
|
||||
input.documentId,
|
||||
input.hookType,
|
||||
input.payloadJson ?? "{}",
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Atomically claim up to `limit` pending events, flipping each to
|
||||
* `running` and incrementing `attempts` in a single statement.
|
||||
* Returns the claimed rows so the processor can dispatch them
|
||||
* without racing another worker against the same ids. Rows that
|
||||
* have already reached `maxAttempts` stay `pending` here — the
|
||||
* operator sees them via `listExhausted` and either retries
|
||||
* manually or purges.
|
||||
*/
|
||||
claimPendingBatch(input: ClaimBatchInput): DocumentLifecycleEventRow[] {
|
||||
const ts = nowIso();
|
||||
const documentScope = input.documentId ? "AND document_id = ?" : "";
|
||||
const selectSql = `
|
||||
SELECT id FROM document_lifecycle_events
|
||||
WHERE status = 'pending' AND attempts < ? ${documentScope}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
`;
|
||||
const updateSql = `
|
||||
UPDATE document_lifecycle_events
|
||||
SET status = 'running', attempts = attempts + 1, updated_at = ?
|
||||
WHERE id IN (${selectSql})
|
||||
RETURNING id, company_id, document_id, hook_type, status, attempts, last_error, payload_json, created_at, updated_at, completed_at
|
||||
`;
|
||||
const params: SQLInputValue[] = [ts, input.maxAttempts];
|
||||
if (input.documentId) params.push(input.documentId);
|
||||
params.push(input.limit);
|
||||
const raw = readAll<LedgerRawRow>(updateSql, ...params);
|
||||
return raw.map(mapLedgerRow);
|
||||
},
|
||||
|
||||
/**
|
||||
* Rescue rows that were claimed by a prior process but never
|
||||
* reached `succeeded` or `failed` (typically because the process
|
||||
* crashed mid-hook). Rows whose `updated_at` is older than
|
||||
* `cutoffIso` move back to `pending` so the next `claimPendingBatch`
|
||||
* picks them up; `attempts` is NOT rewound because the earlier
|
||||
* claim already counted against the cap.
|
||||
*/
|
||||
reclaimStaleRunning(cutoffIso: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb().prepare(
|
||||
"UPDATE document_lifecycle_events SET status = 'pending', updated_at = ? WHERE status = 'running' AND updated_at < ?",
|
||||
).run(ts, cutoffIso);
|
||||
return Number(result.changes ?? 0);
|
||||
},
|
||||
|
||||
markSucceeded(id: string): void {
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE document_lifecycle_events SET status = 'succeeded', last_error = NULL, updated_at = ?, completed_at = ? WHERE id = ?",
|
||||
).run(ts, ts, id);
|
||||
},
|
||||
|
||||
markFailed(id: string, error: string): void {
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE document_lifecycle_events SET status = 'failed', last_error = ?, updated_at = ? WHERE id = ?",
|
||||
).run(error.slice(0, 2000), ts, id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return to `pending` after a handler failure that the caller wants
|
||||
* the processor to retry (e.g. transient network failure). The
|
||||
* attempt counter is preserved so the cap still bounds retries.
|
||||
*/
|
||||
markRetryable(id: string, error: string): void {
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE document_lifecycle_events SET status = 'pending', last_error = ?, updated_at = ? WHERE id = ?",
|
||||
).run(error.slice(0, 2000), ts, id);
|
||||
},
|
||||
|
||||
listPendingForDocument(documentId: string): DocumentLifecycleEventRow[] {
|
||||
return readRows(readAll,
|
||||
"SELECT id, company_id, document_id, hook_type, status, attempts, last_error, payload_json, created_at, updated_at, completed_at FROM document_lifecycle_events WHERE document_id = ? AND status = 'pending' ORDER BY created_at ASC",
|
||||
documentId,
|
||||
);
|
||||
},
|
||||
|
||||
listFailedForCompany(companyId: string, limit = 200): DocumentLifecycleEventRow[] {
|
||||
return readRows(readAll,
|
||||
"SELECT id, company_id, document_id, hook_type, status, attempts, last_error, payload_json, created_at, updated_at, completed_at FROM document_lifecycle_events WHERE company_id = ? AND status = 'failed' ORDER BY updated_at DESC LIMIT ?",
|
||||
companyId,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Rows that have reached the attempt cap without succeeding. Surfaced
|
||||
* to operators so a failing hook is visible instead of disappearing
|
||||
* into the `pending` queue once the cap locks it out.
|
||||
*/
|
||||
listExhausted(maxAttempts: number, limit = 200): DocumentLifecycleEventRow[] {
|
||||
return readRows(readAll,
|
||||
"SELECT id, company_id, document_id, hook_type, status, attempts, last_error, payload_json, created_at, updated_at, completed_at FROM document_lifecycle_events WHERE status = 'pending' AND attempts >= ? ORDER BY updated_at DESC LIMIT ?",
|
||||
maxAttempts,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface LedgerRawRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
document_id: string;
|
||||
hook_type: string;
|
||||
status: "pending" | "running" | "succeeded" | "failed";
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function mapLedgerRow(row: LedgerRawRow): DocumentLifecycleEventRow {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
documentId: row.document_id,
|
||||
hookType: row.hook_type,
|
||||
status: row.status,
|
||||
attempts: row.attempts,
|
||||
lastError: row.last_error,
|
||||
payloadJson: row.payload_json,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
completedAt: row.completed_at,
|
||||
};
|
||||
}
|
||||
|
||||
function readRows(
|
||||
readAll: ReadAll,
|
||||
sql: string,
|
||||
...params: SQLInputValue[]
|
||||
): DocumentLifecycleEventRow[] {
|
||||
return readAll<LedgerRawRow>(sql, ...params).map(mapLedgerRow);
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { statSync } from "node:fs";
|
||||
import { isAbsolute, resolve as resolvePath, relative as relativePath } from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import {
|
||||
workProductProviderEnum,
|
||||
workProductReviewStateEnum,
|
||||
workProductStatusEnum,
|
||||
workProductTypeEnum,
|
||||
type WorkProductProvider,
|
||||
type WorkProductReviewState,
|
||||
type WorkProductStatus,
|
||||
type WorkProductType,
|
||||
} from "@shared/contracts";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso } from "./helpers";
|
||||
|
||||
function resolveWorkspaceRoot(
|
||||
getDb: () => DatabaseSync,
|
||||
companyId: string,
|
||||
workspaceId: string | null,
|
||||
taskId: string | null,
|
||||
runId: string | null,
|
||||
): string | null {
|
||||
if (workspaceId) {
|
||||
const row = getDb().prepare(
|
||||
"SELECT local_path AS localPath FROM workspaces WHERE id = ? AND company_id = ?",
|
||||
).get(workspaceId, companyId) as { localPath: string } | undefined;
|
||||
if (row?.localPath) return row.localPath;
|
||||
}
|
||||
if (taskId) {
|
||||
const row = getDb().prepare(
|
||||
"SELECT w.local_path AS localPath FROM tasks t JOIN workspaces w ON w.id = t.workspace_id WHERE t.id = ? AND t.company_id = ?",
|
||||
).get(taskId, companyId) as { localPath: string } | undefined;
|
||||
if (row?.localPath) return row.localPath;
|
||||
}
|
||||
if (runId) {
|
||||
const row = getDb().prepare(
|
||||
"SELECT w.local_path AS localPath FROM runs r JOIN workspaces w ON w.id = r.workspace_id WHERE r.id = ? AND r.company_id = ?",
|
||||
).get(runId, companyId) as { localPath: string } | undefined;
|
||||
if (row?.localPath) return row.localPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listCompanyWorkspaceRoots(
|
||||
getDb: () => DatabaseSync,
|
||||
companyId: string,
|
||||
): string[] {
|
||||
return (getDb()
|
||||
.prepare(
|
||||
"SELECT local_path AS localPath FROM workspaces WHERE company_id = ? AND local_path IS NOT NULL ORDER BY is_primary DESC, created_at ASC",
|
||||
)
|
||||
.all(companyId) as Array<{ localPath: string }>)
|
||||
.map((row) => row.localPath)
|
||||
.filter((localPath) => localPath.length > 0);
|
||||
}
|
||||
|
||||
function orderedUnique(values: Array<string | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
unique.push(value);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function fileExists(targetPath: string): boolean {
|
||||
try {
|
||||
return statSync(targetPath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function joinWorkspaceRelative(root: string, rel: string): string | null {
|
||||
if (isAbsolute(rel)) return null;
|
||||
const joined = resolvePath(root, rel);
|
||||
const within = relativePath(root, joined);
|
||||
if (within.startsWith("..") || isAbsolute(within)) return null;
|
||||
return joined;
|
||||
}
|
||||
|
||||
function parseWorkProductType(value: string): WorkProductType {
|
||||
return workProductTypeEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductProvider(value: string): WorkProductProvider {
|
||||
return workProductProviderEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductStatus(value: string): WorkProductStatus {
|
||||
return workProductStatusEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductReviewState(value: string): WorkProductReviewState {
|
||||
return workProductReviewStateEnum.parse(value);
|
||||
}
|
||||
|
||||
export class WorkProductScopeError extends Error {
|
||||
constructor(
|
||||
public readonly reference: "task" | "project" | "workspace",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkProductScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkProductScope(
|
||||
getDb: () => DatabaseSync,
|
||||
input: {
|
||||
companyId: string;
|
||||
taskId?: string | null;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
},
|
||||
): { taskId: string | null; projectId: string | null; workspaceId: string | null } {
|
||||
const taskId = normalizeOptionalId(input.taskId);
|
||||
let taskProjectId: string | null = null;
|
||||
let taskWorkspaceId: string | null = null;
|
||||
if (taskId) {
|
||||
const task = getDb()
|
||||
.prepare("SELECT project_id AS projectId, workspace_id AS workspaceId FROM tasks WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(taskId, input.companyId) as { projectId: string | null; workspaceId: string | null } | undefined;
|
||||
if (!task) {
|
||||
throw new WorkProductScopeError("task", "Work product task does not belong to the current company");
|
||||
}
|
||||
taskProjectId = task.projectId ?? null;
|
||||
taskWorkspaceId = task.workspaceId ?? null;
|
||||
}
|
||||
|
||||
let projectId = input.projectId === undefined
|
||||
? taskProjectId
|
||||
: normalizeOptionalId(input.projectId);
|
||||
if (taskId && projectId !== taskProjectId) {
|
||||
throw new WorkProductScopeError("project", "Work product project must match the task project");
|
||||
}
|
||||
if (taskId) {
|
||||
projectId = taskProjectId;
|
||||
}
|
||||
if (projectId) {
|
||||
const project = getDb()
|
||||
.prepare("SELECT id FROM projects WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(projectId, input.companyId) as { id: string } | undefined;
|
||||
if (!project) {
|
||||
throw new WorkProductScopeError("project", "Work product project does not belong to the current company");
|
||||
}
|
||||
}
|
||||
|
||||
const requestedWorkspaceId = normalizeOptionalId(input.workspaceId);
|
||||
if (taskWorkspaceId && requestedWorkspaceId && requestedWorkspaceId !== taskWorkspaceId) {
|
||||
throw new WorkProductScopeError("workspace", "Work product workspace must match the task workspace");
|
||||
}
|
||||
const workspaceId = taskWorkspaceId ?? requestedWorkspaceId;
|
||||
if (workspaceId) {
|
||||
const workspace = getDb()
|
||||
.prepare("SELECT id, project_id AS projectId FROM workspaces WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(workspaceId, input.companyId) as { id: string; projectId: string | null } | undefined;
|
||||
if (!workspace) {
|
||||
throw new WorkProductScopeError("workspace", "Work product workspace does not belong to the current company");
|
||||
}
|
||||
if (projectId && workspace.projectId !== projectId) {
|
||||
throw new WorkProductScopeError("workspace", "Work product workspace project must match the work product project");
|
||||
}
|
||||
}
|
||||
|
||||
return { taskId, projectId, workspaceId };
|
||||
}
|
||||
|
||||
export function buildWorkProductMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string | null; projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
type: WorkProductType; provider: WorkProductProvider; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: WorkProductStatus; reviewState?: WorkProductReviewState;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
}): string {
|
||||
return transaction(() => {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const scope = resolveWorkProductScope(getDb, input);
|
||||
const type = parseWorkProductType(input.type);
|
||||
const provider = parseWorkProductProvider(input.provider);
|
||||
const status = input.status !== undefined ? parseWorkProductStatus(input.status) : "active";
|
||||
const reviewState = input.reviewState !== undefined ? parseWorkProductReviewState(input.reviewState) : "none";
|
||||
if (input.isPrimary && scope.taskId && status !== "archived") {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived'",
|
||||
).run(ts, input.companyId, scope.taskId, type);
|
||||
}
|
||||
getDb().prepare(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, scope.projectId, scope.taskId,
|
||||
scope.workspaceId,
|
||||
type, provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, status,
|
||||
reviewState, input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
});
|
||||
},
|
||||
|
||||
findWorkProductByExternalId(
|
||||
companyId: string,
|
||||
provider: WorkProductProvider,
|
||||
externalId: string,
|
||||
workspaceId: string | null,
|
||||
): { id: string; taskId: string | null } | null {
|
||||
const parsedProvider = parseWorkProductProvider(provider);
|
||||
if (workspaceId) {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id = ? AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, workspaceId, parsedProvider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id IS NULL AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, parsedProvider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
updateWorkProduct(
|
||||
id: string,
|
||||
companyId: string,
|
||||
patch: {
|
||||
type?: WorkProductType;
|
||||
provider?: WorkProductProvider;
|
||||
externalId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
title?: string;
|
||||
url?: string | null;
|
||||
status?: WorkProductStatus;
|
||||
reviewState?: WorkProductReviewState;
|
||||
isPrimary?: boolean;
|
||||
summary?: string | null;
|
||||
metadataJson?: string | null;
|
||||
lastTouchedByRunId?: string | null;
|
||||
},
|
||||
): number {
|
||||
return transaction(() => {
|
||||
const current = getDb().prepare(
|
||||
"SELECT task_id as taskId, type, status, is_primary as isPrimary FROM work_products WHERE id = ? AND company_id = ?",
|
||||
).get(id, companyId) as
|
||||
| { taskId: string | null; type: string; status: string; isPrimary: number }
|
||||
| undefined;
|
||||
if (!current) return 0;
|
||||
|
||||
const ts = nowIso();
|
||||
const nextType = patch.type !== undefined ? parseWorkProductType(patch.type) : parseWorkProductType(current.type);
|
||||
const nextStatus = patch.status !== undefined ? parseWorkProductStatus(patch.status) : parseWorkProductStatus(current.status);
|
||||
const parsedProvider = patch.provider !== undefined ? parseWorkProductProvider(patch.provider) : undefined;
|
||||
const parsedReviewState = patch.reviewState !== undefined ? parseWorkProductReviewState(patch.reviewState) : undefined;
|
||||
const parsedWorkspaceId = patch.workspaceId !== undefined
|
||||
? resolveWorkProductScope(getDb, {
|
||||
companyId,
|
||||
taskId: current.taskId,
|
||||
workspaceId: patch.workspaceId,
|
||||
}).workspaceId
|
||||
: undefined;
|
||||
const nextIsPrimary = patch.isPrimary ?? Boolean(current.isPrimary);
|
||||
if (nextIsPrimary && current.taskId && nextStatus !== "archived") {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived' AND id <> ?",
|
||||
).run(ts, companyId, current.taskId, nextType, id);
|
||||
}
|
||||
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: SQLInputValue[] = [ts];
|
||||
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
|
||||
["type", "type", () => nextType],
|
||||
["provider", "provider", () => parsedProvider ?? null],
|
||||
["externalId", "external_id", (v) => (v as string | null) ?? null],
|
||||
["workspaceId", "workspace_id", () => parsedWorkspaceId ?? null],
|
||||
["title", "title", (v) => (v as string) ?? null],
|
||||
["url", "url", (v) => (v as string | null) ?? null],
|
||||
["status", "status", () => nextStatus],
|
||||
["reviewState", "review_state", () => parsedReviewState ?? null],
|
||||
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
|
||||
["summary", "summary", (v) => (v as string | null) ?? null],
|
||||
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
|
||||
["lastTouchedByRunId", "last_touched_by_run_id", (v) => (v as string | null) ?? null],
|
||||
];
|
||||
for (const [key, col, coerce] of map) {
|
||||
if (patch[key] !== undefined) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(coerce(patch[key]));
|
||||
}
|
||||
}
|
||||
values.push(id, companyId);
|
||||
const result = getDb()
|
||||
.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`)
|
||||
.run(...values);
|
||||
return Number(result.changes ?? 0);
|
||||
});
|
||||
},
|
||||
|
||||
resolveWorkProductTarget(
|
||||
workProductId: string,
|
||||
companyId: string,
|
||||
):
|
||||
| { kind: "url" | "path"; value: string }
|
||||
| { kind: "error"; reason: "not_found" | "archived" | "no_target" | "workspace_unresolved" | "path_escape" | "path_missing"; detail: string } {
|
||||
const row = getDb().prepare(
|
||||
"SELECT provider, external_id, url, status, task_id, created_by_run_id, last_touched_by_run_id, workspace_id FROM work_products WHERE id = ? AND company_id = ?",
|
||||
).get(workProductId, companyId) as
|
||||
| { provider: string; external_id: string | null; url: string | null; status: string; task_id: string | null; created_by_run_id: string | null; last_touched_by_run_id: string | null; workspace_id: string | null }
|
||||
| undefined;
|
||||
if (!row) return { kind: "error", reason: "not_found", detail: `No work_product row with id=${workProductId}` };
|
||||
if (row.status === "archived") return { kind: "error", reason: "archived", detail: "Work product is archived (retirement tombstone)" };
|
||||
|
||||
if (row.url && row.url.length > 0) return { kind: "url", value: row.url };
|
||||
if (row.provider !== "filesystem" || !row.external_id) {
|
||||
return { kind: "error", reason: "no_target", detail: `provider=${row.provider} external_id=${row.external_id ?? "null"} url=null` };
|
||||
}
|
||||
|
||||
const workspaceRoots = orderedUnique([
|
||||
resolveWorkspaceRoot(
|
||||
getDb,
|
||||
companyId,
|
||||
row.workspace_id,
|
||||
row.task_id,
|
||||
row.last_touched_by_run_id ?? row.created_by_run_id,
|
||||
),
|
||||
...listCompanyWorkspaceRoots(getDb, companyId),
|
||||
]);
|
||||
let firstCandidate: string | null = null;
|
||||
for (const workspaceRoot of workspaceRoots) {
|
||||
const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id);
|
||||
if (!absolute) {
|
||||
return { kind: "error", reason: "path_escape", detail: `external_id=${row.external_id} escapes workspace root ${workspaceRoot}` };
|
||||
}
|
||||
firstCandidate ??= absolute;
|
||||
if (fileExists(absolute)) {
|
||||
return { kind: "path", value: absolute };
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCandidate) {
|
||||
return {
|
||||
kind: "error",
|
||||
reason: "path_missing",
|
||||
detail: `No registered company workspace contains ${row.external_id}; first candidate was ${firstCandidate}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "error",
|
||||
reason: "workspace_unresolved",
|
||||
detail: `workspace_id=${row.workspace_id ?? "null"} task_id=${row.task_id ?? "null"} run_id=${row.last_touched_by_run_id ?? row.created_by_run_id ?? "null"} — none led to a workspace with a local_path`,
|
||||
};
|
||||
},
|
||||
|
||||
listWorkProductsForTask(taskId: string, companyId: string) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; project_id: string | null; task_id: string;
|
||||
workspace_id: string | null;
|
||||
type: string; provider: string; external_id: string | null; title: string;
|
||||
url: string | null; status: string; review_state: string; is_primary: number;
|
||||
summary: string | null; metadata_json: string | null;
|
||||
created_by_run_id: string | null; last_touched_by_run_id: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? AND status <> 'archived' ORDER BY is_primary DESC, updated_at DESC",
|
||||
taskId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
workspaceId: r.workspace_id,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
lastTouchedByRunId: r.last_touched_by_run_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
listWorkProductsForCompany(companyId: string, limit = 500) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; project_id: string | null; task_id: string | null;
|
||||
workspace_id: string | null;
|
||||
type: string; provider: string; external_id: string | null; title: string;
|
||||
url: string | null; status: string; review_state: string; is_primary: number;
|
||||
summary: string | null; metadata_json: string | null;
|
||||
created_by_run_id: string | null; last_touched_by_run_id: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM work_products WHERE company_id = ? AND status <> 'archived' ORDER BY updated_at DESC LIMIT ?",
|
||||
companyId, limit,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
workspaceId: r.workspace_id,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
lastTouchedByRunId: r.last_touched_by_run_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteWorkProduct(id: string, companyId: string): number {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?")
|
||||
.run(id, companyId);
|
||||
return Number(result.changes ?? 0);
|
||||
},
|
||||
|
||||
archiveWorkProductsByExternalPaths(input: {
|
||||
companyId: string;
|
||||
workspaceId: string | null;
|
||||
provider: WorkProductProvider;
|
||||
paths: string[];
|
||||
lastTouchedByRunId: string | null;
|
||||
}): Array<{ id: string; externalId: string }> {
|
||||
if (input.paths.length === 0) return [];
|
||||
const provider = parseWorkProductProvider(input.provider);
|
||||
const ts = nowIso();
|
||||
const CHUNK = 500;
|
||||
const workspaceClause = input.workspaceId
|
||||
? "AND workspace_id = ?"
|
||||
: "AND workspace_id IS NULL";
|
||||
const results: Array<{ id: string; externalId: string }> = [];
|
||||
for (let i = 0; i < input.paths.length; i += CHUNK) {
|
||||
const chunk = input.paths.slice(i, i + CHUNK);
|
||||
const placeholders = chunk.map(() => "?").join(", ");
|
||||
const params: SQLInputValue[] = [
|
||||
input.lastTouchedByRunId ?? null,
|
||||
ts,
|
||||
input.companyId,
|
||||
provider,
|
||||
];
|
||||
if (input.workspaceId) params.push(input.workspaceId);
|
||||
params.push(...chunk);
|
||||
const rows = readAll<{ id: string; external_id: string }>(
|
||||
`UPDATE work_products
|
||||
SET status = 'archived',
|
||||
last_touched_by_run_id = ?,
|
||||
updated_at = ?
|
||||
WHERE company_id = ?
|
||||
AND provider = ?
|
||||
${workspaceClause}
|
||||
AND external_id IN (${placeholders})
|
||||
AND status <> 'archived'
|
||||
RETURNING id, external_id`,
|
||||
...params,
|
||||
);
|
||||
for (const row of rows) {
|
||||
results.push({ id: row.id, externalId: row.external_id });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { canonicalizeJsonString, jsonStringArraySchema } from "@shared/contracts";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { ensureId, nowIso } from "./helpers";
|
||||
import { buildConfigRevisionMethods } from "./config-revisions";
|
||||
import { buildWorkProductMethods } from "./documents-work-products";
|
||||
|
||||
/**
|
||||
* Document row shape returned by listDocuments — used by
|
||||
* GET /api/companies/:companyId/documents. Mirrors the historical inline
|
||||
* SQL projection so HTTP response bodies stay byte-stable for clients.
|
||||
*/
|
||||
export type DocumentListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorAgentId: string;
|
||||
reviewerAgentId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
parentDocId: string | null;
|
||||
version: number;
|
||||
status: string;
|
||||
tagsJson: string;
|
||||
latestRevisionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Document lookup row used by the PATCH document handler — narrower
|
||||
* projection without parent_doc_id / latest_revision_id / timestamps.
|
||||
*/
|
||||
export type DocumentLookupRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorAgentId: string;
|
||||
reviewerAgentId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
version: number;
|
||||
status: string;
|
||||
tagsJson: string;
|
||||
};
|
||||
|
||||
export function buildDocumentMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
...buildConfigRevisionMethods(getDb, readAll, transaction),
|
||||
...buildWorkProductMethods(getDb, readAll, transaction),
|
||||
saveDocument(input: {
|
||||
id?: string | null; companyId: string; type: string; title: string; content?: string;
|
||||
authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null;
|
||||
goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string;
|
||||
sourceRunId?: string | null;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from documents where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Document ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
const tagsJson = canonicalizeJsonString(input.tagsJson, jsonStringArraySchema, []);
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", tagsJson, input.sourceRunId ?? null, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", tagsJson, input.sourceRunId ?? null, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all documents for a company, ordered by updated_at desc.
|
||||
* Used by GET /api/companies/:companyId/documents.
|
||||
*/
|
||||
listDocuments(companyId: string): DocumentListRow[] {
|
||||
return readAll<DocumentListRow>(
|
||||
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, parent_doc_id as parentDocId, version, status, tags_json as tagsJson, latest_revision_id as latestRevisionId, created_at as createdAt, updated_at as updatedAt from documents where company_id = ? order by updated_at desc",
|
||||
companyId,
|
||||
).map((d) => ({ ...d, latestRevisionId: d.latestRevisionId ?? null }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the lookup row needed for PATCH /api/companies/:companyId/documents/:documentId.
|
||||
* Returns null when the document is missing or belongs to another company.
|
||||
*/
|
||||
getDocumentForUpdate(
|
||||
documentId: string,
|
||||
companyId: string,
|
||||
): DocumentLookupRow | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, version, status, tags_json as tagsJson from documents where id = ? and company_id = ? limit 1",
|
||||
)
|
||||
.get(documentId, companyId) as DocumentLookupRow | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
updateDocumentStatus(id: string, companyId: string, status: string): void {
|
||||
getDb().prepare(
|
||||
"UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
).run(status, nowIso(), id, companyId);
|
||||
},
|
||||
|
||||
deleteDocument(id: string, companyId: string) {
|
||||
getDb().prepare("delete from documents where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
createDocumentRevision(input: {
|
||||
documentId: string; companyId: string; body: string;
|
||||
changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null;
|
||||
}): { id: string; revisionNumber: number } {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const current = getDb().prepare(
|
||||
"SELECT version FROM documents WHERE id = ? AND company_id = ?",
|
||||
).get(input.documentId, input.companyId) as { version: number } | undefined;
|
||||
if (!current) throw new Error(`Document ${input.documentId} not found`);
|
||||
const revisionNumber = current.version + 1;
|
||||
getDb().prepare(
|
||||
"INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts);
|
||||
getDb().prepare(
|
||||
"UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
).run(revisionNumber, id, ts, input.documentId, input.companyId);
|
||||
return { id, revisionNumber };
|
||||
},
|
||||
|
||||
getDocumentLatestRevisionId(documentId: string, companyId: string): string | null {
|
||||
const row = getDb().prepare(
|
||||
"SELECT latest_revision_id FROM documents WHERE id = ? AND company_id = ?",
|
||||
).get(documentId, companyId) as { latest_revision_id: string | null } | undefined;
|
||||
return row?.latest_revision_id ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns up to `limit` documents of the given types for a company that
|
||||
* are either authored by `authorAgentId` or were created at/after the
|
||||
* task's createdAt. Used by the deliverable-review gate when a task is
|
||||
* advanced to "done".
|
||||
*/
|
||||
listImportantDocsForReview(input: {
|
||||
companyId: string;
|
||||
types: readonly string[];
|
||||
authorAgentId: string | null;
|
||||
taskId: string;
|
||||
limit?: number;
|
||||
}): Array<{ id: string; type: string; title: string }> {
|
||||
const limit = input.limit ?? 10;
|
||||
const placeholders = input.types.map(() => "?").join(", ");
|
||||
return readAll<{ id: string; type: string; title: string }>(
|
||||
`select id, type, title from documents where company_id = ? and type in (${placeholders})
|
||||
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
|
||||
order by created_at desc limit ?`,
|
||||
input.companyId,
|
||||
...input.types,
|
||||
input.authorAgentId ?? "",
|
||||
input.taskId,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
|
||||
listDocumentRevisions(documentId: string, companyId: string) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; document_id: string; revision_number: number;
|
||||
body: string; change_summary: string | null; created_by_agent_id: string | null;
|
||||
created_by_user_id: string | null; created_at: string;
|
||||
}>(
|
||||
"SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC",
|
||||
documentId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, documentId: r.document_id,
|
||||
revisionNumber: r.revision_number, body: r.body,
|
||||
changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id,
|
||||
createdByUserId: r.created_by_user_id, createdAt: r.created_at,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Task-Document Links ──
|
||||
|
||||
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts);
|
||||
return id;
|
||||
},
|
||||
|
||||
unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void {
|
||||
getDb().prepare(
|
||||
"DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?",
|
||||
).run(taskId, key.toLowerCase(), companyId);
|
||||
},
|
||||
|
||||
listDocumentsForTask(taskId: string, companyId: string) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; task_id: string; document_id: string;
|
||||
key: string; created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC",
|
||||
taskId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, taskId: r.task_id,
|
||||
documentId: r.document_id, key: r.key,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord, ConnectorId, TaskRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { activeRunIdSql, ensureId, normalizeOptionalId, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Goal row shape returned by listGoals / getGoalForUpdate. Mirrors the
|
||||
* historical inline SQL projection in routes-work-graph.ts so HTTP
|
||||
* responses stay byte-stable.
|
||||
*/
|
||||
export type GoalListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
parentId: string | null;
|
||||
ownerAgentId: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project row shape returned by listProjects / getProjectForUpdate.
|
||||
* Mirrors the historical inline SQL projection in routes-work-graph.ts.
|
||||
*/
|
||||
export type ProjectListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
goalId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
leadAgentId: string | null;
|
||||
status: string;
|
||||
targetDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function buildEntityCrudMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
_deps: {
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
vault: { encrypt: (v: string) => { backend: string; payload: Uint8Array }; decrypt: (backend: "safe_storage" | "vault", ciphertext: Buffer) => string };
|
||||
listInvalidEnvBindingKeys: (t: string) => string[];
|
||||
parseEnvBindingText: (t: string) => Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const { vault, listInvalidEnvBindingKeys, parseEnvBindingText } = _deps;
|
||||
return {
|
||||
getTask(id: string): TaskRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?`,
|
||||
)
|
||||
.get(id) as TaskRecord | undefined;
|
||||
if (!row) throw new Error(`Task not found: ${id}`);
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
row.requiresFilesystem = Boolean(row.requiresFilesystem);
|
||||
row.startedAt = row.startedAt ?? null;
|
||||
row.completedAt = row.completedAt ?? null;
|
||||
row.cancelledAt = row.cancelledAt ?? null;
|
||||
return row;
|
||||
},
|
||||
|
||||
getAgent(id: string): AgentRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
|
||||
return row;
|
||||
},
|
||||
|
||||
findAgent(id: string): AgentRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) return null;
|
||||
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all goals for a company. Used by GET /api/companies/:id/goals.
|
||||
*/
|
||||
listGoals(companyId: string): GoalListRow[] {
|
||||
return readAll<GoalListRow>(
|
||||
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where company_id = ? order by created_at asc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the goal lookup row used by PATCH /api/goals/:goalId.
|
||||
* Returns null when the goal is missing or belongs to another company.
|
||||
*/
|
||||
getGoalForUpdate(goalId: string, companyId: string): GoalListRow | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where id = ? and company_id = ? limit 1",
|
||||
)
|
||||
.get(goalId, companyId) as GoalListRow | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all projects for a company. Used by
|
||||
* GET /api/companies/:id/projects.
|
||||
*/
|
||||
listProjects(companyId: string): ProjectListRow[] {
|
||||
return readAll<ProjectListRow>(
|
||||
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where company_id = ? order by created_at desc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the project lookup row used by PATCH /api/projects/:projectId.
|
||||
* Returns null when the project is missing or belongs to another company.
|
||||
*/
|
||||
getProjectForUpdate(projectId: string, companyId: string): ProjectListRow | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where id = ? and company_id = ? limit 1",
|
||||
)
|
||||
.get(projectId, companyId) as ProjectListRow | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare("select id from goals where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update goals set title = ?, description = ?, parent_id = ?, owner_agent_id = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into goals (id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(id, input.companyId, input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
saveProject(input: { id?: string | null; companyId: string; goalId?: string | null; name: string; description: string; leadAgentId?: string | null; targetDate?: string | null; status: string }) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare("select id from projects where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update projects set goal_id = ?, name = ?, description = ?, lead_agent_id = ?, status = ?, target_date = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, id, input.companyId);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into projects (id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(id, input.companyId, normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
saveSecret(input: { id?: string | null; companyId: string; name: string; description: string; value: string }) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const encrypted = vault.encrypt(input.value);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare("select id, company_id as companyId from secrets where id = ?").get(id) as
|
||||
| { id: string; companyId: string }
|
||||
| undefined;
|
||||
if (existing) {
|
||||
if (existing.companyId !== input.companyId) {
|
||||
throw new Error("Secrets cannot be moved across companies.");
|
||||
}
|
||||
getDb()
|
||||
.prepare("update secrets set name = ?, description = ?, backend = ?, ciphertext = ?, updated_at = ? where id = ? and company_id = ?")
|
||||
.run(input.name, input.description, encrypted.backend, encrypted.payload, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, input.companyId, input.name, input.description, encrypted.backend, encrypted.payload, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
resolveSecretEnv(bindingText: string, companyId: string): Record<string, string> {
|
||||
const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText));
|
||||
const bindings = parseEnvBindingText(bindingText);
|
||||
const result: Record<string, string> = {};
|
||||
for (const [envKey, rawValue] of Object.entries(bindings)) {
|
||||
if (invalidKeys.has(envKey)) continue;
|
||||
const row = getDb().prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, rawValue) as
|
||||
| { backend: "safe_storage" | "vault"; ciphertext: Uint8Array }
|
||||
| undefined;
|
||||
if (row) {
|
||||
result[envKey] = vault.decrypt(row.backend, Buffer.from(row.ciphertext));
|
||||
} else {
|
||||
result[envKey] = rawValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteGoal(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb().prepare("update goals set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("update projects set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("update tasks set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("delete from goals where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
deleteProject(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb().prepare("update tasks set project_id = null where project_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("update workspaces set project_id = null where project_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("delete from projects where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
deleteSecret(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare("update social_accounts set credential_secret_id = null, updated_at = ? where credential_secret_id = ? and company_id = ?")
|
||||
.run(nowIso(), id, companyId);
|
||||
getDb().prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
countActiveGoalsForCompany(companyId: string): number {
|
||||
const row = getDb().prepare(
|
||||
"select count(*) as cnt from goals where company_id = ? and status in ('active', 'planned')",
|
||||
).get(companyId) as { cnt: number };
|
||||
return row.cnt;
|
||||
},
|
||||
|
||||
createHireRequest(input: {
|
||||
companyId: string; requestedByAgentId?: string | null;
|
||||
name: string; role: string; title: string; department?: string | null;
|
||||
reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null;
|
||||
model?: string | null; metadataJson?: string; capabilities: string;
|
||||
budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string;
|
||||
payloadJson?: string | null;
|
||||
}, selfDeps: {
|
||||
saveAgent: (input: {
|
||||
companyId: string; name: string; role: string; title: string;
|
||||
department: string | null; status: AgentRecord["status"];
|
||||
reportsTo: string | null; connectorId: ConnectorId;
|
||||
workspaceId: string | null; model: string | null;
|
||||
metadataJson: string; capabilities: string; budgetMonthlyUsd: number;
|
||||
modelStrategy?: "explicit" | "auto";
|
||||
}) => string;
|
||||
requestApproval: (input: {
|
||||
companyId: string; requestedByAgentId: string | null;
|
||||
relatedAgentId: string; type: string;
|
||||
payloadSummary: string; impactSummary: string;
|
||||
payloadJson: string | null;
|
||||
}) => string;
|
||||
}) {
|
||||
return transaction(() => {
|
||||
const agentId = selfDeps.saveAgent({
|
||||
companyId: input.companyId, name: input.name, role: input.role,
|
||||
title: input.title, department: input.department ?? null,
|
||||
status: "pending_approval", reportsTo: input.reportsTo ?? null,
|
||||
connectorId: input.connectorId, workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null, metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
// Agents hired by the CEO (or any other agent) default to `auto`:
|
||||
// the dispatcher will pick the cheapest enabled model that
|
||||
// clears their inferred task profile. User-driven form saves
|
||||
// flip this to 'explicit' at the IPC handler.
|
||||
modelStrategy: "auto",
|
||||
});
|
||||
const approvalId = selfDeps.requestApproval({
|
||||
companyId: input.companyId,
|
||||
requestedByAgentId: input.requestedByAgentId ?? null,
|
||||
relatedAgentId: agentId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
payloadJson: input.payloadJson ?? null,
|
||||
});
|
||||
return { agentId, approvalId };
|
||||
});
|
||||
},
|
||||
|
||||
incrementActiveSprintVelocity(companyId: string, delta: number) {
|
||||
const ts = nowIso();
|
||||
const today = ts.slice(0, 10);
|
||||
getDb().prepare(
|
||||
"update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?",
|
||||
).run(delta, ts, companyId, today, today);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SqliteRow represents dynamic row shapes from node:sqlite; callers narrow via explicit column reads
|
||||
export type SqliteRow = Record<string, any>;
|
||||
export type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
export type Transaction = <T>(operation: () => T) => T;
|
||||
|
||||
export const BOARD_READER_ID = "__board__";
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function parseJson<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureId(id: string | null | undefined): string {
|
||||
return id && id.length > 0 ? id : randomUUID();
|
||||
}
|
||||
|
||||
export function normalizeOptionalId(id: string | null | undefined): string | null {
|
||||
return id && id.length > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function toBoolean(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
export function taskPriorityOrderSql(columnName: string): string {
|
||||
return `case ${columnName}
|
||||
when 'critical' then 4
|
||||
when 'high' then 3
|
||||
when 'medium' then 2
|
||||
when 'low' then 1
|
||||
else 0
|
||||
end`;
|
||||
}
|
||||
|
||||
export function activeRunIdSql(taskTableAlias: string): string {
|
||||
return `(select l.run_id
|
||||
from task_leases l
|
||||
join runs r on r.id = l.run_id
|
||||
where l.task_id = ${taskTableAlias}.id
|
||||
and r.status in ('queued', 'running')
|
||||
limit 1)`;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Ledger accessor for `agent_message_projections`.
|
||||
*
|
||||
* The projection path (inbox → session shadow) used to assume
|
||||
* `markAgentInboxDelivered` acted as its completion mark, but that
|
||||
* write lived outside the transaction that appended to the shadow
|
||||
* and wrote the thread event. A crash in that window produced a
|
||||
* replay that double-inserted both. The ledger is the idempotent
|
||||
* gate: a single `INSERT OR IGNORE` per `(message_id, session_id)`
|
||||
* short-circuits the rest of the projection closure when the pair
|
||||
* has already committed.
|
||||
*/
|
||||
export function buildInboxProjectionMethods(getDb: () => DatabaseSync) {
|
||||
return {
|
||||
/**
|
||||
* Attempt to claim a projection slot. Returns true when this call
|
||||
* wrote a fresh row, false when the pair was already recorded.
|
||||
* Callers run this inside the same transaction that performs the
|
||||
* shadow append, the thread-event write, and the delivered mark,
|
||||
* so either all four writes commit or none do.
|
||||
*/
|
||||
claimProjection(messageId: string, sessionId: string): boolean {
|
||||
const result = getDb().prepare(
|
||||
"INSERT OR IGNORE INTO agent_message_projections (message_id, session_id, projected_at) VALUES (?, ?, ?)",
|
||||
).run(messageId, sessionId, nowIso());
|
||||
return Number(result.changes ?? 0) > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Purge ledger rows for sessions no agent currently references as
|
||||
* its active `session_id`. A rotated session is never read again —
|
||||
* its ledger entries only exist to prevent replays against that
|
||||
* specific shadow, which no future run will touch. The safety
|
||||
* margin (rows younger than the cutoff are preserved) covers
|
||||
* rotations that happen mid-sweep so a race cannot strip the
|
||||
* ledger under an active run.
|
||||
*/
|
||||
purgeOrphanProjections(cutoffIso: string): number {
|
||||
const result = getDb().prepare(
|
||||
`DELETE FROM agent_message_projections
|
||||
WHERE projected_at < ?
|
||||
AND session_id NOT IN (SELECT session_id FROM agents WHERE session_id IS NOT NULL)`,
|
||||
).run(cutoffIso);
|
||||
return Number(result.changes ?? 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../database";
|
||||
@@ -1,281 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { canonicalizeJsonString, jsonArraySchema, jsonStringArraySchema, meetingActionItemArraySchema } from "@shared/contracts";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { ensureId, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Meeting row shape returned by listMeetings / getMeeting — used by
|
||||
* meeting routes. Mirrors the historical inline SQL projection so HTTP
|
||||
* response bodies stay byte-stable.
|
||||
*/
|
||||
export type MeetingListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
organizerAgentId: string;
|
||||
participantAgentIds: string;
|
||||
scheduledAt: string;
|
||||
durationMinutes: number;
|
||||
agendaJson: string;
|
||||
notesJson: string | null;
|
||||
decisionsJson: string | null;
|
||||
actionItemsJson: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const MEETING_LIST_COLUMNS =
|
||||
"select id, company_id as companyId, type, title, organizer_agent_id as organizerAgentId, participant_agent_ids as participantAgentIds, scheduled_at as scheduledAt, duration_minutes as durationMinutes, agenda_json as agendaJson, notes_json as notesJson, decisions_json as decisionsJson, action_items_json as actionItemsJson, status, created_at as createdAt, updated_at as updatedAt from meetings";
|
||||
|
||||
/**
|
||||
* Sprint row shape returned by listSprints — used by
|
||||
* GET /api/companies/:companyId/sprints. Mirrors the historical inline
|
||||
* SQL projection.
|
||||
*/
|
||||
export type SprintListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: string;
|
||||
retrospectiveNotes: string | null;
|
||||
velocityPoints: number;
|
||||
completedPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Knowledge entry row shape returned by listKnowledgeEntries — used by
|
||||
* GET /api/companies/:companyId/knowledge. Mirrors the historical inline
|
||||
* SQL projection so HTTP responses stay byte-stable.
|
||||
*/
|
||||
export type KnowledgeListRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
category: string;
|
||||
topic: string;
|
||||
content: string;
|
||||
authorAgentId: string;
|
||||
importance: string;
|
||||
tagsJson: string;
|
||||
referencedEntityType: string | null;
|
||||
referencedEntityId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function buildKnowledgeMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
return {
|
||||
listMeetings(companyId: string): MeetingListRow[] {
|
||||
return readAll<MeetingListRow>(
|
||||
`${MEETING_LIST_COLUMNS} where company_id = ? order by scheduled_at desc`,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
getMeeting(meetingId: string, companyId: string): MeetingListRow | null {
|
||||
const row = getDb()
|
||||
.prepare(`${MEETING_LIST_COLUMNS} where id = ? and company_id = ? limit 1`)
|
||||
.get(meetingId, companyId) as MeetingListRow | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
saveMeeting(input: {
|
||||
id?: string | null; companyId: string; type: string; title: string; organizerAgentId?: string | null;
|
||||
participantAgentIds?: string; scheduledAt: string; durationMinutes?: number;
|
||||
agendaJson?: string; notesJson?: string; decisionsJson?: string; actionItemsJson?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from meetings where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Meeting ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
const participantAgentIds = canonicalizeJsonString(input.participantAgentIds, jsonStringArraySchema, []);
|
||||
const agendaJson = canonicalizeJsonString(input.agendaJson, jsonArraySchema, []);
|
||||
const notesJson = canonicalizeJsonString(input.notesJson, jsonArraySchema, []);
|
||||
const decisionsJson = canonicalizeJsonString(input.decisionsJson, jsonArraySchema, []);
|
||||
const actionItemsJson = canonicalizeJsonString(input.actionItemsJson, meetingActionItemArraySchema, []);
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.type, input.title, input.organizerAgentId ?? null, participantAgentIds, input.scheduledAt, input.durationMinutes ?? 30, agendaJson, notesJson, decisionsJson, actionItemsJson, input.status ?? "scheduled", ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into meetings (id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.type, input.title, input.organizerAgentId ?? null, participantAgentIds, input.scheduledAt, input.durationMinutes ?? 30, agendaJson, notesJson, decisionsJson, actionItemsJson, input.status ?? "scheduled", ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteMeeting(id: string, companyId: string) {
|
||||
getDb().prepare("delete from meetings where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
saveKnowledgeEntry(input: {
|
||||
id?: string | null; companyId: string; category: string; topic: string; content: string;
|
||||
authorAgentId?: string | null; importance?: string; tagsJson?: string;
|
||||
referencedEntityType?: string | null; referencedEntityId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from knowledge_base where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Knowledge entry ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
const tagsJson = canonicalizeJsonString(input.tagsJson, jsonStringArraySchema, []);
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", tagsJson, input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into knowledge_base (id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", tagsJson, input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteKnowledgeEntry(id: string, companyId: string) {
|
||||
getDb().prepare("delete from knowledge_base where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all knowledge entries for a company, ordered by updated_at desc.
|
||||
* Used by GET /api/companies/:companyId/knowledge.
|
||||
*/
|
||||
listKnowledgeEntries(companyId: string): KnowledgeListRow[] {
|
||||
return readAll<KnowledgeListRow>(
|
||||
"select id, company_id as companyId, category, topic, content, author_agent_id as authorAgentId, importance, tags_json as tagsJson, referenced_entity_type as referencedEntityType, referenced_entity_id as referencedEntityId, created_at as createdAt, updated_at as updatedAt from knowledge_base where company_id = ? order by updated_at desc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
saveSprint(input: {
|
||||
id?: string | null; companyId: string; name: string; goal?: string;
|
||||
startDate: string; endDate: string; status?: string; retrospectiveNotes?: string;
|
||||
velocityPoints?: number; completedPoints?: number;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const existing = getDb().prepare(
|
||||
"select company_id as companyId from sprints where id = ?",
|
||||
).get(id) as { companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new Error(`Sprint ${id} does not belong to company ${input.companyId}`);
|
||||
}
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb().prepare(
|
||||
"insert into sprints (id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteSprint(id: string, companyId: string) {
|
||||
getDb().prepare("delete from sprints where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Collects the set of deliverable signals produced by a single run.
|
||||
* Each signal reflects a distinct artifact surface:
|
||||
*
|
||||
* - knowledge entry bound to the run (or written during its window)
|
||||
* - document bound to the run (or written during its window)
|
||||
* - filesystem `work_product` registered with `created_by_run_id = run`
|
||||
* - substantial task comment (>100 chars) posted during the run window
|
||||
*
|
||||
* The caller in `automation-run-completion` decides which signals are
|
||||
* acceptable for a given task type, so schema changes to any of the
|
||||
* source tables surface here rather than in an opaque boolean.
|
||||
*/
|
||||
collectRunDeliverableSignals(
|
||||
runId: string,
|
||||
companyId: string,
|
||||
taskId: string,
|
||||
): {
|
||||
hasKnowledgeEntry: boolean;
|
||||
hasDocument: boolean;
|
||||
hasWorkProduct: boolean;
|
||||
hasSubstantialComment: boolean;
|
||||
} {
|
||||
const causalKnowledge = readAll<{ id: string }>(
|
||||
"select id from knowledge_base where company_id = ? and source_run_id = ? limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
const legacyKnowledge = causalKnowledge.length === 0
|
||||
? readAll<{ id: string }>(
|
||||
"select id from knowledge_base where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
|
||||
companyId, runId,
|
||||
)
|
||||
: [];
|
||||
|
||||
const causalDocs = readAll<{ id: string }>(
|
||||
"select id from documents where company_id = ? and source_run_id = ? limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
const legacyDocs = causalDocs.length === 0
|
||||
? readAll<{ id: string }>(
|
||||
"select id from documents where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
|
||||
companyId, runId,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Pre-v45 provenance was frozen at creation; post-v45 every upsert
|
||||
// refreshes `last_touched_by_run_id`. Querying by either side keeps
|
||||
// the deliverable signal correct for a run that edited a pre-existing
|
||||
// work product as well as a run that created a new one.
|
||||
//
|
||||
// `status <> 'archived'` excludes retirement tombstones: a run
|
||||
// whose only filesystem action was deleting the file stamps
|
||||
// `last_touched_by_run_id` onto the archived row, and without
|
||||
// this filter the deliverable gate would count the removal as a
|
||||
// produced artifact and let the task advance to review.
|
||||
const workProducts = readAll<{ id: string }>(
|
||||
"select id from work_products where company_id = ? and status <> 'archived' and (last_touched_by_run_id = ? or created_by_run_id = ?) limit 1",
|
||||
companyId, runId, runId,
|
||||
);
|
||||
|
||||
const recentComments = readAll<{ body: string }>(
|
||||
"select body from task_comments where task_id = ? and created_at >= (select started_at from runs where id = ?) and length(body) > 100",
|
||||
taskId, runId,
|
||||
);
|
||||
|
||||
return {
|
||||
hasKnowledgeEntry: causalKnowledge.length > 0 || legacyKnowledge.length > 0,
|
||||
hasDocument: causalDocs.length > 0 || legacyDocs.length > 0,
|
||||
hasWorkProduct: workProducts.length > 0,
|
||||
hasSubstantialComment: recentComments.length > 0,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all sprints for a company, ordered by start_date desc.
|
||||
* Used by GET /api/companies/:companyId/sprints.
|
||||
*/
|
||||
listSprints(companyId: string): SprintListRow[] {
|
||||
return readAll<SprintListRow>(
|
||||
"select id, company_id as companyId, name, goal, start_date as startDate, end_date as endDate, status, retrospective_notes as retrospectiveNotes, velocity_points as velocityPoints, completed_points as completedPoints, created_at as createdAt, updated_at as updatedAt from sprints where company_id = ? order by start_date desc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
|
||||
import { normalizeMarkdownText } from "@shared/markdown-text";
|
||||
import {
|
||||
BOARD_READER_ID,
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
nowIso,
|
||||
} from "./helpers";
|
||||
|
||||
export function buildMessageMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
deps: {
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
transaction: Transaction;
|
||||
},
|
||||
) {
|
||||
/** Write the @mention fan-out rows for a freshly-persisted message.
|
||||
* Idempotent: the primary key is (message_id, agent_id), so a retry
|
||||
* driven by a steer/wake race never duplicates rows. Skipping the
|
||||
* sender keeps self-mentions out of the join so an agent talking
|
||||
* about itself ("cc @me …") cannot inflate its own inbox. */
|
||||
function insertMessageMentions(
|
||||
messageId: string,
|
||||
companyId: string,
|
||||
fromAgentId: string,
|
||||
mentionedAgentIds: Iterable<string>,
|
||||
createdAt: string,
|
||||
): void {
|
||||
const stmt = getDb().prepare(
|
||||
"insert or ignore into agent_message_mentions (message_id, agent_id, company_id, created_at) values (?, ?, ?, ?)",
|
||||
);
|
||||
for (const agentId of mentionedAgentIds) {
|
||||
if (!agentId || agentId === fromAgentId || agentId === BOARD_READER_ID) continue;
|
||||
stmt.run(messageId, agentId, companyId, createdAt);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sendAgentMessage(input: {
|
||||
companyId: string;
|
||||
fromAgentId: string;
|
||||
toAgentId?: string | null;
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
priority?: string;
|
||||
parentMessageId?: string | null;
|
||||
attachmentsJson?: string;
|
||||
senderKind?: "agent" | "human" | "system";
|
||||
/** Agent IDs parsed out of the body's @mentions. Persisted into
|
||||
* `agent_message_mentions` alongside the row so the inbox queries
|
||||
* can surface the message to mentioned agents without re-parsing
|
||||
* every body at read time. Empty / absent = no mention fan-out. */
|
||||
mentionedAgentIds?: readonly string[];
|
||||
}): string {
|
||||
const channel = input.channel ?? "direct";
|
||||
if (channel === "direct" && !input.toAgentId) {
|
||||
throw new Error("Direct messages require a recipient.");
|
||||
}
|
||||
if (channel === "direct" && input.toAgentId === input.fromAgentId) {
|
||||
throw new Error("Direct messages require two distinct participants.");
|
||||
}
|
||||
const senderKind = input.senderKind
|
||||
?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent");
|
||||
const body = normalizeMarkdownText(input.body);
|
||||
const id = randomUUID();
|
||||
const createdAt = nowIso();
|
||||
// Single transaction: the row, its self-read receipt, and its
|
||||
// mention fan-out must land atomically. The self-read receipt
|
||||
// makes "author has read their own message" an invariant in the
|
||||
// join table — without it, every message a sender posts inflates
|
||||
// their own sidebar / inbox / channel unread counts until they
|
||||
// happen to re-open the channel, because `readAt` is derived from
|
||||
// `agent_message_reads` per viewer.
|
||||
deps.transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agent_messages (id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.companyId,
|
||||
input.fromAgentId,
|
||||
input.toAgentId ?? null,
|
||||
senderKind,
|
||||
channel,
|
||||
input.channelTargetId ?? null,
|
||||
input.subject,
|
||||
body,
|
||||
input.priority ?? "normal",
|
||||
input.parentMessageId ?? null,
|
||||
input.attachmentsJson ?? "[]",
|
||||
createdAt,
|
||||
);
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agent_message_reads (message_id, agent_id, read_at) values (?, ?, ?)",
|
||||
)
|
||||
.run(id, input.fromAgentId, createdAt);
|
||||
if (input.mentionedAgentIds && input.mentionedAgentIds.length > 0) {
|
||||
insertMessageMentions(id, input.companyId, input.fromAgentId, input.mentionedAgentIds, createdAt);
|
||||
}
|
||||
});
|
||||
deps.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: input.fromAgentId,
|
||||
action: "sent_message",
|
||||
entityType: "agent_message",
|
||||
entityId: id,
|
||||
detail: `Message: ${input.subject}`,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
getAgentMessage(messageId: string, companyId: string): AgentMessageRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, sender_kind as senderKind, channel, channel_target_id as channelTargetId, subject, body, priority, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where id = ? and company_id = ?",
|
||||
)
|
||||
.get(messageId, companyId) as Omit<AgentMessageRecord, "readAt"> | undefined;
|
||||
if (!row) return null;
|
||||
// `readAt` is a per-viewer computed field resolved from
|
||||
// agent_message_reads at the callers that need it (inbox
|
||||
// projection, renderer snapshot). This single-row lookup has no
|
||||
// viewer context, so the field is surfaced as `null`.
|
||||
return { ...row, readAt: null };
|
||||
},
|
||||
|
||||
listAgentMessages(
|
||||
companyId: string,
|
||||
opts?: {
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
unreadOnly?: boolean;
|
||||
limit?: number;
|
||||
},
|
||||
): AgentMessageRecord[] {
|
||||
const limit = opts?.limit ?? 200;
|
||||
// `readAt` is resolved from `agent_message_reads` further down,
|
||||
// per viewer (BOARD_READER_ID for the operator-wide path, the
|
||||
// specific agent id for the per-agent path). The column lives
|
||||
// outside the row itself since v49.
|
||||
const selectCols =
|
||||
"select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, sender_kind as senderKind, channel, channel_target_id as channelTargetId, subject, body, priority, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages";
|
||||
let sql = `${selectCols} where company_id = ?`;
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
if (opts?.messageId) {
|
||||
sql += " and id = ?";
|
||||
params.push(opts.messageId);
|
||||
}
|
||||
if (opts?.channel) {
|
||||
sql += " and channel = ?";
|
||||
params.push(opts.channel);
|
||||
}
|
||||
if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) {
|
||||
sql += " and channel_target_id = ?";
|
||||
params.push(opts.channelTargetId);
|
||||
}
|
||||
|
||||
if (!opts?.agentId) {
|
||||
sql += " order by created_at desc";
|
||||
if (limit > 0) {
|
||||
sql += " limit ?";
|
||||
params.push(limit);
|
||||
}
|
||||
const rows = readAll<Omit<AgentMessageRecord, "readAt">>(sql, ...params).reverse();
|
||||
const boardReadReceiptMap = new Map(
|
||||
readAll<{ messageId: string; readAt: string }>(
|
||||
`select reads.message_id as messageId, reads.read_at as readAt
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ? and messages.company_id = ?`,
|
||||
BOARD_READER_ID,
|
||||
companyId,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
let result = rows.map((message) => ({
|
||||
...message,
|
||||
readAt: boardReadReceiptMap.get(message.id) ?? null,
|
||||
}));
|
||||
if (opts?.unreadOnly) {
|
||||
result = result.filter((message) => !message.readAt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let department: string | null = null;
|
||||
try {
|
||||
const agent = deps.getAgent(opts.agentId);
|
||||
if (agent.companyId === companyId) {
|
||||
department = agent.department;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`[database/messages] getAgent failed for ${opts.agentId}; department filter disabled:`, err instanceof Error ? err.message : String(err));
|
||||
department = null;
|
||||
}
|
||||
|
||||
const relevantProjectIds = new Set<string>();
|
||||
for (const row of readAll<{ projectId: string | null }>(
|
||||
"select distinct project_id as projectId from tasks where company_id = ? and assignee_agent_id = ? and project_id is not null",
|
||||
companyId,
|
||||
opts.agentId,
|
||||
)) {
|
||||
if (row.projectId) relevantProjectIds.add(row.projectId);
|
||||
}
|
||||
for (const row of readAll<{ id: string }>(
|
||||
"select id from projects where company_id = ? and lead_agent_id = ?",
|
||||
companyId,
|
||||
opts.agentId,
|
||||
)) {
|
||||
relevantProjectIds.add(row.id);
|
||||
}
|
||||
|
||||
const visibilityClauses: string[] = [
|
||||
"from_agent_id = ?",
|
||||
"(channel = 'direct' and to_agent_id = ?)",
|
||||
"channel in ('company','incident')",
|
||||
"id in (select message_id from agent_message_mentions where agent_id = ? and company_id = ?)",
|
||||
];
|
||||
const visibilityParams: SQLInputValue[] = [opts.agentId, opts.agentId, opts.agentId, companyId];
|
||||
if (department) {
|
||||
visibilityClauses.push("(channel = 'department' and channel_target_id = ?)");
|
||||
visibilityParams.push(department);
|
||||
}
|
||||
if (relevantProjectIds.size > 0) {
|
||||
const placeholders = Array.from(relevantProjectIds).map(() => "?").join(",");
|
||||
visibilityClauses.push(`(channel = 'project' and channel_target_id in (${placeholders}))`);
|
||||
visibilityParams.push(...Array.from(relevantProjectIds));
|
||||
}
|
||||
sql += ` and (${visibilityClauses.join(" or ")})`;
|
||||
params.push(...visibilityParams);
|
||||
sql += " order by created_at desc";
|
||||
if (limit > 0) {
|
||||
sql += " limit ?";
|
||||
params.push(limit);
|
||||
}
|
||||
const rows = readAll<Omit<AgentMessageRecord, "readAt">>(sql, ...params).reverse();
|
||||
|
||||
const readReceiptMap = new Map(
|
||||
readAll<{ messageId: string; readAt: string }>(
|
||||
`select reads.message_id as messageId, reads.read_at as readAt
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ? and messages.company_id = ?`,
|
||||
opts.agentId,
|
||||
companyId,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
|
||||
let messages = rows.map((message) => {
|
||||
if (message.fromAgentId === opts.agentId) {
|
||||
return { ...message, readAt: message.createdAt };
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
readAt: readReceiptMap.get(message.id) ?? null,
|
||||
};
|
||||
});
|
||||
if (opts?.unreadOnly) {
|
||||
messages = messages.filter((message) => !message.readAt);
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
|
||||
searchMessages(
|
||||
companyId: string,
|
||||
query: string,
|
||||
opts?: {
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
limit?: number;
|
||||
},
|
||||
): AgentMessageRecord[] {
|
||||
const escaped = query.replace(/[\\%_]/g, "\\$&");
|
||||
const pattern = `%${escaped}%`;
|
||||
let sql = `SELECT id, company_id AS companyId, from_agent_id AS fromAgentId,
|
||||
to_agent_id AS toAgentId, sender_kind AS senderKind, channel, channel_target_id AS channelTargetId,
|
||||
subject, body, priority, parent_message_id AS parentMessageId,
|
||||
attachments_json AS attachmentsJson, created_at AS createdAt
|
||||
FROM agent_messages WHERE company_id = ? AND (subject LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`;
|
||||
const params: SQLInputValue[] = [companyId, pattern, pattern];
|
||||
if (opts?.channel) {
|
||||
sql += " AND channel = ?";
|
||||
params.push(opts.channel);
|
||||
}
|
||||
if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) {
|
||||
sql += " AND channel_target_id = ?";
|
||||
params.push(opts.channelTargetId);
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT ?";
|
||||
params.push(opts?.limit ?? 50);
|
||||
// Operator-facing search has no viewer context — `readAt` is
|
||||
// surfaced as `null`. Callers that need per-viewer read state
|
||||
// pull it from `agent_message_reads` separately (inbox projection,
|
||||
// snapshot).
|
||||
return readAll<Omit<AgentMessageRecord, "readAt">>(sql, ...params).map((row) => ({ ...row, readAt: null }));
|
||||
},
|
||||
|
||||
markMessageReadForReader(
|
||||
messageId: string,
|
||||
companyId: string,
|
||||
readerAgentId?: string | null,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`insert into agent_message_reads (message_id, agent_id, read_at)
|
||||
select id, ?, ?
|
||||
from agent_messages
|
||||
where id = ? and company_id = ?
|
||||
on conflict(message_id, agent_id) do update set read_at = excluded.read_at`,
|
||||
)
|
||||
.run(readerAgentId ?? BOARD_READER_ID, ts, messageId, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* List unread inbox messages for an agent, chronologically oldest-first,
|
||||
* for pi-native transcript injection. Uses the same channel-subscription
|
||||
* visibility rules as listAgentMessages (direct/company/incident always,
|
||||
* department when agent belongs, project when assigned/lead).
|
||||
*
|
||||
* "Unread" is defined uniformly across every channel: no row in
|
||||
* `agent_message_reads` for (message_id, agent_id). The row's
|
||||
* `readAt` field is always `null` here because callers want the
|
||||
* unread set — per-viewer read state lives exclusively in the
|
||||
* `agent_message_reads` join table since v49 dropped the redundant
|
||||
* `agent_messages.read_at` column.
|
||||
*
|
||||
* Messages where `from_agent_id = agentId` are excluded (agents don't
|
||||
* inject their own sent messages back into their transcript).
|
||||
*/
|
||||
listAgentInboxUnread(companyId: string, agentId: string): AgentMessageRecord[] {
|
||||
let department: string | null = null;
|
||||
try {
|
||||
const agent = deps.getAgent(agentId);
|
||||
if (agent.companyId === companyId) {
|
||||
department = agent.department;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`[database/messages] getAgent failed for ${agentId} in listAgentInboxUnread; department filter disabled:`, err instanceof Error ? err.message : String(err));
|
||||
department = null;
|
||||
}
|
||||
|
||||
const relevantProjectIds = new Set<string>();
|
||||
for (const row of readAll<{ projectId: string | null }>(
|
||||
"select distinct project_id as projectId from tasks where company_id = ? and assignee_agent_id = ? and project_id is not null",
|
||||
companyId,
|
||||
agentId,
|
||||
)) {
|
||||
if (row.projectId) relevantProjectIds.add(row.projectId);
|
||||
}
|
||||
for (const row of readAll<{ id: string }>(
|
||||
"select id from projects where company_id = ? and lead_agent_id = ?",
|
||||
companyId,
|
||||
agentId,
|
||||
)) {
|
||||
relevantProjectIds.add(row.id);
|
||||
}
|
||||
|
||||
const visibilityClauses: string[] = [
|
||||
"(channel = 'direct' and to_agent_id = ?)",
|
||||
"channel in ('company','incident')",
|
||||
// @mention fan-out: a DM addressed to someone else still lands
|
||||
// in my inbox when I'm mentioned in the body, because the
|
||||
// dispatcher persisted (message_id, agent_id) into
|
||||
// `agent_message_mentions` at write time. Without this clause a
|
||||
// "@CEO please update" inside a human ↔ engineer chat woke CEO
|
||||
// with an empty inbox and produced silence instead of a reply.
|
||||
"id in (select message_id from agent_message_mentions where agent_id = ? and company_id = ?)",
|
||||
];
|
||||
const visibilityParams: SQLInputValue[] = [agentId, agentId, companyId];
|
||||
if (department) {
|
||||
visibilityClauses.push("(channel = 'department' and channel_target_id = ?)");
|
||||
visibilityParams.push(department);
|
||||
}
|
||||
if (relevantProjectIds.size > 0) {
|
||||
const placeholders = Array.from(relevantProjectIds).map(() => "?").join(",");
|
||||
visibilityClauses.push(`(channel = 'project' and channel_target_id in (${placeholders}))`);
|
||||
visibilityParams.push(...Array.from(relevantProjectIds));
|
||||
}
|
||||
|
||||
const selectCols =
|
||||
"select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, sender_kind as senderKind, channel, channel_target_id as channelTargetId, subject, body, priority, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages";
|
||||
const sql = `${selectCols} where company_id = ? and from_agent_id != ? and (${visibilityClauses.join(" or ")}) order by created_at asc`;
|
||||
const rows = readAll<Omit<AgentMessageRecord, "readAt">>(sql, companyId, agentId, ...visibilityParams).map((row) => ({ ...row, readAt: null }));
|
||||
|
||||
const readIds = new Set(
|
||||
readAll<{ messageId: string }>(
|
||||
`select reads.message_id as messageId
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ? and messages.company_id = ?`,
|
||||
agentId,
|
||||
companyId,
|
||||
).map((row) => row.messageId),
|
||||
);
|
||||
|
||||
return rows.filter((message) => !readIds.has(message.id));
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
export const initialEnterpriseSchema = `
|
||||
create table if not exists organizations (
|
||||
id text primary key,
|
||||
name text not null,
|
||||
mission text not null,
|
||||
goal text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists employees (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
display_name text not null,
|
||||
role text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists agents (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
role text not null,
|
||||
department text not null,
|
||||
model_provider_id text,
|
||||
model_id text,
|
||||
budget_cents integer not null,
|
||||
manager_agent_id text references agents(id) on delete set null,
|
||||
lifecycle text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists tasks (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
assignee_agent_id text references agents(id) on delete set null,
|
||||
risk text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists approvals (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
kind text not null,
|
||||
title text not null,
|
||||
details text not null,
|
||||
risk text not null,
|
||||
requested_by_type text not null,
|
||||
requested_by_id text,
|
||||
state text not null,
|
||||
decided_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
decided_at text
|
||||
);
|
||||
|
||||
create table if not exists chat_spaces (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
space_type text not null,
|
||||
name text not null,
|
||||
topic text not null,
|
||||
visibility text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists chat_memberships (
|
||||
space_id text not null references chat_spaces(id) on delete cascade,
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
role text not null,
|
||||
mute_state text not null,
|
||||
primary key (space_id, principal_type, principal_id)
|
||||
);
|
||||
|
||||
create table if not exists chat_messages (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
space_id text not null references chat_spaces(id) on delete cascade,
|
||||
thread_id text,
|
||||
sender_type text not null,
|
||||
sender_id text,
|
||||
body_md text not null,
|
||||
created_at text not null,
|
||||
edited_at text,
|
||||
deleted_at text
|
||||
);
|
||||
|
||||
create table if not exists file_objects (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
size_bytes integer not null,
|
||||
sha256 text not null,
|
||||
storage_path text not null,
|
||||
owner_type text not null,
|
||||
owner_id text,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists providers (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
label text not null,
|
||||
auth_type text not null,
|
||||
model text,
|
||||
credential_id text,
|
||||
enabled integer not null,
|
||||
created_at text not null,
|
||||
unique(org_id, provider_key)
|
||||
);
|
||||
|
||||
create table if not exists credentials (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
kind text not null,
|
||||
ciphertext blob not null,
|
||||
display_hint text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists skill_definitions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
slug text not null,
|
||||
name text not null,
|
||||
owner_type text not null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
unique(org_id, slug)
|
||||
);
|
||||
|
||||
create table if not exists skill_revisions (
|
||||
id text primary key,
|
||||
skill_id text not null references skill_definitions(id) on delete cascade,
|
||||
version_semver text not null,
|
||||
manifest_json text not null,
|
||||
source_type text not null,
|
||||
source_ref text not null,
|
||||
published_at text not null,
|
||||
immutable_checksum text not null,
|
||||
unique(skill_id, version_semver)
|
||||
);
|
||||
|
||||
create table if not exists skill_artifacts (
|
||||
id text primary key,
|
||||
revision_id text not null references skill_revisions(id) on delete cascade,
|
||||
artifact_type text not null,
|
||||
object_key text not null,
|
||||
sha256 text not null,
|
||||
size_bytes integer not null
|
||||
);
|
||||
|
||||
create table if not exists skill_tests (
|
||||
id text primary key,
|
||||
revision_id text not null references skill_revisions(id) on delete cascade,
|
||||
test_type text not null,
|
||||
expected_output_ref text not null,
|
||||
last_result text,
|
||||
last_run_at text
|
||||
);
|
||||
|
||||
create table if not exists skill_bindings (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
skill_id text not null references skill_definitions(id) on delete cascade,
|
||||
pinned_revision_id text not null references skill_revisions(id) on delete cascade,
|
||||
binding_mode text not null,
|
||||
priority integer not null,
|
||||
enabled integer not null,
|
||||
unique(agent_id, skill_id)
|
||||
);
|
||||
|
||||
create table if not exists social_accounts (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
platform text not null,
|
||||
handle text not null,
|
||||
display_name text not null,
|
||||
authorized_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists social_account_grants (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
role text not null,
|
||||
scope_json text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists browser_actions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
action_type text not null,
|
||||
policy_decision text not null,
|
||||
evidence_file_id text references file_objects(id) on delete set null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists migration_reports (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
source_ref text not null,
|
||||
report_json text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists audit_events (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id text not null,
|
||||
actor_type text not null,
|
||||
actor_id text,
|
||||
action text not null,
|
||||
payload_json text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_tasks_org_status on tasks(org_id, status);
|
||||
create index if not exists idx_messages_space_created on chat_messages(space_id, created_at);
|
||||
create index if not exists idx_audit_org_created on audit_events(org_id, created_at);
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (1, 'initial_enterprise_schema', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,117 @@
|
||||
export const collaborationProviderPolicySchema = `
|
||||
create table if not exists skill_dependencies (
|
||||
revision_id text not null references skill_revisions(id) on delete cascade,
|
||||
depends_on_revision_id text not null references skill_revisions(id) on delete cascade,
|
||||
dependency_kind text not null,
|
||||
optional integer not null,
|
||||
primary key (revision_id, depends_on_revision_id)
|
||||
);
|
||||
|
||||
create table if not exists skill_runtime_snapshots (
|
||||
id text primary key,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
run_id text not null,
|
||||
compiled_prompt text not null,
|
||||
tools_manifest text not null,
|
||||
source_revision_ids text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists skill_catalog_tags (
|
||||
skill_id text not null references skill_definitions(id) on delete cascade,
|
||||
tag text not null,
|
||||
tag_type text not null,
|
||||
primary key (skill_id, tag, tag_type)
|
||||
);
|
||||
|
||||
create table if not exists chat_threads (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
space_id text not null references chat_spaces(id) on delete cascade,
|
||||
root_message_id text references chat_messages(id) on delete set null,
|
||||
title text not null,
|
||||
state text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists chat_mentions (
|
||||
message_id text not null references chat_messages(id) on delete cascade,
|
||||
target_type text not null,
|
||||
target_id text not null,
|
||||
offset_start integer not null,
|
||||
offset_end integer not null
|
||||
);
|
||||
|
||||
create table if not exists chat_attachments (
|
||||
message_id text not null references chat_messages(id) on delete cascade,
|
||||
file_id text not null references file_objects(id) on delete cascade,
|
||||
transfer_type text not null,
|
||||
checksum text not null,
|
||||
preview_key text
|
||||
);
|
||||
|
||||
create table if not exists chat_presence (
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
status text not null,
|
||||
status_text text not null,
|
||||
last_seen_at text not null,
|
||||
primary key (principal_type, principal_id)
|
||||
);
|
||||
|
||||
create table if not exists chat_slash_invocations (
|
||||
id text primary key,
|
||||
message_id text not null references chat_messages(id) on delete cascade,
|
||||
command text not null,
|
||||
args_json text not null,
|
||||
result_ref text,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists provider_requests (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
model text not null,
|
||||
prompt_sha256 text not null,
|
||||
response_text text,
|
||||
raw_response_json text,
|
||||
state text not null,
|
||||
error_message text,
|
||||
created_at text not null,
|
||||
completed_at text
|
||||
);
|
||||
|
||||
create table if not exists policy_rules (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
subject_type text not null,
|
||||
subject_id text,
|
||||
action text not null,
|
||||
effect text not null,
|
||||
risk_ceiling text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_requests_org_created on provider_requests(org_id, created_at);
|
||||
create index if not exists idx_chat_threads_space_updated on chat_threads(space_id, updated_at);
|
||||
insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at)
|
||||
select
|
||||
'pol_' || lower(hex(randomblob(16))),
|
||||
id,
|
||||
'organization',
|
||||
id,
|
||||
'task_create',
|
||||
'require_approval',
|
||||
'medium',
|
||||
datetime('now')
|
||||
from organizations
|
||||
where not exists (
|
||||
select 1 from policy_rules
|
||||
where policy_rules.org_id = organizations.id and policy_rules.action = 'task_create'
|
||||
);
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (2, 'collaboration_provider_policy_schema', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,76 @@
|
||||
export const onboardingExecutionSchema = `
|
||||
alter table organizations add column description text not null default '';
|
||||
alter table organizations add column execution_mode text not null default 'ai_ceo';
|
||||
alter table organizations add column auto_approve_hires integer not null default 1;
|
||||
alter table organizations add column review_deliverables integer not null default 0;
|
||||
|
||||
alter table agents add column title text not null default '';
|
||||
alter table agents add column role_level text not null default 'staff';
|
||||
alter table agents add column model_strategy text not null default 'pooled';
|
||||
alter table agents add column workspace_id text;
|
||||
alter table agents add column heartbeat_enabled integer not null default 0;
|
||||
alter table agents add column heartbeat_interval_sec integer not null default 0;
|
||||
|
||||
alter table tasks add column project_id text;
|
||||
alter table tasks add column goal_id text;
|
||||
alter table tasks add column workspace_id text;
|
||||
alter table tasks add column priority text not null default 'normal';
|
||||
alter table tasks add column requires_filesystem integer not null default 0;
|
||||
|
||||
create table if not exists workspaces (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
local_path text not null,
|
||||
repo_url text not null,
|
||||
repo_ref text not null,
|
||||
project_id text,
|
||||
is_primary integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists goals (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
owner_agent_id text references agents(id) on delete set null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists projects (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
goal_id text references goals(id) on delete set null,
|
||||
name text not null,
|
||||
description text not null,
|
||||
lead_agent_id text references agents(id) on delete set null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists task_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
task_id text not null references tasks(id) on delete cascade,
|
||||
agent_id text references agents(id) on delete set null,
|
||||
provider_id text references providers(id) on delete set null,
|
||||
trigger text not null,
|
||||
state text not null,
|
||||
created_at text not null,
|
||||
started_at text,
|
||||
completed_at text,
|
||||
error_message text
|
||||
);
|
||||
|
||||
create index if not exists idx_workspaces_org_primary on workspaces(org_id, is_primary);
|
||||
create index if not exists idx_goals_org_status on goals(org_id, status);
|
||||
create index if not exists idx_projects_org_status on projects(org_id, status);
|
||||
create index if not exists idx_task_runs_task_created on task_runs(task_id, created_at);
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (3, 'onboarding_execution_schema', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,25 @@
|
||||
export const filesPinnedAndScheduledPosts = `
|
||||
alter table file_objects add column pinned integer not null default 0;
|
||||
alter table file_objects add column space_id text;
|
||||
alter table file_objects add column updated_at text;
|
||||
|
||||
create table if not exists scheduled_posts (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
body_md text not null,
|
||||
scheduled_for text not null,
|
||||
state text not null,
|
||||
approval_id text references approvals(id) on delete set null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_files_org_pinned on file_objects(org_id, pinned);
|
||||
create index if not exists idx_scheduled_posts_org_state on scheduled_posts(org_id, state);
|
||||
create index if not exists idx_scheduled_posts_account_date on scheduled_posts(social_account_id, scheduled_for);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (4, 'files_pinned_and_scheduled_posts', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const chatReadState = `
|
||||
create table if not exists chat_read_state (
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
space_id text not null references chat_spaces(id) on delete cascade,
|
||||
last_read_message_id text references chat_messages(id) on delete set null,
|
||||
last_read_at text not null,
|
||||
primary key (employee_id, space_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_chat_read_state_org on chat_read_state(org_id, employee_id);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (5, 'chat_read_state', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
export const appStateSchema = `
|
||||
create table if not exists app_state (
|
||||
id integer primary key check (id = 1),
|
||||
active_org_id text references organizations(id) on delete set null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
insert into app_state(id, active_org_id, updated_at)
|
||||
select 1, (select id from organizations order by created_at asc limit 1), datetime('now')
|
||||
where not exists (select 1 from app_state where id = 1);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (6, 'app_state', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export const skillHardeningSchema = `
|
||||
create unique index if not exists idx_skill_runtime_snapshots_agent_run
|
||||
on skill_runtime_snapshots(agent_id, run_id);
|
||||
`;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const providerHealthSchema = `
|
||||
alter table providers add column auth_state text not null default 'unconfigured';
|
||||
alter table providers add column health_state text not null default 'unchecked';
|
||||
alter table providers add column last_checked_at text;
|
||||
alter table providers add column last_error text;
|
||||
`;
|
||||
@@ -0,0 +1,49 @@
|
||||
export const socialGovernanceSchema = `
|
||||
update social_account_grants
|
||||
set scope_json = '{"actions":["login","draft","approve"],"riskCeiling":"medium"}'
|
||||
where scope_json = '{"actions":["login","draft","approve"]}';
|
||||
|
||||
create temp table if not exists social_post_approval_backfill(
|
||||
post_id text primary key,
|
||||
approval_id text not null
|
||||
);
|
||||
|
||||
delete from social_post_approval_backfill;
|
||||
|
||||
insert into social_post_approval_backfill(post_id, approval_id)
|
||||
select id, 'app_' || lower(hex(randomblob(16)))
|
||||
from scheduled_posts
|
||||
where state = 'scheduled' and approval_id is null;
|
||||
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
|
||||
state, decided_by_employee_id, created_at, decided_at
|
||||
)
|
||||
select
|
||||
backfill.approval_id,
|
||||
posts.org_id,
|
||||
'social_publish',
|
||||
'Approve social post',
|
||||
posts.body_md,
|
||||
'medium',
|
||||
case when posts.created_by_employee_id is null then 'system' else 'employee' end,
|
||||
posts.created_by_employee_id,
|
||||
'pending',
|
||||
null,
|
||||
posts.created_at,
|
||||
null
|
||||
from scheduled_posts posts
|
||||
join social_post_approval_backfill backfill on backfill.post_id = posts.id;
|
||||
|
||||
update scheduled_posts
|
||||
set approval_id = (
|
||||
select approval_id from social_post_approval_backfill
|
||||
where social_post_approval_backfill.post_id = scheduled_posts.id
|
||||
)
|
||||
where id in (select post_id from social_post_approval_backfill);
|
||||
|
||||
drop table social_post_approval_backfill;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (9, 'social_governance', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,52 @@
|
||||
export const fileHandoffSecuritySchema = `
|
||||
alter table file_objects add column scan_state text not null default 'approved';
|
||||
alter table file_objects add column scan_labels_json text not null default '[]';
|
||||
alter table file_objects add column scanned_at text;
|
||||
|
||||
create table if not exists file_access_grants (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
file_id text not null references file_objects(id) on delete cascade,
|
||||
granted_to_type text not null,
|
||||
granted_to_id text not null,
|
||||
granted_by_type text not null,
|
||||
granted_by_id text,
|
||||
source_message_id text references chat_messages(id) on delete set null,
|
||||
source_thread_id text references chat_threads(id) on delete set null,
|
||||
access_level text not null,
|
||||
created_at text not null,
|
||||
unique(file_id, granted_to_type, granted_to_id, source_message_id, access_level)
|
||||
);
|
||||
|
||||
insert into file_access_grants(
|
||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||
source_message_id, source_thread_id, access_level, created_at
|
||||
)
|
||||
select
|
||||
'fgr_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
id,
|
||||
'org_group',
|
||||
org_id || ':workspace',
|
||||
'system',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'manage',
|
||||
coalesce(created_at, datetime('now'))
|
||||
from file_objects
|
||||
where owner_type = 'system'
|
||||
and not exists (
|
||||
select 1 from file_access_grants
|
||||
where file_access_grants.file_id = file_objects.id
|
||||
and file_access_grants.granted_to_type = 'org_group'
|
||||
and file_access_grants.granted_to_id = file_objects.org_id || ':workspace'
|
||||
);
|
||||
|
||||
create index if not exists idx_file_grants_org_file on file_access_grants(org_id, file_id);
|
||||
create index if not exists idx_file_grants_principal on file_access_grants(org_id, granted_to_type, granted_to_id);
|
||||
create index if not exists idx_files_org_scan on file_objects(org_id, scan_state);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (10, 'file_handoff_security', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,29 @@
|
||||
export const boardInterventionSchema = `
|
||||
alter table chat_threads add column board_state text not null default 'open';
|
||||
alter table chat_threads add column controlled_by_type text;
|
||||
alter table chat_threads add column controlled_by_id text;
|
||||
|
||||
create table if not exists board_interventions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
thread_id text not null references chat_threads(id) on delete cascade,
|
||||
message_id text not null references chat_messages(id) on delete cascade,
|
||||
mode text not null,
|
||||
action text not null,
|
||||
details text not null,
|
||||
target_type text,
|
||||
target_id text,
|
||||
approval_id text references approvals(id) on delete set null,
|
||||
created_by_type text not null,
|
||||
created_by_id text,
|
||||
created_at text not null,
|
||||
unique(message_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_board_interventions_org_thread on board_interventions(org_id, thread_id, created_at);
|
||||
create index if not exists idx_board_interventions_message on board_interventions(message_id);
|
||||
create index if not exists idx_chat_threads_org_board_state on chat_threads(org_id, board_state);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (11, 'board_interventions', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,47 @@
|
||||
export const complianceControlsSchema = `
|
||||
alter table chat_spaces add column retention_policy text not null default 'standard_365d';
|
||||
|
||||
create table if not exists compliance_exports (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
subject_type text not null,
|
||||
subject_id text not null,
|
||||
export_type text not null,
|
||||
state text not null,
|
||||
payload_json text not null,
|
||||
requested_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
completed_at text
|
||||
);
|
||||
|
||||
create table if not exists legal_holds (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create table if not exists retention_freezes (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
scope_type text not null,
|
||||
scope_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create index if not exists idx_compliance_exports_org_created on compliance_exports(org_id, created_at);
|
||||
create index if not exists idx_legal_holds_org_entity on legal_holds(org_id, entity_type, entity_id, active);
|
||||
create index if not exists idx_retention_freezes_org_scope on retention_freezes(org_id, scope_type, scope_id, active);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (12, 'compliance_controls', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const searchDocumentsSchema = `
|
||||
create virtual table if not exists search_documents using fts5(
|
||||
org_id unindexed,
|
||||
entity_type unindexed,
|
||||
entity_id unindexed,
|
||||
title,
|
||||
body,
|
||||
facet_json unindexed
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (13, 'search_documents', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,31 @@
|
||||
export const providerPolicyUsageSchema = `
|
||||
alter table providers add column data_policy text not null default 'standard';
|
||||
alter table providers add column background_enabled integer not null default 0;
|
||||
alter table providers add column monthly_request_limit integer not null default 1000;
|
||||
alter table providers add column workspace_scope text not null default 'organization';
|
||||
|
||||
alter table provider_requests add column input_char_count integer not null default 0;
|
||||
alter table provider_requests add column output_char_count integer not null default 0;
|
||||
alter table provider_requests add column execution_mode text not null default 'foreground';
|
||||
alter table provider_requests add column policy_decision text not null default 'allowed:standard';
|
||||
|
||||
create table if not exists provider_usage_windows (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
window_start text not null,
|
||||
window_end text not null,
|
||||
request_count integer not null,
|
||||
input_char_count integer not null,
|
||||
output_char_count integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(provider_id, window_start)
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_usage_windows_org_start
|
||||
on provider_usage_windows(org_id, window_start);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (14, 'provider_policy_usage', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,90 @@
|
||||
export const identityAccessSchema = `
|
||||
create table if not exists employee_identities (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
source text not null,
|
||||
external_subject text not null,
|
||||
email text,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
last_seen_at text,
|
||||
unique(org_id, source, external_subject)
|
||||
);
|
||||
|
||||
create table if not exists service_identities (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
name text not null,
|
||||
scope_json text not null,
|
||||
created_at text not null,
|
||||
unique(org_id, agent_id)
|
||||
);
|
||||
|
||||
create table if not exists role_grants (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
role_key text not null,
|
||||
scope_type text not null,
|
||||
scope_id text not null,
|
||||
granted_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
unique(org_id, principal_type, principal_id, role_key, scope_type, scope_id)
|
||||
);
|
||||
|
||||
create table if not exists access_decisions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
action text not null,
|
||||
resource_type text not null,
|
||||
resource_id text not null,
|
||||
decision text not null,
|
||||
reason text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_role_grants_principal on role_grants(org_id, principal_type, principal_id);
|
||||
create index if not exists idx_access_decisions_org_created on access_decisions(org_id, created_at);
|
||||
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
select 'eid_' || lower(hex(randomblob(16))), org_id, id, 'bootstrap', id, null, 1, datetime('now'), datetime('now')
|
||||
from employees
|
||||
where not exists (
|
||||
select 1 from employee_identities
|
||||
where employee_identities.org_id = employees.org_id and employee_identities.employee_id = employees.id
|
||||
);
|
||||
|
||||
insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at)
|
||||
select 'rgr_' || lower(hex(randomblob(16))), org_id, 'employee', id, seed_role_key, 'organization', org_id, null, datetime('now')
|
||||
from employees
|
||||
cross join (
|
||||
select 'org_admin' as seed_role_key
|
||||
union all select 'approval_decider'
|
||||
union all select 'hiring_manager'
|
||||
)
|
||||
where not exists (
|
||||
select 1 from role_grants
|
||||
where role_grants.org_id = employees.org_id
|
||||
and role_grants.principal_type = 'employee'
|
||||
and role_grants.principal_id = employees.id
|
||||
and role_grants.role_key = seed_role_key
|
||||
and role_grants.scope_type = 'organization'
|
||||
and role_grants.scope_id = employees.org_id
|
||||
);
|
||||
|
||||
insert into service_identities(id, org_id, agent_id, name, scope_json, created_at)
|
||||
select 'sid_' || lower(hex(randomblob(16))), org_id, id, name || ' service identity', json_object('agentId', id), datetime('now')
|
||||
from agents
|
||||
where not exists (
|
||||
select 1 from service_identities
|
||||
where service_identities.org_id = agents.org_id and service_identities.agent_id = agents.id
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (15, 'identity_access', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,89 @@
|
||||
export const orgGovernanceSchema = `
|
||||
create table if not exists org_departments (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
lead_agent_id text references agents(id) on delete set null,
|
||||
headcount_limit integer not null,
|
||||
monthly_budget_limit_cents integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, name)
|
||||
);
|
||||
|
||||
create table if not exists hiring_policies (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
department_name text not null,
|
||||
max_headcount integer not null,
|
||||
monthly_budget_limit_cents integer not null,
|
||||
require_approval_above_cents integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, department_name)
|
||||
);
|
||||
|
||||
create table if not exists approval_routing_rules (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
kind text not null,
|
||||
minimum_risk text not null,
|
||||
route_to_type text not null,
|
||||
route_to_id text not null,
|
||||
created_at text not null,
|
||||
unique(org_id, kind, minimum_risk)
|
||||
);
|
||||
|
||||
create table if not exists approval_assignments (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
approval_id text not null references approvals(id) on delete cascade,
|
||||
assignee_type text not null,
|
||||
assignee_id text not null,
|
||||
routing_rule_id text references approval_routing_rules(id) on delete set null,
|
||||
created_at text not null,
|
||||
unique(approval_id, assignee_type, assignee_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_org_departments_org_name on org_departments(org_id, name);
|
||||
create index if not exists idx_hiring_policies_org_department on hiring_policies(org_id, department_name);
|
||||
create index if not exists idx_approval_assignments_org_approval on approval_assignments(org_id, approval_id);
|
||||
|
||||
insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at)
|
||||
select 'dep_' || lower(hex(randomblob(16))), organizations.id, seed.name, null, 12, 2500000, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'Executive' as name union all select 'Engineering' union all select 'Product' union all select 'Design'
|
||||
union all select 'Marketing' union all select 'Operations' union all select 'Support'
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from org_departments
|
||||
where org_departments.org_id = organizations.id and org_departments.name = seed.name
|
||||
);
|
||||
|
||||
insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at)
|
||||
select 'hpl_' || lower(hex(randomblob(16))), org_id, name, headcount_limit, monthly_budget_limit_cents, 500000, datetime('now'), datetime('now')
|
||||
from org_departments
|
||||
where not exists (
|
||||
select 1 from hiring_policies
|
||||
where hiring_policies.org_id = org_departments.org_id and hiring_policies.department_name = org_departments.name
|
||||
);
|
||||
|
||||
insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at)
|
||||
select 'arr_' || lower(hex(randomblob(16))), organizations.id, '*', 'low', 'employee', employees.id, datetime('now')
|
||||
from organizations
|
||||
join employees on employees.org_id = organizations.id
|
||||
where employees.id = (
|
||||
select first_employee.id from employees first_employee
|
||||
where first_employee.org_id = organizations.id
|
||||
order by first_employee.created_at asc
|
||||
limit 1
|
||||
)
|
||||
and not exists (
|
||||
select 1 from approval_routing_rules
|
||||
where approval_routing_rules.org_id = organizations.id and approval_routing_rules.kind = '*' and approval_routing_rules.minimum_risk = 'low'
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (16, 'org_governance', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,109 @@
|
||||
export const operationsControlsSchema = `
|
||||
create table if not exists feature_flags (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
label text not null,
|
||||
enabled integer not null check(enabled in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, key)
|
||||
);
|
||||
|
||||
create table if not exists slo_targets (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
signal_key text not null,
|
||||
label text not null,
|
||||
target_basis_points integer not null,
|
||||
window_minutes integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, signal_key)
|
||||
);
|
||||
|
||||
create table if not exists operational_events (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
signal_key text not null,
|
||||
source text not null,
|
||||
success integer not null check(success in (0, 1)),
|
||||
duration_ms integer,
|
||||
detail_json text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists chaos_drills (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
label text not null,
|
||||
scenario_key text not null,
|
||||
enabled integer not null check(enabled in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, key)
|
||||
);
|
||||
|
||||
create table if not exists chaos_drill_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
drill_id text not null references chaos_drills(id) on delete cascade,
|
||||
state text not null,
|
||||
result_json text not null,
|
||||
started_at text not null,
|
||||
completed_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_operational_events_org_signal_created
|
||||
on operational_events(org_id, signal_key, created_at);
|
||||
|
||||
create index if not exists idx_chaos_drill_runs_org_drill_started
|
||||
on chaos_drill_runs(org_id, drill_id, started_at);
|
||||
|
||||
insert into feature_flags(id, org_id, key, label, enabled, created_at, updated_at)
|
||||
select 'ffg_' || lower(hex(randomblob(16))), organizations.id, seed.key, seed.label, seed.enabled, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'provider_background_execution' as key, 'Provider background execution' as label, 1 as enabled
|
||||
union all select 'social_publish_approval', 'Social publish approval', 1
|
||||
union all select 'board_intervention', 'Board intervention', 1
|
||||
union all select 'chaos_drills', 'Chaos drills', 1
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from feature_flags
|
||||
where feature_flags.org_id = organizations.id and feature_flags.key = seed.key
|
||||
);
|
||||
|
||||
insert into slo_targets(id, org_id, signal_key, label, target_basis_points, window_minutes, created_at, updated_at)
|
||||
select 'slo_' || lower(hex(randomblob(16))), organizations.id, seed.signal_key, seed.label, seed.target_basis_points, seed.window_minutes, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'provider_request_success' as signal_key, 'Provider requests' as label, 9950 as target_basis_points, 43200 as window_minutes
|
||||
union all select 'approval_route_coverage', 'Approval routing', 10000, 43200
|
||||
union all select 'browser_session_boundary', 'Browser session boundary', 10000, 43200
|
||||
union all select 'skill_resolve_success', 'Skill resolve', 9950, 43200
|
||||
union all select 'migration_validation_success', 'Migration validation', 10000, 43200
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from slo_targets
|
||||
where slo_targets.org_id = organizations.id and slo_targets.signal_key = seed.signal_key
|
||||
);
|
||||
|
||||
insert into chaos_drills(id, org_id, key, label, scenario_key, enabled, created_at, updated_at)
|
||||
select 'cdr_' || lower(hex(randomblob(16))), organizations.id, seed.key, seed.label, seed.scenario_key, 1, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'provider-outage-policy' as key, 'Provider outage policy' as label, 'provider_outage_policy_gate' as scenario_key
|
||||
union all select 'approval-backlog-routing', 'Approval backlog routing', 'approval_backlog_gate'
|
||||
union all select 'browser-session-boundary', 'Browser session boundary', 'browser_session_boundary'
|
||||
union all select 'migration-rollback-pack', 'Migration rollback pack', 'migration_rollback_pack'
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from chaos_drills
|
||||
where chaos_drills.org_id = organizations.id and chaos_drills.key = seed.key
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (17, 'operations_controls', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,137 @@
|
||||
export const sdlcControlsSchema = `
|
||||
create table if not exists sdlc_review_agents (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
label text not null,
|
||||
specialty text not null,
|
||||
enabled integer not null check(enabled in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, key)
|
||||
);
|
||||
|
||||
create table if not exists sdlc_review_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
review_agent_id text not null references sdlc_review_agents(id) on delete cascade,
|
||||
target_type text not null,
|
||||
target_ref text not null,
|
||||
state text not null,
|
||||
result_json text not null,
|
||||
created_at text not null,
|
||||
completed_at text not null
|
||||
);
|
||||
|
||||
create table if not exists eval_suites (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
label text not null,
|
||||
scope text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, key)
|
||||
);
|
||||
|
||||
create table if not exists eval_cases (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
suite_id text not null references eval_suites(id) on delete cascade,
|
||||
title text not null,
|
||||
source_type text not null,
|
||||
source_ref text not null,
|
||||
expected_signal text not null,
|
||||
created_at text not null,
|
||||
unique(suite_id, title)
|
||||
);
|
||||
|
||||
create table if not exists eval_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
suite_id text not null references eval_suites(id) on delete cascade,
|
||||
state text not null,
|
||||
passed_count integer not null,
|
||||
failed_count integer not null,
|
||||
result_json text not null,
|
||||
created_at text not null,
|
||||
completed_at text not null
|
||||
);
|
||||
|
||||
create table if not exists pr_bot_gates (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
label text not null,
|
||||
gate_type text not null,
|
||||
enabled integer not null check(enabled in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(org_id, key)
|
||||
);
|
||||
|
||||
create index if not exists idx_sdlc_review_runs_org_agent_created
|
||||
on sdlc_review_runs(org_id, review_agent_id, created_at);
|
||||
|
||||
create index if not exists idx_eval_runs_org_suite_created
|
||||
on eval_runs(org_id, suite_id, created_at);
|
||||
|
||||
insert into sdlc_review_agents(id, org_id, key, label, specialty, enabled, created_at, updated_at)
|
||||
select 'sra_' || lower(hex(randomblob(16))), organizations.id, seed.key, seed.label, seed.specialty, 1, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'code_reviewer' as key, 'Code Review Agent' as label, 'static quality and architecture' as specialty
|
||||
union all select 'migration_reviewer', 'Migration Review Agent', 'schema diff and rollback evidence'
|
||||
union all select 'policy_reviewer', 'Policy Review Agent', 'provider, approval, and access policy'
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from sdlc_review_agents
|
||||
where sdlc_review_agents.org_id = organizations.id and sdlc_review_agents.key = seed.key
|
||||
);
|
||||
|
||||
insert into eval_suites(id, org_id, key, label, scope, created_at, updated_at)
|
||||
select 'evs_' || lower(hex(randomblob(16))), organizations.id, seed.key, seed.label, seed.scope, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'contract' as key, 'Contract checks' as label, 'provider adapters, event schemas, slash commands' as scope
|
||||
union all select 'integration', 'Integration checks', 'chat, approval, file, skill resolution flows'
|
||||
union all select 'replay', 'Replay checks', 'audit timeline and historical decision regression'
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from eval_suites
|
||||
where eval_suites.org_id = organizations.id and eval_suites.key = seed.key
|
||||
);
|
||||
|
||||
insert into eval_cases(id, org_id, suite_id, title, source_type, source_ref, expected_signal, created_at)
|
||||
select 'evc_' || lower(hex(randomblob(16))), eval_suites.org_id, eval_suites.id, seed.title, seed.source_type, seed.source_ref, seed.expected_signal, datetime('now')
|
||||
from eval_suites
|
||||
join (
|
||||
select 'contract' as suite_key, 'Provider catalog has adapters' as title, 'provider_gateway' as source_type, 'providers' as source_ref, 'adapter_coverage' as expected_signal
|
||||
union all select 'contract', 'Schema ledger is current', 'database', 'schema_migrations', 'migration_ledger'
|
||||
union all select 'integration', 'Approval routes are assigned', 'org_governance', 'approval_assignments', 'route_coverage'
|
||||
union all select 'integration', 'Skills can resolve runtime snapshots', 'skills_platform', 'skill_runtime_snapshots', 'skill_resolution'
|
||||
union all select 'replay', 'Audit timeline has domain events', 'audit', 'audit_events', 'replayable_timeline'
|
||||
union all select 'replay', 'Operational signals are captured', 'operations', 'operational_events', 'slo_signal'
|
||||
) seed on seed.suite_key = eval_suites.key
|
||||
where not exists (
|
||||
select 1 from eval_cases
|
||||
where eval_cases.suite_id = eval_suites.id and eval_cases.title = seed.title
|
||||
);
|
||||
|
||||
insert into pr_bot_gates(id, org_id, key, label, gate_type, enabled, created_at, updated_at)
|
||||
select 'pbg_' || lower(hex(randomblob(16))), organizations.id, seed.key, seed.label, seed.gate_type, 1, datetime('now'), datetime('now')
|
||||
from organizations
|
||||
cross join (
|
||||
select 'static_checks' as key, 'Static checks' as label, 'typecheck_lint_test' as gate_type
|
||||
union all select 'migration_diff', 'Migration diff' as label, 'schema_review' as gate_type
|
||||
union all select 'policy_diff', 'Policy diff' as label, 'policy_review' as gate_type
|
||||
union all select 'human_approval', 'Human approval' as label, 'approval_required' as gate_type
|
||||
) seed
|
||||
where not exists (
|
||||
select 1 from pr_bot_gates
|
||||
where pr_bot_gates.org_id = organizations.id and pr_bot_gates.key = seed.key
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (18, 'sdlc_controls', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
export const auditReplaySchema = `
|
||||
create table if not exists audit_replay_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
thread_id text not null references chat_threads(id) on delete cascade,
|
||||
event_count integer not null,
|
||||
event_digest text not null,
|
||||
replay_json text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_audit_replay_runs_org_created on audit_replay_runs(org_id, created_at);
|
||||
create index if not exists idx_audit_replay_runs_thread on audit_replay_runs(thread_id, created_at);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (19, 'audit_replay', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,25 @@
|
||||
export const rollbackSnapshotsSchema = `
|
||||
create table if not exists rollback_snapshots (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
migration_report_id text not null references migration_reports(id) on delete cascade,
|
||||
source_database_path text not null,
|
||||
source_fingerprint text not null,
|
||||
source_database_sha256 text not null,
|
||||
workspace_manifest_json text not null,
|
||||
workspace_manifest_sha256 text not null,
|
||||
delta_journal_json text not null,
|
||||
restore_plan_json text not null,
|
||||
active integer not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
activated_at text
|
||||
);
|
||||
|
||||
create index if not exists idx_rollback_snapshots_org_created on rollback_snapshots(org_id, created_at);
|
||||
create index if not exists idx_rollback_snapshots_report on rollback_snapshots(migration_report_id);
|
||||
create unique index if not exists idx_rollback_snapshots_one_active on rollback_snapshots(org_id) where active = 1;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (20, 'rollback_snapshots', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,59 @@
|
||||
export const providerAuthBrokerSchema = `
|
||||
create table if not exists provider_auth_profiles (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
credential_id text not null references credentials(id) on delete restrict,
|
||||
provider_key text not null,
|
||||
auth_type text not null,
|
||||
profile_kind text not null,
|
||||
display_hint text not null,
|
||||
source_type text not null,
|
||||
source_ref text,
|
||||
capability_json text not null,
|
||||
active integer not null check(active in (0, 1)),
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_auth_profiles_org_provider
|
||||
on provider_auth_profiles(org_id, provider_id, created_at);
|
||||
create index if not exists idx_provider_auth_profiles_credential
|
||||
on provider_auth_profiles(credential_id);
|
||||
create unique index if not exists idx_provider_auth_profiles_active
|
||||
on provider_auth_profiles(provider_id) where active = 1;
|
||||
|
||||
insert into provider_auth_profiles(
|
||||
id, org_id, provider_id, credential_id, provider_key, auth_type,
|
||||
profile_kind, display_hint, source_type, source_ref, capability_json,
|
||||
active, created_at
|
||||
)
|
||||
select
|
||||
'authp_' || lower(hex(randomblob(16))),
|
||||
providers.org_id,
|
||||
providers.id,
|
||||
credentials.id,
|
||||
providers.provider_key,
|
||||
providers.auth_type,
|
||||
providers.auth_type,
|
||||
credentials.display_hint,
|
||||
'migrated_credential',
|
||||
null,
|
||||
'{"providerKey":"' || providers.provider_key || '","authType":"' || providers.auth_type || '","capabilityTags":[]}',
|
||||
1,
|
||||
credentials.created_at
|
||||
from providers
|
||||
join credentials
|
||||
on credentials.id = providers.credential_id
|
||||
and credentials.org_id = providers.org_id
|
||||
and credentials.provider_key = providers.provider_key
|
||||
where providers.credential_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from provider_auth_profiles existing_profiles
|
||||
where existing_profiles.provider_id = providers.id
|
||||
and existing_profiles.active = 1
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (21, 'provider_auth_broker', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,21 @@
|
||||
export const providerRateLimitObservationSchema = `
|
||||
create table if not exists provider_rate_limit_observations (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
model text,
|
||||
request_limit integer,
|
||||
remaining_requests integer,
|
||||
reset_at text,
|
||||
retry_after_ms integer,
|
||||
raw_state_json text not null,
|
||||
observed_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_rate_limit_observations_org_provider
|
||||
on provider_rate_limit_observations(org_id, provider_id, observed_at desc);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (22, 'provider_rate_limit_observations', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
export const providerRequestRuntimeSchema = `
|
||||
alter table provider_requests add column external_response_id text;
|
||||
alter table provider_requests add column stream_event_count integer not null default 0;
|
||||
alter table provider_requests add column cancelled_at text;
|
||||
|
||||
create index if not exists idx_provider_requests_external_response
|
||||
on provider_requests(provider_id, external_response_id)
|
||||
where external_response_id is not null;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (23, 'provider_request_runtime', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const taskRunProviderRequestSchema = `
|
||||
alter table task_runs add column provider_request_id text references provider_requests(id) on delete set null;
|
||||
|
||||
create index if not exists idx_task_runs_provider_request
|
||||
on task_runs(provider_request_id)
|
||||
where provider_request_id is not null;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (24, 'task_run_provider_request', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const chatMessageDeliverySchema = `
|
||||
alter table chat_messages add column body_structured_json text not null default '{}';
|
||||
alter table chat_messages add column delivery_state text not null default 'sent';
|
||||
|
||||
create index if not exists idx_chat_messages_delivery_state
|
||||
on chat_messages(org_id, delivery_state, created_at);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (25, 'chat_message_delivery', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,127 @@
|
||||
export const browserPlatformGovernanceSchema = `
|
||||
create table if not exists browser_session_containers (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
platform text not null,
|
||||
partition_key text not null,
|
||||
host_allowlist_json text not null,
|
||||
permission_policy text not null,
|
||||
sandbox_enabled integer not null,
|
||||
created_at text not null,
|
||||
last_used_at text,
|
||||
unique(social_account_id)
|
||||
);
|
||||
|
||||
create table if not exists browser_action_policies (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
action_type text not null,
|
||||
content_category text not null,
|
||||
risk_level text not null,
|
||||
approval_required integer not null,
|
||||
approval_kind text,
|
||||
created_at text not null,
|
||||
unique(social_account_id, action_type, content_category)
|
||||
);
|
||||
|
||||
create table if not exists social_approval_routes (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
action_type text not null,
|
||||
risk_level text not null,
|
||||
approval_kind text not null,
|
||||
route_to_type text not null,
|
||||
route_to_id text,
|
||||
created_at text not null,
|
||||
unique(social_account_id, action_type, risk_level)
|
||||
);
|
||||
|
||||
insert into browser_session_containers(
|
||||
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
|
||||
permission_policy, sandbox_enabled, created_at, last_used_at
|
||||
)
|
||||
select
|
||||
'bsc_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
id,
|
||||
platform,
|
||||
'persist:agentcompany-social-' || id,
|
||||
case platform
|
||||
when 'x' then '["x.com","twitter.com"]'
|
||||
when 'linkedin' then '["linkedin.com","www.linkedin.com"]'
|
||||
when 'youtube' then '["accounts.google.com","youtube.com","www.youtube.com"]'
|
||||
when 'instagram' then '["instagram.com","www.instagram.com"]'
|
||||
else '[]'
|
||||
end,
|
||||
'deny_all',
|
||||
1,
|
||||
datetime('now'),
|
||||
null
|
||||
from social_accounts
|
||||
where not exists (
|
||||
select 1 from browser_session_containers
|
||||
where browser_session_containers.social_account_id = social_accounts.id
|
||||
);
|
||||
|
||||
insert into browser_action_policies(
|
||||
id, org_id, social_account_id, action_type, content_category, risk_level,
|
||||
approval_required, approval_kind, created_at
|
||||
)
|
||||
select
|
||||
'bap_' || lower(hex(randomblob(16))),
|
||||
social_accounts.org_id,
|
||||
social_accounts.id,
|
||||
policy.action_type,
|
||||
policy.content_category,
|
||||
policy.risk_level,
|
||||
policy.approval_required,
|
||||
policy.approval_kind,
|
||||
datetime('now')
|
||||
from social_accounts
|
||||
join (
|
||||
select 'login' action_type, 'auth' content_category, 'low' risk_level, 0 approval_required, null approval_kind
|
||||
union all select 'draft', 'content', 'low', 0, null
|
||||
union all select 'approve', 'content', 'medium', 1, 'social_publish'
|
||||
) policy
|
||||
where not exists (
|
||||
select 1 from browser_action_policies
|
||||
where browser_action_policies.social_account_id = social_accounts.id
|
||||
and browser_action_policies.action_type = policy.action_type
|
||||
and browser_action_policies.content_category = policy.content_category
|
||||
);
|
||||
|
||||
insert into social_approval_routes(
|
||||
id, org_id, social_account_id, action_type, risk_level, approval_kind,
|
||||
route_to_type, route_to_id, created_at
|
||||
)
|
||||
select
|
||||
'sar_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
id,
|
||||
'approve',
|
||||
'medium',
|
||||
'social_publish',
|
||||
'authorized_employee',
|
||||
authorized_employee_id,
|
||||
datetime('now')
|
||||
from social_accounts
|
||||
where not exists (
|
||||
select 1 from social_approval_routes
|
||||
where social_approval_routes.social_account_id = social_accounts.id
|
||||
and social_approval_routes.action_type = 'approve'
|
||||
and social_approval_routes.risk_level = 'medium'
|
||||
);
|
||||
|
||||
create index if not exists idx_browser_session_containers_org
|
||||
on browser_session_containers(org_id, social_account_id);
|
||||
create index if not exists idx_browser_action_policies_org_account
|
||||
on browser_action_policies(org_id, social_account_id, action_type);
|
||||
create index if not exists idx_social_approval_routes_org_account
|
||||
on social_approval_routes(org_id, social_account_id, action_type);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (26, 'browser_platform_governance', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,62 @@
|
||||
export const socialAuditRecordsSchema = `
|
||||
create table if not exists social_audit_records (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text references social_accounts(id) on delete set null,
|
||||
source_type text not null,
|
||||
source_id text,
|
||||
event_type text not null,
|
||||
actor_type text not null,
|
||||
actor_id text,
|
||||
policy_decision text not null,
|
||||
evidence_file_id text references file_objects(id) on delete set null,
|
||||
metadata_json text not null,
|
||||
created_at text not null,
|
||||
unique(source_type, source_id, event_type)
|
||||
);
|
||||
|
||||
insert into social_audit_records(
|
||||
id, org_id, social_account_id, source_type, source_id, event_type,
|
||||
actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at
|
||||
)
|
||||
select
|
||||
'saud_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
social_account_id,
|
||||
'browser_action',
|
||||
id,
|
||||
action_type,
|
||||
'system',
|
||||
null,
|
||||
policy_decision,
|
||||
evidence_file_id,
|
||||
json_object('source', 'browser_actions'),
|
||||
created_at
|
||||
from browser_actions
|
||||
where not exists (
|
||||
select 1 from social_audit_records
|
||||
where social_audit_records.source_type = 'browser_action'
|
||||
and social_audit_records.source_id = browser_actions.id
|
||||
and social_audit_records.event_type = browser_actions.action_type
|
||||
);
|
||||
|
||||
create index if not exists idx_social_audit_records_org_created
|
||||
on social_audit_records(org_id, created_at);
|
||||
create index if not exists idx_social_audit_records_account_created
|
||||
on social_audit_records(social_account_id, created_at);
|
||||
|
||||
create trigger if not exists trg_social_audit_records_no_update
|
||||
before update on social_audit_records
|
||||
begin
|
||||
select raise(abort, 'Social audit records are immutable.');
|
||||
end;
|
||||
|
||||
create trigger if not exists trg_social_audit_records_no_delete
|
||||
before delete on social_audit_records
|
||||
begin
|
||||
select raise(abort, 'Social audit records are immutable.');
|
||||
end;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (27, 'social_audit_records', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,62 @@
|
||||
export const migrationCutoverJournalSchema = `
|
||||
create table if not exists migration_cutover_events (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
migration_report_id text not null references migration_reports(id) on delete cascade,
|
||||
rollback_snapshot_id text references rollback_snapshots(id) on delete set null,
|
||||
event_type text not null,
|
||||
actor_type text not null,
|
||||
actor_id text,
|
||||
payload_json text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create table if not exists migration_reconciliation_items (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
migration_report_id text not null references migration_reports(id) on delete cascade,
|
||||
rollback_snapshot_id text references rollback_snapshots(id) on delete set null,
|
||||
source_event_id text not null,
|
||||
target_type text not null,
|
||||
target_id text not null,
|
||||
reason text not null,
|
||||
state text not null,
|
||||
payload_json text not null,
|
||||
created_at text not null,
|
||||
resolved_at text,
|
||||
resolved_by_employee_id text references employees(id) on delete set null,
|
||||
unique(rollback_snapshot_id, source_event_id)
|
||||
);
|
||||
|
||||
insert into migration_cutover_events(
|
||||
id, org_id, migration_report_id, rollback_snapshot_id, event_type,
|
||||
actor_type, actor_id, payload_json, created_at
|
||||
)
|
||||
select
|
||||
'mce_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
migration_report_id,
|
||||
id,
|
||||
case active when 1 then 'rollback_snapshot_activated' else 'rollback_snapshot_created' end,
|
||||
'system',
|
||||
null,
|
||||
json_object('sourceFingerprint', source_fingerprint, 'sourceDatabaseSha256', source_database_sha256),
|
||||
created_at
|
||||
from rollback_snapshots
|
||||
where not exists (
|
||||
select 1 from migration_cutover_events
|
||||
where migration_cutover_events.rollback_snapshot_id = rollback_snapshots.id
|
||||
);
|
||||
|
||||
create index if not exists idx_migration_cutover_events_org_created
|
||||
on migration_cutover_events(org_id, created_at);
|
||||
create index if not exists idx_migration_cutover_events_report
|
||||
on migration_cutover_events(migration_report_id, created_at);
|
||||
create index if not exists idx_migration_reconciliation_items_org_state
|
||||
on migration_reconciliation_items(org_id, state, created_at);
|
||||
create index if not exists idx_migration_reconciliation_items_report
|
||||
on migration_reconciliation_items(migration_report_id, created_at);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (28, 'migration_cutover_journal', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,65 @@
|
||||
export const fileHandoffRecordsSchema = `
|
||||
create table if not exists file_handoff_records (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
message_id text not null references chat_messages(id) on delete cascade,
|
||||
file_id text not null references file_objects(id) on delete cascade,
|
||||
source_principal_type text not null,
|
||||
source_principal_id text,
|
||||
target_principal_type text not null,
|
||||
target_principal_id text not null,
|
||||
transfer_type text not null,
|
||||
checksum text not null,
|
||||
scan_state text not null,
|
||||
scan_labels_json text not null,
|
||||
policy_decision text not null,
|
||||
object_ref text not null,
|
||||
created_at text not null,
|
||||
unique(message_id, file_id, target_principal_type, target_principal_id)
|
||||
);
|
||||
|
||||
insert into file_handoff_records(
|
||||
id, org_id, message_id, file_id, source_principal_type, source_principal_id,
|
||||
target_principal_type, target_principal_id, transfer_type, checksum, scan_state,
|
||||
scan_labels_json, policy_decision, object_ref, created_at
|
||||
)
|
||||
select
|
||||
'fhr_' || lower(hex(randomblob(16))),
|
||||
messages.org_id,
|
||||
attachments.message_id,
|
||||
attachments.file_id,
|
||||
messages.sender_type,
|
||||
messages.sender_id,
|
||||
coalesce(grants.granted_to_type, 'org_group'),
|
||||
coalesce(grants.granted_to_id, messages.org_id || ':workspace'),
|
||||
attachments.transfer_type,
|
||||
attachments.checksum,
|
||||
files.scan_state,
|
||||
files.scan_labels_json,
|
||||
case
|
||||
when files.scan_state = 'approved' then 'approved_for_handoff'
|
||||
else 'blocked_by_scan'
|
||||
end,
|
||||
files.storage_path,
|
||||
messages.created_at
|
||||
from chat_attachments attachments
|
||||
join chat_messages messages on messages.id = attachments.message_id
|
||||
join file_objects files on files.id = attachments.file_id
|
||||
left join file_access_grants grants
|
||||
on grants.file_id = attachments.file_id
|
||||
and grants.source_message_id = attachments.message_id
|
||||
where not exists (
|
||||
select 1 from file_handoff_records records
|
||||
where records.message_id = attachments.message_id
|
||||
and records.file_id = attachments.file_id
|
||||
and records.target_principal_type = coalesce(grants.granted_to_type, 'org_group')
|
||||
and records.target_principal_id = coalesce(grants.granted_to_id, messages.org_id || ':workspace')
|
||||
);
|
||||
|
||||
create index if not exists idx_file_handoff_org_created on file_handoff_records(org_id, created_at);
|
||||
create index if not exists idx_file_handoff_file on file_handoff_records(org_id, file_id);
|
||||
create index if not exists idx_file_handoff_target on file_handoff_records(org_id, target_principal_type, target_principal_id);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (29, 'file_handoff_records', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,49 @@
|
||||
export const approvalSourceRefsSchema = `
|
||||
alter table approvals add column source_entity_type text;
|
||||
alter table approvals add column source_entity_id text;
|
||||
|
||||
update approvals
|
||||
set
|
||||
source_entity_type = 'task',
|
||||
source_entity_id = (
|
||||
select tasks.id
|
||||
from tasks
|
||||
where tasks.org_id = approvals.org_id
|
||||
and tasks.title = approvals.title
|
||||
and tasks.description = approvals.details
|
||||
order by tasks.created_at desc
|
||||
limit 1
|
||||
)
|
||||
where kind = 'task_risk'
|
||||
and source_entity_id is null
|
||||
and exists (
|
||||
select 1
|
||||
from tasks
|
||||
where tasks.org_id = approvals.org_id
|
||||
and tasks.title = approvals.title
|
||||
and tasks.description = approvals.details
|
||||
);
|
||||
|
||||
update approvals
|
||||
set
|
||||
source_entity_type = 'scheduled_post',
|
||||
source_entity_id = (
|
||||
select scheduled_posts.id
|
||||
from scheduled_posts
|
||||
where scheduled_posts.approval_id = approvals.id
|
||||
limit 1
|
||||
)
|
||||
where kind = 'social_publish'
|
||||
and source_entity_id is null
|
||||
and exists (
|
||||
select 1
|
||||
from scheduled_posts
|
||||
where scheduled_posts.approval_id = approvals.id
|
||||
);
|
||||
|
||||
create index if not exists idx_approvals_source
|
||||
on approvals(org_id, source_entity_type, source_entity_id, kind, state);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (30, 'approval_source_refs', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const providerRequestWorkspaceSchema = `
|
||||
alter table provider_requests add column workspace_id text references workspaces(id) on delete set null;
|
||||
|
||||
create index if not exists idx_provider_requests_workspace
|
||||
on provider_requests(org_id, workspace_id, created_at)
|
||||
where workspace_id is not null;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (31, 'provider_request_workspace', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const browserActionMetadataSchema = `
|
||||
alter table browser_actions add column metadata_json text not null default '{}';
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (32, 'browser_action_metadata', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
export const auditEventImmutabilitySchema = `
|
||||
create trigger if not exists trg_audit_events_no_update
|
||||
before update on audit_events
|
||||
begin
|
||||
select raise(abort, 'Audit events are immutable.');
|
||||
end;
|
||||
|
||||
create trigger if not exists trg_audit_events_no_delete
|
||||
before delete on audit_events
|
||||
begin
|
||||
select raise(abort, 'Audit events are immutable.');
|
||||
end;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (33, 'audit_event_immutability', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,27 @@
|
||||
export const providerAuthSessionsSchema = `
|
||||
create table if not exists provider_auth_sessions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
state text not null,
|
||||
nonce text not null,
|
||||
code_verifier_sha256 text not null,
|
||||
redirect_uri text not null,
|
||||
sign_in_url text not null,
|
||||
partition text not null,
|
||||
created_at text not null,
|
||||
expires_at text not null,
|
||||
completed_at text,
|
||||
unique(provider_id, state)
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_auth_sessions_org_provider
|
||||
on provider_auth_sessions(org_id, provider_id, created_at);
|
||||
create index if not exists idx_provider_auth_sessions_employee
|
||||
on provider_auth_sessions(org_id, employee_id, created_at);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (34, 'provider_auth_sessions', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
export const taskRunIsolationSchema = `
|
||||
alter table task_runs add column execution_context_id text;
|
||||
alter table task_runs add column service_identity_id text references service_identities(id) on delete set null;
|
||||
alter table task_runs add column workspace_mount_json text not null default '[]';
|
||||
alter table task_runs add column secret_scope_json text not null default '{}';
|
||||
|
||||
create unique index if not exists idx_task_runs_execution_context
|
||||
on task_runs(execution_context_id)
|
||||
where execution_context_id is not null;
|
||||
create index if not exists idx_task_runs_service_identity
|
||||
on task_runs(service_identity_id)
|
||||
where service_identity_id is not null;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (35, 'task_run_isolation', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
export const additionalConnectorsSchema = `
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'claude-pro', 'Claude Pro/Max', 'oauth_pkce', 'claude-sonnet-4-5', null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'mistral', 'Mistral', 'api_key', 'mistral-large-latest', null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'cerebras', 'Cerebras', 'api_key', 'qwen-3-235b-a22b-instruct-2507', null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'openrouter', 'OpenRouter', 'api_key', 'anthropic/claude-sonnet-4.5', null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (36, 'additional_connectors', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
export const customProvidersSchema = `
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'custom-openai', 'Custom OpenAI-compatible', 'cloud', null, null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'custom-anthropic', 'Custom Anthropic-compatible', 'cloud', null, null, 0, datetime('now')
|
||||
from organizations;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (37, 'custom_providers', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Deleting an organization cascades into its `credentials`, but
|
||||
// `provider_auth_profiles.credential_id` referenced `credentials(id)` with
|
||||
// `on delete restrict`, which blocked the cascade and made the whole DELETE
|
||||
// throw — so deleting an organization silently failed. This rebuilds the
|
||||
// (leaf) table with `on delete cascade`, preserving every existing row and
|
||||
// index. Revocation only deactivates a profile (active = 0) and never deletes
|
||||
// a credential row, so cascade has no effect on normal operation.
|
||||
export const providerAuthProfileCascadeSchema = `
|
||||
create table provider_auth_profiles_rebuild (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
credential_id text not null references credentials(id) on delete cascade,
|
||||
provider_key text not null,
|
||||
auth_type text not null,
|
||||
profile_kind text not null,
|
||||
display_hint text not null,
|
||||
source_type text not null,
|
||||
source_ref text,
|
||||
capability_json text not null,
|
||||
active integer not null check(active in (0, 1)),
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
insert into provider_auth_profiles_rebuild(
|
||||
id, org_id, provider_id, credential_id, provider_key, auth_type,
|
||||
profile_kind, display_hint, source_type, source_ref, capability_json, active, created_at
|
||||
)
|
||||
select
|
||||
id, org_id, provider_id, credential_id, provider_key, auth_type,
|
||||
profile_kind, display_hint, source_type, source_ref, capability_json, active, created_at
|
||||
from provider_auth_profiles;
|
||||
|
||||
drop table provider_auth_profiles;
|
||||
alter table provider_auth_profiles_rebuild rename to provider_auth_profiles;
|
||||
|
||||
create index if not exists idx_provider_auth_profiles_org_provider
|
||||
on provider_auth_profiles(org_id, provider_id, created_at);
|
||||
create index if not exists idx_provider_auth_profiles_credential
|
||||
on provider_auth_profiles(credential_id);
|
||||
create unique index if not exists idx_provider_auth_profiles_active
|
||||
on provider_auth_profiles(provider_id) where active = 1;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (38, 'provider_auth_profile_cascade', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,28 @@
|
||||
// The audit_events and social_audit_records immutability triggers raised ABORT
|
||||
// on EVERY delete, including the legitimate cascade when an organization is torn
|
||||
// down — so deleting an organization always rolled back. These rebuilt triggers
|
||||
// still block tampering deletes of individual records (the owning org exists),
|
||||
// but allow the cascade once the organization row itself is gone. Direct
|
||||
// deletes of audit rows whose org still exists remain forbidden.
|
||||
export const auditImmutabilityOrgTeardownSchema = `
|
||||
drop trigger if exists trg_audit_events_no_delete;
|
||||
create trigger trg_audit_events_no_delete
|
||||
before delete on audit_events
|
||||
for each row
|
||||
when exists (select 1 from organizations where id = old.org_id)
|
||||
begin
|
||||
select raise(abort, 'Audit events are immutable.');
|
||||
end;
|
||||
|
||||
drop trigger if exists trg_social_audit_records_no_delete;
|
||||
create trigger trg_social_audit_records_no_delete
|
||||
before delete on social_audit_records
|
||||
for each row
|
||||
when exists (select 1 from organizations where id = old.org_id)
|
||||
begin
|
||||
select raise(abort, 'Social audit records are immutable.');
|
||||
end;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (39, 'audit_immutability_org_teardown', datetime('now'));
|
||||
`;
|
||||
@@ -0,0 +1,52 @@
|
||||
export const documentsKnowledgeAutomationSchema = `
|
||||
create table if not exists documents (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
doc_type text not null,
|
||||
content text not null,
|
||||
status text not null,
|
||||
author_agent_id text references agents(id) on delete set null,
|
||||
reviewer_agent_id text references agents(id) on delete set null,
|
||||
project_id text references projects(id) on delete set null,
|
||||
goal_id text references goals(id) on delete set null,
|
||||
task_id text references tasks(id) on delete set null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_documents_org on documents(org_id, doc_type);
|
||||
|
||||
create table if not exists knowledge_entries (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
topic text not null,
|
||||
category text not null,
|
||||
content text not null,
|
||||
importance text not null,
|
||||
source_type text not null,
|
||||
source_id text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_knowledge_org on knowledge_entries(org_id, category);
|
||||
|
||||
create table if not exists automation_rules (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
trigger_event text not null,
|
||||
condition_json text not null,
|
||||
action_type text not null,
|
||||
action_config_json text not null,
|
||||
priority integer not null,
|
||||
enabled integer not null,
|
||||
last_fired_at text,
|
||||
fire_count integer not null default 0,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_automation_org on automation_rules(org_id, enabled);
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const taskSubtasksDeleteSchema = `
|
||||
alter table tasks add column parent_task_id text references tasks(id) on delete set null;
|
||||
`;
|
||||
@@ -0,0 +1,37 @@
|
||||
export const approvalNotesMeetingsSprintsSchema = `
|
||||
alter table approvals add column decision_notes text;
|
||||
|
||||
create table if not exists meetings (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
meeting_type text not null,
|
||||
status text not null,
|
||||
scheduled_at text,
|
||||
duration_minutes integer not null,
|
||||
agenda text not null,
|
||||
notes text not null,
|
||||
decisions text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_meetings_org on meetings(org_id, scheduled_at);
|
||||
|
||||
create table if not exists sprints (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
goal text not null,
|
||||
status text not null,
|
||||
start_date text not null,
|
||||
end_date text not null,
|
||||
velocity_points integer not null default 0,
|
||||
completed_points integer not null default 0,
|
||||
retrospective text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_sprints_org on sprints(org_id, status);
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const composioIntegrationSchema = `
|
||||
create table if not exists composio_settings (
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
value text not null,
|
||||
updated_at text not null,
|
||||
primary key (org_id, key)
|
||||
);
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const mentionWakeStateSchema = `
|
||||
alter table chat_mentions add column wake_state text not null default 'none';
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
export const connectorGrantsSchema = `
|
||||
create table if not exists connector_accounts (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
toolkit_slug text not null,
|
||||
toolkit_name text not null,
|
||||
auth_config_id text not null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique (org_id, toolkit_slug)
|
||||
);
|
||||
|
||||
create table if not exists agent_connector_grants (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
toolkit_slug text not null,
|
||||
granted_by_type text not null,
|
||||
granted_by_id text,
|
||||
created_at text not null,
|
||||
unique (org_id, agent_id, toolkit_slug)
|
||||
);
|
||||
|
||||
create index if not exists idx_connector_grants_agent on agent_connector_grants(org_id, agent_id);
|
||||
`;
|
||||
@@ -1,263 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export const BASELINE_SCHEMA_VERSION = 56;
|
||||
export const BASELINE_SCHEMA_NAME = "baseline_v56";
|
||||
|
||||
export const BASELINE_TABLE_SQL = [
|
||||
"CREATE TABLE activity (id text primary key, company_id text not null, actor text not null, action text not null, entity_type text not null, entity_id text not null, detail text not null, created_at text not null)",
|
||||
"CREATE TABLE agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE agent_message_mentions (\n message_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n company_id TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (message_id, agent_id)\n )",
|
||||
"CREATE TABLE agent_message_projections (\n message_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n projected_at TEXT NOT NULL,\n PRIMARY KEY (message_id, session_id)\n )",
|
||||
"CREATE TABLE agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id))",
|
||||
"CREATE TABLE agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', parent_message_id text, attachments_json text not null default '[]', created_at text not null, sender_kind TEXT NOT NULL DEFAULT 'agent')",
|
||||
"CREATE TABLE agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE agents (id text primary key, company_id text not null, name text not null, role text not null, title text not null, status text not null, reports_to text, connector_id text not null, workspace_id text, model text, capabilities text not null, budget_monthly_usd real not null default 0, spent_monthly_usd real not null default 0, metadata_json text not null default '{}', created_at text not null, updated_at text not null, department text, permissions_json TEXT NOT NULL DEFAULT '{}', heartbeat_policy_json TEXT, runtime_state_json TEXT, role_level TEXT NOT NULL DEFAULT 'ic', session_id TEXT, model_strategy TEXT NOT NULL DEFAULT 'explicit', heartbeat_enabled integer not null default 0, heartbeat_interval_sec integer not null default 120, last_heartbeat_at text)",
|
||||
"CREATE TABLE approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE approvals (id text primary key, company_id text not null, related_task_id text, requested_by_agent_id text, type text not null, payload_summary text not null, impact_summary text not null, payload_json text, decision_note text not null default '', state text not null, created_at text not null, updated_at text not null, related_agent_id text)",
|
||||
"CREATE TABLE automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null)",
|
||||
"CREATE TABLE automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE browser_actions (id text primary key, company_id text not null references companies(id) on delete cascade, social_account_id text not null references social_accounts(id) on delete cascade, agent_id text not null references agents(id) on delete cascade, task_id text, action_type text not null default 'custom_script', payload_json text not null default '{}', status text not null default 'queued', result_summary text not null default '', screenshot_path text, approval_id text, error_message text, started_at text, finished_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
|
||||
"CREATE TABLE budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null, auto_approve_hires integer not null default 0, review_deliverables integer not null default 0, execution_mode text not null default 'ai_ceo')",
|
||||
"CREATE TABLE \"company_skills\" (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE connector_model_pool_settings (\n company_id TEXT NOT NULL,\n connector_id TEXT NOT NULL,\n disabled_models_json TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n PRIMARY KEY (company_id, connector_id),\n FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE\n )",
|
||||
"CREATE TABLE connectors (id text primary key, label text not null, description text not null, status text not null, command text not null, version text, auth_state text not null, last_checked_at text, last_error text, model text, env_binding_text text not null, notes text not null, updated_at text, capability_json text)",
|
||||
"CREATE TABLE conversation_events (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n actor_id TEXT NOT NULL,\n actor_type TEXT NOT NULL,\n event_type TEXT NOT NULL,\n body TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE conversation_participants (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n participant_id TEXT NOT NULL,\n participant_type TEXT NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n joined_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, participant_id)\n )",
|
||||
"CREATE TABLE conversation_threads (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n title TEXT NOT NULL,\n type TEXT NOT NULL DEFAULT 'task',\n related_entity_type TEXT,\n related_entity_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE cost_entries (id text primary key, company_id text not null, run_id text not null, task_id text, agent_id text, connector_id text not null, amount_usd real, unattributed integer not null default 0, created_at text not null, project_id TEXT)",
|
||||
"CREATE TABLE credentials (\n connector_id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n backend TEXT NOT NULL,\n ciphertext BLOB NOT NULL,\n display_hint TEXT NOT NULL DEFAULT '',\n status TEXT NOT NULL DEFAULT 'active',\n expires_at TEXT,\n last_refreshed_at TEXT,\n last_error TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE document_lifecycle_events (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n document_id TEXT NOT NULL,\n hook_type TEXT NOT NULL,\n status TEXT NOT NULL,\n attempts INTEGER NOT NULL DEFAULT 0,\n last_error TEXT,\n payload_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n completed_at TEXT\n )",
|
||||
"CREATE TABLE document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null, latest_revision_id TEXT, source_run_id TEXT)",
|
||||
"CREATE TABLE goals (id text primary key, company_id text not null, title text not null, description text not null, parent_id text, owner_agent_id text, status text not null, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null, source_run_id TEXT)",
|
||||
"CREATE TABLE meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE model_capabilities (\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n reasoning INTEGER NOT NULL,\n coding INTEGER NOT NULL,\n writing INTEGER NOT NULL,\n tier TEXT NOT NULL,\n cost_per_mtok_out REAL NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (provider, model_id)\n )",
|
||||
"CREATE TABLE projects (id text primary key, company_id text not null, goal_id text, name text not null, description text not null, lead_agent_id text, status text not null, target_date text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE \"runs\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n task_id TEXT,\n agent_id TEXT NOT NULL,\n workspace_id TEXT,\n connector_id TEXT NOT NULL,\n status TEXT NOT NULL,\n summary TEXT NOT NULL,\n error_message TEXT,\n exit_code INTEGER,\n signal TEXT,\n model TEXT,\n cost_usd REAL,\n unattributed_cost INTEGER NOT NULL DEFAULT 0,\n actor_type TEXT,\n actor_id TEXT,\n started_at TEXT,\n finished_at TEXT,\n log_path TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , usage_status TEXT NOT NULL DEFAULT 'pre_first_message')",
|
||||
"CREATE TABLE schema_migrations (version integer primary key, name text not null, applied_at text not null)",
|
||||
"CREATE TABLE secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name))",
|
||||
"CREATE TABLE session_entries (\n id TEXT PRIMARY KEY,\n task_session_id TEXT NOT NULL,\n sequence INTEGER NOT NULL,\n kind TEXT NOT NULL,\n role TEXT,\n payload_json TEXT NOT NULL,\n is_superseded INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE settings (key text primary key, value text not null)",
|
||||
"CREATE TABLE social_accounts (id text primary key, company_id text not null references companies(id) on delete cascade, platform text not null default 'other', account_name text not null, display_name text not null default '', profile_url text not null default '', credential_secret_id text, status text not null default 'login_required', require_approval integer not null default 1, metadata_json text not null default '{}', last_used_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
|
||||
"CREATE TABLE sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null)",
|
||||
"CREATE TABLE task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE \"task_sessions\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n adapter_type TEXT NOT NULL,\n last_run_id TEXT,\n last_error TEXT,\n run_count INTEGER NOT NULL DEFAULT 0,\n total_input_tokens INTEGER NOT NULL DEFAULT 0,\n total_output_tokens INTEGER NOT NULL DEFAULT 0,\n rotated_at TEXT,\n rotation_reason TEXT,\n handoff_markdown TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE \"tasks\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n goal_id TEXT,\n parent_id TEXT,\n title TEXT NOT NULL,\n description TEXT NOT NULL,\n assignee_agent_id TEXT,\n workspace_id TEXT,\n status TEXT NOT NULL,\n priority TEXT NOT NULL,\n active_run_id TEXT,\n cost_usd REAL NOT NULL DEFAULT 0,\n task_type TEXT NOT NULL DEFAULT 'general',\n requires_user_review INTEGER NOT NULL DEFAULT 0,\n retry_count INTEGER NOT NULL DEFAULT 0,\n last_failure_at TEXT,\n checkout_run_id TEXT,\n execution_locked_at TEXT,\n started_at TEXT,\n completed_at TEXT,\n cancelled_at TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , requires_filesystem INTEGER NOT NULL DEFAULT 0, active_session_id text)",
|
||||
"CREATE TABLE thread_subscriptions (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n subscriber_id TEXT NOT NULL,\n subscriber_type TEXT NOT NULL,\n notify_on TEXT NOT NULL DEFAULT 'all',\n subscribed_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, subscriber_id)\n )",
|
||||
"CREATE TABLE thread_wait_states (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n company_id TEXT NOT NULL,\n waiting_for TEXT NOT NULL,\n related_approval_id TEXT,\n reason TEXT NOT NULL,\n created_at TEXT NOT NULL,\n resolved_at TEXT\n )",
|
||||
"CREATE TABLE wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, context_snapshot TEXT)",
|
||||
"CREATE TABLE \"work_products\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n task_id TEXT,\n type TEXT NOT NULL,\n provider TEXT NOT NULL,\n external_id TEXT,\n title TEXT NOT NULL,\n url TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n review_state TEXT NOT NULL DEFAULT 'none',\n is_primary INTEGER NOT NULL DEFAULT 0,\n summary TEXT,\n metadata_json TEXT,\n created_by_run_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL, workspace_id TEXT, last_touched_by_run_id TEXT,\n FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE\n )",
|
||||
"CREATE TABLE workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE workspaces (id text primary key, company_id text not null, name text not null, local_path text not null, repo_url text not null, repo_ref text not null, project_id text, state text not null, is_primary integer not null default 0, created_at text not null, updated_at text not null, lock_holder_run_id text, lock_acquired_at text)",
|
||||
] as const;
|
||||
|
||||
export const BASELINE_INDEX_SQL = [
|
||||
"CREATE INDEX idx_activity_company on activity(company_id)",
|
||||
"CREATE INDEX idx_activity_company_actor_action ON activity(company_id, actor, action)",
|
||||
"CREATE INDEX idx_activity_company_created_at on activity(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at)",
|
||||
"CREATE UNIQUE INDEX idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number)",
|
||||
"CREATE INDEX idx_agent_message_mentions_agent\n ON agent_message_mentions(company_id, agent_id, created_at)\n ",
|
||||
"CREATE INDEX idx_agent_message_mentions_message\n ON agent_message_mentions(message_id)\n ",
|
||||
"CREATE INDEX idx_agent_message_projections_session\n ON agent_message_projections(session_id, projected_at)\n ",
|
||||
"CREATE INDEX idx_agent_message_reads_agent on agent_message_reads(agent_id)",
|
||||
"CREATE INDEX idx_agent_messages_channel on agent_messages(channel)",
|
||||
"CREATE INDEX idx_agent_messages_company on agent_messages(company_id)",
|
||||
"CREATE INDEX idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_agent_messages_from on agent_messages(from_agent_id)",
|
||||
"CREATE INDEX idx_agent_messages_to on agent_messages(to_agent_id)",
|
||||
"CREATE INDEX idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)",
|
||||
"CREATE INDEX idx_agents_company on agents(company_id)",
|
||||
"CREATE INDEX idx_agents_company_status on agents(company_id, status)",
|
||||
"CREATE INDEX idx_agents_department on agents(company_id, department)",
|
||||
"CREATE INDEX idx_agents_reports_to on agents(reports_to)",
|
||||
"CREATE INDEX idx_agents_session_id ON agents(session_id)",
|
||||
"CREATE INDEX idx_approval_comments_approval ON approval_comments(approval_id, created_at)",
|
||||
"CREATE INDEX idx_approvals_company on approvals(company_id)",
|
||||
"CREATE INDEX idx_approvals_company_created_at ON approvals(company_id, created_at DESC)",
|
||||
"CREATE INDEX idx_approvals_company_state on approvals(company_id, state)",
|
||||
"CREATE INDEX idx_approvals_company_state_type ON approvals(company_id, state, type)",
|
||||
"CREATE INDEX idx_approvals_related_task ON approvals(related_task_id)",
|
||||
"CREATE INDEX idx_automation_log_company on automation_log(company_id)",
|
||||
"CREATE INDEX idx_automation_rules_company on automation_rules(company_id)",
|
||||
"CREATE INDEX idx_automation_rules_company_status on automation_rules(company_id, status)",
|
||||
"CREATE INDEX idx_automation_rules_trigger on automation_rules(trigger)",
|
||||
"CREATE INDEX idx_browser_actions_account on browser_actions(social_account_id)",
|
||||
"CREATE INDEX idx_browser_actions_company on browser_actions(company_id)",
|
||||
"CREATE INDEX idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status)",
|
||||
"CREATE INDEX idx_budget_incidents_company_status ON budget_incidents(company_id, status)",
|
||||
"CREATE INDEX idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type)",
|
||||
"CREATE INDEX idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active)",
|
||||
"CREATE UNIQUE INDEX idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind)",
|
||||
"CREATE INDEX idx_comments_company on task_comments(company_id)",
|
||||
"CREATE INDEX idx_comments_task on task_comments(task_id)",
|
||||
"CREATE UNIQUE INDEX idx_company_skills_company_key ON company_skills(company_id, key)",
|
||||
"CREATE INDEX idx_company_skills_company_name ON company_skills(company_id, name)",
|
||||
"CREATE INDEX idx_connector_model_pool_company\n ON connector_model_pool_settings(company_id)",
|
||||
"CREATE INDEX idx_conv_events_thread ON conversation_events(thread_id, created_at)",
|
||||
"CREATE INDEX idx_conv_participants_participant ON conversation_participants(participant_id)",
|
||||
"CREATE INDEX idx_conv_threads_company ON conversation_threads(company_id)",
|
||||
"CREATE INDEX idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id)",
|
||||
"CREATE INDEX idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)",
|
||||
"CREATE INDEX idx_cost_entries_company on cost_entries(company_id)",
|
||||
"CREATE INDEX idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_cost_entries_project ON cost_entries(project_id)",
|
||||
"CREATE INDEX idx_cost_entries_run on cost_entries(run_id)",
|
||||
"CREATE INDEX idx_credentials_status ON credentials(status)",
|
||||
"CREATE INDEX idx_doc_rev_company ON document_revisions(company_id, document_id, created_at)",
|
||||
"CREATE UNIQUE INDEX idx_doc_rev_doc_num ON document_revisions(document_id, revision_number)",
|
||||
"CREATE INDEX idx_document_lifecycle_events_company\n ON document_lifecycle_events(company_id, status, updated_at)\n ",
|
||||
"CREATE INDEX idx_document_lifecycle_events_document\n ON document_lifecycle_events(document_id, hook_type)\n ",
|
||||
"CREATE INDEX idx_documents_author on documents(author_agent_id)",
|
||||
"CREATE INDEX idx_documents_company on documents(company_id)",
|
||||
"CREATE INDEX idx_documents_company_updated_at ON documents(company_id, updated_at DESC)",
|
||||
"CREATE INDEX idx_documents_project on documents(project_id)",
|
||||
"CREATE INDEX idx_goals_company on goals(company_id)",
|
||||
"CREATE INDEX idx_knowledge_category on knowledge_base(category)",
|
||||
"CREATE INDEX idx_knowledge_company on knowledge_base(company_id)",
|
||||
"CREATE INDEX idx_meetings_company on meetings(company_id)",
|
||||
"CREATE INDEX idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)",
|
||||
"CREATE INDEX idx_meetings_organizer on meetings(organizer_agent_id)",
|
||||
"CREATE INDEX idx_messages_channel on agent_messages(channel, channel_target_id)",
|
||||
"CREATE INDEX idx_messages_company on agent_messages(company_id)",
|
||||
"CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)",
|
||||
"CREATE INDEX idx_projects_company on projects(company_id)",
|
||||
"CREATE INDEX idx_run_events_company ON run_events(company_id, run_id)",
|
||||
"CREATE INDEX idx_run_events_run ON run_events(run_id, seq)",
|
||||
"CREATE UNIQUE INDEX idx_run_events_run_seq_unique ON run_events(run_id, seq)",
|
||||
"CREATE INDEX idx_runs_agent ON runs(agent_id)",
|
||||
"CREATE INDEX idx_runs_agent_created_at ON runs(agent_id, created_at DESC)",
|
||||
"CREATE INDEX idx_runs_company ON runs(company_id)",
|
||||
"CREATE INDEX idx_runs_company_created_at ON runs(company_id, created_at DESC)",
|
||||
"CREATE INDEX idx_runs_company_status ON runs(company_id, status)",
|
||||
"CREATE INDEX idx_runs_task ON runs(task_id)",
|
||||
"CREATE INDEX idx_secrets_company on secrets(company_id)",
|
||||
"CREATE INDEX idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence)",
|
||||
"CREATE INDEX idx_session_entries_session ON session_entries(task_session_id, sequence)",
|
||||
"CREATE INDEX idx_social_accounts_company on social_accounts(company_id)",
|
||||
"CREATE INDEX idx_sprints_company on sprints(company_id)",
|
||||
"CREATE INDEX idx_sprints_company_start_date ON sprints(company_id, start_date DESC)",
|
||||
"CREATE INDEX idx_task_approval_links_approval ON task_approval_links(approval_id)",
|
||||
"CREATE INDEX idx_task_approval_links_task ON task_approval_links(task_id)",
|
||||
"CREATE UNIQUE INDEX idx_task_documents_doc ON task_documents(document_id)",
|
||||
"CREATE UNIQUE INDEX idx_task_documents_task_key ON task_documents(company_id, task_id, key)",
|
||||
"CREATE INDEX idx_task_sessions_company_agent\n ON task_sessions(company_id, agent_id, updated_at)",
|
||||
"CREATE INDEX idx_task_sessions_lookup\n ON task_sessions(company_id, agent_id, adapter_type)",
|
||||
"CREATE INDEX idx_tasks_assignee ON tasks(assignee_agent_id)",
|
||||
"CREATE INDEX idx_tasks_company ON tasks(company_id)",
|
||||
"CREATE INDEX idx_tasks_company_status ON tasks(company_id, status)",
|
||||
"CREATE INDEX idx_tasks_project ON tasks(project_id)",
|
||||
"CREATE INDEX idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)",
|
||||
"CREATE INDEX idx_thread_wait_company ON thread_wait_states(company_id, resolved_at)",
|
||||
"CREATE INDEX idx_thread_wait_thread ON thread_wait_states(thread_id)",
|
||||
"CREATE INDEX idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status)",
|
||||
"CREATE INDEX idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)",
|
||||
"CREATE INDEX idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status)",
|
||||
"CREATE INDEX idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at)",
|
||||
"CREATE UNIQUE INDEX idx_work_products_identity\n ON work_products(company_id, workspace_id, provider, external_id)\n WHERE workspace_id IS NOT NULL AND external_id IS NOT NULL\n ",
|
||||
"CREATE INDEX idx_work_products_last_touched\n ON work_products(company_id, last_touched_by_run_id)\n ",
|
||||
"CREATE UNIQUE INDEX idx_work_products_primary_active\n ON work_products(company_id, task_id, type)\n WHERE task_id IS NOT NULL\n AND is_primary = 1\n AND status <> 'archived'",
|
||||
"CREATE INDEX idx_work_products_task\n ON work_products(company_id, task_id, type)",
|
||||
"CREATE INDEX idx_work_products_updated\n ON work_products(company_id, updated_at)",
|
||||
"CREATE INDEX idx_workflows_company on workflow_pipelines(company_id)",
|
||||
"CREATE INDEX idx_workspaces_company on workspaces(company_id)",
|
||||
"CREATE UNIQUE INDEX secrets_company_name_idx on secrets(company_id, name)",
|
||||
] as const;
|
||||
|
||||
function quoteIdentifier(name: string): string {
|
||||
return `"${name.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function parseSchemaName(sql: string, prefix: RegExp, kind: string): string {
|
||||
const match = prefix.exec(sql);
|
||||
if (!match) throw new Error(`Cannot parse baseline ${kind} name from SQL: ${sql}`);
|
||||
return match[1] ?? match[2]!;
|
||||
}
|
||||
|
||||
export function parseBaselineTableName(sql: string): string {
|
||||
return parseSchemaName(
|
||||
sql,
|
||||
/^CREATE TABLE(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
|
||||
"table",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseBaselineIndexName(sql: string): string {
|
||||
return parseSchemaName(
|
||||
sql,
|
||||
/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
|
||||
"index",
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaSql(sql: string): string {
|
||||
return sql
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\(\s+/g, "(")
|
||||
.replace(/\s+\)/g, ")")
|
||||
.replace(/^CREATE TABLE "([^"]+)"/, "CREATE TABLE $1")
|
||||
.replace(/^CREATE INDEX "([^"]+)"/, "CREATE INDEX $1")
|
||||
.replace(/^CREATE UNIQUE INDEX "([^"]+)"/, "CREATE UNIQUE INDEX $1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function ifNotExists(sql: string): string {
|
||||
return sql
|
||||
.replace(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ")
|
||||
.replace(/^CREATE INDEX /, "CREATE INDEX IF NOT EXISTS ")
|
||||
.replace(/^CREATE UNIQUE INDEX /, "CREATE UNIQUE INDEX IF NOT EXISTS ");
|
||||
}
|
||||
|
||||
export function installBaselineTables(db: DatabaseSync): void {
|
||||
db.exec(BASELINE_TABLE_SQL.map(ifNotExists).join(";\n"));
|
||||
}
|
||||
|
||||
export function installBaselineIndexes(db: DatabaseSync): void {
|
||||
db.exec(BASELINE_INDEX_SQL.map(ifNotExists).join(";\n"));
|
||||
}
|
||||
|
||||
export function installCanonicalBaselineIndexes(db: DatabaseSync): void {
|
||||
for (const sql of BASELINE_INDEX_SQL) {
|
||||
db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(parseBaselineIndexName(sql))}`);
|
||||
}
|
||||
db.exec(BASELINE_INDEX_SQL.join(";\n"));
|
||||
}
|
||||
|
||||
export function installBaselineSchema(db: DatabaseSync): void {
|
||||
installBaselineTables(db);
|
||||
installBaselineIndexes(db);
|
||||
}
|
||||
|
||||
export function assertBaselineSchema(db: DatabaseSync): void {
|
||||
assertSchemaEntries(
|
||||
db,
|
||||
"table",
|
||||
BASELINE_TABLE_SQL.map((sql) => [parseBaselineTableName(sql), normalizeSchemaSql(sql)] as const),
|
||||
);
|
||||
assertSchemaEntries(
|
||||
db,
|
||||
"index",
|
||||
BASELINE_INDEX_SQL.map((sql) => [parseBaselineIndexName(sql), normalizeSchemaSql(sql)] as const),
|
||||
);
|
||||
}
|
||||
|
||||
function assertSchemaEntries(
|
||||
db: DatabaseSync,
|
||||
type: "table" | "index",
|
||||
expectedEntries: ReadonlyArray<readonly [string, string]>,
|
||||
): void {
|
||||
const selectSql = db.prepare("select sql from sqlite_master where type = ? and name = ?");
|
||||
for (const [name, expectedSql] of expectedEntries) {
|
||||
const row = selectSql.get(type, name) as { sql: string | null } | undefined;
|
||||
const actualSql = row?.sql ? normalizeSchemaSql(row.sql) : null;
|
||||
if (actualSql !== expectedSql) {
|
||||
throw new Error(`Baseline v56 ${type} drift for ${name}: expected ${expectedSql}; actual ${actualSql ?? "missing"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
BASELINE_SCHEMA_NAME,
|
||||
BASELINE_SCHEMA_VERSION,
|
||||
BASELINE_TABLE_SQL,
|
||||
installCanonicalBaselineIndexes,
|
||||
installBaselineTables,
|
||||
normalizeSchemaSql,
|
||||
parseBaselineTableName,
|
||||
} from "./baseline-v56";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
tableExists,
|
||||
type ReadAll,
|
||||
} from "./types";
|
||||
|
||||
type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type LegacyDeps = {
|
||||
db: DatabaseSync;
|
||||
readAll: ReadAll;
|
||||
};
|
||||
|
||||
const BASELINE_TABLE_NAMES = BASELINE_TABLE_SQL.map((sql) => parseTableName(sql));
|
||||
const EXPECTED_DROPPED_COLUMNS: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
agent_messages: new Set(["read_at"]),
|
||||
company_skills: new Set(["markdown"]),
|
||||
runs: new Set(["context_snapshot_json", "session_display_id"]),
|
||||
tasks: new Set(["is_heartbeat", "phase", "session_display_id", "session_json"]),
|
||||
task_sessions: new Set(["session_display_id", "session_params_json", "task_id"]),
|
||||
};
|
||||
|
||||
export function markBaselineSchema(db: DatabaseSync): void {
|
||||
db.prepare("delete from schema_migrations where version <> ?").run(BASELINE_SCHEMA_VERSION);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(BASELINE_SCHEMA_VERSION, BASELINE_SCHEMA_NAME, nowIso());
|
||||
}
|
||||
|
||||
export function upgradeLegacyProfileToBaselineV56({ db, readAll }: LegacyDeps): void {
|
||||
installBaselineTables(db);
|
||||
migrateLegacySecrets(db);
|
||||
migratePendingWakes(db, readAll);
|
||||
migrateAgentBudgets(db, readAll);
|
||||
|
||||
for (const tableName of BASELINE_TABLE_NAMES) {
|
||||
if (tableName === "schema_migrations") continue;
|
||||
if (tableName === "runs") {
|
||||
backfillRunsUsageStatus(db);
|
||||
}
|
||||
if (tableName === "tasks") {
|
||||
migrateTaskSessions(db, readAll);
|
||||
backfillRequiresFilesystem(db);
|
||||
removeSyntheticHeartbeatTasks(db);
|
||||
}
|
||||
if (tableName === "work_products") {
|
||||
detachDanglingWorkProducts(db);
|
||||
backfillWorkProductProvenance(db);
|
||||
normalizeWorkProductPrimaries(db);
|
||||
}
|
||||
rebuildTableToBaseline(db, tableName);
|
||||
}
|
||||
|
||||
db.exec("drop table if exists pending_wakes");
|
||||
db.exec("drop table if exists task_leases");
|
||||
db.exec("drop trigger if exists trg_tasks_phase_from_status_insert");
|
||||
db.exec("drop trigger if exists trg_tasks_phase_from_status_update");
|
||||
installCanonicalBaselineIndexes(db);
|
||||
assertNoForeignKeyViolations(db);
|
||||
markBaselineSchema(db);
|
||||
}
|
||||
|
||||
function parseTableName(sql: string): string {
|
||||
return parseBaselineTableName(sql);
|
||||
}
|
||||
|
||||
function quoteIdentifier(name: string): string {
|
||||
return `"${name.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function tableSql(tableName: string): string {
|
||||
const sql = BASELINE_TABLE_SQL.find((entry) => parseTableName(entry) === tableName);
|
||||
if (!sql) throw new Error(`Missing baseline SQL for table ${tableName}`);
|
||||
return sql;
|
||||
}
|
||||
|
||||
function tableColumns(db: DatabaseSync, tableName: string): ColumnInfo[] {
|
||||
return db.prepare(`pragma table_info(${quoteIdentifier(tableName)})`).all() as ColumnInfo[];
|
||||
}
|
||||
|
||||
function createSqlForTable(sql: string, tableName: string): string {
|
||||
return sql.replace(/^CREATE TABLE (?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/, `CREATE TABLE ${quoteIdentifier(tableName)}`);
|
||||
}
|
||||
|
||||
function rebuildTableToBaseline(db: DatabaseSync, tableName: string): void {
|
||||
if (!tableExists(db, tableName)) return;
|
||||
if (isBaselineTableDefinition(db, tableName)) return;
|
||||
|
||||
const tempTableName = `__baseline_${tableName}_${process.pid}`;
|
||||
db.exec(`drop table if exists ${quoteIdentifier(tempTableName)}`);
|
||||
db.exec(createSqlForTable(tableSql(tableName), tempTableName));
|
||||
|
||||
const beforeCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tableName)}`).get() as { count: number }).count;
|
||||
const sourceColumns = new Set(tableColumns(db, tableName).map((column) => column.name));
|
||||
const targetColumns = tableColumns(db, tempTableName);
|
||||
assertDroppedColumnsAreExpected(tableName, sourceColumns, new Set(targetColumns.map((column) => column.name)));
|
||||
const insertColumns = targetColumns.map((column) => quoteIdentifier(column.name));
|
||||
const selectExpressions = targetColumns.map((column) => {
|
||||
if (sourceColumns.has(column.name)) return quoteIdentifier(column.name);
|
||||
if (column.dflt_value !== null) return column.dflt_value;
|
||||
if (column.notnull === 0) return "NULL";
|
||||
if (beforeCount === 0) return "NULL";
|
||||
throw new Error(`Cannot rebuild ${tableName}: missing required column ${column.name}`);
|
||||
});
|
||||
|
||||
db.exec(`insert into ${quoteIdentifier(tempTableName)} (${insertColumns.join(", ")}) select ${selectExpressions.join(", ")} from ${quoteIdentifier(tableName)}`);
|
||||
const afterCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tempTableName)}`).get() as { count: number }).count;
|
||||
if (afterCount !== beforeCount) {
|
||||
throw new Error(`Cannot rebuild ${tableName}: copied ${afterCount} of ${beforeCount} rows`);
|
||||
}
|
||||
|
||||
db.exec(`drop table ${quoteIdentifier(tableName)}`);
|
||||
db.exec(`alter table ${quoteIdentifier(tempTableName)} rename to ${quoteIdentifier(tableName)}`);
|
||||
}
|
||||
|
||||
function isBaselineTableDefinition(db: DatabaseSync, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("select sql from sqlite_master where type = 'table' and name = ?")
|
||||
.get(tableName) as { sql: string | null } | undefined;
|
||||
if (!row?.sql) return false;
|
||||
return normalizeSchemaSql(row.sql) === normalizeSchemaSql(tableSql(tableName));
|
||||
}
|
||||
|
||||
function assertDroppedColumnsAreExpected(tableName: string, sourceColumns: Set<string>, targetColumns: Set<string>): void {
|
||||
const allowed = EXPECTED_DROPPED_COLUMNS[tableName] ?? new Set<string>();
|
||||
const unexpected = [...sourceColumns]
|
||||
.filter((column) => !targetColumns.has(column))
|
||||
.filter((column) => !allowed.has(column));
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`Cannot rebuild ${tableName}: unexpected legacy columns would be dropped: ${unexpected.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacySecrets(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "secrets") || columnExists(db, "secrets", "company_id")) return;
|
||||
|
||||
const currentCompany = db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined;
|
||||
const firstCompany = db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined;
|
||||
const companyId = currentCompany?.value ?? firstCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error("Cannot migrate legacy secrets table: no company row exists.");
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
alter table secrets rename to secrets_legacy;
|
||||
create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));
|
||||
`);
|
||||
db
|
||||
.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy")
|
||||
.run(companyId);
|
||||
db.exec("drop table secrets_legacy");
|
||||
}
|
||||
|
||||
function migratePendingWakes(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "pending_wakes")) return;
|
||||
const rows = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>(
|
||||
"select id, company_id as companyId, agent_id as agentId, trigger_reason as triggerReason, created_at as createdAt from pending_wakes",
|
||||
);
|
||||
const insert = db.prepare(`
|
||||
insert or ignore into wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)
|
||||
`);
|
||||
const ts = nowIso();
|
||||
for (const row of rows) {
|
||||
const detail = JSON.stringify([{ trigger: row.triggerReason, at: row.createdAt, detail: row.triggerReason }]);
|
||||
insert.run(row.id, row.companyId, row.agentId, row.triggerReason, detail, row.createdAt, ts, ts);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateAgentBudgets(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "agents") || !tableExists(db, "budget_policies")) return;
|
||||
if (!columnExists(db, "agents", "budget_monthly_usd")) return;
|
||||
const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>(
|
||||
"select id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd from agents where budget_monthly_usd > 0",
|
||||
);
|
||||
const existing = db.prepare("select id from budget_policies where company_id = ? and scope_type = 'agent' and scope_id = ? and metric = 'billed_cents' and window_kind = 'calendar_month_utc'");
|
||||
const insert = db.prepare(`
|
||||
insert into budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at)
|
||||
values (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)
|
||||
`);
|
||||
const ts = nowIso();
|
||||
for (const agent of agents) {
|
||||
if (existing.get(agent.companyId, agent.id)) continue;
|
||||
insert.run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateTaskSessions(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "session_json")) return;
|
||||
if (!columnExists(db, "tasks", "active_session_id")) {
|
||||
db.exec("alter table tasks add column active_session_id text");
|
||||
}
|
||||
|
||||
const rows = readAll<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
assigneeAgentId: string | null;
|
||||
sessionJson: string;
|
||||
sessionDisplayId: string | null;
|
||||
updatedAt: string;
|
||||
connectorId: string | null;
|
||||
}>(`
|
||||
select t.id,
|
||||
t.company_id as companyId,
|
||||
t.assignee_agent_id as assigneeAgentId,
|
||||
t.session_json as sessionJson,
|
||||
${columnExists(db, "tasks", "session_display_id") ? "t.session_display_id" : "NULL"} as sessionDisplayId,
|
||||
t.updated_at as updatedAt,
|
||||
a.connector_id as connectorId
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.session_json is not null
|
||||
and (t.active_session_id is null or t.active_session_id = '')
|
||||
`);
|
||||
const sessionColumns = new Set(tableColumns(db, "task_sessions").map((column) => column.name));
|
||||
const updateTask = db.prepare("update tasks set active_session_id = ? where id = ?");
|
||||
const existingSessionByTask = sessionColumns.has("task_id")
|
||||
? db.prepare("select id from task_sessions where task_id = ? order by updated_at desc limit 1")
|
||||
: null;
|
||||
const insertColumns = [
|
||||
"id",
|
||||
"company_id",
|
||||
"agent_id",
|
||||
"adapter_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
...(sessionColumns.has("task_id") ? ["task_id"] : []),
|
||||
...(sessionColumns.has("session_params_json") ? ["session_params_json"] : []),
|
||||
...(sessionColumns.has("session_display_id") ? ["session_display_id"] : []),
|
||||
];
|
||||
const insert = db.prepare(`
|
||||
insert into task_sessions (${insertColumns.map(quoteIdentifier).join(", ")})
|
||||
values (${insertColumns.map(() => "?").join(", ")})
|
||||
`);
|
||||
for (const row of rows) {
|
||||
JSON.parse(row.sessionJson);
|
||||
const existingSession = existingSessionByTask?.get(row.id) as { id: string } | undefined;
|
||||
if (existingSession) {
|
||||
updateTask.run(existingSession.id, row.id);
|
||||
continue;
|
||||
}
|
||||
if (!row.assigneeAgentId) {
|
||||
throw new Error(`Cannot migrate task session for task ${row.id}: assignee_agent_id is missing.`);
|
||||
}
|
||||
if (!row.connectorId) {
|
||||
throw new Error(`Cannot migrate task session for task ${row.id}: assigned agent connector_id is missing.`);
|
||||
}
|
||||
const sessionId = randomUUID();
|
||||
const values = [
|
||||
sessionId,
|
||||
row.companyId,
|
||||
row.assigneeAgentId,
|
||||
row.connectorId,
|
||||
row.updatedAt,
|
||||
row.updatedAt,
|
||||
...(sessionColumns.has("task_id") ? [row.id] : []),
|
||||
...(sessionColumns.has("session_params_json") ? [row.sessionJson] : []),
|
||||
...(sessionColumns.has("session_display_id") ? [row.sessionDisplayId] : []),
|
||||
];
|
||||
insert.run(...values);
|
||||
updateTask.run(sessionId, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function backfillRequiresFilesystem(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks")) return;
|
||||
if (!columnExists(db, "tasks", "requires_filesystem")) {
|
||||
db.exec("alter table tasks add column requires_filesystem integer not null default 0");
|
||||
}
|
||||
if (columnExists(db, "tasks", "active_run_id")) {
|
||||
db.exec(`
|
||||
update tasks
|
||||
set requires_filesystem = 1
|
||||
where status = 'in_progress'
|
||||
and active_run_id is not null
|
||||
`);
|
||||
}
|
||||
if (columnExists(db, "tasks", "assignee_agent_id")) {
|
||||
db.exec(`
|
||||
update tasks
|
||||
set requires_filesystem = 1
|
||||
where status in ('todo', 'backlog')
|
||||
and assignee_agent_id is not null
|
||||
and exists (
|
||||
select 1 from agents a
|
||||
where a.id = tasks.assignee_agent_id
|
||||
and (
|
||||
a.role like '%cto%'
|
||||
or a.role like '%engineer%'
|
||||
or a.role like '%developer%'
|
||||
or a.role like '%devops%'
|
||||
or a.role like '%sre%'
|
||||
or a.department = 'engineering'
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSyntheticHeartbeatTasks(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "is_heartbeat")) return;
|
||||
if (tableExists(db, "runs")) {
|
||||
db.exec("update runs set task_id = null where task_id in (select id from tasks where is_heartbeat = 1)");
|
||||
}
|
||||
db.exec("delete from tasks where is_heartbeat = 1");
|
||||
}
|
||||
|
||||
function backfillRunsUsageStatus(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "runs")) return;
|
||||
if (!columnExists(db, "runs", "usage_status")) {
|
||||
db.exec("alter table runs add column usage_status text not null default 'pre_first_message'");
|
||||
}
|
||||
if (!columnExists(db, "runs", "unattributed_cost") || !columnExists(db, "runs", "status")) return;
|
||||
db.exec(`
|
||||
update runs
|
||||
set usage_status = case
|
||||
when unattributed_cost = 0 then 'ok'
|
||||
when status in ('cancelled', 'interrupted') then 'aborted_before_usage'
|
||||
else 'pre_first_message'
|
||||
end
|
||||
where usage_status = 'pre_first_message'
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizeWorkProductPrimaries(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products")) return;
|
||||
db.exec(`
|
||||
update work_products
|
||||
set is_primary = 0,
|
||||
updated_at = datetime('now')
|
||||
where id in (
|
||||
select id from (
|
||||
select id,
|
||||
row_number() over (
|
||||
partition by company_id, task_id, type
|
||||
order by updated_at desc, created_at desc, id desc
|
||||
) as rank_in_group
|
||||
from work_products
|
||||
where task_id is not null
|
||||
and is_primary = 1
|
||||
and status <> 'archived'
|
||||
)
|
||||
where rank_in_group > 1
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function backfillWorkProductProvenance(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products")) return;
|
||||
if (!columnExists(db, "work_products", "workspace_id")) {
|
||||
db.exec("alter table work_products add column workspace_id text");
|
||||
}
|
||||
if (!columnExists(db, "work_products", "last_touched_by_run_id")) {
|
||||
db.exec("alter table work_products add column last_touched_by_run_id text");
|
||||
}
|
||||
if (tableExists(db, "tasks") && columnExists(db, "work_products", "task_id") && columnExists(db, "tasks", "workspace_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set workspace_id = (
|
||||
select tasks.workspace_id from tasks
|
||||
where tasks.id = work_products.task_id
|
||||
)
|
||||
where workspace_id is null
|
||||
and task_id is not null
|
||||
`);
|
||||
}
|
||||
if (tableExists(db, "runs") && columnExists(db, "work_products", "created_by_run_id") && columnExists(db, "runs", "workspace_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set workspace_id = (
|
||||
select runs.workspace_id from runs
|
||||
where runs.id = work_products.created_by_run_id
|
||||
)
|
||||
where workspace_id is null
|
||||
and created_by_run_id is not null
|
||||
`);
|
||||
}
|
||||
if (columnExists(db, "work_products", "created_by_run_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set last_touched_by_run_id = created_by_run_id
|
||||
where last_touched_by_run_id is null
|
||||
and created_by_run_id is not null
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function detachDanglingWorkProducts(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products") || !tableExists(db, "tasks") || !columnExists(db, "work_products", "task_id")) return;
|
||||
db.exec(`
|
||||
update work_products
|
||||
set task_id = null
|
||||
where task_id is not null
|
||||
and not exists (
|
||||
select 1 from tasks
|
||||
where tasks.id = work_products.task_id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function assertNoForeignKeyViolations(db: DatabaseSync): void {
|
||||
const violations = db.prepare("pragma foreign_key_check").all() as Array<{
|
||||
table: string;
|
||||
rowid: number | null;
|
||||
parent: string;
|
||||
fkid: number;
|
||||
}>;
|
||||
if (violations.length === 0) return;
|
||||
|
||||
const sample = violations
|
||||
.slice(0, 5)
|
||||
.map((row) => `${row.table}:${row.rowid ?? "unknown"}->${row.parent}#${row.fkid}`)
|
||||
.join(", ");
|
||||
throw new Error(`Cannot migrate legacy profile: ${violations.length} foreign key violations after v56 rebuild (${sample}).`);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
|
||||
export type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
export type Transaction = <T>(operation: () => T) => T;
|
||||
|
||||
export type Migration = {
|
||||
version: number;
|
||||
name: string;
|
||||
sql?: string;
|
||||
fn?: () => void;
|
||||
};
|
||||
|
||||
export type MigrationDeps = {
|
||||
db: DatabaseSync;
|
||||
readAll: ReadAll;
|
||||
};
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function columnExists(
|
||||
db: DatabaseSync,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
const rows = db.prepare(`pragma table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
export function tableExists(db: DatabaseSync, table: string): boolean {
|
||||
const row = db
|
||||
.prepare("select name from sqlite_master where type = 'table' and name = ?")
|
||||
.get(table) as { name: string } | undefined;
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function addColumnIfMissing(
|
||||
db: DatabaseSync,
|
||||
table: string,
|
||||
column: string,
|
||||
sql: string,
|
||||
): void {
|
||||
if (!columnExists(db, table, column)) db.prepare(sql).run();
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { STALE_LOCK_TIMEOUT_MS } from "../../constants";
|
||||
import { columnExists, nowIso, tableExists } from "./types";
|
||||
|
||||
export const TASK_LEASE_SCHEMA_VERSION = 57;
|
||||
export const TASK_LEASE_SCHEMA_NAME = "task_leases_runtime_ownership";
|
||||
|
||||
const LEASE_SECONDS = Math.floor(STALE_LOCK_TIMEOUT_MS / 1000);
|
||||
|
||||
export function migrateTaskLeasesV57(db: DatabaseSync): void {
|
||||
installTaskLeasesTable(db);
|
||||
backfillTaskLeases(db);
|
||||
dropTaskOwnershipColumns(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(TASK_LEASE_SCHEMA_VERSION, TASK_LEASE_SCHEMA_NAME, nowIso());
|
||||
assertTaskLeasesV57(db);
|
||||
}
|
||||
|
||||
export function assertTaskLeasesV57(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "task_leases")) {
|
||||
throw new Error("Schema v57 drift: task_leases table is missing.");
|
||||
}
|
||||
if (!tableExists(db, "tasks")) {
|
||||
throw new Error("Schema v57 drift: tasks table is missing.");
|
||||
}
|
||||
|
||||
const leaseColumns = db
|
||||
.prepare("pragma table_info(task_leases)")
|
||||
.all()
|
||||
.map((row) => row as { name: string; type: string; notnull: number; pk: number });
|
||||
const expectedLeaseColumns = [
|
||||
["task_id", "TEXT", 0, 1],
|
||||
["company_id", "TEXT", 1, 0],
|
||||
["run_id", "TEXT", 1, 0],
|
||||
["agent_id", "TEXT", 1, 0],
|
||||
["acquired_at", "TEXT", 1, 0],
|
||||
["renewed_at", "TEXT", 1, 0],
|
||||
["expires_at", "TEXT", 1, 0],
|
||||
["created_at", "TEXT", 1, 0],
|
||||
["updated_at", "TEXT", 1, 0],
|
||||
];
|
||||
const actualLeaseColumns = leaseColumns.map((column) => [
|
||||
column.name,
|
||||
column.type.toUpperCase(),
|
||||
column.notnull,
|
||||
column.pk,
|
||||
]);
|
||||
if (JSON.stringify(actualLeaseColumns) !== JSON.stringify(expectedLeaseColumns)) {
|
||||
throw new Error("Schema v57 drift: task_leases columns do not match the canonical schema.");
|
||||
}
|
||||
|
||||
const columns = new Set(
|
||||
db.prepare("pragma table_info(tasks)").all().map((row) => (row as { name: string }).name),
|
||||
);
|
||||
for (const column of ["active_run_id", "checkout_run_id", "execution_locked_at", "active_session_id"]) {
|
||||
if (columns.has(column)) {
|
||||
throw new Error(`Schema v57 drift: tasks.${column} should be stored in task_leases.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installTaskLeasesTable(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
create table if not exists task_leases (
|
||||
task_id text primary key references tasks(id) on delete cascade,
|
||||
company_id text not null,
|
||||
run_id text not null unique references runs(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
acquired_at text not null,
|
||||
renewed_at text not null,
|
||||
expires_at text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
create index if not exists idx_task_leases_company_agent on task_leases(company_id, agent_id);
|
||||
create index if not exists idx_task_leases_expires_at on task_leases(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
function backfillTaskLeases(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks") || !tableExists(db, "runs")) return;
|
||||
if (!columnExists(db, "tasks", "checkout_run_id") && !columnExists(db, "tasks", "active_run_id")) return;
|
||||
|
||||
const runExpr = [
|
||||
columnExists(db, "tasks", "checkout_run_id") ? "t.checkout_run_id" : "NULL",
|
||||
columnExists(db, "tasks", "active_run_id") ? "t.active_run_id" : "NULL",
|
||||
].join(", ");
|
||||
const lockExpr = columnExists(db, "tasks", "execution_locked_at")
|
||||
? "coalesce(t.execution_locked_at, t.updated_at)"
|
||||
: "t.updated_at";
|
||||
const ts = nowIso();
|
||||
|
||||
db.exec(`
|
||||
insert or ignore into task_leases (
|
||||
task_id, company_id, run_id, agent_id, acquired_at, renewed_at, expires_at, created_at, updated_at
|
||||
)
|
||||
select t.id,
|
||||
t.company_id,
|
||||
coalesce(${runExpr}) as run_id,
|
||||
r.agent_id,
|
||||
${lockExpr} as acquired_at,
|
||||
${lockExpr} as renewed_at,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', ${lockExpr}, '+${LEASE_SECONDS} seconds') as expires_at,
|
||||
'${ts}' as created_at,
|
||||
'${ts}' as updated_at
|
||||
from tasks t
|
||||
join runs r on r.id = coalesce(${runExpr})
|
||||
where coalesce(${runExpr}) is not null
|
||||
and r.status in ('queued', 'running')
|
||||
`);
|
||||
}
|
||||
|
||||
function dropTaskOwnershipColumns(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks")) return;
|
||||
for (const column of ["active_run_id", "checkout_run_id", "execution_locked_at", "active_session_id"]) {
|
||||
if (columnExists(db, "tasks", column)) {
|
||||
db.exec(`alter table tasks drop column ${column}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_POLICY_SEED } from "../../approval-policy-definitions";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export const APPROVAL_POLICY_SCHEMA_VERSION = 58;
|
||||
export const APPROVAL_POLICY_SCHEMA_NAME = "approval_policy_table";
|
||||
|
||||
export function migrateApprovalPoliciesV58(db: DatabaseSync): void {
|
||||
installApprovalPoliciesTable(db);
|
||||
seedApprovalPolicies(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(APPROVAL_POLICY_SCHEMA_VERSION, APPROVAL_POLICY_SCHEMA_NAME, nowIso());
|
||||
assertApprovalPoliciesV58(db);
|
||||
}
|
||||
|
||||
export function assertApprovalPoliciesV58(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "approval_policies")) {
|
||||
throw new Error("Schema v58 drift: approval_policies table is missing.");
|
||||
}
|
||||
|
||||
const columns = db
|
||||
.prepare("pragma table_info(approval_policies)")
|
||||
.all()
|
||||
.map((row) => row as { name: string; type: string; notnull: number; pk: number });
|
||||
const actualColumns = columns.map((column) => [
|
||||
column.name,
|
||||
column.type.toUpperCase(),
|
||||
column.notnull,
|
||||
column.pk,
|
||||
]);
|
||||
const expectedColumns = [
|
||||
["approval_type", "TEXT", 0, 1],
|
||||
["scope_type", "TEXT", 1, 0],
|
||||
["blocks_execution", "INTEGER", 1, 0],
|
||||
["required_mode", "TEXT", 1, 0],
|
||||
["auto_approvable", "INTEGER", 1, 0],
|
||||
["created_at", "TEXT", 1, 0],
|
||||
["updated_at", "TEXT", 1, 0],
|
||||
];
|
||||
if (JSON.stringify(actualColumns) !== JSON.stringify(expectedColumns)) {
|
||||
throw new Error("Schema v58 drift: approval_policies columns do not match the canonical schema.");
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
"select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies order by approval_type",
|
||||
)
|
||||
.all() as Array<{
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: number;
|
||||
requiredMode: string;
|
||||
autoApprovable: number;
|
||||
}>;
|
||||
const actual = rows.map((row) => ({
|
||||
approvalType: row.approvalType,
|
||||
scopeType: row.scopeType,
|
||||
blocksExecution: row.blocksExecution,
|
||||
requiredMode: row.requiredMode,
|
||||
autoApprovable: row.autoApprovable,
|
||||
}));
|
||||
const expected = [...APPROVAL_POLICY_SEED]
|
||||
.sort((a, b) => a.approvalType.localeCompare(b.approvalType))
|
||||
.map((policy) => ({
|
||||
approvalType: policy.approvalType,
|
||||
scopeType: policy.scopeType,
|
||||
blocksExecution: policy.blocksExecution ? 1 : 0,
|
||||
requiredMode: policy.requiredMode,
|
||||
autoApprovable: policy.autoApprovable ? 1 : 0,
|
||||
}));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Schema v58 drift: approval_policies seed rows do not match the canonical policy.");
|
||||
}
|
||||
}
|
||||
|
||||
function installApprovalPoliciesTable(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
create table if not exists approval_policies (
|
||||
approval_type text primary key,
|
||||
scope_type text not null check (scope_type in ('requester_or_task', 'related_agent')),
|
||||
blocks_execution integer not null check (blocks_execution in (0, 1)),
|
||||
required_mode text not null check (required_mode in ('any', 'ai_ceo', 'ai_cofounder')),
|
||||
auto_approvable integer not null check (auto_approvable in (0, 1)),
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedApprovalPolicies(db: DatabaseSync): void {
|
||||
const ts = nowIso();
|
||||
const stmt = db.prepare(
|
||||
`insert or replace into approval_policies (
|
||||
approval_type, scope_type, blocks_execution, required_mode, auto_approvable, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, coalesce((select created_at from approval_policies where approval_type = ?), ?), ?)`,
|
||||
);
|
||||
for (const policy of APPROVAL_POLICY_SEED) {
|
||||
stmt.run(
|
||||
policy.approvalType,
|
||||
policy.scopeType,
|
||||
policy.blocksExecution ? 1 : 0,
|
||||
policy.requiredMode,
|
||||
policy.autoApprovable ? 1 : 0,
|
||||
policy.approvalType,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export const SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION = 59;
|
||||
export const SOCIAL_COMPANY_SCOPE_SCHEMA_NAME = "social_company_scope";
|
||||
|
||||
const TRIGGER_NAMES = [
|
||||
"trg_social_accounts_credential_secret_scope_insert",
|
||||
"trg_social_accounts_credential_secret_scope_update",
|
||||
"trg_browser_actions_social_account_scope_insert",
|
||||
"trg_browser_actions_social_account_scope_update",
|
||||
] as const;
|
||||
|
||||
export function migrateSocialCompanyScopeV59(db: DatabaseSync): void {
|
||||
cleanupInvalidSocialAccountSecretRefs(db);
|
||||
cleanupInvalidBrowserActionRefs(db);
|
||||
installSocialCompanyScopeTriggers(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION, SOCIAL_COMPANY_SCOPE_SCHEMA_NAME, nowIso());
|
||||
assertSocialCompanyScopeV59(db);
|
||||
}
|
||||
|
||||
export function assertSocialCompanyScopeV59(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "social_accounts")) {
|
||||
throw new Error("Schema v59 drift: social_accounts table is missing.");
|
||||
}
|
||||
if (!tableExists(db, "secrets")) {
|
||||
throw new Error("Schema v59 drift: secrets table is missing.");
|
||||
}
|
||||
if (!tableExists(db, "browser_actions")) {
|
||||
throw new Error("Schema v59 drift: browser_actions table is missing.");
|
||||
}
|
||||
const invalid = db
|
||||
.prepare(
|
||||
`select count(*) as count
|
||||
from social_accounts sa
|
||||
where sa.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from secrets s
|
||||
where s.id = sa.credential_secret_id
|
||||
and s.company_id = sa.company_id
|
||||
)`,
|
||||
)
|
||||
.get() as { count: number } | undefined;
|
||||
if ((invalid?.count ?? 0) > 0) {
|
||||
throw new Error("Schema v59 drift: social account credential secrets must belong to the same company.");
|
||||
}
|
||||
const invalidActions = db
|
||||
.prepare(
|
||||
`select count(*) as count
|
||||
from browser_actions ba
|
||||
where not exists (
|
||||
select 1
|
||||
from social_accounts sa
|
||||
where sa.id = ba.social_account_id
|
||||
and sa.company_id = ba.company_id
|
||||
)`,
|
||||
)
|
||||
.get() as { count: number } | undefined;
|
||||
if ((invalidActions?.count ?? 0) > 0) {
|
||||
throw new Error("Schema v59 drift: browser actions must reference social accounts in the same company.");
|
||||
}
|
||||
for (const triggerName of TRIGGER_NAMES) {
|
||||
if (!triggerExists(db, triggerName)) {
|
||||
throw new Error(`Schema v59 drift: ${triggerName} trigger is missing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupInvalidSocialAccountSecretRefs(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "social_accounts") || !tableExists(db, "secrets")) return;
|
||||
db
|
||||
.prepare(
|
||||
`update social_accounts
|
||||
set credential_secret_id = null,
|
||||
updated_at = ?
|
||||
where credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from secrets s
|
||||
where s.id = social_accounts.credential_secret_id
|
||||
and s.company_id = social_accounts.company_id
|
||||
)`,
|
||||
)
|
||||
.run(nowIso());
|
||||
}
|
||||
|
||||
function cleanupInvalidBrowserActionRefs(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "browser_actions") || !tableExists(db, "social_accounts")) return;
|
||||
db
|
||||
.prepare(
|
||||
`delete from browser_actions
|
||||
where not exists (
|
||||
select 1
|
||||
from social_accounts sa
|
||||
where sa.id = browser_actions.social_account_id
|
||||
and sa.company_id = browser_actions.company_id
|
||||
)`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
function installSocialCompanyScopeTriggers(db: DatabaseSync): void {
|
||||
for (const triggerName of TRIGGER_NAMES) {
|
||||
db.exec(`drop trigger if exists ${triggerName}`);
|
||||
}
|
||||
db.exec(`
|
||||
create trigger trg_social_accounts_credential_secret_scope_insert
|
||||
before insert on social_accounts
|
||||
when new.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1 from secrets
|
||||
where id = new.credential_secret_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'social account credential secret must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_social_accounts_credential_secret_scope_update
|
||||
before update of credential_secret_id, company_id on social_accounts
|
||||
when new.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1 from secrets
|
||||
where id = new.credential_secret_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'social account credential secret must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_browser_actions_social_account_scope_insert
|
||||
before insert on browser_actions
|
||||
when not exists (
|
||||
select 1 from social_accounts
|
||||
where id = new.social_account_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'browser action social account must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_browser_actions_social_account_scope_update
|
||||
before update of social_account_id, company_id on browser_actions
|
||||
when not exists (
|
||||
select 1 from social_accounts
|
||||
where id = new.social_account_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'browser action social account must belong to the same company');
|
||||
end;
|
||||
`);
|
||||
}
|
||||
|
||||
function triggerExists(db: DatabaseSync, name: string): boolean {
|
||||
const row = db
|
||||
.prepare("select name from sqlite_master where type = 'trigger' and name = ? limit 1")
|
||||
.get(name) as { name: string } | undefined;
|
||||
return Boolean(row);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION = 60;
|
||||
export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME = "filesystem_task_workspaces";
|
||||
|
||||
const ACTIVE_TASK_STATUSES = "'backlog', 'todo', 'in_progress', 'blocked'";
|
||||
|
||||
export function migrateFilesystemTaskWorkspacesV60(db: DatabaseSync): void {
|
||||
backfillProjectWorkspaces(db);
|
||||
backfillAssigneeWorkspaces(db);
|
||||
backfillSingleCompanyWorkspaces(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION, FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME, nowIso());
|
||||
assertFilesystemTaskWorkspacesV60(db);
|
||||
}
|
||||
|
||||
export function assertFilesystemTaskWorkspacesV60(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks") || !tableExists(db, "workspaces")) {
|
||||
throw new Error("Schema v60 drift: tasks/workspaces tables are missing.");
|
||||
}
|
||||
const unresolved = db
|
||||
.prepare(
|
||||
`select count(*) as count
|
||||
from tasks t
|
||||
where t.requires_filesystem = 1
|
||||
and t.workspace_id is null
|
||||
and t.status in (${ACTIVE_TASK_STATUSES})
|
||||
and (
|
||||
select count(*)
|
||||
from workspaces w
|
||||
where w.company_id = t.company_id
|
||||
) = 1`,
|
||||
)
|
||||
.get() as { count: number } | undefined;
|
||||
if ((unresolved?.count ?? 0) > 0) {
|
||||
throw new Error("Schema v60 drift: single-workspace companies still have unresolved filesystem tasks.");
|
||||
}
|
||||
}
|
||||
|
||||
function backfillProjectWorkspaces(db: DatabaseSync): void {
|
||||
if (!hasRequiredTables(db)) return;
|
||||
db
|
||||
.prepare(
|
||||
`update tasks
|
||||
set workspace_id = (
|
||||
select w.id
|
||||
from workspaces w
|
||||
where w.company_id = tasks.company_id
|
||||
and w.project_id = tasks.project_id
|
||||
order by w.is_primary desc, w.created_at asc
|
||||
limit 1
|
||||
),
|
||||
updated_at = ?
|
||||
where requires_filesystem = 1
|
||||
and workspace_id is null
|
||||
and status in (${ACTIVE_TASK_STATUSES})
|
||||
and project_id is not null
|
||||
and exists (
|
||||
select 1
|
||||
from workspaces w
|
||||
where w.company_id = tasks.company_id
|
||||
and w.project_id = tasks.project_id
|
||||
)`,
|
||||
)
|
||||
.run(nowIso());
|
||||
}
|
||||
|
||||
function backfillAssigneeWorkspaces(db: DatabaseSync): void {
|
||||
if (!hasRequiredTables(db) || !tableExists(db, "agents")) return;
|
||||
db
|
||||
.prepare(
|
||||
`update tasks
|
||||
set workspace_id = (
|
||||
select a.workspace_id
|
||||
from agents a
|
||||
join workspaces w
|
||||
on w.id = a.workspace_id
|
||||
and w.company_id = a.company_id
|
||||
where a.id = tasks.assignee_agent_id
|
||||
and a.company_id = tasks.company_id
|
||||
limit 1
|
||||
),
|
||||
updated_at = ?
|
||||
where requires_filesystem = 1
|
||||
and workspace_id is null
|
||||
and status in (${ACTIVE_TASK_STATUSES})
|
||||
and assignee_agent_id is not null
|
||||
and exists (
|
||||
select 1
|
||||
from agents a
|
||||
join workspaces w
|
||||
on w.id = a.workspace_id
|
||||
and w.company_id = a.company_id
|
||||
where a.id = tasks.assignee_agent_id
|
||||
and a.company_id = tasks.company_id
|
||||
)`,
|
||||
)
|
||||
.run(nowIso());
|
||||
}
|
||||
|
||||
function backfillSingleCompanyWorkspaces(db: DatabaseSync): void {
|
||||
if (!hasRequiredTables(db)) return;
|
||||
db
|
||||
.prepare(
|
||||
`update tasks
|
||||
set workspace_id = (
|
||||
select w.id
|
||||
from workspaces w
|
||||
where w.company_id = tasks.company_id
|
||||
order by w.is_primary desc, w.created_at asc
|
||||
limit 1
|
||||
),
|
||||
updated_at = ?
|
||||
where requires_filesystem = 1
|
||||
and workspace_id is null
|
||||
and status in (${ACTIVE_TASK_STATUSES})
|
||||
and (
|
||||
select count(*)
|
||||
from workspaces w
|
||||
where w.company_id = tasks.company_id
|
||||
) = 1`,
|
||||
)
|
||||
.run(nowIso());
|
||||
}
|
||||
|
||||
function hasRequiredTables(db: DatabaseSync): boolean {
|
||||
return tableExists(db, "tasks") && tableExists(db, "workspaces");
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ModelCapability } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* CRUD for `model_capabilities`. The table is (provider, model_id) keyed; the
|
||||
* dispatcher reads it on every run, so every row must have numeric scores —
|
||||
* we validate inputs at the application layer before touching the DB.
|
||||
*/
|
||||
export function buildModelCapabilityMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
type Row = {
|
||||
provider: string;
|
||||
model_id: string;
|
||||
reasoning: number;
|
||||
coding: number;
|
||||
writing: number;
|
||||
tier: "trivial" | "standard" | "hard";
|
||||
cost_per_mtok_out: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function rowToRecord(row: Row): ModelCapability {
|
||||
return {
|
||||
provider: row.provider,
|
||||
modelId: row.model_id,
|
||||
reasoning: row.reasoning,
|
||||
coding: row.coding,
|
||||
writing: row.writing,
|
||||
tier: row.tier,
|
||||
costPerMtokOut: row.cost_per_mtok_out,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function listModelCapabilities(): ModelCapability[] {
|
||||
return readAll<Row>(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities ORDER BY provider, model_id",
|
||||
).map(rowToRecord);
|
||||
}
|
||||
|
||||
function getModelCapability(provider: string, modelId: string): ModelCapability | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities WHERE provider = ? AND model_id = ?",
|
||||
)
|
||||
.get(provider, modelId) as Row | undefined;
|
||||
return row ? rowToRecord(row) : null;
|
||||
}
|
||||
|
||||
function upsertModelCapability(input: Omit<ModelCapability, "updatedAt">): void {
|
||||
const now = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO model_capabilities (provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, model_id) DO UPDATE SET
|
||||
reasoning = excluded.reasoning,
|
||||
coding = excluded.coding,
|
||||
writing = excluded.writing,
|
||||
tier = excluded.tier,
|
||||
cost_per_mtok_out = excluded.cost_per_mtok_out,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
input.provider,
|
||||
input.modelId,
|
||||
input.reasoning,
|
||||
input.coding,
|
||||
input.writing,
|
||||
input.tier,
|
||||
input.costPerMtokOut,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function deleteModelCapability(provider: string, modelId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM model_capabilities WHERE provider = ? AND model_id = ?")
|
||||
.run(provider, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the table from a list of rows. Used at boot when the table is empty
|
||||
* so a fresh install has something to dispatch against. Runs inside a
|
||||
* single transaction so either every row is written or none — we don't
|
||||
* want partial seeding if something goes wrong mid-loop.
|
||||
*/
|
||||
function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): number {
|
||||
return transaction(() => {
|
||||
let inserted = 0;
|
||||
const ts = nowIso();
|
||||
const stmt = getDb().prepare(
|
||||
`INSERT OR IGNORE INTO model_capabilities (
|
||||
provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const result = stmt.run(
|
||||
row.provider,
|
||||
row.modelId,
|
||||
row.reasoning,
|
||||
row.coding,
|
||||
row.writing,
|
||||
row.tier,
|
||||
row.costPerMtokOut,
|
||||
ts,
|
||||
);
|
||||
inserted += Number(result.changes ?? 0);
|
||||
}
|
||||
return inserted;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
listModelCapabilities,
|
||||
getModelCapability,
|
||||
upsertModelCapability,
|
||||
deleteModelCapability,
|
||||
seedModelCapabilities,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { type ReadAll, type SqliteRow, type Transaction, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* run_events helpers extracted from runs.ts. Every method is spread into
|
||||
* the final `buildRunMethods` result so AppDatabase call-sites remain
|
||||
* unchanged.
|
||||
*/
|
||||
export function buildRunEventMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
appendRunEvent(input: {
|
||||
companyId: string; runId: string; agentId: string;
|
||||
eventType: string; stream?: string | null; level?: string | null;
|
||||
message?: string | null; payload?: Record<string, unknown> | null;
|
||||
}): void {
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
const lastSeq = getDb().prepare(
|
||||
"SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?"
|
||||
).get(input.runId) as { maxSeq: number | null } | undefined;
|
||||
const seq = (lastSeq?.maxSeq ?? 0) + 1;
|
||||
getDb().prepare(
|
||||
"INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
input.companyId, input.runId, input.agentId, seq,
|
||||
input.eventType, input.stream ?? null, input.level ?? null,
|
||||
input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{
|
||||
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
||||
stream: string | null; level: string | null; message: string | null;
|
||||
payload: Record<string, unknown> | null; createdAt: string;
|
||||
}> {
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? 500;
|
||||
return readAll<SqliteRow>(
|
||||
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?",
|
||||
runId, companyId, limit, offset
|
||||
).map((row: SqliteRow) => ({
|
||||
id: row.id,
|
||||
runId: row.run_id,
|
||||
agentId: row.agent_id,
|
||||
seq: row.seq,
|
||||
eventType: row.event_type,
|
||||
stream: row.stream,
|
||||
level: row.level,
|
||||
message: row.message,
|
||||
payload: row.payload ? JSON.parse(row.payload) : null,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
},
|
||||
|
||||
listRunEventsAfter(runId: string, companyId: string, afterSeq: number, limit: number): Array<{
|
||||
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
||||
stream: string | null; level: string | null; message: string | null;
|
||||
payload: Record<string, unknown> | null; createdAt: string;
|
||||
}> {
|
||||
return readAll<SqliteRow>(
|
||||
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?",
|
||||
runId, companyId, afterSeq, limit
|
||||
).map((row: SqliteRow) => ({
|
||||
id: row.id,
|
||||
runId: row.run_id,
|
||||
agentId: row.agent_id,
|
||||
seq: row.seq,
|
||||
eventType: row.event_type,
|
||||
stream: row.stream,
|
||||
level: row.level,
|
||||
message: row.message,
|
||||
payload: row.payload ? JSON.parse(row.payload) : null,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { RunLogChunk } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Find the last complete UTF-8 character boundary in a buffer.
|
||||
* Returns the number of bytes up to (and including) the last complete character.
|
||||
* This prevents corrupting multi-byte CJK characters when slicing at chunk edges.
|
||||
*/
|
||||
export function findUtf8Boundary(buffer: Buffer, bytesRead: number): number {
|
||||
if (bytesRead === 0) return 0;
|
||||
let pos = bytesRead - 1;
|
||||
while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) {
|
||||
pos--;
|
||||
}
|
||||
if (pos < 0) return bytesRead;
|
||||
const lead = buffer[pos];
|
||||
let charLen: number;
|
||||
if ((lead & 0x80) === 0) charLen = 1;
|
||||
else if ((lead & 0xe0) === 0xc0) charLen = 2;
|
||||
else if ((lead & 0xf0) === 0xe0) charLen = 3;
|
||||
else if ((lead & 0xf8) === 0xf0) charLen = 4;
|
||||
else return bytesRead;
|
||||
if (pos + charLen <= bytesRead) return bytesRead;
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run log file-IO helpers extracted from runs.ts. Every method is spread
|
||||
* into the final `buildRunMethods` result so AppDatabase call-sites remain
|
||||
* unchanged. `writeFileSync` is re-exported indirectly through createRun
|
||||
* (still in runs.ts) via the shared logs directory.
|
||||
*/
|
||||
export function buildRunLogMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) {
|
||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||
if (!run?.logPath) return;
|
||||
return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8");
|
||||
},
|
||||
|
||||
readRunLog(runId: string) {
|
||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||
if (!run?.logPath) return "";
|
||||
try {
|
||||
return readFileSync(run.logPath, "utf8");
|
||||
} catch (err) {
|
||||
log.warn(`[database/runs] readFileSync failed for run ${runId} log ${run.logPath}:`, err instanceof Error ? err.message : String(err));
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
|
||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||
if (!run?.logPath || !existsSync(run.logPath)) {
|
||||
return { content: "", offset: 0, nextOffset: 0, totalBytes: 0, eof: true };
|
||||
}
|
||||
|
||||
const totalBytes = statSync(run.logPath).size;
|
||||
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
|
||||
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
|
||||
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
|
||||
|
||||
if (bytesToRead === 0) {
|
||||
return { content: "", offset: safeOffset, nextOffset: safeOffset, totalBytes, eof: true };
|
||||
}
|
||||
|
||||
const fileDescriptor = openSync(run.logPath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
|
||||
const safeBytesRead = findUtf8Boundary(buffer, bytesRead);
|
||||
const nextOffset = safeOffset + safeBytesRead;
|
||||
return {
|
||||
content: buffer.subarray(0, safeBytesRead).toString("utf8"),
|
||||
offset: safeOffset,
|
||||
nextOffset,
|
||||
totalBytes,
|
||||
eof: nextOffset >= totalBytes,
|
||||
};
|
||||
} finally {
|
||||
closeSync(fileDescriptor);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-exported so runs.ts can still use writeFileSync on run creation.
|
||||
export { writeFileSync };
|
||||
@@ -1,374 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ConnectorId, RunRecord, RunUsageStatus } from "@shared/types";
|
||||
import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers";
|
||||
import { buildRunLogMethods } from "./run-logs";
|
||||
import { buildRunEventMethods } from "./run-events";
|
||||
import { buildCostEntryMethods } from "./cost-entries";
|
||||
import { checkoutTaskInTransaction } from "./task-checkout";
|
||||
import { deriveLegacyRunUsageStatus } from "../run-usage-status";
|
||||
|
||||
class TaskCheckoutConflict extends Error {
|
||||
constructor(readonly taskId: string) {
|
||||
super(`Task checkout conflict: ${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRunMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
logsDir: string;
|
||||
},
|
||||
) {
|
||||
function createEmptyRunLog(logPath: string): void {
|
||||
writeFileSync(logPath, "", { encoding: "utf8", flag: "wx" });
|
||||
}
|
||||
|
||||
function cleanupEmptyRunLog(logPath: string): void {
|
||||
rmSync(logPath, { force: true });
|
||||
}
|
||||
|
||||
function insertRunRow(input: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
logPath: string;
|
||||
ts: string;
|
||||
}): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.companyId,
|
||||
input.taskId,
|
||||
input.agentId,
|
||||
input.workspaceId,
|
||||
input.connectorId,
|
||||
input.logPath,
|
||||
input.ts,
|
||||
input.ts,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...buildRunLogMethods(getDb, readAll, transaction),
|
||||
...buildRunEventMethods(getDb, readAll, transaction),
|
||||
...buildCostEntryMethods(getDb, readAll, transaction),
|
||||
|
||||
getRun(id: string): RunRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, cost_usd as costUsd, unattributed_cost as unattributedCost, usage_status as usageStatus, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs where id = ?",
|
||||
)
|
||||
.get(id) as RunRecord | undefined;
|
||||
if (!row) throw new Error(`Run not found: ${id}`);
|
||||
row.unattributedCost = Boolean(row.unattributedCost);
|
||||
return row;
|
||||
},
|
||||
|
||||
getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null {
|
||||
const row = getDb()
|
||||
.prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?")
|
||||
.get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
createRun(input: {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
}) {
|
||||
const id = input.id ?? randomUUID();
|
||||
const ts = nowIso();
|
||||
const logPath = join(deps.logsDir, `${id}.log`);
|
||||
createEmptyRunLog(logPath);
|
||||
try {
|
||||
transaction(() => {
|
||||
insertRunRow({ ...input, id, logPath, ts });
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupEmptyRunLog(logPath);
|
||||
throw error;
|
||||
}
|
||||
return { id, logPath };
|
||||
},
|
||||
|
||||
createTaskRun(input: {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
}): { id: string; logPath: string } | null {
|
||||
const id = input.id ?? randomUUID();
|
||||
const ts = nowIso();
|
||||
const logPath = join(deps.logsDir, `${id}.log`);
|
||||
createEmptyRunLog(logPath);
|
||||
try {
|
||||
transaction(() => {
|
||||
insertRunRow({ ...input, id, logPath, ts });
|
||||
const checkoutOk = checkoutTaskInTransaction(getDb(), input.taskId, id);
|
||||
if (!checkoutOk) {
|
||||
throw new TaskCheckoutConflict(input.taskId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupEmptyRunLog(logPath);
|
||||
if (error instanceof TaskCheckoutConflict) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { id, logPath };
|
||||
},
|
||||
|
||||
markRunStatus(runId: string, status: RunRecord["status"], message: string | null) {
|
||||
const ts = nowIso();
|
||||
const startedAt = status === "running" ? ts : null;
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? is not null then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run(status, message, message, startedAt, ts, runId);
|
||||
|
||||
if (status === "running") {
|
||||
const run = getDb().prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined;
|
||||
if (run) {
|
||||
getDb()
|
||||
.prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')")
|
||||
.run(ts, run.agent_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
finishRun(input: {
|
||||
runId: string;
|
||||
status: RunRecord["status"];
|
||||
summary: string | null;
|
||||
errorMessage: string | null;
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
model: string | null;
|
||||
unattributedCost: boolean;
|
||||
usageStatus?: RunUsageStatus;
|
||||
recoverTaskForRetry?: boolean;
|
||||
}) {
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId, started_at as startedAt from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId; startedAt: string | null }
|
||||
| undefined;
|
||||
if (!run) return { budgetHardStopped: false };
|
||||
// Recoverable terminal paths must roll the task back to 'todo'.
|
||||
// Otherwise the task is stuck in 'in_progress' with no owner:
|
||||
// checkoutTask refuses (WHERE status IN todo/backlog), and the
|
||||
// scheduler loops forever logging "lost checkout race".
|
||||
const runNeverStarted =
|
||||
run.startedAt === null && (input.status === "failed" || input.status === "cancelled");
|
||||
const shouldReturnTaskToTodo =
|
||||
run.taskId !== null && (runNeverStarted || input.recoverTaskForRetry === true);
|
||||
const ts = nowIso();
|
||||
const usageStatus = input.usageStatus ?? deriveLegacyRunUsageStatus({
|
||||
runStatus: input.status,
|
||||
unattributedCost: input.unattributedCost,
|
||||
});
|
||||
|
||||
// Cost is owned by CostWriter — per-message rows in `cost_entries`.
|
||||
// finishRun reads the aggregate instead of writing another row, so
|
||||
// there is exactly one source of truth and `cost_entries.run_id`
|
||||
// cannot double-count against the previous finishRun INSERT.
|
||||
const costRow = getDb()
|
||||
.prepare("select coalesce(sum(amount_usd), 0) as total from cost_entries where run_id = ? and unattributed = 0")
|
||||
.get(input.runId) as { total: number } | undefined;
|
||||
const runCostUsd = costRow?.total ?? 0;
|
||||
|
||||
const agent = getDb().prepare(
|
||||
"select budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, status from agents where id = ?",
|
||||
).get(run.agentId) as { budgetMonthlyUsd: number; spentMonthlyUsd: number; status: string } | undefined;
|
||||
const nextSpend = (agent?.spentMonthlyUsd ?? 0) + runCostUsd;
|
||||
const budgetHardStopped = Boolean(
|
||||
agent &&
|
||||
agent.budgetMonthlyUsd > 0 &&
|
||||
nextSpend >= agent.budgetMonthlyUsd &&
|
||||
agent.status !== "paused" &&
|
||||
agent.status !== "terminated",
|
||||
);
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = ?, error_message = ?, exit_code = ?, signal = ?, model = ?, cost_usd = ?, unattributed_cost = ?, usage_status = ?, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(
|
||||
input.status, input.summary ?? "", input.errorMessage, input.exitCode,
|
||||
input.signal, input.model, runCostUsd,
|
||||
input.unattributedCost ? 1 : 0, usageStatus, ts, ts, input.runId,
|
||||
);
|
||||
if (run.taskId !== null) {
|
||||
getDb()
|
||||
.prepare("delete from task_leases where task_id = ? and run_id = ?")
|
||||
.run(run.taskId, input.runId);
|
||||
if (shouldReturnTaskToTodo) {
|
||||
// Roll the task back to `todo` when no *other* live run claims
|
||||
// it. Invariant: `task.status = 'in_progress'` ⇒ a queued or
|
||||
// running run owns it through `task_leases`.
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo',
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'in_progress'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_leases l
|
||||
WHERE l.task_id = tasks.id
|
||||
AND l.run_id != ?
|
||||
)`,
|
||||
)
|
||||
.run(ts, run.taskId, input.runId);
|
||||
}
|
||||
}
|
||||
if (runCostUsd > 0) {
|
||||
if (run.taskId !== null) {
|
||||
getDb()
|
||||
.prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?")
|
||||
.run(runCostUsd, ts, run.taskId);
|
||||
}
|
||||
getDb()
|
||||
.prepare("update agents set spent_monthly_usd = spent_monthly_usd + ?, updated_at = ? where id = ?")
|
||||
.run(runCostUsd, ts, run.agentId);
|
||||
}
|
||||
if (budgetHardStopped) {
|
||||
getDb()
|
||||
.prepare("update agents set status = 'paused', updated_at = ? where id = ?")
|
||||
.run(ts, run.agentId);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'")
|
||||
.run(ts, run.agentId);
|
||||
}
|
||||
});
|
||||
return { budgetHardStopped };
|
||||
},
|
||||
|
||||
markRunCompletionFailure(runId: string, errorMessage: string): void {
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = 'failed', summary = ?, error_message = ?, usage_status = 'error_stop', finished_at = coalesce(finished_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run("Run completion blocked by artifact projection failure.", errorMessage, ts, ts, runId);
|
||||
});
|
||||
},
|
||||
|
||||
cancelRunForDeletion(runId: string, taskId: string | null, workspaceId: string | null, summary: string) {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, cost_usd = null, unattributed_cost = 1, usage_status = ?, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(summary, "aborted_before_usage", ts, ts, runId);
|
||||
if (taskId !== null) {
|
||||
getDb()
|
||||
.prepare("delete from task_leases where task_id = ? and run_id = ?")
|
||||
.run(taskId, runId);
|
||||
}
|
||||
if (workspaceId) {
|
||||
getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
|
||||
where id = ? and lock_holder_run_id = ?`,
|
||||
)
|
||||
.run(ts, workspaceId, runId);
|
||||
}
|
||||
},
|
||||
|
||||
hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) {
|
||||
if (filter.taskId) {
|
||||
const whereClauses = [
|
||||
"l.company_id = ?",
|
||||
"l.task_id = ?",
|
||||
"r.status in ('queued', 'running')",
|
||||
];
|
||||
const params: SQLInputValue[] = [filter.companyId, filter.taskId];
|
||||
if (filter.agentId) {
|
||||
whereClauses.push("r.agent_id = ?");
|
||||
params.push(filter.agentId);
|
||||
}
|
||||
const row = getDb().prepare(
|
||||
`select 1 as present
|
||||
from task_leases l
|
||||
join runs r on r.id = l.run_id
|
||||
where ${whereClauses.join(" and ")}
|
||||
limit 1`,
|
||||
).get(...params) as { present: number } | undefined;
|
||||
return Boolean(row?.present);
|
||||
}
|
||||
|
||||
const whereClauses = ["company_id = ?", "status in ('queued', 'running')"];
|
||||
const params: SQLInputValue[] = [filter.companyId];
|
||||
if (filter.agentId) {
|
||||
whereClauses.push("agent_id = ?");
|
||||
params.push(filter.agentId);
|
||||
}
|
||||
const row = getDb().prepare(
|
||||
`select 1 as present from runs where ${whereClauses.join(" and ")} limit 1`,
|
||||
).get(...params) as { present: number } | undefined;
|
||||
return Boolean(row?.present);
|
||||
},
|
||||
|
||||
listQueuedRuns() {
|
||||
return readAll<RunRecord>(
|
||||
`select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId,
|
||||
connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode,
|
||||
signal, model, cost_usd as costUsd,
|
||||
unattributed_cost as unattributedCost, usage_status as usageStatus, started_at as startedAt, finished_at as finishedAt,
|
||||
log_path as logPath, created_at as createdAt, updated_at as updatedAt
|
||||
from runs
|
||||
where status = 'queued'
|
||||
order by created_at asc`,
|
||||
).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[];
|
||||
},
|
||||
|
||||
getInterruptedRunsForRecovery(): Array<{
|
||||
runId: string; taskId: string; taskTitle: string; agentId: string;
|
||||
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
|
||||
}> {
|
||||
return readAll<{
|
||||
runId: string; taskId: string; taskTitle: string; agentId: string;
|
||||
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
|
||||
}>(
|
||||
`select r.id as runId, r.task_id as taskId, t.title as taskTitle,
|
||||
r.agent_id as agentId, a.name as agentName,
|
||||
r.company_id as companyId,
|
||||
r.connector_id as connectorId, r.finished_at as interruptedAt
|
||||
from runs r
|
||||
join tasks t on t.id = r.task_id
|
||||
join agents a on a.id = r.agent_id
|
||||
where r.status = 'interrupted'
|
||||
and t.status = 'blocked'
|
||||
order by r.finished_at desc`
|
||||
);
|
||||
},
|
||||
|
||||
updateRunActor(runId: string, actorType: string, actorId: string | null): void {
|
||||
getDb().prepare(
|
||||
"UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?"
|
||||
).run(actorType, actorId, nowIso(), runId);
|
||||
},
|
||||
};
|
||||
}
|
||||
+140
-164
@@ -1,173 +1,149 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import {
|
||||
BASELINE_SCHEMA_VERSION,
|
||||
assertBaselineSchema,
|
||||
installBaselineSchema,
|
||||
} from "./migrations/baseline-v56";
|
||||
import {
|
||||
markBaselineSchema,
|
||||
upgradeLegacyProfileToBaselineV56,
|
||||
} from "./migrations/legacy-to-v56";
|
||||
import {
|
||||
TASK_LEASE_SCHEMA_VERSION,
|
||||
assertTaskLeasesV57,
|
||||
migrateTaskLeasesV57,
|
||||
} from "./migrations/v57-task-leases";
|
||||
import {
|
||||
APPROVAL_POLICY_SCHEMA_VERSION,
|
||||
assertApprovalPoliciesV58,
|
||||
migrateApprovalPoliciesV58,
|
||||
} from "./migrations/v58-approval-policies";
|
||||
import {
|
||||
SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION,
|
||||
assertSocialCompanyScopeV59,
|
||||
migrateSocialCompanyScopeV59,
|
||||
} from "./migrations/v59-social-company-scope";
|
||||
import {
|
||||
FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION,
|
||||
assertFilesystemTaskWorkspacesV60,
|
||||
migrateFilesystemTaskWorkspacesV60,
|
||||
} from "./migrations/v60-filesystem-task-workspaces";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
} from "./migrations/types";
|
||||
import { initialEnterpriseSchema } from "./migrations/001-initial";
|
||||
import { collaborationProviderPolicySchema } from "./migrations/002-collaboration";
|
||||
import { onboardingExecutionSchema } from "./migrations/003-onboarding";
|
||||
import { filesPinnedAndScheduledPosts } from "./migrations/004-files-posts";
|
||||
import { chatReadState } from "./migrations/005-chat-read-state";
|
||||
import { appStateSchema } from "./migrations/006-app-state";
|
||||
import { skillHardeningSchema } from "./migrations/007-skill-hardening";
|
||||
import { providerHealthSchema } from "./migrations/008-provider-health";
|
||||
import { socialGovernanceSchema } from "./migrations/009-social-governance";
|
||||
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
|
||||
import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
||||
import { searchDocumentsSchema } from "./migrations/013-search-documents";
|
||||
import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage";
|
||||
import { identityAccessSchema } from "./migrations/015-identity-access";
|
||||
import { orgGovernanceSchema } from "./migrations/016-org-governance";
|
||||
import { operationsControlsSchema } from "./migrations/017-operations-controls";
|
||||
import { sdlcControlsSchema } from "./migrations/018-sdlc-controls";
|
||||
import { auditReplaySchema } from "./migrations/019-audit-replay";
|
||||
import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots";
|
||||
import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker";
|
||||
import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations";
|
||||
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
||||
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
|
||||
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
|
||||
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
|
||||
import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
|
||||
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
|
||||
import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records";
|
||||
import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs";
|
||||
import { providerRequestWorkspaceSchema } from "./migrations/031-provider-request-workspace";
|
||||
import { browserActionMetadataSchema } from "./migrations/032-browser-action-metadata";
|
||||
import { auditEventImmutabilitySchema } from "./migrations/033-audit-event-immutability";
|
||||
import { providerAuthSessionsSchema } from "./migrations/034-provider-auth-sessions";
|
||||
import { taskRunIsolationSchema } from "./migrations/035-task-run-isolation";
|
||||
import { additionalConnectorsSchema } from "./migrations/036-additional-connectors";
|
||||
import { customProvidersSchema } from "./migrations/037-custom-providers";
|
||||
import { providerAuthProfileCascadeSchema } from "./migrations/038-provider-auth-profile-cascade";
|
||||
import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immutability-org-teardown";
|
||||
import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation";
|
||||
import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete";
|
||||
import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints";
|
||||
import { composioIntegrationSchema } from "./migrations/043-composio-integration";
|
||||
import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state";
|
||||
import { connectorGrantsSchema } from "./migrations/045-connector-grants";
|
||||
|
||||
const LATEST_SCHEMA_VERSION = FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION;
|
||||
|
||||
export function initializeDatabase(
|
||||
db: DatabaseSync,
|
||||
ra: ReadAll,
|
||||
tx: Transaction,
|
||||
createAutomaticBackup: (reason: string) => string,
|
||||
): void {
|
||||
db.exec("create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null)");
|
||||
|
||||
const currentVersion = currentSchemaVersion(db);
|
||||
if (currentVersion > LATEST_SCHEMA_VERSION) {
|
||||
throw new Error(`Profile schema version ${currentVersion} is newer than this app schema ${LATEST_SCHEMA_VERSION}.`);
|
||||
}
|
||||
|
||||
if (currentVersion === LATEST_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
assertSocialCompanyScopeV59(db);
|
||||
assertFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
assertSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === APPROVAL_POLICY_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === TASK_LEASE_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === BASELINE_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
installBaselineSchema(db);
|
||||
markBaselineSchema(db);
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileHasApplicationTables(db)) {
|
||||
const backupPath = createAutomaticBackup(`pre-migration-v${BASELINE_SCHEMA_VERSION}`);
|
||||
log.info(`[schema] pre-migration snapshot for v${BASELINE_SCHEMA_VERSION} at ${backupPath}`);
|
||||
withForeignKeysPaused(db, tx, () => {
|
||||
upgradeLegacyProfileToBaselineV56({ db, readAll: ra });
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
tx(() => {
|
||||
installBaselineSchema(db);
|
||||
markBaselineSchema(db);
|
||||
seedSettings(db);
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
migrateFilesystemTaskWorkspacesV60(db);
|
||||
});
|
||||
interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export function seedSettings(db: DatabaseSync): void {
|
||||
const getSetting = (key: string): string | null => {
|
||||
const row = db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
};
|
||||
const setSetting = (key: string, value: string) => {
|
||||
db.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value").run(key, value);
|
||||
};
|
||||
if (!getSetting("theme")) setSetting("theme", "system");
|
||||
if (!getSetting("updates_enabled")) setSetting("updates_enabled", "false");
|
||||
if (!getSetting("log_retention_days")) setSetting("log_retention_days", "90");
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{ version: 1, name: "initial_enterprise_schema", sql: initialEnterpriseSchema },
|
||||
{ version: 2, name: "collaboration_provider_policy_schema", sql: collaborationProviderPolicySchema },
|
||||
{ version: 3, name: "onboarding_execution_schema", sql: onboardingExecutionSchema },
|
||||
{ version: 4, name: "files_pinned_and_scheduled_posts", sql: filesPinnedAndScheduledPosts },
|
||||
{ version: 5, name: "chat_read_state", sql: chatReadState },
|
||||
{ version: 6, name: "app_state", sql: appStateSchema },
|
||||
{ version: 7, name: "skill_hardening", sql: skillHardeningSchema },
|
||||
{ version: 8, name: "provider_health", sql: providerHealthSchema },
|
||||
{ version: 9, name: "social_governance", sql: socialGovernanceSchema },
|
||||
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
|
||||
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
|
||||
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
|
||||
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
|
||||
{ version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema },
|
||||
{ version: 15, name: "identity_access", sql: identityAccessSchema },
|
||||
{ version: 16, name: "org_governance", sql: orgGovernanceSchema },
|
||||
{ version: 17, name: "operations_controls", sql: operationsControlsSchema },
|
||||
{ version: 18, name: "sdlc_controls", sql: sdlcControlsSchema },
|
||||
{ version: 19, name: "audit_replay", sql: auditReplaySchema },
|
||||
{ version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema },
|
||||
{ version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema },
|
||||
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
|
||||
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
|
||||
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
|
||||
{ version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema },
|
||||
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
|
||||
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
|
||||
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
|
||||
{ version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema },
|
||||
{ version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema },
|
||||
{ version: 31, name: "provider_request_workspace", sql: providerRequestWorkspaceSchema },
|
||||
{ version: 32, name: "browser_action_metadata", sql: browserActionMetadataSchema },
|
||||
{ version: 33, name: "audit_event_immutability", sql: auditEventImmutabilitySchema },
|
||||
{ version: 34, name: "provider_auth_sessions", sql: providerAuthSessionsSchema },
|
||||
{ version: 35, name: "task_run_isolation", sql: taskRunIsolationSchema },
|
||||
{ version: 36, name: "additional_connectors", sql: additionalConnectorsSchema },
|
||||
{ version: 37, name: "custom_providers", sql: customProvidersSchema },
|
||||
{ version: 38, name: "provider_auth_profile_cascade", sql: providerAuthProfileCascadeSchema },
|
||||
{ version: 39, name: "audit_immutability_org_teardown", sql: auditImmutabilityOrgTeardownSchema },
|
||||
{ version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema },
|
||||
{ version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema },
|
||||
{ version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema },
|
||||
{ version: 43, name: "composio_integration", sql: composioIntegrationSchema },
|
||||
{ version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema },
|
||||
{ version: 45, name: "connector_grants", sql: connectorGrantsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
export function installSchema(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
create table if not exists schema_migrations (
|
||||
version integer primary key,
|
||||
name text not null,
|
||||
applied_at text not null
|
||||
);
|
||||
`);
|
||||
|
||||
const row = db.prepare("select coalesce(max(version), 0) as version from schema_migrations").get() as { version: number };
|
||||
let currentVersion = row.version;
|
||||
if (currentVersion > SCHEMA_VERSION) {
|
||||
throw new Error(`Database schema ${currentVersion} is newer than supported schema ${SCHEMA_VERSION}.`);
|
||||
}
|
||||
backfillMigrationLedger(db, currentVersion);
|
||||
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (currentVersion >= migration.version) continue;
|
||||
applyMigration(db, migration);
|
||||
currentVersion = migration.version;
|
||||
}
|
||||
}
|
||||
|
||||
function currentSchemaVersion(db: DatabaseSync): number {
|
||||
const row = db
|
||||
.prepare("select coalesce(max(version), 0) as version from schema_migrations")
|
||||
.get() as { version: number } | undefined;
|
||||
return row?.version ?? 0;
|
||||
}
|
||||
|
||||
function profileHasApplicationTables(db: DatabaseSync): boolean {
|
||||
const rows = db
|
||||
.prepare("select name from sqlite_master where type = 'table' and name not like 'sqlite_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
return rows.some((row) => row.name !== "schema_migrations" && row.name !== "settings");
|
||||
}
|
||||
|
||||
function withForeignKeysPaused<T>(db: DatabaseSync, tx: Transaction, op: () => T): T {
|
||||
const row = db.prepare("pragma foreign_keys").get() as { foreign_keys: number } | undefined;
|
||||
const previous = row?.foreign_keys === 1 ? "ON" : "OFF";
|
||||
db.exec("pragma foreign_keys = OFF");
|
||||
function applyMigration(db: DatabaseSync, migration: Migration): void {
|
||||
db.exec("begin immediate");
|
||||
try {
|
||||
return tx(op);
|
||||
} finally {
|
||||
db.exec(`pragma foreign_keys = ${previous}`);
|
||||
db.exec(migration.sql);
|
||||
recordMigration(db, migration);
|
||||
db.exec("commit");
|
||||
} catch (error) {
|
||||
db.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function backfillMigrationLedger(db: DatabaseSync, currentVersion: number): void {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (migration.version > currentVersion) return;
|
||||
recordMigration(db, migration);
|
||||
}
|
||||
}
|
||||
|
||||
function recordMigration(db: DatabaseSync, migration: Migration): void {
|
||||
db.prepare("insert or ignore into schema_migrations(version, name, applied_at) values (?, ?, datetime('now'))")
|
||||
.run(migration.version, migration.name);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* The `session_entries` table only ever stores one kind — full pi
|
||||
* `AgentMessage` envelopes emitted by `message_end`. Earlier builds
|
||||
* reserved `"compaction"` and `"summary"` variants for a supersede
|
||||
* pipeline that never shipped; their writers are gone and the column
|
||||
* holds `"message"` for every live row. Narrowing the type prevents
|
||||
* new code from writing a dead kind by accident.
|
||||
*/
|
||||
export type SessionEntryKind = "message";
|
||||
|
||||
export interface SessionEntryRow {
|
||||
id: string;
|
||||
taskSessionId: string;
|
||||
sequence: number;
|
||||
kind: SessionEntryKind;
|
||||
role: string | null;
|
||||
payloadJson: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppendInput {
|
||||
taskSessionId: string;
|
||||
kind: SessionEntryKind;
|
||||
role?: string | null;
|
||||
payloadJson: string;
|
||||
}
|
||||
|
||||
export function buildSessionEntryMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
function nextSequence(taskSessionId: string): number {
|
||||
const rows = readAll<{ maxSeq: number | null }>(
|
||||
"select max(sequence) as maxSeq from session_entries where task_session_id = ?",
|
||||
taskSessionId,
|
||||
);
|
||||
const max = rows[0]?.maxSeq;
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
function append(input: AppendInput): SessionEntryRow {
|
||||
const id = randomUUID();
|
||||
const sequence = nextSequence(input.taskSessionId);
|
||||
const created = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into session_entries (id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at) values (?, ?, ?, ?, ?, ?, 0, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.taskSessionId,
|
||||
sequence,
|
||||
input.kind,
|
||||
input.role ?? null,
|
||||
input.payloadJson,
|
||||
created,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
taskSessionId: input.taskSessionId,
|
||||
sequence,
|
||||
kind: input.kind,
|
||||
role: input.role ?? null,
|
||||
payloadJson: input.payloadJson,
|
||||
createdAt: created,
|
||||
};
|
||||
}
|
||||
|
||||
function listActive(taskSessionId: string): SessionEntryRow[] {
|
||||
const rows = readAll<{
|
||||
id: string;
|
||||
task_session_id: string;
|
||||
sequence: number;
|
||||
kind: SessionEntryKind;
|
||||
role: string | null;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
"select id, task_session_id, sequence, kind, role, payload_json, created_at from session_entries where task_session_id = ? and is_superseded = 0 order by sequence asc, created_at asc",
|
||||
taskSessionId,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
taskSessionId: row.task_session_id,
|
||||
sequence: row.sequence,
|
||||
kind: row.kind,
|
||||
role: row.role,
|
||||
payloadJson: row.payload_json,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function compactWithSummary(input: {
|
||||
taskSessionId: string;
|
||||
supersedeEntryIds: string[];
|
||||
summaryRole: string | null;
|
||||
summaryPayloadJson: string;
|
||||
}): SessionEntryRow {
|
||||
return transaction(() => {
|
||||
const uniqueIds = [...new Set(input.supersedeEntryIds)];
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new Error("session_entries compaction requires at least one entry to supersede.");
|
||||
}
|
||||
const placeholders = uniqueIds.map(() => "?").join(", ");
|
||||
const rows = readAll<{ id: string; sequence: number }>(
|
||||
`select id, sequence from session_entries where task_session_id = ? and is_superseded = 0 and id in (${placeholders}) order by sequence asc`,
|
||||
input.taskSessionId,
|
||||
...(uniqueIds as SQLInputValue[]),
|
||||
);
|
||||
if (rows.length !== uniqueIds.length) {
|
||||
throw new Error(`session_entries compaction expected ${uniqueIds.length} active row(s), found ${rows.length}.`);
|
||||
}
|
||||
const summarySequence = rows[0]?.sequence;
|
||||
if (summarySequence === undefined) {
|
||||
throw new Error("session_entries compaction could not resolve summary sequence.");
|
||||
}
|
||||
getDb()
|
||||
.prepare(`update session_entries set is_superseded = 1 where task_session_id = ? and id in (${placeholders})`)
|
||||
.run(input.taskSessionId, ...(uniqueIds as SQLInputValue[]));
|
||||
|
||||
const id = randomUUID();
|
||||
const created = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into session_entries (id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at) values (?, ?, ?, 'message', ?, ?, 0, ?)",
|
||||
)
|
||||
.run(id, input.taskSessionId, summarySequence, input.summaryRole, input.summaryPayloadJson, created);
|
||||
return {
|
||||
id,
|
||||
taskSessionId: input.taskSessionId,
|
||||
sequence: summarySequence,
|
||||
kind: "message",
|
||||
role: input.summaryRole,
|
||||
payloadJson: input.summaryPayloadJson,
|
||||
createdAt: created,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { append, listActive, compactWithSummary };
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve, sep } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
|
||||
function isWithinOrEqual(targetPath: string, rootPath: string) {
|
||||
const resolvedTarget = resolve(targetPath);
|
||||
const resolvedRoot = resolve(rootPath);
|
||||
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`);
|
||||
}
|
||||
|
||||
function safeRealpath(targetPath: string) {
|
||||
try {
|
||||
return realpathSync(targetPath);
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
`[db/settings] realpathSync fallback for ${targetPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSettingsMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
deps: {
|
||||
profileDir: string;
|
||||
logsDir: string;
|
||||
artifactsDir: string;
|
||||
backupsDir: string;
|
||||
},
|
||||
) {
|
||||
function getSetting(key: string): string | null {
|
||||
const row = getDb().prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
function setSetting(key: string, value: string) {
|
||||
getDb()
|
||||
.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value")
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
return {
|
||||
getSetting,
|
||||
setSetting,
|
||||
|
||||
getCurrentCompanyId() {
|
||||
return getSetting("current_company_id");
|
||||
},
|
||||
|
||||
setCurrentCompany(companyId: string) {
|
||||
setSetting("current_company_id", companyId);
|
||||
},
|
||||
|
||||
updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) {
|
||||
if (input.theme) {
|
||||
setSetting("theme", input.theme);
|
||||
}
|
||||
if (input.locale) {
|
||||
setSetting("locale", input.locale);
|
||||
}
|
||||
if (typeof input.updatesEnabled === "boolean") {
|
||||
setSetting("updates_enabled", input.updatesEnabled ? "true" : "false");
|
||||
}
|
||||
if (typeof input.logRetentionDays === "number") {
|
||||
setSetting("log_retention_days", String(input.logRetentionDays));
|
||||
}
|
||||
},
|
||||
|
||||
getLogRetentionDays(): number {
|
||||
const raw = getSetting("log_retention_days");
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(parsed) && parsed >= 1 ? parsed : 90;
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-(company, connector) disabled model list.
|
||||
*
|
||||
* Pre-v42 the key-value `settings` table stored one global entry
|
||||
* per connector (`disabled_models:${connectorId}`). That let the
|
||||
* setting leak across companies — deleting a company did not
|
||||
* clear the pool config, and new companies inherited the old
|
||||
* selections. v42 moved the data to `connector_model_pool_settings`
|
||||
* with `company_id` in the primary key and `ON DELETE CASCADE`
|
||||
* from companies, so company removal wipes pool config by
|
||||
* construction.
|
||||
*/
|
||||
getDisabledModels(companyId: string, connectorId: string): Array<{ id: string; label: string }> {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT disabled_models_json AS json FROM connector_model_pool_settings WHERE company_id = ? AND connector_id = ?",
|
||||
)
|
||||
.get(companyId, connectorId) as { json: string } | undefined;
|
||||
if (!row) return [];
|
||||
try {
|
||||
return JSON.parse(row.json);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
setDisabledModels(
|
||||
companyId: string,
|
||||
connectorId: string,
|
||||
models: Array<{ id: string; label: string }>,
|
||||
) {
|
||||
const ts = new Date().toISOString();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO connector_model_pool_settings (company_id, connector_id, disabled_models_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(company_id, connector_id) DO UPDATE SET
|
||||
disabled_models_json = excluded.disabled_models_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(companyId, connectorId, JSON.stringify(models), ts);
|
||||
},
|
||||
|
||||
hasModelPoolSettings(companyId: string, connectorId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT 1 AS found FROM connector_model_pool_settings WHERE company_id = ? AND connector_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, connectorId) as { found: number } | undefined;
|
||||
return !!row;
|
||||
},
|
||||
|
||||
getLastBudgetResetMonth(companyId: string): string | null {
|
||||
return getSetting(`budget_reset_month_${companyId}`);
|
||||
},
|
||||
|
||||
setLastBudgetResetMonth(companyId: string, month: string) {
|
||||
setSetting(`budget_reset_month_${companyId}`, month);
|
||||
},
|
||||
|
||||
purgeExpiredRunLogs(): number {
|
||||
const raw = getSetting("log_retention_days");
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
const retentionDays = Number.isFinite(parsed) && parsed >= 1 ? parsed : 90;
|
||||
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
||||
const expiredRuns = readAll<{ id: string; logPath: string | null }>(
|
||||
"select id, log_path as logPath from runs where finished_at is not null and finished_at < ? and log_path is not null",
|
||||
cutoff,
|
||||
);
|
||||
let purged = 0;
|
||||
for (const run of expiredRuns) {
|
||||
if (run.logPath) {
|
||||
try {
|
||||
unlinkSync(run.logPath);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[db/settings] purgeExpiredRunLogs: unlink failed for run ${run.id} (${run.logPath}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
getDb()
|
||||
.prepare("update runs set log_path = null, updated_at = ? where id = ?")
|
||||
.run(nowIso(), run.id);
|
||||
purged++;
|
||||
}
|
||||
return purged;
|
||||
},
|
||||
|
||||
canOpenPath(targetPath: string, companyId: string) {
|
||||
if (!targetPath || !existsSync(targetPath)) {
|
||||
return false;
|
||||
}
|
||||
const managedRoots = [deps.profileDir, deps.logsDir, deps.artifactsDir, deps.backupsDir];
|
||||
const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]);
|
||||
const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]);
|
||||
const resolvedTarget = safeRealpath(targetPath);
|
||||
const targetStats = lstatSync(targetPath);
|
||||
const targetExtension = extname(resolvedTarget).toLowerCase();
|
||||
if (
|
||||
managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) &&
|
||||
sensitiveProfileExtensions.has(targetExtension)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) {
|
||||
return false;
|
||||
}
|
||||
const workspaces = readAll<{ localPath: string }>(
|
||||
"select local_path as localPath from workspaces where company_id = ?",
|
||||
companyId,
|
||||
);
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) {
|
||||
return true;
|
||||
}
|
||||
const runLogs = readAll<{ logPath: string | null }>(
|
||||
"select log_path as logPath from runs where company_id = ? and log_path is not null",
|
||||
companyId,
|
||||
);
|
||||
if (runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false)) {
|
||||
return true;
|
||||
}
|
||||
const browserScreenshots = readAll<{ screenshotPath: string | null }>(
|
||||
"select screenshot_path as screenshotPath from browser_actions where company_id = ? and screenshot_path is not null",
|
||||
companyId,
|
||||
);
|
||||
return browserScreenshots.some((action) => action.screenshotPath ? safeRealpath(action.screenshotPath) === resolvedTarget : false);
|
||||
},
|
||||
|
||||
hasCompany(companyId: string) {
|
||||
const row = getDb().prepare("select id from companies where id = ?").get(companyId) as { id: string } | undefined;
|
||||
return Boolean(row);
|
||||
},
|
||||
|
||||
belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) {
|
||||
const queries: Record<string, string> = {
|
||||
workspaces: "select 1 as present from workspaces where id = ? and company_id = ?",
|
||||
agents: "select 1 as present from agents where id = ? and company_id = ?",
|
||||
goals: "select 1 as present from goals where id = ? and company_id = ?",
|
||||
projects: "select 1 as present from projects where id = ? and company_id = ?",
|
||||
tasks: "select 1 as present from tasks where id = ? and company_id = ?",
|
||||
approvals: "select 1 as present from approvals where id = ? and company_id = ?",
|
||||
runs: "select 1 as present from runs where id = ? and company_id = ?",
|
||||
secrets: "select 1 as present from secrets where id = ? and company_id = ?",
|
||||
meetings: "select 1 as present from meetings where id = ? and company_id = ?",
|
||||
documents: "select 1 as present from documents where id = ? and company_id = ?",
|
||||
knowledge_base: "select 1 as present from knowledge_base where id = ? and company_id = ?",
|
||||
sprints: "select 1 as present from sprints where id = ? and company_id = ?",
|
||||
automation_rules: "select 1 as present from automation_rules where id = ? and company_id = ?",
|
||||
workflow_pipelines: "select 1 as present from workflow_pipelines where id = ? and company_id = ?",
|
||||
};
|
||||
const sql = queries[table];
|
||||
if (!sql) throw new Error(`Unknown table: ${table}`);
|
||||
const row = getDb().prepare(sql).get(id, companyId) as { present: number } | undefined;
|
||||
return Boolean(row?.present);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "../skill-types.js";
|
||||
import type { ReadAll, SqliteRow } from "./helpers";
|
||||
import { nowIso, parseJson } from "./helpers";
|
||||
|
||||
export function buildSkillMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
function assignmentTargetsBelongToCompany(companyId: string, agentId: string, skillId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(`
|
||||
SELECT 1 AS present
|
||||
FROM agents a
|
||||
JOIN company_skills cs ON cs.id = ?
|
||||
WHERE a.id = ?
|
||||
AND a.company_id = ?
|
||||
AND cs.company_id = ?
|
||||
LIMIT 1
|
||||
`)
|
||||
.get(skillId, agentId, companyId, companyId) as { present: number } | undefined;
|
||||
return Boolean(row?.present);
|
||||
}
|
||||
|
||||
function mapSkillRow(row: SqliteRow): SkillRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
key: row.key,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
sourceType: (row.source_type ?? "local_path") as SkillSourceType,
|
||||
sourceLocator: row.source_locator ?? null,
|
||||
sourceRef: row.source_ref ?? null,
|
||||
trustLevel: (row.trust_level ?? "markdown_only") as TrustLevel,
|
||||
compatibility: row.compatibility ?? "compatible",
|
||||
fileInventory: parseJson<FileInventoryEntry[]>(row.file_inventory, []),
|
||||
metadata: parseJson<Record<string, unknown> | null>(row.metadata, null),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
upsertCompanySkill(skill: {
|
||||
id: string; companyId: string; key: string; slug: string; name: string;
|
||||
description: string | null; sourceType: string;
|
||||
sourceLocator: string | null; sourceRef: string | null; trustLevel: string;
|
||||
compatibility: string; fileInventory: string; metadata: string | null;
|
||||
createdAt: string; updatedAt: string;
|
||||
}): { isUpdate: boolean } {
|
||||
const existing = getDb()
|
||||
.prepare("SELECT 1 FROM company_skills WHERE company_id = ? AND key = ?")
|
||||
.get(skill.companyId, skill.key);
|
||||
const isUpdate = existing != null;
|
||||
|
||||
getDb().prepare(`
|
||||
INSERT INTO company_skills (
|
||||
id, company_id, key, slug, name, description,
|
||||
source_type, source_locator, source_ref, trust_level,
|
||||
compatibility, file_inventory, metadata, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(company_id, key) DO UPDATE SET
|
||||
slug = excluded.slug,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
source_ref = excluded.source_ref,
|
||||
trust_level = excluded.trust_level,
|
||||
file_inventory = excluded.file_inventory,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
skill.id, skill.companyId, skill.key, skill.slug,
|
||||
skill.name, skill.description,
|
||||
skill.sourceType, skill.sourceLocator, skill.sourceRef,
|
||||
skill.trustLevel, skill.compatibility, skill.fileInventory,
|
||||
skill.metadata, skill.createdAt, skill.updatedAt,
|
||||
);
|
||||
|
||||
return { isUpdate };
|
||||
},
|
||||
|
||||
listCompanySkills(companyId: string): SkillRecord[] {
|
||||
const rows = readAll<SqliteRow>(
|
||||
"SELECT * FROM company_skills WHERE company_id = ? ORDER BY created_at ASC",
|
||||
companyId,
|
||||
);
|
||||
return rows.map((row) => mapSkillRow(row));
|
||||
},
|
||||
|
||||
getCompanySkill(companyId: string, skillId: string): SkillRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM company_skills WHERE id = ? AND company_id = ?")
|
||||
.get(skillId, companyId) as SqliteRow | undefined;
|
||||
return row ? mapSkillRow(row) : null;
|
||||
},
|
||||
|
||||
deleteCompanySkill(companyId: string, skillId: string): boolean {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM company_skills WHERE id = ? AND company_id = ?")
|
||||
.run(skillId, companyId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
assignSkillToAgent(companyId: string, agentId: string, skillId: string): boolean {
|
||||
if (!assignmentTargetsBelongToCompany(companyId, agentId, skillId)) {
|
||||
return false;
|
||||
}
|
||||
getDb()
|
||||
.prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)")
|
||||
.run(agentId, skillId, nowIso());
|
||||
return true;
|
||||
},
|
||||
|
||||
removeSkillFromAgent(companyId: string, agentId: string, skillId: string): boolean {
|
||||
if (!assignmentTargetsBelongToCompany(companyId, agentId, skillId)) {
|
||||
return false;
|
||||
}
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?")
|
||||
.run(agentId, skillId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
listAgentSkillAssignments(companyId: string, agentId: string): string[] {
|
||||
return readAll<{ skill_id: string }>(
|
||||
`SELECT asa.skill_id
|
||||
FROM agent_skill_assignments asa
|
||||
JOIN agents a ON a.id = asa.agent_id
|
||||
JOIN company_skills cs ON cs.id = asa.skill_id
|
||||
WHERE asa.agent_id = ?
|
||||
AND a.company_id = ?
|
||||
AND cs.company_id = ?
|
||||
ORDER BY cs.created_at ASC`,
|
||||
agentId,
|
||||
companyId,
|
||||
companyId,
|
||||
).map((row) => row.skill_id);
|
||||
},
|
||||
|
||||
listSkillAgentAssignments(companyId: string, skillId: string): string[] {
|
||||
return readAll<{ agent_id: string }>(
|
||||
`SELECT asa.agent_id
|
||||
FROM agent_skill_assignments asa
|
||||
JOIN agents a ON a.id = asa.agent_id
|
||||
JOIN company_skills cs ON cs.id = asa.skill_id
|
||||
WHERE asa.skill_id = ?
|
||||
AND a.company_id = ?
|
||||
AND cs.company_id = ?
|
||||
ORDER BY a.created_at ASC`,
|
||||
skillId,
|
||||
companyId,
|
||||
companyId,
|
||||
).map((row) => row.agent_id);
|
||||
},
|
||||
|
||||
updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string): void {
|
||||
const ts = nowIso();
|
||||
getDb().prepare(
|
||||
"UPDATE company_skills SET metadata = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(metadataJson, ts, skillId, companyId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CompanyRecord,
|
||||
CommentRecord,
|
||||
ConnectorRecord,
|
||||
CostRecord,
|
||||
GoalRecord,
|
||||
ProfileSnapshot,
|
||||
ProjectRecord,
|
||||
RunRecord,
|
||||
SecretRecord,
|
||||
TaskRecord,
|
||||
WorkspaceRecord,
|
||||
} from "@shared/types";
|
||||
import { activeRunIdSql, BOARD_READER_ID, type ReadAll, type SqliteRow, toBoolean } from "./helpers";
|
||||
|
||||
export function buildSnapshotMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
deps: {
|
||||
getCurrentCompanyId: () => string | null;
|
||||
getSetting: (key: string) => string | null;
|
||||
readConnectors: () => ConnectorRecord[];
|
||||
profileDir: string;
|
||||
vaultBackendLabel: () => string;
|
||||
},
|
||||
) {
|
||||
function listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot {
|
||||
const companyId = options.companyId ?? null;
|
||||
const companyWhere = companyId ? " where company_id = ?" : "";
|
||||
const companyArgs: SQLInputValue[] = companyId ? [companyId] : [];
|
||||
const boardReadReceiptParams: SQLInputValue[] = companyId ? [BOARD_READER_ID, companyId] : [BOARD_READER_ID];
|
||||
const connectors = deps.readConnectors();
|
||||
const theme = (deps.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system";
|
||||
const locale = (deps.getSetting("locale") as "en" | "zh" | null) ?? "en";
|
||||
// AgentMessageRecord.readAt is a per-viewer field — its meaning is
|
||||
// "has the current viewer acknowledged this message?". The renderer
|
||||
// view has exactly one human viewer (the Board operator), so the
|
||||
// renderer snapshot fills it from that reader's agent_message_reads
|
||||
// rows. Agent-side queries (listAgentMessages) resolve it against
|
||||
// the current agent's receipts. The old `agent_messages.read_at`
|
||||
// column was dropped in migration v49 — the join table is the
|
||||
// sole source of truth.
|
||||
const boardReadReceiptMap = new Map(
|
||||
readAll<{ messageId: string; readAt: string }>(
|
||||
`select reads.message_id as messageId, reads.read_at as readAt
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ?${companyId ? " and messages.company_id = ?" : ""}`,
|
||||
...boardReadReceiptParams,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
return {
|
||||
currentCompanyId: deps.getCurrentCompanyId(),
|
||||
profilePath: deps.profileDir,
|
||||
updatesEnabled: deps.getSetting("updates_enabled") !== "false",
|
||||
theme,
|
||||
locale,
|
||||
backend: {
|
||||
secretBackend: deps.vaultBackendLabel(),
|
||||
},
|
||||
companies: readAll<CompanyRecord>(
|
||||
"select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, execution_mode as executionMode, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc",
|
||||
).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[],
|
||||
connectors,
|
||||
workspaces: readAll<WorkspaceRecord>(
|
||||
`select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
|
||||
agents: readAll<SqliteRow>(
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
|
||||
goals: readAll<GoalRecord>(
|
||||
`select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
),
|
||||
projects: readAll<ProjectRecord>(
|
||||
`select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
),
|
||||
tasks: readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), requiresFilesystem: toBoolean(record.requiresFilesystem), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
|
||||
approvals: readAll<ApprovalRecord>(
|
||||
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc limit 1000`,
|
||||
...companyArgs,
|
||||
),
|
||||
runs: readAll<RunRecord>(
|
||||
`select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, cost_usd as costUsd, unattributed_cost as unattributedCost, usage_status as usageStatus, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs${companyWhere} order by created_at desc limit ${companyId ? 2000 : 2000}`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, unattributedCost: Boolean(record.unattributedCost) })) as RunRecord[],
|
||||
costs: readAll<CostRecord>(
|
||||
`select id, company_id as companyId, run_id as runId, task_id as taskId, agent_id as agentId, connector_id as connectorId, amount_usd as amountUsd, unattributed, created_at as createdAt from cost_entries${companyWhere} order by created_at desc limit ${companyId ? 2000 : 2000}`,
|
||||
...companyArgs,
|
||||
).map((entry) => ({ ...entry, unattributed: Boolean(entry.unattributed) })) as CostRecord[],
|
||||
activity: readAll<ActivityRecord>(
|
||||
`select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity${companyWhere} order by created_at desc limit ${companyId ? 1000 : 1000}`,
|
||||
...companyArgs,
|
||||
),
|
||||
secrets: readAll<SecretRecord>(
|
||||
`select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
),
|
||||
comments: readAll<CommentRecord>(
|
||||
`select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
),
|
||||
socialAccounts: readAll<SqliteRow>(
|
||||
`select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, platform: r.platform, accountName: r.account_name,
|
||||
displayName: r.display_name, profileUrl: r.profile_url, credentialSecretId: r.credential_secret_id,
|
||||
status: r.status, requireApproval: toBoolean(r.require_approval), metadataJson: r.metadata_json,
|
||||
lastUsedAt: r.last_used_at, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
meetings: readAll<SqliteRow>(
|
||||
`select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings${companyWhere} order by scheduled_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, type: r.type, title: r.title, organizerAgentId: r.organizer_agent_id,
|
||||
participantAgentIds: r.participant_agent_ids, scheduledAt: r.scheduled_at, durationMinutes: r.duration_minutes,
|
||||
agendaJson: r.agenda_json, notesJson: r.notes_json, decisionsJson: r.decisions_json,
|
||||
actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
documents: readAll<SqliteRow>(
|
||||
`select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content,
|
||||
authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id,
|
||||
goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status,
|
||||
tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
// Archived rows stay in the table as tombstones for audit but
|
||||
// never surface as deliverables — the DocumentCenter and every
|
||||
// downstream consumer read this snapshot, so the filter is
|
||||
// pinned here as the canonical visibility gate.
|
||||
workProducts: readAll<SqliteRow>(
|
||||
`select id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at from work_products${companyWhere}${companyWhere ? " and" : " where"} status <> 'archived' order by updated_at desc limit 500`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
workspaceId: r.workspace_id ?? null,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
lastTouchedByRunId: r.last_touched_by_run_id ?? null,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
knowledgeBase: readAll<SqliteRow>(
|
||||
`select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, category: r.category, topic: r.topic, content: r.content,
|
||||
authorAgentId: r.author_agent_id, importance: r.importance, tagsJson: r.tags_json,
|
||||
referencedEntityType: r.referenced_entity_type, referencedEntityId: r.referenced_entity_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
sprints: readAll<SqliteRow>(
|
||||
`select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints${companyWhere} order by start_date desc limit 500`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, name: r.name, goal: r.goal,
|
||||
startDate: r.start_date, endDate: r.end_date, status: r.status,
|
||||
retrospectiveNotes: r.retrospective_notes, velocityPoints: r.velocity_points,
|
||||
completedPoints: r.completed_points, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
automationRules: readAll<SqliteRow>(
|
||||
`select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules${companyWhere} order by priority desc, created_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, name: r.name, description: r.description,
|
||||
trigger: r.trigger, conditionsJson: r.conditions_json, action: r.action,
|
||||
actionConfigJson: r.action_config_json, sourceDepartment: r.source_department,
|
||||
targetDepartment: r.target_department, priority: r.priority, status: r.status,
|
||||
executionCount: r.execution_count, lastExecutedAt: r.last_executed_at,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
agentMessages: readAll<SqliteRow>(
|
||||
// readAt semantics live in the agent_message_reads receipts
|
||||
// table, resolved against the current viewer above. The old
|
||||
// `agent_messages.read_at` column was dropped in v49.
|
||||
`select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc limit 1000`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id,
|
||||
toAgentId: r.to_agent_id, senderKind: r.sender_kind ?? "agent",
|
||||
channel: r.channel, channelTargetId: r.channel_target_id,
|
||||
subject: r.subject, body: r.body, priority: r.priority,
|
||||
readAt: boardReadReceiptMap.get(r.id) ?? null,
|
||||
parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
workflows: readAll<SqliteRow>(
|
||||
`select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, name: r.name, description: r.description,
|
||||
stepsJson: r.steps_json, triggerType: r.trigger_type, triggerConfigJson: r.trigger_config_json,
|
||||
status: r.status, currentStepIndex: r.current_step_index, runCount: r.run_count,
|
||||
lastRunAt: r.last_run_at, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
listSnapshot,
|
||||
|
||||
listRendererSnapshot(): ProfileSnapshot {
|
||||
const currentCompanyId = deps.getCurrentCompanyId();
|
||||
return currentCompanyId
|
||||
? listSnapshot({ companyId: currentCompanyId })
|
||||
: listSnapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { BrowserActionRecord, SocialAccountRecord } from "@shared/types";
|
||||
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
||||
import { ensureId, normalizeOptionalId, nowIso, toBoolean } from "./helpers";
|
||||
|
||||
export class SocialAccountScopeError extends Error {
|
||||
constructor(
|
||||
public readonly reference: "social_account" | "credential_secret",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SocialAccountScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSocialMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
saveSocialAccount(input: {
|
||||
id?: string | null;
|
||||
companyId: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
displayName?: string;
|
||||
profileUrl?: string;
|
||||
credentialSecretId?: string | null;
|
||||
status?: string;
|
||||
requireApproval?: boolean;
|
||||
metadataJson?: string;
|
||||
}): string {
|
||||
return transaction(() => {
|
||||
const id = ensureId(input.id);
|
||||
const now = nowIso();
|
||||
const credentialSecretId = normalizeOptionalId(input.credentialSecretId);
|
||||
const existing = getDb()
|
||||
.prepare("SELECT id, company_id AS companyId FROM social_accounts WHERE id = ? LIMIT 1")
|
||||
.get(id) as { id: string; companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new SocialAccountScopeError("social_account", "Social account does not belong to the current company");
|
||||
}
|
||||
if (credentialSecretId) {
|
||||
const secret = getDb()
|
||||
.prepare("SELECT id FROM secrets WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(credentialSecretId, input.companyId) as { id: string } | undefined;
|
||||
if (!secret) {
|
||||
throw new SocialAccountScopeError("credential_secret", "Credential secret does not belong to the current company");
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
getDb().prepare(`UPDATE social_accounts
|
||||
SET platform = ?,
|
||||
account_name = ?,
|
||||
display_name = ?,
|
||||
profile_url = ?,
|
||||
credential_secret_id = ?,
|
||||
status = ?,
|
||||
require_approval = ?,
|
||||
metadata_json = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND company_id = ?`).run(
|
||||
input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
credentialSecretId, input.status ?? "login_required",
|
||||
input.requireApproval !== false ? 1 : 0,
|
||||
input.metadataJson ?? "{}", now,
|
||||
id, input.companyId,
|
||||
);
|
||||
} else {
|
||||
getDb().prepare(`INSERT INTO social_accounts (id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, input.companyId, input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
credentialSecretId, input.status ?? "login_required",
|
||||
input.requireApproval !== false ? 1 : 0,
|
||||
input.metadataJson ?? "{}", now, now,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
},
|
||||
|
||||
deleteSocialAccount(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb().prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId);
|
||||
getDb().prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
listSocialAccounts(companyId: string): SocialAccountRecord[] {
|
||||
const rows = getDb().prepare("SELECT * FROM social_accounts WHERE company_id = ? ORDER BY created_at DESC").all(companyId) as SqliteRow[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id, companyId: r.company_id, platform: r.platform,
|
||||
accountName: r.account_name, displayName: r.display_name,
|
||||
profileUrl: r.profile_url, credentialSecretId: r.credential_secret_id,
|
||||
status: r.status, requireApproval: toBoolean(r.require_approval),
|
||||
metadataJson: r.metadata_json, lastUsedAt: r.last_used_at,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
getSocialAccount(id: string, companyId: string): SocialAccountRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM social_accounts WHERE id = ? AND company_id = ?")
|
||||
.get(id, companyId) as SqliteRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, companyId: row.company_id, platform: row.platform,
|
||||
accountName: row.account_name, displayName: row.display_name,
|
||||
profileUrl: row.profile_url, credentialSecretId: row.credential_secret_id,
|
||||
status: row.status, requireApproval: toBoolean(row.require_approval),
|
||||
metadataJson: row.metadata_json, lastUsedAt: row.last_used_at,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
};
|
||||
},
|
||||
|
||||
saveBrowserAction(input: {
|
||||
companyId: string;
|
||||
socialAccountId: string;
|
||||
agentId: string;
|
||||
taskId?: string | null;
|
||||
actionType: string;
|
||||
payloadJson: string;
|
||||
status?: string;
|
||||
approvalId?: string | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const socialAccount = getDb()
|
||||
.prepare("SELECT id FROM social_accounts WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(input.socialAccountId, input.companyId) as { id: string } | undefined;
|
||||
if (!socialAccount) {
|
||||
throw new SocialAccountScopeError("social_account", "Browser action social account does not belong to the current company");
|
||||
}
|
||||
getDb().prepare(`INSERT INTO browser_actions (id, company_id, social_account_id, agent_id, task_id, action_type, payload_json, status, approval_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, input.companyId, input.socialAccountId, input.agentId,
|
||||
input.taskId ?? null, input.actionType, input.payloadJson,
|
||||
input.status ?? "queued", input.approvalId ?? null, now, now,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
updateBrowserAction(id: string, updates: { status?: string; resultSummary?: string; screenshotPath?: string | null; errorMessage?: string | null; startedAt?: string; finishedAt?: string }) {
|
||||
const now = nowIso();
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: SQLInputValue[] = [now];
|
||||
if (updates.status) { sets.push("status = ?"); values.push(updates.status); }
|
||||
if (updates.resultSummary !== undefined) { sets.push("result_summary = ?"); values.push(updates.resultSummary); }
|
||||
if (updates.screenshotPath !== undefined) { sets.push("screenshot_path = ?"); values.push(updates.screenshotPath); }
|
||||
if (updates.errorMessage !== undefined) { sets.push("error_message = ?"); values.push(updates.errorMessage); }
|
||||
if (updates.startedAt) { sets.push("started_at = ?"); values.push(updates.startedAt); }
|
||||
if (updates.finishedAt) { sets.push("finished_at = ?"); values.push(updates.finishedAt); }
|
||||
values.push(id);
|
||||
getDb().prepare(`UPDATE browser_actions SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
||||
},
|
||||
|
||||
listBrowserActions(companyId: string, filters?: { socialAccountId?: string; status?: string; limit?: number }): BrowserActionRecord[] {
|
||||
let sql = "SELECT * FROM browser_actions WHERE company_id = ?";
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
if (filters?.socialAccountId) { sql += " AND social_account_id = ?"; params.push(filters.socialAccountId); }
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").filter(Boolean);
|
||||
if (statuses.length > 0) {
|
||||
sql += ` AND status IN (${statuses.map(() => "?").join(",")})`;
|
||||
params.push(...statuses);
|
||||
}
|
||||
}
|
||||
sql += " ORDER BY created_at DESC";
|
||||
if (filters?.limit) { sql += " LIMIT ?"; params.push(filters.limit); }
|
||||
|
||||
const rows = getDb().prepare(sql).all(...params) as SqliteRow[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id, companyId: r.company_id, socialAccountId: r.social_account_id,
|
||||
agentId: r.agent_id, taskId: r.task_id, status: r.status,
|
||||
actionType: r.action_type, payloadJson: r.payload_json,
|
||||
resultSummary: r.result_summary, screenshotPath: r.screenshot_path,
|
||||
approvalId: r.approval_id, errorMessage: r.error_message,
|
||||
startedAt: r.started_at, finishedAt: r.finished_at,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
getBrowserAction(id: string, companyId: string): BrowserActionRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM browser_actions WHERE id = ? AND company_id = ?")
|
||||
.get(id, companyId) as SqliteRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, companyId: row.company_id, socialAccountId: row.social_account_id,
|
||||
agentId: row.agent_id, taskId: row.task_id, status: row.status,
|
||||
actionType: row.action_type, payloadJson: row.payload_json,
|
||||
resultSummary: row.result_summary, screenshotPath: row.screenshot_path,
|
||||
approvalId: row.approval_id, errorMessage: row.error_message,
|
||||
startedAt: row.started_at, finishedAt: row.finished_at,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
};
|
||||
},
|
||||
|
||||
getBrowserActionByApprovalId(approvalId: string, companyId: string): BrowserActionRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM browser_actions WHERE approval_id = ? AND company_id = ? ORDER BY created_at DESC LIMIT 1")
|
||||
.get(approvalId, companyId) as SqliteRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, companyId: row.company_id, socialAccountId: row.social_account_id,
|
||||
agentId: row.agent_id, taskId: row.task_id, status: row.status,
|
||||
actionType: row.action_type, payloadJson: row.payload_json,
|
||||
resultSummary: row.result_summary, screenshotPath: row.screenshot_path,
|
||||
approvalId: row.approval_id, errorMessage: row.error_message,
|
||||
startedAt: row.started_at, finishedAt: row.finished_at,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll } from "./helpers";
|
||||
|
||||
interface AssignmentTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
interface AssignmentCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string | null;
|
||||
capabilities: string;
|
||||
taskCount: number;
|
||||
budgetMonthlyUsd: number;
|
||||
spentMonthlyUsd: number;
|
||||
}
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text.toLowerCase().split(/\W+/).filter((word) => word.length > 3);
|
||||
}
|
||||
|
||||
function getProjectDepartment(
|
||||
db: DatabaseSync,
|
||||
companyId: string,
|
||||
projectId: string | null,
|
||||
): string | null {
|
||||
if (!projectId) return null;
|
||||
const project = db
|
||||
.prepare("select lead_agent_id from projects where id = ? and company_id = ?")
|
||||
.get(projectId, companyId) as { lead_agent_id: string | null } | undefined;
|
||||
if (!project?.lead_agent_id) return null;
|
||||
const lead = db
|
||||
.prepare("select department from agents where id = ? and company_id = ?")
|
||||
.get(project.lead_agent_id, companyId) as { department: string | null } | undefined;
|
||||
return lead?.department ?? null;
|
||||
}
|
||||
|
||||
function loadSkillWordsForAgents(
|
||||
readAll: ReadAll,
|
||||
companyId: string,
|
||||
agentIds: string[],
|
||||
): Map<string, string[]> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (agentIds.length === 0) return result;
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
const rows = readAll<{ agentId: string; name: string; description: string | null; slug: string }>(
|
||||
`select asa.agent_id as agentId, cs.name, cs.description, cs.slug
|
||||
from company_skills cs
|
||||
join agent_skill_assignments asa on asa.skill_id = cs.id
|
||||
where cs.company_id = ? and asa.agent_id in (${placeholders})`,
|
||||
companyId,
|
||||
...agentIds,
|
||||
);
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const blob = `${row.name} ${row.description ?? ""} ${row.slug}`;
|
||||
const existing = grouped.get(row.agentId);
|
||||
if (existing) existing.push(blob);
|
||||
else grouped.set(row.agentId, [blob]);
|
||||
}
|
||||
for (const agentId of agentIds) {
|
||||
result.set(agentId, tokenize((grouped.get(agentId) ?? []).join(" ")));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadAgentsWithReports(
|
||||
readAll: ReadAll,
|
||||
companyId: string,
|
||||
agentIds: string[],
|
||||
): Set<string> {
|
||||
const result = new Set<string>();
|
||||
if (agentIds.length === 0) return result;
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
const rows = readAll<{ reportsTo: string }>(
|
||||
`select distinct reports_to as reportsTo from agents where company_id = ? and reports_to in (${placeholders})`,
|
||||
companyId,
|
||||
...agentIds,
|
||||
);
|
||||
for (const row of rows) result.add(row.reportsTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function chooseBestTaskAssignee(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
companyId: string,
|
||||
task: AssignmentTask,
|
||||
candidates: AssignmentCandidate[],
|
||||
): string | null {
|
||||
const eligibleCandidates = candidates.filter((candidate) => {
|
||||
if (candidate.budgetMonthlyUsd > 0 && candidate.spentMonthlyUsd >= candidate.budgetMonthlyUsd) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligibleCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskWords = tokenize(`${task.title} ${task.description}`);
|
||||
const projectDept = getProjectDepartment(getDb(), companyId, task.projectId);
|
||||
const eligibleIds = eligibleCandidates.map((candidate) => candidate.id);
|
||||
const skillWordsByAgent = loadSkillWordsForAgents(readAll, companyId, eligibleIds);
|
||||
const agentsWithReports = loadAgentsWithReports(readAll, companyId, eligibleIds);
|
||||
|
||||
let bestAgentId: string | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const candidate of eligibleCandidates) {
|
||||
const capabilityWords = tokenize(candidate.capabilities);
|
||||
const skillWords = skillWordsByAgent.get(candidate.id) ?? [];
|
||||
const capabilityOverlap = taskWords.filter((word) => capabilityWords.includes(word)).length;
|
||||
const skillOverlap = taskWords.filter((word) => skillWords.includes(word)).length;
|
||||
let score = capabilityOverlap * 10 + skillOverlap * 12 - candidate.taskCount * 5;
|
||||
if (projectDept && candidate.department === projectDept) {
|
||||
score += 15;
|
||||
}
|
||||
if (!agentsWithReports.has(candidate.id)) {
|
||||
score += 5;
|
||||
}
|
||||
if (
|
||||
score > bestScore ||
|
||||
(score === bestScore &&
|
||||
candidate.taskCount <
|
||||
(eligibleCandidates.find((entry) => entry.id === bestAgentId)?.taskCount ?? Number.POSITIVE_INFINITY))
|
||||
) {
|
||||
bestScore = score;
|
||||
bestAgentId = candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
return bestAgentId ?? eligibleCandidates[0]?.id ?? null;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { STALE_LOCK_TIMEOUT_MS } from "../constants";
|
||||
|
||||
function isoFromMs(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function loadRunForCheckout(
|
||||
db: DatabaseSync,
|
||||
taskId: string,
|
||||
runId: string,
|
||||
): { companyId: string; agentId: string; taskId: string | null } {
|
||||
const row = db
|
||||
.prepare("SELECT company_id as companyId, agent_id as agentId, task_id as taskId FROM runs WHERE id = ?")
|
||||
.get(runId) as { companyId: string; agentId: string; taskId: string | null } | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`Run not found for task checkout: ${runId}`);
|
||||
}
|
||||
if (row.taskId !== null && row.taskId !== taskId) {
|
||||
throw new Error(`Run ${runId} is bound to task ${row.taskId}, not ${taskId}.`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export function checkoutTaskInTransaction(
|
||||
db: DatabaseSync,
|
||||
taskId: string,
|
||||
runId: string,
|
||||
): boolean {
|
||||
const run = loadRunForCheckout(db, taskId, runId);
|
||||
const nowMs = Date.now();
|
||||
const ts = isoFromMs(nowMs);
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'in_progress',
|
||||
started_at = coalesce(started_at, ?),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND company_id = ?
|
||||
AND status IN ('todo', 'backlog')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_leases
|
||||
WHERE task_leases.task_id = tasks.id
|
||||
)`,
|
||||
)
|
||||
.run(ts, ts, taskId, run.companyId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (run.taskId === null) {
|
||||
db.prepare("update runs set task_id = ?, updated_at = ? where id = ? and task_id is null").run(taskId, ts, runId);
|
||||
}
|
||||
|
||||
db
|
||||
.prepare(
|
||||
`insert into task_leases (
|
||||
task_id, company_id, run_id, agent_id, acquired_at, renewed_at, expires_at, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskId,
|
||||
run.companyId,
|
||||
runId,
|
||||
run.agentId,
|
||||
ts,
|
||||
ts,
|
||||
isoFromMs(nowMs + STALE_LOCK_TIMEOUT_MS),
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
import { activeRunIdSql, type ReadAll, type Transaction, taskPriorityOrderSql } from "./helpers";
|
||||
import type { TaskListByStatusesRow } from "./tasks-crud";
|
||||
|
||||
/**
|
||||
* Task list/aggregate read queries extracted from tasks-crud.ts.
|
||||
*
|
||||
* Every method is spread into the final `buildTaskMethods` result so
|
||||
* AppDatabase call-sites remain unchanged.
|
||||
*/
|
||||
export function buildTaskQueryMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Returns actionable real work for an agent.
|
||||
*/
|
||||
listAgentTasks(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
statuses: string[],
|
||||
): TaskRecord[] {
|
||||
const placeholders = statuses.map(() => "?").join(",");
|
||||
return readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
||||
from tasks
|
||||
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
|
||||
order by
|
||||
case status
|
||||
when 'in_progress' then 0
|
||||
when 'todo' then 1
|
||||
when 'blocked' then 2
|
||||
when 'backlog' then 3
|
||||
else 4
|
||||
end asc,
|
||||
${taskPriorityOrderSql("priority")} desc,
|
||||
created_at asc`,
|
||||
companyId,
|
||||
agentId,
|
||||
...statuses,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim task projection (id, title, status, priority) for a
|
||||
* single assignee, ordered by created_at desc.
|
||||
*/
|
||||
listAssigneeTasksForStandup(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Array<{ id: string; title: string; status: string; priority: string }> {
|
||||
return readAll(
|
||||
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
|
||||
companyId,
|
||||
agentId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim task projection for a set of assignees. Safe on
|
||||
* an empty assignee list (returns []).
|
||||
*/
|
||||
listTasksForAssignees(
|
||||
companyId: string,
|
||||
assigneeIds: string[],
|
||||
): Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string;
|
||||
}> {
|
||||
if (assigneeIds.length === 0) return [];
|
||||
const placeholders = assigneeIds.map(() => "?").join(",");
|
||||
return readAll(
|
||||
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
|
||||
companyId,
|
||||
...assigneeIds,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns tasks filtered by status.
|
||||
*/
|
||||
listTasksByStatuses(
|
||||
companyId: string,
|
||||
statuses: string[],
|
||||
): TaskListByStatusesRow[] {
|
||||
const placeholders = statuses.map(() => "?").join(", ");
|
||||
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`,
|
||||
companyId,
|
||||
...statuses,
|
||||
).map((t) => ({
|
||||
...t,
|
||||
requiresUserReview: Boolean(t.requiresUserReview),
|
||||
startedAt: t.startedAt ?? null,
|
||||
completedAt: t.completedAt ?? null,
|
||||
cancelledAt: t.cancelledAt ?? null,
|
||||
})) as TaskListByStatusesRow[];
|
||||
},
|
||||
|
||||
countActiveTasksForCompany(companyId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled')",
|
||||
)
|
||||
.get(companyId) as { cnt: number };
|
||||
return row.cnt;
|
||||
},
|
||||
|
||||
/**
|
||||
* Count tasks that are actively producing progress this cycle —
|
||||
* todo (waiting to be checked out) and in_progress (currently being
|
||||
* worked). Excludes in_review (waiting on a human or manager) and
|
||||
* blocked (waiting on a resolution). Used by the bootstrap
|
||||
* scheduler to decide whether the CEO needs to be pinged: a company
|
||||
* where every task is stuck in review or blocked is functionally
|
||||
* idle even though countActiveTasksForCompany reports > 0.
|
||||
*/
|
||||
countAdvancingTasksForCompany(companyId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select count(*) as cnt from tasks where company_id = ? and status in ('todo','in_progress')",
|
||||
)
|
||||
.get(companyId) as { cnt: number };
|
||||
return row.cnt;
|
||||
},
|
||||
|
||||
listChildTasks(
|
||||
parentId: string,
|
||||
companyId: string,
|
||||
): Array<{ id: string; status: string; assigneeAgentId: string | null }> {
|
||||
return readAll<{ id: string; status: string; assigneeAgentId: string | null }>(
|
||||
`select id, status, assignee_agent_id as assigneeAgentId from tasks where parent_id = ? and company_id = ?`,
|
||||
parentId,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the set of distinct assignee agent ids on tasks in a project,
|
||||
* excluding unassigned tasks. Used by message-dispatch to fan out wake
|
||||
* targets for a project channel.
|
||||
*/
|
||||
listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] {
|
||||
const rows = readAll<{ assigneeAgentId: string | null }>(
|
||||
"select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
|
||||
projectId,
|
||||
companyId,
|
||||
);
|
||||
return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns assignee agent ids for a set of task ids scoped to a company.
|
||||
* Used by task-quality-gates sweep to find reviewers of in-review tasks
|
||||
* that have exceeded the review timeout.
|
||||
*/
|
||||
listReviewTasksForSweep(companyId: string): Array<{
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}> {
|
||||
return readAll(
|
||||
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND status = 'in_review'`,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ReadAll } from "./helpers";
|
||||
|
||||
export function guardInReviewHasReviewer(readAll: ReadAll, taskId: string): void {
|
||||
const row = readAll<{ requiresUserReview: number; reportsTo: string | null }>(
|
||||
`select t.requires_user_review as requiresUserReview,
|
||||
a.reports_to as reportsTo
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.id = ?`,
|
||||
taskId,
|
||||
)[0];
|
||||
if (!row) return;
|
||||
if (row.requiresUserReview === 1) return;
|
||||
if (row.reportsTo !== null) return;
|
||||
throw new Error(
|
||||
`Task ${taskId} has a top-level assignee and no reviewer: cannot enter in_review without requires_user_review=1.`,
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { TaskSessionRecord } from "@shared/types";
|
||||
import { type ReadAll, type Transaction, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Agent-scoped session lifecycle. Sessions belong to agents, not
|
||||
* tasks — `agents.session_id` is the canonical pointer, and
|
||||
* `task_sessions` rows are looked up by their own primary key only.
|
||||
* Legacy task/session pointer columns were dropped by v41; any read
|
||||
* that still needs a session given a task id resolves it through the
|
||||
* task's assignee agent.
|
||||
*/
|
||||
export function buildTaskSessionMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Fetch a session by its primary key. This is the single lookup
|
||||
* path — the rotation evaluator, the worker's finalizer, and the
|
||||
* turn-context builder all know which session id they want because
|
||||
* they either receive it from the agent record or from the worker
|
||||
* event envelope.
|
||||
*/
|
||||
getSessionById(sessionId: string): TaskSessionRecord | null {
|
||||
return (
|
||||
(getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id as companyId, agent_id as agentId,
|
||||
adapter_type as adapterType, last_run_id as lastRunId,
|
||||
last_error as lastError, run_count as runCount,
|
||||
total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens,
|
||||
rotated_at as rotatedAt, rotation_reason as rotationReason,
|
||||
handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt
|
||||
FROM task_sessions WHERE id = ?`,
|
||||
)
|
||||
.get(sessionId) as TaskSessionRecord | undefined) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
incrementSessionUsage(
|
||||
sessionId: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
runId: string,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE task_sessions
|
||||
SET run_count = run_count + 1,
|
||||
total_input_tokens = total_input_tokens + ?,
|
||||
total_output_tokens = total_output_tokens + ?,
|
||||
last_run_id = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(inputTokens, outputTokens, runId, ts, sessionId);
|
||||
},
|
||||
|
||||
archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.run(ts, reason, handoffMarkdown, ts, sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the agent's active session id, creating one lazily on
|
||||
* first call. One session per agent — switching tasks emits a
|
||||
* "now work on X" user turn on the ongoing transcript rather than
|
||||
* spawning a new session.
|
||||
*/
|
||||
ensureAgentSession(input: {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
}): string {
|
||||
const agentRow = getDb()
|
||||
.prepare("SELECT session_id FROM agents WHERE id = ?")
|
||||
.get(input.agentId) as { session_id: string | null } | undefined;
|
||||
if (agentRow?.session_id) return agentRow.session_id;
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO task_sessions (id, company_id, agent_id, adapter_type,
|
||||
run_count, total_input_tokens, total_output_tokens, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, 0, 0, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, input.companyId, input.agentId, input.adapterType, ts, ts);
|
||||
getDb()
|
||||
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
||||
.run(sessionId, ts, input.agentId);
|
||||
});
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
setAgentSessionId(agentId: string, sessionId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
||||
.run(sessionId, ts, agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a replacement session row after rotation. Inherits the
|
||||
* agent / company / adapter type from the caller. Counters start
|
||||
* at zero — Pi Mono has no per-turn provider state to carry forward.
|
||||
*/
|
||||
createNewSession(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
}): string {
|
||||
const sessionId = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO task_sessions (id, company_id, agent_id, adapter_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, input.companyId, input.agentId, input.adapterType, ts, ts);
|
||||
return sessionId;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
export const VALID_TASK_STATUSES = new Set([
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export function assertValidTaskStatus(status: string): void {
|
||||
if (!VALID_TASK_STATUSES.has(status)) {
|
||||
throw new Error(`Invalid task status: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTaskStatusTransition(
|
||||
newStatus: string,
|
||||
timestamp: string,
|
||||
): { sets: string[]; values: (string | null)[] } {
|
||||
const sets: string[] = ["status = ?", "updated_at = ?"];
|
||||
const values: (string | null)[] = [newStatus, timestamp];
|
||||
|
||||
if (newStatus === "in_progress") {
|
||||
sets.push("started_at = COALESCE(started_at, ?)");
|
||||
values.push(timestamp);
|
||||
}
|
||||
if (newStatus === "done") {
|
||||
sets.push("completed_at = ?");
|
||||
values.push(timestamp);
|
||||
} else {
|
||||
sets.push("completed_at = NULL");
|
||||
}
|
||||
if (newStatus === "cancelled") {
|
||||
sets.push("cancelled_at = ?");
|
||||
values.push(timestamp);
|
||||
} else {
|
||||
sets.push("cancelled_at = NULL");
|
||||
}
|
||||
return { sets, values };
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
ensureId,
|
||||
normalizeOptionalId,
|
||||
nowIso,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* Row shape returned by listTasksByStatuses — used by the
|
||||
* GET /api/companies/:companyId/issues route. Mirrors the historical
|
||||
* inline-SQL projection (no taskType column) so the HTTP response shape
|
||||
* stays stable for agent CLI clients.
|
||||
*/
|
||||
export type TaskListByStatusesRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
assigneeAgentId: string | null;
|
||||
workspaceId: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
activeRunId: string | null;
|
||||
costUsd: number | null;
|
||||
requiresUserReview: boolean;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function buildTaskCrudMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
_deps: {
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
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;
|
||||
/**
|
||||
* Per-task filesystem lock policy (v52+). Production callers
|
||||
* pass this explicitly. Direct DB fixtures may omit it: inserts
|
||||
* use the SQL default 0 and updates preserve the stored value.
|
||||
*/
|
||||
requiresFilesystem?: boolean;
|
||||
}): string {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const assigneeAgentId = normalizeOptionalId(input.assigneeAgentId);
|
||||
if (input.status === "in_progress" && !assigneeAgentId) {
|
||||
throw new Error("Tasks in progress require an assignee.");
|
||||
}
|
||||
const existing = getDb()
|
||||
.prepare("select id from tasks where id = ? and company_id = ?")
|
||||
.get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
if (input.requiresFilesystem === undefined) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(
|
||||
normalizeOptionalId(input.projectId),
|
||||
normalizeOptionalId(input.goalId),
|
||||
normalizeOptionalId(input.parentId),
|
||||
input.title,
|
||||
input.description,
|
||||
assigneeAgentId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
input.priority,
|
||||
input.status,
|
||||
ts,
|
||||
id,
|
||||
input.companyId,
|
||||
);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, requires_filesystem = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(
|
||||
normalizeOptionalId(input.projectId),
|
||||
normalizeOptionalId(input.goalId),
|
||||
normalizeOptionalId(input.parentId),
|
||||
input.title,
|
||||
input.description,
|
||||
assigneeAgentId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
input.priority,
|
||||
input.status,
|
||||
input.requiresFilesystem ? 1 : 0,
|
||||
ts,
|
||||
id,
|
||||
input.companyId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, requires_filesystem, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.companyId,
|
||||
normalizeOptionalId(input.projectId),
|
||||
normalizeOptionalId(input.goalId),
|
||||
normalizeOptionalId(input.parentId),
|
||||
input.title,
|
||||
input.description,
|
||||
assigneeAgentId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
input.status,
|
||||
input.priority,
|
||||
input.requiresFilesystem ? 1 : 0,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteTask(id: string, companyId: string): void {
|
||||
transaction(() => {
|
||||
const activeTaskRuns = getDb()
|
||||
.prepare(
|
||||
"select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')",
|
||||
)
|
||||
.all(id, companyId) as Array<{
|
||||
id: string;
|
||||
taskId: string;
|
||||
workspaceId: string | null;
|
||||
}>;
|
||||
for (const run of activeTaskRuns) {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, usage_status = ?, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run("Task deleted.", "aborted_before_usage", ts, ts, run.id);
|
||||
getDb()
|
||||
.prepare("delete from task_leases where task_id = ? and run_id = ?")
|
||||
.run(run.taskId, run.id);
|
||||
if (run.workspaceId) {
|
||||
getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
|
||||
where id = ? and lock_holder_run_id = ?`,
|
||||
)
|
||||
.run(ts, run.workspaceId, run.id);
|
||||
}
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set parent_id = null where parent_id = ? and company_id = ?",
|
||||
)
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare("delete from approvals where related_task_id = ? and company_id = ?")
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare("delete from task_comments where task_id = ? and company_id = ?")
|
||||
.run(id, companyId);
|
||||
getDb()
|
||||
.prepare("delete from tasks where id = ? and company_id = ?")
|
||||
.run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
getRecentRunsForTask(
|
||||
taskId: string,
|
||||
limit: number,
|
||||
): Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
summary: string | null;
|
||||
errorMessage: string | null;
|
||||
finishedAt: string | null;
|
||||
connectorId: string;
|
||||
}> {
|
||||
return readAll(
|
||||
`SELECT id, status, summary, error_message as errorMessage,
|
||||
finished_at as finishedAt, connector_id as connectorId
|
||||
FROM runs WHERE task_id = ? ORDER BY finished_at DESC, rowid DESC LIMIT ?`,
|
||||
taskId,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
|
||||
getRecentActivityForTask(
|
||||
taskId: string,
|
||||
limit: number,
|
||||
): Array<{
|
||||
action: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}> {
|
||||
return readAll(
|
||||
`SELECT action, detail, created_at as createdAt
|
||||
FROM activity WHERE entity_type = 'task' AND entity_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
taskId,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
|
||||
setTaskType(taskId: string, taskType: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare("update tasks set task_type = ?, updated_at = ? where id = ?")
|
||||
.run(taskType, ts, taskId);
|
||||
},
|
||||
|
||||
getTaskRetryCount(taskId: string): number {
|
||||
const row = getDb()
|
||||
.prepare("select retry_count from tasks where id = ?")
|
||||
.get(taskId) as { retry_count: number } | undefined;
|
||||
return row?.retry_count ?? 0;
|
||||
},
|
||||
|
||||
incrementTaskRetryCount(taskId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set retry_count = retry_count + 1, last_failure_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(ts, ts, taskId);
|
||||
},
|
||||
|
||||
resetTaskRetryCount(taskId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(ts, taskId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns `task_type` for a single task id, or null when the task is
|
||||
* missing. Used by checkpoint-engine to branch on task type without
|
||||
* loading the full row.
|
||||
*/
|
||||
getTaskType(taskId: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1")
|
||||
.get(taskId) as { taskType: string | null } | undefined;
|
||||
return row?.taskType ?? null;
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
activeRunIdSql,
|
||||
nowIso,
|
||||
taskPriorityOrderSql,
|
||||
} from "./helpers";
|
||||
import { chooseBestTaskAssignee } from "./task-assignment";
|
||||
import { checkoutTaskInTransaction } from "./task-checkout";
|
||||
import { guardInReviewHasReviewer } from "./task-review-guard";
|
||||
import { assertValidTaskStatus, buildTaskStatusTransition } from "./task-status-rules";
|
||||
import { STALE_LOCK_TIMEOUT_MS } from "../constants";
|
||||
|
||||
export function buildTaskStatusMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
},
|
||||
) {
|
||||
function incrementActiveSprintVelocity(companyId: string, delta: number): void {
|
||||
const ts = nowIso();
|
||||
const today = ts.slice(0, 10);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?",
|
||||
)
|
||||
.run(delta, ts, companyId, today, today);
|
||||
}
|
||||
|
||||
return {
|
||||
advanceTaskStatus(
|
||||
taskId: string,
|
||||
newStatus: string,
|
||||
comment?: string,
|
||||
): { previousStatus: string } {
|
||||
assertValidTaskStatus(newStatus);
|
||||
const task = getDb()
|
||||
.prepare("select status, company_id from tasks where id = ?")
|
||||
.get(taskId) as { status: string; company_id: string } | undefined;
|
||||
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||
const previousStatus = task.status;
|
||||
if (previousStatus === newStatus) return { previousStatus };
|
||||
if (newStatus === "in_review") guardInReviewHasReviewer(readAll, taskId);
|
||||
const ts = nowIso();
|
||||
const transition = buildTaskStatusTransition(newStatus, ts);
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks SET ${transition.sets.join(", ")} WHERE id = ?`,
|
||||
)
|
||||
.run(...transition.values, taskId);
|
||||
if (newStatus !== "in_progress") {
|
||||
getDb().prepare("delete from task_leases where task_id = ?").run(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
if (comment) {
|
||||
deps.addComment({
|
||||
companyId: task.company_id,
|
||||
taskId,
|
||||
authorAgentId: null,
|
||||
authorName: "System",
|
||||
body: comment,
|
||||
});
|
||||
}
|
||||
if (newStatus === "done" && previousStatus !== "done") {
|
||||
incrementActiveSprintVelocity(task.company_id, 1);
|
||||
}
|
||||
if (previousStatus === "done" && newStatus !== "done") {
|
||||
incrementActiveSprintVelocity(task.company_id, -1);
|
||||
}
|
||||
return { previousStatus };
|
||||
},
|
||||
|
||||
listReviewTasksForManager(managerId: string, companyId: string): TaskRecord[] {
|
||||
return readAll<TaskRecord>(
|
||||
`select t.id, t.company_id as companyId, t.project_id as projectId, t.goal_id as goalId,
|
||||
t.parent_id as parentId, t.title, t.description,
|
||||
t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId,
|
||||
t.status, t.priority,
|
||||
${activeRunIdSql("t")} as activeRunId,
|
||||
t.cost_usd as costUsd,
|
||||
t.created_at as createdAt, t.updated_at as updatedAt
|
||||
from tasks t
|
||||
inner join agents a on t.assignee_agent_id = a.id
|
||||
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ?
|
||||
order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`,
|
||||
companyId,
|
||||
managerId,
|
||||
);
|
||||
},
|
||||
|
||||
checkoutTask(taskId: string, runId: string): boolean {
|
||||
return transaction(() => checkoutTaskInTransaction(getDb(), taskId, runId));
|
||||
},
|
||||
|
||||
releaseTaskLock(taskId: string, runId: string): void {
|
||||
getDb()
|
||||
.prepare("delete from task_leases where task_id = ? and run_id = ?")
|
||||
.run(taskId, runId);
|
||||
},
|
||||
|
||||
renewTaskLease(taskId: string, runId: string): boolean {
|
||||
const ts = nowIso();
|
||||
const expiresAt = new Date(Date.now() + STALE_LOCK_TIMEOUT_MS).toISOString();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"update task_leases set renewed_at = ?, expires_at = ?, updated_at = ? where task_id = ? and run_id = ?",
|
||||
)
|
||||
.run(ts, expiresAt, ts, taskId, runId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
setTaskRequiresUserReview(taskId: string, value: boolean): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?")
|
||||
.run(value ? 1 : 0, ts, taskId);
|
||||
},
|
||||
|
||||
hasPendingDeliverableReviews(companyId: string): boolean {
|
||||
const approval = getDb()
|
||||
.prepare(
|
||||
"select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1",
|
||||
)
|
||||
.get(companyId);
|
||||
if (approval) return true;
|
||||
const flaggedTask = getDb()
|
||||
.prepare(
|
||||
"select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1",
|
||||
)
|
||||
.get(companyId);
|
||||
return !!flaggedTask;
|
||||
},
|
||||
|
||||
hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`select 1 from approvals a
|
||||
join tasks t on a.related_task_id = t.id
|
||||
where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending'
|
||||
and t.assignee_agent_id = ?
|
||||
limit 1`,
|
||||
)
|
||||
.get(companyId, agentId);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1",
|
||||
)
|
||||
.get(agentId, companyId);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
autoAssignTask(taskId: string, companyId: string): string | null {
|
||||
const task = getDb()
|
||||
.prepare(
|
||||
"select id, title, description, project_id as projectId, assignee_agent_id as assigneeAgentId from tasks where id = ? and company_id = ?",
|
||||
)
|
||||
.get(taskId, companyId) as
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!task) return null;
|
||||
|
||||
// If task already has an assignee, return them (don't reassign)
|
||||
if (task.assigneeAgentId) return task.assigneeAgentId;
|
||||
|
||||
// Get eligible agents: active/idle with remaining budget
|
||||
const candidates = readAll<{
|
||||
id: string;
|
||||
name: string;
|
||||
department: string | null;
|
||||
capabilities: string;
|
||||
taskCount: number;
|
||||
budgetMonthlyUsd: number;
|
||||
spentMonthlyUsd: number;
|
||||
}>(
|
||||
`select a.id, a.name, a.department, a.capabilities,
|
||||
a.budget_monthly_usd as budgetMonthlyUsd, a.spent_monthly_usd as spentMonthlyUsd,
|
||||
(select count(*) from tasks t where t.assignee_agent_id = a.id and t.status in ('todo', 'in_progress')) as taskCount
|
||||
from agents a
|
||||
where a.company_id = ? and a.status in ('idle', 'active')
|
||||
order by taskCount asc`,
|
||||
companyId,
|
||||
);
|
||||
|
||||
const bestAgent = chooseBestTaskAssignee(getDb, readAll, companyId, task, candidates);
|
||||
if (!bestAgent) return null;
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set assignee_agent_id = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(bestAgent, nowIso(), taskId, companyId);
|
||||
deps.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "task.auto_assigned",
|
||||
entityType: "task",
|
||||
entityId: taskId,
|
||||
detail: `Auto-assigned to ${candidates.find((c) => c.id === bestAgent)?.name ?? bestAgent}`,
|
||||
});
|
||||
return bestAgent;
|
||||
},
|
||||
|
||||
retryInterruptedTask(
|
||||
taskId: string,
|
||||
_agentId: string,
|
||||
_companyId: string,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare("update tasks set status = 'todo', updated_at = ? where id = ?")
|
||||
.run(ts, taskId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(ts, taskId);
|
||||
},
|
||||
|
||||
dismissInterruptedTask(taskId: string): void {
|
||||
const taskRow = getDb()
|
||||
.prepare("select company_id from tasks where id = ?")
|
||||
.get(taskId) as { company_id: string } | undefined;
|
||||
deps.addComment({
|
||||
companyId: taskRow?.company_id ?? "",
|
||||
taskId,
|
||||
authorAgentId: null,
|
||||
authorName: "System",
|
||||
body: "Interrupted run dismissed by user. Task remains blocked for manual handling.",
|
||||
});
|
||||
},
|
||||
|
||||
markInterruptedTaskFailed(taskId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare("update tasks set status = 'cancelled', updated_at = ? where id = ?")
|
||||
.run(ts, taskId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso } from "./helpers";
|
||||
import { guardInReviewHasReviewer } from "./task-review-guard";
|
||||
import { assertValidTaskStatus, buildTaskStatusTransition } from "./task-status-rules";
|
||||
|
||||
type TaskUpdatePatch = {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
projectId?: string | null;
|
||||
goalId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
requiresFilesystem: boolean;
|
||||
};
|
||||
|
||||
type ReviewDocument = { id: string; title: string } & Record<string, unknown>;
|
||||
|
||||
type TaskReviewRequest = {
|
||||
reviewerAgentName: string;
|
||||
documents: ReviewDocument[];
|
||||
};
|
||||
|
||||
type TaskUpdateActivity = {
|
||||
action: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type ApprovalRequestInput = {
|
||||
companyId: string;
|
||||
relatedTaskId?: string | null;
|
||||
requestedByAgentId?: string | null;
|
||||
relatedAgentId?: string | null;
|
||||
type: string;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson?: string | null;
|
||||
};
|
||||
|
||||
type TaskUpdateResult = {
|
||||
previousStatus: string;
|
||||
newStatus: string;
|
||||
previousAssigneeAgentId: string | null;
|
||||
newAssigneeAgentId: string | null;
|
||||
parentId: string | null;
|
||||
reviewRequested: boolean;
|
||||
statusChanged: boolean;
|
||||
};
|
||||
|
||||
export function buildTaskUpdateMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
requestApproval: (input: ApprovalRequestInput) => string;
|
||||
setHeartbeat: (agentId: string, enabled: boolean, intervalSec: number) => void;
|
||||
},
|
||||
) {
|
||||
function incrementActiveSprintVelocity(companyId: string, delta: number): void {
|
||||
const ts = nowIso();
|
||||
const today = ts.slice(0, 10);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?",
|
||||
)
|
||||
.run(delta, ts, companyId, today, today);
|
||||
}
|
||||
|
||||
function pauseAssigneeHeartbeat(agentId: string | null): void {
|
||||
if (!agentId) return;
|
||||
const agent = getDb()
|
||||
.prepare("select heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec from agents where id = ?")
|
||||
.get(agentId) as { heartbeatEnabled: number; heartbeatIntervalSec: number | null } | undefined;
|
||||
if (!agent?.heartbeatEnabled) return;
|
||||
deps.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
|
||||
function requestDeliverableReview(input: {
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
actorAgentId: string;
|
||||
taskTitle: string;
|
||||
currentAssigneeAgentId: string | null;
|
||||
reviewRequest: TaskReviewRequest;
|
||||
}): void {
|
||||
const docTitles = input.reviewRequest.documents.map((document) => document.title).join(", ");
|
||||
deps.requestApproval({
|
||||
companyId: input.companyId,
|
||||
relatedTaskId: input.taskId,
|
||||
requestedByAgentId: input.actorAgentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: `Review deliverable: ${docTitles || input.taskTitle}`.slice(0, 280),
|
||||
impactSummary: `${input.reviewRequest.reviewerAgentName} reviewed and approved. Awaiting your final review.`.slice(0, 400),
|
||||
payloadJson: JSON.stringify({
|
||||
documentIds: input.reviewRequest.documents.map((document) => document.id),
|
||||
documents: input.reviewRequest.documents,
|
||||
}),
|
||||
});
|
||||
getDb()
|
||||
.prepare("update tasks set requires_user_review = 0, updated_at = ? where id = ? and company_id = ?")
|
||||
.run(nowIso(), input.taskId, input.companyId);
|
||||
pauseAssigneeHeartbeat(input.currentAssigneeAgentId);
|
||||
deps.addComment({
|
||||
companyId: input.companyId,
|
||||
taskId: input.taskId,
|
||||
authorAgentId: null,
|
||||
authorName: "System",
|
||||
body: "Deliverable submitted for board review.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updateTaskWithReviewSideEffects(input: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
actorAgentId: string;
|
||||
actorName: string;
|
||||
task: TaskUpdatePatch;
|
||||
comment?: string;
|
||||
reviewRequest?: TaskReviewRequest | null;
|
||||
activity: TaskUpdateActivity;
|
||||
}): TaskUpdateResult {
|
||||
assertValidTaskStatus(input.task.status);
|
||||
const assigneeAgentId = normalizeOptionalId(input.task.assigneeAgentId);
|
||||
if (input.task.status === "in_progress" && !assigneeAgentId) {
|
||||
throw new Error("Tasks in progress require an assignee.");
|
||||
}
|
||||
|
||||
return transaction(() => {
|
||||
const current = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, status, title, assignee_agent_id as assigneeAgentId, parent_id as parentId, requires_user_review as requiresUserReview from tasks where id = ? and company_id = ?",
|
||||
)
|
||||
.get(input.taskId, input.companyId) as
|
||||
| {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
title: string;
|
||||
assigneeAgentId: string | null;
|
||||
parentId: string | null;
|
||||
requiresUserReview: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!current) {
|
||||
throw new Error(`Task not found: ${input.taskId}`);
|
||||
}
|
||||
|
||||
const reviewRequested =
|
||||
input.task.status === "done" &&
|
||||
current.requiresUserReview === 1 &&
|
||||
input.reviewRequest !== null &&
|
||||
input.reviewRequest !== undefined;
|
||||
if (input.task.status === "done" && current.requiresUserReview === 1 && !input.reviewRequest) {
|
||||
throw new Error(`Task ${input.taskId} requires a deliverable review request before it can be marked done.`);
|
||||
}
|
||||
const newStatus = reviewRequested ? "in_review" : input.task.status;
|
||||
assertValidTaskStatus(newStatus);
|
||||
|
||||
if (reviewRequested) {
|
||||
requestDeliverableReview({
|
||||
companyId: input.companyId,
|
||||
taskId: input.taskId,
|
||||
actorAgentId: input.actorAgentId,
|
||||
taskTitle: current.title,
|
||||
currentAssigneeAgentId: current.assigneeAgentId,
|
||||
reviewRequest: input.reviewRequest!,
|
||||
});
|
||||
}
|
||||
|
||||
const statusChanged = current.status !== newStatus;
|
||||
if (statusChanged && newStatus === "in_review") {
|
||||
guardInReviewHasReviewer(readAll, input.taskId);
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
const sets = [
|
||||
"project_id = ?",
|
||||
"goal_id = ?",
|
||||
"parent_id = ?",
|
||||
"title = ?",
|
||||
"description = ?",
|
||||
"assignee_agent_id = ?",
|
||||
"workspace_id = ?",
|
||||
"priority = ?",
|
||||
"requires_filesystem = ?",
|
||||
];
|
||||
const values: Array<string | number | null> = [
|
||||
normalizeOptionalId(input.task.projectId),
|
||||
normalizeOptionalId(input.task.goalId),
|
||||
normalizeOptionalId(input.task.parentId),
|
||||
input.task.title,
|
||||
input.task.description,
|
||||
assigneeAgentId,
|
||||
normalizeOptionalId(input.task.workspaceId),
|
||||
input.task.priority,
|
||||
input.task.requiresFilesystem ? 1 : 0,
|
||||
];
|
||||
const transition = statusChanged ? buildTaskStatusTransition(newStatus, ts) : null;
|
||||
if (transition) {
|
||||
sets.push(...transition.sets);
|
||||
values.push(...transition.values);
|
||||
} else {
|
||||
sets.push("status = ?", "updated_at = ?");
|
||||
values.push(newStatus, ts);
|
||||
}
|
||||
|
||||
const updateResult = getDb()
|
||||
.prepare(`update tasks set ${sets.join(", ")} where id = ? and company_id = ?`)
|
||||
.run(...values, input.taskId, input.companyId);
|
||||
if (updateResult.changes === 0) {
|
||||
throw new Error(`Task not found: ${input.taskId}`);
|
||||
}
|
||||
if (newStatus === "done" && current.status !== "done") {
|
||||
incrementActiveSprintVelocity(input.companyId, 1);
|
||||
}
|
||||
if (current.status === "done" && newStatus !== "done") {
|
||||
incrementActiveSprintVelocity(input.companyId, -1);
|
||||
}
|
||||
if (input.comment) {
|
||||
deps.addComment({
|
||||
companyId: input.companyId,
|
||||
taskId: input.taskId,
|
||||
authorAgentId: input.actorAgentId,
|
||||
authorName: input.actorName,
|
||||
body: input.comment,
|
||||
});
|
||||
}
|
||||
deps.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: input.actorAgentId,
|
||||
action: input.activity.action,
|
||||
entityType: "task",
|
||||
entityId: input.taskId,
|
||||
detail: input.activity.detail,
|
||||
});
|
||||
|
||||
return {
|
||||
previousStatus: current.status,
|
||||
newStatus,
|
||||
previousAssigneeAgentId: current.assigneeAgentId,
|
||||
newAssigneeAgentId: assigneeAgentId,
|
||||
parentId: normalizeOptionalId(input.task.parentId),
|
||||
reviewRequested,
|
||||
statusChanged,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { buildTaskCrudMethods } from "./tasks-crud";
|
||||
import { buildTaskQueryMethods } from "./task-queries";
|
||||
import { buildTaskSessionMethods } from "./task-sessions-crud";
|
||||
import { buildTaskStatusMethods } from "./tasks-status";
|
||||
import { buildTaskUpdateMethods } from "./tasks-update";
|
||||
|
||||
export function buildTaskMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
||||
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
||||
requestApproval: (input: {
|
||||
companyId: string;
|
||||
relatedTaskId?: string | null;
|
||||
requestedByAgentId?: string | null;
|
||||
relatedAgentId?: string | null;
|
||||
type: string;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson?: string | null;
|
||||
}) => string;
|
||||
setHeartbeat: (agentId: string, enabled: boolean, intervalSec: number) => void;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
...buildTaskCrudMethods(getDb, readAll, transaction, deps),
|
||||
...buildTaskQueryMethods(getDb, readAll, transaction),
|
||||
...buildTaskSessionMethods(getDb, readAll, transaction),
|
||||
...buildTaskStatusMethods(getDb, readAll, transaction, deps),
|
||||
...buildTaskUpdateMethods(getDb, readAll, transaction, deps),
|
||||
};
|
||||
}
|
||||
Loaded 100 of 1904 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user