feat: add web-probe observe control views (#848)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-25 03:38:05 +08:00
committed by GitHub
parent 922f8f3f63
commit baf0c2bb05
8 changed files with 5756 additions and 5419 deletions
+6 -1
View File
@@ -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
View File
@@ -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