/** * 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 }; }); }