/** * blueprint — Plan→Confirm→Execute two-phase persistence (Task #176). * * A Blueprint is a frozen record of "what the planner intends to do": * - which problem_class * - which network + active variant * - the fully-specified input args * - per-task seed list (so the runner is deterministic) * * The user (or the Reviewer) must approve a draft before the runner * walks it. The blueprint id is referenced from result.zip's log, so * audits can replay any past run. */ import { desc, eq } from "drizzle-orm"; import { db, executionPlans, type ExecutionPlanRow, type InsertExecutionPlanRow, } from "@workspace/db"; import { newId } from "./ids"; export type PlanStatus = | "draft" | "approved" | "running" | "completed" | "failed" | "cancelled"; export interface BlueprintBody { /** Network name (matches tool_networks.name). */ networkName: string; /** Network input as the planner constructed it. */ input: Record; /** Per-task seed list — runner consumes (taskKey, params) tuples. */ tasks: Array<{ taskKey: string; params: Record }>; /** Planner notes for the audit trail. */ notes?: string; } export interface CreateBlueprintInput { conversationId?: string | null; ownerUserId?: string | null; problemClassPath: string; networkId: string; versionId: string; body: BlueprintBody; notes?: string; } export async function createBlueprint( input: CreateBlueprintInput, ): Promise { const row: InsertExecutionPlanRow = { id: newId("eplan"), conversationId: input.conversationId ?? null, ownerUserId: input.ownerUserId ?? null, problemClassPath: input.problemClassPath, networkId: input.networkId, versionId: input.versionId, blueprint: input.body, status: "draft", notes: input.notes ?? "", }; const [inserted] = await db.insert(executionPlans).values(row).returning(); return inserted!; } export async function getBlueprint( id: string, ): Promise { const rows = await db .select() .from(executionPlans) .where(eq(executionPlans.id, id)) .limit(1); return rows[0] ?? null; } export async function listBlueprints( ownerUserId?: string | null, limit = 50, ): Promise { let q = db.select().from(executionPlans).$dynamic(); if (ownerUserId) q = q.where(eq(executionPlans.ownerUserId, ownerUserId)); return q.orderBy(desc(executionPlans.createdAt)).limit(limit); } export async function approveBlueprint( id: string, approvedBy: string, ): Promise { const [updated] = await db .update(executionPlans) .set({ status: "approved", approvedBy, approvedAt: new Date(), updatedAt: new Date(), }) .where(eq(executionPlans.id, id)) .returning(); if (!updated) throw new Error(`blueprint ${id} not found`); return updated; } export async function setBlueprintStatus( id: string, status: PlanStatus, patch?: { artifactPath?: string | null; notes?: string }, ): Promise { const [updated] = await db .update(executionPlans) .set({ status, ...(patch?.artifactPath !== undefined ? { outputArtifactPath: patch.artifactPath } : {}), ...(patch?.notes !== undefined ? { notes: patch.notes } : {}), updatedAt: new Date(), }) .where(eq(executionPlans.id, id)) .returning(); if (!updated) throw new Error(`blueprint ${id} not found`); return updated; } export function blueprintBody(row: ExecutionPlanRow): BlueprintBody { return row.blueprint as BlueprintBody; }