223 lines
9.9 KiB
TypeScript
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);
|
|
}
|