feat: add web-probe observe control views (#848)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
+50
-415
@@ -10,7 +10,10 @@ import { classifySshTcpPoolFailure } from "./ssh";
|
||||
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "./hwlab-node-control-plane";
|
||||
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec } from "./hwlab-node-lanes";
|
||||
import { nodeWebProbeScriptRunnerSource } from "./hwlab-node-web-probe-runner-source";
|
||||
import { nodeWebObserveAnalyzerSource, nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source";
|
||||
import { nodeWebObserveAnalyzerSource } from "./hwlab-node-web-observe-analyzer-source";
|
||||
import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source";
|
||||
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "./hwlab-node-web-observe-collect";
|
||||
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "./hwlab-node-web-observe-render";
|
||||
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "./hwlab-node-help";
|
||||
import { compactWebProbeResult, compactWebProbeScriptResult } from "./hwlab-node-web-probe-summary";
|
||||
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "./hwlab-node-observability-promql";
|
||||
@@ -61,7 +64,7 @@ interface NodeWebProbeScriptOptions {
|
||||
}
|
||||
|
||||
type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop" | "collect" | "analyze";
|
||||
type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "newSession" | "sendPrompt" | "selectProvider" | "clickSession" | "screenshot" | "mark" | "stop";
|
||||
type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "newSession" | "sendPrompt" | "steer" | "cancel" | "selectProvider" | "clickSession" | "screenshot" | "mark" | "stop";
|
||||
|
||||
interface NodeWebProbeObserveOptions {
|
||||
action: "observe";
|
||||
@@ -81,9 +84,14 @@ interface NodeWebProbeObserveOptions {
|
||||
waitMs: number;
|
||||
tailLines: number;
|
||||
maxFiles: number;
|
||||
collectView: NodeWebProbeObserveCollectView;
|
||||
collectFile: string | null;
|
||||
collectFinding: string | null;
|
||||
collectGrep: string | null;
|
||||
collectTraceId: string | null;
|
||||
collectSampleSeq: number | null;
|
||||
collectTimestamp: string | null;
|
||||
collectTurn: number | null;
|
||||
analyzeArchivePrefix: string | null;
|
||||
analyzeTailSamples: number | null;
|
||||
full: boolean;
|
||||
@@ -7359,9 +7367,14 @@ function parseNodeWebProbeObserveOptions(
|
||||
"--wait-ms",
|
||||
"--tail-lines",
|
||||
"--max-files",
|
||||
"--view",
|
||||
"--file",
|
||||
"--finding",
|
||||
"--grep",
|
||||
"--trace-id",
|
||||
"--sample-seq",
|
||||
"--timestamp",
|
||||
"--turn",
|
||||
"--archive-prefix",
|
||||
"--tail-samples",
|
||||
"--state-dir",
|
||||
@@ -7379,12 +7392,23 @@ function parseNodeWebProbeObserveOptions(
|
||||
const jobId = optionValue(args, "--job-id") ?? observeId ?? indexed?.id ?? null;
|
||||
if (stateDir !== null && !isSafeWebObserveStateDir(stateDir)) throw new Error(`unsafe web-probe observe --state-dir: ${stateDir}`);
|
||||
if (jobId !== null && !isSafeWebObserveJobId(jobId)) throw new Error(`unsafe web-probe observe --job-id: ${jobId}`);
|
||||
const collectView = parseNodeWebProbeObserveCollectView(optionValue(args, "--view") ?? "files");
|
||||
const collectFile = optionValue(args, "--file") ?? null;
|
||||
if (collectFile !== null && !isSafeWebObserveCollectFile(collectFile)) throw new Error(`unsafe web-probe observe --file: ${collectFile}`);
|
||||
const collectFinding = optionValue(args, "--finding") ?? null;
|
||||
if (collectFinding !== null && !isSafeWebObserveFindingId(collectFinding)) throw new Error(`unsafe web-probe observe --finding: ${collectFinding}`);
|
||||
const collectGrep = optionValue(args, "--grep") ?? null;
|
||||
if (collectGrep !== null && (collectGrep.includes("\0") || collectGrep.length > 200)) throw new Error("unsafe web-probe observe --grep: expected 1-200 non-NUL chars");
|
||||
const collectTraceId = optionValue(args, "--trace-id") ?? null;
|
||||
if (collectTraceId !== null && !isSafeWebObserveTraceId(collectTraceId)) throw new Error(`unsafe web-probe observe --trace-id: ${collectTraceId}`);
|
||||
const collectSampleSeqRaw = optionValue(args, "--sample-seq") ?? null;
|
||||
const collectSampleSeq = collectSampleSeqRaw === null ? null : Number(collectSampleSeqRaw);
|
||||
if (collectSampleSeq !== null && (!Number.isInteger(collectSampleSeq) || collectSampleSeq < 1)) throw new Error("unsafe web-probe observe --sample-seq: expected positive integer");
|
||||
const collectTimestamp = optionValue(args, "--timestamp") ?? null;
|
||||
if (collectTimestamp !== null && (collectTimestamp.includes("\0") || collectTimestamp.length > 80 || !Number.isFinite(Date.parse(collectTimestamp)))) throw new Error("unsafe web-probe observe --timestamp: expected parseable timestamp");
|
||||
const collectTurnRaw = optionValue(args, "--turn") ?? null;
|
||||
const collectTurn = collectTurnRaw === null ? null : Number(collectTurnRaw);
|
||||
if (collectTurn !== null && (!Number.isInteger(collectTurn) || collectTurn < 1)) throw new Error("unsafe web-probe observe --turn: expected positive integer");
|
||||
const analyzeArchivePrefix = optionValue(args, "--archive-prefix") ?? null;
|
||||
if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`);
|
||||
const analyzeTailSamplesRaw = optionValue(args, "--tail-samples") ?? null;
|
||||
@@ -7422,9 +7446,14 @@ function parseNodeWebProbeObserveOptions(
|
||||
waitMs: positiveIntegerOption(args, "--wait-ms", 0, 600000),
|
||||
tailLines: positiveIntegerOption(args, "--tail-lines", 5, 200),
|
||||
maxFiles: positiveIntegerOption(args, "--max-files", 80, 5000),
|
||||
collectView,
|
||||
collectFile,
|
||||
collectFinding,
|
||||
collectGrep,
|
||||
collectTraceId,
|
||||
collectSampleSeq,
|
||||
collectTimestamp,
|
||||
collectTurn,
|
||||
analyzeArchivePrefix,
|
||||
analyzeTailSamples,
|
||||
full: args.includes("--full"),
|
||||
@@ -7447,13 +7476,15 @@ function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserve
|
||||
|| value === "goto"
|
||||
|| value === "newSession"
|
||||
|| value === "sendPrompt"
|
||||
|| value === "steer"
|
||||
|| value === "cancel"
|
||||
|| value === "selectProvider"
|
||||
|| value === "clickSession"
|
||||
|| value === "screenshot"
|
||||
|| value === "mark"
|
||||
|| value === "stop"
|
||||
) return value;
|
||||
throw new Error(`web-probe observe command --type must be login, preflight, goto, newSession, sendPrompt, selectProvider, clickSession, screenshot, mark, or stop; got ${value}`);
|
||||
throw new Error(`web-probe observe command --type must be login, preflight, goto, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, screenshot, mark, or stop; got ${value}`);
|
||||
}
|
||||
|
||||
function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
|
||||
@@ -8082,153 +8113,6 @@ function runNodeWebProbeObserveStatus(options: NodeWebProbeObserveOptions, spec:
|
||||
});
|
||||
}
|
||||
|
||||
function withWebObserveStatusRendered(value: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
ok: value.ok !== false,
|
||||
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe status",
|
||||
contentType: "text/plain",
|
||||
renderedText: renderWebObserveStatusTable(value),
|
||||
};
|
||||
}
|
||||
|
||||
function renderWebObserveStatusTable(value: Record<string, unknown>): string {
|
||||
const observer = record(value.observer);
|
||||
const manifest = record(observer?.manifest);
|
||||
const heartbeat = record(observer?.heartbeat);
|
||||
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<string, unknown> => item !== null).slice(-5) : [];
|
||||
const controls = Array.isArray(tails?.control) ? tails.control.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-5) : [];
|
||||
const completedControlIds = new Set(controls
|
||||
.filter((item) => item.phase === "completed" || item.phase === "failed")
|
||||
.map((item) => webObserveText(item.commandId))
|
||||
.filter((item) => item !== "-"));
|
||||
const activeControl = controls.slice().reverse().find((item) => item.phase === "started" && !completedControlIds.has(webObserveText(item.commandId))) ?? null;
|
||||
const activeControlAgeSeconds = activeControl === null ? null : Math.max(0, Math.round((Date.now() - Date.parse(webObserveText(activeControl.ts))) / 1000));
|
||||
const network = Array.isArray(tails?.network)
|
||||
? tails.network.map(record).filter((item): item is Record<string, unknown> => item !== null).filter((item) => {
|
||||
const status = typeof item.status === "number" ? item.status : null;
|
||||
return status === null || status >= 400 || item.failure !== null && item.failure !== undefined;
|
||||
}).slice(-5)
|
||||
: [];
|
||||
const next = record(value.next);
|
||||
const heartbeatError = record(heartbeat?.error) ?? record(manifest?.error);
|
||||
const heartbeatAuth = record(heartbeat?.auth) ?? record(heartbeatError?.auth);
|
||||
const manifestNetwork = record(manifest?.network);
|
||||
const manifestBrowser = record(manifestNetwork?.browser);
|
||||
const manifestProxy = record(manifestNetwork?.proxy);
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe status (${webObserveText(value.status)})`,
|
||||
"",
|
||||
webObserveTable(["ID", "NODE", "LANE", "ALIVE", "PID", "SAMPLE", "UPDATED", "TARGET"], [[
|
||||
webObserveText(value.id),
|
||||
webObserveText(value.node),
|
||||
webObserveText(value.lane),
|
||||
webObserveText(observer?.processAlive),
|
||||
webObserveText(observer?.pid),
|
||||
webObserveText(heartbeat?.sampleSeq),
|
||||
webObserveShort(webObserveText(heartbeat?.updatedAt ?? heartbeat?.lastSampleAt), 24),
|
||||
webObserveShort(`${webObserveText(manifest?.baseUrl)}${webObserveText(manifest?.targetPath) === "-" ? "" : webObserveText(manifest?.targetPath)}`, 52),
|
||||
]]),
|
||||
"",
|
||||
...(value.ok === false ? [
|
||||
"Blocked detail:",
|
||||
webObserveTable(["REASON", "EXIT", "TIMEOUT", "STDOUT", "STDERR"], [[
|
||||
webObserveShort(webObserveText(value.degradedReason), 48),
|
||||
webObserveText(result.exitCode),
|
||||
webObserveText(result.timedOut),
|
||||
webObserveShort(webObserveText(result.stdoutTail), 120),
|
||||
webObserveShort(webObserveText(result.stderr), 120),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
...(heartbeatError !== null ? [
|
||||
"Observer error:",
|
||||
webObserveTable(["STATUS", "RETRY", "EXHAUSTED", "BROWSER_PROXY", "MESSAGE"], [[
|
||||
webObserveText(heartbeat?.status ?? manifest?.status),
|
||||
webObserveText(heartbeatAuth?.lastRetryLabel),
|
||||
webObserveText(heartbeatAuth?.retryExhausted),
|
||||
webObserveShort(webObserveText(manifestBrowser?.proxyMode ?? manifestProxy?.enabled), 32),
|
||||
webObserveShort(webObserveText(heartbeatError.message ?? heartbeatAuth?.lastError), 120),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
...(heartbeatAuth !== null ? [
|
||||
"Auth progress:",
|
||||
webObserveTable(["PHASE", "RETRY", "DELAY_MS", "STATUS", "RETRYABLE", "COOKIE", "EXHAUSTED", "LAST_ERROR"], [[
|
||||
webObserveText(heartbeatAuth.phase),
|
||||
webObserveText(heartbeatAuth.lastRetryLabel),
|
||||
webObserveText(heartbeatAuth.retryDelayMs),
|
||||
webObserveText(heartbeatAuth.lastStatusText === undefined ? heartbeatAuth.lastStatus : `${webObserveText(heartbeatAuth.lastStatus)} ${webObserveText(heartbeatAuth.lastStatusText)}`),
|
||||
webObserveText(heartbeatAuth.retryable),
|
||||
webObserveText(heartbeatAuth.cookiePresent),
|
||||
webObserveText(heartbeatAuth.retryExhausted),
|
||||
webObserveShort(webObserveText(heartbeatAuth.lastError), 80),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
...(activeControl !== null ? [
|
||||
"Active command:",
|
||||
webObserveTable(["TYPE", "COMMAND", "AGE_S", "STARTED_AT", "DETAIL"], [[
|
||||
webObserveText(activeControl.type),
|
||||
webObserveShort(webObserveText(activeControl.commandId), 28),
|
||||
webObserveText(activeControlAgeSeconds),
|
||||
webObserveShort(webObserveText(activeControl.ts), 24),
|
||||
webObserveShort(webObserveText(activeControl.detail), 80),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
"Recent samples:",
|
||||
webObserveTable(["SEQ", "TS", "PATH", "ROUTE_SESSION", "ACTIVE_SESSION", "MSG", "TRACE"], samples.length > 0 ? samples.map((sample) => [
|
||||
webObserveText(sample.seq),
|
||||
webObserveShort(webObserveText(sample.ts), 24),
|
||||
webObserveShort(webObserveText(sample.path), 24),
|
||||
webObserveShort(webObserveText(sample.routeSessionId), 20),
|
||||
webObserveShort(webObserveText(sample.activeSessionId), 20),
|
||||
webObserveText(sample.messageCount),
|
||||
webObserveText(sample.traceRowCount),
|
||||
]) : [["-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Recent control:",
|
||||
webObserveTable(["SEQ", "TS", "PHASE", "TYPE", "COMMAND", "RETRY", "DETAIL"], controls.length > 0 ? controls.map((control) => [
|
||||
webObserveText(control.seq),
|
||||
webObserveShort(webObserveText(control.ts), 24),
|
||||
webObserveText(control.phase),
|
||||
webObserveText(control.type),
|
||||
webObserveShort(webObserveText(control.commandId), 28),
|
||||
webObserveText(control.retry),
|
||||
webObserveShort(webObserveText(control.detail), 80),
|
||||
]) : [["-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Network alerts:",
|
||||
webObserveTable(["TS", "METHOD", "STATUS", "TYPE", "URL_OR_FAILURE"], network.length > 0 ? network.map((item) => [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveText(item.method),
|
||||
webObserveText(item.status),
|
||||
webObserveText(item.type),
|
||||
webObserveShort(webObserveText(item.failure ?? item.url), 80),
|
||||
]) : [["-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
for (const key of ["status", "command", "stop", "analyze"] as const) {
|
||||
const command = webObserveText(next?.[key]);
|
||||
if (command !== "-") lines.push(` ${command}`);
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use observe collect/analyze for artifact-level drill-down.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function webObserveTable(headers: string[], rows: string[][]): string {
|
||||
const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? ""));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length)));
|
||||
return [
|
||||
headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(),
|
||||
...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function webObserveText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
@@ -8307,57 +8191,21 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
|
||||
});
|
||||
}
|
||||
|
||||
function withWebObserveCommandRendered(value: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
ok: value.ok !== false,
|
||||
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe command",
|
||||
contentType: "text/plain",
|
||||
renderedText: renderWebObserveCommandTable(value),
|
||||
};
|
||||
}
|
||||
|
||||
function renderWebObserveCommandTable(value: Record<string, unknown>): string {
|
||||
const observerCommand = record(value.observerCommand);
|
||||
const observer = record(value.observer);
|
||||
const result = record(observer?.result);
|
||||
const id = webObserveText(value.id);
|
||||
const full = value.full === true;
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe command (${webObserveText(value.status)})`,
|
||||
"",
|
||||
webObserveTable(["OBSERVER", "COMMAND", "TYPE", "STATUS", "TEXT_BYTES", "TEXT_HASH", "DETAIL"], [[
|
||||
id,
|
||||
webObserveText(value.commandId),
|
||||
webObserveText(observerCommand?.type),
|
||||
webObserveText(observer?.ok === false ? "failed" : observer?.completedAt !== undefined ? "completed" : value.status),
|
||||
webObserveText(observerCommand?.textBytes),
|
||||
webObserveShort(webObserveText(observerCommand?.textHash), 24),
|
||||
webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80),
|
||||
]]),
|
||||
...(full ? [
|
||||
"",
|
||||
"Full command result:",
|
||||
"```json",
|
||||
JSON.stringify(observer ?? {}, null, 2),
|
||||
"```",
|
||||
] : []),
|
||||
"",
|
||||
"Next:",
|
||||
` bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
|
||||
` bun scripts/cli.ts hwlab nodes web-probe observe analyze ${id}`,
|
||||
"",
|
||||
"Disclosure:",
|
||||
" default view is a bounded command result; pass --full to expose the complete command result JSON.",
|
||||
" use observe status/analyze for sampled state.",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const script = [
|
||||
"set -eu",
|
||||
nodeWebObserveResolveStateDirShell(options),
|
||||
nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding, options.collectGrep),
|
||||
collectScript,
|
||||
].join("\n");
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const collect = parseJsonObject(result.stdout);
|
||||
@@ -8369,6 +8217,7 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
|
||||
node: options.node,
|
||||
lane: options.lane,
|
||||
workspace: spec.workspace,
|
||||
view: options.collectView,
|
||||
requestedFile: options.collectFile,
|
||||
requestedGrep: options.collectGrep,
|
||||
degradedReason: collect === null ? "collect-json-parse-failed" : null,
|
||||
@@ -8378,224 +8227,6 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
|
||||
});
|
||||
}
|
||||
|
||||
function withWebObserveCollectRendered(value: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
ok: value.ok !== false,
|
||||
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe collect",
|
||||
contentType: "text/plain",
|
||||
renderedText: renderWebObserveCollectTable(value),
|
||||
};
|
||||
}
|
||||
|
||||
function renderWebObserveCollectTable(value: Record<string, unknown>): string {
|
||||
const collect = nullableRecord(value.collect);
|
||||
const result = record(value.result);
|
||||
const file = record(collect?.file);
|
||||
const files = Array.isArray(collect?.files) ? collect.files.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 20) : [];
|
||||
const jsonlTail = Array.isArray(file.jsonlTail) ? file.jsonlTail.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
|
||||
const jsonSummary = record(file.jsonSummary);
|
||||
const grepSummary = record(file.grep);
|
||||
const grepLines = Array.isArray(grepSummary?.lines) ? grepSummary.lines.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 40) : [];
|
||||
const jsonContentPreview = file.jsonContent === undefined || file.jsonContent === null ? "" : JSON.stringify(file.jsonContent, null, 2);
|
||||
const jsonRunnerErrors = Array.isArray(jsonSummary.runnerErrors) ? jsonSummary.runnerErrors.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
|
||||
const jsonFindings = Array.isArray(jsonSummary.findings) ? jsonSummary.findings.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
|
||||
const jsonFindingDetail = record(jsonSummary.findingDetail);
|
||||
const jsonFindingSamples = Array.isArray(jsonFindingDetail?.samples) ? jsonFindingDetail.samples.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 12) : [];
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
|
||||
"",
|
||||
webObserveTable(["ID", "NODE", "LANE", "MODE", "STATE_DIR"], [[
|
||||
webObserveText(value.id),
|
||||
webObserveText(value.node),
|
||||
webObserveText(value.lane),
|
||||
webObserveText(collect?.mode ?? "files"),
|
||||
webObserveShort(webObserveText(collect?.stateDir), 88),
|
||||
]]),
|
||||
"",
|
||||
];
|
||||
if (collect === null) {
|
||||
lines.push(
|
||||
"Collect command:",
|
||||
webObserveTable(["REQUESTED", "REASON", "EXIT", "TIMED_OUT", "STDOUT_BYTES", "STDOUT_TAIL", "STDERR"], [[
|
||||
webObserveShort(webObserveText(value.requestedFile), 64),
|
||||
webObserveShort(webObserveText(value.degradedReason), 40),
|
||||
webObserveText(result.exitCode),
|
||||
webObserveText(result.timedOut),
|
||||
webObserveText(result.stdoutBytes),
|
||||
webObserveShort(webObserveText(result.stdoutTail), 160),
|
||||
webObserveShort(webObserveText(result.stderr), 160),
|
||||
]]),
|
||||
"",
|
||||
"Disclosure:",
|
||||
" collect stdout was not valid JSON; fix the collect command or rerun with a narrower --file after the root cause is visible.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
if (collect.mode === "file") {
|
||||
lines.push(
|
||||
"File:",
|
||||
webObserveTable(["RELATIVE", "BYTES", "TRUNC", "SHA256"], [[
|
||||
webObserveShort(webObserveText(file.relative), 64),
|
||||
webObserveText(file.byteCount),
|
||||
webObserveText(file.truncated),
|
||||
webObserveShort(webObserveText(file.sha256), 28),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
if (Object.keys(jsonSummary).length > 0) {
|
||||
lines.push(
|
||||
"JSON summary:",
|
||||
webObserveTable(["KEYS", "COUNTS", "PARSE_ERROR"], [[
|
||||
webObserveShort(webObserveText(jsonSummary.topLevelKeys), 80),
|
||||
webObserveShort(webObserveText(jsonSummary.counts), 80),
|
||||
webObserveShort(webObserveText(jsonSummary.parseError), 80),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (grepSummary !== null) {
|
||||
lines.push(
|
||||
"Grep matches:",
|
||||
webObserveTable(["PATTERN_SHA", "MATCHES", "RETURNED", "TRUNC"], [[
|
||||
webObserveShort(webObserveText(grepSummary.patternSha256), 24),
|
||||
webObserveText(grepSummary.matchCount),
|
||||
webObserveText(grepSummary.returnedLineCount),
|
||||
webObserveText(grepSummary.truncated),
|
||||
]]),
|
||||
webObserveTable(["LINE", "MATCH", "TEXT"], grepLines.length > 0 ? grepLines.map((item) => [
|
||||
webObserveText(item.lineNo),
|
||||
webObserveText(item.match === true),
|
||||
webObserveShort(webObserveText(item.text), 360),
|
||||
]) : [["-", "-", "-"]]),
|
||||
"Grep context:",
|
||||
grepLines.length > 0
|
||||
? grepLines.map((item) => "- " + webObserveText(item.lineNo) + (item.match === true ? " * " : " ") + webObserveShort(webObserveText(item.text), 500)).join("\n")
|
||||
: "-",
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonContentPreview.length > 0) {
|
||||
lines.push("JSON content:", webObserveShort(jsonContentPreview, 2400), "");
|
||||
}
|
||||
if (jsonRunnerErrors.length > 0) {
|
||||
lines.push(
|
||||
"Runner errors:",
|
||||
webObserveTable(["TS", "TYPE", "ATTEMPTS", "READY", "MESSAGE"], jsonRunnerErrors.map((item) => [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveShort(webObserveText(item.type), 20),
|
||||
webObserveText(item.attemptCount),
|
||||
webObserveShort(webObserveText(item.lastReadinessReason), 24),
|
||||
webObserveShort(webObserveText(item.message ?? item.lastError), 96),
|
||||
])),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonFindings.length > 0) {
|
||||
lines.push(
|
||||
"Findings:",
|
||||
webObserveTable(["KIND", "SEVERITY", "COUNT", "SUMMARY"], jsonFindings.map((item) => [
|
||||
webObserveShort(webObserveText(item.kind ?? item.id ?? item.code), 48),
|
||||
webObserveText(item.severity ?? item.level),
|
||||
webObserveText(item.count ?? item.sampleCount),
|
||||
webObserveShort(webObserveText(item.summary ?? item.message), 96),
|
||||
])),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonFindingDetail !== null) {
|
||||
lines.push(
|
||||
"Finding detail:",
|
||||
webObserveTable(["ID", "SEVERITY", "COUNT", "SAMPLE_SOURCE", "SUMMARY"], [[
|
||||
webObserveShort(webObserveText(jsonFindingDetail.id), 48),
|
||||
webObserveText(jsonFindingDetail.severity),
|
||||
webObserveText(jsonFindingDetail.count),
|
||||
webObserveText(jsonFindingDetail.sampleSource),
|
||||
webObserveShort(webObserveText(jsonFindingDetail.summary), 96),
|
||||
]]),
|
||||
"",
|
||||
"Finding samples:",
|
||||
webObserveTable(["SEQ", "TS", "KIND", "FROM", "TO", "DELTA", "SAMPLE_S", "ALLOWED", "EXCESS", "SESSION/PATH", "TRACE", "DETAIL"], jsonFindingSamples.length > 0 ? jsonFindingSamples.map((item) => [
|
||||
webObserveText(item.seq ?? item.sampleSeq ?? item.fromSeq),
|
||||
webObserveShort(webObserveText(item.ts ?? item.sampleTs ?? item.fromTs), 24),
|
||||
webObserveShort(webObserveText(item.diffKind ?? item.kind ?? item.type ?? item.metric ?? item.anomaly), 28),
|
||||
webObserveShort(webObserveText(item.fromValue ?? item.from ?? item.control ?? item.controlValue ?? item.beforeValue), 28),
|
||||
webObserveShort(webObserveText(item.toValue ?? item.to ?? item.observer ?? item.observerValue ?? item.afterValue), 28),
|
||||
webObserveText(item.delta),
|
||||
webObserveText(item.sampleDeltaSeconds),
|
||||
webObserveText(item.allowedIncreaseSeconds),
|
||||
webObserveText(item.excessiveIncreaseSeconds),
|
||||
webObserveShort(webObserveText(item.sessionId ?? item.routeSessionId ?? item.activeSessionId ?? item.path ?? item.urlPath ?? item.url), 48),
|
||||
webObserveShort(webObserveText(item.trace ?? item.traceId ?? item.controlTraceId ?? item.observerTraceId), 48),
|
||||
webObserveShort(webObserveText(item.detail ?? item.summary ?? item.message ?? item.preview), 96),
|
||||
]) : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonlTail.length > 0) {
|
||||
lines.push(
|
||||
"JSONL tail:",
|
||||
webObserveTable(["TS", "ROLE", "TYPE", "COMMAND", "MSG", "TRACE", "TRACE_IDS", "MSG_STATUS", "LOAD", "ATTEMPTS", "READY", "DOM", "PATH", "MESSAGE"], jsonlTail.map((item) => {
|
||||
const error = record(item.error);
|
||||
const attempts = Array.isArray(error.attempts) ? error.attempts : [];
|
||||
const lastAttempt = attempts.length > 0 ? record(attempts[attempts.length - 1]) : {};
|
||||
const rawReadiness = record(lastAttempt.readiness ?? error.navigationReadiness);
|
||||
const readiness = record(rawReadiness.snapshot ?? rawReadiness);
|
||||
const domBits = [
|
||||
`shell=${readiness.workbenchShellVisible === true ? "Y" : "n"}`,
|
||||
`create=${readiness.sessionCreateVisible === true ? "Y" : "n"}`,
|
||||
`input=${readiness.commandInputPresent === true ? "Y" : "n"}`,
|
||||
`tab=${readiness.activeTabPresent === true ? "Y" : "n"}`,
|
||||
`login=${readiness.loginVisible === true ? "Y" : "n"}`,
|
||||
].join(" ");
|
||||
return [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveShort(webObserveText(item.pageRole ?? item.role), 12),
|
||||
webObserveShort(webObserveText(item.type), 20),
|
||||
webObserveShort(webObserveText(item.commandId), 28),
|
||||
webObserveText(item.messageCount),
|
||||
webObserveText(item.traceRowCount ?? item.traceEventCount),
|
||||
webObserveShort(webObserveText(item.traceIds), 48),
|
||||
webObserveShort(webObserveText(item.messageStatuses), 48),
|
||||
webObserveText(item.loadingCount),
|
||||
webObserveText(attempts.length),
|
||||
webObserveShort(webObserveText(readiness.reason), 24),
|
||||
webObserveShort(domBits, 48),
|
||||
webObserveShort(webObserveText(item.path ?? item.urlPath ?? item.url ?? item.currentUrl ?? item.status), 60),
|
||||
webObserveShort(webObserveText(error.message ?? item.message ?? item.text ?? item.preview), 96),
|
||||
];
|
||||
})),
|
||||
"",
|
||||
);
|
||||
} else if (typeof file.content === "string" && file.content.length > 0) {
|
||||
lines.push("Content preview:", webObserveShort(file.content, 4000), "");
|
||||
}
|
||||
} else {
|
||||
lines.push(
|
||||
"Files:",
|
||||
webObserveTable(["RELATIVE", "BYTES", "SHA256"], files.length > 0 ? files.map((item) => [
|
||||
webObserveShort(webObserveText(item.relative), 72),
|
||||
webObserveText(item.byteCount),
|
||||
webObserveShort(webObserveText(item.sha256), 28),
|
||||
]) : [["-", "-", "-"]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (value.ok === false) {
|
||||
lines.push(
|
||||
"Blocked detail:",
|
||||
webObserveTable(["REASON", "EXIT", "TIMEOUT", "STDERR"], [[
|
||||
webObserveShort(webObserveText(collect.reason ?? value.degradedReason), 48),
|
||||
webObserveText(result.exitCode),
|
||||
webObserveText(result.timedOut),
|
||||
webObserveShort(webObserveText(result.stderr), 120),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push("Disclosure:", " collect is bounded; use --file <relative> plus --finding <id> for id-specific drill-down; exact --grep <finding-id> also expands finding samples.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
|
||||
const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64");
|
||||
const alertThresholds = nodeWebProbeAlertThresholds(spec);
|
||||
@@ -10703,6 +10334,10 @@ function isSafeWebObserveCollectFile(value: string): boolean {
|
||||
&& /^[A-Za-z0-9_.\/-]+$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveTraceId(value: string): boolean {
|
||||
return /^trc_[A-Za-z0-9_-]{6,120}$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveFindingId(value: string): boolean {
|
||||
return value.length > 0
|
||||
&& value.length <= 120
|
||||
|
||||
Reference in New Issue
Block a user