fix: connect native Workbench to Kafka SSE
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }); }
|
||||
|
||||
Reference in New Issue
Block a user