diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index bbac766a..d1eb8f2f 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -206,6 +206,11 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - `tool_call`/`command_output` 与 HWLAB `tool`/`status` 是条件事件对,仅在实际出现时校验数量和 lineage; - 直接答复不得为了通过探针而合成工具事件,超时证据必须披露已经观测和仍然缺失的事件族; - refresh 启用时必须证明 retention 到 live handoff、正式 `user` event、`hwlab.event.v1` 同 envelope,以及主页面单一 EventSource 和同一 UI reducer 终态; + - session 切换或重连期间的 replay 可见性以 `docs/reference/hwlab.md#纯-kafka-页面刷新与实时交接合同` 为唯一权威: + - connected 前允许后台追赶,但页面必须保留切走前的稳定投影,不得显示历史前缀导致最近更新时间倒退; + - handoff 完成后只把待处理 frame 交给原 reducer 一次发布,失败或 scope 替换时丢弃未完成事务; + - L1 先用 `validateExistingSessionRefresh` 证明单 EventSource、retention→live 和禁止恢复请求,再对真实运行中 session 用 `switchAwayAndBack --session-id ` 验证用户操作路径; + - 在统一 typed command 尚未提供切换期间的时间标签采样前,L0 可见性测试是“不回退”的主证据,L1 两条 typed command 只补真实入口和 transport 合同,禁止把最终截图冒充过程证据; - 已有 session 的 rail、turn card、Final Response 或回放提示不一致时: - 先执行一次 `workbench events inspect --over-api` 证明服务端 connected、核心事件族和 terminal; - 服务端完整后只执行一次 `validateExistingSessionRefresh` 核对 DOM identity、live handoff 与 forbidden request; diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index 5c840e72..bba0a18a 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -478,6 +478,12 @@ HWLAB v0.3 订阅 AgentRun Kafka event 的权威输入是 `agentrun.event.v1`, - 到达 barrier 后必须退出,不得把每个浏览器连接变成新的长期 Kafka consumer; - 实时阶段继续复用进程级 shared consumer/fanout; - 同一 Workbench session 只建立一个产品 EventSource,刷新与重连不得复制业务 subscriber。 +- retention replay 的页面可见性采用单一事务边界: + - 返回已有 session 或 SSE 重连时,connected 前的 Kafka business frame 可以在后台追赶,但不得逐帧发布历史前缀到当前可见 session bucket; + - 追赶期间必须保留切走前的稳定消息、Trace、terminal 和最近更新时间,错误与基础设施诊断仍应立即可见; + - 只有同一 session scope 的 `workbench.connected` 证明 retention 到 live handoff 完成后,才能在同一浏览器任务内把待处理 frame 交给原生产 reducer 并一次发布最终投影; + - scope 被替换、连接失败或 handoff 合同无效时必须丢弃未完成事务,禁止提交部分历史前缀; + - 该事务只控制同一 Kafka+SSE reducer 的可见时机,不得演变为 snapshot、HTTP history、时间戳仲裁、第二 reducer 或第二状态权威。 - 单一路径禁止用状态优先级伪装: - “陈旧快照不得覆盖 sealed final”、snapshot freshness、final authority priority 等 guard 都意味着多条业务写入路径仍在竞争; - 应删除 HTTP session/message/turn/result、submit response 和本地缓存对业务状态的写入; @@ -490,6 +496,7 @@ HWLAB v0.3 订阅 AgentRun Kafka event 的权威输入是 `agentrun.event.v1`, - 关闭此类问题时先用 repo-owned CLI 复用生产 bootstrap/codec/reducer,披露 scanned、matched、applied、barrier、buffered、deduplicated、terminal 与 stable identity: - CLI 通过后,使用 semantic internal WebProbe 在同一已有 session 上执行只刷新、不提交新输入的验收; - 刷新前后 user/agent identity 和可见顺序必须一致,retention 与产品 SSE envelope 多重集必须精确相等; + - 涉及 session 切换时,必须在真实运行中 session 上执行切走再切回,并证明 handoff 前可见最近更新时间不回退、handoff 后继续接收 live event; - OTel 应只出现 direct publish、shared fanout receive 和 product SSE write,projector/projection write span 必须为 0。 纯 Kafka 调试分成三个可复用入口,不以临时脚本作为长期路径。AgentRun 使用 `./scripts/agentrun kafka regenerate agentrun --session-id --trace-id `,从 stdio Kafka/JSONL 调用生产 reducer,只输出 `agentrun.event.debug.v1` 并明示 partial reconstruction;HWLAB 使用 `hwlab-cli kafka regenerate hwlab --from kafka|jsonl --session-id --trace-id ` 调用生产 mapper,只输出 `hwlab.event.debug.v1`。原 Workbench 页面的管理员 YAML 调试开关只在显式点击后按当前 traceId 建立唯一 debug group,清空隔离面板并复用生产 reducer/card;正式浏览器验收使用 `web-probe observe command --type validateWorkbenchKafkaDebugReplay`。typed command 继承 observer 的 YAML `internal|public` origin,不接受 URL/IP 来选择内外网。 diff --git a/scripts/src/hwlab-native-development.test.ts b/scripts/src/hwlab-native-development.test.ts index f19e5f53..3314038e 100644 --- a/scripts/src/hwlab-native-development.test.ts +++ b/scripts/src/hwlab-native-development.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { hwlabHwpodNativeDevelopmentEnvironment, hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp, hwlabNativeDevelopmentSourceCommitRef } from "./hwlab-native-development"; +import { hwlabHwpodNativeDevelopmentEnvironment, hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp, hwlabNativeDevelopmentSourceCommitRef, parseHwlabNativeCliPayload } from "./hwlab-native-development"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; import { resolveConfigRef } from "./ops/config-refs"; @@ -62,6 +62,32 @@ test("L1 Workbench runner source follows the YAML-declared remote branch", () => expect(hwlabNativeDevelopmentSourceCommitRef(spec)).toBe("refs/remotes/origin/v0.3"); }); +test("L1 native CLI preserves typed evidence for empty and incomplete child JSON", () => { + expect(parseHwlabNativeCliPayload(commandResult(""))).toMatchObject({ + ok: false, + error: { code: "hwlab_native_cli_output_empty", stdoutKind: "empty", stdoutBytes: 0 }, + }); + expect(parseHwlabNativeCliPayload(commandResult('{"ok":true'))).toMatchObject({ + ok: false, + error: { code: "hwlab_native_cli_output_invalid", stdoutKind: "non-json" }, + }); + expect(parseHwlabNativeCliPayload(commandResult("", { timedOut: true, exitCode: null, stderr: "timed out" }))).toMatchObject({ + ok: false, + error: { code: "hwlab_native_cli_timeout", timedOut: true, stderrTail: "timed out" }, + }); + expect(parseHwlabNativeCliPayload(commandResult('{"ok":true,"status":"passed"}'))).toEqual({ ok: true, status: "passed" }); +}); + +function commandResult(stdout: string, overrides: Partial<{ stderr: string; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean }> = {}) { + return { + stdout, + stderr: overrides.stderr ?? "", + exitCode: overrides.exitCode ?? 0, + signal: overrides.signal ?? null, + timedOut: overrides.timedOut ?? false, + }; +} + test("L1 HWPOD spec registry resolves PostgreSQL authority and frozen built-ins from owning YAML", () => { const native = hwlabRuntimeLaneSpecForNode("v03", "NC01").nativeDevelopment?.hwpod; expect(native).toBeDefined(); diff --git a/scripts/src/hwlab-native-development.ts b/scripts/src/hwlab-native-development.ts index 905c5aa5..d010d77c 100644 --- a/scripts/src/hwlab-native-development.ts +++ b/scripts/src/hwlab-native-development.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; -import { runCommand } from "./command"; +import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery"; +import { runCommand, type CommandResult } from "./command"; import { resolveConfigRef } from "./ops/config-refs"; import { readEnvSourceFile, requiredEnvValue } from "./secrets"; import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeNativeDevelopmentSpec } from "./hwlab-node-lanes"; @@ -49,15 +50,17 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record 0) throw new Error(`native-development workbench status does not accept positional arguments: ${remaining.join(" ")}`); const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "workbench", "service", "status"]; const result = runCommand(command, spec.workspace, { timeoutMs: 15_000, env: { ...process.env, ...env } }); - const payload = parsePayload(result.stdout); + const payload = parsePayload(result, "hwlab-workbench-service-status-json"); return { ok: result.exitCode === 0 && payload.ok !== false, command: "hwlab nodes native-development workbench status", @@ -102,7 +107,7 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record 0) throw new Error(`native-development hwpod status does not accept positional arguments: ${remaining.join(" ")}`); const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "hwpod", "service", "status"]; const result = runCommand(command, spec.workspace, { timeoutMs: 30_000, env: { ...process.env, ...env } }); - const payload = parsePayload(result.stdout); + const payload = parsePayload(result, "hwlab-hwpod-service-status-json"); return { ok: result.exitCode === 0 && payload.ok !== false, command: "hwlab nodes native-development hwpod status", @@ -119,7 +124,7 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record { - const parsed = JSON.parse(stdout) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("HWLAB Workbench CLI returned invalid JSON"); - return parsed as Record; +export function parseHwlabNativeCliPayload( + result: Pick, + requestedStdoutType = "hwlab-native-cli-json", +): Record { + const resolution = resolveCliChildJsonCommandResult({ result, requestedStdoutType }); + if (resolution.parsed !== null) return resolution.parsed; + const diagnostics = resolution.diagnostics; + const stdoutKind = String(diagnostics.stdoutKind ?? "non-json"); + const code = result.timedOut + ? "hwlab_native_cli_timeout" + : stdoutKind === "empty" + ? "hwlab_native_cli_output_empty" + : "hwlab_native_cli_output_invalid"; + return { + ok: false, + status: "failed", + error: { + code, + message: result.timedOut + ? "HWLAB native CLI timed out before returning a complete JSON result" + : stdoutKind === "empty" + ? "HWLAB native CLI returned no JSON result" + : "HWLAB native CLI returned an incomplete or invalid JSON result", + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + stdoutKind, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + stderrTail: result.stderr.trim().slice(-2000), + parser: diagnostics, + valuesRedacted: true, + }, + valuesRedacted: true, + }; +} + +function parsePayload(result: CommandResult, requestedStdoutType: string): Record { + return parseHwlabNativeCliPayload(result, requestedStdoutType); +} + +function nativeCliFailure(result: CommandResult, payload: Record): Record { + const payloadError = payload.error; + if (payloadError !== null && typeof payloadError === "object" && !Array.isArray(payloadError)) return payloadError as Record; + return { + code: result.timedOut ? "hwlab_native_cli_timeout" : "hwlab_native_cli_failed", + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + stderrTail: result.stderr.trim().slice(-2000), + valuesRedacted: true, + }; } function parseApplication(value: string | undefined): Application {