update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CommentRecord } from "@shared/types";
|
||||
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 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, input.body, ts);
|
||||
return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body: input.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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord, 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, 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;
|
||||
}): 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, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.get(id, input.companyId) as Record<string, any> | undefined;
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, 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),
|
||||
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 any)[k]) !==
|
||||
JSON.stringify((beforeConfig as any)[k]),
|
||||
);
|
||||
if (changedKeys.length > 0) {
|
||||
try {
|
||||
deps.recordConfigRevision({
|
||||
companyId: input.companyId,
|
||||
agentId: id,
|
||||
changedKeys,
|
||||
configBefore: beforeConfig,
|
||||
configAfter: afterConfig,
|
||||
});
|
||||
} catch {
|
||||
/* Non-fatal: revision recording should never block agent save */
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, 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),
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
metadataJson,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteAgent(id: string, companyId: string): void {
|
||||
const activeAgentRuns = readAll<{ id: string; taskId: string; 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 agents = readAll<{ id: string; name: string; role: string; title: string }>(
|
||||
"SELECT id, name, role, title FROM agents WHERE company_id = ?",
|
||||
companyId,
|
||||
);
|
||||
const lowerText = text.toLowerCase();
|
||||
const aliasMap = new Map<string, string[]>();
|
||||
|
||||
const registerAlias = (alias: string, agentId: string) => {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
const existing = aliasMap.get(normalized) ?? [];
|
||||
existing.push(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>();
|
||||
const uniqueAliases = [...aliasMap.entries()]
|
||||
.filter(([, ids]) => ids.length === 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]!)) {
|
||||
matched.add(ids[0]!);
|
||||
}
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
nowIso,
|
||||
parseJson,
|
||||
toBoolean,
|
||||
} from "./helpers";
|
||||
|
||||
export function buildAgentHierarchyMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
getChainOfCommand(agentId: string): AgentRecord[] {
|
||||
const chain: AgentRecord[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: AgentRecord | undefined;
|
||||
try {
|
||||
current = deps.getAgent(agentId);
|
||||
} catch {
|
||||
return chain;
|
||||
}
|
||||
while (current?.reportsTo) {
|
||||
if (visited.has(current.reportsTo)) {
|
||||
break;
|
||||
}
|
||||
visited.add(current.reportsTo);
|
||||
try {
|
||||
current = deps.getAgent(current.reportsTo);
|
||||
chain.push(current);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
|
||||
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, 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 = ? 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);
|
||||
},
|
||||
|
||||
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, 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 = ? 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;
|
||||
lastHeartbeatAt: string | null;
|
||||
}> {
|
||||
return readAll<{
|
||||
id: string;
|
||||
name: string;
|
||||
department: string | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
}>(
|
||||
"select id, name, department, 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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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";
|
||||
|
||||
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, 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 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ApprovalRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso, parseJson } from "./helpers";
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
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 { /* ignore malformed payload */ }
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
||||
getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null {
|
||||
const taskCondition = taskId != null
|
||||
? "(related_task_id = ? or requested_by_agent_id = ?)"
|
||||
: "requested_by_agent_id = ?";
|
||||
const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId];
|
||||
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,
|
||||
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 = ? and state = 'pending'
|
||||
and (
|
||||
(type = 'hire_agent' and related_agent_id = ?)
|
||||
or (type = 'dangerous_command' and ${taskCondition})
|
||||
or (type = 'secret_access' and ${taskCondition})
|
||||
or (type = 'approve_ceo_strategy' and ${taskCondition})
|
||||
or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition})))
|
||||
)
|
||||
order by created_at asc limit 1`,
|
||||
companyId, agentId,
|
||||
...taskParams, ...taskParams, ...taskParams,
|
||||
agentId, ...taskParams,
|
||||
);
|
||||
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 {
|
||||
const ts = nowIso();
|
||||
for (const taskId of taskIds) {
|
||||
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, ts);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { WorkflowPipelineRecord } from "@shared/types";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { ensureId, nowIso } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
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 id from automation_rules where id = ?").get(id) as { id: string } | undefined;
|
||||
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 = ?",
|
||||
).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);
|
||||
} 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();
|
||||
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, nowIso());
|
||||
getDb().prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ?").run(nowIso(), ruleId);
|
||||
},
|
||||
|
||||
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 id from workflow_pipelines where id = ?").get(id) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ?",
|
||||
).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id);
|
||||
} 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[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { copyFile, cp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync, backup as backupDatabase } from "node:sqlite";
|
||||
|
||||
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;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
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(join(deps.profileDir, "vault.key"), join(targetDir, "vault.key")),
|
||||
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")),
|
||||
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 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}".`);
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
await applyBackup(recoveryDir);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { BudgetIncidentRecord, BudgetPolicyRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
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 id = input.id || randomUUID();
|
||||
const ts = nowIso();
|
||||
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,
|
||||
input.metric ?? "billed_cents", input.windowKind ?? "calendar_month_utc",
|
||||
input.amount, input.warnPercent ?? 80,
|
||||
(input.hardStopEnabled ?? true) ? 1 : 0,
|
||||
(input.notifyEnabled ?? true) ? 1 : 0,
|
||||
(input.isActive ?? true) ? 1 : 0, ts, ts,
|
||||
);
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
InboxItem,
|
||||
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;
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
},
|
||||
|
||||
getInbox(companyId: string): InboxItem[] {
|
||||
const items: InboxItem[] = [];
|
||||
const snapshot = deps.listSnapshot({ companyId });
|
||||
const agents = snapshot.agents;
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
// Pending approvals
|
||||
for (const approval of snapshot.approvals.filter(
|
||||
(a) => a.companyId === companyId && a.state === "pending",
|
||||
)) {
|
||||
const requester = agentMap.get(approval.requestedByAgentId ?? "");
|
||||
items.push({
|
||||
id: `approval-${approval.id}`,
|
||||
type: "pending_approval",
|
||||
title: approval.payloadSummary,
|
||||
subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requester?.name ?? "Unknown"}`,
|
||||
severity: approval.type === "dangerous_command" ? "critical" : "high",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
agentId: approval.requestedByAgentId,
|
||||
agentName: requester?.name ?? null,
|
||||
createdAt: approval.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Failed runs (last 24h)
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
for (const run of snapshot.runs.filter(
|
||||
(r) =>
|
||||
r.companyId === companyId &&
|
||||
(r.status === "failed" || r.status === "timed_out") &&
|
||||
r.createdAt > oneDayAgo,
|
||||
)) {
|
||||
const agent = agentMap.get(run.agentId);
|
||||
items.push({
|
||||
id: `run-${run.id}`,
|
||||
type: "failed_run",
|
||||
title: run.summary || "Run failed",
|
||||
subtitle: `${agent?.name ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`,
|
||||
severity: "high",
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
agentId: run.agentId,
|
||||
agentName: agent?.name ?? null,
|
||||
createdAt: run.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Budget warnings (agents at >80% budget)
|
||||
for (const agent of agents.filter(
|
||||
(a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd > 0.8,
|
||||
)) {
|
||||
const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100);
|
||||
items.push({
|
||||
id: `budget-${agent.id}`,
|
||||
type: "budget_warning",
|
||||
title: `${agent.name} at ${pct}% budget`,
|
||||
subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`,
|
||||
severity: pct >= 100 ? "critical" : "high",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
createdAt: agent.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Blocked tasks
|
||||
for (const task of snapshot.tasks.filter(
|
||||
(t) => t.companyId === companyId && t.status === "blocked",
|
||||
)) {
|
||||
const assignee = agentMap.get(task.assigneeAgentId ?? "");
|
||||
items.push({
|
||||
id: `blocked-${task.id}`,
|
||||
type: "blocked_task",
|
||||
title: task.title,
|
||||
subtitle: `Assigned to ${assignee?.name ?? "Unassigned"} — blocked`,
|
||||
severity: task.priority === "critical" ? "critical" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assignee?.name ?? null,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Tasks needing review
|
||||
for (const task of snapshot.tasks.filter(
|
||||
(t) => t.companyId === companyId && t.status === "in_review",
|
||||
)) {
|
||||
const assignee = agentMap.get(task.assigneeAgentId ?? "");
|
||||
items.push({
|
||||
id: `review-${task.id}`,
|
||||
type: "review_needed",
|
||||
title: task.title,
|
||||
subtitle: `Submitted by ${assignee?.name ?? "Unknown"} — awaiting review`,
|
||||
severity: task.priority === "critical" ? "high" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assignee?.name ?? null,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Unread urgent agent messages (company-wide or incident channels that the Board should see)
|
||||
for (const msg of snapshot.agentMessages.filter(
|
||||
(m) =>
|
||||
m.companyId === companyId &&
|
||||
!m.readAt &&
|
||||
(m.channel === "company" || m.channel === "incident" || m.priority === "urgent"),
|
||||
)) {
|
||||
const sender = agentMap.get(msg.fromAgentId);
|
||||
items.push({
|
||||
id: `message-${msg.id}`,
|
||||
type: "agent_message",
|
||||
title: msg.subject,
|
||||
subtitle: `${sender?.name ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`,
|
||||
severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium",
|
||||
entityType: "message",
|
||||
entityId: msg.id,
|
||||
agentId: msg.fromAgentId,
|
||||
agentName: sender?.name ?? null,
|
||||
createdAt: msg.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
severityOrder[a.severity] - severityOrder[b.severity] ||
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CompanyMetrics,
|
||||
AgentMetrics,
|
||||
ProfileSnapshot,
|
||||
RunStatus,
|
||||
StandupReport,
|
||||
} from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
} from "./helpers";
|
||||
|
||||
export function buildCompanySettingsMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics {
|
||||
const snapshot = deps.listSnapshot({ companyId });
|
||||
const agents = snapshot.agents;
|
||||
const tasks = snapshot.tasks;
|
||||
const runs = snapshot.runs;
|
||||
const costs = snapshot.costs;
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const recentRuns = runs.filter((r) => r.createdAt > cutoff);
|
||||
const recentTasks = tasks.filter((t) => t.status === "done" && t.updatedAt > cutoff);
|
||||
|
||||
const successfulRuns = recentRuns.filter((r) => r.status === "succeeded").length;
|
||||
const failedRuns = recentRuns.filter(
|
||||
(r) => r.status === "failed" || r.status === "timed_out",
|
||||
).length;
|
||||
const totalCost = costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0);
|
||||
const completedTasks = tasks.filter((t) => t.status === "done").length;
|
||||
|
||||
const agentMetrics: AgentMetrics[] = agents
|
||||
.filter((a) => a.status !== "pending_approval" && a.status !== "terminated")
|
||||
.map((agent) => {
|
||||
const agentTasks = tasks.filter((t) => t.assigneeAgentId === agent.id);
|
||||
const agentRuns = runs.filter((r) => r.agentId === agent.id);
|
||||
const agentRecentRuns = agentRuns.filter((r) => r.createdAt > cutoff);
|
||||
const agentCosts = costs.filter((c) => c.agentId === agent.id);
|
||||
|
||||
const agentSucceeded = agentRecentRuns.filter((r) => r.status === "succeeded").length;
|
||||
const agentFailed = agentRecentRuns.filter(
|
||||
(r) => r.status === "failed" || r.status === "timed_out",
|
||||
).length;
|
||||
|
||||
// Avg run duration
|
||||
const finishedRuns = agentRuns.filter((r) => r.startedAt && r.finishedAt);
|
||||
const avgDuration =
|
||||
finishedRuns.length > 0
|
||||
? finishedRuns.reduce(
|
||||
(sum, r) =>
|
||||
sum +
|
||||
(new Date(r.finishedAt!).getTime() - new Date(r.startedAt!).getTime()) / 1000,
|
||||
0,
|
||||
) / finishedRuns.length
|
||||
: 0;
|
||||
|
||||
// Review cycles: count task status transitions by this agent
|
||||
// Actual action strings: "task.status_changed" (api-server), "task.updated" (index.ts), "task.auto_assigned" (database)
|
||||
const reviewCycles = snapshot.activity.filter(
|
||||
(a) =>
|
||||
a.companyId === companyId &&
|
||||
a.actor === agent.id &&
|
||||
(a.action === "task.status_changed" ||
|
||||
a.action === "task.updated" ||
|
||||
a.action === "task.auto_assigned"),
|
||||
).length;
|
||||
|
||||
const lastRun = agentRuns.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0];
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
tasksCompleted: agentTasks.filter((t) => t.status === "done").length,
|
||||
tasksAssigned: agentTasks.filter(
|
||||
(t) => t.status !== "done" && t.status !== "cancelled",
|
||||
).length,
|
||||
tasksFailed: agentTasks.filter((t) => t.status === "cancelled").length,
|
||||
successRate:
|
||||
agentRecentRuns.length > 0
|
||||
? Math.round((agentSucceeded / agentRecentRuns.length) * 100)
|
||||
: 0,
|
||||
avgRunDurationSec: Math.round(avgDuration),
|
||||
totalCostUsd: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0),
|
||||
runsTotal: agentRecentRuns.length,
|
||||
runsSucceeded: agentSucceeded,
|
||||
runsFailed: agentFailed,
|
||||
reviewCycles,
|
||||
lastActiveAt: lastRun?.createdAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalAgents: agents.filter(
|
||||
(a) => a.status !== "pending_approval" && a.status !== "terminated",
|
||||
).length,
|
||||
activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks,
|
||||
blockedTasks: tasks.filter((t) => t.status === "blocked").length,
|
||||
totalRuns: recentRuns.length,
|
||||
successfulRuns,
|
||||
failedRuns,
|
||||
overallSuccessRate:
|
||||
recentRuns.length > 0 ? Math.round((successfulRuns / recentRuns.length) * 100) : 0,
|
||||
totalCostUsd: totalCost,
|
||||
avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0,
|
||||
throughputTasksPerDay: days > 0 ? recentTasks.length / days : 0,
|
||||
agentMetrics,
|
||||
};
|
||||
},
|
||||
|
||||
getStandupReport(companyId: string): StandupReport {
|
||||
const snapshot = deps.listSnapshot({ companyId });
|
||||
const agents = snapshot.agents.filter(
|
||||
(a) => a.status !== "terminated" && a.status !== "pending_approval",
|
||||
);
|
||||
const tasks = snapshot.tasks;
|
||||
const runs = snapshot.runs;
|
||||
const costs = snapshot.costs;
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const recentActivity = snapshot.activity.filter(
|
||||
(a) => a.companyId === companyId && a.createdAt > oneDayAgo,
|
||||
);
|
||||
|
||||
const companyHighlights: string[] = [];
|
||||
const blockers: string[] = [];
|
||||
|
||||
const recentCompletedTasks = tasks.filter(
|
||||
(t) => t.status === "done" && t.updatedAt > oneDayAgo,
|
||||
);
|
||||
if (recentCompletedTasks.length > 0) {
|
||||
companyHighlights.push(`${recentCompletedTasks.length} task(s) completed in last 24h`);
|
||||
}
|
||||
|
||||
const recentRuns = runs.filter((r) => r.createdAt > oneDayAgo);
|
||||
const successfulRuns = recentRuns.filter((r) => r.status === "succeeded").length;
|
||||
const failedRuns = recentRuns.filter(
|
||||
(r) => r.status === "failed" || r.status === "timed_out",
|
||||
).length;
|
||||
if (recentRuns.length > 0) {
|
||||
companyHighlights.push(
|
||||
`${recentRuns.length} runs (${successfulRuns} succeeded, ${failedRuns} failed)`,
|
||||
);
|
||||
}
|
||||
|
||||
const blockedTasks = tasks.filter((t) => t.status === "blocked");
|
||||
for (const task of blockedTasks) {
|
||||
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
|
||||
blockers.push(`${task.title} — blocked (${assignee?.name ?? "unassigned"})`);
|
||||
}
|
||||
|
||||
const pendingApprovals = snapshot.approvals.filter(
|
||||
(a) => a.companyId === companyId && a.state === "pending",
|
||||
).length;
|
||||
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;
|
||||
|
||||
// Active sprint progress
|
||||
const activeSprint = snapshot.sprints.find(
|
||||
(s) => s.companyId === companyId && s.status === "active",
|
||||
);
|
||||
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}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unread messages requiring attention
|
||||
const urgentUnread = snapshot.agentMessages.filter(
|
||||
(m) =>
|
||||
m.companyId === companyId &&
|
||||
!m.readAt &&
|
||||
(m.priority === "urgent" || m.channel === "incident"),
|
||||
).length;
|
||||
if (urgentUnread > 0) {
|
||||
blockers.push(`${urgentUnread} urgent unread message(s) need attention`);
|
||||
}
|
||||
|
||||
const agentReports = agents.map((agent) => {
|
||||
const agentTasks = tasks.filter((t) => t.assigneeAgentId === agent.id);
|
||||
const agentRuns = runs.filter((r) => r.agentId === agent.id);
|
||||
const agentRecentRuns = agentRuns.filter((r) => r.createdAt > oneDayAgo);
|
||||
const agentCosts = costs.filter(
|
||||
(c) => c.agentId === agent.id && c.createdAt > oneDayAgo,
|
||||
);
|
||||
const lastRun = agentRecentRuns.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0];
|
||||
|
||||
const highlights: string[] = [];
|
||||
const completedRecently = agentTasks.filter(
|
||||
(t) => t.status === "done" && t.updatedAt > oneDayAgo,
|
||||
);
|
||||
if (completedRecently.length > 0)
|
||||
highlights.push(`Completed ${completedRecently.length} task(s)`);
|
||||
if (agentRecentRuns.length > 0)
|
||||
highlights.push(`${agentRecentRuns.length} run(s) in last 24h`);
|
||||
const reviewedTasks = recentActivity.filter(
|
||||
(a) =>
|
||||
a.actor === agent.id &&
|
||||
(a.action === "task.status_changed" ||
|
||||
a.action === "task.updated" ||
|
||||
a.action === "task.auto_assigned"),
|
||||
).length;
|
||||
if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`);
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
role: agent.role,
|
||||
status: agent.status,
|
||||
tasksInProgress: agentTasks.filter((t) => t.status === "in_progress").length,
|
||||
tasksCompleted24h: completedRecently.length,
|
||||
tasksBlocked: agentTasks.filter((t) => t.status === "blocked").length,
|
||||
lastRunStatus: (lastRun?.status ?? null) as RunStatus | null,
|
||||
lastRunAt: lastRun?.createdAt ?? null,
|
||||
costLast24h: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0),
|
||||
highlights,
|
||||
};
|
||||
});
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
if (agents.length > 0) summaryParts.push(`${agents.length} active agents`);
|
||||
summaryParts.push(`${recentCompletedTasks.length} tasks done`);
|
||||
if (blockedTasks.length > 0) summaryParts.push(`${blockedTasks.length} blocked`);
|
||||
if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`);
|
||||
|
||||
return {
|
||||
companyId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: summaryParts.join(" · "),
|
||||
agentReports,
|
||||
companyHighlights,
|
||||
blockers,
|
||||
pendingApprovals,
|
||||
budgetUtilization,
|
||||
};
|
||||
},
|
||||
|
||||
getSidebarBadges(companyId: string): {
|
||||
pendingApprovals: number;
|
||||
activeRuns: number;
|
||||
failedRuns: number;
|
||||
todoTasks: number;
|
||||
budgetWarnings: number;
|
||||
unreadMessages: number;
|
||||
} {
|
||||
const BOARD_READER_ID = "__board__";
|
||||
const pendingApprovals =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from approvals where company_id = ? and state = 'pending'",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const activeRuns =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from runs where company_id = ? and status in ('queued', 'running')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const failedRuns =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const todoTasks =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const budgetWarnings =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const unreadMessages =
|
||||
(
|
||||
getDb()
|
||||
.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 = ?
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`,
|
||||
)
|
||||
.get(BOARD_READER_ID, companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { buildCompanyCrudMethods } from "./company-crud";
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import type { ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows } from "../connectors";
|
||||
|
||||
|
||||
export function buildConnectorMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
function readConnectors(): ConnectorRecord[] {
|
||||
const rows = readAll<{
|
||||
id: string; label: string; description: string; status: ConnectorRecord["status"];
|
||||
command: string; 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, command, 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 {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: row.id as ConnectorId,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
command: normalizeCommandValue(row.command),
|
||||
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: definition.configurationDoc,
|
||||
capabilityMatrix: definition.capabilityMatrix,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
readConnectors,
|
||||
|
||||
saveConnector(input: {
|
||||
id: ConnectorId;
|
||||
command: string;
|
||||
model: string | null;
|
||||
envBindingText: string;
|
||||
notes: string;
|
||||
}) {
|
||||
const normalizedCommand = normalizeCommandValue(input.command);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update connectors set command = ?, model = ?, env_binding_text = ?, notes = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(normalizedCommand, 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);
|
||||
},
|
||||
|
||||
getConnector(id: ConnectorId): ConnectorRecord {
|
||||
const record = readConnectors().find((entry) => entry.id === id);
|
||||
if (!record) throw new Error(`Connector not found: ${id}`);
|
||||
return record;
|
||||
},
|
||||
|
||||
seedConnectors() {
|
||||
getDb().prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local') and id not like 'claude_compat%'").run();
|
||||
const currentRows = readAll<Partial<ConnectorRecord>>(
|
||||
"select id, label, description, status, command, 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);
|
||||
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.command, row.version, row.authState, row.lastCheckedAt,
|
||||
row.lastError, row.model, row.envBindingText, row.notes, updatedAt,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { ensureId, nowIso, parseJson } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
export function buildDocumentMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
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 id from documents where id = ?").get(id) as { id: string } | undefined;
|
||||
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 = ?",
|
||||
).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", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id);
|
||||
} 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", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
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 = ?",
|
||||
).run(revisionNumber, id, ts, input.documentId);
|
||||
return { id, revisionNumber };
|
||||
},
|
||||
|
||||
getDocumentLatestRevisionId(documentId: string): string | null {
|
||||
const row = getDb().prepare(
|
||||
"SELECT latest_revision_id FROM documents WHERE id = ?",
|
||||
).get(documentId) as { latest_revision_id: string | null } | undefined;
|
||||
return row?.latest_revision_id ?? null;
|
||||
},
|
||||
|
||||
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,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Work Products ──
|
||||
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string; projectId?: string | null;
|
||||
type: string; provider: string; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: string; reviewState?: string;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary) {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
}
|
||||
getDb().prepare(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, input.status ?? "active",
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
},
|
||||
|
||||
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>): void {
|
||||
const ts = nowIso();
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: unknown[] = [ts];
|
||||
const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
if (ALLOWED.has(col)) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null);
|
||||
}
|
||||
}
|
||||
values.push(id, companyId);
|
||||
getDb().prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...(values as SQLInputValue[]));
|
||||
},
|
||||
|
||||
listWorkProductsForTask(taskId: string, companyId: string) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; project_id: string | null; task_id: string;
|
||||
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;
|
||||
created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? 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,
|
||||
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,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteWorkProduct(id: string, companyId: string): void {
|
||||
getDb().prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
// ── 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,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Config Revisions ──
|
||||
|
||||
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<any>(
|
||||
"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: SqliteRow) => ({
|
||||
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 any;
|
||||
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) => any;
|
||||
getAgent: (id: string) => AgentRecord;
|
||||
recordConfigRevision: (input: any) => 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, any>;
|
||||
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 as any).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,188 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentRecord, ConnectorId, TaskRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { ensureId, normalizeOptionalId, nowIso } from "./helpers";
|
||||
|
||||
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;
|
||||
},
|
||||
) {
|
||||
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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, 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 id = ?",
|
||||
)
|
||||
.get(id) as TaskRecord | undefined;
|
||||
if (!row) throw new Error(`Task not found: ${id}`);
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
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, 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 id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
return row;
|
||||
},
|
||||
|
||||
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 }, vault: { encrypt: (v: string) => { backend: string; payload: Uint8Array } }) {
|
||||
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, vault: { decrypt: (backend: "safe_storage" | "vault", ciphertext: Buffer) => string }, listInvalidEnvBindingKeys: (t: string) => string[], parseEnvBindingText: (t: string) => Record<string, 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) {
|
||||
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: any) => string;
|
||||
requestApproval: (input: any) => 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,
|
||||
});
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
|
||||
export type SqliteRow = Record<string, any>;
|
||||
export type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
export type Transaction = <T>(operation: () => T) => T;
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../database";
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { ensureId, nowIso } from "./helpers";
|
||||
|
||||
export function buildKnowledgeMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_readAll: ReadAll,
|
||||
) {
|
||||
return {
|
||||
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 id from meetings where id = ?").get(id) as { id: string } | undefined;
|
||||
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 = ?",
|
||||
).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id);
|
||||
} 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, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.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 id from knowledge_base where id = ?").get(id) as { id: string } | undefined;
|
||||
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 = ?",
|
||||
).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id);
|
||||
} 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", input.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);
|
||||
},
|
||||
|
||||
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 id from sprints where id = ?").get(id) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb().prepare(
|
||||
"update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ?",
|
||||
).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id);
|
||||
} 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
nowIso,
|
||||
} from "./helpers";
|
||||
|
||||
const BOARD_READER_ID = "__board__";
|
||||
|
||||
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;
|
||||
},
|
||||
) {
|
||||
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";
|
||||
}): string {
|
||||
const channel = input.channel ?? "direct";
|
||||
if (channel === "direct" && !input.toAgentId) {
|
||||
throw new Error("Direct messages require a recipient.");
|
||||
}
|
||||
const senderKind = input.senderKind
|
||||
?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent");
|
||||
const id = randomUUID();
|
||||
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,
|
||||
input.body,
|
||||
input.priority ?? "normal",
|
||||
input.parentMessageId ?? null,
|
||||
input.attachmentsJson ?? "[]",
|
||||
nowIso(),
|
||||
);
|
||||
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, read_at as readAt, 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 AgentMessageRecord | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
listAgentMessages(
|
||||
companyId: string,
|
||||
opts?: {
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
unreadOnly?: boolean;
|
||||
limit?: number;
|
||||
},
|
||||
): AgentMessageRecord[] {
|
||||
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, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages 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);
|
||||
}
|
||||
sql += " order by created_at asc";
|
||||
|
||||
let messages = readAll<AgentMessageRecord>(sql, ...params);
|
||||
if (!opts?.agentId) {
|
||||
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]),
|
||||
);
|
||||
messages = messages.map((message) => ({
|
||||
...message,
|
||||
readAt: boardReadReceiptMap.get(message.id) ?? null,
|
||||
}));
|
||||
}
|
||||
if (opts?.agentId) {
|
||||
let department: string | null = null;
|
||||
try {
|
||||
const agent = deps.getAgent(opts.agentId);
|
||||
if (agent.companyId === companyId) {
|
||||
department = agent.department;
|
||||
}
|
||||
} catch {
|
||||
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 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]),
|
||||
);
|
||||
|
||||
messages = messages
|
||||
.filter((message) => {
|
||||
if (message.fromAgentId === opts.agentId) {
|
||||
return true;
|
||||
}
|
||||
if (message.channel === "direct") {
|
||||
return message.toAgentId === opts.agentId;
|
||||
}
|
||||
if (message.channel === "company" || message.channel === "incident") {
|
||||
return true;
|
||||
}
|
||||
if (message.channel === "department") {
|
||||
return Boolean(department) && message.channelTargetId === department;
|
||||
}
|
||||
if (message.channel === "project") {
|
||||
return Boolean(
|
||||
message.channelTargetId && relevantProjectIds.has(message.channelTargetId),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.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);
|
||||
}
|
||||
const limit = opts?.limit ?? 200;
|
||||
return limit > 0 ? messages.slice(-limit) : 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, read_at AS readAt, 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);
|
||||
return readAll<AgentMessageRecord>(sql, ...params);
|
||||
},
|
||||
|
||||
markMessageRead(messageId: string, companyId: string): 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(BOARD_READER_ID, ts, messageId, companyId);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
markAgentMessageRead(
|
||||
messageId: string,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): 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(agentId, ts, messageId, companyId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ConnectorId, RunLogChunk, RunRecord } from "@shared/types";
|
||||
import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildRunMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
deps: {
|
||||
logsDir: string;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
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, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, 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;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
}) {
|
||||
const id = input.id ?? randomUUID();
|
||||
const ts = nowIso();
|
||||
const logPath = join(deps.logsDir, `${id}.log`);
|
||||
transaction(() => {
|
||||
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(id, input.companyId, input.taskId, input.agentId, input.workspaceId, input.connectorId, logPath, ts, ts);
|
||||
getDb().prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId);
|
||||
});
|
||||
writeFileSync(logPath, "", "utf8");
|
||||
return { id, logPath };
|
||||
},
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
markRunStatus(runId: string, status: RunRecord["status"], message: string) {
|
||||
const ts = nowIso();
|
||||
const startedAt = status === "running" ? ts : null;
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? != '' 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;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
unattributedCost: boolean;
|
||||
}) {
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string; agentId: string; companyId: string; connectorId: ConnectorId }
|
||||
| undefined;
|
||||
if (!run) return { budgetHardStopped: false };
|
||||
const ts = nowIso();
|
||||
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 && typeof input.costUsd === "number" && Number.isFinite(input.costUsd)
|
||||
? agent.spentMonthlyUsd + input.costUsd
|
||||
: agent?.spentMonthlyUsd ?? 0;
|
||||
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 = ?, session_display_id = ?, cost_usd = ?, unattributed_cost = ?, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(
|
||||
input.status, input.summary ?? "", input.errorMessage, input.exitCode,
|
||||
input.signal, input.model, input.sessionDisplayId, input.costUsd,
|
||||
input.unattributedCost ? 1 : 0, ts, ts, input.runId,
|
||||
);
|
||||
getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, run.taskId);
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, run.taskId, input.runId);
|
||||
if (typeof input.costUsd === "number" && Number.isFinite(input.costUsd)) {
|
||||
getDb()
|
||||
.prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?")
|
||||
.run(input.costUsd, ts, run.taskId);
|
||||
getDb()
|
||||
.prepare("update agents set spent_monthly_usd = spent_monthly_usd + ?, updated_at = ? where id = ?")
|
||||
.run(input.costUsd, ts, run.agentId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set status = case when budget_monthly_usd > 0 and (spent_monthly_usd + ?) >= budget_monthly_usd then 'paused' else status end, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(input.costUsd, ts, run.agentId);
|
||||
}
|
||||
const taskProjectRow = getDb().prepare("SELECT project_id FROM tasks WHERE id = ?").get(run.taskId) as { project_id: string | null } | undefined;
|
||||
const projectId = taskProjectRow?.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(randomUUID(), run.companyId, input.runId, run.taskId, run.agentId, run.connectorId, input.costUsd, input.unattributedCost ? 1 : 0, projectId, ts);
|
||||
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 };
|
||||
},
|
||||
|
||||
cancelRunForDeletion(runId: string, taskId: string, 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, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(summary, ts, ts, runId);
|
||||
getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId);
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, 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 }) {
|
||||
const whereClauses = ["company_id = ?", "status in ('queued', 'running')"];
|
||||
const params: SQLInputValue[] = [filter.companyId];
|
||||
if (filter.taskId) {
|
||||
whereClauses.push("task_id = ?");
|
||||
params.push(filter.taskId);
|
||||
}
|
||||
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, session_display_id as sessionDisplayId, cost_usd as costUsd,
|
||||
unattributed_cost as unattributedCost, 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; connectorId: string; interruptedAt: string;
|
||||
}> {
|
||||
return readAll<{
|
||||
runId: string; taskId: string; taskTitle: string; agentId: string;
|
||||
agentName: 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.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`
|
||||
);
|
||||
},
|
||||
|
||||
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();
|
||||
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<any>(
|
||||
"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,
|
||||
}));
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import log from "electron-log/main.js";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
|
||||
type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
type Transaction = <T>(operation: () => T) => T;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function ensureOptionalColumns(db: DatabaseSync) {
|
||||
const addColumnIfMissing = (sql: string) => { try { db.prepare(sql).run(); } catch { /* column already exists */ } };
|
||||
addColumnIfMissing("alter table connectors add column updated_at text");
|
||||
addColumnIfMissing("alter table approvals add column payload_json text");
|
||||
addColumnIfMissing("alter table agents add column heartbeat_enabled integer not null default 0");
|
||||
addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120");
|
||||
addColumnIfMissing("alter table agents add column last_heartbeat_at text");
|
||||
addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0");
|
||||
addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0");
|
||||
addColumnIfMissing("alter table companies add column execution_mode text not null default 'ai_ceo'");
|
||||
addColumnIfMissing("alter table tasks add column task_type text not null default 'general'");
|
||||
addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column retry_count integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column last_failure_at text");
|
||||
addColumnIfMissing("alter table tasks add column checkout_run_id text");
|
||||
addColumnIfMissing("alter table tasks add column execution_locked_at text");
|
||||
addColumnIfMissing("alter table tasks add column active_session_id text");
|
||||
addColumnIfMissing("alter table cost_entries add column project_id text");
|
||||
db.exec(`
|
||||
create table if not exists 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 index if not exists idx_comments_task on task_comments(task_id);
|
||||
create index if not exists idx_comments_company on task_comments(company_id);
|
||||
`);
|
||||
db.exec(`
|
||||
create table if not exists 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 index if not exists idx_social_accounts_company on social_accounts(company_id);
|
||||
`);
|
||||
db.exec(`
|
||||
create table if not exists 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 index if not exists idx_browser_actions_company on browser_actions(company_id);
|
||||
create index if not exists idx_browser_actions_account on browser_actions(social_account_id);
|
||||
`);
|
||||
try {
|
||||
db.exec(`
|
||||
create index if not exists idx_messages_company on agent_messages(company_id);
|
||||
create index if not exists idx_messages_channel on agent_messages(channel, channel_target_id);
|
||||
create index if not exists idx_automation_rules_company_status on automation_rules(company_id, status);
|
||||
create index if not exists idx_automation_log_company on automation_log(company_id);
|
||||
`);
|
||||
} catch { /* tables may not exist yet */ }
|
||||
}
|
||||
|
||||
export function migrateSecretsTable(db: DatabaseSync, readAll: ReadAll) {
|
||||
const columns = readAll<{ name: string }>("pragma table_info(secrets)");
|
||||
if (columns.some((column) => column.name === "company_id")) {
|
||||
db.prepare("create unique index if not exists secrets_company_name_idx on secrets(company_id, name)").run();
|
||||
return;
|
||||
}
|
||||
const fallbackCompanyId =
|
||||
((db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined)?.value) ??
|
||||
((db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined)?.id) ??
|
||||
"00000000-0000-0000-0000-000000000000";
|
||||
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(fallbackCompanyId);
|
||||
db.exec("drop table secrets_legacy;");
|
||||
}
|
||||
|
||||
export function initializeDatabase(db: DatabaseSync, ra: ReadAll, tx: Transaction): void {
|
||||
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
||||
db.exec(`
|
||||
create table if not exists settings (key text primary key, value text not null);
|
||||
create table if not exists 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);
|
||||
create table if not exists 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);
|
||||
create table if not exists 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);
|
||||
create table if not exists 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);
|
||||
create table if not exists 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 if not exists 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 if not exists tasks (id text primary key, company_id text not null, project_id text, goal_id text, parent_id text, title text not null, description text not null, assignee_agent_id text, workspace_id text, status text not null, priority text not null, active_run_id text, session_json text, session_display_id text, cost_usd real not null default 0, created_at text not null, updated_at text not null);
|
||||
create table if not exists 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);
|
||||
create table if not exists runs (id text primary key, company_id text not null, task_id text not null, agent_id text not null, workspace_id text, connector_id text not null, status text not null, summary text not null, error_message text, exit_code integer, signal text, model text, session_display_id text, cost_usd real, unattributed_cost integer not null default 0, started_at text, finished_at text, log_path text, created_at text not null, updated_at text not null);
|
||||
create table if not exists 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);
|
||||
create table if not exists 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 if not exists 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.exec(`
|
||||
create index if not exists idx_workspaces_company on workspaces(company_id);
|
||||
create index if not exists idx_agents_company on agents(company_id);
|
||||
create index if not exists idx_goals_company on goals(company_id);
|
||||
create index if not exists idx_projects_company on projects(company_id);
|
||||
create index if not exists idx_tasks_company on tasks(company_id);
|
||||
create index if not exists idx_tasks_assignee on tasks(assignee_agent_id);
|
||||
create index if not exists idx_tasks_project on tasks(project_id);
|
||||
create index if not exists idx_approvals_company on approvals(company_id);
|
||||
create index if not exists idx_runs_company on runs(company_id);
|
||||
create index if not exists idx_runs_task on runs(task_id);
|
||||
create index if not exists idx_runs_agent on runs(agent_id);
|
||||
create index if not exists idx_cost_entries_company on cost_entries(company_id);
|
||||
create index if not exists idx_cost_entries_run on cost_entries(run_id);
|
||||
create index if not exists idx_activity_company on activity(company_id);
|
||||
create index if not exists idx_tasks_company_status on tasks(company_id, status);
|
||||
create index if not exists idx_agents_company_status on agents(company_id, status);
|
||||
create index if not exists idx_agents_reports_to on agents(reports_to);
|
||||
create index if not exists idx_runs_company_status on runs(company_id, status);
|
||||
create index if not exists idx_approvals_company_state on approvals(company_id, state);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
create table if not exists pending_wakes (id text primary key, company_id text not null, agent_id text not null, trigger_reason text not null, created_at text not null);
|
||||
create index if not exists idx_pending_wakes_agent on pending_wakes(agent_id);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status);
|
||||
`);
|
||||
|
||||
migrateSecretsTable(db, ra);
|
||||
ensureOptionalColumns(db);
|
||||
db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`);
|
||||
runMigrations(db, ra, tx);
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
create index if not exists idx_agents_department on agents(company_id, department);
|
||||
create index if not exists idx_runs_company_created_at on runs(company_id, created_at desc);
|
||||
create index if not exists idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc);
|
||||
create index if not exists idx_activity_company_created_at on activity(company_id, created_at desc);
|
||||
create index if not exists idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc);
|
||||
`);
|
||||
} catch { /* column may not exist if migrations failed */ }
|
||||
|
||||
seedSettings(db);
|
||||
}
|
||||
|
||||
export function seedSettings(db: DatabaseSync) {
|
||||
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");
|
||||
}
|
||||
|
||||
export function runMigrations(
|
||||
db: DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
db.exec(`create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null);`);
|
||||
const applied = new Set(readAll<{ version: number }>("select version from schema_migrations").map((row) => row.version));
|
||||
|
||||
const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [
|
||||
{ version: 1, name: "add_workspace_lock_lease", sql: `alter table workspaces add column lock_holder_run_id text; alter table workspaces add column lock_acquired_at text` },
|
||||
{ version: 2, name: "add_approval_related_agent_id", sql: `alter table approvals add column related_agent_id text` },
|
||||
{ version: 3, name: "add_profile_metadata", sql: `insert or ignore into settings (key, value) values ('profile_version', '1'); insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}')` },
|
||||
{ version: 4, name: "add_connector_capability_snapshot", sql: `alter table connectors add column capability_json text` },
|
||||
{ version: 6, name: "add_agent_department", sql: `alter table agents add column department text` },
|
||||
{ version: 7, name: "add_meetings_table", sql: `create table if not exists 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 index if not exists idx_meetings_company on meetings(company_id); create index if not exists idx_meetings_organizer on meetings(organizer_agent_id)` },
|
||||
{ version: 8, name: "add_documents_table", sql: `create table if not exists 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); create index if not exists idx_documents_company on documents(company_id); create index if not exists idx_documents_author on documents(author_agent_id); create index if not exists idx_documents_project on documents(project_id)` },
|
||||
{ version: 9, name: "add_knowledge_base_table", sql: `create table if not exists 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); create index if not exists idx_knowledge_company on knowledge_base(company_id); create index if not exists idx_knowledge_category on knowledge_base(category)` },
|
||||
{ version: 10, name: "add_sprints_table", sql: `create table if not exists 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 index if not exists idx_sprints_company on sprints(company_id)` },
|
||||
{ version: 11, name: "add_automation_messages_workflows", sql: `create table if not exists 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 index if not exists idx_automation_rules_company on automation_rules(company_id); create index if not exists idx_automation_rules_trigger on automation_rules(trigger); create table if not exists 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', read_at text, parent_message_id text, attachments_json text not null default '[]', created_at text not null); create index if not exists idx_agent_messages_company on agent_messages(company_id); create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); create index if not exists idx_agent_messages_channel on agent_messages(channel); create table if not exists 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 index if not exists idx_workflows_company on workflow_pipelines(company_id); create table if not exists 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 index if not exists idx_automation_log_company on automation_log(company_id)` },
|
||||
{ version: 12, name: "add_agent_message_reads", sql: `create table if not exists 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 index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id)` },
|
||||
{ version: 13, name: "add_checkout_session_budget_columns", sql: `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; ALTER TABLE tasks ADD COLUMN active_session_id TEXT; ALTER TABLE cost_entries ADD COLUMN project_id TEXT; CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id)` },
|
||||
{ version: 14, name: "create_skills_budget_wakeup_session_tables", sql: `CREATE TABLE IF NOT EXISTS 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, markdown TEXT NOT NULL DEFAULT '', 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 UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, adapter_type TEXT NOT NULL, session_params_json TEXT, session_display_id TEXT, last_run_id TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, rotated_at TEXT, rotation_reason TEXT, handoff_markdown TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); CREATE TABLE IF NOT EXISTS 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); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)` },
|
||||
{
|
||||
version: 15, name: "migrate_session_data_to_task_sessions",
|
||||
fn: () => {
|
||||
const tasksWithSessions = readAll<{ id: string; companyId: string; assigneeAgentId: string | null; sessionJson: string | null; sessionDisplayId: string | null }>(
|
||||
`SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, session_json as sessionJson, session_display_id as sessionDisplayId FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL`,
|
||||
);
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
for (const task of tasksWithSessions) {
|
||||
try {
|
||||
JSON.parse(task.sessionJson!);
|
||||
const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined;
|
||||
const adapterType = agent?.connector_id ?? "unknown";
|
||||
const sessionId = randomUUID();
|
||||
const ts = nowIso();
|
||||
db.prepare(`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, task.companyId, task.id, task.assigneeAgentId ?? "unassigned", adapterType, task.sessionJson, task.sessionDisplayId, ts, ts);
|
||||
db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?").run(sessionId, task.id);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`);
|
||||
},
|
||||
},
|
||||
{ version: 16, name: "create_agent_skill_assignments", sql: `CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)` },
|
||||
{
|
||||
version: 17, name: "migrate_pending_wakes_to_wakeup_requests",
|
||||
fn: () => {
|
||||
const existingWakes = 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 ts = nowIso();
|
||||
for (const wake of existingWakes) {
|
||||
const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]);
|
||||
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, ?, ?, ?)`).run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts);
|
||||
}
|
||||
db.exec("DROP TABLE IF EXISTS pending_wakes");
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 18, name: "migrate_agent_budget_to_policies",
|
||||
fn: () => {
|
||||
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 ts = nowIso();
|
||||
for (const agent of agents) {
|
||||
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'").get(agent.companyId, agent.id);
|
||||
if (!existing) {
|
||||
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, ?, ?)").run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts);
|
||||
}
|
||||
}
|
||||
log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 19, name: "add_document_revisions_and_approval_comments",
|
||||
fn: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at)`);
|
||||
try { db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); } catch { /* column already exists */ }
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 20, name: "add_task_timestamps_and_task_approval_links",
|
||||
fn: () => {
|
||||
const addCol = (sql: string) => { try { db.prepare(sql).run(); } catch { /* already exists */ } };
|
||||
addCol("ALTER TABLE tasks ADD COLUMN started_at TEXT");
|
||||
addCol("ALTER TABLE tasks ADD COLUMN completed_at TEXT");
|
||||
addCol("ALTER TABLE tasks ADD COLUMN cancelled_at TEXT");
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id)`);
|
||||
db.prepare("UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')").run();
|
||||
db.prepare("UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'").run();
|
||||
db.prepare("UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 21, name: "add_work_products_task_documents_wakeup_context",
|
||||
fn: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS work_products (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, project_id TEXT, task_id TEXT NOT NULL, type TEXT NOT NULL, provider TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, url TEXT, status TEXT NOT NULL DEFAULT 'active', review_state TEXT NOT NULL DEFAULT 'none', is_primary INTEGER NOT NULL DEFAULT 0, summary TEXT, metadata_json TEXT, created_by_run_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); CREATE TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id)`);
|
||||
try { db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ }
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 22, name: "add_config_revisions_run_events_agent_permissions",
|
||||
fn: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id)`);
|
||||
const addCol = (sql: string) => { try { db.prepare(sql).run(); } catch { /* already exists */ } };
|
||||
addCol("ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'");
|
||||
addCol("ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT");
|
||||
addCol("ALTER TABLE agents ADD COLUMN runtime_state_json TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN actor_type TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN actor_id TEXT");
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 23, name: "create_thread_runtime_tables",
|
||||
fn: () => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS conversation_threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'task',
|
||||
related_entity_type TEXT,
|
||||
related_entity_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_threads_company ON conversation_threads(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_events_thread ON conversation_events(thread_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_wait_states (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
|
||||
company_id TEXT NOT NULL,
|
||||
waiting_for TEXT NOT NULL,
|
||||
related_approval_id TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_wait_company ON thread_wait_states(company_id, resolved_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_wait_thread ON thread_wait_states(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
|
||||
participant_id TEXT NOT NULL,
|
||||
participant_type TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, participant_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_participants_participant ON conversation_participants(participant_id);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 24, name: "create_thread_subscriptions",
|
||||
sql: `CREATE TABLE IF NOT EXISTS thread_subscriptions (
|
||||
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
|
||||
subscriber_id TEXT NOT NULL,
|
||||
subscriber_type TEXT NOT NULL,
|
||||
notify_on TEXT NOT NULL DEFAULT 'all',
|
||||
subscribed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, subscriber_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)`,
|
||||
},
|
||||
{
|
||||
version: 25, name: "add_sender_kind_to_messages",
|
||||
sql: `ALTER TABLE agent_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'agent'`,
|
||||
},
|
||||
{
|
||||
version: 26, name: "add_source_run_id_for_causal_binding",
|
||||
sql: `ALTER TABLE documents ADD COLUMN source_run_id TEXT; ALTER TABLE knowledge_base ADD COLUMN source_run_id TEXT`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (applied.has(migration.version)) continue;
|
||||
try {
|
||||
transaction(() => {
|
||||
if (migration.fn) {
|
||||
migration.fn();
|
||||
} else if (migration.sql) {
|
||||
for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) {
|
||||
db.prepare(stmt).run();
|
||||
}
|
||||
}
|
||||
db.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)").run(migration.version, migration.name, nowIso());
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isDuplicateColumn = /duplicate column|already exists/i.test(message);
|
||||
if (isDuplicateColumn) {
|
||||
db.prepare("insert or ignore into schema_migrations (version, name, applied_at) values (?, ?, ?)").run(migration.version, migration.name, nowIso());
|
||||
} else {
|
||||
throw new Error(`Migration ${migration.version} (${migration.name}) failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve, sep } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
|
||||
getDisabledModels(connectorId: string): Array<{ id: string; label: string }> {
|
||||
const raw = getSetting(`disabled_models:${connectorId}`);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw.split(",").filter(Boolean).map((id) => ({ id, label: id }));
|
||||
}
|
||||
},
|
||||
|
||||
setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) {
|
||||
if (models.length === 0) {
|
||||
getDb().prepare("delete from settings where key = ?").run(`disabled_models:${connectorId}`);
|
||||
} else {
|
||||
setSetting(`disabled_models:${connectorId}`, JSON.stringify(models));
|
||||
}
|
||||
},
|
||||
|
||||
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 { /* Log files may already be gone */ }
|
||||
}
|
||||
getDb()
|
||||
.prepare("update runs set log_path = null, updated_at = ? where id = ?")
|
||||
.run(nowIso(), run.id);
|
||||
purged++;
|
||||
}
|
||||
return purged;
|
||||
},
|
||||
|
||||
canOpenPath(targetPath: 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;
|
||||
}
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) {
|
||||
return true;
|
||||
}
|
||||
const workspaces = readAll<{ localPath: string }>("select local_path as localPath from workspaces");
|
||||
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 log_path is not null");
|
||||
return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "../skill-types.js";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso, parseJson } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
export function buildSkillMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
) {
|
||||
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,
|
||||
markdown: row.markdown ?? "",
|
||||
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; markdown: string; 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, markdown,
|
||||
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,
|
||||
markdown = excluded.markdown,
|
||||
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.markdown,
|
||||
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(agentId: string, skillId: string): void {
|
||||
getDb()
|
||||
.prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)")
|
||||
.run(agentId, skillId, nowIso());
|
||||
},
|
||||
|
||||
removeSkillFromAgent(agentId: string, skillId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?")
|
||||
.run(agentId, skillId);
|
||||
},
|
||||
|
||||
listAgentSkillAssignments(agentId: string): string[] {
|
||||
return readAll<{ skill_id: string }>(
|
||||
"SELECT skill_id FROM agent_skill_assignments WHERE agent_id = ?",
|
||||
agentId,
|
||||
).map((row) => row.skill_id);
|
||||
},
|
||||
|
||||
listSkillAgentAssignments(skillId: string): string[] {
|
||||
return readAll<{ agent_id: string }>(
|
||||
"SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?",
|
||||
skillId,
|
||||
).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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CompanyRecord,
|
||||
CommentRecord,
|
||||
CostRecord,
|
||||
GoalRecord,
|
||||
ProfileSnapshot,
|
||||
ProjectRecord,
|
||||
RunRecord,
|
||||
SecretRecord,
|
||||
TaskRecord,
|
||||
WorkspaceRecord,
|
||||
} from "@shared/types";
|
||||
import { type ReadAll, toBoolean } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
const BOARD_READER_ID = "__board__";
|
||||
|
||||
export function buildSnapshotMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
deps: {
|
||||
getCurrentCompanyId: () => string | null;
|
||||
getSetting: (key: string) => string | null;
|
||||
readConnectors: () => any[];
|
||||
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";
|
||||
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<any>(
|
||||
`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, 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${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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, 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${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), 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`,
|
||||
...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, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, 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${companyId ? "" : " limit 500"}`,
|
||||
...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${companyId ? "" : " limit 500"}`,
|
||||
...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${companyId ? "" : " limit 250"}`,
|
||||
...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,
|
||||
})),
|
||||
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`,
|
||||
...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>(
|
||||
`select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`,
|
||||
...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();
|
||||
if (!currentCompanyId) {
|
||||
return listSnapshot();
|
||||
}
|
||||
const snapshot = listSnapshot({ companyId: currentCompanyId });
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { BrowserActionRecord, SocialAccountRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { ensureId, nowIso, toBoolean } from "./helpers";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
|
||||
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 {
|
||||
const id = ensureId(input.id);
|
||||
const now = nowIso();
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
platform = excluded.platform,
|
||||
account_name = excluded.account_name,
|
||||
display_name = excluded.display_name,
|
||||
profile_url = excluded.profile_url,
|
||||
credential_secret_id = excluded.credential_secret_id,
|
||||
status = excluded.status,
|
||||
require_approval = excluded.require_approval,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at`).run(
|
||||
id, input.companyId, input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
input.credentialSecretId ?? null, 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 social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
getDb().prepare("DELETE FROM browser_actions WHERE social_account_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();
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 getAgentSkillWords(
|
||||
db: DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): string[] {
|
||||
const skills = readAll<{ name: string; description: string | null; slug: string }>(
|
||||
`select cs.name, cs.description, cs.slug
|
||||
from company_skills cs
|
||||
join agent_skill_assignments asa on asa.skill_id = cs.id
|
||||
where asa.agent_id = ? and cs.company_id = ?`,
|
||||
agentId,
|
||||
companyId,
|
||||
);
|
||||
return tokenize(skills.map((skill) => `${skill.name} ${skill.description ?? ""} ${skill.slug}`).join(" "));
|
||||
}
|
||||
|
||||
function scoreCandidate(
|
||||
db: DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
companyId: string,
|
||||
taskWords: string[],
|
||||
projectDept: string | null,
|
||||
candidate: AssignmentCandidate,
|
||||
): number {
|
||||
const capabilityWords = tokenize(candidate.capabilities);
|
||||
const skillWords = getAgentSkillWords(db, readAll, companyId, 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;
|
||||
}
|
||||
|
||||
const hasReports = readAll<{ id: string }>(
|
||||
"select id from agents where reports_to = ? and company_id = ? limit 1",
|
||||
candidate.id,
|
||||
companyId,
|
||||
).length > 0;
|
||||
if (!hasReports) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let bestAgentId: string | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const candidate of eligibleCandidates) {
|
||||
const score = scoreCandidate(getDb(), readAll, companyId, taskWords, projectDept, candidate);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { TaskRecord, TaskSessionRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
ensureId,
|
||||
normalizeOptionalId,
|
||||
nowIso,
|
||||
taskPriorityOrderSql,
|
||||
} from "./helpers";
|
||||
|
||||
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;
|
||||
}): 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) {
|
||||
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(
|
||||
"insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, 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,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteTask(id: string, companyId: string): void {
|
||||
const activeTaskRuns = readAll<{
|
||||
id: string;
|
||||
taskId: string;
|
||||
workspaceId: string | null;
|
||||
}>(
|
||||
"select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')",
|
||||
id,
|
||||
companyId,
|
||||
);
|
||||
transaction(() => {
|
||||
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, finished_at = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run("Task deleted.", ts, ts, run.id);
|
||||
getDb()
|
||||
.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?")
|
||||
.run(ts, run.taskId);
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?",
|
||||
)
|
||||
.run(ts, 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);
|
||||
});
|
||||
},
|
||||
|
||||
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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, 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,
|
||||
);
|
||||
},
|
||||
|
||||
countActiveTasksForCompany(companyId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'",
|
||||
)
|
||||
.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,
|
||||
);
|
||||
},
|
||||
|
||||
updateTaskSession(
|
||||
taskId: string,
|
||||
sessionJson: Record<string, unknown> | null,
|
||||
sessionDisplayId: string | null,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update tasks set session_json = ?, session_display_id = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(
|
||||
sessionJson ? JSON.stringify(sessionJson) : null,
|
||||
sessionDisplayId,
|
||||
nowIso(),
|
||||
taskId,
|
||||
);
|
||||
},
|
||||
|
||||
getTaskSession(taskId: string): {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
} {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select session_json as sessionJson, session_display_id as sessionDisplayId from tasks where id = ?",
|
||||
)
|
||||
.get(taskId) as
|
||||
| { sessionJson: string | null; sessionDisplayId: string | null }
|
||||
| undefined;
|
||||
let sessionParams: Record<string, unknown> | null = null;
|
||||
if (row?.sessionJson) {
|
||||
try {
|
||||
sessionParams = JSON.parse(row.sessionJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
sessionParams = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionParams,
|
||||
sessionDisplayId: row?.sessionDisplayId ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
getActiveTaskSession(taskId: string): TaskSessionRecord | null {
|
||||
const task = getDb()
|
||||
.prepare("SELECT active_session_id FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { active_session_id: string | null } | undefined;
|
||||
if (!task?.active_session_id) return null;
|
||||
return (
|
||||
(getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
||||
adapter_type as adapterType, session_params_json as sessionParamsJson,
|
||||
session_display_id as sessionDisplayId, 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(task.active_session_id) 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);
|
||||
},
|
||||
|
||||
createNewSession(input: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
sessionParamsJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
}): string {
|
||||
const sessionId = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
||||
session_params_json, session_display_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
input.companyId,
|
||||
input.taskId,
|
||||
input.agentId,
|
||||
input.adapterType,
|
||||
input.sessionParamsJson,
|
||||
input.sessionDisplayId,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?")
|
||||
.run(sessionId, ts, input.taskId);
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
nowIso,
|
||||
taskPriorityOrderSql,
|
||||
} from "./helpers";
|
||||
import { chooseBestTaskAssignee } from "./task-assignment";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const VALID_TASK_STATUSES = new Set([
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
return {
|
||||
advanceTaskStatus(
|
||||
taskId: string,
|
||||
newStatus: string,
|
||||
comment?: string,
|
||||
): { previousStatus: string } {
|
||||
if (!VALID_TASK_STATUSES.has(newStatus)) {
|
||||
throw new Error(`Invalid task status: ${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 };
|
||||
const ts = nowIso();
|
||||
|
||||
// Status side-effect timestamps (Paperclip pattern)
|
||||
const timestampSets: string[] = ["status = ?", "updated_at = ?"];
|
||||
const timestampValues: (string | null)[] = [newStatus, ts];
|
||||
|
||||
if (newStatus === "in_progress") {
|
||||
// Set startedAt only once (first time entering in_progress)
|
||||
timestampSets.push("started_at = COALESCE(started_at, ?)");
|
||||
timestampValues.push(ts);
|
||||
}
|
||||
if (newStatus === "done") {
|
||||
timestampSets.push("completed_at = ?");
|
||||
timestampValues.push(ts);
|
||||
} else {
|
||||
timestampSets.push("completed_at = NULL");
|
||||
}
|
||||
if (newStatus === "cancelled") {
|
||||
timestampSets.push("cancelled_at = ?");
|
||||
timestampValues.push(ts);
|
||||
} else {
|
||||
timestampSets.push("cancelled_at = NULL");
|
||||
}
|
||||
if (newStatus !== "in_progress") {
|
||||
timestampSets.push("checkout_run_id = NULL");
|
||||
}
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE tasks SET ${timestampSets.join(", ")} WHERE id = ?`)
|
||||
.run(...timestampValues, 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 };
|
||||
},
|
||||
|
||||
findReusableHeartbeatTask(agentId: string, companyId: string): TaskRecord | null {
|
||||
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, active_run_id as activeRunId,
|
||||
session_json as sessionJson, session_display_id as sessionDisplayId,
|
||||
cost_usd as costUsd, requires_user_review as requiresUserReview,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
|
||||
AND status IN ('done', 'in_review')
|
||||
ORDER BY updated_at DESC, rowid DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(companyId, agentId) as TaskRecord | undefined;
|
||||
if (row) {
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
}
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
cleanupDuplicateHeartbeatTasks(companyId: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks SET status = 'cancelled', updated_at = ?
|
||||
WHERE company_id = ? AND title LIKE '[Heartbeat]%'
|
||||
AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog')
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled'
|
||||
) WHERE rn = 1
|
||||
)`,
|
||||
)
|
||||
.run(ts, companyId, companyId);
|
||||
return Number(result.changes);
|
||||
},
|
||||
|
||||
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, t.active_run_id as activeRunId,
|
||||
t.session_json as sessionJson, t.session_display_id as sessionDisplayId,
|
||||
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 {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ?
|
||||
WHERE id = ?
|
||||
AND checkout_run_id IS NULL
|
||||
AND status IN ('todo', 'backlog')`,
|
||||
)
|
||||
.run(runId, ts, ts, taskId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
releaseTaskLock(taskId: string, runId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?",
|
||||
)
|
||||
.run(nowIso(), taskId, runId);
|
||||
},
|
||||
|
||||
renewTaskLease(taskId: string, runId: string): boolean {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"UPDATE tasks SET execution_locked_at = ?, updated_at = ? WHERE id = ? AND checkout_run_id = ?",
|
||||
)
|
||||
.run(ts, 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { buildTaskCrudMethods } from "./tasks-crud";
|
||||
import { buildTaskStatusMethods } from "./tasks-status";
|
||||
|
||||
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;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
...buildTaskCrudMethods(getDb, readAll, transaction, deps),
|
||||
...buildTaskStatusMethods(getDb, readAll, transaction, deps),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type {
|
||||
ConversationEventRecord,
|
||||
ConversationParticipantRecord,
|
||||
ConversationThreadRecord,
|
||||
ThreadActorType,
|
||||
ThreadEventType,
|
||||
ThreadNotifyOn,
|
||||
ThreadParticipantRole,
|
||||
ThreadSubscriptionRecord,
|
||||
ThreadType,
|
||||
ThreadWaitingFor,
|
||||
ThreadWaitStateRecord,
|
||||
} from "@shared/types";
|
||||
|
||||
type SqliteRow = Record<string, any>;
|
||||
type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function buildThreadMethods(getDb: () => DatabaseSync, readAll: ReadAll) {
|
||||
function getThread(threadId: string, companyId: string): ConversationThreadRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at
|
||||
FROM conversation_threads WHERE id = ? AND company_id = ?`,
|
||||
)
|
||||
.get(threadId, companyId) as SqliteRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id as string,
|
||||
companyId: row.company_id as string,
|
||||
title: row.title as string,
|
||||
type: row.type as ThreadType,
|
||||
relatedEntityType: (row.related_entity_type as string | null) ?? null,
|
||||
relatedEntityId: (row.related_entity_id as string | null) ?? null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createThread(input: {
|
||||
companyId: string;
|
||||
title: string;
|
||||
type: ThreadType;
|
||||
relatedEntityType: string | null;
|
||||
relatedEntityId: string | null;
|
||||
}): ConversationThreadRecord {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO conversation_threads (id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, input.companyId, input.title, input.type, input.relatedEntityType, input.relatedEntityId, ts, ts);
|
||||
return {
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
type: input.type,
|
||||
relatedEntityType: input.relatedEntityType,
|
||||
relatedEntityId: input.relatedEntityId,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
},
|
||||
|
||||
listThreads(options: {
|
||||
companyId: string;
|
||||
type?: ThreadType;
|
||||
relatedEntityType?: string;
|
||||
relatedEntityId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): ConversationThreadRecord[] {
|
||||
const conditions: string[] = ["company_id = ?"];
|
||||
const params: SQLInputValue[] = [options.companyId];
|
||||
if (options.type) { conditions.push("type = ?"); params.push(options.type); }
|
||||
if (options.relatedEntityType) { conditions.push("related_entity_type = ?"); params.push(options.relatedEntityType); }
|
||||
if (options.relatedEntityId) { conditions.push("related_entity_id = ?"); params.push(options.relatedEntityId); }
|
||||
const where = conditions.join(" AND ");
|
||||
const limit = options.limit ?? 50;
|
||||
const offset = options.offset ?? 0;
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at
|
||||
FROM conversation_threads WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
...params, limit, offset,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
companyId: r.company_id as string,
|
||||
title: r.title as string,
|
||||
type: r.type as ThreadType,
|
||||
relatedEntityType: (r.related_entity_type as string | null) ?? null,
|
||||
relatedEntityId: (r.related_entity_id as string | null) ?? null,
|
||||
createdAt: r.created_at as string,
|
||||
updatedAt: r.updated_at as string,
|
||||
}));
|
||||
},
|
||||
|
||||
getThread,
|
||||
|
||||
addThreadEvent(input: {
|
||||
threadId: string;
|
||||
actorId: string;
|
||||
actorType: ThreadActorType;
|
||||
eventType: ThreadEventType;
|
||||
body: string;
|
||||
metadataJson: string;
|
||||
}): ConversationEventRecord {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT INTO conversation_events (id, thread_id, actor_id, actor_type, event_type, body, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, input.threadId, input.actorId, input.actorType, input.eventType, input.body, input.metadataJson, ts);
|
||||
db.prepare("UPDATE conversation_threads SET updated_at = ? WHERE id = ?").run(ts, input.threadId);
|
||||
return {
|
||||
id,
|
||||
threadId: input.threadId,
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
eventType: input.eventType,
|
||||
body: input.body,
|
||||
metadataJson: input.metadataJson,
|
||||
createdAt: ts,
|
||||
};
|
||||
},
|
||||
|
||||
listThreadEvents(
|
||||
threadId: string,
|
||||
companyId: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): ConversationEventRecord[] {
|
||||
if (!getThread(threadId, companyId)) return [];
|
||||
const limit = options.limit ?? 50;
|
||||
const offset = options.offset ?? 0;
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT id, thread_id, actor_id, actor_type, event_type, body, metadata_json, created_at
|
||||
FROM conversation_events WHERE thread_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?`,
|
||||
threadId, limit, offset,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
threadId: r.thread_id as string,
|
||||
actorId: r.actor_id as string,
|
||||
actorType: r.actor_type as ThreadActorType,
|
||||
eventType: r.event_type as ThreadEventType,
|
||||
body: r.body as string,
|
||||
metadataJson: r.metadata_json as string,
|
||||
createdAt: r.created_at as string,
|
||||
}));
|
||||
},
|
||||
|
||||
setThreadWaitState(input: {
|
||||
threadId: string;
|
||||
companyId: string;
|
||||
waitingFor: ThreadWaitingFor;
|
||||
relatedApprovalId: string | null;
|
||||
reason: string;
|
||||
}): ThreadWaitStateRecord {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO thread_wait_states (id, thread_id, company_id, waiting_for, related_approval_id, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, input.threadId, input.companyId, input.waitingFor, input.relatedApprovalId, input.reason, ts);
|
||||
return {
|
||||
id,
|
||||
threadId: input.threadId,
|
||||
companyId: input.companyId,
|
||||
waitingFor: input.waitingFor,
|
||||
relatedApprovalId: input.relatedApprovalId,
|
||||
reason: input.reason,
|
||||
createdAt: ts,
|
||||
resolvedAt: null,
|
||||
};
|
||||
},
|
||||
|
||||
resolveThreadWaitState(waitStateId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE thread_wait_states SET resolved_at = ? WHERE id = ? AND company_id = ? AND resolved_at IS NULL`,
|
||||
)
|
||||
.run(ts, waitStateId, companyId);
|
||||
},
|
||||
|
||||
listThreadWaitStates(
|
||||
companyId: string,
|
||||
options: { resolvedOnly?: boolean } = {},
|
||||
): ThreadWaitStateRecord[] {
|
||||
const where = options.resolvedOnly
|
||||
? "company_id = ? AND resolved_at IS NOT NULL"
|
||||
: "company_id = ? AND resolved_at IS NULL";
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT id, thread_id, company_id, waiting_for, related_approval_id, reason, created_at, resolved_at
|
||||
FROM thread_wait_states WHERE ${where} ORDER BY created_at DESC`,
|
||||
companyId,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
threadId: r.thread_id as string,
|
||||
companyId: r.company_id as string,
|
||||
waitingFor: r.waiting_for as ThreadWaitingFor,
|
||||
relatedApprovalId: (r.related_approval_id as string | null) ?? null,
|
||||
reason: r.reason as string,
|
||||
createdAt: r.created_at as string,
|
||||
resolvedAt: (r.resolved_at as string | null) ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Participants ──────────────────────────────────────────────────────
|
||||
|
||||
addParticipant(input: {
|
||||
threadId: string;
|
||||
participantId: string;
|
||||
participantType: ThreadActorType;
|
||||
role?: ThreadParticipantRole;
|
||||
}): ConversationParticipantRecord {
|
||||
const ts = nowIso();
|
||||
const role = input.role ?? "member";
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO conversation_participants (thread_id, participant_id, participant_type, role, joined_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(input.threadId, input.participantId, input.participantType, role, ts);
|
||||
return {
|
||||
threadId: input.threadId,
|
||||
participantId: input.participantId,
|
||||
participantType: input.participantType,
|
||||
role,
|
||||
joinedAt: ts,
|
||||
};
|
||||
},
|
||||
|
||||
listParticipants(threadId: string): ConversationParticipantRecord[] {
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT thread_id, participant_id, participant_type, role, joined_at
|
||||
FROM conversation_participants WHERE thread_id = ? ORDER BY joined_at ASC`,
|
||||
threadId,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
threadId: r.thread_id as string,
|
||||
participantId: r.participant_id as string,
|
||||
participantType: r.participant_type as ThreadActorType,
|
||||
role: r.role as ThreadParticipantRole,
|
||||
joinedAt: r.joined_at as string,
|
||||
}));
|
||||
},
|
||||
|
||||
removeParticipant(threadId: string, participantId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM conversation_participants WHERE thread_id = ? AND participant_id = ?")
|
||||
.run(threadId, participantId);
|
||||
},
|
||||
|
||||
// ── Subscriptions ────────────────────────────────────────────────────
|
||||
|
||||
subscribe(input: {
|
||||
threadId: string;
|
||||
subscriberId: string;
|
||||
subscriberType: ThreadActorType;
|
||||
notifyOn?: ThreadNotifyOn;
|
||||
}): ThreadSubscriptionRecord {
|
||||
const ts = nowIso();
|
||||
const notifyOn = input.notifyOn ?? "all";
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO thread_subscriptions (thread_id, subscriber_id, subscriber_type, notify_on, subscribed_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(input.threadId, input.subscriberId, input.subscriberType, notifyOn, ts);
|
||||
return {
|
||||
threadId: input.threadId,
|
||||
subscriberId: input.subscriberId,
|
||||
subscriberType: input.subscriberType,
|
||||
notifyOn,
|
||||
subscribedAt: ts,
|
||||
};
|
||||
},
|
||||
|
||||
unsubscribe(threadId: string, subscriberId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM thread_subscriptions WHERE thread_id = ? AND subscriber_id = ?")
|
||||
.run(threadId, subscriberId);
|
||||
},
|
||||
|
||||
listSubscriptions(threadId: string): ThreadSubscriptionRecord[] {
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT thread_id, subscriber_id, subscriber_type, notify_on, subscribed_at
|
||||
FROM thread_subscriptions WHERE thread_id = ? ORDER BY subscribed_at ASC`,
|
||||
threadId,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
threadId: r.thread_id as string,
|
||||
subscriberId: r.subscriber_id as string,
|
||||
subscriberType: r.subscriber_type as ThreadActorType,
|
||||
notifyOn: r.notify_on as ThreadNotifyOn,
|
||||
subscribedAt: r.subscribed_at as string,
|
||||
}));
|
||||
},
|
||||
|
||||
listSubscribedThreads(subscriberId: string, companyId: string): ConversationThreadRecord[] {
|
||||
const rows = readAll<SqliteRow>(
|
||||
`SELECT ct.id, ct.company_id, ct.title, ct.type, ct.related_entity_type, ct.related_entity_id, ct.created_at, ct.updated_at
|
||||
FROM conversation_threads ct
|
||||
JOIN thread_subscriptions ts ON ct.id = ts.thread_id
|
||||
WHERE ts.subscriber_id = ? AND ct.company_id = ?
|
||||
ORDER BY ct.updated_at DESC`,
|
||||
subscriberId, companyId,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
companyId: r.company_id as string,
|
||||
title: r.title as string,
|
||||
type: r.type as ThreadType,
|
||||
relatedEntityType: (r.related_entity_type as string | null) ?? null,
|
||||
relatedEntityId: (r.related_entity_id as string | null) ?? null,
|
||||
createdAt: r.created_at as string,
|
||||
updatedAt: r.updated_at as string,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
export function buildWakeupMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
function parseTriggerDetail(raw: string | null): Array<{ trigger?: string; at?: string; detail?: string }> {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => entry && typeof entry === "object") as Array<{ trigger?: string; at?: string; detail?: string }> : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function choosePreferredTrigger(triggers: string[]): string | null {
|
||||
const unique = new Set(triggers.filter(Boolean));
|
||||
const preferredOrder = [
|
||||
"message",
|
||||
"peer_request",
|
||||
"comment_mentioned",
|
||||
"approval_resolved",
|
||||
"subtask_completed",
|
||||
"report_blocked",
|
||||
"report_failed",
|
||||
"comment",
|
||||
"assignment",
|
||||
"manual",
|
||||
"timer",
|
||||
];
|
||||
for (const trigger of preferredOrder) {
|
||||
if (unique.has(trigger)) return trigger;
|
||||
}
|
||||
return triggers[0] ?? null;
|
||||
}
|
||||
|
||||
function mergeContextSnapshots(existingJson: string | null, incomingJson: string | null): string | null {
|
||||
if (!incomingJson) return existingJson;
|
||||
if (!existingJson) return incomingJson;
|
||||
const existing: Record<string, unknown> = JSON.parse(existingJson);
|
||||
const incoming: Record<string, unknown> = JSON.parse(incomingJson);
|
||||
return JSON.stringify({ ...existing, ...incoming });
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record<string, unknown>): void {
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
const existing = getDb()
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail, coalesced_count, context_snapshot
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as
|
||||
| { id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail
|
||||
? JSON.parse(existing.trigger_detail)
|
||||
: [];
|
||||
triggers.push({ trigger, at: ts, detail });
|
||||
let mergedContext: string | null = existing.context_snapshot;
|
||||
if (context) {
|
||||
const prev: Record<string, unknown> = existing.context_snapshot ? JSON.parse(existing.context_snapshot) : {};
|
||||
const merged = { ...prev, ...context };
|
||||
mergedContext = JSON.stringify(merged);
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests
|
||||
SET trigger_detail = ?,
|
||||
coalesced_count = coalesced_count + 1,
|
||||
context_snapshot = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(JSON.stringify(triggers), mergedContext, ts, existing.id);
|
||||
} else {
|
||||
const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]);
|
||||
const contextJson = context ? JSON.stringify(context) : null;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO wakeup_requests
|
||||
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, context_snapshot, requested_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, contextJson, ts, ts, ts);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
promoteWakeupRequests(agentId: string, companyId: string): number {
|
||||
let promoted = 0;
|
||||
transaction(() => {
|
||||
const queued = getDb()
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail, context_snapshot FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as { id: string; trigger_detail: string | null; context_snapshot: string | null } | undefined;
|
||||
const deferred = readAll<{ id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }>(
|
||||
`SELECT id, trigger_detail, coalesced_count, context_snapshot FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`,
|
||||
agentId, companyId,
|
||||
);
|
||||
if (deferred.length === 0) return;
|
||||
const ts = nowIso();
|
||||
if (queued) {
|
||||
const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
let mergedCtx = queued.context_snapshot;
|
||||
for (const d of deferred) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
queuedTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
mergedCtx = mergeContextSnapshots(mergedCtx, d.context_snapshot);
|
||||
}
|
||||
getDb().prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(queuedTriggers), addedCount, mergedCtx, ts, queued.id);
|
||||
for (const d of deferred) {
|
||||
getDb().prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
promoted = deferred.length;
|
||||
} else {
|
||||
const [first, ...rest] = deferred;
|
||||
getDb().prepare(
|
||||
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
|
||||
).run(ts, first!.id);
|
||||
if (rest.length > 0) {
|
||||
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
let mergedCtx = first!.context_snapshot;
|
||||
for (const d of rest) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
firstTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
mergedCtx = mergeContextSnapshots(mergedCtx, d.context_snapshot);
|
||||
}
|
||||
getDb().prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id);
|
||||
for (const d of rest) {
|
||||
getDb().prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
}
|
||||
promoted = deferred.length;
|
||||
}
|
||||
});
|
||||
return promoted;
|
||||
},
|
||||
|
||||
listWakeupRequests(companyId: string, filters?: { status?: string; agentId?: string; limit?: number }): Array<{
|
||||
id: string; companyId: string; agentId: string; source: string; triggerDetail: string | null;
|
||||
status: string; coalescedCount: number; runId: string | null; requestedAt: string;
|
||||
claimedAt: string | null; finishedAt: string | null; createdAt: string; updatedAt: string;
|
||||
}> {
|
||||
const conditions = ["company_id = ?"];
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
if (filters?.status) { conditions.push("status = ?"); params.push(filters.status); }
|
||||
if (filters?.agentId) { conditions.push("agent_id = ?"); params.push(filters.agentId); }
|
||||
const limit = filters?.limit ?? 50;
|
||||
return readAll(
|
||||
`SELECT id, company_id AS companyId, agent_id AS agentId, source, trigger_detail AS triggerDetail,
|
||||
status, coalesced_count AS coalescedCount, run_id AS runId, requested_at AS requestedAt,
|
||||
claimed_at AS claimedAt, finished_at AS finishedAt, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wakeup_requests
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT ?`,
|
||||
...params, limit,
|
||||
);
|
||||
},
|
||||
|
||||
getPreferredWakeTrigger(agentId: string, companyId: string): string | null {
|
||||
const rows = readAll<{ source: string; trigger_detail: string | null }>(
|
||||
`SELECT source, trigger_detail
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')
|
||||
ORDER BY requested_at ASC`,
|
||||
agentId,
|
||||
companyId,
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const triggers: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.source) triggers.push(row.source);
|
||||
for (const entry of parseTriggerDetail(row.trigger_detail)) {
|
||||
if (typeof entry.trigger === "string" && entry.trigger.length > 0) {
|
||||
triggers.push(entry.trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return choosePreferredTrigger(triggers);
|
||||
},
|
||||
|
||||
completePendingWakeupRequests(agentId: string, companyId: string, runId: string): number {
|
||||
const ts = nowIso();
|
||||
const result = getDb().prepare(
|
||||
`UPDATE wakeup_requests
|
||||
SET status = 'completed',
|
||||
run_id = ?,
|
||||
claimed_at = COALESCE(claimed_at, ?),
|
||||
finished_at = ?,
|
||||
updated_at = ?
|
||||
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')`,
|
||||
).run(runId, ts, ts, ts, agentId, companyId);
|
||||
return Number(result.changes);
|
||||
},
|
||||
|
||||
purgeExpiredWakeupRequests() {
|
||||
const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = getDb().prepare("DELETE FROM wakeup_requests WHERE created_at < ? AND status IN ('completed')").run(cutoff);
|
||||
return Number(result.changes);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunStatus, WorkspaceRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { ensureId, normalizeOptionalId, nowIso, toBoolean } from "./helpers";
|
||||
|
||||
export function buildWorkspaceMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
saveWorkspace(input: {
|
||||
id?: string | null;
|
||||
companyId: string;
|
||||
name: string;
|
||||
localPath: string;
|
||||
repoUrl: string;
|
||||
repoRef: string;
|
||||
projectId?: string | null;
|
||||
isPrimary: boolean;
|
||||
}) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary) {
|
||||
getDb().prepare("update workspaces set is_primary = 0 where company_id = ?").run(input.companyId);
|
||||
}
|
||||
const existing = getDb().prepare("select id from workspaces where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update workspaces set name = ?, local_path = ?, repo_url = ?, repo_ref = ?, project_id = ?, is_primary = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, id, input.companyId);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into workspaces (id, company_id, name, local_path, repo_url, repo_ref, project_id, state, is_primary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?)",
|
||||
)
|
||||
.run(id, input.companyId, input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, ts);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
getWorkspace(id: string | null | undefined): WorkspaceRecord | null {
|
||||
if (!id) return null;
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"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 where id = ?",
|
||||
)
|
||||
.get(id) as WorkspaceRecord | undefined;
|
||||
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
|
||||
},
|
||||
|
||||
getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string): WorkspaceRecord | null {
|
||||
if (!projectId) return null;
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"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 where company_id = ? and project_id = ? order by is_primary desc, created_at asc limit 1",
|
||||
)
|
||||
.get(companyId, projectId) as WorkspaceRecord | undefined;
|
||||
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
|
||||
},
|
||||
|
||||
listWorkspaceLocalPaths(companyId: string): string[] {
|
||||
const rows = getDb()
|
||||
.prepare("select local_path as localPath from workspaces where company_id = ? and local_path is not null")
|
||||
.all(companyId) as Array<{ localPath: string }>;
|
||||
return rows.map((r) => r.localPath).filter(Boolean);
|
||||
},
|
||||
|
||||
getAnyCompanyWorkspace(companyId: string): WorkspaceRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"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 where company_id = ? order by is_primary desc, created_at asc limit 1",
|
||||
)
|
||||
.get(companyId) as WorkspaceRecord | undefined;
|
||||
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
|
||||
},
|
||||
|
||||
deleteWorkspace(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb().prepare("update agents set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("update tasks set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId);
|
||||
getDb().prepare("delete from workspaces where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
acquireWorkspaceLock(workspaceId: string, runId: string): boolean {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
|
||||
where id = ?
|
||||
and (lock_holder_run_id is null or lock_holder_run_id = ?)`,
|
||||
)
|
||||
.run(runId, ts, ts, workspaceId, runId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
claimWorkspaceLock(workspaceId: string, runId: string): boolean {
|
||||
const holder = getDb()
|
||||
.prepare("select lock_holder_run_id as lockHolderRunId from workspaces where id = ?")
|
||||
.get(workspaceId) as { lockHolderRunId: string | null } | undefined;
|
||||
if (!holder) return false;
|
||||
if (!holder.lockHolderRunId || holder.lockHolderRunId === runId) {
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
|
||||
where id = ?
|
||||
and (lock_holder_run_id is null or lock_holder_run_id = ?)`,
|
||||
)
|
||||
.run(runId, ts, ts, workspaceId, runId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
const blockingRun = getDb()
|
||||
.prepare("select status from runs where id = ?")
|
||||
.get(holder.lockHolderRunId) as { status: RunStatus } | undefined;
|
||||
if (blockingRun && (blockingRun.status === "queued" || blockingRun.status === "running")) {
|
||||
return false;
|
||||
}
|
||||
const ts = nowIso();
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
|
||||
where id = ? and lock_holder_run_id = ?`,
|
||||
)
|
||||
.run(runId, ts, ts, workspaceId, holder.lockHolderRunId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
releaseWorkspaceLock(workspaceId: string, runId: string) {
|
||||
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(nowIso(), workspaceId, runId);
|
||||
},
|
||||
|
||||
releaseAllWorkspaceLocks() {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update workspaces set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? where lock_holder_run_id is not null",
|
||||
)
|
||||
.run(nowIso());
|
||||
},
|
||||
|
||||
releaseStaleWorkspaceLocks(): number {
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`update workspaces
|
||||
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
|
||||
where lock_holder_run_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from runs
|
||||
where runs.id = workspaces.lock_holder_run_id
|
||||
and runs.status in ('queued', 'running')
|
||||
)`,
|
||||
)
|
||||
.run(nowIso());
|
||||
return Number(result.changes);
|
||||
},
|
||||
|
||||
isWorkspaceLocked(workspaceId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare("select lock_holder_run_id from workspaces where id = ?")
|
||||
.get(workspaceId) as { lock_holder_run_id: string | null } | undefined;
|
||||
return Boolean(row?.lock_holder_run_id);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user