feat: bridge AgentRun Kafka events to HWLAB stream
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <traceId> [--timeout 600000]",
|
||||
result: "bun scripts/hwlab-code-agent-cli.ts result <traceId>",
|
||||
trace: "bun scripts/hwlab-code-agent-cli.ts trace <traceId> [--full]"
|
||||
trace: "bun scripts/hwlab-code-agent-cli.ts trace <traceId> [--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<string, string | undefined>) {
|
||||
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<string, unknown>) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user