Files
AgentCompany/src/main/database/approvals.ts
T
2026-04-04 12:03:50 -04:00

214 lines
10 KiB
TypeScript

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);
}
},
};
}