Files
boocode/apps/coder/src/routes/runs.ts
indifferentketchup 1937af8df9 feat: in-app Orchestrator (Phase 2) — multi-agent conductor
Brings the deterministic Han-flow conductor into BooCode: launch any read-only
flow from BooChat or BooCoder, watch each agent stream live in a Paseo-style
run pane, get an evidence-disciplined report — on local Qwen, persisted and
resumable. Read-only enforced hard via qwen --approval-mode plan (orchestrator
tasks fail closed if qwen is unavailable; never fall to write-capable native).

Backend (apps/coder): re-homed conductor defs, flow_runs/flow_steps schema,
flow-runner + dispatcher onTaskTerminal hook, restart-resume, runs routes
(launch/list/get/cancel), user-channel WS. Contracts: two flow_run_* frames.
Web: orchestrator pane kind + OrchestratorPane, Workflow button + slash flows
(BooChat/BooCoder parity), FlowLauncherDialog, "New Orchestrator" in the + and
split menus, runs history + export. Plan: openspec/changes/orchestrator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:22:48 +00:00

163 lines
4.9 KiB
TypeScript

/**
* Phase 6 — Orchestrator run routes.
*
* POST /api/runs — launch a flow run (validated, calls flow-runner)
* GET /api/runs?project_id= — runs history for the NewPaneMenu surface
* GET /api/runs/:id — run + steps + report (reopen a pane)
* POST /api/runs/:id/cancel — mark run + steps cancelled, abort in-flight tasks
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { FlowRunner } from '../services/flow-runner.js';
import type { ExternalCancelFn } from './tasks.js';
import { FLOW_NAMES } from '../conductor/flows/index.js';
const CreateRunBody = z.object({
project_id: z.string().uuid(),
flow_name: z.string().min(1).max(100),
band: z.enum(['small', 'medium', 'large']),
input: z.object({
question: z.string().min(1).max(64_000),
}).passthrough(),
model: z.string().max(200).optional(),
});
const ListRunsQuery = z.object({
project_id: z.string().uuid(),
});
const RunIdParam = z.string().uuid();
export function registerRunsRoutes(
app: FastifyInstance,
sql: Sql,
flowRunner: FlowRunner,
cancelExternal: ExternalCancelFn,
): void {
// POST /api/runs — launch a flow run
app.post('/api/runs', async (req, reply) => {
const parsed = CreateRunBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, flow_name, band, input, model } = parsed.data;
if (!FLOW_NAMES.includes(flow_name)) {
reply.code(422);
return { error: 'unknown_flow', message: `unknown flow: ${flow_name}`, known_flows: FLOW_NAMES };
}
const { runId } = await flowRunner.launch({ projectId: project_id, flowName: flow_name, band, input, model });
reply.code(201);
return { run_id: runId };
});
// GET /api/runs?project_id= — runs history, most-recent-first
app.get('/api/runs', async (req, reply) => {
const parsed = ListRunsQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const runs = await sql`
SELECT id, project_id, flow_name, band, model, status, input, report, error, created_at, updated_at
FROM flow_runs
WHERE project_id = ${parsed.data.project_id}
ORDER BY created_at DESC
LIMIT 100
`;
return { runs };
});
// GET /api/runs/:id — single run + its steps + report (reopen)
app.get<{ Params: { id: string } }>('/api/runs/:id', async (req, reply) => {
const parsedId = RunIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [run] = await sql<{
id: string;
project_id: string;
flow_name: string;
band: string;
model: string;
status: string;
input: unknown;
report: string | null;
error: string | null;
created_at: unknown;
updated_at: unknown;
}[]>`
SELECT id, project_id, flow_name, band, model, status, input, report, error, created_at, updated_at
FROM flow_runs
WHERE id = ${id}
`;
if (!run) {
reply.code(404);
return { error: 'run not found' };
}
const steps = await sql`
SELECT fs.id, fs.run_id, fs.step_id, fs.kind, fs.agent, fs.status,
fs.task_id, fs.chat_id, fs.input, fs.output, fs.error,
fs.created_at, fs.updated_at,
c.session_id
FROM flow_steps fs
LEFT JOIN chats c ON c.id = fs.chat_id
WHERE fs.run_id = ${id}
ORDER BY fs.created_at
`;
return { run, steps };
});
// POST /api/runs/:id/cancel — cancel a running flow run
app.post<{ Params: { id: string } }>('/api/runs/:id/cancel', async (req, reply) => {
const parsedId = RunIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
// Verify the run exists
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM flow_runs WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'run not found' };
}
if (row.status !== 'running') {
reply.code(409);
return { error: `cannot cancel run in status '${row.status}'` };
}
// Cancel via flow-runner: marks run + steps cancelled, publishes frames,
// returns task_ids of any in-flight step tasks.
const { cancelled, taskIds } = await flowRunner.cancel(id);
if (!cancelled) {
// Race: another path (e.g. natural completion) settled the run first.
reply.code(409);
return { error: 'run is no longer running' };
}
// Abort any in-flight dispatcher tasks so qwen exits promptly.
for (const taskId of taskIds) {
cancelExternal(taskId);
}
return { cancelled: true };
});
}