/** * Boulder state — cross-session plan persistence for BooCode. * * Plans live above flow_runs: a plan tracks a user's work goal and can link to * a flow run for automatic progress tracking. When the linked flow run reaches * a terminal state (completed/failed/cancelled), the plan is auto-updated. * * Auto-resumption: on startup, plans with a linked in-flight flow_run are * surfaced via the GET endpoint so the UI can show a resume prompt. The * flow-runner's initResume() re-advances the actual run; this store surfaces * the plan-level view. */ import type { Sql } from '../db.js'; export interface Plan { id: string; project_id: string; title: string; description: string | null; status: string; flow_run_id: string | null; progress_pct: number; items_total: number; items_completed: number; metadata: Record | null; created_at: Date; updated_at: Date; } export interface CreatePlanOpts { projectId: string; title: string; description?: string; flowRunId?: string; metadata?: Record; } export interface UpdatePlanOpts { title?: string; description?: string | null; status?: 'active' | 'completed' | 'cancelled' | 'failed'; progressPct?: number; itemsTotal?: number; itemsCompleted?: number; metadata?: Record | null; } export function createPlan(sql: Sql, opts: CreatePlanOpts): Promise { return sql` INSERT INTO plans (project_id, title, description, flow_run_id, metadata) VALUES ( ${opts.projectId}, ${opts.title}, ${opts.description ?? null}, ${opts.flowRunId ?? null}, ${opts.metadata ? sql.json(opts.metadata as never) : null} ) RETURNING * `.then((rows) => rows[0] as unknown as Plan); } export function getPlan(sql: Sql, planId: string): Promise { return sql` SELECT * FROM plans WHERE id = ${planId} `.then((rows) => (rows[0] as unknown as Plan) ?? null); } export function listPlans(sql: Sql, projectId: string): Promise { return sql` SELECT * FROM plans WHERE project_id = ${projectId} ORDER BY created_at DESC LIMIT 100 ` as Promise; } export function listActivePlans(sql: Sql, projectId: string): Promise { return sql` SELECT * FROM plans WHERE project_id = ${projectId} AND status = 'active' ORDER BY created_at DESC ` as Promise; } export async function updatePlan( sql: Sql, planId: string, opts: UpdatePlanOpts, ): Promise { const sets: string[] = []; const values: unknown[] = []; if (opts.title !== undefined) { sets.push(`title = $${values.length + 1}`); values.push(opts.title); } if (opts.description !== undefined) { sets.push(`description = $${values.length + 1}`); values.push(opts.description); } if (opts.status !== undefined) { sets.push(`status = $${values.length + 1}`); values.push(opts.status); } if (opts.progressPct !== undefined) { sets.push(`progress_pct = $${values.length + 1}`); values.push(opts.progressPct); } if (opts.itemsTotal !== undefined) { sets.push(`items_total = $${values.length + 1}`); values.push(opts.itemsTotal); } if (opts.itemsCompleted !== undefined) { sets.push(`items_completed = $${values.length + 1}`); values.push(opts.itemsCompleted); } if (opts.metadata !== undefined) { sets.push(`metadata = $${values.length + 1}::jsonb`); values.push(opts.metadata !== null ? JSON.stringify(opts.metadata) : null); } if (sets.length === 0) return getPlan(sql, planId); sets.push(`updated_at = clock_timestamp()`); const query = ` UPDATE plans SET ${sets.join(', ')} WHERE id = $${values.length + 1} RETURNING * `; values.push(planId); const result = await sql.unsafe(query, values as never[]); return (result[0] as unknown as Plan) ?? null; } /** * Called when a flow run reaches a terminal state. Updates the linked plan's * status based on the run outcome: * - completed → plan completed * - failed → plan failed * - cancelled → plan cancelled * Returns true when a plan was updated, false when no plan is linked to the run. */ export async function updatePlanFromRun( sql: Sql, runId: string, runStatus: 'completed' | 'failed' | 'cancelled', ): Promise { const planStatus = planStatusFromRun(runStatus); const updated = await sql` UPDATE plans SET status = ${planStatus}, progress_pct = 100, items_completed = items_total, updated_at = clock_timestamp() WHERE flow_run_id = ${runId} AND status = 'active' `; return updated.count > 0; } /** Map a flow-run terminal status to its corresponding plan status. Pure. */ export function planStatusFromRun(runStatus: 'completed' | 'failed' | 'cancelled'): string { return runStatus === 'completed' ? 'completed' : runStatus; } /** * Find any active plan linked to a running flow run — used by the startup * resume path to surface plans that have in-flight orchestrator runs. */ export async function findPlanWithRunningRun( sql: Sql, projectId: string, ): Promise<(Plan & { run_status: string }) | null> { const [row] = await sql` SELECT p.*, fr.status AS run_status FROM plans p JOIN flow_runs fr ON fr.id = p.flow_run_id WHERE p.project_id = ${projectId} AND p.status = 'active' AND fr.status = 'running' ORDER BY p.created_at DESC LIMIT 1 `; return (row as unknown as Plan & { run_status: string }) ?? null; }