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.
135 lines
4.1 KiB
TypeScript
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 };
|
|
});
|
|
}
|