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.
This commit is contained in:
75
apps/web/src/hooks/useCoderUserEvents.ts
Normal file
75
apps/web/src/hooks/useCoderUserEvents.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Coder user-channel WS — mirrors useUserEvents but connects to the BooCoder
|
||||
// host service's /api/coder/ws/user endpoint. Forwards flow_run_started and
|
||||
// flow_run_step_updated frames onto the sessionEvents bus so OrchestratorPane
|
||||
// can subscribe to run-level lifecycle updates without a per-session WS.
|
||||
//
|
||||
// Event-dedup discipline: do NOT additionally emit these frames locally after
|
||||
// a POST /api/coder/runs call — this hook forwards the authoritative WS frame.
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import type { FlowRunStartedEvent, FlowRunStepUpdatedEvent } from './sessionEvents';
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
|
||||
export function useCoderUserEvents(): void {
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
let unmounted = false;
|
||||
|
||||
const connect = () => {
|
||||
if (unmounted) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/user`);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(ev.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const validated = WsFrameSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
console.error('ws-frame-validation-failed (coder user channel)', {
|
||||
frame_type: (raw as { type?: unknown })?.type,
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const frame = validated.data;
|
||||
if (frame.type === 'flow_run_started') {
|
||||
sessionEvents.emit(frame as unknown as FlowRunStartedEvent);
|
||||
} else if (frame.type === 'flow_run_step_updated') {
|
||||
sessionEvents.emit(frame as unknown as FlowRunStepUpdatedEvent);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted) return;
|
||||
const delay = reconnectDelay;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try { ws?.close(); } catch {}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) try { ws.close(); } catch {}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user