feat(coder): boulder state — cross-session plan persistence + auto-resumption
New plans table (id, project_id, title, description, status, flow_run_id, progress_pct, items_total, items_completed, metadata, timestamps) with CHECK constraints and indexes. Plan store (plan-store.ts): createPlan, getPlan, listPlans, listActivePlans, updatePlan, updatePlanFromRun, findPlanWithRunningRun, planStatusFromRun. Flow-runner integration: onRunTerminal callback fires on every terminal transition (complete/fail/cancel) and updates linked plans automatically. 5 API endpoints: GET /api/plans, GET /api/plans/active, GET /api/plans/:id, POST /api/plans, PATCH /api/plans/:id. 484 tests pass, build clean.
This commit is contained in:
184
apps/coder/src/services/plan-store.ts
Normal file
184
apps/coder/src/services/plan-store.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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<string, unknown> | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CreatePlanOpts {
|
||||
projectId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
flowRunId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdatePlanOpts {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
status?: 'active' | 'completed' | 'cancelled' | 'failed';
|
||||
progressPct?: number;
|
||||
itemsTotal?: number;
|
||||
itemsCompleted?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function createPlan(sql: Sql, opts: CreatePlanOpts): Promise<Plan> {
|
||||
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<Plan | null> {
|
||||
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<Plan[]> {
|
||||
return sql`
|
||||
SELECT * FROM plans
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
` as Promise<Plan[]>;
|
||||
}
|
||||
|
||||
export function listActivePlans(sql: Sql, projectId: string): Promise<Plan[]> {
|
||||
return sql`
|
||||
SELECT * FROM plans
|
||||
WHERE project_id = ${projectId} AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
` as Promise<Plan[]>;
|
||||
}
|
||||
|
||||
export async function updatePlan(
|
||||
sql: Sql,
|
||||
planId: string,
|
||||
opts: UpdatePlanOpts,
|
||||
): Promise<Plan | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user