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:
134
apps/coder/src/routes/plans.ts
Normal file
134
apps/coder/src/routes/plans.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user