diff --git a/internal/cloud/kafka-event-bridge.test.ts b/internal/cloud/kafka-event-bridge.test.ts new file mode 100644 index 00000000..c8c7a8b3 --- /dev/null +++ b/internal/cloud/kafka-event-bridge.test.ts @@ -0,0 +1,101 @@ +// SPEC: PJ2026-0104010803 Workbench事件流可见性 draft-2026-07-09-p0-kafka-authority. +// Responsibility: lock AgentRun Kafka event -> HWLAB Kafka event projection contract without requiring a live Kafka broker. + +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { projectAgentRunKafkaEventToHwlabEvent } from "./kafka-event-bridge.ts"; + +test("projects AgentRun assistant_message Kafka event into HWLAB trace event", () => { + const projected = projectAgentRunKafkaEventToHwlabEvent({ + schema: "agentrun.event.v1", + eventType: "agentrun.run.event", + source: "agentrun-manager", + producedAt: "2026-07-09T10:00:00.000Z", + run: { + runId: "run_kafka_projection", + sessionId: "ses_kafka_projection", + conversationId: "cnv_kafka_projection", + threadId: "thread-kafka-projection", + projectId: "prj_kafka_projection", + backendProfile: "deepseek" + }, + command: { commandId: "cmd_kafka_projection" }, + event: { + seq: 7, + type: "assistant_message", + createdAt: "2026-07-09T10:00:01.000Z", + payload: { + traceId: "trc_kafka_projection", + commandId: "cmd_kafka_projection", + text: "partial answer", + itemId: "item_assistant_1", + final: false + } + } + }, { source: "hwlab-test", producedAt: "2026-07-09T10:00:02.000Z", sourceTopic: "agentrun.event.v1", sourceOffset: "42" }); + + assert.equal(projected.schema, "hwlab.event.v1"); + assert.equal(projected.eventType, "hwlab.trace.event.projected"); + assert.equal(projected.source, "hwlab-test"); + assert.equal(projected.traceId, "trc_kafka_projection"); + assert.equal(projected.sessionId, "ses_kafka_projection"); + assert.equal(projected.context.runId, "run_kafka_projection"); + assert.equal(projected.context.commandId, "cmd_kafka_projection"); + assert.equal(projected.context.sourceSeq, 7); + assert.equal(projected.context.agentRunEventType, "assistant_message"); + assert.equal(projected.event.type, "assistant"); + assert.equal(projected.event.label, "agentrun:assistant:message"); + assert.equal(projected.event.status, "running"); + assert.equal(projected.event.text, "partial answer"); + assert.equal(projected.event.terminal, false); + assert.equal(projected.sourceEvent.topic, "agentrun.event.v1"); + assert.equal(projected.sourceEvent.offset, "42"); + assert.equal(projected.valuesPrinted, false); +}); + +test("projects AgentRun terminal_status Kafka event into terminal HWLAB event", () => { + const projected = projectAgentRunKafkaEventToHwlabEvent({ + schema: "agentrun.event.v1", + eventType: "agentrun.run.event", + run: { runId: "run_terminal", sessionId: "ses_terminal" }, + command: { commandId: "cmd_terminal" }, + event: { + seq: 12, + type: "terminal_status", + payload: { + traceId: "trc_terminal", + terminalStatus: "completed", + message: "AgentRun completed" + } + } + }, { source: "hwlab-test" }); + + assert.equal(projected.traceId, "trc_terminal"); + assert.equal(projected.event.type, "result"); + assert.equal(projected.event.eventType, "terminal"); + assert.equal(projected.event.status, "completed"); + assert.equal(projected.event.label, "agentrun:terminal:completed"); + assert.equal(projected.event.terminal, true); + assert.equal(projected.event.message, "AgentRun completed"); +}); + +test("projects AgentRun run-created envelope even when no source event is present", () => { + const projected = projectAgentRunKafkaEventToHwlabEvent({ + schema: "agentrun.event.v1", + eventType: "agentrun.run.created", + run: { + runId: "run_created", + sessionId: "ses_created", + traceId: "trc_created", + backendProfile: "codex" + } + }, { source: "hwlab-test" }); + + assert.equal(projected.traceId, "trc_created"); + assert.equal(projected.sessionId, "ses_created"); + assert.equal(projected.context.runId, "run_created"); + assert.equal(projected.context.sourceEventType, "agentrun.run.created"); + assert.equal(projected.event.label, "agentrun:event:agentrun.run.created"); + assert.equal(projected.event.status, "running"); +}); diff --git a/internal/cloud/kafka-event-bridge.ts b/internal/cloud/kafka-event-bridge.ts new file mode 100644 index 00000000..371d997a --- /dev/null +++ b/internal/cloud/kafka-event-bridge.ts @@ -0,0 +1,358 @@ +// SPEC: PJ2026-0104010803 Workbench事件流可见性 draft-2026-07-09-p0-kafka-authority. +// Responsibility: subscribe AgentRun Kafka events, project them into HWLAB Kafka events, and provide bounded Kafka stream queries for diagnostics. + +import { createHash, randomUUID } from "node:crypto"; + +import { Kafka, logLevel } from "kafkajs"; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); +const DEFAULT_AGENTRUN_EVENT_TOPIC = "agentrun.event.v1"; +const DEFAULT_HWLAB_EVENT_TOPIC = "hwlab.event.v1"; +const DEFAULT_CLIENT_ID = "hwlab-v03-cloud-api"; +const DEFAULT_GROUP_ID = "hwlab-v03-agentrun-event-bridge"; +const DEFAULT_QUERY_TIMEOUT_MS = 5000; +const DEFAULT_QUERY_LIMIT = 50; + +export function kafkaEventBridgeConfig(env = process.env) { + if (!truthy(env.HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED ?? env.HWLAB_KAFKA_AGENTRUN_CONSUME_ENABLED ?? env.HWLAB_KAFKA_ENABLED)) return null; + const brokers = csv(env.HWLAB_KAFKA_BOOTSTRAP_SERVERS); + if (brokers.length === 0) return null; + const agentRunTopic = stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC) || DEFAULT_AGENTRUN_EVENT_TOPIC; + const hwlabTopic = stringValue(env.HWLAB_KAFKA_EVENT_TOPIC) || DEFAULT_HWLAB_EVENT_TOPIC; + const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID) || DEFAULT_CLIENT_ID; + const groupId = stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID ?? env.HWLAB_KAFKA_AGENTRUN_CONSUMER_GROUP_ID) || DEFAULT_GROUP_ID; + return { brokers, agentRunTopic, hwlabTopic, clientId, groupId }; +} + +export function startHwlabKafkaEventBridge({ env = process.env, logger = console, kafkaFactory = defaultKafkaFactory } = {}) { + const config = kafkaEventBridgeConfig(env); + if (!config) return { started: false, reason: "disabled_or_unconfigured", stop() {}, valuesPrinted: false }; + + let stopped = false; + let consumer = null; + let producer = null; + const ready = (async () => { + const kafka = kafkaFactory(config); + consumer = kafka.consumer({ groupId: config.groupId, allowAutoTopicCreation: false }); + producer = kafka.producer({ allowAutoTopicCreation: false }); + await producer.connect(); + await consumer.connect(); + await consumer.subscribe({ topic: config.agentRunTopic, fromBeginning: false }); + await consumer.run({ + eachMessage: async ({ topic, partition, message }) => { + if (stopped) return; + const projected = projectAgentRunKafkaMessageToHwlabEvent({ topic, partition, message }, { source: config.clientId }); + if (!projected) return; + await producer.send({ + topic: config.hwlabTopic, + messages: [{ + key: kafkaMessageKey(projected), + value: JSON.stringify(projected), + headers: kafkaHeaders(projected) + }] + }); + } + }); + logInfo(logger, "hwlab-kafka-event-bridge-started", { + agentRunTopic: config.agentRunTopic, + hwlabTopic: config.hwlabTopic, + clientId: config.clientId, + groupId: config.groupId, + valuesPrinted: false + }); + })().catch((error) => { + logWarn(logger, "hwlab-kafka-event-bridge-start-failed", { + message: errorMessage(error), + agentRunTopic: config.agentRunTopic, + hwlabTopic: config.hwlabTopic, + valuesPrinted: false + }); + }); + + return { + started: true, + ...config, + ready, + async stop() { + stopped = true; + await Promise.allSettled([consumer?.disconnect?.(), producer?.disconnect?.()]); + }, + valuesPrinted: false + }; +} + +export function projectAgentRunKafkaMessageToHwlabEvent(kafkaMessage = {}, options = {}) { + const valueText = kafkaMessage?.message?.value ? Buffer.from(kafkaMessage.message.value).toString("utf8") : ""; + const input = parseJson(valueText); + if (!input) return null; + return projectAgentRunKafkaEventToHwlabEvent(input, { + ...options, + sourceTopic: kafkaMessage.topic, + sourcePartition: kafkaMessage.partition, + sourceOffset: kafkaMessage.message?.offset, + sourceKey: kafkaMessage.message?.key ? Buffer.from(kafkaMessage.message.key).toString("utf8") : null, + inputSha256: sha256(valueText) + }); +} + +export function projectAgentRunKafkaEventToHwlabEvent(input = {}, options = {}) { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + const run = objectValue(input.run ?? input.agentRun?.run); + const command = objectValue(input.command ?? input.agentRun?.command); + const sourceEvent = objectValue(input.event ?? input.payload?.event ?? input.agentRunEvent); + const payload = objectValue(sourceEvent.payload ?? input.payload); + const hwlab = objectValue(input.hwlab ?? payload.hwlab); + const context = objectValue(input.context ?? payload.context); + const sourceSeq = integerValue(sourceEvent.seq ?? input.seq ?? input.sourceSeq); + const traceId = firstText(input.traceId, hwlab.traceId, context.traceId, sourceEvent.traceId, payload.traceId, payload.hwlabTraceId, payload.businessTraceId, payload.metadata?.traceId, command.traceId, run.traceId); + const sessionId = firstText(input.sessionId, hwlab.sessionId, context.sessionId, run.sessionId, payload.sessionId, payload.hwlabSessionId); + const runId = firstText(run.runId, sourceEvent.runId, payload.runId, input.runId); + const commandId = firstText(command.commandId, sourceEvent.commandId, payload.commandId, input.commandId); + const event = mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }); + const producedAt = timestampValue(options.producedAt) || new Date().toISOString(); + return { + schema: "hwlab.event.v1", + eventType: "hwlab.trace.event.projected", + source: stringValue(options.source) || DEFAULT_CLIENT_ID, + producedAt, + traceId, + sessionId, + context: { + conversationId: firstText(input.conversationId, hwlab.conversationId, context.conversationId, run.conversationId, payload.conversationId), + threadId: firstText(input.threadId, hwlab.threadId, context.threadId, run.threadId, payload.threadId), + projectId: firstText(input.projectId, hwlab.projectId, context.projectId, run.projectId, payload.projectId), + runId, + commandId, + sourceSeq, + sourceEventType: firstText(input.eventType, sourceEvent.eventType), + agentRunEventType: firstText(sourceEvent.type, payload.type), + valuesRedacted: true + }, + event, + sourceEvent: { + schema: firstText(input.schema), + topic: stringValue(options.sourceTopic), + partition: integerValue(options.sourcePartition), + offset: stringValue(options.sourceOffset), + key: stringValue(options.sourceKey), + sha256: stringValue(options.inputSha256) || sha256(JSON.stringify(input)), + valuesRedacted: true + }, + diagnostics: { + projectedBy: "hwlab-kafka-event-bridge", + valuesPrinted: false + }, + valuesPrinted: false + }; +} + +export async function queryKafkaEventStream({ env = process.env, stream = "hwlab", topic = null, traceId = null, sessionId = null, runId = null, commandId = null, limit = DEFAULT_QUERY_LIMIT, timeoutMs = DEFAULT_QUERY_TIMEOUT_MS, fromBeginning = true, kafkaFactory = defaultKafkaFactory } = {}) { + const brokers = csv(env.HWLAB_KAFKA_BOOTSTRAP_SERVERS); + if (brokers.length === 0) throw new Error("HWLAB_KAFKA_BOOTSTRAP_SERVERS is required for Kafka event queries."); + const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID) || DEFAULT_CLIENT_ID; + const resolvedTopic = stringValue(topic) || kafkaTopicForStream(stream, env); + const maxEvents = Math.max(1, integerValue(limit) || DEFAULT_QUERY_LIMIT); + const budgetMs = Math.max(250, integerValue(timeoutMs) || DEFAULT_QUERY_TIMEOUT_MS); + const kafka = kafkaFactory({ brokers, clientId: `${clientId}-query` }); + const consumer = kafka.consumer({ groupId: `${clientId}-query-${Date.now()}-${randomUUID().slice(0, 8)}`, allowAutoTopicCreation: false }); + const events = []; + let timer = null; + await consumer.connect(); + await consumer.subscribe({ topic: resolvedTopic, fromBeginning: fromBeginning !== false }); + try { + await new Promise((resolve, reject) => { + timer = setTimeout(resolve, budgetMs); + timer.unref?.(); + consumer.run({ + eachMessage: async ({ topic: messageTopic, partition, message }) => { + const valueText = message.value ? Buffer.from(message.value).toString("utf8") : ""; + const value = parseJson(valueText); + if (!value || !eventMatchesFilters(value, { traceId, sessionId, runId, commandId })) return; + events.push({ + topic: messageTopic, + partition, + offset: message.offset, + key: message.key ? Buffer.from(message.key).toString("utf8") : null, + timestamp: message.timestamp ?? null, + value + }); + if (events.length >= maxEvents) resolve(null); + } + }).catch(reject); + }); + } finally { + if (timer) clearTimeout(timer); + await consumer.disconnect(); + } + return { + ok: true, + stream, + topic: resolvedTopic, + count: events.length, + limit: maxEvents, + timeoutMs: budgetMs, + filters: compactObject({ traceId, sessionId, runId, commandId }), + events, + valuesPrinted: false + }; +} + +function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }) { + const base = { + traceId, + sessionId, + source: "agentrun.kafka", + sourceSeq, + runId, + commandId, + attemptId: firstText(command.attemptId, sourceEvent.attemptId, payload.attemptId), + runnerId: firstText(command.runnerId, sourceEvent.runnerId, payload.runnerId), + backend: firstText(run.backendProfile, payload.backendProfile, payload.providerProfile), + createdAt: timestampValue(sourceEvent.createdAt ?? input.producedAt), + valuesPrinted: false + }; + const type = firstText(sourceEvent.type, payload.type, input.eventType) || "event"; + if (type === "assistant_message") { + const terminal = payload.replyAuthority === true || payload.final === true; + return { ...base, type: "assistant", eventType: "assistant", status: terminal ? "completed" : "running", label: "agentrun:assistant:message", message: textPayload(payload, "assistant message"), text: textPayload(payload, ""), itemId: firstText(payload.itemId), replyAuthority: payload.replyAuthority === true, final: payload.final === true, terminal }; + } + if (type === "backend_status") { + const phase = firstText(payload.phase) || "status"; + return { ...base, type: "backend", eventType: "backend", status: "running", label: `agentrun:backend:${phase}`, message: textPayload(payload, phase) }; + } + if (type === "terminal_status") { + const terminalStatus = firstText(payload.terminalStatus, sourceEvent.terminalStatus, payload.status, sourceEvent.status) || "failed"; + const normalized = terminalStatus === "cancelled" ? "canceled" : terminalStatus; + return { ...base, type: "result", eventType: "terminal", status: normalized, label: `agentrun:terminal:${terminalStatus}`, errorCode: firstText(payload.failureKind, payload.errorCode, sourceEvent.failureKind, sourceEvent.errorCode), failureKind: firstText(payload.failureKind, sourceEvent.failureKind), message: textPayload({ ...sourceEvent, ...payload }, `AgentRun terminal status ${terminalStatus}`), terminal: true }; + } + if (type === "command_output") { + const stream = payload.stream === "stderr" ? "stderr" : "stdout"; + return { ...base, type: "output", eventType: "status", status: "running", label: `agentrun:output:${stream}`, stream, message: textPayload(payload, ""), outputTruncated: payload.outputTruncated === true }; + } + if (type === "error") { + const failureKind = firstText(payload.failureKind, payload.errorCode, sourceEvent.failureKind, sourceEvent.errorCode) || "backend"; + return { ...base, type: "error", eventType: "error", status: "failed", label: `agentrun:error:${failureKind}`, errorCode: failureKind, failureKind, message: textPayload(payload, "AgentRun error"), terminal: false }; + } + return { ...base, type: "backend", eventType: "backend", status: "running", label: `agentrun:event:${type}`, message: textPayload(payload, firstText(input.eventType, type) || "AgentRun event") }; +} + +function eventMatchesFilters(value, filters) { + for (const [name, expected] of Object.entries(filters)) { + const needle = stringValue(expected); + if (!needle) continue; + if (!eventFieldCandidates(value, name).includes(needle)) return false; + } + return true; +} + +function eventFieldCandidates(value, name) { + const event = objectValue(value.event); + const context = objectValue(value.context); + const run = objectValue(value.run); + const command = objectValue(value.command); + const sourceEvent = objectValue(value.sourceEvent); + const nestedEvent = objectValue(value.event?.sourceEvent ?? value.agentRunEvent); + if (name === "traceId") return textCandidates(value.traceId, event.traceId, context.traceId, sourceEvent.traceId, nestedEvent.traceId, nestedEvent.payload?.traceId); + if (name === "sessionId") return textCandidates(value.sessionId, event.sessionId, context.sessionId, run.sessionId, nestedEvent.payload?.sessionId); + if (name === "runId") return textCandidates(value.runId, event.runId, context.runId, run.runId, nestedEvent.runId, nestedEvent.payload?.runId); + if (name === "commandId") return textCandidates(value.commandId, event.commandId, context.commandId, command.commandId, nestedEvent.commandId, nestedEvent.payload?.commandId); + return []; +} + +function kafkaTopicForStream(stream, env) { + const normalized = stringValue(stream) || "hwlab"; + if (normalized === "agentrun") return stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC) || DEFAULT_AGENTRUN_EVENT_TOPIC; + if (normalized === "hwlab") return stringValue(env.HWLAB_KAFKA_EVENT_TOPIC) || DEFAULT_HWLAB_EVENT_TOPIC; + return normalized; +} + +function defaultKafkaFactory(config) { + return new Kafka({ clientId: config.clientId, brokers: config.brokers, logLevel: logLevel.NOTHING }); +} + +function kafkaMessageKey(projected) { + return firstText(projected.traceId, projected.sessionId, projected.context?.commandId, projected.context?.runId) || "hwlab-event"; +} + +function kafkaHeaders(projected) { + return { + "x-trace-id": projected.traceId ?? "", + "x-session-id": projected.sessionId ?? "", + "x-run-id": projected.context?.runId ?? "", + "x-command-id": projected.context?.commandId ?? "", + "x-values-printed": "false" + }; +} + +function textPayload(payload, fallback) { + return firstText(payload.text, payload.message, payload.summary, payload.output, payload.errorMessage, payload.reason) || fallback; +} + +function truthy(value) { + return TRUE_VALUES.has(String(value ?? "").trim().toLowerCase()); +} + +function csv(value) { + return String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function firstText(...values) { + for (const value of values) { + const text = stringValue(value); + if (text) return text; + } + return null; +} + +function textCandidates(...values) { + return values.map(stringValue).filter(Boolean); +} + +function stringValue(value) { + const text = typeof value === "string" ? value.trim() : value === null || value === undefined ? "" : String(value).trim(); + return text.length > 0 ? text : null; +} + +function integerValue(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.floor(parsed) : null; +} + +function timestampValue(value) { + const text = stringValue(value); + if (!text) return null; + const ms = Date.parse(text); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + +function sha256(text) { + return createHash("sha256").update(String(text ?? "")).digest("hex"); +} + +function compactObject(value) { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => stringValue(entry))); +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function logInfo(logger, code, details) { + logger?.log?.(JSON.stringify({ code, component: "hwlab-kafka-event-bridge", ...details, valuesPrinted: false })); +} + +function logWarn(logger, code, details) { + logger?.warn?.(JSON.stringify({ code, component: "hwlab-kafka-event-bridge", ...details, valuesPrinted: false })); +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 27e6becc..7012aefb 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -73,6 +73,7 @@ import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handle import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts"; import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts"; import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; +import { startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; import { emitHttpServerRequestSpan, httpRequestOtelTraceContext, newOtelSpanId } from "./otel-trace.ts"; @@ -177,6 +178,7 @@ export function createCloudApiServer(options = {}) { traceStore, logger: console }); + const kafkaEventBridge = startHwlabKafkaEventBridge({ env, logger: console }); const server = createServer(async (request, response) => { const requestContext = buildHttpRequestContext(request); attachHttpRequestContext(request, response, requestContext); @@ -228,6 +230,7 @@ export function createCloudApiServer(options = {}) { server.on("close", () => { workbenchEmptySessionGc.stop(); agentRunProjectionResume.stop(); + void kafkaEventBridge.stop(); }); return server; } diff --git a/skills/hwlab-code-agent/scripts/hwlab-code-agent-cli.ts b/skills/hwlab-code-agent/scripts/hwlab-code-agent-cli.ts index 8543870a..5aa2e74d 100644 --- a/skills/hwlab-code-agent/scripts/hwlab-code-agent-cli.ts +++ b/skills/hwlab-code-agent/scripts/hwlab-code-agent-cli.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { spawn, poll, getResult, getTrace } from "./src/client.ts"; +import { queryKafkaEventStream } from "../../../internal/cloud/kafka-event-bridge.ts"; const argv = process.argv.slice(2); const cmd = argv[0]; @@ -63,6 +64,15 @@ async function main() { await getTrace(traceId, parseArgs(2)); break; } + case "kafka": { + const subcommand = argv[1] || "tail"; + if (subcommand !== "tail" && subcommand !== "query") { + process.stderr.write(JSON.stringify({ ok: false, error: { code: "usage", message: "kafka tail [--stream hwlab|agentrun] [--trace-id TRACE_ID] [--session-id SESSION_ID] [--run-id RUN_ID] [--command-id COMMAND_ID]" } }) + "\n"); + process.exit(1); + } + await kafkaTail(parseArgs(2)); + break; + } default: process.stderr.write(JSON.stringify({ ok: false, @@ -70,13 +80,77 @@ async function main() { spawn: "HWLAB_CODE_AGENT_PROVIDER_PROFILE=PROFILE bun scripts/hwlab-code-agent-cli.ts spawn --message '...' [--profile PROFILE]", poll: "bun scripts/hwlab-code-agent-cli.ts poll [--timeout 600000]", result: "bun scripts/hwlab-code-agent-cli.ts result ", - trace: "bun scripts/hwlab-code-agent-cli.ts trace [--full]" + trace: "bun scripts/hwlab-code-agent-cli.ts trace [--full]", + kafka: "bun tools/hwlab-code-agent kafka tail --stream hwlab --trace-id trc_... --limit 20" } }, null, 2) + "\n"); process.exit(1); } } +async function kafkaTail(args: Record) { + const result = await queryKafkaEventStream({ + env: process.env, + stream: args.stream || "hwlab", + topic: args.topic || null, + traceId: args["trace-id"] || args.traceId || null, + sessionId: args["session-id"] || args.sessionId || null, + runId: args["run-id"] || args.runId || null, + commandId: args["command-id"] || args.commandId || null, + limit: boundedInteger(args.limit, 20, 1, 500), + timeoutMs: boundedInteger(args["timeout-ms"] || args.timeoutMs, 5000, 250, 60000), + fromBeginning: args["from-end"] === "true" || args.fromEnd === "true" ? false : true + }); + process.stdout.write(JSON.stringify({ + ok: true, + action: "hwlab-code-agent.kafka.tail", + stream: result.stream, + topic: result.topic, + count: result.count, + limit: result.limit, + timeoutMs: result.timeoutMs, + filters: result.filters, + rows: result.events.map(kafkaEventRow), + events: args.full === "true" ? result.events : undefined, + valuesPrinted: false + }, null, 2) + "\n"); +} + +function kafkaEventRow(record: any) { + const value = record?.value && typeof record.value === "object" ? record.value : {}; + const event = value.event && typeof value.event === "object" ? value.event : {}; + const context = value.context && typeof value.context === "object" ? value.context : {}; + return clean({ + topic: record.topic, + partition: record.partition, + offset: record.offset, + key: record.key, + producedAt: text(value.producedAt), + eventType: text(value.eventType ?? event.eventType), + traceId: text(value.traceId ?? event.traceId), + sessionId: text(value.sessionId ?? event.sessionId), + runId: text(context.runId ?? event.runId ?? value.runId), + commandId: text(context.commandId ?? event.commandId ?? value.commandId), + label: text(event.label), + status: text(event.status), + sourceSeq: context.sourceSeq ?? event.sourceSeq + }); +} + +function boundedInteger(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number.parseInt(String(value ?? ""), 10); + const selected = Number.isFinite(parsed) ? parsed : fallback; + return Math.min(max, Math.max(min, selected)); +} + +function text(value: unknown) { + return String(value ?? "").trim(); +} + +function clean(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); +} + main().catch((e) => { process.stderr.write(JSON.stringify({ ok: false, error: { code: "unhandled", message: e.message } }) + "\n"); process.exit(1);