214 lines
10 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|
|
}
|