Files
boocode/apps/coder/src/routes/plans.ts
indifferentketchup 31e5d9d4ab 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.
2026-06-08 01:11:07 +00:00

135 lines
4.1 KiB
TypeScript

/**
* Boulder state — plan routes.
*
* GET /api/plans?project_id= — list plans for a project
* GET /api/plans/active?project_id= — list active (in-flight) plans
* POST /api/plans — create a new plan
* PATCH /api/plans/:id — update plan progress / status
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
createPlan,
getPlan,
listPlans,
listActivePlans,
updatePlan,
} from '../services/plan-store.js';
const CreatePlanBody = z.object({
project_id: z.string().uuid(),
title: z.string().min(1).max(500),
description: z.string().max(10_000).optional(),
flow_run_id: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
});
const ListPlansQuery = z.object({
project_id: z.string().uuid(),
});
const UpdatePlanBody = z.object({
title: z.string().min(1).max(500).optional(),
description: z.string().max(10_000).nullable().optional(),
status: z.enum(['active', 'completed', 'cancelled', 'failed']).optional(),
progress_pct: z.number().int().min(0).max(100).optional(),
items_total: z.number().int().min(0).optional(),
items_completed: z.number().int().min(0).optional(),
metadata: z.record(z.unknown()).nullable().optional(),
});
const PlanIdParam = z.string().uuid();
export function registerPlanRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/plans?project_id= — all plans for a project
app.get('/api/plans', async (req, reply) => {
const parsed = ListPlansQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const plans = await listPlans(sql, parsed.data.project_id);
return { plans };
});
// GET /api/plans/active?project_id= — active plans only
app.get('/api/plans/active', async (req, reply) => {
const parsed = ListPlansQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const plans = await listActivePlans(sql, parsed.data.project_id);
return { plans };
});
// POST /api/plans — create a new plan
app.post('/api/plans', async (req, reply) => {
const parsed = CreatePlanBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, title, description, flow_run_id, metadata } = parsed.data;
const plan = await createPlan(sql, {
projectId: project_id,
title,
description,
flowRunId: flow_run_id,
metadata,
});
reply.code(201);
return { plan };
});
// GET /api/plans/:id — single plan
app.get<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
const parsedId = PlanIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const plan = await getPlan(sql, parsedId.data);
if (!plan) {
reply.code(404);
return { error: 'plan not found' };
}
return { plan };
});
// PATCH /api/plans/:id — update plan
app.patch<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
const parsedId = PlanIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const parsed = UpdatePlanBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { title, description, status, progress_pct, items_total, items_completed, metadata } = parsed.data;
const plan = await updatePlan(sql, parsedId.data, {
title,
description: description === null ? null : description,
status,
progressPct: progress_pct,
itemsTotal: items_total,
itemsCompleted: items_completed,
metadata: metadata === null ? null : metadata,
});
if (!plan) {
reply.code(404);
return { error: 'plan not found' };
}
return { plan };
});
}