Merge pull request #2453 from pikasTech/feat/hwlab-kafka-sse-debug
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

feat: stream HWLAB Kafka events to debug SSE
This commit is contained in:
Lyon
2026-07-10 02:36:58 +08:00
committed by GitHub
7 changed files with 641 additions and 8 deletions
+46
View File
@@ -206,6 +206,52 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
};
}
export async function openKafkaEventStream({ env = process.env, stream = "hwlab", topic = null, traceId = null, sessionId = null, runId = null, commandId = null, fromBeginning = false, onEvent, onError = null, kafkaFactory = defaultKafkaFactory } = {}) {
if (typeof onEvent !== "function") throw new Error("onEvent callback is required for Kafka event streaming.");
const brokers = csv(env.HWLAB_KAFKA_BOOTSTRAP_SERVERS);
if (brokers.length === 0) throw new Error("HWLAB_KAFKA_BOOTSTRAP_SERVERS is required for Kafka event streams.");
const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID) || DEFAULT_CLIENT_ID;
const resolvedTopic = stringValue(topic) || kafkaTopicForStream(stream, env);
const filters = compactObject({ traceId, sessionId, runId, commandId });
const kafka = kafkaFactory({ brokers, clientId: `${clientId}-sse` });
const consumer = kafka.consumer({ groupId: `${clientId}-sse-${Date.now()}-${randomUUID().slice(0, 8)}`, allowAutoTopicCreation: false });
let running = false;
let stopped = false;
await consumer.connect();
await consumer.subscribe({ topic: resolvedTopic, fromBeginning: fromBeginning === true });
running = true;
consumer.run({
eachMessage: async ({ topic: messageTopic, partition, message }) => {
if (stopped) return;
const valueText = message.value ? Buffer.from(message.value).toString("utf8") : "";
const value = parseJson(valueText);
if (!value || !eventMatchesFilters(value, filters)) return;
await onEvent({
topic: messageTopic,
partition,
offset: message.offset,
key: message.key ? Buffer.from(message.key).toString("utf8") : null,
timestamp: message.timestamp ?? null,
value
});
}
}).catch((error) => {
if (!stopped && typeof onError === "function") onError(error);
});
return {
ok: true,
stream,
topic: resolvedTopic,
filters,
async stop() {
stopped = true;
if (running) await consumer.stop().catch(() => undefined);
await boundedDisconnect(consumer);
},
valuesPrinted: false
};
}
function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }) {
const base = {
traceId,
+7 -1
View File
@@ -71,6 +71,7 @@ import {
} from "./server-code-agent-http.ts";
import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
import { handleWorkbenchKafkaSseDebugHttp } from "./workbench-kafka-sse-debug.ts";
import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts";
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
import { startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
@@ -742,6 +743,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/workbench/debug/kafka-sse" || url.pathname.startsWith("/v1/workbench/debug/kafka-sse/")) {
await handleWorkbenchKafkaSseDebugHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/workbench/sync" || url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) {
await handleWorkbenchReadModelHttp(request, response, url, options);
return;
@@ -973,7 +979,7 @@ function navIdForRestPath(pathname, method = "GET") {
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/")) return "workbench.debug";
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/") || pathname === "/v1/workbench/debug/kafka-sse" || pathname.startsWith("/v1/workbench/debug/kafka-sse/")) return "workbench.debug";
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/sync" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "bun:test";
import { handleWorkbenchKafkaSseDebugHttp } from "./workbench-kafka-sse-debug.ts";
test("workbench Kafka SSE debug endpoint streams filtered raw HWLAB Kafka events", async () => {
const fakeKafka = createFakeKafkaFactory();
const env = {
HWLAB_KAFKA_BOOTSTRAP_SERVERS: "127.0.0.1:9092",
HWLAB_KAFKA_CLIENT_ID: "test-hwlab-cloud-api",
HWLAB_KAFKA_EVENT_TOPIC: "hwlab.event.v1"
};
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
void handleWorkbenchKafkaSseDebugHttp(request, response, url, { env, kafkaFactory: fakeKafka.factory, logger: null });
});
await listen(server);
const abort = new AbortController();
try {
const response = await fetch(`${serverUrl(server)}/v1/workbench/debug/kafka-sse/events?stream=hwlab&sessionId=ses_kafka_sse_debug`, { signal: abort.signal });
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/u);
const reader = response.body?.getReader();
assert.ok(reader, "SSE response must expose a readable stream");
const connected = await readUntil(reader, "hwlab.kafka.connected");
assert.match(connected, /hwlab\.event\.v1/u);
await fakeKafka.emit({ eventType: "hwlab.trace.event.projected", sessionId: "ses_other", event: { label: "ignored" } });
await fakeKafka.emit({ eventType: "hwlab.trace.event.projected", sessionId: "ses_kafka_sse_debug", traceId: "trc_kafka_sse_debug", context: { runId: "run_kafka_sse_debug", commandId: "cmd_kafka_sse_debug" }, event: { label: "agentrun:event:debug", status: "running" } });
const streamed = await readUntil(reader, "trc_kafka_sse_debug");
assert.match(streamed, /hwlab\.kafka\.event/u);
assert.match(streamed, /ses_kafka_sse_debug/u);
assert.doesNotMatch(streamed, /ses_other/u);
} finally {
abort.abort();
await close(server);
}
});
function createFakeKafkaFactory() {
let eachMessage: ((input: any) => Promise<void>) | null = null;
const consumer = {
connect: async () => undefined,
subscribe: async () => undefined,
run: async (input: any) => { eachMessage = input.eachMessage; },
stop: async () => undefined,
disconnect: async () => undefined
};
return {
factory: () => ({ consumer: () => consumer }),
emit: async (value: Record<string, unknown>) => {
assert.ok(eachMessage, "consumer.run must be called before emitting fake Kafka events");
await eachMessage({
topic: "hwlab.event.v1",
partition: 0,
message: {
offset: String(value.sessionId === "ses_other" ? 1 : 2),
key: Buffer.from(String(value.sessionId ?? "unknown")),
timestamp: "2026-07-09T18:30:00.000Z",
value: Buffer.from(JSON.stringify(value))
}
});
}
};
}
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, pattern: string): Promise<string> {
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + 3000;
while (!text.includes(pattern)) {
if (Date.now() > deadline) throw new Error(`SSE stream did not include ${pattern}: ${text}`);
const next = await reader.read();
if (next.done) break;
text += decoder.decode(next.value, { stream: true });
}
return text;
}
async function listen(server: ReturnType<typeof createServer>) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(server: ReturnType<typeof createServer>) {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
function serverUrl(server: ReturnType<typeof createServer>): string {
const address = server.address();
assert.equal(typeof address, "object");
assert.ok(address && typeof address.port === "number");
return `http://127.0.0.1:${address.port}`;
}
+176
View File
@@ -0,0 +1,176 @@
/*
* SPEC: PJ2026-0104010803 Workbench事件流可见性 draft-2026-07-09-p0-kafka-authority.
* Responsibility: debug-only SSE passthrough for HWLAB Kafka event envelopes.
*/
import { sendJson } from "./server-http-utils.ts";
import { openKafkaEventStream } from "./kafka-event-bridge.ts";
const CONTRACT_VERSION = "workbench-debug-kafka-sse-v1";
const DEFAULT_STREAM = "hwlab";
const ALLOWED_STREAMS = new Set(["hwlab", "agentrun"]);
export async function handleWorkbenchKafkaSseDebugHttp(request, response, url, options = {}) {
const route = routeSuffix(url.pathname);
try {
if (route === "") {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
return sendJson(response, 200, describeKafkaSseDebug(url, options.env ?? process.env));
}
if (route === "/events") {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
return openKafkaDebugSse(request, response, url, options);
}
return sendJson(response, 404, debugError("workbench_debug_kafka_sse_route_not_found", "Workbench debug Kafka SSE route is not implemented.", { route }));
} catch (error) {
options.logger?.warn?.({
event: "workbench_debug_kafka_sse_failed",
route,
errorName: error?.name ?? "Error",
message: error instanceof Error ? error.message : String(error ?? "unknown"),
valuesRedacted: true
});
if (response.headersSent) return writeSse(response, "hwlab.kafka.error", { ok: false, error: errorMessagePayload(error) });
return sendJson(response, 500, debugError("workbench_debug_kafka_sse_failed", "Workbench debug Kafka SSE request failed."));
}
}
function describeKafkaSseDebug(url, env) {
const stream = streamFromUrl(url);
return {
ok: true,
contractVersion: CONTRACT_VERSION,
stream,
topic: topicForStream(stream, env),
filters: filtersFromUrl(url),
eventsRoute: `/v1/workbench/debug/kafka-sse/events${url.search || ""}`,
valuesPrinted: false
};
}
async function openKafkaDebugSse(request, response, url, options) {
const env = options.env ?? process.env;
const stream = streamFromUrl(url);
const filters = filtersFromUrl(url);
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
"x-content-type-options": "nosniff"
});
if (typeof response.flushHeaders === "function") response.flushHeaders();
writeSse(response, "hwlab.kafka.connected", {
ok: true,
contractVersion: CONTRACT_VERSION,
stream,
topic: topicForStream(stream, env),
filters,
serverSentAt: new Date().toISOString(),
valuesPrinted: false
});
let kafkaStream = null;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
void kafkaStream?.stop?.();
};
request.on("close", close);
try {
kafkaStream = await openKafkaEventStream({
env,
stream,
fromBeginning: url.searchParams.get("fromBeginning") === "1" || url.searchParams.get("fromBeginning") === "true",
...filters,
kafkaFactory: options.kafkaFactory,
onEvent: async (record) => {
writeSse(response, "hwlab.kafka.event", {
ok: true,
contractVersion: CONTRACT_VERSION,
stream,
topic: record.topic,
partition: record.partition,
offset: record.offset,
key: record.key,
timestamp: record.timestamp,
value: record.value,
serverSentAt: new Date().toISOString(),
valuesPrinted: false
});
},
onError: (error) => writeSse(response, "hwlab.kafka.error", { ok: false, error: errorMessagePayload(error), valuesPrinted: false })
});
} catch (error) {
writeSse(response, "hwlab.kafka.error", { ok: false, error: errorMessagePayload(error), valuesPrinted: false });
}
}
function writeSse(response, eventName, payload) {
if (response.destroyed || response.writableEnded) return false;
try {
response.write(`event: ${eventName}\n`);
const id = sseEventId(payload);
if (id) response.write(`id: ${id}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
return true;
} catch {
return false;
}
}
function routeSuffix(pathname) {
return String(pathname || "").replace(/^\/v1\/workbench\/debug\/kafka-sse/u, "");
}
function streamFromUrl(url) {
const stream = String(url.searchParams.get("stream") || DEFAULT_STREAM).trim();
return ALLOWED_STREAMS.has(stream) ? stream : DEFAULT_STREAM;
}
function filtersFromUrl(url) {
return compactObject({
traceId: safeId(url.searchParams.get("traceId") || url.searchParams.get("trace-id")),
sessionId: safeId(url.searchParams.get("sessionId") || url.searchParams.get("session-id")),
runId: safeId(url.searchParams.get("runId") || url.searchParams.get("run-id")),
commandId: safeId(url.searchParams.get("commandId") || url.searchParams.get("command-id"))
});
}
function topicForStream(stream, env) {
if (stream === "agentrun") return textValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC) || "agentrun.event.v1";
return textValue(env.HWLAB_KAFKA_EVENT_TOPIC) || "hwlab.event.v1";
}
function methodNotAllowed(response, allow) {
response.setHeader("allow", allow);
return sendJson(response, 405, debugError("method_not_allowed", `Method not allowed; expected ${allow}.`));
}
function debugError(code, message, extra = {}) {
return { ok: false, error: { code, message, ...extra }, valuesPrinted: false };
}
function errorMessagePayload(error) {
return { code: "kafka_sse_error", message: error instanceof Error ? error.message : String(error ?? "unknown") };
}
function safeId(value) {
const text = textValue(value);
return text && /^[A-Za-z0-9_.:-]{3,220}$/u.test(text) ? text : null;
}
function textValue(value) {
const text = typeof value === "string" ? value.trim() : value === null || value === undefined ? "" : String(value).trim();
return text.length > 0 ? text : null;
}
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => textValue(entry)));
}
function sseEventId(payload) {
const topic = textValue(payload?.topic);
const partition = textValue(payload?.partition);
const offset = textValue(payload?.offset);
return topic && partition && offset ? `${topic}:${partition}:${offset}` : null;
}
+1 -1
View File
@@ -4,7 +4,7 @@
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth";
export { workbenchAPI } from "./workbench";
export { workbenchDebugAPI, connectWorkbenchDebugFakeSse, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseResponse, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "./workbench-debug";
export { workbenchDebugAPI, connectWorkbenchDebugFakeSse, connectWorkbenchKafkaSseDebug, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseResponse, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream, type WorkbenchKafkaSseDebugEvent, type WorkbenchKafkaSseDebugFilters } from "./workbench-debug";
export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events";
export { agentAPI } from "./agent";
export { hwpodAPI } from "./hwpod";
@@ -50,6 +50,37 @@ export interface WorkbenchDebugFakeSseStream {
close: () => void;
}
export interface WorkbenchKafkaSseDebugFilters {
traceId?: string;
sessionId?: string;
runId?: string;
commandId?: string;
}
export interface WorkbenchKafkaSseDebugEvent {
ok?: boolean;
contractVersion?: string;
stream?: string;
topic?: string;
partition?: number;
offset?: string;
key?: string | null;
timestamp?: string | null;
value?: Record<string, unknown>;
filters?: WorkbenchKafkaSseDebugFilters;
serverSentAt?: string;
error?: { code?: string; message?: string; [key: string]: unknown };
valuesPrinted?: boolean;
}
export interface WorkbenchKafkaSseDebugStreamOptions extends WorkbenchKafkaSseDebugFilters {
stream?: "hwlab" | "agentrun";
fromBeginning?: boolean;
onEvent: (event: WorkbenchKafkaSseDebugEvent, eventName: string) => void;
onOpen?: () => void;
onError?: (event: Event) => void;
}
const DEBUG_FAKE_SSE_EVENTS = [
"workbench.connected",
"workbench.trace.snapshot",
@@ -93,6 +124,39 @@ export function connectWorkbenchDebugFakeSse(options: WorkbenchDebugFakeSseStrea
};
}
export function connectWorkbenchKafkaSseDebug(options: WorkbenchKafkaSseDebugStreamOptions): WorkbenchDebugFakeSseStream | null {
if (typeof EventSource === "undefined") return null;
const source = new EventSource(kafkaDebugPath("/events", {
stream: options.stream || "hwlab",
traceId: options.traceId,
sessionId: options.sessionId,
runId: options.runId,
commandId: options.commandId,
fromBeginning: options.fromBeginning ? "true" : undefined
}), { withCredentials: true });
source.onopen = () => options.onOpen?.();
source.onerror = (event) => options.onError?.(event);
const names = ["hwlab.kafka.connected", "hwlab.kafka.event", "hwlab.kafka.error"];
const listeners = names.map((name) => {
const listener = (event: MessageEvent) => {
const payload = parseKafkaEvent(event.data);
if (payload) options.onEvent(payload, name);
};
source.addEventListener(name, listener);
return { name, listener };
});
source.onmessage = (event) => {
const payload = parseKafkaEvent(event.data);
if (payload) options.onEvent(payload, "message");
};
return {
close() {
for (const { name, listener } of listeners) source.removeEventListener(name, listener);
source.close();
}
};
}
function debugPath(suffix: string, params: Record<string, string | null | undefined> = {}): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -103,6 +167,16 @@ function debugPath(suffix: string, params: Record<string, string | null | undefi
return `/v1/workbench/debug/fake-sse${suffix}${qs ? `?${qs}` : ""}`;
}
function kafkaDebugPath(suffix: string, params: Record<string, string | null | undefined> = {}): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const text = typeof value === "string" ? value.trim() : "";
if (text) query.set(key, text);
}
const qs = query.toString();
return `/v1/workbench/debug/kafka-sse${suffix}${qs ? `?${qs}` : ""}`;
}
function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null {
try {
const value = JSON.parse(raw);
@@ -111,3 +185,12 @@ function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null {
return null;
}
}
function parseKafkaEvent(raw: string): WorkbenchKafkaSseDebugEvent | null {
try {
const value = JSON.parse(raw);
return value && typeof value === "object" ? value as WorkbenchKafkaSseDebugEvent : null;
} catch {
return null;
}
}
@@ -3,27 +3,42 @@
// Responsibility: Workbench debug route for backend-driven fake SSE single-step Trace card inspection.
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { connectWorkbenchDebugFakeSse, workbenchDebugAPI, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "@/api";
import { connectWorkbenchDebugFakeSse, connectWorkbenchKafkaSseDebug, workbenchDebugAPI, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream, type WorkbenchKafkaSseDebugEvent } from "@/api";
import StatusBadge from "@/components/common/StatusBadge.vue";
import WorkbenchMessageCard from "@/components/workbench/WorkbenchMessageCard.vue";
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { applyWorkbenchDebugFakeSseEvent, createWorkbenchDebugFakeSseState } from "@/stores/workbench-debug-fake-sse";
const queueId = "trace-card";
const activeTab = ref<"fake" | "kafka">("fake");
const selectedSequenceId = ref("trace-card-basic");
const sequences = ref<WorkbenchDebugFakeSseSequence[]>([]);
const queue = ref<WorkbenchDebugFakeSseQueue | null>(null);
const projection = ref(createWorkbenchDebugFakeSseState());
const streamStatus = ref<"connecting" | "open" | "error" | "closed">("connecting");
const kafkaStatus = ref<"idle" | "connecting" | "open" | "error" | "closed">("idle");
const kafkaStreamName = ref<"hwlab" | "agentrun">("hwlab");
const kafkaFromBeginning = ref(false);
const kafkaFilters = ref({ traceId: "", sessionId: "", runId: "", commandId: "" });
const kafkaEvents = ref<KafkaRawRow[]>([]);
const busy = ref(false);
const error = ref<string | null>(null);
const appendDraft = ref(defaultAppendDraft());
let stream: WorkbenchDebugFakeSseStream | null = null;
let kafkaStream: WorkbenchDebugFakeSseStream | null = null;
interface KafkaRawRow {
id: string;
eventName: string;
receivedAt: string;
payload: WorkbenchKafkaSseDebugEvent;
}
const message = computed(() => projection.value.message);
const logs = computed(() => projection.value.logs);
const queueSummary = computed(() => queue.value ? `${queue.value.cursor}/${queue.value.eventCount}` : "-");
const streamBadgeStatus = computed(() => streamStatus.value === "open" ? "completed" : streamStatus.value === "error" ? "failed" : "running");
const kafkaBadgeStatus = computed(() => kafkaStatus.value === "open" ? "completed" : kafkaStatus.value === "error" ? "failed" : "running");
const canStep = computed(() => !busy.value && (queue.value?.remaining ?? 0) > 0);
onMounted(async () => {
@@ -35,7 +50,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
stream?.close();
stream = null;
kafkaStream?.close();
kafkaStream = null;
streamStatus.value = "closed";
kafkaStatus.value = "closed";
});
async function refreshDescription(): Promise<void> {
@@ -65,6 +83,39 @@ function openStream(): void {
if (!stream) streamStatus.value = "error";
}
function openKafkaStream(): void {
kafkaStream?.close();
kafkaStatus.value = "connecting";
kafkaStream = connectWorkbenchKafkaSseDebug({
stream: kafkaStreamName.value,
fromBeginning: kafkaFromBeginning.value,
traceId: cleanFilter(kafkaFilters.value.traceId),
sessionId: cleanFilter(kafkaFilters.value.sessionId),
runId: cleanFilter(kafkaFilters.value.runId),
commandId: cleanFilter(kafkaFilters.value.commandId),
onOpen: () => { kafkaStatus.value = "open"; },
onError: () => { kafkaStatus.value = "error"; },
onEvent: (event, eventName) => {
if (eventName === "hwlab.kafka.error") {
kafkaStatus.value = "error";
error.value = event.error?.message || "Kafka SSE 事件流错误";
}
kafkaEvents.value = [{ id: kafkaRowId(event, eventName), eventName, receivedAt: new Date().toISOString(), payload: event }, ...kafkaEvents.value].slice(0, 120);
}
});
if (!kafkaStream) kafkaStatus.value = "error";
}
function closeKafkaStream(): void {
kafkaStream?.close();
kafkaStream = null;
kafkaStatus.value = "closed";
}
function clearKafkaEvents(): void {
kafkaEvents.value = [];
}
async function resetQueue(): Promise<void> {
busy.value = true;
error.value = null;
@@ -123,6 +174,38 @@ function parseAppendDraft(raw: string): { ok: true; events: WorkbenchRealtimeEve
}
}
function cleanFilter(value: string): string | undefined {
const text = value.trim();
return text ? text : undefined;
}
function kafkaRowId(event: WorkbenchKafkaSseDebugEvent, eventName: string): string {
const topic = event.topic || "topic";
const partition = event.partition ?? "p";
const offset = event.offset || Date.now();
return `${eventName}:${topic}:${partition}:${offset}:${Math.random().toString(16).slice(2)}`;
}
function kafkaEventSummary(event: WorkbenchKafkaSseDebugEvent): string {
const value = recordValue(event.value);
const nestedEvent = recordValue(value.event);
return String(value.eventType || value.type || nestedEvent.type || event.error?.code || "event");
}
function kafkaEventIds(event: WorkbenchKafkaSseDebugEvent): string {
const value = recordValue(event.value);
const context = recordValue(value.context);
return [value.traceId, value.sessionId, context.runId || value.runId, context.commandId || value.commandId].filter(Boolean).join(" · ") || "-";
}
function kafkaEventJson(event: WorkbenchKafkaSseDebugEvent): string {
return JSON.stringify(event.value ?? event, null, 2);
}
function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function defaultAppendDraft(): string {
return JSON.stringify({
type: "trace.event",
@@ -151,16 +234,18 @@ function defaultAppendDraft(): string {
<h1>实时调试台</h1>
</div>
<div class="workbench-debug-status">
<StatusBadge :status="streamBadgeStatus" :label="`SSE ${streamStatus}`" />
<span class="debug-counter">Queue {{ queueSummary }}</span>
<StatusBadge v-if="activeTab === 'fake'" :status="streamBadgeStatus" :label="`SSE ${streamStatus}`" />
<StatusBadge v-else :status="kafkaBadgeStatus" :label="`Kafka SSE ${kafkaStatus}`" />
<span class="debug-counter">{{ activeTab === 'fake' ? `Queue ${queueSummary}` : `${kafkaEvents.length} events` }}</span>
</div>
</header>
<nav class="workbench-debug-tabs" aria-label="Workbench 调试子标签">
<button class="debug-tab" data-active="true" type="button">SSE Trace</button>
<button class="debug-tab" :data-active="activeTab === 'fake' ? 'true' : 'false'" type="button" @click="activeTab = 'fake'">SSE Trace</button>
<button class="debug-tab" :data-active="activeTab === 'kafka' ? 'true' : 'false'" type="button" @click="activeTab = 'kafka'">Kafka SSE</button>
</nav>
<section class="workbench-debug-toolbar" aria-label="fake SSE 控制">
<section v-if="activeTab === 'fake'" class="workbench-debug-toolbar" aria-label="fake SSE 控制">
<label>
<span>序列</span>
<select v-model="selectedSequenceId" class="input" :disabled="busy" @change="resetQueue">
@@ -172,9 +257,42 @@ function defaultAppendDraft(): string {
<button class="btn btn-secondary" type="button" :disabled="!canStep" @click="runAll">全速运行</button>
</section>
<section v-else class="workbench-debug-toolbar kafka-toolbar" aria-label="Kafka SSE 控制">
<label class="compact-label">
<span></span>
<select v-model="kafkaStreamName" class="input" :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'">
<option value="hwlab">hwlab.event.v1</option>
<option value="agentrun">agentrun.event.v1</option>
</select>
</label>
<label>
<span>traceId</span>
<input v-model="kafkaFilters.traceId" class="input" placeholder="trc_..." :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" />
</label>
<label>
<span>sessionId</span>
<input v-model="kafkaFilters.sessionId" class="input" placeholder="ses_..." :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" />
</label>
<label class="compact-label">
<span>runId</span>
<input v-model="kafkaFilters.runId" class="input" placeholder="run_..." :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" />
</label>
<label class="compact-label">
<span>commandId</span>
<input v-model="kafkaFilters.commandId" class="input" placeholder="cmd_..." :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" />
</label>
<label class="debug-check">
<input v-model="kafkaFromBeginning" type="checkbox" :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" />
<span>from beginning</span>
</label>
<button class="btn btn-primary" type="button" :disabled="kafkaStatus === 'connecting' || kafkaStatus === 'open'" @click="openKafkaStream">连接</button>
<button class="btn btn-secondary" type="button" :disabled="kafkaStatus !== 'connecting' && kafkaStatus !== 'open'" @click="closeKafkaStream">断开</button>
<button class="btn btn-secondary" type="button" @click="clearKafkaEvents">清空</button>
</section>
<p v-if="error" class="workbench-debug-error">{{ error }}</p>
<div class="workbench-debug-grid">
<div v-if="activeTab === 'fake'" class="workbench-debug-grid">
<section class="debug-surface" aria-label="Trace 卡片投影">
<WorkbenchMessageCard v-if="message" :message="message" :detail-enabled="false" :trace-auto-expanded="true" storage-key-prefix="hwlab.workbench.debug.trace-open" />
<div v-else class="debug-empty">等待第一条 fake SSE message.snapshot</div>
@@ -204,6 +322,28 @@ function defaultAppendDraft(): string {
</section>
</aside>
</div>
<section v-else class="kafka-debug-panel" aria-label="Kafka SSE 原始事件列表">
<div class="debug-section-title">
<strong>Raw SSE Events</strong>
<span>{{ kafkaEvents.length }} events</span>
</div>
<div v-if="kafkaEvents.length === 0" class="debug-empty">等待后端从 Kafka 订阅并透传 hwlab event</div>
<ol v-else class="kafka-event-list">
<li v-for="row in kafkaEvents" :key="row.id">
<div class="kafka-event-head">
<code>{{ row.eventName }}</code>
<strong>{{ kafkaEventSummary(row.payload) }}</strong>
<span>{{ row.payload.topic || '-' }} #{{ row.payload.offset || '-' }}</span>
</div>
<div class="kafka-event-meta">
<span>{{ kafkaEventIds(row.payload) }}</span>
<time>{{ row.receivedAt }}</time>
</div>
<pre>{{ kafkaEventJson(row.payload) }}</pre>
</li>
</ol>
</section>
</section>
</template>
@@ -286,6 +426,24 @@ function defaultAppendDraft(): string {
font-weight: 750;
}
.workbench-debug-toolbar .compact-label {
width: min(220px, 100%);
}
.kafka-toolbar {
align-items: end;
}
.debug-check {
display: flex !important;
width: auto !important;
grid-auto-flow: column;
align-items: center;
gap: 7px !important;
padding-bottom: 8px;
color: #334155 !important;
}
.workbench-debug-grid {
display: grid;
min-height: 0;
@@ -408,6 +566,76 @@ function defaultAppendDraft(): string {
white-space: nowrap;
}
.kafka-debug-panel {
display: grid;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 10px;
}
.kafka-event-list {
display: grid;
align-content: start;
gap: 8px;
min-height: 0;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.kafka-event-list li {
display: grid;
gap: 6px;
border: 1px solid #d8e1eb;
border-left: 3px solid #0e7490;
border-radius: 8px;
background: #f8fafc;
padding: 9px;
}
.kafka-event-head,
.kafka-event-meta {
display: grid;
grid-template-columns: minmax(150px, 0.5fr) minmax(160px, 0.7fr) minmax(0, 1fr);
gap: 10px;
align-items: center;
color: #334155;
font-size: 12px;
}
.kafka-event-head code {
color: #0f766e;
}
.kafka-event-head span,
.kafka-event-meta {
color: #64748b;
}
.kafka-event-meta {
grid-template-columns: minmax(0, 1fr) auto;
}
.kafka-event-list pre {
max-height: 220px;
margin: 0;
overflow: auto;
border-radius: 7px;
background: #0f172a;
padding: 9px;
color: #d1fae5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 980px) {
.workbench-debug-grid {
grid-template-columns: minmax(0, 1fr);