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
+9 -1
View File
@@ -49,6 +49,8 @@ web-probe 入口分三类:
- `script`:受控 Playwright 托管脚本,适合一轮 55 秒内完成的 DOM/API 断言、截图、route/intercept 和边界采样。
- `observe`:纯客户端长程观测,适合同一 Workbench session 多轮任务、realtime/projection 问题、长时间 trace/DOM/network 采样和无副作用报告生成。长程 Workbench 观测默认同时打开两个浏览器页面:control 页面只执行显式 `observe command` 用户动作,observer 页面只打开同一个 session 做被动观察,并默认每 3 分钟整页刷新一次同一 session,模拟用户离开后返回,用来抓多用户/多页面下同一 session 的投影差异、历史 trace 丢失、耗时跳变和 loading 差异。
工测优先级:Workbench trace 乱序、完成行位置、耗时不一致、时间跳变、final response flicker、session 不刷新、性能慢路径和多轮可靠性问题,默认先用 `observe` 采样并跑 `observe analyze`;不要把临时 Playwright spec、裸 DOM 调试脚本或单次 `script` 结果当作主要工测证据。`script/run` 只作为短路径断言、截图/API 摘要或 observe/analyze 后的补充复核。
需要 Playwright route/intercept、延迟 API、读取 in-flight DOM 或截图时仍使用受控 `web-probe script`,不要裸写 Playwright
```bash
@@ -84,10 +86,14 @@ bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --
bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type newSession
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 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 command webobs-xxxx --type sendPrompt --text-stdin <<'EOF'
long prompt
EOF
bun scripts/cli.ts hwlab nodes web-probe observe status webobs-xxxx --tail-lines 6
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 stop webobs-xxxx
bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx
```
@@ -98,7 +104,9 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx
- `web-probe script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file <path>` 提供脚本;只需要 repo-owned 标准 DOM probe 时使用 `web-probe run`
- `web-probe run|script|observe start` 的默认 URL、browser proxy mode 和 observe/analyze 报警阈值必须来自 `config/hwlab-node-lanes.yaml``webProbe`;需要排除公网/FRP/跨国 proxy 抖动时,在 YAML 里把目标 node/lane 的 `webProbe.defaultOrigin` 配成内部 Service ClusterIP origin,不要在命令行长期手写 `--url` 或裸 Playwright。
- `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不切换 control session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 commandobserver 页面只同步到同一 session URL 后被动采样,并按默认 180000ms 周期整页刷新同一 session 来模拟用户往返;周期刷新只作用于 observer,不得改变 control active session 或作为通过条件。两页的 `pageRole``pageId``sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession``selectProvider``sendPrompt``goto``screenshot``mark``stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`;长 prompt 必须优先用 `sendPrompt --text-stdin`,不要为了绕开 shell quoting 退回裸 Playwright 或临时脚本。
- `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不切换 control session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 commandobserver 页面只同步到同一 session URL 后被动采样,并按默认 180000ms 周期整页刷新同一 session 来模拟用户往返;周期刷新只作用于 observer,不得改变 control active session 或作为通过条件。两页的 `pageRole``pageId``sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession``selectProvider``sendPrompt``steer``cancel``goto``screenshot``mark``stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`;长 prompt 必须优先用 `sendPrompt --text-stdin``steer --text-stdin`,不要为了绕开 shell quoting 退回裸 Playwright 或临时脚本。
- `observe command --type steer``--type cancel` 是显式用户/control actionsteer 复用当前 Workbench composer 的运行中 turn 引导路径,cancel 复用同一 composer 主按钮的取消路径。二者必须进入 `control.jsonl`,不能用后端私有 API、AgentRun direct cancel 或测试后门替代。
- `observe collect --view turn-summary` 是第一层 CLI 阅读视图:只从 `samples.jsonl``control.jsonl` 和已有 `analysis/report.json` 按需渲染同一 session 的多 turn 摘要,包含用户消息 preview/hash、traceId、状态、耗时/最近更新时间、steer/cancel 标记和 Final Response 摘要。`observe collect --view trace-frame --trace-id <id> --sample-seq <n>` 是第二层 CLI 阅读视图:从同一采样帧渲染单帧 trace 文字截图,并固定输出 `Final Response` 区块。collect 视图不是采样器新增保存物,不构成第二事实源。
- `web-probe observe` 的 issue evidence 优先记录 observer id、stateDir、report JSON/Markdown SHA、samples/control/network/artifact 计数、routeSessionId、activeSessionId、prompt hash/textBytes、traceId、AgentRun runId/commandId、最终 status 和必要摘要;不要把 prompt 原文、assistant 大段正文、完整 stdout/stderr 或 provider payload 粘贴到 issue。
- 多轮 Workbench 采样必须证明同一个 `sessionId` 连续承载所有轮次;每轮至少记录 prompt hash、traceId、终态、最终回答摘要和性能/产物表。若 Web UI 投影卡住但 Code Agent/AgentRun result 已 terminal,应同时登记“执行终态”和“Workbench 投影未收敛”,不得用 `goto`、reload、切 session 或 result polling 把 UI 失败伪装成通过。
- `observe analyze` 是离线分析,只读取 artifact JSONL 并写 `analysis/report.md``analysis/report.json`,不访问 Workbench API、不驱动浏览器。`observe start` 每次启动必须先把同一 stateDir 中已有的根目录 JSONL 轮转到带时间戳的 `archive/` 文件;`observe analyze` 默认只分析当前根目录 JSONL,不扫描历史 archive,只有显式指定 archive prefix 时才分析历史轮转窗口。报告必须输出采样点 vs 每个 turn 的总耗时/最近更新时间表、trace row 视觉顺序异常、terminal/轮次完成 row 是否最后、Code Agent 卡片耗时与 trace/轮次完成总耗时一致性、可见“加载中”的数量/归属/并发 owner/连续出现区间、DOM diagnostic/HTTP/console/requestfailed/runtime execution error 分组、page asset provenance segment、同源 API Resource Timing 分位表和超过 YAML `webProbe.alertThresholds` budget 的慢路径 finding;页面/API 加载、可见“加载中”、长连接打开耗时、turn timing 跳变、trace row 顺序、卡片耗时/轮次完成耗时一致性和 session fallback 标题比例的报警阈值只能改 YAML,不能在 analyzer/renderer 中写死。修复必须降低真实请求、投影、渲染或后端路径耗时,禁止为了减少“加载中”出现时间而提前展示未加载完的内容,也不能靠下游 retry/reload/fallback 掩盖。报告里的 `trace-row-order-nonmonotonic``trace-completion-row-not-last``round-completion-elapsed-mismatch``code-agent-card-duration-underreported``final-response-flicker``uncommanded-visible-state-change`、session changed、network 503 等 finding 是排障线索;用于 closeout 时必须结合原始 session/trace/DOM 证据解释,避免把采样噪声直接当作业务结论。
+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