Files
pikasTech-unidesk/scripts/src/hwlab-node/web-probe-observe-collect.ts
T
2026-07-11 16:10:03 +02:00

223 lines
9.9 KiB
TypeScript

// 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<string, unknown> | 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<HwlabRuntimeLaneSpec, "workspace">,
result: CommandResult,
): Record<string, unknown> {
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<string, unknown>, 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<string, unknown> | null,
requestedFile: string | null,
stdout: string,
stateDir: string | null,
): Record<string, unknown> | 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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
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<string, unknown> = {};
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, unknown>): 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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}