feat: 增加 AgentRun 到 HWLAB 的 Kafka 调试 CLI
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
import { main } from "../../src/hwlab-cli-lib.ts";
|
||||
|
||||
await main();
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv[0] === "kafka") {
|
||||
const { mainKafkaCli } = await import("../../src/hwlab-cli/kafka-regenerate.ts");
|
||||
await mainKafkaCli(argv.slice(1));
|
||||
} else await main(argv);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { queryKafkaEventStream } from "../../internal/cloud/kafka-event-bridge.ts";
|
||||
import { mapAgentRunRecordsToHwlabDebugEvents, runKafkaCli } from "../src/hwlab-cli/kafka-regenerate.ts";
|
||||
|
||||
const SESSION_ID = "ses_agentrun_5ec4e141_6abc_466d_9afe_049f7c0ac105";
|
||||
const HWLAB_SESSION_ID = "ses_5ec4e141-6abc-466d-9afe-049f7c0ac105";
|
||||
const TRACE_ID = "trc_mretx18t3jl4tg";
|
||||
const RUN_ID = "run_a78cbcb05f2c4afaa748a3158db44998";
|
||||
|
||||
test("offline JSONL maps 35 stdio reconstructions to 35 ordered HWLAB debug events without runtime dependencies", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-kafka-debug-jsonl-"));
|
||||
const inputFile = path.join(cwd, "agentrun-events.jsonl");
|
||||
const outputFile = path.join(cwd, "hwlab-events.jsonl");
|
||||
const records = Array.from({ length: 35 }, (_, index) => reconstructionRecord(index + 1));
|
||||
await writeFile(inputFile, records.map((record) => JSON.stringify(record)).join("\n") + "\n", "utf8");
|
||||
|
||||
const result = await runKafkaCli([
|
||||
"regenerate", "hwlab",
|
||||
"--from", "jsonl",
|
||||
"--session-id", SESSION_ID,
|
||||
"--trace-id", TRACE_ID,
|
||||
"--jsonl-file", inputFile,
|
||||
"--output-jsonl", outputFile,
|
||||
"--expect-count", "35",
|
||||
"--no-publish",
|
||||
"--json"
|
||||
], {
|
||||
cwd,
|
||||
env: {},
|
||||
now: () => "2026-07-10T12:00:00.000Z",
|
||||
async createProducer() { throw new Error("offline JSONL must not create a producer"); }
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.input.readCount, 35);
|
||||
assert.equal(result.payload.output.count, 35);
|
||||
assert.equal(result.payload.output.publishedCount, 0);
|
||||
assert.deepEqual(result.payload.validation, {
|
||||
expectedCount: 35,
|
||||
oneToOne: true,
|
||||
orderPreserved: true,
|
||||
lineagePreserved: true,
|
||||
firstSourceSeq: 1,
|
||||
lastSourceSeq: 35,
|
||||
inputKinds: ["stdio-derived partial reconstruction"]
|
||||
});
|
||||
assert.deepEqual(result.payload.runtimeDependencies, {
|
||||
cloudApi: false,
|
||||
database: false,
|
||||
transactionalProjector: false,
|
||||
kafka: false
|
||||
});
|
||||
|
||||
const output = (await readFile(outputFile, "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(output.length, 35);
|
||||
assert.deepEqual(output.map((row) => row.value.debugLineage.sourceSeq), Array.from({ length: 35 }, (_, index) => index + 1));
|
||||
assert.ok(output.every((row) => row.topic === "hwlab.event.debug.v1"));
|
||||
assert.ok(output.every((row) => row.value.schema === "hwlab.event.debug.v1"));
|
||||
assert.ok(output.every((row) => row.value.eventId === null && row.value.sourceEventId === null && row.value.outboxSeq === null));
|
||||
assert.ok(output.every((row) => row.value.debugLineage.inputEventId === null && row.value.debugLineage.inputOutboxSeq === null));
|
||||
assert.ok(output.every((row) => row.value.sourceEvent.partition === null && row.value.sourceEvent.offset === null));
|
||||
assert.ok(output.every((row) => row.value.traceId === TRACE_ID && row.value.sessionId === HWLAB_SESSION_ID));
|
||||
assert.ok(output.every((row) => row.value.debugLineage.inputSessionId === SESSION_ID));
|
||||
assert.ok(output.every((row) => row.value.debugLineage.inputHwlabSessionId === HWLAB_SESSION_ID));
|
||||
assert.ok(output.every((row) => row.value.reconstruction.kind === "stdio-derived partial reconstruction"));
|
||||
assert.ok(output.every((row) => row.value.reconstruction.originalEventId === null && row.value.reconstruction.originalOutboxSeq === null));
|
||||
assert.ok(output.every((row) => row.value.reconstruction.identityDerivation.kind === "agentrun-session-uuid-to-hwlab-session"));
|
||||
assert.ok(output.every((row) => row.value.reconstruction.identityDerivation.reversible === true));
|
||||
});
|
||||
|
||||
test("Kafka reader and producer are injected while canonical AgentRun events use strict decode and isolated output", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-kafka-debug-canonical-"));
|
||||
const records = Array.from({ length: 35 }, (_, index) => canonicalRecord(index + 1));
|
||||
const readerCalls: any[] = [];
|
||||
const producerCalls: any[] = [];
|
||||
let connected = 0;
|
||||
let disconnected = 0;
|
||||
const result = await runKafkaCli([
|
||||
"regenerate", "hwlab",
|
||||
"--from", "kafka",
|
||||
"--session-id", SESSION_ID,
|
||||
"--trace-id", TRACE_ID,
|
||||
"--input-topic", "agentrun.event.v1",
|
||||
"--expect-count", "35",
|
||||
"--output-jsonl", path.join(cwd, "canonical-output.jsonl"),
|
||||
"--json"
|
||||
], {
|
||||
cwd,
|
||||
env: {
|
||||
HWLAB_KAFKA_BOOTSTRAP_SERVERS: "kafka.test:9092",
|
||||
HWLAB_KAFKA_HWLAB_DEBUG_EVENT_TOPIC: "hwlab.event.debug.v1",
|
||||
HWLAB_KAFKA_HWLAB_DEBUG_GROUP_PREFIX: "hwlab-v03-workbench-isolated-debug"
|
||||
},
|
||||
now: () => "2026-07-10T12:00:00.000Z",
|
||||
async readKafka(input) {
|
||||
readerCalls.push(input);
|
||||
return { ok: true, groupId: "hwlab-v03-agentrun-event-debug-regenerate-test", events: records };
|
||||
},
|
||||
async createProducer() {
|
||||
return {
|
||||
async connect() { connected += 1; },
|
||||
async send(input: any) { producerCalls.push(input); return [{ topicName: input.topic, partition: 0, baseOffset: "100" }]; },
|
||||
async disconnect() { disconnected += 1; }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(readerCalls.length, 1);
|
||||
assert.equal(readerCalls[0].groupIdPrefix, "hwlab-v03-workbench-isolated-debug");
|
||||
assert.equal(readerCalls[0].sessionId, SESSION_ID);
|
||||
assert.equal(readerCalls[0].limit, 36, "exact count verification must leave room to detect one extra event");
|
||||
assert.equal(connected, 1);
|
||||
assert.equal(disconnected, 1);
|
||||
assert.equal(producerCalls.length, 1);
|
||||
assert.equal(producerCalls[0].topic, "hwlab.event.debug.v1");
|
||||
assert.equal(producerCalls[0].messages.length, 35);
|
||||
const envelopes = producerCalls[0].messages.map((message: any) => JSON.parse(message.value));
|
||||
assert.deepEqual(envelopes.map((event: any) => event.debugLineage.sourceSeq), Array.from({ length: 35 }, (_, index) => index + 1));
|
||||
assert.deepEqual(envelopes.map((event: any) => event.eventId), Array.from({ length: 35 }, (_, index) => `hwlab:evt_${index + 1}`));
|
||||
assert.deepEqual(envelopes.map((event: any) => event.debugLineage.inputOutboxSeq), Array.from({ length: 35 }, (_, index) => index + 1));
|
||||
assert.ok(envelopes.every((event: any) => event.diagnostics.inputKind === "canonical-durable"));
|
||||
assert.ok(envelopes.every((event: any) => event.schema === "hwlab.event.debug.v1"));
|
||||
assert.equal(result.payload.output.publishedCount, 35);
|
||||
assert.deepEqual(result.payload.config.outputTopic, { value: "hwlab.event.debug.v1", source: "env:HWLAB_KAFKA_HWLAB_DEBUG_EVENT_TOPIC" });
|
||||
assert.deepEqual(result.payload.config.groupPrefix, { value: "hwlab-v03-workbench-isolated-debug", source: "env:HWLAB_KAFKA_HWLAB_DEBUG_GROUP_PREFIX" });
|
||||
assert.equal(result.payload.validation.orderPreserved, true);
|
||||
assert.equal(result.payload.validation.lineagePreserved, true);
|
||||
});
|
||||
|
||||
test("reconstruction identity fabrication and product output topic fail closed", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-kafka-debug-invalid-"));
|
||||
const forged = reconstructionRecord(1);
|
||||
forged.value.eventId = "evt_forged";
|
||||
const inputFile = path.join(cwd, "forged.jsonl");
|
||||
await writeFile(inputFile, `${JSON.stringify(forged)}\n`, "utf8");
|
||||
|
||||
const forgedResult = await runKafkaCli([
|
||||
"regenerate", "hwlab", "--from", "jsonl", "--session-id", SESSION_ID,
|
||||
"--jsonl-file", inputFile, "--no-publish"
|
||||
], { cwd, env: {}, now: () => "2026-07-10T12:00:00.000Z" });
|
||||
assert.equal(forgedResult.exitCode, 1);
|
||||
assert.equal(forgedResult.payload.error.code, "hwlab_kafka_debug_identity_invalid");
|
||||
|
||||
const productResult = await runKafkaCli([
|
||||
"regenerate", "hwlab", "--from", "jsonl", "--session-id", SESSION_ID,
|
||||
"--jsonl-file", inputFile, "--output-topic", "hwlab.event.v1", "--no-publish"
|
||||
], { cwd, env: {}, now: () => "2026-07-10T12:00:00.000Z" });
|
||||
assert.equal(productResult.exitCode, 1);
|
||||
assert.equal(productResult.payload.error.code, "product_topic_forbidden");
|
||||
});
|
||||
|
||||
test("Kafka query exposes the isolated generated group and original value hash", async () => {
|
||||
const valueText = JSON.stringify(canonicalRecord(1).value);
|
||||
const consumerConfigs: any[] = [];
|
||||
const consumer = {
|
||||
async connect() {},
|
||||
async subscribe() {},
|
||||
async run({ eachMessage }: any) {
|
||||
await eachMessage({
|
||||
topic: "agentrun.event.v1",
|
||||
partition: 0,
|
||||
message: { offset: "1601", key: Buffer.from(SESSION_ID), value: Buffer.from(valueText), timestamp: "1783681200000" }
|
||||
});
|
||||
},
|
||||
async stop() {},
|
||||
async disconnect() {}
|
||||
};
|
||||
const queried = await queryKafkaEventStream({
|
||||
env: { HWLAB_KAFKA_BOOTSTRAP_SERVERS: "kafka.test:9092", HWLAB_KAFKA_CLIENT_ID: "hwlab-test" },
|
||||
topic: "agentrun.event.v1",
|
||||
sessionId: SESSION_ID,
|
||||
limit: 1,
|
||||
groupIdPrefix: "hwlab-v03-workbench-isolated-debug",
|
||||
kafkaFactory() {
|
||||
return { consumer(config: any) { consumerConfigs.push(config); return consumer; } };
|
||||
}
|
||||
});
|
||||
assert.match(queried.groupId, /^hwlab-v03-workbench-isolated-debug-\d+-[a-f0-9]{8}$/u);
|
||||
assert.equal(consumerConfigs[0].groupId, queried.groupId);
|
||||
assert.equal(queried.events[0].valueSha256, createSha256(valueText));
|
||||
});
|
||||
|
||||
test("non-reversible AgentRun session identity stays only in debug lineage", () => {
|
||||
const rawSessionId = "ses_agentrun_not_uuid";
|
||||
const record = reconstructionRecord(1);
|
||||
record.key = rawSessionId;
|
||||
record.value.sessionId = rawSessionId;
|
||||
record.value.hwlabSessionId = null;
|
||||
record.value.run.sessionId = rawSessionId;
|
||||
record.value.run.hwlabSessionId = null;
|
||||
record.value.event.payload.sessionId = rawSessionId;
|
||||
record.value.reconstruction.identityDerivation = {
|
||||
kind: "none",
|
||||
sourceField: "sessionId",
|
||||
targetField: "hwlabSessionId",
|
||||
reversible: false,
|
||||
reason: "source sessionId is not ses_agentrun_<uuid-with-underscores>",
|
||||
valuesPrinted: false
|
||||
};
|
||||
const mapped = mapAgentRunRecordsToHwlabDebugEvents([record], {
|
||||
sessionId: rawSessionId,
|
||||
traceId: TRACE_ID,
|
||||
expectedCount: 1,
|
||||
producedAt: "2026-07-10T12:00:00.000Z"
|
||||
});
|
||||
assert.equal(mapped.events[0].sessionId, null);
|
||||
assert.equal(mapped.events[0].hwlabSessionId, null);
|
||||
assert.equal(mapped.events[0].event.sessionId, null);
|
||||
assert.equal(mapped.events[0].debugLineage.inputSessionId, rawSessionId);
|
||||
assert.equal(mapped.events[0].reconstruction.identityDerivation.kind, "none");
|
||||
});
|
||||
|
||||
function reconstructionRecord(seq: number): any {
|
||||
const value = {
|
||||
schema: "agentrun.event.reconstruction.v1",
|
||||
eventType: "agentrun.event.reconstructed",
|
||||
source: "agentrun-cli-stdio-reconstruction",
|
||||
eventId: null,
|
||||
outboxSeq: null,
|
||||
sourceSeq: seq,
|
||||
observedAt: `2026-07-10T11:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||
traceId: TRACE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
hwlabSessionId: HWLAB_SESSION_ID,
|
||||
runId: RUN_ID,
|
||||
commandId: "cmd_9fff03fc124b4203b065d04bbb7c4f1e",
|
||||
run: { runId: RUN_ID, sessionId: SESSION_ID, hwlabSessionId: HWLAB_SESSION_ID, status: "running", valuesPrinted: false },
|
||||
command: { commandId: "cmd_9fff03fc124b4203b065d04bbb7c4f1e", runId: RUN_ID, seq: 1, type: "run", state: "running", valuesPrinted: false },
|
||||
event: {
|
||||
id: null,
|
||||
runId: RUN_ID,
|
||||
seq,
|
||||
type: "backend_status",
|
||||
payload: {
|
||||
traceId: TRACE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
phase: `reconstructed-${seq}`,
|
||||
reconstructed: true,
|
||||
reconstructionKind: "stdio-derived partial reconstruction"
|
||||
},
|
||||
createdAt: `2026-07-10T11:00:${String(seq).padStart(2, "0")}.000Z`
|
||||
},
|
||||
reconstruction: {
|
||||
kind: "stdio-derived partial reconstruction",
|
||||
completeLifecycle: false,
|
||||
originalEventId: null,
|
||||
originalOutboxSeq: null,
|
||||
sourceTopic: "codex-stdio.raw.v1",
|
||||
sourcePartition: 0,
|
||||
sourceOffset: String(3500 + seq),
|
||||
sourceFrameSeq: seq,
|
||||
sourceValueSha256: `sha256:${String(seq).padStart(64, "0")}`,
|
||||
identityDerivation: {
|
||||
kind: "agentrun-session-uuid-to-hwlab-session",
|
||||
sourceField: "sessionId",
|
||||
targetField: "hwlabSessionId",
|
||||
reversible: true,
|
||||
sourcePrefix: "ses_agentrun_",
|
||||
targetPrefix: "ses_",
|
||||
separatorTransform: "underscore-to-hyphen",
|
||||
valuesPrinted: false
|
||||
}
|
||||
},
|
||||
valuesPrinted: false
|
||||
};
|
||||
return { topic: "agentrun.event.debug.v1", partition: null, offset: null, key: SESSION_ID, value, headers: { "x-trace-id": TRACE_ID } };
|
||||
}
|
||||
|
||||
function canonicalRecord(seq: number): any {
|
||||
const eventId = `evt_${seq}`;
|
||||
const value = {
|
||||
schema: "agentrun.event.v1",
|
||||
eventType: "agentrun.event.committed",
|
||||
source: "agentrun-manager",
|
||||
eventId,
|
||||
outboxSeq: seq,
|
||||
sourceSeq: seq,
|
||||
committedAt: `2026-07-10T11:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||
traceId: TRACE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
hwlabSessionId: HWLAB_SESSION_ID,
|
||||
runId: RUN_ID,
|
||||
commandId: "cmd_9fff03fc124b4203b065d04bbb7c4f1e",
|
||||
run: {
|
||||
runId: RUN_ID,
|
||||
status: "running",
|
||||
terminalStatus: null,
|
||||
failureKind: null,
|
||||
tenantId: "tenant-test",
|
||||
projectId: "project-test",
|
||||
providerId: "gpt.pika",
|
||||
backendProfile: "gpt.pika",
|
||||
sessionId: SESSION_ID,
|
||||
hwlabSessionId: HWLAB_SESSION_ID,
|
||||
conversationId: null,
|
||||
threadId: null,
|
||||
valuesPrinted: false
|
||||
},
|
||||
command: {
|
||||
commandId: "cmd_9fff03fc124b4203b065d04bbb7c4f1e",
|
||||
runId: RUN_ID,
|
||||
seq: 1,
|
||||
type: "run",
|
||||
state: "running",
|
||||
payloadHash: "hash",
|
||||
valuesPrinted: false
|
||||
},
|
||||
event: {
|
||||
id: eventId,
|
||||
runId: RUN_ID,
|
||||
seq,
|
||||
type: "backend_status",
|
||||
payload: { traceId: TRACE_ID, sessionId: SESSION_ID, phase: `canonical-${seq}` },
|
||||
createdAt: `2026-07-10T11:00:${String(seq).padStart(2, "0")}.000Z`
|
||||
},
|
||||
valuesPrinted: false
|
||||
};
|
||||
return { topic: "agentrun.event.v1", partition: 0, offset: String(1600 + seq), key: SESSION_ID, value };
|
||||
}
|
||||
|
||||
function createSha256(value: string) {
|
||||
return new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Responsibility: regenerate isolated HWLAB debug Kafka events from AgentRun Kafka or JSONL input without Cloud API, DB, or projector dependencies.
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { Kafka, logLevel } from "kafkajs";
|
||||
|
||||
import {
|
||||
buildHwlabKafkaProducerMessage,
|
||||
DEFAULT_AGENTRUN_DEBUG_EVENT_TOPIC,
|
||||
DEFAULT_HWLAB_DEBUG_EVENT_TOPIC,
|
||||
projectAgentRunKafkaMessageToHwlabDebugEvent,
|
||||
queryKafkaEventStream
|
||||
} from "../../../internal/cloud/kafka-event-bridge.ts";
|
||||
|
||||
const CLI_NAME = "hwlab-cli";
|
||||
const VERSION = "0.3.0-kafka-debug";
|
||||
const DEFAULT_GROUP_PREFIX = "hwlab-v03-workbench-isolated-debug";
|
||||
const DEFAULT_LIMIT = 500;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
type JsonRecord = Record<string, any>;
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
|
||||
type KafkaCliOptions = {
|
||||
cwd?: string;
|
||||
env?: EnvLike;
|
||||
now?: () => string;
|
||||
readKafka?: (input: Record<string, any>) => Promise<Record<string, any>>;
|
||||
createProducer?: (input: { env: EnvLike; clientId: string }) => Promise<any> | any;
|
||||
};
|
||||
|
||||
export async function mainKafkaCli(argv = process.argv.slice(3), options: KafkaCliOptions = {}) {
|
||||
const result = await runKafkaCli(argv, options);
|
||||
const json = argv.includes("--json");
|
||||
process.stdout.write(json ? `${JSON.stringify(result.payload, null, 2)}\n` : `${renderKafkaCliText(result.payload)}\n`);
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
export async function runKafkaCli(argv: string[], options: KafkaCliOptions = {}) {
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
try {
|
||||
const parsed = parseOptions(argv);
|
||||
const command = parsed._[0] || "help";
|
||||
const resource = parsed._[1] || "";
|
||||
if (["help", "--help", "-h"].includes(command) || parsed.help === true) {
|
||||
return result(0, kafkaHelp(), now);
|
||||
}
|
||||
if (command !== "regenerate" || resource !== "hwlab") {
|
||||
throw cliError("unsupported_kafka_command", "supported command: kafka regenerate hwlab", { command, resource });
|
||||
}
|
||||
const payload = await regenerateHwlabDebugEvents(parsed, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
env: options.env ?? process.env,
|
||||
now,
|
||||
readKafka: options.readKafka ?? defaultKafkaReader,
|
||||
createProducer: options.createProducer ?? defaultProducerFactory
|
||||
});
|
||||
return result(0, payload, now);
|
||||
} catch (error) {
|
||||
return result(1, failure(error), now);
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateHwlabDebugEvents(parsed: ParsedArgs, dependencies: Required<KafkaCliOptions>) {
|
||||
const sessionId = requiredSessionId(parsed.sessionId);
|
||||
const traceId = optionalTraceId(parsed.traceId);
|
||||
const sourceMode = enumValue(parsed.from ?? "kafka", "from", ["kafka", "jsonl"]);
|
||||
const inputTopicResolution = resolveConfig(parsed.inputTopic, undefined, DEFAULT_AGENTRUN_DEBUG_EVENT_TOPIC, "--input-topic", "contract-default");
|
||||
const outputTopicResolution = resolveConfig(parsed.outputTopic, dependencies.env.HWLAB_KAFKA_HWLAB_DEBUG_EVENT_TOPIC, DEFAULT_HWLAB_DEBUG_EVENT_TOPIC, "--output-topic", "env:HWLAB_KAFKA_HWLAB_DEBUG_EVENT_TOPIC");
|
||||
const groupPrefixResolution = resolveConfig(parsed.groupPrefix, dependencies.env.HWLAB_KAFKA_HWLAB_DEBUG_GROUP_PREFIX, DEFAULT_GROUP_PREFIX, "--group-prefix", "env:HWLAB_KAFKA_HWLAB_DEBUG_GROUP_PREFIX");
|
||||
const inputTopic = inputTopicResolution.value;
|
||||
const outputTopic = outputTopicResolution.value;
|
||||
assertDebugTopic(outputTopic);
|
||||
const limit = boundedInteger(parsed.limit, DEFAULT_LIMIT, 1, 5000, "limit");
|
||||
const timeoutMs = boundedInteger(parsed.timeoutMs, DEFAULT_TIMEOUT_MS, 250, 60000, "timeoutMs");
|
||||
const expectedCount = optionalPositiveInteger(parsed.expectCount, "expectCount");
|
||||
const readLimit = expectedCount === undefined ? limit : Math.min(5000, expectedCount + 1);
|
||||
const groupPrefix = groupPrefixResolution.value;
|
||||
assertDebugGroup(groupPrefix);
|
||||
const shouldPublish = parsed.noPublish === true ? false : parsed.publish === true ? true : sourceMode === "kafka";
|
||||
|
||||
const readResult = sourceMode === "jsonl"
|
||||
? await readJsonlInput(requiredText(parsed.jsonlFile, "jsonlFile"), dependencies.cwd, inputTopic)
|
||||
: await dependencies.readKafka({
|
||||
env: dependencies.env,
|
||||
stream: "agentrun",
|
||||
topic: inputTopic,
|
||||
sessionId,
|
||||
traceId: traceId || null,
|
||||
limit: readLimit,
|
||||
timeoutMs,
|
||||
fromBeginning: parsed.fromEnd !== true,
|
||||
groupIdPrefix: groupPrefix
|
||||
});
|
||||
const records = Array.isArray(readResult.events) ? readResult.events : [];
|
||||
const mapped = mapAgentRunRecordsToHwlabDebugEvents(records, {
|
||||
sessionId,
|
||||
traceId,
|
||||
expectedCount,
|
||||
producedAt: dependencies.now()
|
||||
});
|
||||
|
||||
const stateId = `hwlab-debug-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
const artifactPath = path.resolve(
|
||||
dependencies.cwd,
|
||||
text(parsed.outputJsonl) || path.join(".state", "hwlab-cli", "kafka-debug", stateId, "hwlab-events.jsonl")
|
||||
);
|
||||
const artifactRows = mapped.events.map((envelope) => {
|
||||
const message = buildHwlabKafkaProducerMessage(envelope);
|
||||
return {
|
||||
topic: outputTopic,
|
||||
partition: null,
|
||||
offset: null,
|
||||
key: message.key,
|
||||
value: envelope,
|
||||
headers: message.headers
|
||||
};
|
||||
});
|
||||
const artifactText = artifactRows.map((row) => JSON.stringify(row)).join("\n") + "\n";
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, artifactText, "utf8");
|
||||
|
||||
let publishedCount = 0;
|
||||
let publishMetadata: unknown[] = [];
|
||||
if (shouldPublish) {
|
||||
const producer = await dependencies.createProducer({ env: dependencies.env, clientId: `${groupPrefix}-producer` });
|
||||
try {
|
||||
await producer.connect?.();
|
||||
publishMetadata = await producer.send({
|
||||
topic: outputTopic,
|
||||
messages: mapped.events.map(buildHwlabKafkaProducerMessage)
|
||||
});
|
||||
publishedCount = mapped.events.length;
|
||||
} catch (error) {
|
||||
throw cliError("hwlab_debug_publish_failed", "failed to publish regenerated HWLAB events to the isolated debug topic", {
|
||||
topic: outputTopic,
|
||||
mappedCount: mapped.events.length,
|
||||
artifactPath,
|
||||
causeCode: (error as any)?.code ?? null,
|
||||
causeMessage: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
if (typeof producer.disconnect === "function") await producer.disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const first = mapped.events[0];
|
||||
const last = mapped.events.at(-1);
|
||||
const sourceFile = text(readResult.sourceFile);
|
||||
const traceArgument = traceId ? ` --trace-id ${traceId}` : "";
|
||||
const baseCommand = sourceMode === "jsonl"
|
||||
? `hwlab-cli kafka regenerate hwlab --from jsonl --session-id ${sessionId}${traceArgument} --jsonl-file ${shellArg(sourceFile || String(parsed.jsonlFile))}`
|
||||
: `hwlab-cli kafka regenerate hwlab --from kafka --session-id ${sessionId}${traceArgument} --input-topic ${inputTopic}`;
|
||||
const nextCommand = shouldPublish
|
||||
? `${baseCommand} --no-publish --output-topic ${outputTopic} --expect-count ${mapped.events.length} --json`
|
||||
: `${baseCommand} --publish --output-topic ${outputTopic} --expect-count ${mapped.events.length} --json`;
|
||||
return {
|
||||
ok: true,
|
||||
action: "kafka.regenerate.hwlab",
|
||||
status: "succeeded",
|
||||
sourceMode,
|
||||
config: {
|
||||
inputTopic: inputTopicResolution,
|
||||
outputTopic: outputTopicResolution,
|
||||
groupPrefix: groupPrefixResolution
|
||||
},
|
||||
input: {
|
||||
topic: inputTopic,
|
||||
groupId: readResult.groupId ?? null,
|
||||
readCount: records.length,
|
||||
matchedCount: mapped.events.length,
|
||||
sessionId,
|
||||
traceId: traceId || null
|
||||
},
|
||||
output: {
|
||||
schema: DEFAULT_HWLAB_DEBUG_EVENT_TOPIC,
|
||||
topic: outputTopic,
|
||||
count: mapped.events.length,
|
||||
published: shouldPublish,
|
||||
publishedCount,
|
||||
metadataCount: Array.isArray(publishMetadata) ? publishMetadata.length : 0,
|
||||
artifact: {
|
||||
path: artifactPath,
|
||||
bytes: Buffer.byteLength(artifactText),
|
||||
sha256: `sha256:${sha256(artifactText)}`
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
expectedCount: expectedCount ?? null,
|
||||
oneToOne: mapped.inputCount === mapped.events.length,
|
||||
orderPreserved: mapped.orderPreserved,
|
||||
lineagePreserved: mapped.lineagePreserved,
|
||||
firstSourceSeq: first?.debugLineage?.sourceSeq ?? null,
|
||||
lastSourceSeq: last?.debugLineage?.sourceSeq ?? null,
|
||||
inputKinds: [...new Set(mapped.inputKinds)]
|
||||
},
|
||||
runtimeDependencies: {
|
||||
cloudApi: false,
|
||||
database: false,
|
||||
transactionalProjector: false,
|
||||
kafka: sourceMode === "kafka" || shouldPublish
|
||||
},
|
||||
next: {
|
||||
command: nextCommand,
|
||||
reason: shouldPublish
|
||||
? "用同一源码映射做一次无 Kafka 写入复验;随后 Workbench 调试模式按 traceId 消费独立 debug topic。"
|
||||
: "离线映射已固化为 JSONL;确认内容后可显式 --publish 写入独立 debug topic。"
|
||||
},
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAgentRunRecordsToHwlabDebugEvents(records: JsonRecord[], options: { sessionId: string; traceId?: string; expectedCount?: number; producedAt?: string }) {
|
||||
const matchingRecords = records.filter((record) => recordMatches(record, options.sessionId, options.traceId));
|
||||
if (matchingRecords.length === 0) {
|
||||
throw cliError("agentrun_debug_events_missing", "no AgentRun events matched the requested sessionId/traceId", {
|
||||
sessionId: options.sessionId,
|
||||
traceId: options.traceId || null,
|
||||
readCount: records.length
|
||||
});
|
||||
}
|
||||
if (options.expectedCount !== undefined && matchingRecords.length !== options.expectedCount) {
|
||||
throw cliError("agentrun_debug_event_count_mismatch", "matched AgentRun event count does not equal --expect-count", {
|
||||
expectedCount: options.expectedCount,
|
||||
actualCount: matchingRecords.length
|
||||
});
|
||||
}
|
||||
const events = [];
|
||||
const inputKinds = [];
|
||||
let orderPreserved = true;
|
||||
let lineagePreserved = true;
|
||||
for (const [index, record] of matchingRecords.entries()) {
|
||||
const kafkaMessage = kafkaMessageFromRecord(record, index);
|
||||
const projected = projectAgentRunKafkaMessageToHwlabDebugEvent(kafkaMessage, { producedAt: options.producedAt });
|
||||
if (!projected.ok) {
|
||||
throw cliError(projected.error?.code || "hwlab_debug_projection_failed", projected.error?.message || "AgentRun debug projection failed", {
|
||||
index,
|
||||
topic: kafkaMessage.topic,
|
||||
partition: kafkaMessage.partition,
|
||||
offset: kafkaMessage.message.offset
|
||||
});
|
||||
}
|
||||
const input = valueFromRecord(record);
|
||||
const sourceSeq = Number(input.sourceSeq ?? input.event?.seq);
|
||||
orderPreserved &&= projected.envelope.debugLineage?.sourceSeq === sourceSeq;
|
||||
lineagePreserved &&= projected.envelope.debugLineage?.inputTraceId === text(input.traceId ?? input.event?.payload?.traceId)
|
||||
&& sessionCandidates(input).includes(options.sessionId)
|
||||
&& projected.envelope.sourceEvent?.sha256 === kafkaMessage.inputSha256;
|
||||
events.push(projected.envelope);
|
||||
inputKinds.push(projected.inputKind);
|
||||
}
|
||||
if (events.length !== matchingRecords.length || !orderPreserved || !lineagePreserved) {
|
||||
throw cliError("hwlab_debug_mapping_invariant_failed", "AgentRun -> HWLAB debug mapping did not preserve one-to-one order and lineage", {
|
||||
inputCount: matchingRecords.length,
|
||||
outputCount: events.length,
|
||||
orderPreserved,
|
||||
lineagePreserved
|
||||
});
|
||||
}
|
||||
return { events, inputKinds, inputCount: matchingRecords.length, orderPreserved, lineagePreserved };
|
||||
}
|
||||
|
||||
function kafkaHelp() {
|
||||
return {
|
||||
ok: true,
|
||||
action: "kafka.help",
|
||||
status: "succeeded",
|
||||
commands: [
|
||||
"hwlab-cli kafka regenerate hwlab --from kafka --session-id ses_... [--trace-id trc_...] [--input-topic agentrun.event.debug.v1] [--expect-count 35] [--json]",
|
||||
"hwlab-cli kafka regenerate hwlab --from jsonl --session-id ses_... --jsonl-file agentrun-events.jsonl --no-publish [--output-jsonl hwlab-events.jsonl] [--json]"
|
||||
],
|
||||
defaults: {
|
||||
inputTopic: DEFAULT_AGENTRUN_DEBUG_EVENT_TOPIC,
|
||||
outputTopic: DEFAULT_HWLAB_DEBUG_EVENT_TOPIC,
|
||||
groupPrefix: DEFAULT_GROUP_PREFIX,
|
||||
kafkaInputPublishes: true,
|
||||
jsonlInputPublishes: false
|
||||
},
|
||||
boundaries: {
|
||||
productTopicAllowed: false,
|
||||
cloudApi: false,
|
||||
database: false,
|
||||
transactionalProjector: false,
|
||||
reconstructionIdentityFabricated: false
|
||||
},
|
||||
next: {
|
||||
command: "hwlab-cli kafka regenerate hwlab --from jsonl --session-id ses_... --jsonl-file agentrun-events.jsonl --no-publish --json",
|
||||
reason: "先用完全离线 JSONL 验证一对一顺序映射,再决定是否发布到独立 debug topic。"
|
||||
},
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultKafkaReader(input: Record<string, any>) {
|
||||
return queryKafkaEventStream({
|
||||
...input,
|
||||
kafkaFactory(config: { brokers: string[]; clientId: string }) { return boundedKafka(config); }
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultProducerFactory({ env, clientId }: { env: EnvLike; clientId: string }) {
|
||||
const brokers = csv(env.HWLAB_KAFKA_BOOTSTRAP_SERVERS);
|
||||
if (brokers.length === 0) throw cliError("kafka_brokers_missing", "HWLAB_KAFKA_BOOTSTRAP_SERVERS is required for Kafka publish", { field: "HWLAB_KAFKA_BOOTSTRAP_SERVERS" });
|
||||
return boundedKafka({ clientId, brokers }).producer({ allowAutoTopicCreation: false });
|
||||
}
|
||||
|
||||
function boundedKafka({ clientId, brokers }: { clientId: string; brokers: string[] }) {
|
||||
return new Kafka({
|
||||
clientId,
|
||||
brokers,
|
||||
logLevel: logLevel.NOTHING,
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 10000,
|
||||
retry: { retries: 1, maxRetryTime: 10000 }
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonlInput(fileValue: string, cwd: string, defaultTopic: string) {
|
||||
const file = path.resolve(cwd, fileValue);
|
||||
const content = await readFile(file, "utf8");
|
||||
const events = [];
|
||||
for (const [index, line] of content.split(/\r?\n/u).entries()) {
|
||||
if (!line.trim()) continue;
|
||||
let value;
|
||||
try { value = JSON.parse(line); } catch (error) {
|
||||
throw cliError("jsonl_line_invalid", "input JSONL contains invalid JSON", { file, line: index + 1, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
events.push(normalizeRecord(value, defaultTopic));
|
||||
}
|
||||
return { ok: true, topic: defaultTopic, groupId: null, events, sourceFile: file };
|
||||
}
|
||||
|
||||
function normalizeRecord(record: any, defaultTopic: string) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) throw cliError("jsonl_record_invalid", "each JSONL row must be an object");
|
||||
if (Object.hasOwn(record, "value")) return { ...record, topic: text(record.topic) || defaultTopic };
|
||||
return { topic: defaultTopic, partition: null, offset: null, key: null, value: record };
|
||||
}
|
||||
|
||||
function kafkaMessageFromRecord(record: JsonRecord, index: number) {
|
||||
const value = valueFromRecord(record);
|
||||
const valueText = typeof record.value === "string" ? record.value : JSON.stringify(value);
|
||||
return {
|
||||
topic: text(record.topic) || DEFAULT_AGENTRUN_DEBUG_EVENT_TOPIC,
|
||||
partition: nullableInteger(record.partition),
|
||||
inputSha256: text(record.valueSha256) || sha256(valueText),
|
||||
message: {
|
||||
offset: record.offset === null || record.offset === undefined ? null : String(record.offset),
|
||||
key: record.key === null || record.key === undefined ? null : Buffer.from(String(record.key)),
|
||||
value: Buffer.from(valueText),
|
||||
timestamp: record.timestamp === null || record.timestamp === undefined ? null : String(record.timestamp),
|
||||
headers: record.headers ?? undefined,
|
||||
debugIndex: index
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function valueFromRecord(record: JsonRecord) {
|
||||
const candidate = Object.hasOwn(record, "value") ? record.value : record;
|
||||
if (typeof candidate === "string") {
|
||||
try { return JSON.parse(candidate); } catch { throw cliError("jsonl_value_invalid", "JSONL transport value must contain a JSON object"); }
|
||||
}
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw cliError("jsonl_value_invalid", "JSONL transport value must be a JSON object");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function recordMatches(record: JsonRecord, sessionId: string, traceId?: string) {
|
||||
const value = valueFromRecord(record);
|
||||
if (!sessionCandidates(value).includes(sessionId)) return false;
|
||||
return !traceId || traceCandidates(value).includes(traceId);
|
||||
}
|
||||
|
||||
function sessionCandidates(value: any) {
|
||||
return uniqueText([
|
||||
value.sessionId,
|
||||
value.hwlabSessionId,
|
||||
value.run?.sessionId,
|
||||
value.run?.hwlabSessionId,
|
||||
value.event?.payload?.sessionId,
|
||||
value.event?.payload?.hwlabSessionId
|
||||
]);
|
||||
}
|
||||
|
||||
function traceCandidates(value: any) {
|
||||
return uniqueText([value.traceId, value.run?.traceId, value.command?.traceId, value.event?.payload?.traceId, value.event?.payload?.hwlabTraceId]);
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[]): ParsedArgs {
|
||||
const out: ParsedArgs = { _: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const item = argv[index] ?? "";
|
||||
if (!item.startsWith("--")) { out._.push(item); continue; }
|
||||
const eq = item.indexOf("=");
|
||||
const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2);
|
||||
const key = rawKey.replace(/-([a-z])/gu, (_, character) => String(character).toUpperCase());
|
||||
if (eq >= 0) { out[key] = item.slice(eq + 1); continue; }
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) { out[key] = next; index += 1; }
|
||||
else out[key] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderKafkaCliText(payload: Record<string, any>) {
|
||||
if (payload.ok === false) return `failed action=${payload.action} code=${payload.error?.code} message=${JSON.stringify(payload.error?.message)} next=${JSON.stringify(payload.next?.command)}`;
|
||||
const validation = payload.validation ?? {};
|
||||
return [
|
||||
"ok",
|
||||
`action=${payload.action}`,
|
||||
payload.input ? `input=${payload.input.matchedCount}/${payload.input.readCount}` : null,
|
||||
payload.output ? `output=${payload.output.count}` : null,
|
||||
payload.output ? `published=${payload.output.publishedCount}` : null,
|
||||
validation.orderPreserved !== undefined ? `order=${validation.orderPreserved ? "preserved" : "failed"}` : null,
|
||||
payload.output?.topic ? `topic=${payload.output.topic}` : null,
|
||||
payload.config?.outputTopic?.source ? `topicSource=${payload.config.outputTopic.source}` : null,
|
||||
payload.config?.groupPrefix?.source ? `groupSource=${payload.config.groupPrefix.source}` : null,
|
||||
payload.output?.artifact?.path ? `artifact=${shellArg(payload.output.artifact.path)}` : null,
|
||||
payload.next?.command ? `next=${JSON.stringify(payload.next.command)}` : null
|
||||
].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function result(exitCode: number, payload: Record<string, any>, now: () => string) {
|
||||
return { exitCode, payload: { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload } };
|
||||
}
|
||||
|
||||
function failure(error: any) {
|
||||
return {
|
||||
ok: false,
|
||||
action: "kafka.regenerate.hwlab",
|
||||
status: "failed",
|
||||
error: { code: error?.code ?? "hwlab_kafka_debug_cli_failed", message: error instanceof Error ? error.message : String(error), details: error?.details ?? undefined },
|
||||
next: { command: "hwlab-cli kafka help --json", reason: "检查独立 debug topic、sessionId、JSONL 合同和 Kafka YAML 注入后重试。" },
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function assertDebugTopic(topic: string) {
|
||||
if (topic === "hwlab.event.v1" || !/(?:^|[.-])debug(?:[.-]|$)/u.test(topic)) {
|
||||
throw cliError("product_topic_forbidden", "output topic must be an isolated debug topic; hwlab.event.v1 is forbidden", { topic });
|
||||
}
|
||||
}
|
||||
|
||||
function assertDebugGroup(groupPrefix: string) {
|
||||
if (!/(?:^|[.-])debug(?:[.-]|$)/u.test(groupPrefix)) throw cliError("debug_group_required", "Kafka consumer group prefix must identify an isolated debug group", { groupPrefix });
|
||||
}
|
||||
|
||||
function requiredSessionId(value: unknown) {
|
||||
const result = requiredText(value, "sessionId");
|
||||
if (!/^ses_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_session_id", "sessionId must start with ses_", { sessionId: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalTraceId(value: unknown) {
|
||||
const result = text(value);
|
||||
if (result && !/^trc_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_trace_id", "traceId must start with trc_", { traceId: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
function enumValue(value: unknown, field: string, allowed: string[]) {
|
||||
const result = text(value);
|
||||
if (!allowed.includes(result)) throw cliError("invalid_option", `${field} must be one of: ${allowed.join(", ")}`, { field, value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveConfig(cliValue: unknown, envValue: unknown, fallback: string, cliSource: string, envSource: string) {
|
||||
const explicit = text(cliValue);
|
||||
if (explicit) return { value: explicit, source: cliSource };
|
||||
const injected = text(envValue);
|
||||
if (injected) return { value: injected, source: envSource };
|
||||
return { value: fallback, source: "contract-default" };
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(value: unknown, field: string) {
|
||||
if (value === undefined) return undefined;
|
||||
return boundedInteger(value, 0, 1, 5000, field);
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number, field: string) {
|
||||
const parsed = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) throw cliError("invalid_integer", `${field} must be an integer between ${minimum} and ${maximum}`, { field, value });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw cliError("invalid_partition", "partition must be a non-negative integer or null", { value });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, field: string) {
|
||||
const result = text(value);
|
||||
if (!result) throw cliError("missing_required_value", `${field} is required`, { field });
|
||||
return result;
|
||||
}
|
||||
|
||||
function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
function uniqueText(values: unknown[]) { return [...new Set(values.map(text).filter(Boolean))]; }
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function csv(value: unknown) { return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
|
||||
function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); }
|
||||
function shellArg(value: unknown) { const result = String(value ?? ""); return /^[A-Za-z0-9_./:@-]+$/u.test(result) ? result : `'${result.replace(/'/gu, `'"'"'`)}'`; }
|
||||
Reference in New Issue
Block a user