From 0fa47e9295e4bb602a11d48df0f2dd451ece1773 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:59:43 +0200 Subject: [PATCH 1/3] fix: validate Kafka refresh delivery in web probe --- .agents/skills/unidesk-webdev/SKILL.md | 7 +- config/hwlab-node-lanes.yaml | 10 ++ .../R1.1_Task_Report.md | 7 + .../R1.2_Task_Report.md | 6 + .../R1.3_Task_Report.md | 7 + .../web-probe-kafka-refresh-delivery.md | 22 +++ scripts/src/hwlab-node-lanes.ts | 28 +++- ...web-observe-runner-realtime-source.test.ts | 158 +++++++++++++++++- ...node-web-observe-runner-realtime-source.ts | 133 ++++++++++++--- .../web-probe-observe-options.test.ts | 11 ++ 10 files changed, 360 insertions(+), 29 deletions(-) create mode 100644 docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.1_Task_Report.md create mode 100644 docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.2_Task_Report.md create mode 100644 docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md create mode 100644 docs/MDTODO/web-probe-kafka-refresh-delivery.md diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 50225cde..98e38116 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -39,7 +39,12 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible`、`reportFullscreenVisible`、报告 deep link 和截图 SHA。 - MDTODO → Workbench launch 验收必须引用 `launchWorkbenchFromMdtodo` 的 command result,确认文件/任务选择、Workbench session、launch/chat 状态和 OTel trace header;不要只凭页面最终停在 Workbench URL 判断通过。 - Workbench provider profile 验收使用 `observe command --type sendPrompt --provider ` 时,command result 必须显示 `requestedProvider`、`providerSelection.selected`、`/v1/agent/chat` 202、traceId 和最终 `turn-summary`;只有 prompt 成功不够,必须确认浏览器控件实际选中目标 profile。 -- Workbench 纯实时 fanout 的重复验收使用 `observe command --type validateRealtimeFanout --profile --provider --text --second-text `;它在 observer 后台维持两个独立 browser context 和三条 debug SSE,命令提交后按 `status --command-id ` 轮询,不得回退为长连接 `web-probe script`。 +- Workbench Kafka 实时 fanout 的重复验收: + - 使用 `observe command --type validateRealtimeFanout --profile --provider --text --second-text `; + - connected、refresh/reconnect 和 capability 期望来自 owning YAML,可独立组合,禁止在命令中硬编码 live-only 或 refresh mode; + - refresh 启用时必须证明 retention 到 live handoff、正式 `user` event、`hwlab.event.v1` 同 envelope,以及主页面单一 EventSource 和同一 UI reducer 终态; + - observer 后台维持两个独立 product subscriber 和三条 debug SSE; + - 命令提交后按 `status --command-id ` 轮询,不得回退为长连接 `web-probe script`。 - Workbench 隔离 Kafka debug 重放使用 `observe command --type validateWorkbenchKafkaDebugReplay`: - 复用 observer 当前 session 和 semantic origin; - 按 owning YAML 校验 topic、独立 group prefix、请求静默窗口和产品/调试 SSE 路径; diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 2ea379fc..737053f1 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -579,6 +579,14 @@ templates: navigationMaxAttempts: 4 navigationRetryDelayMs: 1000 reconnectQuietMs: 3000 + expectedProductSse: + deliverySemantics: kafka-retention-then-live + liveOnly: false + replay: true + replaySupported: true + lossPossible: false + refreshHandoff: true + replayPriorTraceOnReconnect: true forbiddenRequestPaths: - /v1/workbench/sync - /v1/agent/chat/result/ @@ -589,11 +597,13 @@ templates: - /finalizer requiredEventTypes: agentrun: + - user_message - tool_call - command_output - assistant_message - terminal_status hwlab: + - user - tool - status - assistant diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.1_Task_Report.md new file mode 100644 index 00000000..53973d09 --- /dev/null +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.1_Task_Report.md @@ -0,0 +1,7 @@ +# R1.1 任务报告 + +- 来源:https://github.com/pikasTech/unidesk/issues/1751 +- 已冻结 YAML-owned 可组合合同:`directPublish`、`liveKafkaSse`、`kafkaRefreshReplay` 独立校验。 +- refresh profile 要求 `workbench.connected` 披露 `kafka-retention-then-live`,并验证 barrier、retention 计数和 live handoff。 +- 验收禁止 HTTP history、cursor、projector、snapshot、polling、gap-fill 和 fallback。 +- 正式用户输入必须以稳定 `userMessageId` 一对一贯穿 AgentRun、HWLAB Kafka 和 product SSE。 diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.2_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.2_Task_Report.md new file mode 100644 index 00000000..15f66793 --- /dev/null +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.2_Task_Report.md @@ -0,0 +1,6 @@ +# R1.2 任务报告 + +- owning YAML 新增 `expectedProductSse`,connected/reconnect 期望不再由 runner 硬编码 live-only。 +- typed `validateRealtimeFanout` 同时支持 YAML 显式声明的 live-only 与 refresh+live 合同。 +- reconnect 时验证 retained trace 原 envelope 重放,随后第二轮增量继续从 live fanout 到达。 +- report 输出 bounded connected、handoff、正式 user event、request ledger 和同一 Workbench UI reducer 证据。 diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md new file mode 100644 index 00000000..849688d8 --- /dev/null +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md @@ -0,0 +1,7 @@ +# R1.3 任务报告 + +- `bun test` 定向执行 17 个测试,全部通过。 +- 覆盖 YAML refresh/live connected 合同、legacy live-only profile、handoff 计数一致性和正式 user event 三段 lineage。 +- 覆盖 semantic origin 继承、URL 选择歧义拒绝和既有 collect 行为。 +- `bun scripts/cli.ts check --syntax-only` 通过 11 项检查。 +- `git diff --check` 通过。 diff --git a/docs/MDTODO/web-probe-kafka-refresh-delivery.md b/docs/MDTODO/web-probe-kafka-refresh-delivery.md new file mode 100644 index 00000000..9b57fc3a --- /dev/null +++ b/docs/MDTODO/web-probe-kafka-refresh-delivery.md @@ -0,0 +1,22 @@ +# Web Probe Kafka 刷新重放验收 + +来源:https://github.com/pikasTech/unidesk/issues/1751 + +边界:只增强 YAML-first typed Web Probe;不部署、不合并,不引入临时脚本、URL/IP、HTTP history、projector 或 fallback。 + + +## R1 [in_progress] + +完成 [UniDesk #1751](https://github.com/pikasTech/unidesk/issues/1751) 的 YAML-first Kafka refresh→live typed Web Probe 验收,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1_Task_Report.md)。 +### R1.1 [completed] + +冻结 [#1751](https://github.com/pikasTech/unidesk/issues/1751) 可组合 connected、handoff、user event 与同源 reducer 合同,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.1_Task_Report.md)。 +### R1.2 [completed] + +实现 YAML profile 与 typed realtime validator,不保留 hardcoded live-only,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.2_Task_Report.md)。 +### R1.3 [completed] + +补齐 connected、retention→live、正式 user event、同 EventSource/envelope 的定向测试,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md)。 +### R1.4 [in_progress] + +完成帮助/skill、报告、提交、非 draft PR 与 preflight,保持不部署不合并,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md)。 diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 27edb30b..685a0516 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -192,6 +192,15 @@ export interface HwlabRuntimeWebProbeRealtimeFanoutProfileSpec { readonly navigationMaxAttempts: number; readonly navigationRetryDelayMs: number; readonly reconnectQuietMs: number; + readonly expectedProductSse: Readonly<{ + deliverySemantics: string; + liveOnly: boolean; + replay: boolean; + replaySupported: boolean; + lossPossible: boolean; + refreshHandoff: boolean; + replayPriorTraceOnReconnect: boolean; + }>; readonly forbiddenRequestPaths: readonly string[]; readonly requiredEventTypes: Readonly<{ agentrun: readonly string[]; @@ -1435,7 +1444,23 @@ function webProbeRealtimeFanoutProfileConfig(value: unknown, path: string): Hwla throw new Error(`${path}.debugStreams must contain stdio, agentrun, and hwlab exactly once`); } const fromBeginning = booleanField(raw, "fromBeginning", path); - if (fromBeginning !== false) throw new Error(`${path}.fromBeginning must be false for live-only validation`); + if (fromBeginning !== false) throw new Error(`${path}.fromBeginning must be false for turn-scoped debug streams`); + const expectedProductSseRaw = asRecord(raw.expectedProductSse, `${path}.expectedProductSse`); + const expectedProductSse = { + deliverySemantics: stringField(expectedProductSseRaw, "deliverySemantics", `${path}.expectedProductSse`), + liveOnly: booleanField(expectedProductSseRaw, "liveOnly", `${path}.expectedProductSse`), + replay: booleanField(expectedProductSseRaw, "replay", `${path}.expectedProductSse`), + replaySupported: booleanField(expectedProductSseRaw, "replaySupported", `${path}.expectedProductSse`), + lossPossible: booleanField(expectedProductSseRaw, "lossPossible", `${path}.expectedProductSse`), + refreshHandoff: booleanField(expectedProductSseRaw, "refreshHandoff", `${path}.expectedProductSse`), + replayPriorTraceOnReconnect: booleanField(expectedProductSseRaw, "replayPriorTraceOnReconnect", `${path}.expectedProductSse`), + }; + if (expectedProductSse.refreshHandoff && (!expectedProductSse.replay || !expectedProductSse.replaySupported || expectedProductSse.liveOnly)) { + throw new Error(`${path}.expectedProductSse refresh handoff requires replay=true, replaySupported=true, and liveOnly=false`); + } + if (expectedProductSse.replayPriorTraceOnReconnect && !expectedProductSse.replay) { + throw new Error(`${path}.expectedProductSse replayPriorTraceOnReconnect requires replay=true`); + } const requiredEventTypes = asRecord(raw.requiredEventTypes, `${path}.requiredEventTypes`); const forbiddenRequestPaths = nonEmptyStringArrayField(raw, "forbiddenRequestPaths", path); if (new Set(forbiddenRequestPaths).size !== forbiddenRequestPaths.length) throw new Error(`${path}.forbiddenRequestPaths must not contain duplicates`); @@ -1466,6 +1491,7 @@ function webProbeRealtimeFanoutProfileConfig(value: unknown, path: string): Hwla navigationMaxAttempts: boundedIntegerField(raw, "navigationMaxAttempts", path, 1, 10), navigationRetryDelayMs: boundedIntegerField(raw, "navigationRetryDelayMs", path, 100, 10_000), reconnectQuietMs: boundedIntegerField(raw, "reconnectQuietMs", path, 250, 30_000), + expectedProductSse, forbiddenRequestPaths, requiredEventTypes: { agentrun: agentrunEventTypes, diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts index a2c79b70..88203959 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts @@ -25,12 +25,21 @@ test("realtime fanout keeps the YAML terminal timeout when durationMs is absent" terminalTimeoutMs: 480_000, terminalQuietMs: 2_000, reconnectQuietMs: 3_000, + expectedProductSse: { + deliverySemantics: "kafka-retention-then-live", + liveOnly: false, + replay: true, + replaySupported: true, + lossPossible: false, + refreshHandoff: true, + replayPriorTraceOnReconnect: true, + }, forbiddenRequestPaths: [], requiredEventTypes: { agentrun: ["terminal_status"], hwlab: ["terminal"] }, expectedKafka: { topics: { stdio: "codex-stdio.raw.v1", agentrun: "agentrun.event.v1", hwlab: "hwlab.event.v1" }, groups: { directPublish: "direct", transactionalProjector: "projector", liveSse: "live" }, - capabilities: { directPublish: true, liveKafkaSse: true }, + capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true }, }, }; @@ -83,3 +92,150 @@ test("realtime fanout validates stdio against the scoped AgentRun session lineag assert.equal(expectedStreamSessionId("agentrun", hwlabSessionId), hwlabSessionId); assert.equal(expectedStreamSessionId("hwlab", hwlabSessionId), hwlabSessionId); }); + +test("realtime fanout validates YAML-owned refresh and legacy live-only connected contracts", () => { + const source = nodeWebObserveRunnerRealtimeSource(); + const build = new Function(`${source}\nreturn realtimeAssertConnectedContract;`) as () => ( + connected: Record, + sessionId: string, + label: string, + profile: Record, + options?: Record, + ) => void; + const assertConnected = build(); + const sessionId = "ses_refresh_contract"; + const expectedKafka = { + topics: { hwlab: "hwlab.event.v1" }, + capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true }, + }; + const refreshProfile = { + expectedKafka, + expectedProductSse: { + deliverySemantics: "kafka-retention-then-live", + liveOnly: false, + replay: true, + replaySupported: true, + lossPossible: false, + refreshHandoff: true, + replayPriorTraceOnReconnect: true, + }, + }; + const refreshConnected = { + realtimeSource: "hwlab.event.v1", + deliverySemantics: "kafka-retention-then-live", + liveOnly: false, + replay: true, + replaySupported: true, + lossPossible: false, + filters: { sessionId, traceId: null }, + capabilities: expectedKafka.capabilities, + refreshReplay: { + phase: "live", + topic: "hwlab.event.v1", + topicPartitions: [0], + barrier: [{ partition: 0, endOffset: "18" }], + counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 }, + }, + }; + + assert.doesNotThrow(() => assertConnected(refreshConnected, sessionId, "refresh", refreshProfile, { requireRetainedEvents: true })); + assert.throws( + () => assertConnected({ ...refreshConnected, deliverySemantics: "live-only" }, sessionId, "refresh", refreshProfile), + /differs from the owning YAML/u, + ); + assert.throws( + () => assertConnected({ ...refreshConnected, refreshReplay: { ...refreshConnected.refreshReplay, counts: { ...refreshConnected.refreshReplay.counts, replayed: 9 } } }, sessionId, "refresh", refreshProfile), + /replayed count exceeds matched count/u, + ); + + const liveProfile = { + expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: false } }, + expectedProductSse: { + deliverySemantics: "live-only", + liveOnly: true, + replay: false, + replaySupported: false, + lossPossible: true, + refreshHandoff: false, + replayPriorTraceOnReconnect: false, + }, + }; + assert.doesNotThrow(() => assertConnected({ + realtimeSource: "hwlab.event.v1", + deliverySemantics: "live-only", + liveOnly: true, + replay: false, + replaySupported: false, + lossPossible: true, + filters: { sessionId, traceId: null }, + capabilities: liveProfile.expectedKafka.capabilities, + }, sessionId, "live", liveProfile)); +}); + +test("realtime fanout proves one formal user event through AgentRun, HWLAB Kafka, and product SSE", () => { + const source = nodeWebObserveRunnerRealtimeSource(); + const build = new Function(`${source}\nreturn realtimeValidateTurnEvidence;`) as () => (input: Record) => void; + const validate = build(); + const traceId = "trc_user_passthrough"; + const sessionId = "ses_user_passthrough"; + const runId = "run_user_passthrough"; + const commandId = "cmd_user_passthrough"; + const userMessageId = "msg_user_passthrough"; + const base = { traceId, runId, commandId, terminal: false }; + const userHwlab = { + ...base, + partition: 0, + offset: "0", + hwlabSessionId: sessionId, + eventId: "hwlab:evt_user", + sourceEventId: "evt_user", + sourceSeq: 1, + eventType: "user", + userMessageId, + envelopeFingerprint: "fingerprint:user", + }; + const terminalHwlab = { + ...base, + partition: 0, + offset: "1", + hwlabSessionId: sessionId, + eventId: "hwlab:evt_terminal", + sourceEventId: "evt_terminal", + sourceSeq: 2, + eventType: "terminal", + userMessageId: null, + terminal: true, + envelopeFingerprint: "fingerprint:terminal", + }; + const debug = { + connected: { + stdio: { consumerReady: true, topic: "codex-stdio.raw.v1" }, + agentrun: { consumerReady: true, topic: "agentrun.event.v1" }, + hwlab: { consumerReady: true, topic: "hwlab.event.v1" }, + }, + openCount: { stdio: 1, agentrun: 1, hwlab: 1 }, + connectedEventCount: { stdio: 1, agentrun: 1, hwlab: 1 }, + errors: { stdio: [], agentrun: [], hwlab: [] }, + records: { + stdio: [{ ...base, partition: 0, offset: "0", frameSeq: 1, hwlabSessionId: "ses_agentrun_user_passthrough" }], + agentrun: [ + { ...base, partition: 0, offset: "0", hwlabSessionId: sessionId, eventId: "evt_user", eventType: "user_message", userMessageId }, + { ...base, partition: 0, offset: "1", hwlabSessionId: sessionId, eventId: "evt_terminal", eventType: "terminal_status", terminal: true }, + ], + hwlab: [userHwlab, terminalHwlab], + }, + }; + const product = { openCount: 1, connectedEventCount: 1, errors: [], records: [{ ...userHwlab }, { ...terminalHwlab }] }; + const profile = { + debugStreams: ["stdio", "agentrun", "hwlab"], + requiredEventTypes: { agentrun: ["user_message", "terminal_status"], hwlab: ["user", "terminal"] }, + expectedKafka: { topics: { stdio: "codex-stdio.raw.v1", agentrun: "agentrun.event.v1", hwlab: "hwlab.event.v1" } }, + }; + + assert.doesNotThrow(() => validate({ turn: 1, traceId, sessionId, runId, commandId, debug, terminalSnapshots: [product], preTerminalSnapshots: [], profile })); + const missingUserProduct = { ...product, records: [terminalHwlab] }; + assert.throws( + () => validate({ turn: 1, traceId, sessionId, runId, commandId, debug, terminalSnapshots: [missingUserProduct], preTerminalSnapshots: [], profile }), + /product subscriber lacks pre-terminal or terminal event|product SSE lacks the formal user event/u, + ); +}); diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts index 56f31e0e..ab6265aa 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts @@ -75,14 +75,13 @@ async function validateRealtimeFanout(command) { debug = await realtimeNewDebugContext(storageState, effective); const connectedA = await realtimeWaitProductConnected(subscriberA, effective.barrierTimeoutMs); const connectedB = await realtimeWaitProductConnected(subscriberB, effective.barrierTimeoutMs); - realtimeAssertLiveConnected(connectedA, sessionId, "subscriber-a", effective); - realtimeAssertLiveConnected(connectedB, sessionId, "subscriber-b", effective); + realtimeAssertConnectedContract(connectedA, sessionId, "subscriber-a", effective); + realtimeAssertConnectedContract(connectedB, sessionId, "subscriber-b", effective); await appendJsonl(files.control, eventRecord("realtime-fanout-product-ready", { profile: profileId, sessionId, subscriberCount: 2, - liveOnly: true, - replay: false, + expectedProductSse: effective.expectedProductSse, valuesRedacted: true, })); @@ -132,12 +131,14 @@ async function validateRealtimeFanout(command) { subscriberB2 = await realtimeNewSubscriber(storageState, sessionId, "subscriber-b-reconnected", effective); const bReconnected = await realtimeWaitProductConnected(subscriberB2, effective.barrierTimeoutMs); - realtimeAssertLiveConnected(bReconnected, sessionId, "subscriber-b-reconnected", effective); + realtimeAssertConnectedContract(bReconnected, sessionId, "subscriber-b-reconnected", effective, { requireRetainedEvents: effective.expectedProductSse.replayPriorTraceOnReconnect }); await sleep(effective.reconnectQuietMs); const reconnectBeforeSecond = await realtimeProductSnapshot(subscriberB2); realtimeAssertStableProductTransport(reconnectBeforeSecond, "subscriber-b-reconnected-before-second-turn"); - if (reconnectBeforeSecond.records.some((item) => item.traceId === first.traceId)) { - throw new Error("reconnected subscriber replayed first-turn events"); + if (effective.expectedProductSse.replayPriorTraceOnReconnect) { + realtimeAssertReplayedTrace(firstDebug, reconnectBeforeSecond, first.traceId, "subscriber-b-reconnected-before-second-turn"); + } else if (reconnectBeforeSecond.records.some((item) => item.traceId === first.traceId)) { + throw new Error("reconnected subscriber replayed first-turn events contrary to the owning YAML"); } const second = await realtimeRunTurn({ @@ -159,8 +160,10 @@ async function validateRealtimeFanout(command) { const [secondProductA, secondProductB] = secondStable.products; realtimeAssertStableProductTransport(secondProductA, "subscriber-a-second-terminal"); realtimeAssertStableProductTransport(secondProductB, "subscriber-b-reconnected-second-terminal"); - if (secondProductB.records.some((item) => item.traceId === first.traceId)) { - throw new Error("reconnected subscriber received first-turn events after the second submit"); + if (effective.expectedProductSse.replayPriorTraceOnReconnect) { + realtimeAssertReplayedTrace(firstDebug, secondProductB, first.traceId, "subscriber-b-reconnected-after-second-turn"); + } else if (secondProductB.records.some((item) => item.traceId === first.traceId)) { + throw new Error("reconnected subscriber received first-turn events contrary to the owning YAML"); } realtimeValidateTurnEvidence({ turn: 2, @@ -179,6 +182,9 @@ async function validateRealtimeFanout(command) { const productB2 = await realtimeProductSnapshot(subscriberB2); realtimeAssertStableProductTransport(productA, "subscriber-a-final"); realtimeAssertStableProductTransport(productB2, "subscriber-b-reconnected-final"); + if (effective.expectedProductSse.replayPriorTraceOnReconnect) { + realtimeAssertReplayedTrace(firstDebug, productB2, first.traceId, "subscriber-b-reconnected-final"); + } const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId); const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths)); if (mainStreams.length !== 1) throw new Error("main Workbench session SSE connection count is " + mainStreams.length + ", expected 1"); @@ -187,14 +193,14 @@ async function validateRealtimeFanout(command) { if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(",")); const summaryScreenshot = await realtimeCaptureSubscriberScreenshot(debug.page, reportDir, "realtime-fanout-summary.png", { - title: "Pure Kafka live fanout validated", + title: "Pure Kafka retention and live fanout validated", profile: profileId, sessionId, traceIds: [first.traceId, second.traceId], productSubscriberCount: 2, debugStreams: effective.debugStreams, forbiddenRequestCount: forbidden.length, - replayedEventCount: 0, + replayedEventCount: bReconnected?.refreshReplay?.counts?.replayed || 0, valuesRedacted: true, }); const subscriberAScreenshot = await realtimeCaptureSubscriberScreenshot(subscriberA.page, reportDir, "subscriber-a-final.png", { @@ -206,9 +212,9 @@ async function validateRealtimeFanout(command) { valuesRedacted: true, }); const subscriberB2Screenshot = await realtimeCaptureSubscriberScreenshot(subscriberB2.page, reportDir, "subscriber-b-reconnected-final.png", { - title: "Subscriber B reconnected live-only", + title: "Subscriber B reconnected through Kafka retention then live", sessionId, - traceIds: [second.traceId], + traceIds: effective.expectedProductSse.replayPriorTraceOnReconnect ? [first.traceId, second.traceId] : [second.traceId], eventCount: productB2.records.length, firstTraceReplayCount: productB2.records.filter((item) => item.traceId === first.traceId).length, valuesRedacted: true, @@ -229,11 +235,20 @@ async function validateRealtimeFanout(command) { lastEventIdCount: 0, subscriberAEventCount: productA.records.length, subscriberBReconnectEventCount: productB2.records.length, - subscriberBFirstTraceReplayCount: 0, + subscriberBFirstTraceReplayCount: productB2.records.filter((item) => item.traceId === first.traceId).length, valuesRedacted: true, }, - liveContract: { + productUiReducer: { + mainSessionEventSourceCount: mainStreams.length, + sessionScoped: mainStreams[0]?.traceId === null, + firstTraceTerminalRendered: true, + secondTraceTerminalRendered: true, + evidenceSource: "same-workbench-page-eventsource-and-ui-terminal", + valuesRedacted: true, + }, + realtimeContract: { expectedKafka: effective.expectedKafka, + expectedProductSse: effective.expectedProductSse, subscriberA: realtimeConnectedSummary(connectedA), subscriberBBeforeDisconnect: realtimeConnectedSummary(connectedB), subscriberBAfterReconnect: realtimeConnectedSummary(bReconnected), @@ -268,7 +283,7 @@ async function validateRealtimeFanout(command) { commandIds: turns.map((item) => item.commandId), warmRunnerReused: report.warmRunnerReused, forbiddenRequestCount: 0, - replayedEventCount: 0, + replayedEventCount: bReconnected?.refreshReplay?.counts?.replayed || 0, reportPath: reportArtifact.path, reportSha256: reportArtifact.sha256, screenshots: report.screenshots, @@ -319,11 +334,19 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) { throw new Error("realtime fanout profile debugStreams must be stdio, agentrun, and hwlab"); } if (profile.fromBeginning !== false) throw new Error("realtime fanout profile fromBeginning must be false"); + const expectedProductSse = profile.expectedProductSse && typeof profile.expectedProductSse === "object" ? profile.expectedProductSse : null; + if (!expectedProductSse || typeof expectedProductSse.deliverySemantics !== "string") throw new Error("realtime fanout profile lacks YAML-owned product SSE expectations"); const expectedKafka = profile.expectedKafka && typeof profile.expectedKafka === "object" ? profile.expectedKafka : null; if (!expectedKafka?.topics || !expectedKafka?.groups || !expectedKafka?.capabilities) throw new Error("realtime fanout profile lacks YAML-derived Kafka expectations"); if (expectedKafka.capabilities.directPublish !== true || expectedKafka.capabilities.liveKafkaSse !== true) { throw new Error("realtime fanout profile requires YAML-enabled directPublish and liveKafkaSse capabilities"); } + if (expectedProductSse.refreshHandoff !== (expectedKafka.capabilities.kafkaRefreshReplay === true)) { + throw new Error("realtime fanout product SSE refreshHandoff differs from the YAML Kafka capability"); + } + if (expectedProductSse.replayPriorTraceOnReconnect === true && expectedProductSse.replay !== true) { + throw new Error("realtime fanout replayPriorTraceOnReconnect requires replay=true"); + } const hasDurationOverride = durationMs !== null && durationMs !== undefined && durationMs !== "" && Number.isFinite(Number(durationMs)); const terminalTimeoutMs = hasDurationOverride ? Math.min(Number(profile.terminalTimeoutMs), Math.max(1000, Number(durationMs))) @@ -331,14 +354,15 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) { return { ...profile, debugStreams, - barrierTimeoutMs: boundedInteger(profile.barrierTimeoutMs, 45000, 1000, 120000), - terminalTimeoutMs: boundedInteger(terminalTimeoutMs, 480000, 1000, 600000), - terminalQuietMs: boundedInteger(profile.terminalQuietMs, 2000, 250, 10000), - navigationMaxAttempts: boundedInteger(profile.navigationMaxAttempts, 4, 1, 10), - navigationRetryDelayMs: boundedInteger(profile.navigationRetryDelayMs, 1000, 100, 10000), - reconnectQuietMs: boundedInteger(profile.reconnectQuietMs, 3000, 250, 30000), + barrierTimeoutMs: Number(profile.barrierTimeoutMs), + terminalTimeoutMs: Number(terminalTimeoutMs), + terminalQuietMs: Number(profile.terminalQuietMs), + navigationMaxAttempts: Number(profile.navigationMaxAttempts), + navigationRetryDelayMs: Number(profile.navigationRetryDelayMs), + reconnectQuietMs: Number(profile.reconnectQuietMs), forbiddenRequestPaths: Array.isArray(profile.forbiddenRequestPaths) ? profile.forbiddenRequestPaths.map(String) : [], requiredEventTypes: profile.requiredEventTypes && typeof profile.requiredEventTypes === "object" ? profile.requiredEventTypes : {}, + expectedProductSse: sanitize(expectedProductSse), expectedKafka: sanitize(expectedKafka), }; } @@ -383,6 +407,7 @@ async function realtimeNewSubscriber(storageState, sessionId, label, profile) { hwlabSessionId: firstText(value.hwlabSessionId, value.sessionId, nested.hwlabSessionId, nested.sessionId, context.sessionId), runId: firstText(value.runId, nested.runId, context.runId), commandId: firstText(value.commandId, nested.commandId, context.commandId), + userMessageId: firstText(value.userMessageId, nested.userMessageId, nested.messageId), terminal: value.terminal === true || nested.terminal === true || eventType === "terminal" || eventType === "terminal_status", status: firstText(nested.status, value.status), envelopeFingerprint: fingerprint(raw), @@ -480,7 +505,7 @@ async function realtimeRunTurn(input) { } for (const subscriber of input.subscribers) { const connected = await realtimeWaitProductConnected(subscriber, input.profile.barrierTimeoutMs); - realtimeAssertLiveConnected(connected, input.sessionId, subscriber.label, input.profile); + realtimeAssertConnectedContract(connected, input.sessionId, subscriber.label, input.profile); } await appendJsonl(files.control, eventRecord("realtime-fanout-ready-barrier", { turn, @@ -587,6 +612,7 @@ async function realtimeOpenDebugStreams(debug, key, traceId, profile) { hwlabSessionId: firstText(value.hwlabSessionId, value.sessionId, run.hwlabSessionId, run.sessionId, context.sessionId, payload.hwlabSessionId, payload.sessionId, nested.hwlabSessionId, nested.sessionId), runId: firstText(value.runId, run.runId, context.runId, nested.runId, payload.runId), commandId: firstText(value.commandId, command.commandId, context.commandId, payload.commandId, nested.commandId), + userMessageId: firstText(value.userMessageId, payload.userMessageId, payload.messageId, nested.userMessageId, nested.messageId), runnerId: firstText(value.runnerId, run.runnerId, command.runnerId, context.runnerId, payload.runnerId, nested.runnerId), attemptId: firstText(value.attemptId, run.attemptId, command.attemptId, context.attemptId, payload.attemptId, nested.attemptId), terminal: value.terminal === true || nested.terminal === true || eventType === "terminal" || eventType === "terminal_status", @@ -730,15 +756,47 @@ async function realtimeCloseDebugStreams(debug, key) { }, key); } -function realtimeAssertLiveConnected(connected, sessionId, label, profile) { +function realtimeAssertConnectedContract(connected, sessionId, label, profile, options = {}) { if (connected?.realtimeSource !== profile.expectedKafka.topics.hwlab) throw new Error(label + " realtime source mismatch"); - if (connected?.deliverySemantics !== "live-only" || connected?.liveOnly !== true) throw new Error(label + " is not live-only"); - if (connected?.replay !== false || connected?.replaySupported !== false || connected?.lossPossible !== true) throw new Error(label + " replay/loss disclosure mismatch"); + const expected = profile.expectedProductSse; + for (const field of ["deliverySemantics", "liveOnly", "replay", "replaySupported", "lossPossible"]) { + if (connected?.[field] !== expected[field]) throw new Error(label + " product SSE " + field + " differs from the owning YAML"); + } if (connected?.filters?.sessionId !== sessionId) throw new Error(label + " session filter mismatch"); + if (connected?.filters?.traceId !== null) throw new Error(label + " unexpectedly used a trace-scoped product SSE filter"); const capabilities = connected?.capabilities || {}; for (const [name, expected] of Object.entries(profile.expectedKafka.capabilities)) { if (capabilities[name] !== expected) throw new Error(label + " capability " + name + " differs from the owning YAML"); } + if (expected.refreshHandoff === true) realtimeAssertRefreshHandoff(connected, label, profile, options.requireRetainedEvents === true); + else if (connected?.refreshReplay !== undefined && connected?.refreshReplay !== null) throw new Error(label + " disclosed an unexpected refresh handoff"); +} + +function realtimeAssertRefreshHandoff(connected, label, profile, requireRetainedEvents) { + const handoff = connected?.refreshReplay; + if (!handoff || handoff.phase !== "live") throw new Error(label + " refresh handoff did not reach live phase"); + if (handoff.topic !== profile.expectedKafka.topics.hwlab) throw new Error(label + " refresh handoff topic mismatch"); + if (!Array.isArray(handoff.barrier) || handoff.barrier.length === 0) throw new Error(label + " refresh handoff barrier is missing"); + if (handoff.barrier.some((entry) => !Number.isInteger(entry?.partition) || typeof entry?.endOffset !== "string" || !entry.endOffset)) { + throw new Error(label + " refresh handoff barrier identity is invalid"); + } + if (!Array.isArray(handoff.topicPartitions) || handoff.topicPartitions.some((partition) => !Number.isInteger(partition))) { + throw new Error(label + " refresh handoff topicPartitions are invalid"); + } + const counts = handoff.counts; + for (const field of ["matched", "replayed", "buffered", "bufferedDelivered", "liveDelivered", "deduplicated"]) { + if (!Number.isInteger(counts?.[field]) || counts[field] < 0) throw new Error(label + " refresh handoff count " + field + " is invalid"); + } + if (counts.replayed > counts.matched) throw new Error(label + " refresh handoff replayed count exceeds matched count"); + if (counts.bufferedDelivered > counts.buffered) throw new Error(label + " refresh handoff delivered more buffered events than observed"); + if (requireRetainedEvents && counts.replayed < 1) throw new Error(label + " refresh handoff replayed no retained events"); +} + +function realtimeAssertReplayedTrace(debug, product, traceId, label) { + const kafkaRows = (debug.records?.hwlab || []).filter((item) => item.traceId === traceId); + const productRows = (product.records || []).filter((item) => item.traceId === traceId); + if (!productRows.some((item) => item.terminal === true)) throw new Error(label + " lacks the retained terminal event"); + realtimeAssertEnvelopePassthrough(kafkaRows, productRows, true, label + " retained trace"); } function realtimeConnectedSummary(connected) { @@ -751,6 +809,7 @@ function realtimeConnectedSummary(connected) { lossPossible: connected?.lossPossible === true, filters: { sessionId: connected?.filters?.sessionId || null, traceId: connected?.filters?.traceId || null }, capabilities: sanitize(connected?.capabilities || {}), + refreshReplay: connected?.refreshReplay ? sanitize(connected.refreshReplay) : null, valuesRedacted: true, }; } @@ -802,10 +861,19 @@ function realtimeValidateTurnEvidence(input) { const commandIds = new Set(identityRows.map((item) => item.commandId).filter(Boolean)); if (!input.commandId || commandIds.size !== 1 || !commandIds.has(input.commandId)) throw new Error("turn " + input.turn + " commandId missing or mismatched across command-bound events"); const hwlabRows = input.debug.records.hwlab.filter((item) => item.traceId === input.traceId); + const agentrunUserRows = input.debug.records.agentrun.filter((item) => item.traceId === input.traceId && item.eventType === "user_message"); + const hwlabUserRows = hwlabRows.filter((item) => item.eventType === "user"); + if (agentrunUserRows.length !== 1 || hwlabUserRows.length !== 1) throw new Error("turn " + input.turn + " formal user event is not one-to-one across AgentRun and HWLAB"); + const userMessageIds = new Set([...agentrunUserRows, ...hwlabUserRows].map((item) => item.userMessageId).filter(Boolean)); + if (userMessageIds.size !== 1) throw new Error("turn " + input.turn + " formal user event lacks one stable userMessageId"); for (const snapshot of input.terminalSnapshots) { const rows = snapshot.records.filter((item) => item.traceId === input.traceId); if (!rows.some((item) => item.terminal !== true) || !rows.some((item) => item.terminal === true)) throw new Error("turn " + input.turn + " product subscriber lacks pre-terminal or terminal event"); realtimeAssertEnvelopePassthrough(hwlabRows, rows, true, "turn " + input.turn + " terminal subscriber"); + const productUserRows = rows.filter((item) => item.eventType === "user"); + if (productUserRows.length !== 1 || productUserRows[0].userMessageId !== hwlabUserRows[0].userMessageId) { + throw new Error("turn " + input.turn + " product SSE lacks the formal user event"); + } } for (const snapshot of input.preTerminalSnapshots) { const rows = snapshot.records.filter((item) => item.traceId === input.traceId); @@ -833,6 +901,7 @@ function realtimeAssertEnvelopePassthrough(kafkaRows, productRows, requireAllKaf && left.hwlabSessionId === right.hwlabSessionId && left.runId === right.runId && left.commandId === right.commandId + && left.userMessageId === right.userMessageId && left.terminal === right.terminal; for (const productRow of productRows) { if (!kafkaRows.some((kafkaRow) => sameEnvelope(kafkaRow, productRow))) throw new Error(label + " received a product SSE envelope absent from hwlab.event.v1"); @@ -890,6 +959,9 @@ function realtimeTurnSummary(turn, debug, product, terminal) { }; } const productRows = product.records.filter((item) => item.traceId === turn.traceId); + const agentrunUser = debug.records.agentrun.find((item) => item.traceId === turn.traceId && item.eventType === "user_message") || null; + const hwlabUser = debug.records.hwlab.find((item) => item.traceId === turn.traceId && item.eventType === "user") || null; + const productUser = productRows.find((item) => item.eventType === "user") || null; return { turn: turn.turn, traceId: turn.traceId, @@ -901,6 +973,15 @@ function realtimeTurnSummary(turn, debug, product, terminal) { admissionStatus: turn.admissionStatus, terminalStatus: terminal?.status || null, streams, + formalUserEvent: { + userMessageId: hwlabUser?.userMessageId || agentrunUser?.userMessageId || null, + agentrunEventId: agentrunUser?.eventId || null, + hwlabEventId: hwlabUser?.eventId || null, + hwlabSourceEventId: hwlabUser?.sourceEventId || null, + kafkaEnvelopeFingerprint: hwlabUser?.envelopeFingerprint || null, + productEnvelopeFingerprint: productUser?.envelopeFingerprint || null, + valuesRedacted: true, + }, product: { count: productRows.length, openCount: product.openCount, connectedEventCount: product.connectedEventCount, errorCount: product.errors.length, preTerminalSeen: productRows.some((item) => item.terminal !== true), terminalSeen: productRows.some((item) => item.terminal === true), valuesRedacted: true }, valuesRedacted: true, }; diff --git a/scripts/src/hwlab-node/web-probe-observe-options.test.ts b/scripts/src/hwlab-node/web-probe-observe-options.test.ts index 7b475375..a3864091 100644 --- a/scripts/src/hwlab-node/web-probe-observe-options.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-options.test.ts @@ -32,6 +32,17 @@ test("validateRealtimeFanout parses the YAML profile and two independent prompts assert.equal(options.commandSecondText, "second turn"); assert.deepEqual(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.debugStreams, ["stdio", "agentrun", "hwlab"]); assert.equal(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.fromBeginning, false); + assert.deepEqual(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.expectedProductSse, { + deliverySemantics: "kafka-retention-then-live", + liveOnly: false, + replay: true, + replaySupported: true, + lossPossible: false, + refreshHandoff: true, + replayPriorTraceOnReconnect: true, + }); + assert.ok(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.requiredEventTypes.agentrun.includes("user_message")); + assert.ok(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.requiredEventTypes.hwlab.includes("user")); }); test("validateRealtimeFanout refuses body as a substitute for the second prompt", () => { From 74ac2aca4f1a2601c315927472aa828ddc1545d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 07:01:44 +0200 Subject: [PATCH 2/3] docs: close Kafka refresh web probe task --- .../R1.4_Task_Report.md | 7 +++++++ .../web-probe-kafka-refresh-delivery/R1_Task_Report.md | 10 ++++++++++ docs/MDTODO/web-probe-kafka-refresh-delivery.md | 4 ++-- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md create mode 100644 docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md new file mode 100644 index 00000000..5bef4a18 --- /dev/null +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md @@ -0,0 +1,7 @@ +# R1.4 任务报告 + +- 更新 `$unidesk-webdev`,明确该 typed command 由 YAML 组合 connected、refresh 和 capability 合同。 +- 提交 `0fa47e9295e4bb602a11d48df0f2dd451ece1773` 已推送到 `fix/web-probe-kafka-refresh-delivery`。 +- 非 draft PR:https://github.com/pikasTech/unidesk/pull/1752 +- 受控 `gh pr preflight` 返回 `MERGEABLE/CLEAN`,无代码侧 blocker 或 pending check。 +- 未部署、未合并。 diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md new file mode 100644 index 00000000..f2101caf --- /dev/null +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md @@ -0,0 +1,10 @@ +# R1 任务报告 + +- 来源:https://github.com/pikasTech/unidesk/issues/1751 +- 交付 PR:https://github.com/pikasTech/unidesk/pull/1752 +- `validateRealtimeFanout` 已从 owning YAML 读取可组合 product SSE 合同,同时支持显式 live-only 与 refresh+live profile。 +- refresh 验收覆盖 connected、barrier、retention→live handoff、重连 retained trace、第二轮 live 增量和正式 user event 三段 lineage。 +- product evidence 同时证明 `hwlab.event.v1` envelope 原样透传,以及主 Workbench 单一 session-scoped EventSource 进入同一 UI reducer 并渲染终态。 +- 定向测试 17/17、syntax-only 11/11、PR preflight `MERGEABLE/CLEAN`。 +- 未引入 HTTP history、projector、snapshot、polling、gap-fill、fallback、临时 script 或 URL/IP 运行面选择。 +- 按任务边界未部署、未合并。 diff --git a/docs/MDTODO/web-probe-kafka-refresh-delivery.md b/docs/MDTODO/web-probe-kafka-refresh-delivery.md index 9b57fc3a..9a6410af 100644 --- a/docs/MDTODO/web-probe-kafka-refresh-delivery.md +++ b/docs/MDTODO/web-probe-kafka-refresh-delivery.md @@ -5,7 +5,7 @@ 边界:只增强 YAML-first typed Web Probe;不部署、不合并,不引入临时脚本、URL/IP、HTTP history、projector 或 fallback。 -## R1 [in_progress] +## R1 [completed] 完成 [UniDesk #1751](https://github.com/pikasTech/unidesk/issues/1751) 的 YAML-first Kafka refresh→live typed Web Probe 验收,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1_Task_Report.md)。 ### R1.1 [completed] @@ -17,6 +17,6 @@ ### R1.3 [completed] 补齐 connected、retention→live、正式 user event、同 EventSource/envelope 的定向测试,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md)。 -### R1.4 [in_progress] +### R1.4 [completed] 完成帮助/skill、报告、提交、非 draft PR 与 preflight,保持不部署不合并,完成任务后将详细报告写入[任务报告](./details/web-probe-kafka-refresh-delivery/R1.4_Task_Report.md)。 From 46bdccd33ac9351be58a48432ffe9d74e2356c28 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 07:12:14 +0200 Subject: [PATCH 3/3] docs: clarify web probe test counts --- .../web-probe-kafka-refresh-delivery/R1.3_Task_Report.md | 7 +++++-- .../web-probe-kafka-refresh-delivery/R1_Task_Report.md | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md index 849688d8..c79ff05c 100644 --- a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1.3_Task_Report.md @@ -1,7 +1,10 @@ # R1.3 任务报告 -- `bun test` 定向执行 17 个测试,全部通过。 +- 精确 Bun 测试命令: + - `bun test scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts scripts/src/hwlab-node/web-probe-observe-options.test.ts scripts/src/hwlab-node/web-probe-observe-collect.test.ts` + - 三个文件合计 17/17 通过:realtime 5、options 8、collect 4。 + - 若只执行 realtime 与 options 两个核心定向文件,精确计数为 13/13。 - 覆盖 YAML refresh/live connected 合同、legacy live-only profile、handoff 计数一致性和正式 user event 三段 lineage。 - 覆盖 semantic origin 继承、URL 选择歧义拒绝和既有 collect 行为。 -- `bun scripts/cli.ts check --syntax-only` 通过 11 项检查。 +- 独立命令 `bun scripts/cli.ts check --syntax-only` 通过 11/11;该计数未并入 Bun test 的 17 个测试。 - `git diff --check` 通过。 diff --git a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md index f2101caf..432e638d 100644 --- a/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md +++ b/docs/MDTODO/details/web-probe-kafka-refresh-delivery/R1_Task_Report.md @@ -5,6 +5,8 @@ - `validateRealtimeFanout` 已从 owning YAML 读取可组合 product SSE 合同,同时支持显式 live-only 与 refresh+live profile。 - refresh 验收覆盖 connected、barrier、retention→live handoff、重连 retained trace、第二轮 live 增量和正式 user event 三段 lineage。 - product evidence 同时证明 `hwlab.event.v1` envelope 原样透传,以及主 Workbench 单一 session-scoped EventSource 进入同一 UI reducer 并渲染终态。 -- 定向测试 17/17、syntax-only 11/11、PR preflight `MERGEABLE/CLEAN`。 +- 三文件 Bun 定向测试 17/17:realtime 5、options 8、collect 4;其中 realtime 与 options 两文件为 13/13。 +- 独立 syntax-only 检查 11/11;该计数未与 Bun test 合并。 +- PR preflight 为 `MERGEABLE/CLEAN`。 - 未引入 HTTP history、projector、snapshot、polling、gap-fill、fallback、临时 script 或 URL/IP 运行面选择。 - 按任务边界未部署、未合并。