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