diff --git a/src/lib/conductor/bridge.ts b/src/lib/conductor/bridge.ts index 610e7f4d1b..5839f9cb31 100644 --- a/src/lib/conductor/bridge.ts +++ b/src/lib/conductor/bridge.ts @@ -7,6 +7,8 @@ * TaskManager uses `cancelled` (2 L) — the mapping here is explicit and tested. */ +import { z } from "zod"; + import type { A2ATaskManager, TaskState } from "@/lib/a2a/taskManager"; export interface ConductorEvent { @@ -105,3 +107,144 @@ export function applyConductorEvent(tm: A2ATaskManager, index: Map void; + backoffBaseMs?: number; +} + +export interface ConductorBridge { + start(): void; + stop(): void; + state(): "connected" | "reconnecting" | "stopped"; +} + +const BACKOFF_CAP_MS = 30_000; + +/** + * Long-lived consumer: connects to the hub SSE with the persisted cursor, mirrors + * events, reconnects with exponential backoff. A hub outage never propagates — + * errors are logged and retried; `stop()` aborts for good. + */ +export function createConductorBridge(opts: ConductorBridgeOptions): ConductorBridge { + const log = opts.log ?? ((msg: string) => console.log(`[conductor-bridge] ${msg}`)); + const doFetch = opts.fetchImpl ?? fetch; + const backoffBase = opts.backoffBaseMs ?? 1_000; + const index = new Map(); + let state: "connected" | "reconnecting" | "stopped" = "stopped"; + let abort: AbortController | null = null; + let attempt = 0; + let running = false; + + async function readStream(res: Response): Promise { + const reader = res.body?.getReader(); + if (!reader) throw new Error("SSE response without body"); + const parser = new SseParser(); + const decoder = new TextDecoder(); + for (;;) { + const { value, done } = await reader.read(); + if (done) return; + for (const frame of parser.push(decoder.decode(value, { stream: true }))) { + let parsed: z.infer; + try { + parsed = hubEventSchema.parse(JSON.parse(frame.data)); + } catch { + log(`evento SSE malformado ignorado (id=${frame.id ?? "?"})`); + continue; + } + applyConductorEvent(opts.tm, index, parsed); + opts.cursor.set(parsed.id); // persisted per event — replay covers any gap on reconnect + } + } + } + + async function loop(): Promise { + while (running) { + abort = new AbortController(); + try { + const since = opts.cursor.get() ?? "0"; + const res = await doFetch(`${opts.hubUrl}/v1/events?last_event_id=${encodeURIComponent(since)}`, { + headers: { authorization: `Bearer ${opts.token}`, accept: "text/event-stream" }, + signal: abort.signal, + }); + if (!res.ok) throw new Error(`hub respondeu HTTP ${res.status}`); + state = "connected"; + attempt = 0; + log(`conectado ao hub (last_event_id=${since})`); + await readStream(res); // resolves when the hub closes the stream + throw new Error("stream encerrado pelo hub"); + } catch (err) { + if (!running) break; + state = "reconnecting"; + const wait = Math.min(backoffBase * 2 ** attempt, BACKOFF_CAP_MS); + attempt++; + log(`conexão caiu (${err instanceof Error ? err.message : String(err)}) — reconectando em ${wait}ms`); + await new Promise((resolve) => setTimeout(resolve, wait)); + } + } + state = "stopped"; + } + + return { + start() { + if (running) return; + running = true; + void loop(); + }, + stop() { + running = false; + state = "stopped"; + abort?.abort(); + }, + state: () => state, + }; +} diff --git a/tests/unit/conductor-bridge-sse.test.ts b/tests/unit/conductor-bridge-sse.test.ts new file mode 100644 index 0000000000..754d936c13 --- /dev/null +++ b/tests/unit/conductor-bridge-sse.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { SseParser } from "../../src/lib/conductor/bridge.ts"; + +test("whole event in a single chunk", () => { + const p = new SseParser(); + const out = p.push('id: 7\nevent: task.created\ndata: {"task_id":"t_1"}\n\n'); + assert.equal(out.length, 1); + assert.deepEqual(out[0], { id: "7", event: "task.created", data: '{"task_id":"t_1"}' }); +}); + +test("event fragmented across three chunks (cut mid-data)", () => { + const p = new SseParser(); + assert.equal(p.push("id: 8\nevent: task.sch").length, 0); + assert.equal(p.push('eduled\ndata: {"task_id"').length, 0); + const out = p.push(':"t_2"}\n\n'); + assert.equal(out.length, 1); + assert.deepEqual(out[0], { id: "8", event: "task.scheduled", data: '{"task_id":"t_2"}' }); +}); + +test("two events in one chunk", () => { + const p = new SseParser(); + const out = p.push("id: 1\nevent: a\ndata: x\n\nid: 2\nevent: b\ndata: y\n\n"); + assert.equal(out.length, 2); + assert.equal(out[0].id, "1"); + assert.equal(out[1].data, "y"); +}); + +test("comment pings are ignored", () => { + const p = new SseParser(); + assert.equal(p.push(": ping\n\n").length, 0); + const out = p.push(": ping\n\nid: 3\nevent: c\ndata: z\n\n"); + assert.equal(out.length, 1); + assert.equal(out[0].id, "3"); +}); + +test("multi-line data joins with newline (SSE spec)", () => { + const p = new SseParser(); + const out = p.push("event: m\ndata: line1\ndata: line2\n\n"); + assert.equal(out.length, 1); + assert.equal(out[0].data, "line1\nline2"); +});