|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
|
|
DEFAULT_AGENTRUN_EVENT_TOPIC,
|
|
|
|
|
DEFAULT_HWLAB_DEBUG_EVENT_TOPIC,
|
|
|
|
|
DEFAULT_HWLAB_EVENT_TOPIC,
|
|
|
|
|
projectAgentRunKafkaEventToHwlabEvent,
|
|
|
|
|
projectAgentRunKafkaMessageToHwlabDebugEvent,
|
|
|
|
|
queryKafkaEventStream
|
|
|
|
|
} from "../../../internal/cloud/kafka-event-bridge.ts";
|
|
|
|
@@ -20,10 +21,12 @@ import { decodeWorkbenchRealtimeEventFrame } from "../../../web/hwlab-cloud-web/
|
|
|
|
|
import { reduceWorkbenchRealtimeEvent } from "../../../web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts";
|
|
|
|
|
import { projectWorkbenchLiveKafkaMessage, projectWorkbenchLiveKafkaUserMessage, workbenchLiveKafkaAssistantText, workbenchLiveKafkaProjectionTarget } from "../../../web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts";
|
|
|
|
|
import { planWorkbenchRealtimeApply } from "../../../web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts";
|
|
|
|
|
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages } from "../../../web/hwlab-cloud-web/src/stores/workbench-server-state.ts";
|
|
|
|
|
import { buildWorkbenchTimelineRows, workbenchMessageIdentity } from "../../../web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts";
|
|
|
|
|
import { renderTraceRowsMarkdown, traceDisplayRows } from "./trace-renderer.ts";
|
|
|
|
|
|
|
|
|
|
const CLI_NAME = "hwlab-cli";
|
|
|
|
|
const VERSION = "0.3.5-kafka-trace-layer-duplicates";
|
|
|
|
|
const VERSION = "0.3.6-kafka-session-order";
|
|
|
|
|
const DEFAULT_LIMIT = 500;
|
|
|
|
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
|
|
|
const DEBUG_OUTPUT_PARTITION = 0;
|
|
|
|
@@ -69,6 +72,15 @@ export async function runKafkaCli(argv: string[], options: KafkaCliOptions = {})
|
|
|
|
|
});
|
|
|
|
|
return { ...result(rendered.payload.status === "partial" ? 2 : 0, rendered.payload, now), markdownOutput: rendered.markdown };
|
|
|
|
|
}
|
|
|
|
|
if (command === "inspect" && resource === "order") {
|
|
|
|
|
action = "kafka.inspect.order";
|
|
|
|
|
const payload = await inspectKafkaSessionOrder(parsed, {
|
|
|
|
|
env: options.env ?? process.env,
|
|
|
|
|
now,
|
|
|
|
|
readKafka: options.readKafka ?? defaultKafkaReader
|
|
|
|
|
});
|
|
|
|
|
return result(payload.status === "partial" ? 2 : 0, payload, now);
|
|
|
|
|
}
|
|
|
|
|
if (command !== "regenerate" || resource !== "hwlab") throw cliError("unsupported_kafka_command", "supported commands: kafka regenerate hwlab | kafka render trace", { command, resource });
|
|
|
|
|
action = "kafka.regenerate.hwlab";
|
|
|
|
|
const payload = await regenerateHwlabDebugEvents(parsed, {
|
|
|
|
@@ -673,11 +685,507 @@ export async function renderHwlabKafkaTrace(parsed: ParsedArgs, dependencies: Re
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function inspectKafkaSessionOrder(parsed: ParsedArgs, dependencies: {
|
|
|
|
|
env: EnvLike;
|
|
|
|
|
now: () => string;
|
|
|
|
|
readKafka: (input: Record<string, any>) => Promise<Record<string, any>>;
|
|
|
|
|
}) {
|
|
|
|
|
const requestedSessionId = optionalSessionId(parsed.sessionId);
|
|
|
|
|
const containsText = text(parsed.containsText);
|
|
|
|
|
if (!requestedSessionId && !containsText) {
|
|
|
|
|
throw cliError("order_scope_required", "kafka inspect order requires --session-id or --contains-text", { fields: ["sessionId", "containsText"] });
|
|
|
|
|
}
|
|
|
|
|
if (containsText.length > 500) throw cliError("order_contains_text_too_long", "containsText must not exceed 500 characters", { length: containsText.length });
|
|
|
|
|
|
|
|
|
|
const agentrunTopic = resolveConfig(parsed.agentrunTopic, dependencies.env.HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC, DEFAULT_AGENTRUN_EVENT_TOPIC, "--agentrun-topic", "env:HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC");
|
|
|
|
|
const hwlabTopic = resolveConfig(parsed.hwlabTopic, dependencies.env.HWLAB_KAFKA_EVENT_TOPIC, DEFAULT_HWLAB_EVENT_TOPIC, "--hwlab-topic", "env:HWLAB_KAFKA_EVENT_TOPIC");
|
|
|
|
|
const group = resolveKafkaGroupConfig(parsed.groupPrefix, dependencies.env.HWLAB_KAFKA_HWLAB_DEBUG_GROUP_PREFIX, true);
|
|
|
|
|
const groupPrefix = text(group.value);
|
|
|
|
|
assertDebugGroup(groupPrefix);
|
|
|
|
|
const limit = boundedInteger(parsed.limit, DEFAULT_LIMIT, 1, 5000, "limit");
|
|
|
|
|
const timeoutMs = boundedInteger(parsed.timeoutMs, DEFAULT_TIMEOUT_MS, 250, 60000, "timeoutMs");
|
|
|
|
|
const rowLimit = boundedInteger(parsed.rowLimit, 80, 1, 500, "rowLimit");
|
|
|
|
|
|
|
|
|
|
let sessionId = requestedSessionId;
|
|
|
|
|
let discovery: Record<string, any> | null = null;
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
const discoveredRead = await dependencies.readKafka({
|
|
|
|
|
env: dependencies.env,
|
|
|
|
|
stream: "hwlab",
|
|
|
|
|
topic: hwlabTopic.value,
|
|
|
|
|
limit,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
fromBeginning: true,
|
|
|
|
|
groupIdPrefix: `${groupPrefix}-order-discovery`
|
|
|
|
|
});
|
|
|
|
|
if (!kafkaReadComplete(discoveredRead)) return partialKafkaOrderResult({
|
|
|
|
|
sessionId: null,
|
|
|
|
|
containsText,
|
|
|
|
|
agentrunTopic,
|
|
|
|
|
hwlabTopic,
|
|
|
|
|
group,
|
|
|
|
|
reads: { discovery: discoveredRead }
|
|
|
|
|
});
|
|
|
|
|
const candidates = discoverKafkaOrderScopes(discoveredRead.events, containsText);
|
|
|
|
|
const sessions = uniqueText(candidates.flatMap((candidate) => candidate.sessionIds));
|
|
|
|
|
discovery = {
|
|
|
|
|
containsTextChars: containsText.length,
|
|
|
|
|
containsTextSha256: sha256(containsText),
|
|
|
|
|
matchedEventCount: candidates.length,
|
|
|
|
|
candidateSessionIds: sessions,
|
|
|
|
|
candidates: candidates.slice(0, 20),
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
if (sessions.length === 0) throw cliError("order_scope_not_found", "no retained HWLAB event matched --contains-text", {
|
|
|
|
|
containsTextChars: containsText.length,
|
|
|
|
|
containsTextSha256: sha256(containsText),
|
|
|
|
|
scannedCount: discoveredRead.scannedCount ?? null
|
|
|
|
|
});
|
|
|
|
|
if (sessions.length !== 1) throw cliError("order_scope_ambiguous", "--contains-text matched more than one HWLAB session", {
|
|
|
|
|
candidateSessionIds: sessions,
|
|
|
|
|
matchedEventCount: candidates.length,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
});
|
|
|
|
|
sessionId = sessions[0];
|
|
|
|
|
}
|
|
|
|
|
if (!sessionId) throw cliError("order_scope_not_found", "Kafka order inspection did not resolve a sessionId");
|
|
|
|
|
|
|
|
|
|
const [agentrunRead, hwlabRead] = await Promise.all([
|
|
|
|
|
dependencies.readKafka({
|
|
|
|
|
env: dependencies.env,
|
|
|
|
|
stream: "agentrun",
|
|
|
|
|
topic: agentrunTopic.value,
|
|
|
|
|
sessionId,
|
|
|
|
|
limit,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
fromBeginning: true,
|
|
|
|
|
groupIdPrefix: `${groupPrefix}-order-agentrun`
|
|
|
|
|
}),
|
|
|
|
|
dependencies.readKafka({
|
|
|
|
|
env: dependencies.env,
|
|
|
|
|
stream: "hwlab",
|
|
|
|
|
topic: hwlabTopic.value,
|
|
|
|
|
sessionId,
|
|
|
|
|
limit,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
fromBeginning: true,
|
|
|
|
|
groupIdPrefix: `${groupPrefix}-order-hwlab`
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
if (!kafkaReadComplete(agentrunRead) || !kafkaReadComplete(hwlabRead)) return partialKafkaOrderResult({
|
|
|
|
|
sessionId,
|
|
|
|
|
containsText,
|
|
|
|
|
agentrunTopic,
|
|
|
|
|
hwlabTopic,
|
|
|
|
|
group,
|
|
|
|
|
reads: { agentrun: agentrunRead, hwlab: hwlabRead },
|
|
|
|
|
discovery
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const refreshHandoff = await replayHwlabKafkaScopeThroughProductionHandoff({
|
|
|
|
|
traceId: null,
|
|
|
|
|
sessionId,
|
|
|
|
|
query: { stream: "hwlab", topic: hwlabTopic.value, sessionId },
|
|
|
|
|
readKafka: async () => hwlabRead
|
|
|
|
|
});
|
|
|
|
|
if (refreshHandoff.status !== "succeeded") {
|
|
|
|
|
throw cliError("order_refresh_handoff_failed", "production Kafka refresh handoff rejected the retained session order", {
|
|
|
|
|
sessionId,
|
|
|
|
|
refreshHandoff: refreshHandoff.evidence,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const agentrunRecords = Array.isArray(agentrunRead.events) ? agentrunRead.events : [];
|
|
|
|
|
const hwlabRecords = refreshHandoff.records;
|
|
|
|
|
const projected = projectHwlabSessionRecordsThroughWorkbench(hwlabRecords, { sessionId, observedAt: dependencies.now() });
|
|
|
|
|
const agentrunByEventId = new Map<string, { index: number; record: JsonRecord; value: JsonRecord }>();
|
|
|
|
|
agentrunRecords.forEach((record, index) => {
|
|
|
|
|
const value = valueFromRecord(record);
|
|
|
|
|
const eventId = text(value.eventId ?? value.event?.id);
|
|
|
|
|
if (eventId) agentrunByEventId.set(eventId, { index, record, value });
|
|
|
|
|
});
|
|
|
|
|
const orderRows = projected.rows.map((row, index) => {
|
|
|
|
|
const hwlabRecord = hwlabRecords[index];
|
|
|
|
|
const hwlabValue = valueFromRecord(hwlabRecord);
|
|
|
|
|
const sourceEventId = text(hwlabValue.sourceEventId ?? hwlabValue.event?.sourceEventId);
|
|
|
|
|
const source = agentrunByEventId.get(sourceEventId) ?? null;
|
|
|
|
|
const expected = source ? projectAgentRunKafkaEventToHwlabEvent(source.value, {
|
|
|
|
|
sourceTopic: source.record.topic,
|
|
|
|
|
sourcePartition: source.record.partition,
|
|
|
|
|
sourceOffset: source.record.offset,
|
|
|
|
|
sourceKey: source.record.key,
|
|
|
|
|
inputSha256: source.record.valueSha256
|
|
|
|
|
}) : null;
|
|
|
|
|
return {
|
|
|
|
|
eventId: text(hwlabValue.eventId) || null,
|
|
|
|
|
sourceEventId: sourceEventId || null,
|
|
|
|
|
traceId: row.traceId,
|
|
|
|
|
targetTraceId: row.targetTraceId,
|
|
|
|
|
sourceSeq: row.sourceSeq,
|
|
|
|
|
kind: row.kind,
|
|
|
|
|
role: row.role,
|
|
|
|
|
agentrun: source ? { index: source.index, topic: source.record.topic, partition: source.record.partition, offset: source.record.offset } : null,
|
|
|
|
|
mapper: {
|
|
|
|
|
matched: Boolean(expected
|
|
|
|
|
&& text(expected.eventId) === text(hwlabValue.eventId)
|
|
|
|
|
&& text(expected.sourceEventId) === sourceEventId
|
|
|
|
|
&& Number(expected.event?.sourceSeq) === Number(hwlabValue.event?.sourceSeq)
|
|
|
|
|
&& text(expected.event?.type) === text(hwlabValue.event?.type)),
|
|
|
|
|
sourceTopic: text(hwlabValue.sourceEvent?.topic) || null,
|
|
|
|
|
sourcePartition: nullableIntegerOrNull(hwlabValue.sourceEvent?.partition),
|
|
|
|
|
sourceOffset: text(hwlabValue.sourceEvent?.offset) || null
|
|
|
|
|
},
|
|
|
|
|
hwlab: { index, topic: hwlabRecord.topic, partition: hwlabRecord.partition, offset: hwlabRecord.offset },
|
|
|
|
|
sse: { index: row.sseIndex, delivery: "replay" },
|
|
|
|
|
reducer: { index: row.reducerIndex, applied: row.applied },
|
|
|
|
|
web: {
|
|
|
|
|
messageId: row.messageId,
|
|
|
|
|
messageIndexAtApply: row.messageIndexAtApply,
|
|
|
|
|
finalMessageIndex: row.finalMessageIndex,
|
|
|
|
|
domRowIndexAtApply: row.domRowIndexAtApply,
|
|
|
|
|
finalDomRowIndex: row.finalDomRowIndex
|
|
|
|
|
},
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const turnPairs = kafkaOrderTurnPairs(orderRows);
|
|
|
|
|
const firstDivergence = firstKafkaOrderDivergence(turnPairs);
|
|
|
|
|
const mappedAgentRunIndices = orderRows.map((row) => row.agentrun?.index).filter((value): value is number => Number.isInteger(value));
|
|
|
|
|
const validation = {
|
|
|
|
|
agentrunScanComplete: true,
|
|
|
|
|
hwlabScanComplete: true,
|
|
|
|
|
refreshHandoffComplete: true,
|
|
|
|
|
mapperOneToOne: orderRows.length === agentrunByEventId.size && orderRows.every((row) => row.agentrun && row.mapper.matched),
|
|
|
|
|
sourceOrderPreserved: strictlyIncreasing(mappedAgentRunIndices),
|
|
|
|
|
sseOrderPreserved: orderRows.every((row, index) => row.hwlab.index === index && row.sse.index === index),
|
|
|
|
|
allEventsApplied: projected.rejectedCount === 0 && projected.appliedCount === orderRows.length,
|
|
|
|
|
reducerOrderPreserved: orderRows.every((row, index) => row.reducer.applied && row.reducer.index === index),
|
|
|
|
|
domTurnOrderPreserved: turnPairs.every((pair) => pair.dom.user < pair.dom.agent),
|
|
|
|
|
noFirstDivergence: firstDivergence === null,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
const returnedRows = orderRows.slice(-rowLimit);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "kafka.inspect.order",
|
|
|
|
|
status: "succeeded",
|
|
|
|
|
scope: {
|
|
|
|
|
requestedSessionId: requestedSessionId || null,
|
|
|
|
|
resolvedSessionId: sessionId,
|
|
|
|
|
discovery,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
},
|
|
|
|
|
input: {
|
|
|
|
|
agentrun: kafkaOrderReadEvidence(agentrunRead),
|
|
|
|
|
hwlab: kafkaOrderReadEvidence(hwlabRead),
|
|
|
|
|
refreshHandoff: refreshHandoff.evidence,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
},
|
|
|
|
|
order: {
|
|
|
|
|
pipeline: ["agentrun.event.v1", "HWLAB direct mapper", "hwlab.event.v1", "Kafka refresh handoff", "SSE frame", "Web decode/queue/reducer", "conversation DOM"],
|
|
|
|
|
eventCount: orderRows.length,
|
|
|
|
|
rowsReturned: returnedRows.length,
|
|
|
|
|
rowsOmitted: Math.max(0, orderRows.length - returnedRows.length),
|
|
|
|
|
rowWindow: orderRows.length > rowLimit ? "tail" : "full",
|
|
|
|
|
rows: returnedRows,
|
|
|
|
|
turnPairs,
|
|
|
|
|
firstDivergence,
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
},
|
|
|
|
|
projection: {
|
|
|
|
|
decodedCount: projected.decodedCount,
|
|
|
|
|
plannedCount: projected.plannedCount,
|
|
|
|
|
appliedCount: projected.appliedCount,
|
|
|
|
|
rejectedCount: projected.rejectedCount,
|
|
|
|
|
finalMessageOrder: projected.messages.map((message: any, index: number) => ({
|
|
|
|
|
index,
|
|
|
|
|
role: message.role,
|
|
|
|
|
traceId: text(message.traceId ?? message.runnerTrace?.traceId) || null,
|
|
|
|
|
messageId: text(message.messageId ?? message.id) || null
|
|
|
|
|
})),
|
|
|
|
|
finalDomOrder: projected.timelineRows.filter((row: any) => row.message).map((row: any, index: number) => ({
|
|
|
|
|
index,
|
|
|
|
|
type: row.type,
|
|
|
|
|
role: row.role ?? null,
|
|
|
|
|
traceId: row.traceId,
|
|
|
|
|
messageId: row.identity
|
|
|
|
|
})),
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
},
|
|
|
|
|
validation,
|
|
|
|
|
runtimeDependencies: { cloudApi: false, database: false, transactionalProjector: false, kafka: true },
|
|
|
|
|
next: {
|
|
|
|
|
command: `hwlab-cli kafka inspect order --session-id ${sessionId} --group-prefix ${groupPrefix} --json`,
|
|
|
|
|
reason: firstDivergence
|
|
|
|
|
? `首个顺序分歧位于 ${firstDivergence.layer};使用同一 session 和稳定身份继续定点修复。`
|
|
|
|
|
: "同一 session 在 source、mapper、SSE、reducer 与 DOM 的顺序一致。"
|
|
|
|
|
},
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function projectHwlabSessionRecordsThroughWorkbench(records: JsonRecord[], options: { sessionId: string; observedAt: string }) {
|
|
|
|
|
let state = createWorkbenchServerState();
|
|
|
|
|
let decodedCount = 0;
|
|
|
|
|
let plannedCount = 0;
|
|
|
|
|
let appliedCount = 0;
|
|
|
|
|
let rejectedCount = 0;
|
|
|
|
|
const rows: any[] = [];
|
|
|
|
|
const observedBase = Date.parse(options.observedAt);
|
|
|
|
|
const baseMs = Number.isFinite(observedBase) ? observedBase : 0;
|
|
|
|
|
for (const [index, record] of records.entries()) {
|
|
|
|
|
const envelope = valueFromRecord(record);
|
|
|
|
|
const decoded = decodeWorkbenchRealtimeEventFrame(JSON.stringify(envelope));
|
|
|
|
|
if (!decoded.payload) {
|
|
|
|
|
rejectedCount += 1;
|
|
|
|
|
rows.push(rejectedKafkaOrderProjectionRow(index, envelope, `decode-${decoded.status}`));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
decodedCount += 1;
|
|
|
|
|
const reduced = reduceWorkbenchRealtimeEvent(decoded.payload as any, "hwlab.event.v1");
|
|
|
|
|
const plan = planWorkbenchRealtimeApply(reduced.action);
|
|
|
|
|
const traceStep = plan.steps.find((step) => step.type === "apply-trace-event");
|
|
|
|
|
if (!traceStep || traceStep.type !== "apply-trace-event" || !traceStep.event) {
|
|
|
|
|
rejectedCount += 1;
|
|
|
|
|
rows.push(rejectedKafkaOrderProjectionRow(index, envelope, reduced.action.type === "ignore" ? reduced.action.reason : "trace-step-missing"));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
plannedCount += 1;
|
|
|
|
|
const traceId = text(envelope.traceId ?? traceStep.event.traceId);
|
|
|
|
|
const sessionId = text(envelope.hwlabSessionId ?? envelope.sessionId ?? traceStep.event.sessionId);
|
|
|
|
|
if (!traceId || sessionId !== options.sessionId) {
|
|
|
|
|
rejectedCount += 1;
|
|
|
|
|
rows.push(rejectedKafkaOrderProjectionRow(index, envelope, !traceId ? "trace-missing" : "session-mismatch"));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const event = traceStep.event as any;
|
|
|
|
|
const receivedAt = new Date(baseMs + index).toISOString();
|
|
|
|
|
const role = workbenchLiveKafkaProjectionTarget(event) === "user" ? "user" : "agent";
|
|
|
|
|
let message: any = null;
|
|
|
|
|
if (role === "user") {
|
|
|
|
|
const messageId = text(event.userMessageId ?? event.messageId);
|
|
|
|
|
const previous = selectActiveMessages(state, sessionId).find((item) => text(item.messageId ?? item.id) === messageId) ?? null;
|
|
|
|
|
message = projectWorkbenchLiveKafkaUserMessage({ previous, traceId, sessionId, event, receivedAt });
|
|
|
|
|
} else {
|
|
|
|
|
const previous = selectActiveMessages(state, sessionId).find((item) => item.role === "agent" && text(item.traceId ?? item.runnerTrace?.traceId) === traceId) ?? null;
|
|
|
|
|
message = projectWorkbenchLiveKafkaMessage({ previous, traceId, sessionId, event, receivedAt });
|
|
|
|
|
}
|
|
|
|
|
if (!message) {
|
|
|
|
|
rejectedCount += 1;
|
|
|
|
|
rows.push(rejectedKafkaOrderProjectionRow(index, envelope, "projection-invalid"));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId, message });
|
|
|
|
|
appliedCount += 1;
|
|
|
|
|
const messages = selectActiveMessages(state, sessionId);
|
|
|
|
|
const messageId = text(message.messageId ?? message.id);
|
|
|
|
|
const timelineRows = buildWorkbenchTimelineRows(messages);
|
|
|
|
|
rows.push({
|
|
|
|
|
sseIndex: index,
|
|
|
|
|
reducerIndex: index,
|
|
|
|
|
applied: true,
|
|
|
|
|
traceId,
|
|
|
|
|
targetTraceId: text(event.targetTraceId) || null,
|
|
|
|
|
sourceSeq: Number.isInteger(Number(event.sourceSeq)) ? Number(event.sourceSeq) : null,
|
|
|
|
|
kind: text(event.type ?? event.eventType) || "unknown",
|
|
|
|
|
role,
|
|
|
|
|
messageId,
|
|
|
|
|
messageIndexAtApply: messages.findIndex((item) => text(item.messageId ?? item.id) === messageId),
|
|
|
|
|
domRowIndexAtApply: timelineRows.findIndex((row) => row.message && workbenchMessageIdentity(row.message) === messageId),
|
|
|
|
|
finalMessageIndex: null,
|
|
|
|
|
finalDomRowIndex: null
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const messages = selectActiveMessages(state, options.sessionId);
|
|
|
|
|
const timelineRows = buildWorkbenchTimelineRows(messages);
|
|
|
|
|
const finalMessageIndex = new Map(messages.map((message, index) => [text(message.messageId ?? message.id), index]));
|
|
|
|
|
const finalDomIndex = new Map(timelineRows.filter((row) => row.message).map((row, index) => [workbenchMessageIdentity(row.message as any), index]));
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
row.finalMessageIndex = finalMessageIndex.get(row.messageId) ?? -1;
|
|
|
|
|
row.finalDomRowIndex = finalDomIndex.get(row.messageId) ?? -1;
|
|
|
|
|
}
|
|
|
|
|
return { rows, state, messages, timelineRows, decodedCount, plannedCount, appliedCount, rejectedCount };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function discoverKafkaOrderScopes(records: unknown, needle: string) {
|
|
|
|
|
if (!needle || !Array.isArray(records)) return [];
|
|
|
|
|
const normalizedNeedle = needle.toLocaleLowerCase();
|
|
|
|
|
return records.flatMap((record: JsonRecord) => {
|
|
|
|
|
const value = valueFromRecord(record);
|
|
|
|
|
const event = recordObject(value.event) ?? {};
|
|
|
|
|
const finalResponse = recordObject(event.finalResponse) ?? {};
|
|
|
|
|
const visibleTexts = uniqueText([
|
|
|
|
|
event.text,
|
|
|
|
|
event.message,
|
|
|
|
|
event.assistantText,
|
|
|
|
|
finalResponse.text
|
|
|
|
|
]);
|
|
|
|
|
if (!visibleTexts.some((candidate) => candidate.toLocaleLowerCase().includes(normalizedNeedle))) return [];
|
|
|
|
|
return [{
|
|
|
|
|
sessionIds: hwlabSessionCandidates(value),
|
|
|
|
|
traceIds: traceCandidates(value),
|
|
|
|
|
sourceEventId: text(value.sourceEventId ?? event.sourceEventId) || null,
|
|
|
|
|
topic: text(record.topic) || null,
|
|
|
|
|
partition: nullableIntegerOrNull(record.partition),
|
|
|
|
|
offset: text(record.offset) || null,
|
|
|
|
|
kind: text(event.type ?? event.eventType) || "unknown",
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
}];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kafkaOrderTurnPairs(rows: any[]) {
|
|
|
|
|
const byTrace = new Map<string, { user?: any; agent?: any }>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (!row.traceId || !["user", "agent"].includes(row.role)) continue;
|
|
|
|
|
const pair = byTrace.get(row.traceId) ?? {};
|
|
|
|
|
if (!pair[row.role as "user" | "agent"]) pair[row.role as "user" | "agent"] = row;
|
|
|
|
|
byTrace.set(row.traceId, pair);
|
|
|
|
|
}
|
|
|
|
|
return [...byTrace.entries()].flatMap(([traceId, pair]) => {
|
|
|
|
|
if (!pair.user || !pair.agent) return [];
|
|
|
|
|
return [{
|
|
|
|
|
traceId,
|
|
|
|
|
userMessageId: pair.user.web.messageId,
|
|
|
|
|
agentMessageId: pair.agent.web.messageId,
|
|
|
|
|
agentrun: { user: pair.user.agentrun?.index ?? -1, agent: pair.agent.agentrun?.index ?? -1 },
|
|
|
|
|
hwlab: { user: pair.user.hwlab.index, agent: pair.agent.hwlab.index },
|
|
|
|
|
sse: { user: pair.user.sse.index, agent: pair.agent.sse.index },
|
|
|
|
|
reducer: { user: pair.user.reducer.index, agent: pair.agent.reducer.index },
|
|
|
|
|
message: { user: pair.user.web.finalMessageIndex, agent: pair.agent.web.finalMessageIndex },
|
|
|
|
|
dom: { user: pair.user.web.finalDomRowIndex, agent: pair.agent.web.finalDomRowIndex },
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
}];
|
|
|
|
|
}).sort((left, right) => Math.min(left.hwlab.user, left.hwlab.agent) - Math.min(right.hwlab.user, right.hwlab.agent));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function firstKafkaOrderDivergence(turnPairs: any[]) {
|
|
|
|
|
for (const pair of turnPairs) {
|
|
|
|
|
if (pair.agentrun.user < 0 || pair.agentrun.agent < 0) return kafkaOrderDivergence(pair, "agentrun.event.v1 -> HWLAB direct mapper", "source-lineage-missing");
|
|
|
|
|
if (pair.agentrun.user > pair.agentrun.agent) return kafkaOrderDivergence(pair, "agentrun.event.v1", "user-after-agent-source-event");
|
|
|
|
|
if (pair.hwlab.user > pair.hwlab.agent) return kafkaOrderDivergence(pair, "HWLAB direct mapper -> hwlab.event.v1", "user-after-agent-hwlab-event");
|
|
|
|
|
if (pair.sse.user > pair.sse.agent) return kafkaOrderDivergence(pair, "Kafka refresh handoff -> SSE frame", "user-after-agent-sse-frame");
|
|
|
|
|
if (pair.reducer.user > pair.reducer.agent) return kafkaOrderDivergence(pair, "Web decode/queue/reducer", "user-after-agent-reducer-apply");
|
|
|
|
|
if (pair.message.user > pair.message.agent) return kafkaOrderDivergence(pair, "Web message reducer", "user-after-agent-message-order");
|
|
|
|
|
if (pair.dom.user > pair.dom.agent) return kafkaOrderDivergence(pair, "conversation DOM", "user-after-agent-dom-row");
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kafkaOrderDivergence(pair: any, layer: string, code: string) {
|
|
|
|
|
return { layer, code, traceId: pair.traceId, evidence: pair, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rejectedKafkaOrderProjectionRow(index: number, envelope: JsonRecord, reason: string) {
|
|
|
|
|
const event = recordObject(envelope.event) ?? {};
|
|
|
|
|
return {
|
|
|
|
|
sseIndex: index,
|
|
|
|
|
reducerIndex: index,
|
|
|
|
|
applied: false,
|
|
|
|
|
rejection: reason,
|
|
|
|
|
traceId: text(envelope.traceId ?? event.traceId) || null,
|
|
|
|
|
targetTraceId: text(event.targetTraceId) || null,
|
|
|
|
|
sourceSeq: Number.isInteger(Number(event.sourceSeq)) ? Number(event.sourceSeq) : null,
|
|
|
|
|
kind: text(event.type ?? event.eventType) || "unknown",
|
|
|
|
|
role: workbenchLiveKafkaProjectionTarget(event as any),
|
|
|
|
|
messageId: null,
|
|
|
|
|
messageIndexAtApply: -1,
|
|
|
|
|
domRowIndexAtApply: -1,
|
|
|
|
|
finalMessageIndex: -1,
|
|
|
|
|
finalDomRowIndex: -1
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kafkaReadComplete(read: Record<string, any> | null | undefined) {
|
|
|
|
|
return read?.completionReason === "end-offset"
|
|
|
|
|
&& read?.reachedEndOffsets === true
|
|
|
|
|
&& read?.completion?.complete === true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kafkaOrderReadEvidence(read: Record<string, any> | null | undefined) {
|
|
|
|
|
return {
|
|
|
|
|
topic: text(read?.topic) || null,
|
|
|
|
|
groupId: text(read?.groupId) || null,
|
|
|
|
|
scannedCount: read?.scannedCount ?? 0,
|
|
|
|
|
parsedCount: read?.parsedCount ?? 0,
|
|
|
|
|
matchedCount: read?.matchedCount ?? (Array.isArray(read?.events) ? read.events.length : 0),
|
|
|
|
|
invalidJsonCount: read?.invalidJsonCount ?? 0,
|
|
|
|
|
filterRejectedCount: read?.filterRejectedCount ?? 0,
|
|
|
|
|
completionReason: text(read?.completionReason) || null,
|
|
|
|
|
reachedEndOffsets: read?.reachedEndOffsets === true,
|
|
|
|
|
endOffsets: Array.isArray(read?.endOffsets) ? read.endOffsets : [],
|
|
|
|
|
lastScannedOffsets: Array.isArray(read?.lastScannedOffsets) ? read.lastScannedOffsets : [],
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function partialKafkaOrderResult(input: {
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
containsText: string;
|
|
|
|
|
agentrunTopic: Record<string, any>;
|
|
|
|
|
hwlabTopic: Record<string, any>;
|
|
|
|
|
group: Record<string, any>;
|
|
|
|
|
reads: Record<string, Record<string, any>>;
|
|
|
|
|
discovery?: Record<string, any> | null;
|
|
|
|
|
}) {
|
|
|
|
|
const incomplete = Object.entries(input.reads).find(([, read]) => !kafkaReadComplete(read));
|
|
|
|
|
const evidence = Object.fromEntries(Object.entries(input.reads).map(([name, read]) => [name, kafkaOrderReadEvidence(read)]));
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
action: "kafka.inspect.order",
|
|
|
|
|
status: "partial",
|
|
|
|
|
error: {
|
|
|
|
|
code: "source_scan_incomplete",
|
|
|
|
|
message: `Kafka ${incomplete?.[0] ?? "order"} scan ended before its captured topic barrier`,
|
|
|
|
|
details: { stream: incomplete?.[0] ?? null, completionReason: incomplete?.[1]?.completionReason ?? null, valuesPrinted: false }
|
|
|
|
|
},
|
|
|
|
|
scope: {
|
|
|
|
|
resolvedSessionId: input.sessionId,
|
|
|
|
|
discovery: input.discovery ?? (input.containsText ? {
|
|
|
|
|
containsTextChars: input.containsText.length,
|
|
|
|
|
containsTextSha256: sha256(input.containsText),
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
} : null),
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
},
|
|
|
|
|
config: { agentrunTopic: input.agentrunTopic, hwlabTopic: input.hwlabTopic, groupPrefix: input.group },
|
|
|
|
|
input: { ...evidence, valuesPrinted: false },
|
|
|
|
|
validation: { agentrunScanComplete: kafkaReadComplete(input.reads.agentrun), hwlabScanComplete: kafkaReadComplete(input.reads.hwlab), valuesPrinted: false },
|
|
|
|
|
next: {
|
|
|
|
|
command: input.sessionId
|
|
|
|
|
? `hwlab-cli kafka inspect order --session-id ${input.sessionId} --group-prefix ${input.group.value} --json`
|
|
|
|
|
: `hwlab-cli kafka inspect order --contains-text <text> --group-prefix ${input.group.value} --json`,
|
|
|
|
|
reason: "使用新的隔离 debug group 和足够的 YAML-owned limit/timeout 重试,只有完整 barrier 才能判定顺序。"
|
|
|
|
|
},
|
|
|
|
|
valuesPrinted: false
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function strictlyIncreasing(values: number[]) {
|
|
|
|
|
return values.every((value, index) => index === 0 || value > values[index - 1]!);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nullableIntegerOrNull(value: unknown) {
|
|
|
|
|
if (value === null || value === undefined || value === "") return null;
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function replayHwlabKafkaTraceThroughProductionHandoff(input: {
|
|
|
|
|
traceId: string;
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
query: Record<string, any>;
|
|
|
|
|
readKafka: (query: Record<string, any>) => Promise<Record<string, any>>;
|
|
|
|
|
}) {
|
|
|
|
|
return replayHwlabKafkaScopeThroughProductionHandoff(input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function replayHwlabKafkaScopeThroughProductionHandoff(input: {
|
|
|
|
|
traceId: string | null;
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
query: Record<string, any>;
|
|
|
|
|
readKafka: (query: Record<string, any>) => Promise<Record<string, any>>;
|
|
|
|
|
}) {
|
|
|
|
|
let readResult: Record<string, any> | null = null;
|
|
|
|
|
let summary: Record<string, any> | null = null;
|
|
|
|
@@ -1116,6 +1624,8 @@ function kafkaHelp() {
|
|
|
|
|
action: "kafka.help",
|
|
|
|
|
status: "succeeded",
|
|
|
|
|
commands: [
|
|
|
|
|
"hwlab-cli kafka inspect order --session-id ses_... [--agentrun-topic agentrun.event.v1] [--hwlab-topic hwlab.event.v1] --group-prefix hwlab-...-debug [--row-limit 80] [--json]",
|
|
|
|
|
"hwlab-cli kafka inspect order --contains-text <bounded-user-text> --group-prefix hwlab-...-debug [--json]",
|
|
|
|
|
"hwlab-cli kafka render trace --from kafka --trace-id trc_... [--session-id ses_...] [--run-id run_...] [--command-id cmd_...] [--input-topic hwlab.event.v1] [--group-prefix hwlab-...-debug] [--format markdown] [--row-limit 20] [--json]",
|
|
|
|
|
"hwlab-cli kafka render trace --from jsonl --trace-id trc_... [--run-id run_...] [--command-id cmd_...] --jsonl-file hwlab-events.jsonl [--format markdown] [--output-markdown trace.md] [--row-limit 20] [--json]",
|
|
|
|
|
"hwlab-cli kafka regenerate hwlab --from kafka --session-id ses_... [--trace-id trc_...] [--replay-id rpl_...] [--input-topic agentrun.event.v1] [--group-prefix hwlab-...-debug] [--expect-count 35] [--json]",
|
|
|
|
@@ -1439,6 +1949,7 @@ export function renderKafkaCliText(payload: Record<string, any>) {
|
|
|
|
|
return [
|
|
|
|
|
"ok",
|
|
|
|
|
`action=${payload.action}`,
|
|
|
|
|
payload.scope?.resolvedSessionId ? `session=${payload.scope.resolvedSessionId}` : null,
|
|
|
|
|
payload.input?.scannedCount !== undefined ? `scanned=${payload.input.scannedCount}` : null,
|
|
|
|
|
payload.input?.parsedCount !== undefined ? `parsed=${payload.input.parsedCount}` : null,
|
|
|
|
|
payload.input?.queryMatchedCount !== undefined ? `queryMatched=${payload.input.queryMatchedCount}` : null,
|
|
|
|
@@ -1458,6 +1969,8 @@ export function renderKafkaCliText(payload: Record<string, any>) {
|
|
|
|
|
payload.output ? `output=${payload.output.count}` : null,
|
|
|
|
|
payload.output ? `published=${payload.output.publishedCount}` : null,
|
|
|
|
|
validation.orderPreserved !== undefined ? `order=${validation.orderPreserved ? "preserved" : "failed"}` : null,
|
|
|
|
|
validation.noFirstDivergence !== undefined ? `order=${validation.noFirstDivergence ? "preserved" : "diverged"}` : null,
|
|
|
|
|
payload.order?.firstDivergence?.layer ? `firstDivergence=${JSON.stringify(payload.order.firstDivergence.layer)}` : 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,
|
|
|
|
|