fix: connect native Workbench to Kafka SSE

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-18 05:53:23 +02:00
parent 3fa9e8541e
commit ae24fcb899
4 changed files with 149 additions and 6 deletions
+60
View File
@@ -6,6 +6,7 @@ export async function runWorkbenchCli(argv: string[], env: Record<string, string
const parsed = parse(argv);
if (parsed.help || parsed.positionals.length === 0) return help();
if (parsed.positionals[0] === "service") return workbenchNativeServiceCommand({ service: parsed.positionals[1], action: parsed.positionals[2] ?? "status", cwd: process.cwd(), env });
if (parsed.positionals[0] === "events" && parsed.positionals[1] === "inspect") return inspectEvents(parsed, env);
const command = commandFrom(parsed, env);
if (parsed.overApi) return overApi(command, parsed, env);
const runtime = workbenchRuntime(env);
@@ -26,6 +27,7 @@ function help() {
"hwlab-cli workbench health [--over-api]",
"hwlab-cli workbench session create --actor-id ID [--provider-profile PROFILE] [--over-api]",
"hwlab-cli workbench turn submit --actor-id ID --session-id ID --message TEXT [--trace-id ID] [--over-api]",
"hwlab-cli workbench events inspect --session-id ID [--trace-id ID] --over-api [--timeout-ms MS] [--min-events N]",
"hwlab-cli workbench turn cancel --actor-id ID --trace-id ID [--over-api]",
"hwlab-cli workbench service api|worker|web start|stop|restart|status|logs"
],
@@ -36,6 +38,53 @@ function help() {
};
}
async function inspectEvents(parsed: Parsed, env: Record<string, string | undefined>) {
if (!parsed.overApi) throw codedError("workbench_events_over_api_required", "workbench events inspect requires --over-api so it exercises the product SSE route");
const baseUrl = parsed.values["api-url"] || env.WORKBENCH_API_URL;
if (!baseUrl) throw codedError("workbench_api_url_required", "WORKBENCH_API_URL or --api-url is required with --over-api");
const sessionId = parsed.values["session-id"] ?? "";
const traceId = parsed.values["trace-id"] ?? "";
if (!sessionId && !traceId) throw codedError("workbench_realtime_scope_required", "--session-id or --trace-id is required");
const timeoutMs = positiveInteger(parsed.values["timeout-ms"], 5000);
const minEvents = positiveInteger(parsed.values["min-events"], 1);
const params = new URLSearchParams();
if (sessionId) params.set("sessionId", sessionId);
if (traceId) params.set("traceId", traceId);
const path = `/v1/workbench/events?${params.toString()}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const frames: Array<{ name: string; data: Record<string, any> }> = [];
let buffer = "";
try {
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${path}`, { signal: controller.signal });
if (!response.ok || !response.body) throw codedError("workbench_sse_open_failed", `Workbench SSE returned HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const item = await reader.read();
if (item.done) break;
buffer += decoder.decode(item.value, { stream: true });
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const frame = parseSseBlock(block);
if (frame) frames.push(frame);
if (frames.filter((entry) => entry.name === "hwlab.event.v1").length >= minEvents) controller.abort();
boundary = buffer.indexOf("\n\n");
}
}
} catch (error: any) {
if (error?.name !== "AbortError") throw error;
} finally {
clearTimeout(timer);
}
const business = frames.filter((entry) => entry.name === "hwlab.event.v1");
const connected = frames.some((entry) => entry.name === "workbench.connected");
const ok = connected && business.length >= minEvents;
return { ok, operation: "events.inspect", status: ok ? "passed" : "event-timeout", transport: "api-sse", baseUrl, route: `GET ${path}`, identity: { sessionId: sessionId || null, traceId: traceId || null }, connected, eventCount: business.length, eventTypes: business.map((entry) => String(entry.data?.event?.eventType ?? entry.data?.event?.type ?? entry.data?.eventType ?? "unknown")), timeoutMs, minEvents, error: ok ? undefined : { code: "workbench_kafka_sse_event_missing", message: "Workbench SSE did not deliver the required Kafka business events within the bounded window" }, valuesPrinted: false };
}
function commandFrom(parsed: Parsed, env: Record<string, string | undefined>): WorkbenchCommand {
const [group, action] = parsed.positionals;
if (group === "health") return { operation: "health" };
@@ -77,6 +126,17 @@ function parse(argv: string[]): Parsed {
return parsed;
}
function identity(data: any) { return { sessionId: data?.sessionId ?? data?.session?.sessionId ?? null, traceId: data?.traceId ?? null, workflowId: data?.workflowId ?? null, workflowRunId: data?.workflowRunId ?? null }; }
function record(value: unknown): Record<string, any> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : null; }
function positiveInteger(value: unknown, fallback: number): number { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; }
function parseSseBlock(block: string): { name: string; data: Record<string, any> } | null {
if (!block || block.startsWith(":")) return null;
const lines = block.split(/\r?\n/u);
const name = lines.find((line) => line.startsWith("event:"))?.slice(6).trim() || "message";
const dataText = lines.filter((line) => line.startsWith("data:" )).map((line) => line.slice(5).trimStart()).join("\n");
if (!dataText) return null;
const data = JSON.parse(dataText);
return { name, data: record(data) ?? {} };
}
function compact(value: Record<string, unknown>) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); }
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw codedError("option_required", `--${key} is required`); return value; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }