spb/note-spboucher-ai
Public
1/** Approval step (API context): applies approved proposals through the pages service, records every decision in `approvals`. */2import { and, eq } from "drizzle-orm";3import { db, schema } from "@/db";4import { getPage, updatePage } from "@/lib/pages/service";5import { markdownToDoc } from "@/lib/editor/markdown-client";6import { HttpError } from "@/lib/auth/guard";7import { audit } from "@/lib/audit";8import type { PMNode } from "@/lib/editor/text";9import type { Proposal } from "./types";1011export async function decideProposals(ws: { id: string }, userId: string, executionId: string, decision: { approve: number[]; reject: number[] }) {12 const [ex] = await db.select().from(schema.aiExecutions).where(and(eq(schema.aiExecutions.id, executionId), eq(schema.aiExecutions.workspaceId, ws.id)));13 if (!ex) throw new HttpError(404, "Execution not found");14 if (ex.status !== "awaiting_approval") throw new HttpError(409, `Execution is ${ex.status}, nothing to approve`);15 const proposals = (ex.proposedChanges ?? []) as Proposal[]; const approvals = [...(ex.approvals ?? [])];16 const at = new Date().toISOString(); const applied: number[] = []; const failed: { idx: number; error: string }[] = [];17 const touch = (idx: number) => proposals[idx] && proposals[idx].status === "proposed";18 for (const idx of decision.reject) { if (!touch(idx)) continue; proposals[idx].status = "rejected"; proposals[idx].decidedAt = at; approvals.push({ change: idx, approved: false, at }); await audit(userId, "agent.proposal_reject", { type: "execution", id: executionId }, { change: idx, kind: proposals[idx].kind }); }19 for (const idx of decision.approve) {20 if (!touch(idx)) continue; const p = proposals[idx]; p.status = "approved"; p.decidedAt = at; approvals.push({ change: idx, approved: true, at });21 try { p.result = await applyOne(ws.id, ex.model, p); p.status = "applied"; p.appliedAt = new Date().toISOString(); applied.push(idx); await audit(userId, "agent.proposal_apply", { type: "execution", id: executionId }, { change: idx, kind: p.kind, result: p.result as any }); }22 catch (e: any) { p.status = "failed"; p.error = String(e?.message ?? e).slice(0, 300); failed.push({ idx, error: p.error }); await audit(userId, "agent.proposal_fail", { type: "execution", id: executionId }, { change: idx, error: p.error }); }23 }24 const pending = proposals.filter((p) => p.status === "proposed").length;25 const anyApplied = proposals.some((p) => p.status === "applied");26 const status = pending ? "awaiting_approval" : anyApplied ? "approved" : "rejected";27 const outcome = pending ? `${proposals.length - pending}/${proposals.length} decided · ${pending} pending` : `${proposals.filter((p) => p.status === "applied").length} applied · ${proposals.filter((p) => p.status === "rejected").length} rejected${failed.length ? ` · ${failed.length} failed` : ""}`;28 await db.update(schema.aiExecutions).set({ proposedChanges: proposals as any, approvals, status, outcome }).where(eq(schema.aiExecutions.id, executionId));29 return { status, applied, failed, pending };30}3132async function applyOne(wsId: string, model: string | null, p: Proposal): Promise<unknown> {33 switch (p.kind) {34 case "page_edit": {35 const page = await getPage(wsId, p.payload.pageId);36 const blocks = markdownToDoc(p.payload.markdown);37 const content: PMNode = p.payload.mode === "append" ? { type: "doc", content: [...(((page.content as PMNode).content ?? []).filter((n, i, a) => !(a.length === 1 && n.type === "paragraph" && !n.content))), ...blocks] } : { type: "doc", content: blocks };38 const r = await updatePage(wsId, page.id, { content }, { versionReason: `agent ${p.payload.mode}` });39 return { pageId: r.id, version: r.version };40 }41 case "task": {42 const [t] = await db.insert(schema.tasks).values({ workspaceId: wsId, pageId: p.payload.pageId, title: p.payload.title, priority: p.payload.priority, dueAt: p.payload.dueAt ? new Date(p.payload.dueAt) : null, project: p.payload.project, status: "todo" }).returning({ id: schema.tasks.id });43 return { taskId: t.id };44 }45 case "link": {46 await getPage(wsId, p.payload.fromPageId); await getPage(wsId, p.payload.toPageId);47 await db.insert(schema.links).values({ fromPageId: p.payload.fromPageId, toPageId: p.payload.toPageId, kind: "ai_suggested" }).onConflictDoNothing();48 const [e] = await db.insert(schema.knowledgeEdges).values({ workspaceId: wsId, fromId: p.payload.fromPageId, toId: p.payload.toPageId, kind: "ai_suggested", status: "accepted", confidence: p.payload.confidence, rationale: `${p.payload.relation}: ${p.rationale}`.slice(0, 1000), model, decidedAt: new Date() }).onConflictDoUpdate({ target: [schema.knowledgeEdges.fromId, schema.knowledgeEdges.toId, schema.knowledgeEdges.kind], set: { status: "accepted", decidedAt: new Date(), confidence: p.payload.confidence, model } }).returning({ id: schema.knowledgeEdges.id });49 return { edgeId: e?.id ?? null };50 }51 case "property": {52 const page = await getPage(wsId, p.payload.pageId);53 await updatePage(wsId, page.id, { properties: { ...(page.properties as Record<string, unknown>), [p.payload.key]: p.payload.value } });54 return { pageId: page.id, key: p.payload.key };55 }56 }57}58