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