feat: add web-probe observe control views (#848)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -81,7 +81,11 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectProvider --provider codex-api",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type sendPrompt --text-stdin <<'EOF'\nlong prompt\nEOF",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type steer --text '继续观察当前 trace'",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type cancel",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe status webobs-xxxx",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view turn-summary",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view trace-frame --trace-id trc_xxx --sample-seq 42",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ waitWorkbenchReady, fetchJson, fetchApiMatrix, recordStep, collectText, safeEvaluate, screenshot }) => {\n const ready = await waitWorkbenchReady();\n const workspace = await fetchJson('/v1/workbench/workspace?projectId=prj_hwpod_workbench');\n const apiMatrix = await fetchApiMatrix(['/v1/workbench/workspace?projectId=prj_hwpod_workbench', '/auth/session']);\n const workspaceText = await collectText('#workspace');\n const evaluated = await safeEvaluate(({ a, b }) => ({ sum: a + b }), { a: 1, b: 2 });\n await screenshot('workbench.png');\n recordStep('workbench-summary', { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, apiMatrixOk: apiMatrix.ok });\n return { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, workspaceText, evaluated };\n};\nJS",
|
||||
],
|
||||
@@ -96,7 +100,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
"Issue-ready evidence is available under issueEvidence and summary.issueEvidence; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.",
|
||||
"observe sampling is passive by default: it records DOM summaries and natural page request/response/requestfailed events with observerInitiated=false; it does not actively fetch Workbench APIs, reload, switch sessions, route/intercept, or call repair helpers.",
|
||||
"observe start registers a local UniDesk-side observer id under .state/web-observe/index.json; after start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
|
||||
"observe command actions are explicit user/control actions and are appended to control.jsonl; use --type newSession/selectProvider/sendPrompt/goto/screenshot/mark/stop. For long prompts use sendPrompt --text-stdin; keep prompt text out of issue comments by citing textHash/textBytes.",
|
||||
"observe command actions are explicit user/control actions and are appended to control.jsonl; use --type newSession/selectProvider/sendPrompt/steer/cancel/goto/screenshot/mark/stop. steer/cancel reuse the Workbench composer path, not private backend APIs. For long prompts use sendPrompt/steer --text-stdin; keep prompt text out of issue comments by citing textHash/textBytes.",
|
||||
"observe collect --view turn-summary renders the multi-turn CLI reading layer from samples/control artifacts; --view trace-frame renders one sampled trace frame with a fixed Final Response block. collect views do not save a second source of truth.",
|
||||
"observe analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser.",
|
||||
"observe analyze scans every sampled DOM point, extracts Workbench timing text such as 总耗时/total and 最近 N 秒/分前, and writes a sample point vs turn timing report: each Markdown table row starts with the timestamp, followed by each turn's 总耗时(s) and 最近更新(s). Timing series are reported for post-processing/manual analysis instead of auto-judged from status tail output.",
|
||||
"observe analyze also reports visible “加载中” count, owner attribution, concurrent loading owners, and continuous visible segments; fixes must reduce real loading latency, not reveal incomplete content early to make this metric disappear.",
|
||||
|
||||
+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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
|
||||
// Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts.
|
||||
import { shellQuote } from "./ssh";
|
||||
|
||||
export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame";
|
||||
|
||||
export function parseNodeWebProbeObserveCollectView(value: string): NodeWebProbeObserveCollectView {
|
||||
if (value === "files" || value === "turn-summary" || value === "trace-frame") return value;
|
||||
throw new Error(`web-probe observe collect --view must be files, turn-summary, or trace-frame; got ${value}`);
|
||||
}
|
||||
|
||||
export function nodeWebObserveCollectViewNodeScript(input: {
|
||||
maxFiles: number;
|
||||
view: NodeWebProbeObserveCollectView;
|
||||
traceId: string | null;
|
||||
sampleSeq: number | null;
|
||||
timestamp: string | null;
|
||||
turn: number | null;
|
||||
}): string {
|
||||
const requestedSampleSeq = input.sampleSeq === null ? "null" : String(input.sampleSeq);
|
||||
const requestedTurn = input.turn === null ? "null" : String(input.turn);
|
||||
return `node -e ${shellQuote(`
|
||||
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
||||
const dir=process.argv[1]; const maxFiles=${input.maxFiles}; const view=${JSON.stringify(input.view)}; const requestedTraceId=${JSON.stringify(input.traceId)}; const requestedSampleSeq=${requestedSampleSeq}; const requestedTimestamp=${JSON.stringify(input.timestamp)}; const requestedTurn=${requestedTurn};
|
||||
const sha=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
|
||||
const short=(value,limit=96)=>{const text=String(value||'').replace(/\\s+/gu,' ').trim(); return text.length>limit?text.slice(0,Math.max(1,limit-1))+'...':text;};
|
||||
const textOf=(value)=>String(value?.text||value?.textPreview||value?.preview||'');
|
||||
const readJson=(rel)=>{try{return JSON.parse(fs.readFileSync(path.join(dir,rel),'utf8'))}catch{return null}};
|
||||
const readJsonl=(rel)=>{try{return fs.readFileSync(path.join(dir,rel),'utf8').split(/\\r?\\n/u).filter(Boolean).map((line)=>{try{return JSON.parse(line)}catch{return {parseError:true,rawHash:sha(line)}}})}catch{return []}};
|
||||
const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}};
|
||||
walk(dir);
|
||||
const files=out.slice(0,maxFiles).map(file=>{const st=fs.statSync(file); const hash=crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); return {path:file,relative:path.relative(dir,file),byteCount:st.size,sha256:'sha256:'+hash}});
|
||||
const totalBytes=files.reduce((sum,item)=>sum+item.byteCount,0);
|
||||
if(view==='files'){
|
||||
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,fileCount:files.length,totalBytes,files,valuesRedacted:true},null,2));
|
||||
process.exit(0);
|
||||
}
|
||||
const samples=readJsonl('samples.jsonl');
|
||||
const control=readJsonl('control.jsonl');
|
||||
const manifest=readJson('manifest.json')||{};
|
||||
function unique(values){return Array.from(new Set(values.filter(Boolean)));}
|
||||
function tsMs(value){const ms=Date.parse(String(value||'')); return Number.isFinite(ms)?ms:null}
|
||||
function promptCommands(){
|
||||
const map=new Map();
|
||||
for(const item of control){
|
||||
if((item.type!=='sendPrompt'&&item.type!=='steer')||(item.phase!=='started'&&item.phase!=='completed'&&item.phase!=='failed')) continue;
|
||||
const id=item.commandId||item.seq||String(map.size+1);
|
||||
const existing=map.get(id)||{};
|
||||
map.set(id,{...existing,...item,input:{...(existing.input||{}),...(item.input||{})},firstTs:existing.firstTs||item.ts,lastTs:item.ts});
|
||||
}
|
||||
return Array.from(map.values()).filter((item)=>tsMs(item.firstTs)!==null).sort((a,b)=>tsMs(a.firstTs)-tsMs(b.firstTs));
|
||||
}
|
||||
function segmentFor(index,prompts){
|
||||
const start=tsMs(prompts[index]?.firstTs); const end=index+1<prompts.length?tsMs(prompts[index+1].firstTs):Infinity;
|
||||
return samples.filter((sample)=>{const ms=tsMs(sample.ts); return ms!==null&&ms>=start&&ms<end});
|
||||
}
|
||||
function controlsFor(index,prompts){
|
||||
const start=tsMs(prompts[index]?.firstTs); const end=index+1<prompts.length?tsMs(prompts[index+1].firstTs):Infinity;
|
||||
return control.filter((item)=>{const ms=tsMs(item.ts); return ms!==null&&ms>=start&&ms<end});
|
||||
}
|
||||
function firstTraceId(text){const match=String(text||'').match(/\\btrc_[A-Za-z0-9_-]+\\b/u); return match?match[0]:null}
|
||||
function traceIdsFromSamples(items){
|
||||
const ids=[];
|
||||
for(const sample of items){
|
||||
for(const turn of Array.isArray(sample.turns)?sample.turns:[]) ids.push(turn.traceId);
|
||||
for(const row of Array.isArray(sample.traceRows)?sample.traceRows:[]) ids.push(row.traceId||firstTraceId(textOf(row)));
|
||||
for(const message of Array.isArray(sample.messages)?sample.messages:[]) ids.push(message.traceId||firstTraceId(textOf(message)));
|
||||
}
|
||||
return unique(ids);
|
||||
}
|
||||
function parseElapsed(text){
|
||||
const values=[]; const raw=String(text||'');
|
||||
for(const m of raw.matchAll(/(?:耗时|总耗时|total\\s*=?)\\s*(?:(\\d+)\\s*(?:小时|h)\\s*)?(?:(\\d+)\\s*(?:分|分钟|m)\\s*)?(?:(\\d+)\\s*(?:秒|s))?/giu)){
|
||||
const total=Number(m[1]||0)*3600+Number(m[2]||0)*60+Number(m[3]||0); if(total>0) values.push(total);
|
||||
}
|
||||
for(const m of raw.matchAll(/\\b(?:total|elapsed)\\s*=\\s*(?:(\\d{1,2}):)?(\\d{1,2}):(\\d{2})\\b/giu)){
|
||||
values.push(Number(m[1]||0)*3600+Number(m[2]||0)*60+Number(m[3]||0));
|
||||
}
|
||||
return values.length?Math.max(...values):null;
|
||||
}
|
||||
function parseRecent(text){const m=String(text||'').match(/最近\\s*(\\d+)\\s*(秒|分钟|分|小时)前/u); if(!m)return null; const n=Number(m[1]); return m[2]==='小时'?n*3600:m[2]==='秒'?n:n*60}
|
||||
function fmtDuration(seconds){if(seconds===null||seconds===undefined||!Number.isFinite(Number(seconds)))return '-'; const value=Math.max(0,Math.round(Number(seconds))); const h=Math.floor(value/3600), m=Math.floor((value%3600)/60), s=value%60; return (h>0?String(h).padStart(2,'0')+':':'')+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0')}
|
||||
function terminalText(text){return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|completed|failed|canceled|cancelled|terminal/iu.test(String(text||''))}
|
||||
function statusFor(items){
|
||||
const texts=items.flatMap((sample)=>[...(Array.isArray(sample.turns)?sample.turns:[]),...(Array.isArray(sample.traceRows)?sample.traceRows:[]),...(Array.isArray(sample.messages)?sample.messages:[])].map(textOf));
|
||||
const joined=texts.join('\\n');
|
||||
const lastTurn=[].concat(...items.map((sample)=>Array.isArray(sample.turns)?sample.turns:[])).slice(-1)[0]||null;
|
||||
const raw=String(lastTurn?.status||'').toLowerCase();
|
||||
if(/cancel/iu.test(joined)||raw.includes('cancel'))return 'canceled';
|
||||
if(/failed|error|agentrun:error/iu.test(joined)||raw.includes('fail'))return 'failed';
|
||||
if(texts.some(terminalText)||raw.includes('complete'))return 'completed';
|
||||
if(/running|运行中|Code Agent\\s*耗时|最近\\s*\\d+/iu.test(joined)||raw.includes('running'))return 'running';
|
||||
return 'unknown';
|
||||
}
|
||||
function userMessageFor(items,prompt){
|
||||
const hash=prompt?.input?.textHash||null;
|
||||
for(const sample of items){
|
||||
for(const message of Array.isArray(sample.messages)?sample.messages:[]){
|
||||
if(hash&&message.textHash===hash)return {preview:short(textOf(message),90),textHash:message.textHash||hash,textBytes:message.textBytes??prompt?.input?.textBytes??null};
|
||||
}
|
||||
}
|
||||
return {preview:short(prompt?.input?.textPreview||'',90)||'(hash only)',textHash:hash,textBytes:prompt?.input?.textBytes??null};
|
||||
}
|
||||
function finalResponseFor(items,traceId){
|
||||
const turns=[].concat(...items.map((sample)=>Array.isArray(sample.turns)?sample.turns:[]));
|
||||
const candidates=(traceId?turns.filter((turn)=>turn.traceId===traceId):turns).slice().reverse();
|
||||
for(const turn of candidates){
|
||||
const status=String(turn.status||'').toLowerCase(); const text=textOf(turn);
|
||||
if(status.includes('running')&&!terminalText(text))continue;
|
||||
if(!terminalText(text)&&!status.match(/complete|fail|cancel|block/u))continue;
|
||||
const preview=short(text,180);
|
||||
return preview?{preview,textHash:turn.textHash||sha(text),textBytes:turn.textBytes??Buffer.byteLength(text),empty:false}:{preview:'(空内容)',textHash:null,textBytes:0,empty:true};
|
||||
}
|
||||
return {preview:'(空内容)',textHash:null,textBytes:0,empty:true};
|
||||
}
|
||||
function turnSummaryRows(){
|
||||
const prompts=promptCommands();
|
||||
if(prompts.length===0){
|
||||
const allTraceIds=traceIdsFromSamples(samples);
|
||||
return [{round:0,commandId:null,commandType:null,userPreview:'(无 sendPrompt/steer control 记录)',userHash:null,userBytes:null,traceId:allTraceIds[0]||null,status:statusFor(samples),elapsedSeconds:null,recentUpdateSeconds:null,marks:'-',firstSeq:samples[0]?.seq??null,lastSeq:samples[samples.length-1]?.seq??null,lastTs:samples[samples.length-1]?.ts??null,finalResponse:finalResponseFor(samples,allTraceIds[0]||null),valuesRedacted:true}];
|
||||
}
|
||||
return prompts.map((prompt,index)=>{
|
||||
const segment=segmentFor(index,prompts); const segmentControls=controlsFor(index,prompts); const ids=traceIdsFromSamples(segment); const traceId=ids[0]||null; const user=userMessageFor(segment,prompt);
|
||||
const texts=segment.flatMap((sample)=>[...(Array.isArray(sample.turns)?sample.turns:[]),...(Array.isArray(sample.traceRows)?sample.traceRows:[]),...(Array.isArray(sample.messages)?sample.messages:[])].map(textOf));
|
||||
const elapsedValues=texts.map(parseElapsed).filter((value)=>value!==null); const recentValues=texts.map(parseRecent).filter((value)=>value!==null);
|
||||
const marks=unique([prompt.type==='steer'?'steer':null,...segmentControls.map((item)=>item.type==='cancel'?'cancel':item.type==='steer'?'steer':null)]).join(',')||'-';
|
||||
return {round:index+1,commandId:prompt.commandId||null,commandType:prompt.type||null,userPreview:user.preview,userHash:user.textHash,userBytes:user.textBytes,traceId,status:statusFor(segment),elapsedSeconds:elapsedValues.length?Math.max(...elapsedValues):null,recentUpdateSeconds:recentValues.length?Math.max(...recentValues):null,marks,firstSeq:segment[0]?.seq??null,lastSeq:segment[segment.length-1]?.seq??null,lastTs:segment[segment.length-1]?.ts??null,finalResponse:finalResponseFor(segment,traceId),valuesRedacted:true};
|
||||
});
|
||||
}
|
||||
function pad(value,width){const text=short(value,width); return text+Array(Math.max(0,width-text.length)+1).join(' ')}
|
||||
function renderTurnSummary(rows){
|
||||
const title='Workbench Session '+(samples[samples.length-1]?.routeSessionId||samples[samples.length-1]?.activeSessionId||'-')+' observer '+(manifest.jobId||'-');
|
||||
const header='轮次 用户消息 Trace 状态 耗时/最近 标记 Final Response';
|
||||
const body=rows.map((row)=>pad(row.round,5)+' '+pad((row.userHash?row.userHash.slice(0,18)+' ':'')+row.userPreview,32)+' '+pad(row.traceId||'-',12)+' '+pad(row.status,10)+' '+pad(fmtDuration(row.elapsedSeconds)+'/'+(row.recentUpdateSeconds===null?'-':String(row.recentUpdateSeconds)+'s'),13)+' '+pad(row.marks,11)+' '+short(row.finalResponse?.preview||'(空内容)',80)).join('\\n');
|
||||
return [title,'=======================================================',header,body||'(空内容)','', 'NEXT', ' detail: bun scripts/cli.ts hwlab nodes web-probe observe collect '+(manifest.jobId||'<observer>')+' --view trace-frame --trace-id <traceId> --sample-seq <seq>'].join('\\n');
|
||||
}
|
||||
function selectSample(rows){
|
||||
if(requestedSampleSeq!==null){const exact=samples.find((sample)=>Number(sample.seq)===requestedSampleSeq); if(exact)return exact;}
|
||||
if(requestedTimestamp){const target=tsMs(requestedTimestamp); if(target!==null){const before=samples.filter((sample)=>{const ms=tsMs(sample.ts); return ms!==null&&ms<=target}).slice(-1)[0]; if(before)return before;}}
|
||||
if(requestedTurn!==null&&rows[requestedTurn-1]?.lastSeq!==null){const byTurn=samples.find((sample)=>Number(sample.seq)===Number(rows[requestedTurn-1].lastSeq)); if(byTurn)return byTurn;}
|
||||
if(requestedTraceId){const byTrace=samples.filter((sample)=>traceIdsFromSamples([sample]).includes(requestedTraceId)).slice(-1)[0]; if(byTrace)return byTrace;}
|
||||
return samples[samples.length-1]||null;
|
||||
}
|
||||
function renderTraceFrame(sample,rows){
|
||||
if(!sample)return {ok:false,renderedText:'TRACE_FRAME_BLOCKER\\nno sample matched --sample-seq/--timestamp/--trace-id/--turn',blocker:'sample-not-found'};
|
||||
const sampleTraceIds=traceIdsFromSamples([sample]); const traceId=requestedTraceId||sampleTraceIds[0]||rows.find((row)=>row.lastSeq===sample.seq)?.traceId||null;
|
||||
const traceRows=(Array.isArray(sample.traceRows)?sample.traceRows:[]).filter((row)=>!traceId||row.traceId===traceId||!row.traceId||textOf(row).includes(traceId));
|
||||
const turns=(Array.isArray(sample.turns)?sample.turns:[]).filter((turn)=>!traceId||turn.traceId===traceId||textOf(turn).includes(traceId));
|
||||
const texts=[...turns.map(textOf),...traceRows.map(textOf)];
|
||||
const elapsed=Math.max(-1,...texts.map(parseElapsed).filter((value)=>value!==null)); const recent=Math.max(-1,...texts.map(parseRecent).filter((value)=>value!==null));
|
||||
const status=statusFor([sample]); const finalResponse=finalResponseFor([sample],traceId);
|
||||
const visibleTraceRows=traceRows.slice(-24);
|
||||
const rowLines=visibleTraceRows.map((row,index)=>{const text=textOf(row); return short((row.status?row.status+' ':'')+text,180)||('row#'+index+' '+(row.textHash||'-'));});
|
||||
if(traceRows.length>visibleTraceRows.length) rowLines.unshift('(已省略 '+(traceRows.length-visibleTraceRows.length)+' 条较早 trace rows;需要原始数据请看 samples.jsonl)');
|
||||
const visibleTurns=turns.slice(-6);
|
||||
const assistantLines=visibleTurns.map((turn)=>'助手消息 '+(turn.status||'-')+' '+short(textOf(turn),180));
|
||||
if(turns.length>visibleTurns.length) assistantLines.unshift('(已省略 '+(turns.length-visibleTurns.length)+' 条较早 assistant turn summaries)');
|
||||
const rendered=['Code Agent 耗时 '+(elapsed>=0?fmtDuration(elapsed):'-')+' 最近 '+(recent>=0?String(recent)+' 秒前':'-')+' ('+status+')','=======================================================','sample seq='+(sample.seq??'-')+' ts='+(sample.ts||'-')+' traceId='+(traceId||'-')+' routeSession='+(sample.routeSessionId||'-')+' activeSession='+(sample.activeSessionId||'-'),...(rowLines.length?rowLines:['(无 trace rows;这是 blocker,不能当业务通过证据)']),...(assistantLines.length?assistantLines:[]),'==========================','Final Response',finalResponse.preview||'(空内容)'].join('\\n');
|
||||
return {ok:rowLines.length>0,renderedText:rendered,blocker:rowLines.length>0?null:'trace-rows-missing',sampleSeq:sample.seq??null,traceId,finalResponse,valuesRedacted:true};
|
||||
}
|
||||
const rows=turnSummaryRows();
|
||||
if(view==='turn-summary'){
|
||||
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,turnCount:rows.length,rows:rows.slice(0,80),renderedText:renderTurnSummary(rows),sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2));
|
||||
process.exit(0);
|
||||
}
|
||||
const frame=renderTraceFrame(selectSample(rows),rows);
|
||||
console.log(JSON.stringify({ok:frame.ok!==false,command:'web-probe-observe collect',view,stateDir:dir,...frame,sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2));
|
||||
`)} "$state_dir"`;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||||
// Responsibility: CLI text rendering for hwlab nodes web-probe observe status/command/collect.
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
export 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),
|
||||
};
|
||||
}
|
||||
|
||||
export 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),
|
||||
};
|
||||
}
|
||||
|
||||
export 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 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"], [[
|
||||
value.id,
|
||||
value.node,
|
||||
value.lane,
|
||||
observer?.processAlive,
|
||||
observer?.pid,
|
||||
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),
|
||||
result.exitCode,
|
||||
result.timedOut,
|
||||
webObserveShort(webObserveText(result.stdoutTail), 120),
|
||||
webObserveShort(webObserveText(result.stderr), 120),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
...(heartbeatError !== null ? [
|
||||
"Observer error:",
|
||||
webObserveTable(["STATUS", "RETRY", "EXHAUSTED", "BROWSER_PROXY", "MESSAGE"], [[
|
||||
heartbeat?.status ?? manifest?.status,
|
||||
heartbeatAuth?.lastRetryLabel,
|
||||
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"], [[
|
||||
heartbeatAuth.phase,
|
||||
heartbeatAuth.lastRetryLabel,
|
||||
heartbeatAuth.retryDelayMs,
|
||||
heartbeatAuth.lastStatusText === undefined ? heartbeatAuth.lastStatus : `${webObserveText(heartbeatAuth.lastStatus)} ${webObserveText(heartbeatAuth.lastStatusText)}`,
|
||||
heartbeatAuth.retryable,
|
||||
heartbeatAuth.cookiePresent,
|
||||
heartbeatAuth.retryExhausted,
|
||||
webObserveShort(webObserveText(heartbeatAuth.lastError), 80),
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
...(activeControl !== null ? [
|
||||
"Active command:",
|
||||
webObserveTable(["TYPE", "COMMAND", "AGE_S", "STARTED_AT", "DETAIL"], [[
|
||||
activeControl.type,
|
||||
webObserveShort(webObserveText(activeControl.commandId), 28),
|
||||
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) => [
|
||||
sample.seq,
|
||||
webObserveShort(webObserveText(sample.ts), 24),
|
||||
webObserveShort(webObserveText(sample.path), 24),
|
||||
webObserveShort(webObserveText(sample.routeSessionId), 20),
|
||||
webObserveShort(webObserveText(sample.activeSessionId), 20),
|
||||
sample.messageCount,
|
||||
sample.traceRowCount,
|
||||
]) : [["-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Recent control:",
|
||||
webObserveTable(["SEQ", "TS", "PHASE", "TYPE", "COMMAND", "RETRY", "DETAIL"], controls.length > 0 ? controls.map((control) => [
|
||||
control.seq,
|
||||
webObserveShort(webObserveText(control.ts), 24),
|
||||
control.phase,
|
||||
control.type,
|
||||
webObserveShort(webObserveText(control.commandId), 28),
|
||||
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),
|
||||
item.method,
|
||||
item.status,
|
||||
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 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,
|
||||
value.commandId,
|
||||
observerCommand?.type,
|
||||
observer?.ok === false ? "failed" : observer?.completedAt !== undefined ? "completed" : value.status,
|
||||
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 renderWebObserveCollectTable(value: Record<string, unknown>): string {
|
||||
const collect = nullableRecord(value.collect);
|
||||
if (typeof collect?.renderedText === "string") {
|
||||
return [
|
||||
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
|
||||
"",
|
||||
collect.renderedText,
|
||||
"",
|
||||
"Source:",
|
||||
webObserveTable(["ID", "NODE", "LANE", "VIEW", "STATE_DIR"], [[
|
||||
value.id,
|
||||
value.node,
|
||||
value.lane,
|
||||
collect.view ?? value.view,
|
||||
webObserveShort(webObserveText(collect.stateDir), 88),
|
||||
]]),
|
||||
"",
|
||||
"Disclosure:",
|
||||
" collect views are rendered from existing samples/control/analysis artifacts; they do not create a new sampling source.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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"], [[
|
||||
value.id,
|
||||
value.node,
|
||||
value.lane,
|
||||
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),
|
||||
result.exitCode,
|
||||
result.timedOut,
|
||||
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),
|
||||
file.byteCount,
|
||||
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),
|
||||
grepSummary.matchCount,
|
||||
grepSummary.returnedLineCount,
|
||||
grepSummary.truncated,
|
||||
]]),
|
||||
webObserveTable(["LINE", "MATCH", "TEXT"], grepLines.length > 0 ? grepLines.map((item) => [
|
||||
item.lineNo,
|
||||
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),
|
||||
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),
|
||||
item.severity ?? item.level,
|
||||
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),
|
||||
jsonFindingDetail.severity,
|
||||
jsonFindingDetail.count,
|
||||
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) => [
|
||||
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),
|
||||
item.delta,
|
||||
item.sampleDeltaSeconds,
|
||||
item.allowedIncreaseSeconds,
|
||||
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),
|
||||
item.messageCount,
|
||||
item.traceRowCount ?? item.traceEventCount,
|
||||
webObserveShort(webObserveText(item.traceIds), 48),
|
||||
webObserveShort(webObserveText(item.messageStatuses), 48),
|
||||
item.loadingCount,
|
||||
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),
|
||||
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),
|
||||
result.exitCode,
|
||||
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 webObserveTable(headers: string[], rows: unknown[][]): string {
|
||||
const stringRows = rows.map((row) => headers.map((_, index) => webObserveCell(row[index])));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...stringRows.map((row) => row[index]?.length ?? 0)));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...stringRows.map(renderRow)].join("\n");
|
||||
}
|
||||
|
||||
function webObserveCell(value: unknown, maxLength = 96): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
const compact = text.replace(/\s+/gu, " ").trim();
|
||||
if (compact.length === 0) return "-";
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, Math.max(1, maxLength - 1))}...`;
|
||||
}
|
||||
|
||||
function webObserveText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function webObserveShort(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 1) return value.slice(0, maxLength);
|
||||
return `${value.slice(0, maxLength - 1)}~`;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
|
||||
// Responsibility: Extra control actions injected into the pure-client web-probe observer runner.
|
||||
|
||||
export function nodeWebObserveRunnerCommandActionsSource(): string {
|
||||
return String.raw`
|
||||
async function cancelRunningTurn() {
|
||||
const beforeUrl = currentPageUrl();
|
||||
const editor = page.locator("#command-input").last();
|
||||
if (await editor.isVisible().catch(() => false)) {
|
||||
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
|
||||
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
|
||||
if (tag === "textarea" || tag === "input") await editor.fill("");
|
||||
else if (editable) {
|
||||
await editor.click();
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {});
|
||||
await page.keyboard.press("Backspace").catch(() => {});
|
||||
}
|
||||
}
|
||||
const primaryButton = page.locator('#command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]').last();
|
||||
const button = await primaryButton.isVisible().catch(() => false)
|
||||
? primaryButton
|
||||
: page.locator([
|
||||
'button:has-text("取消")',
|
||||
'button:has-text("Cancel")',
|
||||
'[data-testid*="cancel" i]',
|
||||
'[aria-label*="cancel" i]',
|
||||
'[aria-label*="取消"]'
|
||||
].join(", ")).last();
|
||||
await button.waitFor({ state: "visible", timeout: 15000 });
|
||||
const cancelResponsePromise = page.waitForResponse((response) => {
|
||||
const request = response.request();
|
||||
if (request.method().toUpperCase() !== "POST") return false;
|
||||
try {
|
||||
const pathname = new URL(response.url()).pathname;
|
||||
return pathname === "/v1/agent/chat/cancel" || /\/cancel$/u.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 10000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||||
await button.click();
|
||||
const cancelResponse = await cancelResponsePromise;
|
||||
await page.waitForTimeout(500);
|
||||
if (cancelResponse?.waitError) {
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), cancelSubmit: { status: null, statusText: null, waitError: cancelResponse.waitError, responseObserved: false }, pageId };
|
||||
}
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), cancelSubmit: { status: cancelResponse.status(), statusText: cancelResponse.statusText(), urlPath: safeUrlPath(cancelResponse.url()), responseObserved: true }, pageId };
|
||||
}
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user