// 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"; import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render"; import { buildWebObserveWrapperForObserveOptions } from "../hwlab-node-web-observe-wrapper"; import type { HwlabRuntimeLaneSpec } from "../hwlab-node-lanes"; import type { RenderedCliResult } from "../output"; import type { NodeWebProbeObserveOptions } from "./entry"; import { runTransWorkspaceStdinScript } from "./public-exposure"; import { compactCommandResult, compactCommandResultWithStdoutTail } from "./utils"; import { webObserveCommandLabel, webObserveIdFromOptions, nodeWebObserveResolveStateDirShell } from "./web-observe-render"; import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts"; import { compactObserveCollectForRaw } from "./web-observe-collect-compact"; export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record | RenderedCliResult { const collectScript = options.collectView === "files" ? nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding, options.collectGrep) : nodeWebObserveCollectViewNodeScript({ maxFiles: options.maxFiles, view: options.collectView, traceId: options.collectTraceId, sampleSeq: options.collectSampleSeq, timestamp: options.collectTimestamp, turn: options.collectTurn, commandId: options.collectCommandId, windowMs: options.collectWindowMs, }); const script = [ "set -eu", nodeWebObserveResolveStateDirShell(options), collectScript, ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const payload = buildNodeWebProbeObserveCollectPayload(options, spec, result); return options.raw ? payload : withWebObserveCollectRendered(payload); } export function buildNodeWebProbeObserveCollectPayload( options: NodeWebProbeObserveOptions, spec: Pick, result: CommandResult, ): Record { const stdoutResolution = resolveCliChildJsonCommandResult({ result, requestedStdoutType: "web-probe-observe-collect", acceptParsed: (value) => isWebObserveCollectJsonContract(value, options.collectFile), }); const collect = normalizeWebObserveCollectResult(stdoutResolution.parsed, options.collectFile, result.stdout, options.stateDir); const compactRaw = options.raw && options.compactRaw; const degradedReason = collect === null ? collectDegradedReason(stdoutResolution.diagnostics) : null; const collectStatus = collect === null ? null : stringOrNull(collect.status); return { ok: result.exitCode === 0 && collect !== null && collect.ok !== false, status: result.exitCode === 0 && collect !== null ? collect.ok === false ? collectStatus ?? "blocked" : collectStatus ?? "collected" : "blocked", command: webObserveCommandLabel("collect", options), id: webObserveIdFromOptions(options), node: options.node, lane: options.lane, workspace: spec.workspace, view: options.collectView, requestedFile: options.collectFile, requestedGrep: options.collectGrep, requestedCommandId: options.collectCommandId, requestedWindowMs: options.collectWindowMs, degradedReason, stdoutRecovery: stdoutResolution.diagnostics, collect: compactRaw ? compactObserveCollectForRaw(collect) : collect, wrapper: compactRaw ? { mode: "wrapper-only", action: "collect", node: options.node, lane: options.lane, id: webObserveIdFromOptions(options), stateDir: options.stateDir, valuesRedacted: true } : buildWebObserveWrapperForObserveOptions("collect", options, spec.workspace), result: compactRaw ? { exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr), stdoutRecovery: stdoutResolution.diagnostics } : collect === null ? { ...compactCommandResultWithStdoutTail(result), stdoutRecovery: stdoutResolution.diagnostics } : compactCommandResult(result), valuesRedacted: true, }; } export function isWebObserveCollectJsonContract(value: Record, requestedFile: string | null = null): boolean { const command = stringOrNull(value.command); const view = stringOrNull(value.view); const mode = stringOrNull(value.mode); const stateDir = stringOrNull(value.stateDir); if (commandArtifactBucket(value, requestedFile) !== null) return true; if (value.ok === false) { return command === "web-probe-observe collect" || view !== null || stringOrNull(value.reason) !== null || stringOrNull(value.error) !== null; } return command === "web-probe-observe collect" && (view !== null || mode === "file" || mode === "list") && 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"; if (fallbackReason === "stdout-trans-truncated" || fallbackReason === "stdout-ssh-truncated") return "collect-stdout-dump-unavailable"; if (fallbackReason === "stdout-dump-wrapper-unreadable") return "collect-stdout-dump-unreadable"; if (fallbackReason === "stdout-empty") return "collect-stdout-empty"; return "collect-stdout-json-unavailable"; } 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); }