diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index a839eb16..b4ed7ddb 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -32,10 +32,12 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - Workbench 三态/卡死离线调查优先用 `observe collect --view workbench-triad --trace-id `,它把 DOM turn/message/trace rows、Final Response、Workbench REST terminal evidence、browser freeze blocker 和 analyzer findings 聚合到一个有界视图;只有需要样本细节时再用 `--finding` 或 `trace-frame` 下钻。 - `observe collect --view turn-summary --command-id ` 必须按 commandId 精确返回单轮;不存在时保持结构化 `not-found`,禁止回退全量轮次。输出中 observer control 完成、turn submission 与异步 turn terminal 是三个独立证据字段,control completed 不等于业务 turn terminal。 - `workbench-triad` 的本地 `network.jsonl` 没有匹配响应时,结论必须写作 `artifact evidence missing`,并明确这不能证明业务 API 未发生;将 control artifact 中的 OTel trace reference 作为独立证据引用,再走 `$unidesk-otel` 受控诊断核验完整业务链路。 +- `web-probe script` 结果首行固定返回 `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT `,同时在结构化结果首字段返回 `commandPromotionHint`;一次性脚本在重复前必须沉淀为 repo-owned typed command。内置 generated command(例如 `web-probe opencode-smoke`)会明确返回可直接复用且不附加临时脚本 warning,禁止复制其生成脚本另起一次性入口。 - Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary`;`performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。 - 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`。 - `observe analyze` 的 archive/history findings 可能包含旧样本或旧规则误判;关闭用户入口问题前同时核对最新 sample 字段、DOM 几何和截图。若 top-level findings、latest sample 和 archive red 冲突,说明冲突并补工具反馈 issue,不要只凭 archive red 或单张截图下结论。 ## 高频工作流 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index f8e0ba62..cd5e2a0b 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -208,6 +208,7 @@ lanes: projectionRealtime: false configRef: config/platform-infra/kafka.yaml#clients.hwlab-v03-cloud-api bootstrapServers: platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092 + stdioTopic: codex-stdio.raw.v1 agentRunEventTopic: agentrun.event.v1 hwlabEventTopic: hwlab.event.v1 clientId: hwlab-v03-cloud-api @@ -540,6 +541,42 @@ templates: requestTimeoutMs: 30000 initialDelayMs: 500 maxDelayMs: 10000 + realtimeFanoutProfiles: + pure-kafka-live: + subscriberCount: 2 + debugStreams: + - stdio + - agentrun + - hwlab + productEventsPath: /v1/workbench/events + debugEventsPath: /v1/workbench/debug/kafka-sse/events + productReadyEvent: workbench.connected + debugReadyEvent: hwlab.kafka.connected + businessEvent: hwlab.event.v1 + fromBeginning: false + barrierTimeoutMs: 45000 + terminalTimeoutMs: 480000 + terminalQuietMs: 2000 + reconnectQuietMs: 3000 + forbiddenRequestPaths: + - /v1/workbench/sync + - /v1/agent/chat/result/ + - /v1/agent/chat/inspect + - /snapshot + - /replay + - /gap-fill + - /finalizer + requiredEventTypes: + agentrun: + - tool_call + - command_output + - assistant_message + - terminal_status + hwlab: + - tool + - status + - assistant + - terminal alertThresholds: sameOriginApiSlowMs: 10000 partialApiSlowMs: 10000 diff --git a/scripts/src/check.ts b/scripts/src/check.ts index b0cffc65..b2c56ff4 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -63,6 +63,7 @@ export interface CheckOptions { recoveryGuardrails: boolean; rust: boolean; scriptsTypecheckTimeoutMs: number; + scriptsTypecheckPath: string | null; checkHeartbeatMs: number; } @@ -79,6 +80,7 @@ const defaultCheckOptions: CheckOptions = { recoveryGuardrails: false, rust: false, scriptsTypecheckTimeoutMs: defaultScriptsTypecheckTimeoutMs, + scriptsTypecheckPath: null, checkHeartbeatMs: defaultCheckHeartbeatMs, }; @@ -95,7 +97,7 @@ export function checkHelp(): Record { ok: true, command: "check", usage: [ - "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust]", + "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-path PREFIX|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust]", "bun scripts/cli.ts check recovery-guardrails", ], defaultMode: "syntax/config only; Rust is never compiled on the master server by default", @@ -104,6 +106,7 @@ export function checkHelp(): Record { { name: "--full", description: "Enable all non-Rust checks." }, { name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." }, { name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck through the observed checker." }, + { name: "--scripts-typecheck-path PREFIX", description: "Keep the full typecheck gate, but return bounded primary diagnostics only for a selected path prefix." }, { name: "--scripts-typecheck-timeout-ms N", description: `Bound scripts TypeScript typecheck duration; default ${defaultScriptsTypecheckTimeoutMs}.` }, { name: "--check-heartbeat-ms N", description: `Emit unidesk.check.progress JSON lines for running command checks; default ${defaultCheckHeartbeatMs}.` }, { name: "--components", description: "Run component TypeScript typecheck." }, @@ -139,6 +142,12 @@ export function parseCheckOptions(args: string[]): CheckOptions { } else if (arg === "--scripts-typecheck-timeout-ms") { options.scriptsTypecheckTimeoutMs = positiveIntegerOption(arg, args[index + 1], 600_000); index += 1; + } else if (arg === "--scripts-typecheck-path") { + const value = args[index + 1]; + if (!value || value.includes("\0") || value.length > 300) throw new Error(`${arg} requires a 1-300 character non-NUL prefix`); + options.scriptsTypecheck = true; + options.scriptsTypecheckPath = value; + index += 1; } else if (arg === "--check-heartbeat-ms") { options.checkHeartbeatMs = positiveIntegerOption(arg, args[index + 1], 60_000); index += 1; @@ -180,7 +189,7 @@ function emitCheckProgress(detail: Record): void { console.error(JSON.stringify({ event: "unidesk.check.progress", ...detail })); } -async function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env, heartbeatMs = defaultCheckHeartbeatMs): Promise { +async function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env, heartbeatMs = defaultCheckHeartbeatMs, diagnosticPath: string | null = null): Promise { const startedAt = Date.now(); emitCheckProgress({ phase: "started", name, command, timeoutMs, heartbeatMs }); const result = await runCommandObserved(command, repoRoot, { @@ -213,12 +222,48 @@ async function commandItem(name: string, command: string[], timeoutMs = 30_000, stderrBytes: result.stderrBytes, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, - stdoutTail: result.stdout.slice(-4000), + ...(name === "typescript:scripts" ? { diagnostics: typescriptDiagnosticSummary(result.stdout, diagnosticPath) } : {}), + stdoutTail: name === "typescript:scripts" ? "" : result.stdout.slice(-4000), stderrTail: result.stderr.slice(-4000), }, }; } +function typescriptDiagnosticSummary(stdout: string, pathPrefix: string | null): Record { + const rows = stdout.split(/\r?\n/u).flatMap((line) => { + const match = line.match(/^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/u); + if (match === null) return []; + return [{ + path: match[1], + line: Number(match[2]), + column: Number(match[3]), + code: match[4], + message: (match[5] ?? "").slice(0, 240), + }]; + }); + const byPath = new Map(); + for (const row of rows) { + byPath.set(row.path, (byPath.get(row.path) ?? 0) + 1); + } + const selected = pathPrefix === null ? rows : rows.filter((row) => row.path.startsWith(pathPrefix)); + const selectedPathCounts = [...byPath.entries()].filter(([path]) => pathPrefix === null || path.startsWith(pathPrefix)); + const itemLimit = pathPrefix === null ? 5 : 8; + const pathLimit = pathPrefix === null ? 20 : 12; + return { + count: rows.length, + pathCount: byPath.size, + pathPrefix, + matchedCount: selected.length, + paths: selectedPathCounts.slice(0, pathLimit).map(([path, count]) => ({ path, count })), + items: selected.slice(0, itemLimit), + truncated: selected.length > itemLimit || selectedPathCounts.length > pathLimit, + next: selected.length > itemLimit || pathPrefix === null + ? "bun scripts/cli.ts check --scripts-typecheck --scripts-typecheck-path " + : null, + valuesRedacted: true, + }; +} + function emitCommandHeartbeat(name: string, command: string[], progress: CommandProgress): void { emitCheckProgress({ phase: "running", @@ -425,7 +470,7 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full")); } if (options.scriptsTypecheck) { - items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs, options.scriptsTypecheckPath)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); } diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index b79d7d38..e1fa2863 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -60,6 +60,8 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe observe start --node --lane --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe start --node --lane --target-path /projects/mdtodo --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'", + "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '' --second-text ''", + "bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type closeMdtodoSourceConfig", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type selectMdtodoFile --filename 20260609_频率判断_用户反馈.md", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type openMdtodoReportPreview --task R1 --link R1.1", @@ -100,10 +102,12 @@ export function hwlabNodeWebProbeHelp(): Record { notes: [ "Default URL, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.", "`web-probe script` is an ad-hoc exploration escape hatch; repeated/high-frequency workflows must become `web-probe observe command` types or repo-owned web-probe commands.", + "Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT ` guidance; repo-owned generated commands report reuse instead of ad-hoc promotion.", "`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.", "observe is passive by default; user actions must be explicit observe command entries in control.jsonl.", "observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.", "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", + "validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", "performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.", "analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 2fc1e707..60d010f4 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -144,6 +144,27 @@ export interface HwlabRuntimeWebProbeSpec { readonly alertThresholds?: HwlabRuntimeWebProbeAlertThresholdsSpec; readonly browserFreezePolicy?: HwlabRuntimeWebProbeBrowserFreezePolicySpec; readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec; + readonly realtimeFanoutProfiles?: Readonly>; +} + +export interface HwlabRuntimeWebProbeRealtimeFanoutProfileSpec { + readonly subscriberCount: 2; + readonly debugStreams: readonly ("stdio" | "agentrun" | "hwlab")[]; + readonly productEventsPath: string; + readonly debugEventsPath: string; + readonly productReadyEvent: string; + readonly debugReadyEvent: string; + readonly businessEvent: string; + readonly fromBeginning: false; + readonly barrierTimeoutMs: number; + readonly terminalTimeoutMs: number; + readonly terminalQuietMs: number; + readonly reconnectQuietMs: number; + readonly forbiddenRequestPaths: readonly string[]; + readonly requiredEventTypes: Readonly<{ + agentrun: readonly string[]; + hwlab: readonly string[]; + }>; } export interface HwlabRuntimeWebProbeAuthLoginSpec { @@ -427,6 +448,7 @@ export interface HwlabRuntimeKafkaEventBridgeSpec { }; readonly configRef: string; readonly bootstrapServers: string; + readonly stdioTopic: string; readonly agentRunEventTopic: string; readonly hwlabEventTopic: string; readonly clientId: string; @@ -1050,6 +1072,7 @@ function codeAgentKafkaEventBridgeConfig(value: unknown, path: string): HwlabRun }, configRef: stringField(raw, "configRef", path), bootstrapServers: stringField(raw, "bootstrapServers", path), + stdioTopic: stringField(raw, "stdioTopic", path), agentRunEventTopic: stringField(raw, "agentRunEventTopic", path), hwlabEventTopic: stringField(raw, "hwlabEventTopic", path), clientId: stringField(raw, "clientId", path), @@ -1250,6 +1273,63 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec ...(raw.alertThresholds === undefined ? {} : { alertThresholds: webProbeAlertThresholdsConfig(raw.alertThresholds, `${path}.alertThresholds`) }), ...(raw.browserFreezePolicy === undefined ? {} : { browserFreezePolicy: webProbeBrowserFreezePolicyConfig(raw.browserFreezePolicy, `${path}.browserFreezePolicy`) }), ...(raw.projectManagement === undefined ? {} : { projectManagement: webProbeProjectManagementConfig(raw.projectManagement, `${path}.projectManagement`) }), + ...(raw.realtimeFanoutProfiles === undefined ? {} : { realtimeFanoutProfiles: webProbeRealtimeFanoutProfilesConfig(raw.realtimeFanoutProfiles, `${path}.realtimeFanoutProfiles`) }), + }; +} + +function webProbeRealtimeFanoutProfilesConfig(value: unknown, path: string): Readonly> { + const raw = asRecord(value, path); + const entries = Object.entries(raw).map(([id, item]) => { + if (!/^[A-Za-z0-9_.-]+$/u.test(id)) throw new Error(`${path} profile id ${id} must use safe identifier characters`); + return [id, webProbeRealtimeFanoutProfileConfig(item, `${path}.${id}`)] as const; + }); + if (entries.length === 0) throw new Error(`${path} must declare at least one profile`); + return Object.fromEntries(entries); +} + +function webProbeRealtimeFanoutProfileConfig(value: unknown, path: string): HwlabRuntimeWebProbeRealtimeFanoutProfileSpec { + const raw = asRecord(value, path); + const subscriberCount = numberField(raw, "subscriberCount", path); + if (subscriberCount !== 2) throw new Error(`${path}.subscriberCount must be 2`); + const debugStreams = nonEmptyStringArrayField(raw, "debugStreams", path); + if (debugStreams.length !== 3 || new Set(debugStreams).size !== 3 || debugStreams.some((stream) => !["stdio", "agentrun", "hwlab"].includes(stream))) { + 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`); + 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`); + for (const requestPath of forbiddenRequestPaths) { + if (!requestPath.startsWith("/")) throw new Error(`${path}.forbiddenRequestPaths entries must be absolute paths`); + } + const productEventsPath = stringField(raw, "productEventsPath", path); + const debugEventsPath = stringField(raw, "debugEventsPath", path); + for (const [field, routePath] of [["productEventsPath", productEventsPath], ["debugEventsPath", debugEventsPath]] as const) { + if (!routePath.startsWith("/")) throw new Error(`${path}.${field} must be an absolute path`); + } + const agentrunEventTypes = nonEmptyStringArrayField(requiredEventTypes, "agentrun", `${path}.requiredEventTypes`); + const hwlabEventTypes = nonEmptyStringArrayField(requiredEventTypes, "hwlab", `${path}.requiredEventTypes`); + if (new Set(agentrunEventTypes).size !== agentrunEventTypes.length) throw new Error(`${path}.requiredEventTypes.agentrun must not contain duplicates`); + if (new Set(hwlabEventTypes).size !== hwlabEventTypes.length) throw new Error(`${path}.requiredEventTypes.hwlab must not contain duplicates`); + return { + subscriberCount: 2, + debugStreams: debugStreams as ("stdio" | "agentrun" | "hwlab")[], + productEventsPath, + debugEventsPath, + productReadyEvent: stringField(raw, "productReadyEvent", path), + debugReadyEvent: stringField(raw, "debugReadyEvent", path), + businessEvent: stringField(raw, "businessEvent", path), + fromBeginning: false, + barrierTimeoutMs: boundedIntegerField(raw, "barrierTimeoutMs", path, 1_000, 120_000), + terminalTimeoutMs: boundedIntegerField(raw, "terminalTimeoutMs", path, 1_000, 600_000), + terminalQuietMs: boundedIntegerField(raw, "terminalQuietMs", path, 250, 10_000), + reconnectQuietMs: boundedIntegerField(raw, "reconnectQuietMs", path, 250, 30_000), + forbiddenRequestPaths, + requiredEventTypes: { + agentrun: agentrunEventTypes, + hwlab: hwlabEventTypes, + }, }; } diff --git a/scripts/src/hwlab-node-web-observe-runner-control-source.ts b/scripts/src/hwlab-node-web-observe-runner-control-source.ts index 00ad193b..f16ccceb 100644 --- a/scripts/src/hwlab-node-web-observe-runner-control-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-control-source.ts @@ -101,6 +101,7 @@ async function processCommand(command) { throwOnActionMismatch: true, expectedActionWaitMs: command.expectedActionWaitMs, }), "sendPrompt"); + case "validateRealtimeFanout": return validateRealtimeFanout(command); case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer"); case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel"); case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider"); diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts new file mode 100644 index 00000000..f4942fc4 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts @@ -0,0 +1,971 @@ +// SPEC: PJ2026-0104010803 Workbench realtime visibility. +// Responsibility: Long-running, typed realtime fanout validation inside the persistent Web observer. + +export function nodeWebObserveRunnerRealtimeSource(): string { + return String.raw` +function parseRealtimeFanoutProfiles(raw) { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +async function validateRealtimeFanout(command) { + const profileId = String(command.profile || "").trim(); + const profile = realtimeFanoutProfiles[profileId]; + if (!profile || typeof profile !== "object") throw new Error("validateRealtimeFanout profile is not declared by the owning YAML: " + profileId); + const provider = String(command.provider || "").trim(); + const firstPrompt = String(command.text || "").trim(); + const secondPrompt = String(command.secondText || "").trim(); + if (!provider) throw new Error("validateRealtimeFanout requires provider"); + if (!firstPrompt || !secondPrompt) throw new Error("validateRealtimeFanout requires first and second prompt"); + const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs); + const reportDir = path.join(stateDir, "artifacts", "realtime-fanout", safeId(command.id)); + await mkdir(reportDir, { recursive: true, mode: 0o700 }); + const network = []; + const requestListener = (request) => { + let url; + try { url = new URL(request.url()); } catch { return; } + if (url.origin !== new URL(baseUrl).origin) return; + network.push({ + at: Date.now(), + method: request.method(), + path: url.pathname, + sessionId: url.searchParams.get("sessionId"), + traceId: url.searchParams.get("traceId"), + afterSeq: url.searchParams.get("afterSeq"), + lastEventId: request.headers()["last-event-id"] || null, + valuesRedacted: true, + }); + if (network.length > 1000) network.splice(0, network.length - 1000); + }; + page.on("request", requestListener); + let subscriberA = null; + let subscriberB = null; + let subscriberB2 = null; + let debug = null; + let createdSessionId = null; + const report = { + ok: false, + contract: "web-probe-realtime-fanout-v1", + commandId: command.id, + profile: profileId, + provider, + startedAt: new Date().toISOString(), + promptEvidence: { + first: { hash: sha256Text(firstPrompt), bytes: Buffer.byteLength(firstPrompt) }, + second: { hash: sha256Text(secondPrompt), bytes: Buffer.byteLength(secondPrompt) }, + valuesRedacted: true, + }, + valuesRedacted: true, + }; + try { + const providerSelection = await selectProvider(provider); + const session = await createSessionFromUi(); + const sessionId = session.sessionId; + createdSessionId = sessionId; + report.sessionId = sessionId; + report.providerSelection = sanitize(providerSelection); + const storageState = await context.storageState(); + subscriberA = await realtimeNewSubscriber(storageState, sessionId, "subscriber-a", effective); + subscriberB = await realtimeNewSubscriber(storageState, sessionId, "subscriber-b", effective); + debug = await realtimeNewDebugContext(storageState); + 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); + await appendJsonl(files.control, eventRecord("realtime-fanout-product-ready", { + profile: profileId, + sessionId, + subscriberCount: 2, + liveOnly: true, + replay: false, + valuesRedacted: true, + })); + + const first = await realtimeRunTurn({ + command, + turn: 1, + prompt: firstPrompt, + sessionId, + subscribers: [subscriberA, subscriberB], + debug, + profile: effective, + }); + const bBeforeDisconnect = await realtimeProductSnapshot(subscriberB); + realtimeAssertStableProductTransport(bBeforeDisconnect, "subscriber-b-before-disconnect"); + if (bBeforeDisconnect.records.some((item) => item.traceId === first.traceId && item.terminal === true)) { + throw new Error("first turn reached terminal before subscriber B could be disconnected; use a prompt with an observable live interval"); + } + const bBeforeShot = await realtimeCaptureSubscriberScreenshot(subscriberB.page, reportDir, "subscriber-b-before-disconnect.png", { + title: "Subscriber B before disconnect", + sessionId, + traceId: first.traceId, + eventCount: bBeforeDisconnect.records.length, + terminalSeen: bBeforeDisconnect.records.some((item) => item.terminal === true), + valuesRedacted: true, + }); + await subscriberB.context.close(); + subscriberB = null; + const firstTerminal = await realtimeWaitTraceEvent(subscriberA, first.traceId, true, effective.terminalTimeoutMs); + await realtimeWaitUiTerminal(first.traceId, effective.terminalTimeoutMs); + const firstStable = await realtimeWaitTurnStable(debug, first.debugKey, first.traceId, [subscriberA], effective); + const firstDebug = firstStable.debug; + realtimeEnrichTurnExecutionIds(first, firstDebug); + const firstProductA = firstStable.products[0]; + realtimeAssertStableProductTransport(firstProductA, "subscriber-a-first-terminal"); + realtimeValidateTurnEvidence({ + turn: 1, + traceId: first.traceId, + sessionId, + runId: first.runId, + commandId: first.commandId, + debug: firstDebug, + terminalSnapshots: [firstProductA], + preTerminalSnapshots: [bBeforeDisconnect], + profile: effective, + }); + await realtimeCloseDebugStreams(debug, first.debugKey); + + subscriberB2 = await realtimeNewSubscriber(storageState, sessionId, "subscriber-b-reconnected", effective); + const bReconnected = await realtimeWaitProductConnected(subscriberB2, effective.barrierTimeoutMs); + realtimeAssertLiveConnected(bReconnected, sessionId, "subscriber-b-reconnected", effective); + 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"); + } + + const second = await realtimeRunTurn({ + command, + turn: 2, + prompt: secondPrompt, + sessionId, + subscribers: [subscriberA, subscriberB2], + debug, + profile: effective, + }); + if (second.traceId === first.traceId) throw new Error("second turn reused the first business traceId"); + const secondTerminalA = await realtimeWaitTraceEvent(subscriberA, second.traceId, true, effective.terminalTimeoutMs); + const secondTerminalB = await realtimeWaitTraceEvent(subscriberB2, second.traceId, true, effective.terminalTimeoutMs); + await realtimeWaitUiTerminal(second.traceId, effective.terminalTimeoutMs); + const secondStable = await realtimeWaitTurnStable(debug, second.debugKey, second.traceId, [subscriberA, subscriberB2], effective); + const secondDebug = secondStable.debug; + realtimeEnrichTurnExecutionIds(second, secondDebug); + 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"); + } + realtimeValidateTurnEvidence({ + turn: 2, + traceId: second.traceId, + sessionId, + runId: second.runId, + commandId: second.commandId, + debug: secondDebug, + terminalSnapshots: [secondProductA, secondProductB], + preTerminalSnapshots: [], + profile: effective, + }); + await realtimeCloseDebugStreams(debug, second.debugKey); + + const productA = await realtimeProductSnapshot(subscriberA); + const productB2 = await realtimeProductSnapshot(subscriberB2); + realtimeAssertStableProductTransport(productA, "subscriber-a-final"); + realtimeAssertStableProductTransport(productB2, "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"); + if (mainStreams[0].traceId !== null) throw new Error("main Workbench switched to trace-scoped SSE"); + if (mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench requested replay cursor"); + 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", + profile: profileId, + sessionId, + traceIds: [first.traceId, second.traceId], + productSubscriberCount: 2, + debugStreams: effective.debugStreams, + forbiddenRequestCount: forbidden.length, + replayedEventCount: 0, + valuesRedacted: true, + }); + const subscriberAScreenshot = await realtimeCaptureSubscriberScreenshot(subscriberA.page, reportDir, "subscriber-a-final.png", { + title: "Subscriber A continuous live connection", + sessionId, + traceIds: [first.traceId, second.traceId], + eventCount: productA.records.length, + terminalCount: productA.records.filter((item) => item.terminal === true).length, + valuesRedacted: true, + }); + const subscriberB2Screenshot = await realtimeCaptureSubscriberScreenshot(subscriberB2.page, reportDir, "subscriber-b-reconnected-final.png", { + title: "Subscriber B reconnected live-only", + sessionId, + traceIds: [second.traceId], + eventCount: productB2.records.length, + firstTraceReplayCount: productB2.records.filter((item) => item.traceId === first.traceId).length, + valuesRedacted: true, + }); + const turns = [ + realtimeTurnSummary(first, firstDebug, productA, firstTerminal), + realtimeTurnSummary(second, secondDebug, productB2, secondTerminalB), + ]; + Object.assign(report, { + ok: true, + status: "passed", + completedAt: new Date().toISOString(), + turns, + productSse: { + mainSessionConnectionCount: mainStreams.length, + sessionScoped: true, + afterSeqCount: 0, + lastEventIdCount: 0, + subscriberAEventCount: productA.records.length, + subscriberBReconnectEventCount: productB2.records.length, + subscriberBFirstTraceReplayCount: 0, + valuesRedacted: true, + }, + liveContract: { + expectedKafka: effective.expectedKafka, + subscriberA: realtimeConnectedSummary(connectedA), + subscriberBBeforeDisconnect: realtimeConnectedSummary(connectedB), + subscriberBAfterReconnect: realtimeConnectedSummary(bReconnected), + valuesRedacted: true, + }, + forbiddenRequests: { count: forbidden.length, rows: forbidden, valuesRedacted: true }, + screenshots: [bBeforeShot, summaryScreenshot, subscriberAScreenshot, subscriberB2Screenshot], + warmRunnerReused: first.runnerId && second.runnerId ? first.runnerId === second.runnerId : null, + valuesRedacted: true, + }); + const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [firstDebug, secondDebug]); + report.artifacts = artifacts; + const reportArtifact = await realtimeWriteReport(reportDir, report); + await appendJsonl(files.control, eventRecord("realtime-fanout-validated", { + profile: profileId, + sessionId, + traceIds: turns.map((item) => item.traceId), + runIds: turns.map((item) => item.runId), + commandIds: turns.map((item) => item.commandId), + reportPath: reportArtifact.path, + reportSha256: reportArtifact.sha256, + valuesRedacted: true, + })); + const observer = await syncObserverPageToControlSession("validateRealtimeFanout", sessionId); + return { + ok: true, + status: "passed", + profile: profileId, + sessionId, + traceIds: turns.map((item) => item.traceId), + runIds: turns.map((item) => item.runId), + commandIds: turns.map((item) => item.commandId), + warmRunnerReused: report.warmRunnerReused, + forbiddenRequestCount: 0, + replayedEventCount: 0, + reportPath: reportArtifact.path, + reportSha256: reportArtifact.sha256, + screenshots: report.screenshots, + observer, + valuesRedacted: true, + }; + } catch (error) { + Object.assign(report, { + ok: false, + status: "failed", + completedAt: new Date().toISOString(), + failure: errorSummary(error), + valuesRedacted: true, + }); + const partialDebug = debug ? await realtimeAllDebugSnapshots(debug).catch(() => []) : []; + const partialProducts = await realtimePartialProductEvidence([subscriberA, subscriberB, subscriberB2]); + const partialArtifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, partialDebug).catch(() => null); + report.partialEvidence = { products: partialProducts, debug: partialDebug.map(realtimeDebugSnapshotSummary), valuesRedacted: true }; + if (partialArtifacts) report.artifacts = partialArtifacts; + const reportArtifact = await realtimeWriteReport(reportDir, report).catch(() => null); + if (createdSessionId) { + await syncObserverPageToControlSession("validateRealtimeFanout-failed", createdSessionId) + .catch((syncError) => appendJsonl(files.errors, eventRecord("realtime-fanout-observer-sync-error", { commandId: command.id, error: errorSummary(syncError), valuesRedacted: true }))); + } + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { + ...(wrapped.details || {}), + profile: profileId, + reportPath: reportArtifact?.path || null, + reportSha256: reportArtifact?.sha256 || null, + valuesRedacted: true, + }; + throw wrapped; + } finally { + page.off("request", requestListener); + if (debug) await realtimeCloseDebugStreams(debug, null).catch(() => {}); + if (debug) await debug.context.close().catch(() => {}); + if (subscriberA) await subscriberA.context.close().catch(() => {}); + if (subscriberB) await subscriberB.context.close().catch(() => {}); + if (subscriberB2) await subscriberB2.context.close().catch(() => {}); + } +} + +function realtimeFanoutEffectiveProfile(profile, durationMs) { + const debugStreams = Array.isArray(profile.debugStreams) ? profile.debugStreams.map(String) : []; + if (Number(profile.subscriberCount) !== 2) throw new Error("realtime fanout profile subscriberCount must be 2"); + if (debugStreams.length !== 3 || new Set(debugStreams).size !== 3 || !["stdio", "agentrun", "hwlab"].every((item) => debugStreams.includes(item))) { + 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 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"); + } + const terminalTimeoutMs = Number.isFinite(Number(durationMs)) + ? Math.min(Number(profile.terminalTimeoutMs), Math.max(1000, Number(durationMs))) + : Number(profile.terminalTimeoutMs); + return { + ...profile, + debugStreams, + barrierTimeoutMs: boundedInteger(profile.barrierTimeoutMs, 45000, 1000, 120000), + terminalTimeoutMs: boundedInteger(terminalTimeoutMs, 480000, 1000, 600000), + terminalQuietMs: boundedInteger(profile.terminalQuietMs, 2000, 250, 10000), + reconnectQuietMs: boundedInteger(profile.reconnectQuietMs, 3000, 250, 30000), + forbiddenRequestPaths: Array.isArray(profile.forbiddenRequestPaths) ? profile.forbiddenRequestPaths.map(String) : [], + requiredEventTypes: profile.requiredEventTypes && typeof profile.requiredEventTypes === "object" ? profile.requiredEventTypes : {}, + expectedKafka: sanitize(expectedKafka), + }; +} + +async function realtimeNewSubscriber(storageState, sessionId, label, profile) { + const subscriberContext = await browser.newContext({ storageState, viewport }); + const subscriberPage = await subscriberContext.newPage(); + await subscriberPage.goto(new URL("/health/live", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: profile.barrierTimeoutMs }); + const key = label + "-" + sessionId; + await subscriberPage.evaluate(({ key: streamKey, sessionId: scopedSessionId, profile: inputProfile }) => { + const root = window.__unideskRealtimeFanoutProducts ??= {}; + const state = { connected: null, connectedEventCount: 0, openCount: 0, records: [], errors: [], source: null }; + root[streamKey] = state; + const firstText = (...values) => { + for (const value of values) if (typeof value === "string" && value.trim()) return value.trim(); + return null; + }; + const fingerprint = (text) => { + const bytes = new TextEncoder().encode(String(text)); + let hash = 2166136261; + for (const byte of bytes) hash = Math.imul((hash ^ byte) >>> 0, 16777619) >>> 0; + return "fnv1a32:" + hash.toString(16).padStart(8, "0") + ":" + bytes.length; + }; + const source = new EventSource(inputProfile.productEventsPath + "?sessionId=" + encodeURIComponent(scopedSessionId), { withCredentials: true }); + state.source = source; + source.onopen = () => { state.openCount += 1; }; + const record = (eventName, raw) => { + let value; + try { value = JSON.parse(raw); } catch { return; } + const nested = value?.event && typeof value.event === "object" ? value.event : {}; + const context = value?.context && typeof value.context === "object" ? value.context : {}; + const eventType = firstText(nested.eventType, nested.type, value.eventType, value.type); + state.records.push({ + eventName, + at: Date.now(), + schema: firstText(value.schema), + eventId: firstText(value.eventId, nested.id), + sourceEventId: firstText(value.sourceEventId, nested.sourceEventId), + eventType, + sourceSeq: Number.isFinite(Number(value.sourceSeq)) ? Number(value.sourceSeq) : Number.isFinite(Number(nested.sourceSeq)) ? Number(nested.sourceSeq) : null, + traceId: firstText(value.traceId, nested.traceId, context.traceId), + 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), + terminal: value.terminal === true || nested.terminal === true || eventType === "terminal" || eventType === "terminal_status", + status: firstText(nested.status, value.status), + envelopeFingerprint: fingerprint(raw), + valuesRedacted: true, + }); + if (state.records.length > 800) state.records.splice(0, state.records.length - 800); + }; + source.addEventListener(inputProfile.productReadyEvent, (event) => { + state.connectedEventCount += 1; + try { state.connected = JSON.parse(event.data); } catch { state.connected = { parseError: true }; } + }); + source.addEventListener(inputProfile.businessEvent, (event) => record(inputProfile.businessEvent, event.data)); + source.addEventListener("workbench.error", (event) => state.errors.push({ at: Date.now(), event: "workbench.error", dataBytes: String(event.data || "").length })); + source.onerror = () => state.errors.push({ at: Date.now(), event: "transport.error" }); + }, { key, sessionId, profile: { productEventsPath: profile.productEventsPath, productReadyEvent: profile.productReadyEvent, businessEvent: profile.businessEvent } }); + return { label, key, context: subscriberContext, page: subscriberPage }; +} + +async function realtimeNewDebugContext(storageState) { + const debugContext = await browser.newContext({ storageState, viewport }); + const debugPage = await debugContext.newPage(); + await debugPage.goto(new URL("/health/live", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 30000 }); + return { context: debugContext, page: debugPage }; +} + +async function realtimeRunTurn(input) { + const turn = input.turn; + const prompt = input.prompt; + await page.locator("#command-input").fill(prompt); + await page.locator("#command-send").waitFor({ state: "visible", timeout: input.profile.barrierTimeoutMs }); + await page.waitForFunction(() => { + const button = document.querySelector("#command-send"); + return Boolean(button && !button.disabled && button.getAttribute("data-action") === "turn"); + }, null, { timeout: input.profile.barrierTimeoutMs }); + let capturedResolve; + let releaseResolve; + let releasedToServer = false; + const capturedPromise = new Promise((resolve) => { capturedResolve = resolve; }); + const releasePromise = new Promise((resolve) => { releaseResolve = resolve; }); + const handler = async (route) => { + const request = route.request(); + let body = {}; + try { body = request.postDataJSON(); } catch {} + capturedResolve({ traceId: String(body?.traceId || ""), sessionId: String(body?.sessionId || ""), provider: String(body?.providerProfile || "") }); + const releaseAction = await releasePromise; + if (releaseAction === "continue") await route.continue(); + else await route.abort("aborted"); + }; + await page.route("**/v1/agent/chat", handler); + try { + const admissionPromise = page.waitForResponse((response) => { + let url; + try { url = new URL(response.url()); } catch { return false; } + return response.request().method() === "POST" && url.pathname === "/v1/agent/chat"; + }, { timeout: Math.min(input.profile.terminalTimeoutMs, input.profile.barrierTimeoutMs + 30000) }) + .then((response) => ({ response, error: null })) + .catch((error) => ({ response: null, error })); + await page.locator("#command-send").click(); + const captured = await withHardTimeout(capturedPromise, input.profile.barrierTimeoutMs, "realtime turn request capture timed out"); + if (!/^trc_[A-Za-z0-9_-]+$/u.test(captured.traceId)) throw new Error("realtime turn did not pre-generate a traceId"); + if (captured.sessionId !== input.sessionId) throw new Error("realtime turn sessionId mismatch"); + if (captured.provider !== String(input.command.provider || "")) throw new Error("realtime turn provider mismatch"); + const debugKey = "turn-" + turn + "-" + captured.traceId; + await realtimeOpenDebugStreams(input.debug, debugKey, captured.traceId, input.profile); + const debugConnected = await realtimeWaitDebugConnected(input.debug, debugKey, input.profile); + for (const stream of input.profile.debugStreams) { + const connected = debugConnected[stream]; + if (connected?.consumerReady !== true) throw new Error(stream + " debug consumer is not ready"); + if (!String(connected?.groupId || "").includes("-debug-sse-")) throw new Error(stream + " debug group is not isolated"); + if (connected?.topic !== input.profile.expectedKafka.topics[stream]) throw new Error(stream + " debug topic does not match the owning YAML"); + } + for (const subscriber of input.subscribers) { + const connected = await realtimeWaitProductConnected(subscriber, input.profile.barrierTimeoutMs); + realtimeAssertLiveConnected(connected, input.sessionId, subscriber.label, input.profile); + } + await appendJsonl(files.control, eventRecord("realtime-fanout-ready-barrier", { + turn, + traceId: captured.traceId, + productSubscriberCount: input.subscribers.length, + debugStreams: input.profile.debugStreams, + topics: Object.fromEntries(input.profile.debugStreams.map((stream) => [stream, debugConnected[stream]?.topic || null])), + groups: Object.fromEntries(input.profile.debugStreams.map((stream) => [stream, debugConnected[stream]?.groupId || null])), + valuesRedacted: true, + })); + releasedToServer = true; + releaseResolve("continue"); + const admissionOutcome = await admissionPromise; + if (!admissionOutcome.response) throw new Error("realtime turn admission response failed: " + String(admissionOutcome.error?.message || admissionOutcome.error || "unknown")); + const admission = admissionOutcome.response; + if (admission.status() < 200 || admission.status() >= 300) throw new Error("realtime turn admission returned HTTP " + admission.status()); + let admissionBody = {}; + try { admissionBody = await admission.json(); } catch {} + const canonicalTraceId = realtimeFirstText(admissionBody?.traceId, admissionBody?.result?.traceId, captured.traceId); + if (canonicalTraceId !== captured.traceId) throw new Error("canonical trace changed after ready barrier"); + const nonTerminal = []; + for (const subscriber of input.subscribers) { + nonTerminal.push(await realtimeWaitTraceEvent(subscriber, canonicalTraceId, false, input.profile.terminalTimeoutMs)); + } + const ids = await realtimeWaitTurnIds(input.debug, debugKey, canonicalTraceId, input.profile.terminalTimeoutMs); + await appendJsonl(files.control, eventRecord("realtime-fanout-pre-terminal", { + turn, + traceId: canonicalTraceId, + runId: ids.runId, + commandId: ids.commandId, + subscriberCount: nonTerminal.length, + valuesRedacted: true, + })); + return { turn, traceId: canonicalTraceId, sessionId: input.sessionId, runId: ids.runId, commandId: ids.commandId, runnerId: ids.runnerId, attemptId: ids.attemptId, debugKey, admissionStatus: admission.status(), valuesRedacted: true }; + } finally { + if (!releasedToServer) releaseResolve("abort"); + await page.unroute("**/v1/agent/chat", handler).catch(() => {}); + } +} + +async function realtimeOpenDebugStreams(debug, key, traceId, profile) { + await debug.page.evaluate(({ key: streamKey, traceId: scopedTraceId, profile: inputProfile }) => { + const root = window.__unideskRealtimeFanoutDebug ??= {}; + const state = { connected: {}, connectedEventCount: {}, openCount: {}, records: {}, errors: {}, sources: {} }; + root[streamKey] = state; + const firstText = (...values) => { + for (const value of values) if (typeof value === "string" && value.trim()) return value.trim(); + return null; + }; + const finite = (...values) => { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return null; + }; + const fingerprint = (text) => { + const bytes = new TextEncoder().encode(String(text)); + let hash = 2166136261; + for (const byte of bytes) hash = Math.imul((hash ^ byte) >>> 0, 16777619) >>> 0; + return "fnv1a32:" + hash.toString(16).padStart(8, "0") + ":" + bytes.length; + }; + for (const stream of inputProfile.debugStreams) { + state.records[stream] = []; + state.errors[stream] = []; + state.connectedEventCount[stream] = 0; + state.openCount[stream] = 0; + const params = new URLSearchParams({ stream, traceId: scopedTraceId }); + if (inputProfile.fromBeginning === true) params.set("fromBeginning", "true"); + const source = new EventSource(inputProfile.debugEventsPath + "?" + params.toString(), { withCredentials: true }); + state.sources[stream] = source; + source.onopen = () => { state.openCount[stream] += 1; }; + source.addEventListener(inputProfile.debugReadyEvent, (event) => { + state.connectedEventCount[stream] += 1; + try { state.connected[stream] = JSON.parse(event.data); } catch { state.connected[stream] = { parseError: true }; } + }); + source.addEventListener("hwlab.kafka.event", (event) => { + let outer; + try { outer = JSON.parse(event.data); } catch { return; } + const value = outer?.value && typeof outer.value === "object" ? outer.value : {}; + const nested = value?.event && typeof value.event === "object" ? value.event : {}; + const payload = nested?.payload && typeof nested.payload === "object" ? nested.payload : {}; + const context = value?.context && typeof value.context === "object" ? value.context : {}; + const run = value?.run && typeof value.run === "object" ? value.run : {}; + const command = value?.command && typeof value.command === "object" ? value.command : {}; + const eventType = stream === "agentrun" + ? firstText(nested.type, value.eventType) + : stream === "hwlab" + ? firstText(nested.eventType, nested.type, value.eventType) + : firstText(value.eventType, value.stdio?.method); + state.records[stream].push({ + stream, + at: Date.now(), + topic: firstText(outer.topic), + partition: finite(outer.partition), + offset: firstText(outer.offset), + schema: firstText(value.schema), + eventId: firstText(value.eventId, nested.id), + sourceEventId: firstText(value.sourceEventId, nested.sourceEventId), + eventType, + sourceSeq: finite(value.sourceSeq, nested.seq, nested.sourceSeq), + frameSeq: finite(value.frameSeq, value.stdio?.frameSeq), + traceId: firstText(value.traceId, context.traceId, payload.traceId, nested.traceId), + 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), + 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", + direction: firstText(value.stdio?.direction), + method: firstText(value.stdio?.method), + envelopeFingerprint: stream === "hwlab" ? fingerprint(JSON.stringify(value)) : null, + valuesRedacted: true, + }); + if (state.records[stream].length > 1000) state.records[stream].splice(0, state.records[stream].length - 1000); + }); + source.addEventListener("hwlab.kafka.error", (event) => state.errors[stream].push({ at: Date.now(), dataBytes: String(event.data || "").length })); + source.onerror = () => state.errors[stream].push({ at: Date.now(), transport: true }); + } + }, { key, traceId, profile: { + debugStreams: profile.debugStreams, + debugEventsPath: profile.debugEventsPath, + debugReadyEvent: profile.debugReadyEvent, + fromBeginning: profile.fromBeginning, + } }); +} + +async function realtimeWaitProductConnected(subscriber, timeoutMs) { + return realtimePoll(async () => (await realtimeProductSnapshot(subscriber)).connected, timeoutMs, "product connected"); +} + +async function realtimeWaitDebugConnected(debug, key, profile) { + return realtimePoll(async () => { + const snapshot = await realtimeDebugSnapshot(debug, key); + return profile.debugStreams.every((stream) => snapshot.connected[stream]?.consumerReady === true) ? snapshot.connected : null; + }, profile.barrierTimeoutMs, "debug consumers ready"); +} + +async function realtimeWaitTurnIds(debug, key, traceId, timeoutMs) { + return realtimePoll(async () => { + const snapshot = await realtimeDebugSnapshot(debug, key); + const rows = Object.values(snapshot.records).flat().filter((item) => item.traceId === traceId); + const runId = rows.map((item) => item.runId).find(Boolean) || null; + const commandId = rows.map((item) => item.commandId).find(Boolean) || null; + const runnerId = rows.map((item) => item.runnerId).find(Boolean) || null; + const attemptId = rows.map((item) => item.attemptId).find(Boolean) || null; + return runId && commandId ? { runId, commandId, runnerId, attemptId } : null; + }, timeoutMs, "realtime run and command ids"); +} + +async function realtimeWaitDebugTurnComplete(debug, key, traceId, profile) { + return realtimePoll(async () => { + const snapshot = await realtimeDebugSnapshot(debug, key); + if (!profile.debugStreams.every((stream) => (snapshot.records[stream] || []).some((item) => item.traceId === traceId))) return null; + const agentrunTypes = new Set((snapshot.records.agentrun || []).filter((item) => item.traceId === traceId).map((item) => item.eventType)); + const hwlabTypes = new Set((snapshot.records.hwlab || []).filter((item) => item.traceId === traceId).map((item) => item.eventType)); + if (!(profile.requiredEventTypes.agentrun || []).every((type) => agentrunTypes.has(type))) return null; + if (!(profile.requiredEventTypes.hwlab || []).every((type) => hwlabTypes.has(type))) return null; + return snapshot; + }, profile.terminalTimeoutMs, "realtime debug terminal event families"); +} + +async function realtimeWaitTurnStable(debug, key, traceId, subscribers, profile) { + await realtimeWaitDebugTurnComplete(debug, key, traceId, profile); + const deadline = Date.now() + profile.terminalTimeoutMs; + let previousSignature = null; + let stableSince = Date.now(); + while (Date.now() < deadline) { + const debugSnapshot = await realtimeDebugSnapshot(debug, key); + const productSnapshots = await Promise.all(subscribers.map((subscriber) => realtimeProductSnapshot(subscriber))); + const signature = JSON.stringify({ + debug: Object.fromEntries(profile.debugStreams.map((stream) => { + const rows = (debugSnapshot.records[stream] || []).filter((item) => item.traceId === traceId); + return [stream, { count: rows.length, lastOffset: rows.at(-1)?.offset || null, lastFingerprint: rows.at(-1)?.envelopeFingerprint || null }]; + })), + products: productSnapshots.map((snapshot) => { + const rows = snapshot.records.filter((item) => item.traceId === traceId); + return { count: rows.length, lastSourceSeq: rows.at(-1)?.sourceSeq || null, lastFingerprint: rows.at(-1)?.envelopeFingerprint || null }; + }), + }); + if (signature !== previousSignature) { + previousSignature = signature; + stableSince = Date.now(); + } else if (Date.now() - stableSince >= profile.terminalQuietMs) { + return { debug: debugSnapshot, products: productSnapshots }; + } + await sleep(250); + } + throw new Error("realtime debug/product streams did not become quiet after terminal"); +} + +async function realtimeWaitTraceEvent(subscriber, traceId, terminal, timeoutMs) { + return realtimePoll(async () => { + const snapshot = await realtimeProductSnapshot(subscriber); + return snapshot.records.find((item) => item.traceId === traceId && item.terminal === terminal) || null; + }, timeoutMs, terminal ? "terminal product event" : "pre-terminal product event"); +} + +async function realtimeWaitUiTerminal(traceId, timeoutMs) { + await page.waitForFunction((expectedTraceId) => { + const card = document.querySelector(".message-card[data-role='agent'][data-trace-id='" + expectedTraceId + "']"); + if (!card) return false; + const status = card.getAttribute("data-status"); + const body = card.querySelector(".message-text")?.textContent?.trim() || ""; + return status === "completed" && body.length > 0; + }, traceId, { timeout: timeoutMs }); +} + +async function realtimeProductSnapshot(subscriber) { + return subscriber.page.evaluate((key) => { + const state = window.__unideskRealtimeFanoutProducts?.[key]; + if (!state) return { connected: null, connectedEventCount: 0, openCount: 0, records: [], errors: [{ missing: true }] }; + return { connected: state.connected ? JSON.parse(JSON.stringify(state.connected)) : null, connectedEventCount: state.connectedEventCount, openCount: state.openCount, records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)) }; + }, subscriber.key); +} + +async function realtimeDebugSnapshot(debug, key) { + return debug.page.evaluate((streamKey) => { + const state = window.__unideskRealtimeFanoutDebug?.[streamKey]; + if (!state) return { connected: {}, connectedEventCount: {}, openCount: {}, records: { stdio: [], agentrun: [], hwlab: [] }, errors: { stdio: [], agentrun: [], hwlab: [] } }; + return { connected: JSON.parse(JSON.stringify(state.connected)), connectedEventCount: JSON.parse(JSON.stringify(state.connectedEventCount)), openCount: JSON.parse(JSON.stringify(state.openCount)), records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)) }; + }, key); +} + +async function realtimeAllDebugSnapshots(debug) { + return debug.page.evaluate(() => { + const root = window.__unideskRealtimeFanoutDebug || {}; + return Object.entries(root).map(([key, state]) => ({ + key, + connected: JSON.parse(JSON.stringify(state.connected || {})), + connectedEventCount: JSON.parse(JSON.stringify(state.connectedEventCount || {})), + openCount: JSON.parse(JSON.stringify(state.openCount || {})), + records: JSON.parse(JSON.stringify(state.records || {})), + errors: JSON.parse(JSON.stringify(state.errors || {})), + })); + }); +} + +async function realtimeCloseDebugStreams(debug, key) { + if (!debug?.page || debug.page.isClosed()) return; + await debug.page.evaluate((streamKey) => { + const root = window.__unideskRealtimeFanoutDebug || {}; + const keys = streamKey ? [streamKey] : Object.keys(root); + for (const current of keys) { + for (const source of Object.values(root[current]?.sources || {})) source?.close?.(); + } + }, key); +} + +function realtimeAssertLiveConnected(connected, sessionId, label, profile) { + 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"); + if (connected?.filters?.sessionId !== sessionId) throw new Error(label + " session filter mismatch"); + 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"); + } +} + +function realtimeConnectedSummary(connected) { + return { + realtimeSource: connected?.realtimeSource || null, + deliverySemantics: connected?.deliverySemantics || null, + liveOnly: connected?.liveOnly === true, + replay: connected?.replay === true, + replaySupported: connected?.replaySupported === true, + lossPossible: connected?.lossPossible === true, + filters: { sessionId: connected?.filters?.sessionId || null, traceId: connected?.filters?.traceId || null }, + capabilities: sanitize(connected?.capabilities || {}), + valuesRedacted: true, + }; +} + +function realtimeAssertStableProductTransport(snapshot, label) { + if (snapshot.openCount !== 1) throw new Error(label + " product SSE open count is " + snapshot.openCount + ", expected 1"); + if (snapshot.connectedEventCount !== 1) throw new Error(label + " product connected event count is " + snapshot.connectedEventCount + ", expected 1"); + if ((snapshot.errors || []).length !== 0) throw new Error(label + " product SSE observed a transport or business error"); +} + +function realtimeEnrichTurnExecutionIds(turn, debug) { + const rows = Object.values(debug.records || {}).flat().filter((item) => item.traceId === turn.traceId); + turn.runnerId ||= rows.map((item) => item.runnerId).find(Boolean) || null; + turn.attemptId ||= rows.map((item) => item.attemptId).find(Boolean) || null; +} + +function realtimeValidateTurnEvidence(input) { + const profile = input.profile; + for (const stream of profile.debugStreams) { + if (input.debug.connected[stream]?.consumerReady !== true) throw new Error("turn " + input.turn + " " + stream + " consumer not ready"); + if (input.debug.connected[stream]?.topic !== profile.expectedKafka.topics[stream]) throw new Error("turn " + input.turn + " " + stream + " topic mismatch"); + if (input.debug.openCount[stream] !== 1 || input.debug.connectedEventCount[stream] !== 1) throw new Error("turn " + input.turn + " " + stream + " debug SSE reconnected"); + const rows = (input.debug.records[stream] || []).filter((item) => item.traceId === input.traceId); + if (rows.length === 0) throw new Error("turn " + input.turn + " " + stream + " has no trace records"); + if (!realtimeMonotonicOffsets(rows)) throw new Error("turn " + input.turn + " " + stream + " offsets are not monotonic"); + if (rows.some((item) => item.hwlabSessionId !== input.sessionId)) throw new Error("turn " + input.turn + " " + stream + " session missing or mismatched"); + if (rows.some((item) => item.runId !== input.runId)) throw new Error("turn " + input.turn + " " + stream + " runId missing or mismatched"); + if (rows.some((item) => item.commandId && item.commandId !== input.commandId)) throw new Error("turn " + input.turn + " " + stream + " commandId mismatched"); + const commandBoundRows = stream === "stdio" + ? rows + : rows.filter((item) => (profile.requiredEventTypes[stream] || []).includes(item.eventType)); + if (commandBoundRows.length === 0 || commandBoundRows.some((item) => item.commandId !== input.commandId)) { + throw new Error("turn " + input.turn + " " + stream + " command-bound event lacks the current commandId"); + } + if ((input.debug.errors[stream] || []).length !== 0) throw new Error("turn " + input.turn + " " + stream + " debug SSE error"); + } + const stdio = input.debug.records.stdio.filter((item) => item.traceId === input.traceId); + const frameSeqs = stdio.map((item) => item.frameSeq).filter(Number.isFinite); + if (frameSeqs.length === 0 || !realtimeStrictlyIncreasing(frameSeqs)) throw new Error("turn " + input.turn + " stdio frameSeq is not strictly increasing"); + const agentrunTypes = new Set(input.debug.records.agentrun.filter((item) => item.traceId === input.traceId).map((item) => item.eventType)); + const hwlabTypes = new Set(input.debug.records.hwlab.filter((item) => item.traceId === input.traceId).map((item) => item.eventType)); + for (const type of profile.requiredEventTypes.agentrun || []) if (!agentrunTypes.has(type)) throw new Error("turn " + input.turn + " AgentRun lacks " + type); + for (const type of profile.requiredEventTypes.hwlab || []) if (!hwlabTypes.has(type)) throw new Error("turn " + input.turn + " HWLAB lacks " + type); + const identityRows = Object.values(input.debug.records).flat().filter((item) => item.traceId === input.traceId); + if (!input.runId || new Set(identityRows.map((item) => item.runId)).size !== 1) throw new Error("turn " + input.turn + " runId missing or mismatched across streams"); + 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); + 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"); + } + for (const snapshot of input.preTerminalSnapshots) { + const rows = snapshot.records.filter((item) => item.traceId === input.traceId); + if (!rows.some((item) => item.terminal !== true)) throw new Error("turn " + input.turn + " disconnected subscriber lacks pre-terminal event"); + realtimeAssertEnvelopePassthrough(hwlabRows, rows, false, "turn " + input.turn + " pre-terminal subscriber"); + } +} + +function realtimeAssertEnvelopePassthrough(kafkaRows, productRows, requireAllKafkaRows, label) { + const sameEnvelope = (left, right) => left.envelopeFingerprint && left.envelopeFingerprint === right.envelopeFingerprint + && left.eventId === right.eventId + && left.sourceEventId === right.sourceEventId + && left.sourceSeq === right.sourceSeq + && left.eventType === right.eventType + && left.traceId === right.traceId + && left.hwlabSessionId === right.hwlabSessionId + && left.runId === right.runId + && left.commandId === right.commandId + && 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"); + } + if (requireAllKafkaRows) { + if (productRows.length !== kafkaRows.length) throw new Error(label + " envelope count differs from hwlab.event.v1"); + for (const kafkaRow of kafkaRows) { + if (!productRows.some((productRow) => sameEnvelope(kafkaRow, productRow))) throw new Error(label + " did not receive an unchanged hwlab.event.v1 envelope"); + } + } +} + +function realtimeMonotonicOffsets(rows) { + const partitions = new Map(); + for (const row of rows) { + let offset; + try { offset = BigInt(String(row.offset)); } catch { return false; } + if (!Number.isFinite(row.partition)) return false; + const partition = row.partition; + const previous = partitions.get(partition); + if (previous !== undefined && offset <= previous) return false; + partitions.set(partition, offset); + } + return partitions.size > 0; +} + +function realtimeStrictlyIncreasing(values) { + for (let index = 1; index < values.length; index += 1) if (!(values[index] > values[index - 1])) return false; + return true; +} + +function realtimeForbiddenRequest(item, forbiddenPaths) { + const pathValue = String(item.path || "").toLowerCase(); + return forbiddenPaths.some((entry) => pathValue.includes(String(entry).toLowerCase())); +} + +function realtimeTurnSummary(turn, debug, product, terminal) { + const streams = {}; + for (const [stream, rowsRaw] of Object.entries(debug.records)) { + const rows = rowsRaw.filter((item) => item.traceId === turn.traceId); + streams[stream] = { + topic: debug.connected[stream]?.topic || rows[0]?.topic || null, + groupId: debug.connected[stream]?.groupId || null, + count: rows.length, + partitions: [...new Set(rows.map((item) => item.partition).filter(Number.isFinite))], + firstOffset: rows[0]?.offset || null, + lastOffset: rows.at(-1)?.offset || null, + eventTypes: [...new Set(rows.map((item) => item.eventType).filter(Boolean))], + firstFrameSeq: rows.map((item) => item.frameSeq).find(Number.isFinite) || null, + lastFrameSeq: rows.map((item) => item.frameSeq).filter(Number.isFinite).at(-1) || null, + openCount: debug.openCount?.[stream] || 0, + connectedEventCount: debug.connectedEventCount?.[stream] || 0, + errorCount: (debug.errors?.[stream] || []).length, + valuesRedacted: true, + }; + } + const productRows = product.records.filter((item) => item.traceId === turn.traceId); + return { + turn: turn.turn, + traceId: turn.traceId, + runId: turn.runId, + commandId: turn.commandId, + runnerId: turn.runnerId, + attemptId: turn.attemptId, + sessionId: turn.sessionId, + admissionStatus: turn.admissionStatus, + terminalStatus: terminal?.status || null, + streams, + 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, + }; +} + +async function realtimeCaptureSubscriberScreenshot(targetPage, reportDir, name, summary) { + await targetPage.evaluate((value) => { + document.documentElement.style.background = "#0b1020"; + document.body.innerHTML = ""; + const pre = document.createElement("pre"); + pre.style.cssText = "color:#d7e3ff;background:#111a30;padding:32px;border-radius:16px;font:18px/1.55 ui-monospace,monospace;white-space:pre-wrap;margin:40px;"; + pre.textContent = JSON.stringify(value, null, 2); + document.body.appendChild(pre); + }, summary); + const file = path.join(reportDir, name); + await targetPage.screenshot({ path: file, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs }); + const meta = await fileMeta(file); + const artifact = { kind: "realtime-fanout-screenshot", path: file, name, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true }; + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact }); + return artifact; +} + +async function realtimePartialProductEvidence(subscribers) { + const out = []; + for (const subscriber of subscribers.filter(Boolean)) { + const snapshot = await realtimeProductSnapshot(subscriber).catch(() => null); + if (!snapshot) continue; + out.push({ + label: subscriber.label, + openCount: snapshot.openCount, + connectedEventCount: snapshot.connectedEventCount, + errorCount: snapshot.errors.length, + eventCount: snapshot.records.length, + traceIds: [...new Set(snapshot.records.map((item) => item.traceId).filter(Boolean))].slice(0, 4), + terminalCount: snapshot.records.filter((item) => item.terminal === true).length, + connected: realtimeConnectedSummary(snapshot.connected), + valuesRedacted: true, + }); + } + return out; +} + +function realtimeDebugSnapshotSummary(snapshot) { + return { + key: snapshot.key || null, + streams: Object.fromEntries(Object.entries(snapshot.records || {}).map(([stream, rows]) => [stream, { + topic: snapshot.connected?.[stream]?.topic || null, + groupId: snapshot.connected?.[stream]?.groupId || null, + openCount: snapshot.openCount?.[stream] || 0, + connectedEventCount: snapshot.connectedEventCount?.[stream] || 0, + errorCount: (snapshot.errors?.[stream] || []).length, + eventCount: rows.length, + traceIds: [...new Set(rows.map((item) => item.traceId).filter(Boolean))].slice(0, 4), + valuesRedacted: true, + }])), + valuesRedacted: true, + }; +} + +async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots) { + const requestsFile = path.join(reportDir, "request-ledger.jsonl"); + const eventsFile = path.join(reportDir, "events.jsonl"); + await writeFile(requestsFile, network.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 }); + const eventRows = []; + for (const snapshot of debugSnapshots) { + for (const [stream, rows] of Object.entries(snapshot.records || {})) { + for (const row of rows) eventRows.push({ ...row, stream, valuesRedacted: true }); + } + } + await writeFile(eventsFile, eventRows.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 }); + const requestMeta = await fileMeta(requestsFile); + const eventMeta = await fileMeta(eventsFile); + const artifacts = { + requestLedger: { path: requestsFile, ...requestMeta, valuesRedacted: true }, + events: { path: eventsFile, ...eventMeta, count: eventRows.length, valuesRedacted: true }, + valuesRedacted: true, + }; + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger }); + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-events", commandId: activeCommandId, ...artifacts.events }); + return artifacts; +} + +async function realtimeWriteReport(reportDir, report) { + const reportFile = path.join(reportDir, "report.json"); + await writeFile(reportFile, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 }); + const meta = await fileMeta(reportFile); + const artifact = { kind: "realtime-fanout-report", path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true }; + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact }); + return artifact; +} + +async function realtimePoll(read, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value) return value; + await sleep(250); + } + throw new Error(label + " timed out after " + timeoutMs + "ms"); +} + +function realtimeFirstText(...values) { + for (const value of values) if (typeof value === "string" && value.trim()) return value.trim(); + return ""; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 8d9aaac7..771ca555 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -8,6 +8,7 @@ import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runn import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source"; import { nodeWebObserveRunnerSamplingSource } from "./hwlab-node-web-observe-runner-sampling-source"; import { nodeWebObserveRunnerUtilitySource } from "./hwlab-node-web-observe-runner-utility-source"; +import { nodeWebObserveRunnerRealtimeSource } from "./hwlab-node-web-observe-runner-realtime-source"; export function nodeWebObserveRunnerSource(): string { return String.raw`#!/usr/bin/env node @@ -42,6 +43,7 @@ const navigationMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_NAV const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON); const browserFreezePolicy = parseBrowserFreezePolicy(process.env.UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON); const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON); +const realtimeFanoutProfiles = parseRealtimeFanoutProfiles(process.env.UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON); const playwrightProxy = proxyConfigFromEnv(baseUrl); const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy); const pageId = "control-" + randomBytes(4).toString("hex"); @@ -185,6 +187,8 @@ ${nodeWebObserveRunnerCommandActionsSource()} ${nodeWebObserveRunnerWorkbenchSource()} +${nodeWebObserveRunnerRealtimeSource()} + ${nodeWebObserveRunnerSamplingSource()} ${nodeWebObserveRunnerUtilitySource()} diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index f8b3bb77..a3bc6ca8 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -129,6 +129,7 @@ export type NodeWebProbeObserveCommandType = | "gotoProjectMdtodo" | "newSession" | "sendPrompt" + | "validateRealtimeFanout" | "steer" | "cancel" | "selectProvider" @@ -206,10 +207,12 @@ export interface NodeWebProbeObserveOptions { force: boolean; commandType: NodeWebProbeObserveCommandType | null; commandText: string | null; + commandSecondText: string | null; commandPath: string | null; commandLabel: string | null; commandSessionId: string | null; commandProvider: string | null; + commandProfile: string | null; commandAfterRound: number | null; commandSeverity: string | null; commandAlternateSessionStrategy: string | null; diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index 3405bfae..18707b70 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -1858,6 +1858,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId)) || codeAgentRuntimeChanged;", @@ -2269,6 +2270,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector)));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers));", + " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId));", diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index 37e37dbb..572d0d19 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -3,6 +3,7 @@ import { test } from "bun:test"; import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render"; import { renderWebProbeScriptResult } from "./web-observe-render"; +import { webProbeScriptCommandGovernance } from "./web-observe-scripts"; test("observe command renderer separates control completion from async turn terminal state", () => { const rendered = withWebObserveCommandRendered({ @@ -24,7 +25,21 @@ test("observe command renderer separates control completion from async turn term }); test("web-probe script render warns to promote repeated scripts into commands", () => { + const commandPromotionHint = { + schemaVersion: "unidesk.web-probe.command-promotion.v1", + code: "prefer_repo_owned_typed_command", + promotionRequiredBeforeRepeat: true, + repoOwnedTypedCommand: false, + sourceKind: "stdin", + currentCommand: "web-probe script --node D601 --lane v03", + preferredArtifact: "repo-owned-typed-command", + preferredSurface: "web-probe observe command or a dedicated web-probe subcommand", + action: "promote-before-repeat", + message: "Before repeating this one-off script, promote the workflow to a repo-owned typed web-probe command.", + valuesRedacted: true, + }; const rendered = renderWebProbeScriptResult({ + commandPromotionHint, ok: true, status: "pass", command: "web-probe script --node D601 --lane v03", @@ -34,12 +49,15 @@ test("web-probe script render warns to promote repeated scripts into commands", warnings: [{ code: "web_probe_script_ad_hoc_only", severity: "warning", - message: "web-probe script is for one-off exploration; repeated or high-frequency checks must be promoted to a typed command instead of rerunning temporary scripts.", + requiredAction: "promote-before-repeat", + preferredArtifact: "repo-owned-typed-command", + message: "web-probe script is a one-off exploration escape hatch; before repeating it, promote the workflow to a repo-owned typed command instead of rerunning temporary scripts.", }], hints: [ - "Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows.", + "Before rerunning this script, promote the workflow to a repo-owned typed `web-probe` command.", ], preferredCommands: { + typedCommandCatalog: "bun scripts/cli.ts web-probe --help", startObserver: "bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo", mdtodoSummary: "bun scripts/cli.ts web-probe observe collect --view project-mdtodo-summary", }, @@ -51,10 +69,64 @@ test("web-probe script render warns to promote repeated scripts into commands", }); const text = String(rendered.renderedText ?? ""); + const marker = "UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT "; + const firstLine = text.split("\n", 1)[0] ?? ""; + assert.ok(firstLine.startsWith(marker)); + assert.deepEqual(JSON.parse(firstLine.slice(marker.length)), commandPromotionHint); + assert.equal(Object.keys(rendered)[0], "commandPromotionHint"); assert.match(text, /WARNINGS/u); assert.match(text, /web_probe_script_ad_hoc_only/u); assert.match(text, /HINTS/u); - assert.match(text, /web-probe observe command/u); + assert.match(text, /repo-owned typed `web-probe` command/u); + assert.match(text, /typedCommandCatalog: bun scripts\/cli\.ts web-probe --help/u); assert.match(text, /startObserver: bun scripts\/cli\.ts web-probe observe start/u); assert.match(text, /mdtodoSummary: bun scripts\/cli\.ts web-probe observe collect/u); }); + +test("web-probe command governance distinguishes ad-hoc scripts from repo-owned generated commands", () => { + const base = { + action: "script" as const, + node: "D601", + lane: "v03", + url: "https://hwlab.example.test", + timeoutMs: 30000, + viewport: "1920x1080", + browserProxyMode: "auto" as const, + commandTimeoutSeconds: 60, + scriptText: "return { ok: true };", + scriptSource: { + kind: "stdin" as const, + path: null, + byteCount: 20, + sha256: "sha256:fixture", + }, + }; + + const adHoc = webProbeScriptCommandGovernance(base); + assert.equal(adHoc.commandPromotionHint.promotionRequiredBeforeRepeat, true); + assert.equal(adHoc.commandPromotionHint.action, "promote-before-repeat"); + assert.equal(adHoc.warnings.length, 1); + assert.equal(adHoc.warnings[0]?.requiredAction, "promote-before-repeat"); + assert.match(adHoc.hints[0] ?? "", /repo-owned typed `web-probe` command/u); + assert.deepEqual(Object.keys(adHoc.preferredCommands)[0], "typedCommandCatalog"); + assert.ok(Object.values(adHoc.preferredCommands).every((command) => command.startsWith("bun scripts/cli.ts web-probe"))); + + const repoOwned = webProbeScriptCommandGovernance({ + ...base, + commandLabel: "web-probe opencode-smoke --node D601 --lane v03", + suppressAdHocWarning: true, + generatedHints: ["OpenCode smoke is a repo-owned typed web-probe command."], + generatedPreferredCommands: { + withExpectedText: "web-probe opencode-smoke --node D601 --lane v03 --expect-text ", + }, + scriptSource: { + ...base.scriptSource, + kind: "generated", + path: "builtin:opencode-smoke", + }, + }); + assert.equal(repoOwned.commandPromotionHint.promotionRequiredBeforeRepeat, false); + assert.equal(repoOwned.commandPromotionHint.action, "reuse-repo-owned-typed-command"); + assert.deepEqual(repoOwned.warnings, []); + assert.doesNotMatch(repoOwned.hints.join("\n"), /temporary script|promote-before-repeat/iu); +}); diff --git a/scripts/src/hwlab-node/web-observe-render.ts b/scripts/src/hwlab-node/web-observe-render.ts index 06fc9d34..22f0a30d 100644 --- a/scripts/src/hwlab-node/web-observe-render.ts +++ b/scripts/src/hwlab-node/web-observe-render.ts @@ -1353,6 +1353,9 @@ export function renderWebObserveStatusResult(result: Record): R const commandResult = record(result.result); const manifest = record(observer.manifest); const heartbeat = record(observer.heartbeat); + const exactCommand = record(observer.exactCommand); + const exactResult = record(exactCommand.result); + const exactError = record(exactCommand.error); const tails = record(observer.tails); const samples = webObserveArray(tails.samples); const network = webObserveArray(tails.network); @@ -1375,6 +1378,21 @@ export function renderWebObserveStatusResult(result: Record): R ], ), ]; + const exactCommandSection = Object.keys(exactCommand).length === 0 ? [] : [ + "", + webObserveTable( + ["COMMAND_ID", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], + [[ + exactCommand.commandId ?? "-", + exactCommand.type ?? "-", + exactCommand.phase ?? "-", + exactCommand.ok ?? "-", + exactResult.status ?? "-", + exactResult.reportSha256 ?? "-", + exactError.message ?? exactResult.reportPath ?? "-", + ]], + ), + ]; const renderedText = [ webObserveTable( ["OBSERVER", "NODE", "LANE", "STATUS", "ALIVE", "PID", "SAMPLE", "COMMAND", "UPDATED"], @@ -1395,6 +1413,7 @@ export function renderWebObserveStatusResult(result: Record): R ["artifacts", artifacts.length, record(artifacts[artifacts.length - 1]).sha256 ?? "-"], ], ), + ...exactCommandSection, ...resultSection, "", "NEXT", @@ -1421,13 +1440,16 @@ export function renderWebObserveCommandResult(result: Record): ["label", observerCommand.label ?? "-"], ["path", observerCommand.path ?? "-"], ["provider", observerCommand.provider ?? "-"], + ["profile", observerCommand.profile ?? "-"], ["sessionId", observerCommand.sessionId ?? "-"], ["text", observerCommand.textHash === null || observerCommand.textHash === undefined ? "-" : `${observerCommand.textBytes ?? "-"}B ${observerCommand.textHash}`], + ["secondText", observerCommand.secondTextHash === null || observerCommand.secondTextHash === undefined ? "-" : `${observerCommand.secondTextBytes ?? "-"}B ${observerCommand.secondTextHash}`], ], ), "", "NEXT", - ` status: bun scripts/cli.ts web-probe observe status ${id}`, + ` status: bun scripts/cli.ts web-probe observe status ${id} --command-id ${result.commandId}`, + ` result: bun scripts/cli.ts web-probe observe collect ${id} --file commands/done/${result.commandId}.json`, ` analyze: bun scripts/cli.ts web-probe observe analyze ${id}`, ].join("\n"); return withWebObserveRendered(result, renderedText); @@ -1626,6 +1648,7 @@ export function renderWebObserveAnalyzeResult(result: Record): } export function renderWebProbeScriptResult(result: Record): Record { + const commandPromotionHint = record(result.commandPromotionHint); const summary = record(result.summary); const issueEvidence = record(result.issueEvidence); const probe = record(result.probe); @@ -1656,6 +1679,8 @@ export function renderWebProbeScriptResult(result: Record): Rec ["exitCode", commandResult.exitCode ?? "-"], ]; const renderedText = [ + `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT ${JSON.stringify(commandPromotionHint)}`, + "", webObserveTable( ["WEB_PROBE_SCRIPT", "NODE", "LANE", "STATUS", "OK", "URL"], [[result.command ?? "web-probe script", result.node, result.lane, result.status, result.ok, result.url]], @@ -1711,7 +1736,7 @@ export function renderWebProbeScriptResult(result: Record): Rec ` rerun: ${result.command ?? `web-probe script --node ${result.node ?? "-"} --lane ${result.lane ?? "-"}`}`, ...Object.entries(preferredCommands).map(([name, command]) => ` ${name}: ${String(command)}`), ].join("\n"); - return withWebObserveRendered(result, renderedText); + return withWebObserveRendered(Object.assign({ commandPromotionHint }, result), renderedText); } export function webProbeScriptRecordRows(value: Record, limit: number): unknown[][] { diff --git a/scripts/src/hwlab-node/web-observe-scripts.test.ts b/scripts/src/hwlab-node/web-observe-scripts.test.ts new file mode 100644 index 00000000..19b03f13 --- /dev/null +++ b/scripts/src/hwlab-node/web-observe-scripts.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "bun:test"; + +import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts"; + +async function runStatusScript(stateDir: string, commandId: string): Promise> { + const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], { + env: { ...process.env, state_dir: stateDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert.equal(exitCode, 0, stderr); + return JSON.parse(stdout) as Record; +} + +test("observe status returns one exact completed command without prompt payloads", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-status-")); + const commandId = "cmd-fixture"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await mkdir(join(stateDir, "commands", "processing"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "validateRealtimeFanout", + completedAt: "2026-07-10T12:00:00.000Z", + result: { + ok: true, + status: "passed", + profile: "pure-kafka-live", + sessionId: "ses_fixture", + traceIds: ["trc_first", "trc_second"], + runIds: ["run_fixture"], + commandIds: ["cmd_runner_first", "cmd_runner_second"], + reportPath: "/tmp/report.json", + reportSha256: "sha256:fixture", + prompt: "must-not-leak", + }, + }) + "\n"); + await writeFile(join(stateDir, "commands", "processing", `${commandId}.json`), JSON.stringify({ + ok: null, + commandId, + type: "validateRealtimeFanout", + }) + "\n"); + + const status = await runStatusScript(stateDir, commandId); + assert.equal(status.exactCommand.phase, "done"); + assert.equal(status.exactCommand.type, "validateRealtimeFanout"); + assert.equal(status.exactCommand.result.reportSha256, "sha256:fixture"); + assert.equal(status.exactCommand.result.prompt, undefined); + assert.equal(status.exactCommand.valuesRedacted, true); +}); + +test("observe status keeps exact command not-found structured", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-missing-")); + const status = await runStatusScript(stateDir, "cmd-missing"); + assert.deepEqual(status.exactCommand, { + commandId: "cmd-missing", + phase: "not-found", + ok: false, + valuesRedacted: true, + }); +}); diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 5442ecfc..d0a44e39 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -36,12 +36,13 @@ import { compactCommandResultRedacted, nullableRecord, redactKnownSecrets, shell import { renderWebProbeScriptResult } from "./web-observe-render"; import { nodeWebProbeHostProxyEnv } from "./web-probe-observe"; -export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string): string { +export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string, commandId: string | null = null): string { return `node -e ${shellQuote(` const fs=require('fs'),path=require('path'); const dir=process.env.state_dir||process.argv[1]; const node=process.argv[2]; const lane=process.argv[3]; + const exactCommandId=process.argv[4]||''; const tailN=Math.min(${tailLines},3); const readJson=(name)=>{try{return JSON.parse(fs.readFileSync(path.join(dir,name),'utf8'))}catch{return null}}; const readJsonPath=(file)=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return null}}; @@ -65,6 +66,17 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, const ages=items.map((item)=>item.ageSeconds).filter((value)=>Number.isFinite(value)); return {count:entries.length,oldestAgeSeconds:ages.length?Math.max(...ages):null,items}; }; + const exactCommand=()=>{ + if(!exactCommandId)return null; + for(const bucket of ['done','failed','abandoned','processing','pending']){ + const parsed=readJsonPath(path.join(commandDir(bucket),exactCommandId+'.json')); + if(!parsed)continue; + const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null; + const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null; + return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,valuesRedacted:true}:null}:null,valuesRedacted:true}; + } + return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true}; + }; const pidText=fs.existsSync(path.join(dir,'pid'))?fs.readFileSync(path.join(dir,'pid'),'utf8').trim():null; let alive=false; if(pidText){try{process.kill(Number(pidText),0); alive=true}catch{}} const manifest=readJson('manifest.json'); @@ -82,8 +94,8 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, const commandsFailed=commandSummary('failed'); const diagnostics={heartbeatAgeSeconds,heartbeatStale,heartbeatStaleAfterSeconds:Math.round(staleAfterMs/1000),heartbeatUpdatedAt:updatedRaw,terminal,processAlive:alive,effectiveLiveness:heartbeatStale?'stale':alive?'alive':'not-running',commandBacklog:commandsPending.count+commandsProcessing.count,oldestPendingAgeSeconds:commandsPending.oldestAgeSeconds,oldestProcessingAgeSeconds:commandsProcessing.oldestAgeSeconds,valuesRedacted:true}; const commands={pendingCount:commandsPending.count,processingCount:commandsProcessing.count,abandonedCount:commandsAbandoned.count,failedCount:commandsFailed.count,pending:commandsPending.items,processing:commandsProcessing.items,abandoned:commandsAbandoned.items.slice(0,6),failed:commandsFailed.items.slice(0,6),valuesRedacted:true}; - console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir:dir,pid:pidText?Number(pidText):null,processAlive:alive,manifest:compactManifest(manifest),heartbeat:compactHeartbeat(heartbeat,diagnostics),diagnostics,commands,tails:{control:tailJsonl('control.jsonl').map(compactControl),samples:tailJsonl('samples.jsonl').map(compactSample),network:tailJsonl('network.jsonl').map(compactNetwork)},next:{command:'bun scripts/cli.ts web-probe observe command --node '+node+' --lane '+lane+' --state-dir '+dir+' --type mark --label checkpoint',stop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir,forceStop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir+' --force',analyze:'bun scripts/cli.ts web-probe observe analyze --node '+node+' --lane '+lane+' --state-dir '+dir},valuesRedacted:true})); - `)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)}`; + console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir:dir,pid:pidText?Number(pidText):null,processAlive:alive,manifest:compactManifest(manifest),heartbeat:compactHeartbeat(heartbeat,diagnostics),diagnostics,commands,exactCommand:exactCommand(),tails:{control:tailJsonl('control.jsonl').map(compactControl),samples:tailJsonl('samples.jsonl').map(compactSample),network:tailJsonl('network.jsonl').map(compactNetwork)},next:{command:'bun scripts/cli.ts web-probe observe command --node '+node+' --lane '+lane+' --state-dir '+dir+' --type mark --label checkpoint',stop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir,forceStop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir+' --force',analyze:'bun scripts/cli.ts web-probe observe analyze --node '+node+' --lane '+lane+' --state-dir '+dir},valuesRedacted:true})); + `)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)} ${shellQuote(commandId ?? "")}`; } export function nodeWebObserveForceStopNodeScript(reason: string, forcedByCommandId: string): string { @@ -385,6 +397,7 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number export function commandSummaryForOutput(payload: Record): Record { const text = typeof payload.text === "string" ? payload.text : null; + const secondText = typeof payload.secondText === "string" ? payload.secondText : null; const title = typeof payload.title === "string" ? payload.title : null; const body = typeof payload.body === "string" ? payload.body : null; const opaque = (value: unknown) => typeof value === "string" && value.length > 0 @@ -401,6 +414,7 @@ export function commandSummaryForOutput(payload: Record): Recor label: payload.label ?? null, sessionId: payload.sessionId ?? null, provider: payload.provider ?? null, + profile: payload.profile ?? null, durationMs: payload.durationMs ?? null, sourceId: opaque(payload.sourceId), fileRef: opaque(payload.fileRef), @@ -416,6 +430,8 @@ export function commandSummaryForOutput(payload: Record): Recor root: opaque(payload.root), textHash: text === null ? null : `sha256:${createHash("sha256").update(text).digest("hex")}`, textBytes: text === null ? null : Buffer.byteLength(text), + secondTextHash: secondText === null ? null : `sha256:${createHash("sha256").update(secondText).digest("hex")}`, + secondTextBytes: secondText === null ? null : Buffer.byteLength(secondText), valuesRedacted: true, }; } @@ -478,6 +494,7 @@ export function runNodeWebProbeScript( credential: Record, ): Record { const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`; + const commandGovernance = webProbeScriptCommandGovernance(options); const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); @@ -570,6 +587,7 @@ export function runNodeWebProbeScript( compactResult.stderrTail = redactKnownSecrets(result.stderr.slice(-2000), [material.password ?? ""]); } return renderWebProbeScriptResult({ + commandPromotionHint: commandGovernance.commandPromotionHint, ok: passed, status: passed ? "pass" : "blocked", command: commandLabel, @@ -593,9 +611,9 @@ export function runNodeWebProbeScript( stdoutRecovery: recoveredReport.stdoutRecovery ?? null, }, stdoutRecovery: stdoutResolution.diagnostics, - warnings: webProbeScriptGovernanceWarnings(options), - hints: webProbeScriptGovernanceHints(options), - preferredCommands: webProbeScriptPreferredCommands(options), + warnings: commandGovernance.warnings, + hints: commandGovernance.hints, + preferredCommands: commandGovernance.preferredCommands, recoveredArtifacts: recoveredArtifacts === null ? null : { source: recoveredArtifacts.source, degradedReason: recoveredArtifacts.degradedReason, @@ -607,34 +625,72 @@ export function runNodeWebProbeScript( }); } -function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record[] { +export function webProbeScriptCommandGovernance(options: NodeWebProbeScriptOptions): { + commandPromotionHint: Record; + warnings: Record[]; + hints: string[]; + preferredCommands: Record; +} { + return { + commandPromotionHint: webProbeScriptCommandPromotionHint(options), + warnings: webProbeScriptGovernanceWarnings(options), + hints: webProbeScriptGovernanceHints(options), + preferredCommands: webProbeScriptPreferredCommands(options), + }; +} + +export function webProbeScriptCommandPromotionHint(options: NodeWebProbeScriptOptions): Record { + const repoOwnedTypedCommand = options.suppressAdHocWarning === true; + const currentCommand = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`; + return { + schemaVersion: "unidesk.web-probe.command-promotion.v1", + code: "prefer_repo_owned_typed_command", + promotionRequiredBeforeRepeat: !repoOwnedTypedCommand, + repoOwnedTypedCommand, + sourceKind: options.scriptSource.kind, + currentCommand, + preferredArtifact: "repo-owned-typed-command", + preferredSurface: "web-probe observe command or a dedicated web-probe subcommand", + action: repoOwnedTypedCommand ? "reuse-repo-owned-typed-command" : "promote-before-repeat", + message: repoOwnedTypedCommand + ? "This probe is already a repo-owned typed command; reuse its public command instead of copying the generated script." + : "Before repeating this one-off script, promote the workflow to a repo-owned typed web-probe command.", + valuesRedacted: true, + }; +} + +export function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record[] { if (options.suppressAdHocWarning === true) return []; return [{ code: "web_probe_script_ad_hoc_only", severity: "warning", - message: "web-probe script is for one-off exploration; repeated or high-frequency checks must be promoted to a typed command instead of rerunning temporary scripts.", + requiredAction: "promote-before-repeat", + preferredArtifact: "repo-owned-typed-command", + preferredSurface: "web-probe observe command or a dedicated web-probe subcommand", + temporaryScriptScope: "one-off-exploration-only", + message: "web-probe script is a one-off exploration escape hatch; before repeating it, promote the workflow to a repo-owned typed command instead of rerunning temporary scripts.", node: options.node, lane: options.lane, valuesRedacted: true, }]; } -function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] { +export function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] { if (options.generatedHints !== undefined) return options.generatedHints; return [ + "Before rerunning this script, promote the workflow to a repo-owned typed `web-probe` command so auth, artifacts, output bounds and evidence contracts remain reusable.", "Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows; use `observe collect/analyze` for repeated evidence reads.", - "If the same script is needed more than once, add or extend a reusable command type in the web-probe observe command surface.", `For this target, start with: bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`, ]; } -function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record { +export function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record { if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands; return { + typedCommandCatalog: "bun scripts/cli.ts web-probe --help", startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`, mdtodoSummary: "bun scripts/cli.ts web-probe observe collect --view project-mdtodo-summary", analyze: "bun scripts/cli.ts web-probe observe analyze ", - addCommandType: "Add a repo-owned web-probe observe command type before repeating this script.", }; } diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index e754dbaf..c1318394 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -4,7 +4,30 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; -import { buildWebObserveCommandVisibility, resolveWebObserveActionJson } from "./web-probe-observe-actions"; +import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; +import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, resolveWebObserveActionJson } from "./web-probe-observe-actions"; + +test("realtime fanout runner contract derives topics, groups, and independent capabilities from owning YAML", () => { + const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01")); + const expectedKafka = profiles["pure-kafka-live"]?.expectedKafka as Record; + assert.deepEqual(expectedKafka.topics, { + stdio: "codex-stdio.raw.v1", + agentrun: "agentrun.event.v1", + hwlab: "hwlab.event.v1", + }); + assert.deepEqual(expectedKafka.groups, { + directPublish: "hwlab-v03-agentrun-event-direct-publish", + transactionalProjector: "hwlab-v03-agentrun-event-projector", + liveSse: "hwlab-v03-workbench-live-sse", + }); + assert.deepEqual(expectedKafka.capabilities, { + directPublish: true, + liveKafkaSse: true, + transactionalProjector: false, + projectionOutboxRelay: false, + projectionRealtime: false, + }); +}); test("web observe command visibility does not equate control completion with async turn completion", () => { const visibility = buildWebObserveCommandVisibility({ diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 59ee09d2..6c492ffe 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -17,6 +17,37 @@ import { commandSummaryForOutput, nodeWebObserveForceStopNodeScript, nodeWebObse type WebObserveActionJsonContract = "gc" | "start" | "status" | "command" | "force-stop"; +export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec): Record> { + const profiles = spec.webProbe?.realtimeFanoutProfiles ?? {}; + if (Object.keys(profiles).length === 0) return {}; + const kafka = spec.codeAgentRuntime?.kafkaEventBridge; + if (kafka === undefined) throw new Error("realtimeFanoutProfiles requires codeAgentRuntime.kafkaEventBridge in the owning YAML"); + const capabilities = Object.fromEntries(Object.entries(kafka.features).map(([name, enabled]) => [name, kafka.enabled && enabled])); + return Object.fromEntries(Object.entries(profiles).map(([id, profile]) => { + if (profile.businessEvent !== kafka.hwlabEventTopic) { + throw new Error(`realtimeFanoutProfiles.${id}.businessEvent must match codeAgentRuntime.kafkaEventBridge.hwlabEventTopic`); + } + return [id, { + ...profile, + expectedKafka: { + enabled: kafka.enabled, + topics: { + stdio: kafka.stdioTopic, + agentrun: kafka.agentRunEventTopic, + hwlab: kafka.hwlabEventTopic, + }, + groups: { + directPublish: kafka.directPublishConsumerGroupId, + transactionalProjector: kafka.transactionalProjectorConsumerGroupId, + liveSse: kafka.hwlabEventConsumerGroupId, + }, + capabilities, + valuesRedacted: true, + }, + }]; + })); +} + export function resolveWebObserveActionJson( result: { stdout: string; stderr?: string; exitCode?: number | null; timedOut?: boolean }, contract: WebObserveActionJsonContract, @@ -439,6 +470,7 @@ export function runNodeWebProbeObserveStart( `UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=${shellQuote(JSON.stringify(alertThresholds))}`, `UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON=${shellQuote(JSON.stringify(browserFreezePolicy))}`, `UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=${shellQuote(JSON.stringify(projectManagement))}`, + `UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON=${shellQuote(JSON.stringify(nodeWebProbeRealtimeFanoutProfiles(spec)))}`, ...(authLogin === null ? [] : [ @@ -583,7 +615,7 @@ export function readNodeWebProbeObserveRemoteStatus( const script = [ "set -eu", nodeWebObserveResolveStateDirShell(options), - nodeWebObserveStatusNodeScript(tailLines, options.node, options.lane), + nodeWebObserveStatusNodeScript(tailLines, options.node, options.lane, options.collectCommandId), ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, timeoutSeconds); const statusResolution = resolveWebObserveActionJson(result, "status"); @@ -668,6 +700,15 @@ export function buildWebObserveCommandVisibility(input: { export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record | RenderedCliResult { const type = options.commandType ?? (stopCommand ? "stop" : null); if (type === null) throw new Error("web-probe observe command requires --type"); + if (type === "validateRealtimeFanout") { + if (!options.commandProfile?.trim()) throw new Error("validateRealtimeFanout requires --profile"); + if (spec.webProbe?.realtimeFanoutProfiles?.[options.commandProfile.trim()] === undefined) { + throw new Error(`validateRealtimeFanout profile is not declared by the owning YAML: ${options.commandProfile.trim()}`); + } + if (!options.commandProvider?.trim()) throw new Error("validateRealtimeFanout requires --provider"); + if (!options.commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn"); + if (!options.commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn"); + } const commandId = `cmd-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; const payload = { id: commandId, @@ -676,9 +717,11 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption source: "cli", path: options.commandPath, text: options.commandText, + secondText: options.commandSecondText, label: options.commandLabel, sessionId: options.commandSessionId, provider: options.commandProvider, + profile: options.commandProfile, afterRound: options.commandAfterRound, severity: options.commandSeverity, alternateSessionStrategy: options.commandAlternateSessionStrategy, diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts index e429b73b..55776a33 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -124,10 +124,12 @@ function collectOptions(): NodeWebProbeObserveOptions { force: false, commandType: null, commandText: null, + commandSecondText: null, commandPath: null, commandLabel: null, commandSessionId: null, commandProvider: null, + commandProfile: null, commandAfterRound: null, commandSeverity: null, commandAlternateSessionStrategy: null, diff --git a/scripts/src/hwlab-node/web-probe-observe-options.test.ts b/scripts/src/hwlab-node/web-probe-observe-options.test.ts new file mode 100644 index 00000000..71a6a034 --- /dev/null +++ b/scripts/src/hwlab-node/web-probe-observe-options.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; +import { parseNodeWebProbeObserveOptions } from "./web-probe-observe"; + +const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); + +function parseRealtimeCommand(extra: string[]) { + return parseNodeWebProbeObserveOptions( + ["command", "--type", "validateRealtimeFanout", ...extra], + "NC01", + "v03", + spec, + "webobs-fixture", + null, + ); +} + +test("validateRealtimeFanout parses the YAML profile and two independent prompts", () => { + const options = parseRealtimeCommand([ + "--profile", "pure-kafka-live", + "--provider", "gpt.pika", + "--text", "first turn", + "--second-text", "second turn", + ]); + + assert.equal(options.commandType, "validateRealtimeFanout"); + assert.equal(options.commandProfile, "pure-kafka-live"); + assert.equal(options.commandProvider, "gpt.pika"); + assert.equal(options.commandText, "first turn"); + 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); +}); + +test("validateRealtimeFanout refuses body as a substitute for the second prompt", () => { + assert.throws( + () => parseRealtimeCommand([ + "--profile", "pure-kafka-live", + "--provider", "gpt.pika", + "--text", "first turn", + "--body", "not a second prompt", + ]), + /requires --second-text/u, + ); +}); + +test("validateRealtimeFanout rejects profiles absent from the owning YAML", () => { + assert.throws( + () => parseRealtimeCommand([ + "--profile", "projection-mode", + "--provider", "gpt.pika", + "--text", "first turn", + "--second-text", "second turn", + ]), + /profile is not declared by the owning YAML/u, + ); +}); diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index ccf60929..7ef0deae 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -419,10 +419,12 @@ export function parseNodeWebProbeObserveOptions( "--job-id", "--type", "--text", + "--second-text", "--path", "--label", "--session-id", "--provider", + "--profile", "--account-id", "--account", "--from-account-id", @@ -501,7 +503,10 @@ export function parseNodeWebProbeObserveOptions( throw new Error("web-probe observe command accepts either --text or --text-stdin, not both"); } const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption; + const commandSecondText = optionValue(args, "--second-text") ?? null; const commandSourceId = optionValue(args, "--source-id") ?? null; + const commandProfile = optionValue(args, "--profile") ?? null; + const commandProvider = optionValue(args, "--provider") ?? null; const commandAccountId = optionValue(args, "--account-id") ?? optionValue(args, "--account") ?? null; const commandFromAccountId = optionValue(args, "--from-account-id") ?? null; const commandToAccountId = optionValue(args, "--to-account-id") ?? null; @@ -540,6 +545,8 @@ export function parseNodeWebProbeObserveOptions( const commandBlocking = args.includes("--blocking") ? true : args.includes("--non-blocking") ? false : null; for (const [label, value] of [ ["--severity", commandSeverity], + ["--profile", commandProfile], + ["--second-text", commandSecondText], ["--alternate-session-strategy", commandAlternateSessionStrategy], ["--expected-sentinel-range", commandExpectedSentinelRange], ["--finding-id", commandFindingId], @@ -563,6 +570,15 @@ export function parseNodeWebProbeObserveOptions( ] as const) { if (value !== null && (value.includes("\0") || value.length > 500)) throw new Error(`unsafe web-probe observe ${label}: expected 1-500 non-NUL chars`); } + if (observeActionRaw === "command" && commandType === "validateRealtimeFanout") { + if (!commandProfile?.trim()) throw new Error("validateRealtimeFanout requires --profile"); + if (spec.webProbe?.realtimeFanoutProfiles?.[commandProfile.trim()] === undefined) { + throw new Error(`validateRealtimeFanout profile is not declared by the owning YAML: ${commandProfile.trim()}`); + } + if (!commandProvider?.trim()) throw new Error("validateRealtimeFanout requires --provider"); + if (!commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn"); + if (!commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn"); + } return { action: "observe", observeAction: observeActionRaw, @@ -606,10 +622,12 @@ export function parseNodeWebProbeObserveOptions( force: args.includes("--force"), commandType, commandText, + commandSecondText, commandPath: optionValue(args, "--path") ?? null, commandLabel: optionValue(args, "--label") ?? null, commandSessionId: optionValue(args, "--session-id") ?? null, - commandProvider: optionValue(args, "--provider") ?? null, + commandProvider, + commandProfile, commandAfterRound, commandSeverity, commandAlternateSessionStrategy, @@ -651,6 +669,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe || value === "goto" || value === "newSession" || value === "sendPrompt" + || value === "validateRealtimeFanout" || value === "steer" || value === "cancel" || value === "selectProvider" @@ -686,7 +705,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe || value === "mark" || value === "stop" ) return value; - throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`); + throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`); } export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode { diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 1315a6c2..b104e74e 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -1627,6 +1627,7 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled)); expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector))); expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers); + expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic); expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic); expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic); expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId);