diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index 778fa6eb..0c465c83 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -37,6 +37,10 @@ function renderWebObserveStatusTable(value: Record): string { const heartbeat = record(observer?.heartbeat); const diagnostics = record(observer?.diagnostics) ?? record(value.diagnostics); const commands = record(observer?.commands); + const exactCommand = record(observer?.exactCommand); + const exactResult = record(exactCommand?.result); + const exactError = record(exactCommand?.error); + const exactErrorDetails = record(exactError?.details); const tails = record(observer?.tails); const result = record(value.result); const samples = Array.isArray(tails?.samples) ? tails.samples.map(record).filter((item): item is Record => item !== null).slice(-5) : []; @@ -158,6 +162,19 @@ function renderWebObserveStatusTable(value: Record): string { ]]), "", ] : []), + ...(Object.keys(exactCommand).length > 0 ? [ + "Exact command:", + webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[ + webObserveShort(webObserveText(exactCommand.commandId), 40), + exactCommand.type, + exactCommand.phase, + exactCommand.ok, + exactResult.status, + webObserveShort(webObserveText(exactResult.reportSha256 ?? exactErrorDetails.reportSha256), 32), + webObserveShort(webObserveText(exactError.message ?? exactResult.reportPath ?? exactErrorDetails.reportPath), 96), + ]]), + "", + ] : []), "Recent samples:", webObserveTable(["SEQ", "TS", "PATH", "ROUTE_SESSION", "ACTIVE_SESSION", "MSG", "TRACE"], samples.length > 0 ? samples.map((sample) => [ sample.seq, @@ -255,7 +272,7 @@ function renderWebObserveCommandTable(value: Record): string { ] : []), "", "Next:", - ` bun scripts/cli.ts web-probe observe status ${id}`, + ` bun scripts/cli.ts web-probe observe status ${id} --command-id ${webObserveText(value.commandId)}`, ` bun scripts/cli.ts web-probe observe analyze ${id}`, ` bun scripts/cli.ts web-probe observe collect ${id} --view turn-summary --command-id ${webObserveText(value.commandId)}`, "", diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index 572d0d19..07015fc0 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render"; +import { withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render"; import { renderWebProbeScriptResult } from "./web-observe-render"; import { webProbeScriptCommandGovernance } from "./web-observe-scripts"; @@ -21,9 +21,51 @@ test("observe command renderer separates control completion from async turn term assert.match(text, /CONTROL.*TURN_SUBMIT.*TURN_TERMINAL/u); assert.match(text, /completed.*accepted.*not-observed.*trc_fixture/u); assert.match(text, /does not imply the asynchronous business turn reached a terminal state/u); + assert.match(text, /observe status webobs-fixture --command-id cmd-fixture/u); assert.match(text, /--view turn-summary --command-id cmd-fixture/u); }); +test("observe status renderer exposes the exact command phase and failed report evidence", () => { + const rendered = withWebObserveStatusRendered({ + ok: true, + status: "running", + command: "web-probe observe status", + id: "webobs-fixture", + node: "NC01", + lane: "v03", + observer: { + processAlive: true, + manifest: { baseUrl: "https://hwlab.example.test", targetPath: "/workbench" }, + heartbeat: { status: "running", sampleSeq: 12, commandSeq: 3, updatedAt: "2026-07-10T01:00:01.000Z" }, + diagnostics: { effectiveLiveness: "alive", heartbeatStale: false }, + commands: { pendingCount: 0, processingCount: 0, abandonedCount: 0, failedCount: 1 }, + exactCommand: { + commandId: "cmd-fixture", + type: "validateRealtimeFanout", + phase: "failed", + ok: false, + error: { + message: "terminal event arrived before subscriber disconnect", + details: { + reportPath: "artifacts/realtime-fanout/cmd-fixture/report.json", + reportSha256: "sha256:fixture-report", + }, + }, + }, + tails: { samples: [], control: [], network: [] }, + }, + next: { + status: "bun scripts/cli.ts web-probe observe status webobs-fixture", + }, + }); + + const text = String(rendered.renderedText); + assert.match(text, /Exact command:/u); + assert.match(text, /cmd-fixture.*validateRealtimeFanout.*failed.*false/u); + assert.match(text, /sha256:fixture-report/u); + assert.match(text, /terminal event arrived before subscriber disconnect/u); +}); + test("web-probe script render warns to promote repeated scripts into commands", () => { const commandPromotionHint = { schemaVersion: "unidesk.web-probe.command-promotion.v1", 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 55776a33..7db6f765 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test"; import type { CommandResult } from "../command"; import type { NodeWebProbeObserveOptions } from "./entry"; import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect"; +import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render"; describe("web-probe observe collect child JSON recovery", () => { test("recovers collect JSON from trans stdout dump", () => { @@ -80,6 +81,60 @@ test("propagates structured turn-summary not-found status without treating it as expect(collect.rows).toEqual([]); }); +test("collects an exact failed command artifact without treating artifact failure as collect failure", () => { + const options = collectOptions(); + options.collectView = "files"; + options.collectFile = "commands/failed/cmd-fixture.json"; + const failedArtifact = { + ok: false, + commandId: "cmd-fixture", + type: "validateRealtimeFanout", + failedAt: "2026-07-10T12:00:00.000Z", + error: { + name: "Error", + message: "terminal event arrived before subscriber disconnect", + details: { + reportPath: "artifacts/realtime-fanout/cmd-fixture/report.json", + reportSha256: "sha256:fixture-report", + sessionToken: "must-not-render", + }, + }, + failureSample: { ok: true, sampleSeq: 22, valuesRedacted: true }, + }; + const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, { + command: ["trans", "NC01:/workspace", "sh"], + cwd: "/repo", + exitCode: 0, + stdout: JSON.stringify(failedArtifact), + stderr: "", + signal: null, + timedOut: false, + } satisfies CommandResult); + + expect(payload.ok).toBe(true); + expect(payload.status).toBe("collected"); + expect(payload.degradedReason).toBeNull(); + const recovery = payload.stdoutRecovery as Record; + expect(recovery.source).toBe("stdout"); + expect(recovery.stdoutContractAccepted).toBe(true); + const collect = payload.collect as Record; + expect(collect.ok).toBe(true); + expect(collect.artifactOutcome).toEqual({ + ok: false, + commandId: "cmd-fixture", + type: "validateRealtimeFanout", + bucket: "failed", + valuesRedacted: true, + }); + expect(collect.file.jsonContent.commandId).toBe("cmd-fixture"); + expect(collect.file.jsonContent.error.message).toBe("terminal event arrived before subscriber disconnect"); + expect(collect.file.jsonContent.error.details.sessionToken).toBe("[redacted]"); + + const rendered = withWebObserveCollectRendered(payload); + expect(String(rendered.renderedText)).toContain("cmd-fixture"); + expect(String(rendered.renderedText)).toContain("terminal event arrived before subscriber disconnect"); +}); + function collectOptions(): NodeWebProbeObserveOptions { return { action: "observe", diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.ts b/scripts/src/hwlab-node/web-probe-observe-collect.ts index 79643a0c..b85568b4 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.ts @@ -1,5 +1,6 @@ // SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer. // Responsibility: web-probe observe collect action and child JSON recovery. +import { createHash } from "node:crypto"; import type { CommandResult } from "../command"; import { resolveCliChildJsonCommandResult } from "../cli-child-json-recovery"; import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect"; @@ -45,9 +46,9 @@ export function buildNodeWebProbeObserveCollectPayload( const stdoutResolution = resolveCliChildJsonCommandResult({ result, requestedStdoutType: "web-probe-observe-collect", - acceptParsed: isWebObserveCollectJsonContract, + acceptParsed: (value) => isWebObserveCollectJsonContract(value, options.collectFile), }); - const collect = stdoutResolution.parsed; + const collect = normalizeWebObserveCollectResult(stdoutResolution.parsed, options.collectFile, result.stdout, options.stateDir); const compactRaw = options.raw && options.compactRaw; const degradedReason = collect === null ? collectDegradedReason(stdoutResolution.diagnostics) @@ -85,10 +86,11 @@ export function buildNodeWebProbeObserveCollectPayload( }; } -export function isWebObserveCollectJsonContract(value: Record): boolean { +export function isWebObserveCollectJsonContract(value: Record, requestedFile: string | null = null): boolean { const command = stringOrNull(value.command); const view = stringOrNull(value.view); const stateDir = stringOrNull(value.stateDir); + if (commandArtifactBucket(value, requestedFile) !== null) return true; if (value.ok === false) { return command === "web-probe-observe collect" || view !== null @@ -100,6 +102,107 @@ export function isWebObserveCollectJsonContract(value: Record): && stateDir !== null; } +function normalizeWebObserveCollectResult( + value: Record | null, + requestedFile: string | null, + stdout: string, + stateDir: string | null, +): Record | null { + if (value === null) return null; + const bucket = commandArtifactBucket(value, requestedFile); + if (bucket === null || requestedFile === null) return value; + const jsonContent = compactCommandArtifact(value); + return { + ok: true, + command: "web-probe-observe collect", + stateDir, + mode: "file", + requestedFile, + resolvedFile: requestedFile, + fallbackReason: "terminal-command-artifact-stdout-normalized", + file: { + relative: requestedFile, + requestedRelative: requestedFile, + resolvedRelative: requestedFile, + fallbackReason: "terminal-command-artifact-stdout-normalized", + byteCount: Buffer.byteLength(stdout), + sha256: `sha256:${createHash("sha256").update(stdout).digest("hex")}`, + truncated: false, + contentTruncated: false, + jsonlTail: null, + jsonSummary: { + topLevelKeys: Object.keys(value).slice(0, 24), + commandArtifactBucket: bucket, + artifactOk: value.ok, + }, + jsonContent, + grep: null, + lineCount: stdout.split(/\r?\n/u).filter(Boolean).length, + }, + artifactOutcome: { + ok: value.ok, + commandId: value.commandId, + type: value.type, + bucket, + valuesRedacted: true, + }, + valuesRedacted: true, + }; +} + +function commandArtifactBucket(value: Record, requestedFile: string | null): "done" | "failed" | "abandoned" | null { + if (requestedFile === null) return null; + const match = requestedFile.match(/^commands\/(done|failed|abandoned)\/([A-Za-z0-9_.-]+)\.json$/u); + if (match === null || stringOrNull(value.commandId) !== match[2] || stringOrNull(value.type) === null) return null; + if (match[1] === "done") { + return value.ok === true && stringOrNull(value.completedAt) !== null && isRecord(value.result) ? "done" : null; + } + if (match[1] === "failed") { + return value.ok === false && stringOrNull(value.failedAt) !== null && isRecord(value.error) ? "failed" : null; + } + return value.ok === false && stringOrNull(value.abandonedAt) !== null && stringOrNull(value.reason) !== null ? "abandoned" : null; +} + +function compactCommandArtifact(value: Record): Record { + return { + ok: value.ok, + commandId: value.commandId, + type: value.type, + completedAt: value.completedAt ?? null, + failedAt: value.failedAt ?? null, + abandonedAt: value.abandonedAt ?? null, + reason: boundedRedactedValue(value.reason), + result: boundedRedactedValue(value.result), + error: boundedRedactedValue(value.error), + failureSample: boundedRedactedValue(value.failureSample), + valuesRedacted: true, + }; +} + +function boundedRedactedValue(value: unknown, depth = 0): unknown { + if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") return value ?? null; + if (typeof value === "string") return value.slice(0, 360); + if (depth >= 4) return Array.isArray(value) + ? { arrayLength: value.length } + : isRecord(value) + ? { keys: Object.keys(value).slice(0, 20) } + : String(value).slice(0, 180); + if (Array.isArray(value)) { + const items = value.slice(0, 12).map((item) => boundedRedactedValue(item, depth + 1)); + return value.length > 12 ? [...items, { omitted: value.length - 12 }] : items; + } + if (!isRecord(value)) return String(value).slice(0, 180); + const output: Record = {}; + const keys = Object.keys(value).slice(0, 40); + for (const key of keys) { + output[key] = /token|secret|password|passwd|authorization|cookie|api[-_]?key|session/iu.test(key) + ? "[redacted]" + : boundedRedactedValue(value[key], depth + 1); + } + if (Object.keys(value).length > keys.length) output.__omittedKeys = Object.keys(value).length - keys.length; + return output; +} + function collectDegradedReason(diagnostics: Record): string { const fallbackReason = stringOrNull(diagnostics.fallbackReason); if (fallbackReason === "stdout-json-contract-invalid") return "collect-stdout-json-contract-invalid"; @@ -112,3 +215,7 @@ function collectDegradedReason(diagnostics: Record): string { function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +}