Files
pikasTech-HWLAB/internal/cloud/kafka-event-bridge.ts
T

359 lines
16 KiB
TypeScript

// 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 }));
}