feat: add project-management web-probe observe

This commit is contained in:
Codex
2026-06-25 11:39:53 +00:00
parent 8d26e6fbc3
commit 80351ce5d2
9 changed files with 1070 additions and 23 deletions
+16 -4
View File
@@ -98,20 +98,32 @@ bun scripts/cli.ts hwlab nodes web-probe observe stop webobs-xxxx
bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx
```
项目管理 / mdtodo 页面同样优先使用 `observe`,不要退回一次性 Playwright 脚本:
```bash
bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo --sample-interval-ms 5000 --screenshot-interval-ms 60000 --command-timeout-seconds 55
bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectProjectSource --source-id <opaque-source-id>
bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectMdtodoFile --file-ref <opaque-file-ref>
bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectMdtodoTask --task-ref <opaque-task-ref>
bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type launchWorkbenchFromTask
bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view project-summary
bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx
```
`observe command --type newSession` 是显式用户/control action:它通过当前 Workbench UI 点击 `#session-create` 创建新 session,等待 active session id 改变和 composer ready,并把前后 snapshot 写入 control log。它只能用于采样开始时建立目标 session,或作为用户明确的新建会话动作;不得在 route/session mismatch 后当作 repair 手段。
约束:
- `web-probe script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file <path>` 提供脚本;只需要 repo-owned 标准 DOM probe 时使用 `web-probe run`
- `web-probe run|script|observe start` 的默认 URL、browser proxy modeobserve/analyze 报警阈值必须来自 `config/hwlab-node-lanes.yaml``webProbe`;需要排除公网/FRP/跨国 proxy 抖动时,在 YAML 里把目标 node/lane 的 `webProbe.defaultOrigin` 配成内部 Service ClusterIP origin,不要在命令行长期手写 `--url` 或裸 Playwright。
- `web-probe run|script|observe start` 的默认 URL、browser proxy modeobserve/analyze 报警阈值和 project-management 采样/命令 allowlist 必须来自 `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``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 视图不是采样器新增保存物,不构成第二事实源。
- `observe command --type steer``--type cancel` 是显式用户/control actionsteer 复用当前 Workbench composer 的运行中 turn 引导路径,cancel 复用同一 composer 主按钮的取消路径。二者必须进入 `control.jsonl`,不能用后端私有 API、AgentRun direct cancel 或测试后门替代。`selectProjectSource``selectMdtodoFile``selectMdtodoTask``launchWorkbenchFromTask` 也是显式用户/control action,只能使用页面公开 `data-*` id、正式按钮和 YAML 允许的自然 API;它们通过 opaque public id 与 Workbench 关联,不能读取内部 store、私有后端或把 mdtodo 页面包含进 Workbench。
- `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` 区块。`observe collect --view project-summary` 从同一 artifact 渲染项目管理 / mdtodo DOM 采样、Workbench launch command、捕获到的 `x-hwlab-otel-trace-id` 和 Tempo drill-down 命令。collect 视图不是采样器新增保存物,不构成第二事实源。
- `trace-frame` 出现 `(无 trace rows;这是 blocker...)` 时,必须先看同一输出中的 `TRACE DIAGNOSTIC`:记录 pageRole/pageId、traceRows/turns/messages 数量、sampleTraceIds、尾部 traceRow/turn/message 归属。若目标 trace 的 turn/message/final 存在但 traceRows 全部属于旧 trace,应按 Workbench read model authority 分裂登记到架构/业务 issue(例:HWLAB #2124),不得把旧 traceRows 当作新 turn 通过证据,也不得让 analyzer 的聚合计数压过 CLI trace 视图。
-`observe status` 显示 PID still alive 但 heartbeat/sample 不推进、`commands/pending/*.json` 不被消费,或 `observe stop --force` 只是继续排队 stop command,应先按 web-probe runner 工具缺陷处理(例:UniDesk #874),用 route 只读确认 PID/heartbeat 后清理进程;不要把 pending command、未触发的 cancel 或 runner stale 混入 Workbench 业务结论。
- `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 证据解释,避免把采样噪声直接当作业务结论。
- `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项目管理页还必须输出 DOM readiness、source/file/task 计数、缺失 public task ref、Workbench launch success/failure、captured OTel trace header、自然 project-management API 分组和超过 YAML `webProbe.projectManagement.slowApiBudgetMs` 的慢路径 finding。页面/API 加载、可见“加载中”、长连接打开耗时、turn timing 跳变、trace row 顺序、卡片耗时/轮次完成耗时一致性session fallback 标题比例和项目管理 API 慢路径阈值只能改 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``mdtodo-workbench-launch-otel-trace-missing``project-management-api-slow`session changed、network 503 等 finding 是排障线索;用于 closeout 时必须结合原始 session/trace/DOM 证据解释,避免把采样噪声直接当作业务结论。
- 自定义 `web-probe script` 仍运行在 UniDesk `trans` 60s 最外层短连接约束内;能在一轮内完成的 P4 验收优先把 `--command-timeout-seconds` 控制在 55 秒以内,并减少无界 selector/network 等待。确需等待更久时,改用 `web-probe run` 的异步 job/status 语义,或把动作拆成“提交/采样/截图/状态读取”多次短 probe。若输出出现 `UNIDESK_SSH_RUNTIME_TIMEOUT` 但同时恢复了 `reportPath``reportSha256`、screenshots 或 DOM steps,先按远端报告判断脚本/页面实际状态;最终关闭证据仍优先用一次未触发短连接超时的 bounded rerun。
- issue closeout 优先引用 `web-probe script` 输出的顶层 `issueEvidence``summary.issueEvidence`;只有需要展开调查时才粘贴 `probe.script.result``probe.steps` 或完整 `reportPath`,避免 stdout、summary 和 report 多层重复同一证据。
- stdin heredoc 与 `--script-file` 都按 ES module 加载,脚本必须导出 `export default async ({ page, gotoStable, recordStep, ... }) => { ... }`;不要在模块顶层直接写 `return`。失败为 `Illegal return statement``does not provide an export named default` 或 finalUrl 仍是 `about:blank` 且 stepCount=0 时,先按 probe 脚本入口误用处理,不要归因成 Cloud Web 行为失败。
+18
View File
@@ -167,6 +167,24 @@ lanes:
scrollJumpFromY: 250
scrollJumpToY: 40
sessionRailFallbackRatio: 0.5
projectManagement:
enabled: true
targetPaths:
- /projects
- /projects/mdtodo
readinessSelectors:
- '[data-testid="project-management-root"]'
- '[data-testid="project-management-mdtodo"]'
naturalApiPathPrefixes:
- /v1/project-management/
- /v1/workbench/launches
commandAllowlist:
- selectProjectSource
- selectMdtodoFile
- selectMdtodoTask
- launchWorkbenchFromTask
launchRoute: /v1/workbench/launches
slowApiBudgetMs: 10000
tektonDir: tekton-v03
argoApplicationFile: application-v03.yaml
registryPrefix: 127.0.0.1:5000/hwlab
+8 -4
View File
@@ -77,16 +77,20 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --fresh-session --message 'ping'",
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo --sample-interval-ms 5000",
"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 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 command webobs-xxxx --type selectMdtodoTask --task-ref <opaque-task-ref>",
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type launchWorkbenchFromTask",
"bun scripts/cli.ts hwlab nodes web-probe observe status webobs-xxxx",
"bun scripts/cli.ts hwlab nodes web-probe observe stop webobs-xxxx --force",
"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 collect webobs-xxxx --view project-summary",
"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,16 +100,16 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
observe: "Start, inspect, control, stop, collect, and analyze a pure-client long-running Workbench observer on the target host. The observer runs a control page plus a passive observer page in a shared-auth browser context, receives commands through stateDir/commands files, writes JSONL artifacts, and does not expose any inbound service API.",
},
notes: [
"The default probe URL, browser proxy mode, and observe/analyze alert thresholds come from config/hwlab-node-lanes.yaml webProbe; pass --url only when intentionally overriding the YAML-selected origin.",
"The default probe URL, browser proxy mode, observe/analyze alert thresholds, and project-management observe behavior come from config/hwlab-node-lanes.yaml webProbe; pass --url only when intentionally overriding the YAML-selected origin.",
"Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.",
"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 status reports runner liveness separately from process existence, including heartbeat age, stale threshold, command backlog, and abandoned command counts.",
"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 command actions are explicit user/control actions and are appended to control.jsonl; use --type newSession/selectProvider/sendPrompt/steer/cancel/goto/screenshot/mark/stop. Project-management actions are selectProjectSource/selectMdtodoFile/selectMdtodoTask/launchWorkbenchFromTask and are allowed only by the selected node/lane YAML. steer/cancel reuse the Workbench composer path, and project-management launch uses public UI/API evidence plus x-hwlab-otel-trace-id capture, not private backend APIs. For long prompts use sendPrompt/steer --text-stdin; keep prompt text out of issue comments by citing textHash/textBytes.",
"observe stop --force first checks heartbeat/backlog health; if the runner is stale or commands are not being consumed, it kills the recorded PID from outside the command queue and marks pending/processing commands abandoned so analyze classifies them as tooling findings.",
"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 plus observer command/heartbeat artifacts and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser.",
"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; --view project-summary renders project-management/mdtodo samples, Workbench launch commands, and OTel trace drill-down commands from the same artifacts. collect views do not save a second source of truth.",
"observe analyze is offline-only: it reads artifact JSONL plus observer command/heartbeat artifacts and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser. For project-management pages it reports DOM readiness, public task id coverage, Workbench launch success/failure, captured launch OTel trace headers, and YAML-budgeted project API timing.",
"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.",
"script/observe support --browser-proxy-mode auto|direct; use direct for A/B evidence when frontend RUM is slow but OTel server spans are absent or fast, instead of falling back to raw Playwright.",
+133 -7
View File
@@ -8,7 +8,7 @@ import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
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 { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "./hwlab-node-lanes";
import { nodeWebProbeScriptRunnerSource } from "./hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "./hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source";
@@ -64,7 +64,7 @@ interface NodeWebProbeScriptOptions {
}
type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop" | "collect" | "analyze";
type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "newSession" | "sendPrompt" | "steer" | "cancel" | "selectProvider" | "clickSession" | "screenshot" | "mark" | "stop";
type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "newSession" | "sendPrompt" | "steer" | "cancel" | "selectProvider" | "clickSession" | "selectProjectSource" | "selectMdtodoFile" | "selectMdtodoTask" | "launchWorkbenchFromTask" | "screenshot" | "mark" | "stop";
interface NodeWebProbeObserveOptions {
action: "observe";
@@ -104,6 +104,9 @@ interface NodeWebProbeObserveOptions {
commandLabel: string | null;
commandSessionId: string | null;
commandProvider: string | null;
commandSourceId: string | null;
commandFileRef: string | null;
commandTaskRef: string | null;
}
type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions;
@@ -7385,6 +7388,9 @@ function parseNodeWebProbeObserveOptions(
"--label",
"--session-id",
"--provider",
"--source-id",
"--file-ref",
"--task-ref",
]), new Set(["--force", "--full", "--text-stdin"]));
const commandTypeRaw = optionValue(args, "--type") ?? null;
const commandType = commandTypeRaw === null ? null : parseNodeWebProbeObserveCommandType(commandTypeRaw);
@@ -7428,6 +7434,12 @@ function parseNodeWebProbeObserveOptions(
throw new Error("web-probe observe command accepts either --text or --text-stdin, not both");
}
const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption;
const commandSourceId = optionValue(args, "--source-id") ?? null;
const commandFileRef = optionValue(args, "--file-ref") ?? null;
const commandTaskRef = optionValue(args, "--task-ref") ?? null;
for (const [label, value] of [["--source-id", commandSourceId], ["--file-ref", commandFileRef], ["--task-ref", commandTaskRef]] as const) {
if (value !== null && (value.includes("\0") || value.length > 500)) throw new Error(`unsafe web-probe observe ${label}: expected 1-500 non-NUL chars`);
}
return {
action: "observe",
observeAction: observeActionRaw,
@@ -7466,6 +7478,9 @@ function parseNodeWebProbeObserveOptions(
commandLabel: optionValue(args, "--label") ?? null,
commandSessionId: optionValue(args, "--session-id") ?? null,
commandProvider: optionValue(args, "--provider") ?? null,
commandSourceId,
commandFileRef,
commandTaskRef,
};
}
@@ -7480,11 +7495,15 @@ function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserve
|| value === "cancel"
|| value === "selectProvider"
|| value === "clickSession"
|| value === "selectProjectSource"
|| value === "selectMdtodoFile"
|| value === "selectMdtodoTask"
|| value === "launchWorkbenchFromTask"
|| value === "screenshot"
|| value === "mark"
|| value === "stop"
) return 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}`);
throw new Error(`web-probe observe command --type must be login, preflight, goto, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, selectProjectSource, selectMdtodoFile, selectMdtodoTask, launchWorkbenchFromTask, screenshot, mark, or stop; got ${value}`);
}
function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -7843,6 +7862,10 @@ function nodeWebProbeAlertThresholds(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWe
return thresholds;
}
function nodeWebProbeProjectManagementConfig(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeProjectManagementSpec | null {
return spec.webProbe?.projectManagement ?? null;
}
interface NodeWebProbeHostProxyEnv {
readonly envAssignments: string[];
readonly summary: Record<string, unknown>;
@@ -7999,6 +8022,7 @@ function runNodeWebProbeObserveStart(
const runnerB64Body = runnerB64.match(/.{1,76}/gu)?.join("\n") ?? runnerB64;
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const alertThresholds = nodeWebProbeAlertThresholds(spec);
const projectManagement = nodeWebProbeProjectManagementConfig(spec);
const runnerEnvAssignments = [
...webProbeProxy.envAssignments,
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
@@ -8014,6 +8038,7 @@ function runNodeWebProbeObserveStart(
`UNIDESK_WEB_OBSERVE_VIEWPORT=${shellQuote(options.viewport)}`,
`UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE=${shellQuote(options.browserProxyMode)}`,
`UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=${shellQuote(JSON.stringify(alertThresholds))}`,
`UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=${shellQuote(JSON.stringify(projectManagement))}`,
].join(" ");
const script = [
"set -eu",
@@ -8062,6 +8087,7 @@ function runNodeWebProbeObserveStart(
url: options.url,
network: webProbeProxy.summary,
alertThresholds,
projectManagement,
targetPath: options.targetPath,
id: observerId,
credential,
@@ -8149,6 +8175,9 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
label: options.commandLabel,
sessionId: options.commandSessionId,
provider: options.commandProvider,
sourceId: options.commandSourceId,
fileRef: options.commandFileRef,
taskRef: options.commandTaskRef,
};
const preStopStatus = options.force && stopCommand
? readNodeWebProbeObserveRemoteStatus(options, spec, 1, Math.min(options.commandTimeoutSeconds, 30))
@@ -8279,6 +8308,7 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64");
const alertThresholds = nodeWebProbeAlertThresholds(spec);
const projectManagement = nodeWebProbeProjectManagementConfig(spec);
const script = [
"set -eu",
nodeWebObserveResolveStateDirShell(options),
@@ -8297,7 +8327,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
`UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX=${shellQuote(options.analyzeArchivePrefix ?? "")}`,
`UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES=${shellQuote(options.analyzeTailSamples === null ? "" : String(options.analyzeTailSamples))}`,
`UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=${shellQuote(JSON.stringify(alertThresholds))}`,
"UNIDESK_WEB_OBSERVE_STATE_DIR=\"$state_dir\" UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX=\"$UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX\" UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES=\"$UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES\" UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=\"$UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON\" node \"$analyzer\" >\"$analysis_stdout\" 2>\"$analysis_stderr\"",
`UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=${shellQuote(JSON.stringify(projectManagement))}`,
"UNIDESK_WEB_OBSERVE_STATE_DIR=\"$state_dir\" UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX=\"$UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX\" UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES=\"$UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES\" UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=\"$UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON\" UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=\"$UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON\" node \"$analyzer\" >\"$analysis_stdout\" 2>\"$analysis_stderr\"",
"analyzer_exit=$?",
"set -e",
"report_json=\"$state_dir/analysis/report.json\"",
@@ -8319,7 +8350,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
"const readJsonlTail = (path, limit) => readText(path).split(/\\r?\\n/).filter(Boolean).slice(-limit).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);",
"const mergeArrays = (...values) => { const out = []; const seen = new Set(); for (const value of values) { if (!Array.isArray(value)) continue; for (const item of value) { const key = JSON.stringify([item?.id ?? item?.kind ?? item?.code ?? item?.columnLabel ?? item?.traceId ?? null, item?.path ?? item?.urlPath ?? null, item?.method ?? null, item?.status ?? null, item?.summary ?? item?.message ?? item?.fromSeq ?? item?.firstAt ?? null, item?.toSeq ?? item?.lastAt ?? null]); if (seen.has(key)) continue; seen.add(key); out.push(item); } } return out; };",
"const clip = (value, limit = 160) => value === null || value === undefined ? null : String(value).slice(0, limit);",
"const findingRank = (item) => { const id = String(item?.id ?? item?.kind ?? item?.code ?? ''); if (id === 'observer-command-failed') return 0; if (id === 'page-performance-slow-same-origin-api') return 1; if (id === 'session-rail-title-fallback-majority') return 2; if (id.startsWith('turn-timing-total-elapsed')) return 3; if (id.startsWith('turn-timing-recent-update')) return 4; if (id.includes('runtime-execution') || id.includes('prompt-chat-submit-failed')) return 5; return 10; };",
"const findingRank = (item) => { const id = String(item?.id ?? item?.kind ?? item?.code ?? ''); if (id.startsWith('project-management-') || id.startsWith('mdtodo-') || id === 'workbench-launch-button-unavailable') return 0; if (id === 'observer-command-failed') return 0.2; if (id === 'page-performance-slow-same-origin-api') return 1; if (id === 'session-rail-title-fallback-majority') return 2; if (id.startsWith('turn-timing-total-elapsed')) return 3; if (id.startsWith('turn-timing-recent-update')) return 4; if (id.includes('runtime-execution') || id.includes('prompt-chat-submit-failed')) return 5; return 10; };",
"const severityRank = (item) => { const severity = String(item?.severity ?? item?.level ?? '').toLowerCase(); if (severity === 'red') return 0; if (severity === 'amber' || severity === 'warning') return 1; if (severity === 'info') return 3; return 2; };",
"const sortFindings = (items) => (Array.isArray(items) ? items : []).slice().sort((a, b) => findingRank(a) - findingRank(b) || severityRank(a) - severityRank(b));",
"const stdoutJson = readJson(stdoutPath);",
@@ -8332,6 +8363,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
"const slimSlowSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, seq: v.seq ?? null, path: clip(v.path ?? v.rawPath, 96), initiatorType: clip(v.initiatorType, 24), durationMs: v.durationMs ?? null, requestToResponseStartMs: v.requestToResponseStartMs ?? v.streamOpenMs ?? null, responseTransferMs: v.responseTransferMs ?? null, nextHopProtocol: clip(v.nextHopProtocol, 24), timingStatus: clip(v.timingStatus, 16), serverTimingNames: Array.isArray(v.serverTimingNames) ? v.serverTimingNames.slice(0, 4).map((x) => clip(x, 32)) : [], otelTraceId: clip(v.otelTraceId, 32) }; };",
"const slimSlowApi = (item) => { const v = objectOrNull(item) || {}; return { path: clip(v.path ?? v.route, 96), route: clip(v.route ?? v.path, 96), sampleCount: v.sampleCount ?? null, p95Ms: v.p95Ms ?? v.p95 ?? null, maxMs: v.maxMs ?? v.max ?? null, budgetMs: v.budgetMs ?? null, overBudgetCount: v.overBudgetCount ?? null, overFiveSecondCount: v.overFiveSecondCount ?? null, slowSamples: Array.isArray(v.slowSamples) ? v.slowSamples.slice(0, 3).map(slimSlowSample) : [] }; };",
"const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180) }; };",
"const slimProjectManagement = (value) => { const v = objectOrNull(value); if (!v) return null; const s = objectOrNull(v.summary) || v; return { summary: { enabled: s.enabled === true, projectSampleCount: s.projectSampleCount ?? null, mdtodoSampleCount: s.mdtodoSampleCount ?? null, latestPageKind: clip(s.latestPageKind, 48), latestPath: clip(s.latestPath, 96), latestSourceCount: s.latestSourceCount ?? null, latestFileCount: s.latestFileCount ?? null, latestTaskCount: s.latestTaskCount ?? null, latestSelectedTaskRefHash: clip(s.latestSelectedTaskRefHash, 80), launchCommandCount: s.launchCommandCount ?? null, launchSuccessCount: s.launchSuccessCount ?? null, launchFailureCount: s.launchFailureCount ?? null, launchWithOtelTraceHeaderCount: s.launchWithOtelTraceHeaderCount ?? null, projectApiResponseCount: s.projectApiResponseCount ?? null, projectApiFailureCount: s.projectApiFailureCount ?? null, projectApiSlowPathCount: s.projectApiSlowPathCount ?? null, slowApiBudgetMs: s.slowApiBudgetMs ?? null }, commands: takeTail(v.commands, 8).map((item) => { const row = objectOrNull(item) || {}; return { ts: row.ts ?? null, phase: clip(row.phase, 16), type: clip(row.type, 32), commandId: clip(row.commandId, 80), launchStatus: row.launchStatus ?? null, sessionId: clip(row.sessionId, 80), workbenchUrl: clip(row.workbenchUrl, 120), otelTraceId: clip(row.otelTraceId, 32), selectedTaskRefHash: clip(row.selectedTaskRefHash, 80) }; }), samples: takeTail(v.samples, 8).map((item) => { const row = objectOrNull(item) || {}; return { seq: row.seq ?? null, ts: row.ts ?? null, pageRole: clip(row.pageRole, 24), path: clip(row.path, 96), pageKind: clip(row.pageKind, 48), sourceCount: row.sourceCount ?? null, fileCount: row.fileCount ?? null, taskCount: row.taskCount ?? null, selectedTaskRefHash: clip(row.selectedTaskRefHash, 80), launchButtonEnabled: row.launchButtonEnabled === true, workbenchLinkCount: row.workbenchLinkCount ?? null }; }), projectApiByPath: takeHead(v.projectApiByPath, 8).map(slimNetworkGroup), valuesRedacted: true }; };",
"const slimDomGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180) }; };",
"const slimNetworkGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, method: clip(v.method, 12), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, promptIndexes: Array.isArray(v.promptIndexes) ? v.promptIndexes.slice(0, 6) : [], failureKinds: Array.isArray(v.failureKinds) ? v.failureKinds.slice(0, 4).map((x) => clip(x, 48)) : [] }; };",
"const slimDomSample = (item) => { const v = objectOrNull(item) || {}; return { seq: v.seq ?? null, ts: v.ts ?? null, source: clip(v.source, 32), diagnosticCode: clip(v.diagnosticCode, 48), traceId: clip(v.traceId, 64), httpStatus: v.httpStatus ?? null, idleSeconds: v.idleSeconds ?? null, waitingFor: clip(v.waitingFor, 48), lastEventLabel: clip(v.lastEventLabel, 80), text: clip(v.text ?? v.preview, 180) }; };",
@@ -8400,6 +8432,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
"const commandFailuresFromFindings = [...allFindingsForCommands, ...archiveRedFindings].flatMap((item) => Array.isArray(item?.commands) ? item.commands : []);",
"const srcPromptNetwork = objectOrNull(source?.promptNetwork);",
"const promptNetwork = srcPromptNetwork ? { promptSegments: srcPromptNetwork.promptSegments ?? null } : null;",
"const projectManagement = slimProjectManagement(source?.projectManagement || fullSource?.projectManagement);",
"const runnerErrorsFromJsonl = readJsonlTail(reportJsonPath.replace(/\\/analysis\\/report\\.json$/u, '/errors.jsonl'), 8).filter((item) => item?.type === 'runner-error').map(slimRunnerErrorFromJsonl);",
"const compact = source ? {",
" ok: analyzerExit === 0,",
@@ -8410,6 +8443,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" sampleMetrics: metrics,",
" runtimeAlerts,",
" pagePerformance,",
" projectManagement,",
" promptNetwork,",
" pagePerformanceSlowApi: takeHead(sourceSlowApi, 4).map(slimSlowApi),",
" archivePagePerformanceSlowApi: takeHead(archiveSlowApi, 8).map(slimSlowApi),",
@@ -8498,6 +8532,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" } : null,",
" runtimeAlerts: compact.runtimeAlerts ?? null,",
" pagePerformance: compact.pagePerformance ?? null,",
" projectManagement: compact.projectManagement ?? null,",
" promptNetwork: compact.promptNetwork ?? null,",
" toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],",
" commandState: compact.commandState ?? null,",
@@ -8561,6 +8596,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" } : null,",
" runtimeAlerts: compact.runtimeAlerts ?? null,",
" pagePerformance: compact.pagePerformance ?? null,",
" projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-4) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-4) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-4) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 4) : [], slowProjectApiPerformance: Array.isArray(compact.projectManagement.slowProjectApiPerformance) ? compact.projectManagement.slowProjectApiPerformance.slice(0, 4) : [], valuesRedacted: true } : null,",
" toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],",
" commandState: compact.commandState ?? null,",
" findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4) : [],",
@@ -8601,6 +8637,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" } : null,",
" runtimeAlerts: compact.runtimeAlerts ?? null,",
" pagePerformance: compact.pagePerformance ?? null,",
" projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-3) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-3) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-3) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 3) : [], slowProjectApiPerformance: Array.isArray(compact.projectManagement.slowProjectApiPerformance) ? compact.projectManagement.slowProjectApiPerformance.slice(0, 2) : [], valuesRedacted: true } : null,",
" toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],",
" commandState: compact.commandState ?? null,",
" findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, summary: clip(item.summary ?? item.message, 120) })) : [],",
@@ -8624,7 +8661,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" output = compactOutput(ultratiny);",
" }",
" if (Buffer.byteLength(output, 'utf8') > compactStdoutLimitBytes) {",
" output = compactOutput({ ok: compact.ok, counts: compact.counts ?? null, jsonlScope: compact.jsonlScope ?? null, analysisWindow: compact.analysisWindow ?? null, archiveSummary: compact.archiveSummary ? { redFindingCount: compact.archiveSummary.redFindingCount ?? null, findingCount: compact.archiveSummary.findingCount ?? null, sampleCount: compact.archiveSummary.sampleMetrics?.sampleCount ?? null, slowPathCount: compact.archiveSummary.pagePerformance?.slowPathCount ?? null } : null, toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [], commandState: compact.commandState ? { pendingCount: compact.commandState.pendingCount ?? null, processingCount: compact.commandState.processingCount ?? null, abandonedCount: compact.commandState.abandonedCount ?? null, failedCount: compact.commandState.failedCount ?? null } : null, findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, summary: clip(item.summary ?? item.message, 120) })) : [], archiveRedFindings: Array.isArray(compact.archiveRedFindings) ? compact.archiveRedFindings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, summary: clip(item.summary ?? item.message, 120) })) : [], commandFailures: Array.isArray(compact.commandFailures) ? compact.commandFailures.slice(-3).map((item) => ({ ts: item.ts ?? null, type: item.type ?? null, commandId: item.commandId ?? null, durationMs: item.durationMs ?? null, sampleSeq: item.sampleSeq ?? null, beforePath: item.beforePath ?? null, afterPath: item.afterPath ?? null, message: clip(item.message ?? item.failureKind ?? item.name, 120) })) : [], archivePagePerformanceSlowApi: Array.isArray(compact.archivePagePerformanceSlowApi) ? compact.archivePagePerformanceSlowApi.slice(0, 4).map((item) => ({ path: item.path ?? item.route ?? null, sampleCount: item.sampleCount ?? null, p95Ms: item.p95Ms ?? null, maxMs: item.maxMs ?? null, overFiveSecondCount: item.overFiveSecondCount ?? null })) : [], reportJsonPath: compact.reportJsonPath ?? reportJsonPath, reportJsonSha256: compact.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: compact.reportMdPath ?? reportMdPath, reportMdSha256: compact.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(compact.analyzer ?? {}), compactStdoutLimited: true, ultratiny: true, hardFallback: true, compactStdoutLimitBytes }, valuesRedacted: true });",
" output = compactOutput({ ok: compact.ok, counts: compact.counts ?? null, jsonlScope: compact.jsonlScope ?? null, analysisWindow: compact.analysisWindow ?? null, archiveSummary: compact.archiveSummary ? { redFindingCount: compact.archiveSummary.redFindingCount ?? null, findingCount: compact.archiveSummary.findingCount ?? null, sampleCount: compact.archiveSummary.sampleMetrics?.sampleCount ?? null, slowPathCount: compact.archiveSummary.pagePerformance?.slowPathCount ?? null } : null, projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-2) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-2) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-2) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 2) : [], valuesRedacted: true } : null, toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [], commandState: compact.commandState ? { pendingCount: compact.commandState.pendingCount ?? null, processingCount: compact.commandState.processingCount ?? null, abandonedCount: compact.commandState.abandonedCount ?? null, failedCount: compact.commandState.failedCount ?? null } : null, findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, summary: clip(item.summary ?? item.message, 120) })) : [], archiveRedFindings: Array.isArray(compact.archiveRedFindings) ? compact.archiveRedFindings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, summary: clip(item.summary ?? item.message, 120) })) : [], commandFailures: Array.isArray(compact.commandFailures) ? compact.commandFailures.slice(-3).map((item) => ({ ts: item.ts ?? null, type: item.type ?? null, commandId: item.commandId ?? null, durationMs: item.durationMs ?? null, sampleSeq: item.sampleSeq ?? null, beforePath: item.beforePath ?? null, afterPath: item.afterPath ?? null, message: clip(item.message ?? item.failureKind ?? item.name, 120) })) : [], archivePagePerformanceSlowApi: Array.isArray(compact.archivePagePerformanceSlowApi) ? compact.archivePagePerformanceSlowApi.slice(0, 4).map((item) => ({ path: item.path ?? item.route ?? null, sampleCount: item.sampleCount ?? null, p95Ms: item.p95Ms ?? null, maxMs: item.maxMs ?? null, overFiveSecondCount: item.overFiveSecondCount ?? null })) : [], reportJsonPath: compact.reportJsonPath ?? reportJsonPath, reportJsonSha256: compact.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: compact.reportMdPath ?? reportMdPath, reportMdSha256: compact.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(compact.analyzer ?? {}), compactStdoutLimited: true, ultratiny: true, hardFallback: true, compactStdoutLimitBytes }, valuesRedacted: true });",
" }",
" }",
"}",
@@ -8750,7 +8787,7 @@ function recoverWebObserveAnalyzeFromArtifacts(options: NodeWebProbeObserveOptio
"const archiveSummary = objectOrNull(source.archiveSummary) || {};",
"const archiveSampleMetrics = objectOrNull(archiveSummary.sampleMetrics) || objectOrNull(source.sampleMetrics?.summary) || objectOrNull(srcMetrics.summary) || {};",
"const slowApis = arr(source.pagePerformanceSlowApi).length > 0 ? arr(source.pagePerformanceSlowApi) : arr(pagePerformance.sameOriginApiByPath).filter((item) => Number(item?.overBudgetCount ?? item?.overFiveSecondCount ?? 0) > 0);",
"const compact = { ok: source.ok === true, command: source.command ?? 'web-probe-observe analyze', stateDir: source.stateDir ?? stateDir, jsonlScope: source.jsonlScope ?? null, alertThresholds: source.alertThresholds ?? null, counts: source.counts ?? null, archiveSummary: { ...archiveSummary, sampleMetrics: archiveSampleMetrics, pagePerformance: objectOrNull(archiveSummary.pagePerformance) || objectOrNull(pagePerformance.summary) || {}, runtimeAlerts: objectOrNull(archiveSummary.runtimeAlerts) || objectOrNull(runtimeAlerts.summary) || {}, redFindings: arr(archiveSummary.redFindings).slice(0, 12).map(slimFinding) }, analysisWindow: source.analysisWindow ?? objectOrNull(recent.summary), sampleMetrics: compactMetrics(srcMetrics), pageProvenance: objectOrNull(source.pageProvenance?.summary) || source.pageProvenance ?? null, pagePerformance: objectOrNull(pagePerformance.summary) || pagePerformance, promptNetwork: objectOrNull(promptNetwork.summary) || promptNetwork, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, runnerErrors: arr(source.runnerErrors).slice(-8), commandFailures: arr(source.commandFailures).slice(-8), commandState: objectOrNull(source.commandState) || null, toolFindings: arr(source.toolFindings).slice(0, 8).map(slimFinding), httpErrorGroups: arr(source.httpErrorGroups ?? runtimeAlerts.networkHttpErrorsByPath).slice(0, 8).map(slimGroup), requestFailedGroups: arr(source.requestFailedGroups ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 8).map(slimGroup), domDiagnosticGroups: arr(source.domDiagnosticGroups ?? runtimeAlerts.domDiagnosticsByText).slice(0, 5).map(slimGroup), domDiagnosticSamples: arr(source.domDiagnosticSamples ?? runtimeAlerts.domDiagnostics).slice(0, 8).map(slimGroup), consoleAlertGroups: arr(source.consoleAlertGroups ?? runtimeAlerts.consoleAlertsByPath).slice(0, 8).map(slimGroup), consoleAlertSamples: arr(source.consoleAlertSamples ?? runtimeAlerts.consoleAlerts).slice(0, 8).map(slimGroup), turnTimingRecentUpdateJumps: arr(source.turnTimingRecentUpdateJumps ?? srcMetrics.turnTimingRecentUpdateSawtoothJumps).slice(0, 8), turnTimingElapsedZeroResets: arr(source.turnTimingElapsedZeroResets ?? srcMetrics.turnTimingElapsedZeroResets).slice(0, 8), turnTimingTotalElapsedForwardJumps: arr(source.turnTimingTotalElapsedForwardJumps ?? srcMetrics.turnTimingTotalElapsedForwardJumps).slice(0, 8), pagePerformanceSlowApi: slowApis.slice(0, 8).map(slimSlowApi), archivePagePerformanceSlowApi: arr(source.archivePagePerformanceSlowApi).slice(0, 8).map(slimSlowApi), pagePerformancePartialApi: arr(source.pagePerformancePartialApi).slice(0, 8), pagePerformanceSseStreams: arr(source.pagePerformanceSseStreams).slice(0, 8), findings: arr(source.findings).slice(0, 12).map(slimFinding), archiveRedFindings: arr(source.archiveRedFindings ?? archiveSummary.redFindings).slice(0, 12).map(slimFinding), reportJsonPath: source.reportJsonPath ?? reportJsonPath, reportJsonSha256: source.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: source.reportMdPath ?? reportMdPath, reportMdSha256: source.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(objectOrNull(source.analyzer) || {}), recoveredFrom: 'analysis-artifact-after-transport-timeout', stdoutPath, stderrPath, stdoutBytes: statSize(stdoutPath), stderrBytes: statSize(stderrPath), reportJsonBytes: statSize(reportJsonPath), reportMdBytes: statSize(reportMdPath), transportExitCode: Number(transportExitRaw), transportTimedOut: transportTimedOutRaw === 'true', valuesRedacted: true }, valuesRedacted: true };",
"const compact = { ok: source.ok === true, command: source.command ?? 'web-probe-observe analyze', stateDir: source.stateDir ?? stateDir, jsonlScope: source.jsonlScope ?? null, alertThresholds: source.alertThresholds ?? null, counts: source.counts ?? null, archiveSummary: { ...archiveSummary, sampleMetrics: archiveSampleMetrics, pagePerformance: objectOrNull(archiveSummary.pagePerformance) || objectOrNull(pagePerformance.summary) || {}, runtimeAlerts: objectOrNull(archiveSummary.runtimeAlerts) || objectOrNull(runtimeAlerts.summary) || {}, redFindings: arr(archiveSummary.redFindings).slice(0, 12).map(slimFinding) }, analysisWindow: source.analysisWindow ?? objectOrNull(recent.summary), sampleMetrics: compactMetrics(srcMetrics), pageProvenance: objectOrNull(source.pageProvenance?.summary) || source.pageProvenance ?? null, pagePerformance: objectOrNull(pagePerformance.summary) || pagePerformance, projectManagement: objectOrNull(source.projectManagement) || null, promptNetwork: objectOrNull(promptNetwork.summary) || promptNetwork, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, runnerErrors: arr(source.runnerErrors).slice(-8), commandFailures: arr(source.commandFailures).slice(-8), commandState: objectOrNull(source.commandState) || null, toolFindings: arr(source.toolFindings).slice(0, 8).map(slimFinding), httpErrorGroups: arr(source.httpErrorGroups ?? runtimeAlerts.networkHttpErrorsByPath).slice(0, 8).map(slimGroup), requestFailedGroups: arr(source.requestFailedGroups ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 8).map(slimGroup), domDiagnosticGroups: arr(source.domDiagnosticGroups ?? runtimeAlerts.domDiagnosticsByText).slice(0, 5).map(slimGroup), domDiagnosticSamples: arr(source.domDiagnosticSamples ?? runtimeAlerts.domDiagnostics).slice(0, 8).map(slimGroup), consoleAlertGroups: arr(source.consoleAlertGroups ?? runtimeAlerts.consoleAlertsByPath).slice(0, 8).map(slimGroup), consoleAlertSamples: arr(source.consoleAlertSamples ?? runtimeAlerts.consoleAlerts).slice(0, 8).map(slimGroup), turnTimingRecentUpdateJumps: arr(source.turnTimingRecentUpdateJumps ?? srcMetrics.turnTimingRecentUpdateSawtoothJumps).slice(0, 8), turnTimingElapsedZeroResets: arr(source.turnTimingElapsedZeroResets ?? srcMetrics.turnTimingElapsedZeroResets).slice(0, 8), turnTimingTotalElapsedForwardJumps: arr(source.turnTimingTotalElapsedForwardJumps ?? srcMetrics.turnTimingTotalElapsedForwardJumps).slice(0, 8), pagePerformanceSlowApi: slowApis.slice(0, 8).map(slimSlowApi), archivePagePerformanceSlowApi: arr(source.archivePagePerformanceSlowApi).slice(0, 8).map(slimSlowApi), pagePerformancePartialApi: arr(source.pagePerformancePartialApi).slice(0, 8), pagePerformanceSseStreams: arr(source.pagePerformanceSseStreams).slice(0, 8), findings: arr(source.findings).slice(0, 12).map(slimFinding), archiveRedFindings: arr(source.archiveRedFindings ?? archiveSummary.redFindings).slice(0, 12).map(slimFinding), reportJsonPath: source.reportJsonPath ?? reportJsonPath, reportJsonSha256: source.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: source.reportMdPath ?? reportMdPath, reportMdSha256: source.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(objectOrNull(source.analyzer) || {}), recoveredFrom: 'analysis-artifact-after-transport-timeout', stdoutPath, stderrPath, stdoutBytes: statSize(stdoutPath), stderrBytes: statSize(stderrPath), reportJsonBytes: statSize(reportJsonPath), reportMdBytes: statSize(reportMdPath), transportExitCode: Number(transportExitRaw), transportTimedOut: transportTimedOutRaw === 'true', valuesRedacted: true }, valuesRedacted: true };",
"console.log(JSON.stringify(compact));",
"UNIDESK_WEB_OBSERVE_RECOVER_ANALYZE_ARTIFACT",
].join("\n");
@@ -8840,6 +8877,15 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
const runtimeAlerts = record(analysis?.runtimeAlerts);
const pagePerformance = record(analysis?.pagePerformance);
const pagePerformanceSummary = record(pagePerformance?.summary);
const projectManagement = record(analysis?.projectManagement) ?? record(value.projectManagement);
const projectManagementSummary = record(projectManagement?.summary) ?? projectManagement;
const projectManagementCommandsSource = Array.isArray(projectManagement?.launchCommands) && projectManagement.launchCommands.length > 0
? projectManagement.launchCommands
: projectManagement?.commands;
const projectManagementCommands = webObserveArray(projectManagementCommandsSource).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8);
const projectManagementSamples = webObserveArray(projectManagement?.samples).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8);
const projectManagementApiByPath = webObserveArray(projectManagement?.projectApiByPath).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8);
const projectManagementSlowApis = webObserveArray(projectManagement?.slowProjectApiPerformance).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8);
const alertThresholds = record(analysis?.alertThresholds ?? pagePerformanceSummary?.alertThresholds ?? value.alertThresholds);
const budgetLabel = (rawValue: unknown): string => {
const parsed = Number(rawValue);
@@ -8848,6 +8894,7 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
return ms % 1000 === 0 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
};
const sameOriginApiBudgetLabel = budgetLabel(alertThresholds?.sameOriginApiSlowMs ?? pagePerformanceSummary?.budgetMs);
const projectManagementApiBudgetLabel = budgetLabel(projectManagementSummary?.slowApiBudgetMs ?? alertThresholds?.sameOriginApiSlowMs ?? pagePerformanceSummary?.budgetMs);
const partialApiBudgetLabel = budgetLabel(alertThresholds?.partialApiSlowMs);
const streamOpenBudgetLabel = budgetLabel(alertThresholds?.longLivedStreamOpenSlowMs);
const loadingBudgetLabel = budgetLabel(alertThresholds?.visibleLoadingSlowMs);
@@ -8993,6 +9040,47 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
webObserveShort(webObserveText(item.lastAt ?? item.firstAt), 24),
webObserveShort(webObserveArray(item.failureKinds).join(",") || "-", 40),
]);
const projectManagementCommandRows = projectManagementCommands.map((item) => [
webObserveShort(webObserveText(item.ts), 24),
webObserveShort(webObserveText(item.phase), 12),
webObserveShort(webObserveText(item.type), 26),
webObserveText(item.launchStatus ?? item.status),
webObserveShort(webObserveText(item.sessionId), 24),
webObserveShort(webObserveText(item.otelTraceId), 18),
webObserveShort(webObserveText(item.selectedTaskRefHash ?? item.taskHash), 18),
webObserveShort(webObserveText(item.workbenchUrl ?? item.afterPath), 44),
webObserveShort(webObserveText(item.message ?? item.errorMessageHash), 72),
]);
const projectManagementSampleRows = projectManagementSamples.map((item) => [
webObserveText(item.seq),
webObserveShort(webObserveText(item.ts), 24),
webObserveShort(webObserveText(item.pageRole), 12),
webObserveShort(webObserveText(item.pageKind), 28),
webObserveShort(webObserveText(item.path), 36),
webObserveText(item.sourceCount),
webObserveText(item.fileCount),
webObserveText(item.taskCount),
webObserveShort(webObserveText(item.selectedTaskRefHash), 18),
webObserveText(item.launchButtonEnabled),
webObserveText(item.workbenchLinkCount),
]);
const projectManagementApiRows = projectManagementApiByPath.map((item) => [
webObserveText(item.count ?? item.sampleCount),
webObserveShort(webObserveText(item.type), 12),
webObserveText(item.method),
webObserveText(item.status),
webObserveShort(webObserveText(item.path ?? item.urlPath), 52),
webObserveShort(webObserveText(item.lastAt ?? item.firstAt), 24),
webObserveShort(webObserveArray(item.failureKinds).join(",") || "-", 40),
]);
const projectManagementSlowRows = projectManagementSlowApis.map((item) => [
webObserveShort(webObserveText(item.path ?? item.route), 52),
webObserveText(item.sampleCount),
webObserveText(item.p95Ms ?? item.p95),
webObserveText(item.maxMs ?? item.max),
webObserveText(item.overBudgetCount ?? item.overFiveSecondCount),
webObserveShort(webObserveArray(item.slowSamples).map((sample) => webObserveText(record(sample)?.otelTraceId)).filter((text) => text !== "-").join(",") || "-", 36),
]);
const lines = [
`hwlab nodes web-probe observe analyze (${webObserveText(value.status)})`,
"",
@@ -9047,6 +9135,34 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
["md", webObserveShort(webObserveText(analysis?.reportMdPath), 96), webObserveShort(webObserveText(analysis?.reportMdSha256), 24)],
]),
"",
"Project management:",
webObserveTable(["ENABLED", "SAMPLES", "MDTODO", "LATEST", "SRC", "FILES", "TASKS", "SELECTED_TASK", "LAUNCH", "OTEL", "API", `SLOW>${projectManagementApiBudgetLabel}`], [[
webObserveText(projectManagementSummary?.enabled),
webObserveText(projectManagementSummary?.projectSampleCount),
webObserveText(projectManagementSummary?.mdtodoSampleCount),
webObserveShort([webObserveText(projectManagementSummary?.latestPageKind), webObserveText(projectManagementSummary?.latestPath)].filter((part) => part !== "-" && part !== "").join(" "), 52),
webObserveText(projectManagementSummary?.latestSourceCount),
webObserveText(projectManagementSummary?.latestFileCount),
webObserveText(projectManagementSummary?.latestTaskCount),
webObserveShort(webObserveText(projectManagementSummary?.latestSelectedTaskRefHash), 18),
`ok=${webObserveText(projectManagementSummary?.launchSuccessCount)} fail=${webObserveText(projectManagementSummary?.launchFailureCount)} total=${webObserveText(projectManagementSummary?.launchCommandCount)}`,
`${webObserveText(projectManagementSummary?.launchWithOtelTraceHeaderCount)}/${webObserveText(projectManagementSummary?.launchSuccessCount)}`,
`resp=${webObserveText(projectManagementSummary?.projectApiResponseCount)} fail=${webObserveText(projectManagementSummary?.projectApiFailureCount ?? 0)}/${webObserveText(projectManagementSummary?.projectApiRequestFailedCount ?? 0)}`,
webObserveText(projectManagementSummary?.projectApiSlowPathCount),
]]),
"",
"Project management samples:",
webObserveTable(["SEQ", "TS", "ROLE", "KIND", "PATH", "SRC", "FILES", "TASKS", "SELECTED", "LAUNCH", "LINKS"], projectManagementSampleRows.length > 0 ? projectManagementSampleRows : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]),
"",
"Project management commands:",
webObserveTable(["TS", "PHASE", "TYPE", "STATUS", "SESSION", "OTEL", "TASK", "URL", "MESSAGE"], projectManagementCommandRows.length > 0 ? projectManagementCommandRows : [["-", "-", "-", "-", "-", "-", "-", "-", "-"]]),
"",
"Project management API:",
webObserveTable(["COUNT", "TYPE", "METHOD", "STATUS", "PATH", "LAST", "FAILURE"], projectManagementApiRows.length > 0 ? projectManagementApiRows : [["-", "-", "-", "-", "-", "-", "-"]]),
"",
`Project management slow API (>${projectManagementApiBudgetLabel}):`,
webObserveTable(["PATH", "SAMPLES", "P95", "MAX", "OVER_BUDGET", "OTEL"], projectManagementSlowRows.length > 0 ? projectManagementSlowRows : [["-", "-", "-", "-", "-", "-"]]),
"",
"Turn timing:",
webObserveTable(["ROUNDS", "TURNS", "ROWS", "NON_MONO", "ELAPSED_DEC", "ZERO_RESET", "ELAPSED_JUMP", "TERMINAL_GROWTH", "RECENT_JUMP", "MAX_RECENT_STEP"], [[
webObserveText(roundCount),
@@ -10477,6 +10593,13 @@ function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number): stri
function commandSummaryForOutput(payload: Record<string, unknown>): Record<string, unknown> {
const text = typeof payload.text === "string" ? payload.text : null;
const opaque = (value: unknown) => typeof value === "string" && value.length > 0
? {
hash: `sha256:${createHash("sha256").update(value).digest("hex")}`,
preview: value.length <= 18 ? value : `${value.slice(0, 10)}...${value.slice(-5)}`,
bytes: Buffer.byteLength(value),
}
: null;
return {
id: payload.id,
type: payload.type,
@@ -10484,6 +10607,9 @@ function commandSummaryForOutput(payload: Record<string, unknown>): Record<strin
label: payload.label ?? null,
sessionId: payload.sessionId ?? null,
provider: payload.provider ?? null,
sourceId: opaque(payload.sourceId),
fileRef: opaque(payload.fileRef),
taskRef: opaque(payload.taskRef),
textHash: text === null ? null : `sha256:${createHash("sha256").update(text).digest("hex")}`,
textBytes: text === null ? null : Buffer.byteLength(text),
valuesRedacted: true,
+42
View File
@@ -128,6 +128,7 @@ export interface HwlabRuntimeWebProbeSpec {
readonly browserProxyMode?: "auto" | "direct";
readonly defaultOrigin?: HwlabRuntimeWebProbeOriginSpec;
readonly alertThresholds?: HwlabRuntimeWebProbeAlertThresholdsSpec;
readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec;
}
export interface HwlabRuntimeWebProbeAlertThresholdsSpec {
@@ -143,6 +144,16 @@ export interface HwlabRuntimeWebProbeAlertThresholdsSpec {
readonly sessionRailFallbackRatio: number;
}
export interface HwlabRuntimeWebProbeProjectManagementSpec {
readonly enabled: boolean;
readonly targetPaths: readonly string[];
readonly readinessSelectors: readonly string[];
readonly naturalApiPathPrefixes: readonly string[];
readonly commandAllowlist: readonly string[];
readonly launchRoute: string;
readonly slowApiBudgetMs: number;
}
export interface HwlabRuntimeBuildkitSpec {
readonly sidecarImage: string;
}
@@ -369,6 +380,12 @@ function stringArrayField(obj: Record<string, unknown>, key: string, path: strin
return [...value] as string[];
}
function nonEmptyStringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const values = stringArrayField(obj, key, path);
if (values.length === 0) throw new Error(`${path}.${key} must contain at least one string`);
return values;
}
function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined) return undefined;
@@ -711,6 +728,31 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
...(browserProxyMode === undefined ? {} : { browserProxyMode }),
...(raw.defaultOrigin === undefined ? {} : { defaultOrigin: webProbeOriginConfig(raw.defaultOrigin, `${path}.defaultOrigin`) }),
...(raw.alertThresholds === undefined ? {} : { alertThresholds: webProbeAlertThresholdsConfig(raw.alertThresholds, `${path}.alertThresholds`) }),
...(raw.projectManagement === undefined ? {} : { projectManagement: webProbeProjectManagementConfig(raw.projectManagement, `${path}.projectManagement`) }),
};
}
function webProbeProjectManagementConfig(value: unknown, path: string): HwlabRuntimeWebProbeProjectManagementSpec {
const raw = asRecord(value, path);
const targetPaths = nonEmptyStringArrayField(raw, "targetPaths", path);
const readinessSelectors = nonEmptyStringArrayField(raw, "readinessSelectors", path);
const naturalApiPathPrefixes = nonEmptyStringArrayField(raw, "naturalApiPathPrefixes", path);
const commandAllowlist = nonEmptyStringArrayField(raw, "commandAllowlist", path);
const launchRoute = stringField(raw, "launchRoute", path);
if (!launchRoute.startsWith("/")) throw new Error(`${path}.launchRoute must be an absolute path`);
for (const [field, values] of Object.entries({ targetPaths, naturalApiPathPrefixes })) {
for (const item of values) {
if (!item.startsWith("/")) throw new Error(`${path}.${field} entries must be absolute paths; got ${item}`);
}
}
return {
enabled: booleanField(raw, "enabled", path),
targetPaths,
readinessSelectors,
naturalApiPathPrefixes,
commandAllowlist,
launchRoute,
slowApiBudgetMs: positiveNumberField(raw, "slowApiBudgetMs", path),
};
}
@@ -19,6 +19,7 @@ const analyzeTailSamples = (() => {
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 360;
})();
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
const projectManagementConfig = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
const dataDir = archivePrefix ? path.join(stateDir, "archive") : stateDir;
const dataFile = (name) => path.join(dataDir, archivePrefix ? archivePrefix + "-" + name : name);
const analysisDir = path.join(stateDir, "analysis");
@@ -53,6 +54,7 @@ const pageProvenance = buildPageProvenanceReport(samples, control, manifest);
const pagePerformance = buildPagePerformanceReport(samples, manifest);
const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows);
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
const projectManagement = buildProjectManagementReport(samples, control, network, pagePerformance, projectManagementConfig);
const runnerErrors = errors.slice(-8).map((item) => {
const attempts = Array.isArray(item.error?.attempts) ? item.error.attempts : [];
const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
@@ -95,7 +97,7 @@ const runnerErrors = errors.slice(-8).map((item) => {
});
const commandFailures = summarizeCommandFailures(control);
const toolFindings = buildToolFindings({ manifest, heartbeat, commandState });
const findings = [...toolFindings, ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures)];
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures)];
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest });
const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl }));
@@ -114,6 +116,7 @@ const report = {
sampleMetrics,
pageProvenance,
pagePerformance,
projectManagement,
promptNetwork,
runtimeAlerts,
runnerErrors,
@@ -142,6 +145,7 @@ console.log(JSON.stringify({
archiveSummary: {
sampleMetrics: sampleMetrics.summary,
pagePerformance: pagePerformance.summary,
projectManagement: projectManagement.summary,
runtimeAlerts: runtimeAlerts.summary,
findingCount: findings.length,
redFindingCount: findings.filter((item) => item.severity === "red").length,
@@ -159,6 +163,7 @@ console.log(JSON.stringify({
},
pageProvenance: recentWindow.pageProvenance.summary,
pagePerformance: recentWindow.pagePerformance.summary,
projectManagement: compactProjectManagementForOutput(projectManagement),
promptNetwork: recentWindow.promptNetwork.summary,
runtimeAlerts: recentWindow.runtimeAlerts.summary,
runnerErrors,
@@ -538,6 +543,47 @@ function parseAlertThresholds(value) {
};
}
function parseProjectManagementConfig(value) {
if (!value || value === "null") {
return {
enabled: false,
targetPaths: [],
readinessSelectors: [],
naturalApiPathPrefixes: [],
commandAllowlist: [],
launchRoute: "",
slowApiBudgetMs: 0,
source: "yaml-env",
valuesRedacted: true
};
}
const raw = (() => {
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
})();
if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled");
if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true };
const stringList = (key) => {
const list = raw?.[key];
if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
return list;
};
const slowApiBudgetMs = Number(raw?.slowApiBudgetMs);
if (!Number.isFinite(slowApiBudgetMs) || slowApiBudgetMs <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive slowApiBudgetMs");
const launchRoute = String(raw.launchRoute || "");
if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path");
return {
enabled: true,
targetPaths: stringList("targetPaths"),
readinessSelectors: stringList("readinessSelectors"),
naturalApiPathPrefixes: stringList("naturalApiPathPrefixes"),
commandAllowlist: stringList("commandAllowlist"),
launchRoute,
slowApiBudgetMs,
source: "yaml-env",
valuesRedacted: true
};
}
async function readJsonl(file, options = {}) {
const tailLimit = Number.isFinite(Number(options.tail)) && Number(options.tail) > 0 ? Math.floor(Number(options.tail)) : 0;
if (tailLimit > 0) return readJsonlTail(file, tailLimit, options);
@@ -712,11 +758,38 @@ function compactSampleForAnalysis(sample) {
sessionRail: compactSessionRail(sample.sessionRail),
turns: compactDomItems(sample.turns),
diagnostics: compactDomItems(sample.diagnostics),
projectManagement: compactProjectManagementSample(sample.projectManagement),
pageProvenance: compactSamplePageProvenance(sample.pageProvenance),
performance: compactPerformanceItems(sample.performance)
};
}
function compactProjectManagementSample(value) {
if (!value || typeof value !== "object") return null;
return {
pageKind: value.pageKind ?? null,
configuredPath: value.configuredPath === true,
rootVisible: value.rootVisible === true,
mdtodoVisible: value.mdtodoVisible === true,
sourceCount: value.sourceCount ?? null,
fileCount: value.fileCount ?? null,
taskCount: value.taskCount ?? null,
taskRefMissingCount: value.taskRefMissingCount ?? null,
selectedSourceId: value.selectedSourceId ?? null,
selectedFileRef: value.selectedFileRef ?? null,
selectedTaskRef: value.selectedTaskRef ?? null,
selectedTaskStatus: value.selectedTaskStatus ?? null,
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
launchButtonVisible: value.launchButtonVisible === true,
launchButtonEnabled: value.launchButtonEnabled === true,
launchButtonText: value.launchButtonText ?? null,
blockerCount: value.blockerCount ?? 0,
blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 6) : [],
workbenchLinkCount: value.workbenchLinkCount ?? 0,
valuesRedacted: true
};
}
function compactSessionRail(value) {
if (!value || typeof value !== "object") return null;
const items = Array.isArray(value.items) ? value.items.slice(0, 80).map((item) => ({
@@ -917,6 +990,273 @@ function summarizeCommandFailures(control) {
});
}
function buildProjectManagementReport(samples, control, network, pagePerformance, config) {
const enabled = config?.enabled === true;
const targetPathSamples = (samples || []).filter((sample) => enabled && config.targetPaths.some((target) => String(sample?.path || "").startsWith(target)));
const projectSamples = (samples || []).filter((sample) => sample?.projectManagement && typeof sample.projectManagement === "object");
const latest = projectSamples[projectSamples.length - 1] || null;
const latestProject = latest?.projectManagement || null;
const pageKindCounts = countBy(projectSamples.map((sample) => sample.projectManagement?.pageKind).filter(Boolean));
const latestTaskStatusCounts = latestProject?.taskStatusCounts && typeof latestProject.taskStatusCounts === "object" ? latestProject.taskStatusCounts : {};
const commandRows = projectManagementCommandRows(control, config);
const launchCommands = commandRows.filter((item) => item.type === "launchWorkbenchFromTask");
const launchSuccess = launchCommands.filter((item) => item.phase === "completed" && Number(item.launchStatus ?? 0) >= 200 && Number(item.launchStatus ?? 0) < 300);
const launchFailed = launchCommands.filter((item) => item.phase === "failed" || Number(item.launchStatus ?? 200) >= 400);
const projectApiEvents = projectManagementNetworkRows(network, config);
const projectApiResponses = projectApiEvents.filter((item) => item.type === "response");
const projectApiFailures = projectApiResponses.filter((item) => Number(item.status ?? 0) >= 400);
const projectApiFailedRequests = projectApiEvents.filter((item) => item.type === "requestfailed");
const projectApiByPath = groupProjectApiEvents(projectApiEvents);
const projectApiPerformance = projectManagementPerformanceRows(pagePerformance, config);
const slowProjectApiPerformance = projectApiPerformance.filter((item) => Number(item.overBudgetCount ?? 0) > 0 || Number(item.p95Ms ?? 0) > Number(config?.slowApiBudgetMs ?? 0));
const selectedTaskSamples = projectSamples.filter((sample) => sample.projectManagement?.selectedTaskRef?.hash);
const launchEnabledSamples = projectSamples.filter((sample) => sample.projectManagement?.launchButtonEnabled === true);
const launchVisibleSamples = projectSamples.filter((sample) => sample.projectManagement?.launchButtonVisible === true);
const mdtodoSamples = projectSamples.filter((sample) => sample.projectManagement?.pageKind === "project-management-mdtodo");
return {
enabled,
config: config || null,
summary: {
enabled,
targetPathSampleCount: targetPathSamples.length,
projectSampleCount: projectSamples.length,
mdtodoSampleCount: mdtodoSamples.length,
pageKindCounts,
latestPageKind: latestProject?.pageKind ?? null,
latestPath: latest?.path ?? null,
latestSeq: latest?.seq ?? null,
latestTs: latest?.ts ?? null,
latestSourceCount: latestProject?.sourceCount ?? null,
latestFileCount: latestProject?.fileCount ?? null,
latestTaskCount: latestProject?.taskCount ?? null,
maxSourceCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.sourceCount)),
maxFileCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.fileCount)),
maxTaskCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.taskCount)),
taskRefMissingMax: maxNumber(projectSamples.map((sample) => sample.projectManagement?.taskRefMissingCount)),
latestSelectedTaskRefHash: latestProject?.selectedTaskRef?.hash ?? null,
latestSelectedTaskRefPreview: latestProject?.selectedTaskRef?.preview ?? null,
latestSelectedTaskStatus: latestProject?.selectedTaskStatus ?? null,
latestTaskStatusCounts,
launchButtonVisibleSamples: launchVisibleSamples.length,
launchButtonEnabledSamples: launchEnabledSamples.length,
launchButtonDisabledSamples: Math.max(0, launchVisibleSamples.length - launchEnabledSamples.length),
latestWorkbenchLinkCount: latestProject?.workbenchLinkCount ?? null,
maxWorkbenchLinkCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.workbenchLinkCount)),
maxBlockerCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.blockerCount)),
selectedTaskSampleCount: selectedTaskSamples.length,
projectCommandCount: commandRows.length,
launchCommandCount: launchCommands.length,
launchSuccessCount: launchSuccess.length,
launchFailureCount: launchFailed.length,
launchWithOtelTraceHeaderCount: launchSuccess.filter((item) => item.otelTraceId).length,
projectApiEventCount: projectApiEvents.length,
projectApiResponseCount: projectApiResponses.length,
projectApiFailureCount: projectApiFailures.length,
projectApiRequestFailedCount: projectApiFailedRequests.length,
projectApiSlowPathCount: slowProjectApiPerformance.length,
slowApiBudgetMs: config?.slowApiBudgetMs ?? null,
valuesRedacted: true
},
latest: latestProject,
samples: projectSamples.slice(-80).map((sample) => ({
seq: sample.seq ?? null,
ts: sample.ts ?? null,
pageRole: sample.pageRole ?? null,
path: sample.path ?? null,
pageKind: sample.projectManagement?.pageKind ?? null,
sourceCount: sample.projectManagement?.sourceCount ?? null,
fileCount: sample.projectManagement?.fileCount ?? null,
taskCount: sample.projectManagement?.taskCount ?? null,
taskRefMissingCount: sample.projectManagement?.taskRefMissingCount ?? null,
selectedTaskRefHash: sample.projectManagement?.selectedTaskRef?.hash ?? null,
selectedTaskStatus: sample.projectManagement?.selectedTaskStatus ?? null,
launchButtonVisible: sample.projectManagement?.launchButtonVisible === true,
launchButtonEnabled: sample.projectManagement?.launchButtonEnabled === true,
blockerCount: sample.projectManagement?.blockerCount ?? 0,
workbenchLinkCount: sample.projectManagement?.workbenchLinkCount ?? 0,
valuesRedacted: true
})),
targetPathWithoutProjectSummary: targetPathSamples.filter((sample) => !sample.projectManagement).slice(0, 20).map(ref),
commands: commandRows,
launchCommands,
projectApiByPath,
projectApiFailures: projectApiFailures.slice(0, 40),
projectApiRequestFailed: projectApiFailedRequests.slice(0, 40),
projectApiPerformance,
slowProjectApiPerformance,
valuesRedacted: true
};
}
function compactProjectManagementForOutput(report) {
if (!report || typeof report !== "object") return null;
const compactCommand = (item) => ({
ts: item?.ts ?? null,
phase: item?.phase ?? null,
type: item?.type ?? null,
commandId: item?.commandId ?? null,
afterPath: item?.afterPath ?? null,
launchStatus: item?.launchStatus ?? null,
sessionId: item?.sessionId ?? null,
workbenchUrl: item?.workbenchUrl ?? null,
otelTraceId: item?.otelTraceId ?? null,
contractVersion: item?.contractVersion ?? null,
selectedTaskRefHash: item?.selectedTaskRefHash ?? null,
errorMessageHash: item?.errorMessageHash ?? null,
message: item?.message ? limitText(item.message, 180) : null,
valuesRedacted: true
});
const compactApiGroup = (item) => ({
method: item?.method ?? null,
path: item?.path ?? item?.urlPath ?? null,
status: item?.status ?? null,
type: item?.type ?? null,
count: item?.count ?? item?.sampleCount ?? null,
firstAt: item?.firstAt ?? null,
lastAt: item?.lastAt ?? null,
failureKinds: Array.isArray(item?.failureKinds) ? item.failureKinds.slice(0, 4) : [],
valuesRedacted: true
});
const compactSlowSample = (item) => ({
ts: item?.ts ?? null,
seq: item?.seq ?? null,
path: item?.path ?? item?.rawPath ?? null,
durationMs: item?.durationMs ?? null,
requestToResponseStartMs: item?.requestToResponseStartMs ?? item?.streamOpenMs ?? null,
responseTransferMs: item?.responseTransferMs ?? null,
timingStatus: item?.timingStatus ?? null,
initiatorType: item?.initiatorType ?? null,
nextHopProtocol: item?.nextHopProtocol ?? null,
serverTimingNames: Array.isArray(item?.serverTimingNames) ? item.serverTimingNames.slice(0, 4) : [],
otelTraceId: item?.otelTraceId ?? null,
valuesRedacted: true
});
const compactPerformance = (item) => ({
path: item?.path ?? item?.route ?? null,
sampleCount: item?.sampleCount ?? null,
p95Ms: item?.p95Ms ?? item?.p95 ?? null,
maxMs: item?.maxMs ?? item?.max ?? null,
budgetMs: item?.projectSlowBudgetMs ?? item?.budgetMs ?? report.summary?.slowApiBudgetMs ?? null,
overBudgetCount: item?.overBudgetCount ?? item?.overFiveSecondCount ?? null,
slowSamples: Array.isArray(item?.slowSamples) ? item.slowSamples.slice(0, 3).map(compactSlowSample) : [],
valuesRedacted: true
});
return {
summary: report.summary ?? null,
samples: Array.isArray(report.samples) ? report.samples.slice(-8) : [],
commands: Array.isArray(report.commands) ? report.commands.slice(-8).map(compactCommand) : [],
launchCommands: Array.isArray(report.launchCommands) ? report.launchCommands.slice(-8).map(compactCommand) : [],
projectApiByPath: Array.isArray(report.projectApiByPath) ? report.projectApiByPath.slice(0, 8).map(compactApiGroup) : [],
projectApiPerformance: Array.isArray(report.projectApiPerformance) ? report.projectApiPerformance.slice(0, 8).map(compactPerformance) : [],
slowProjectApiPerformance: Array.isArray(report.slowProjectApiPerformance) ? report.slowProjectApiPerformance.slice(0, 8).map(compactPerformance) : [],
valuesRedacted: true
};
}
function projectManagementCommandRows(control, config) {
const allowed = new Set(config?.commandAllowlist || []);
return (control || [])
.filter((item) => allowed.has(item?.type) || String(item?.type || "").startsWith("selectMdtodo") || item?.type === "selectProjectSource" || item?.type === "launchWorkbenchFromTask")
.filter((item) => item.phase === "completed" || item.phase === "failed")
.map((item) => {
const detail = item.detail && typeof item.detail === "object" ? item.detail : {};
const error = detail.error && typeof detail.error === "object" ? detail.error : {};
return {
ts: item.ts ?? null,
phase: item.phase ?? null,
type: item.type ?? null,
commandId: item.commandId ?? null,
afterPath: urlPath(item.afterUrl),
launchStatus: detail.launchStatus ?? error.details?.launchStatus ?? null,
sessionId: detail.sessionId ?? error.details?.sessionId ?? null,
workbenchUrl: detail.workbenchUrl ?? error.details?.workbenchUrl ?? null,
otelTraceId: detail.otelTraceId ?? error.details?.otelTraceId ?? null,
contractVersion: detail.contractVersion ?? error.details?.contractVersion ?? null,
selectedTaskRefHash: detail.selectedTask?.hash ?? detail.projectBeforeClick?.selectedTaskRef?.hash ?? null,
errorName: error.name ?? null,
errorMessageHash: error.message ? sha256(error.message) : null,
message: error.message ? limitText(error.message, 180) : null,
valuesRedacted: true
};
});
}
function projectManagementNetworkRows(network, config) {
const prefixes = config?.naturalApiPathPrefixes || [];
return (network || [])
.filter((item) => item?.observerInitiated !== true)
.map((item) => ({
ts: item.ts ?? null,
type: item.type ?? null,
method: String(item.method || "GET").toUpperCase(),
status: Number.isFinite(Number(item.status)) ? Number(item.status) : null,
path: urlPath(item.url),
failureKind: item.failure ? limitText(item.failure, 120) : null,
valuesRedacted: true
}))
.filter((item) => prefixes.some((prefix) => String(item.path || "").startsWith(prefix)));
}
function groupProjectApiEvents(events) {
const groups = new Map();
for (const item of events || []) {
const key = [item.method, item.path, item.status ?? "-", item.type].join(" ");
const existing = groups.get(key) || { method: item.method, path: item.path, status: item.status, type: item.type, count: 0, firstAt: item.ts, lastAt: item.ts, failureKinds: [], valuesRedacted: true };
existing.count += 1;
existing.lastAt = item.ts;
if (item.failureKind && !existing.failureKinds.includes(item.failureKind)) existing.failureKinds.push(item.failureKind);
groups.set(key, existing);
}
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.path).localeCompare(String(b.path)));
}
function projectManagementPerformanceRows(pagePerformance, config) {
const prefixes = config?.naturalApiPathPrefixes || [];
const rows = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath : [];
return rows
.filter((item) => prefixes.some((prefix) => String(item?.path || "").startsWith(prefix)))
.map((item) => ({ ...item, projectSlowBudgetMs: config?.slowApiBudgetMs ?? null }));
}
function buildProjectManagementFindings(report) {
if (!report?.enabled) return [];
const findings = [];
const summary = report.summary || {};
if (Number(summary.targetPathSampleCount ?? 0) > 0 && Number(summary.projectSampleCount ?? 0) === 0) {
findings.push({ id: "project-management-route-not-ready", severity: "red", summary: "project management target path was sampled but no project-management DOM summary was detected", count: summary.targetPathSampleCount, samples: report.targetPathWithoutProjectSummary, valuesRedacted: true });
}
if (Number(summary.taskRefMissingMax ?? 0) > 0) {
findings.push({ id: "mdtodo-taskref-missing", severity: "red", summary: "mdtodo task rows were visible without stable data-task-ref; Workbench launch must bind by opaque public task id", count: summary.taskRefMissingMax, samples: report.samples.filter((item) => Number(item.taskRefMissingCount ?? 0) > 0).slice(0, 20), valuesRedacted: true });
}
if (Number(summary.mdtodoSampleCount ?? 0) > 0 && Number(summary.latestTaskCount ?? 0) > 0 && Number(summary.launchButtonEnabledSamples ?? 0) === 0) {
findings.push({ id: "workbench-launch-button-unavailable", severity: "red", summary: "mdtodo tasks were sampled but the Workbench launch button was never enabled", count: summary.mdtodoSampleCount, latest: report.latest, valuesRedacted: true });
}
if (Number(summary.projectApiFailureCount ?? 0) > 0 || Number(summary.projectApiRequestFailedCount ?? 0) > 0) {
findings.push({ id: "project-management-api-failed", severity: "amber", summary: "natural project-management or Workbench launch API requests failed during observation", count: Number(summary.projectApiFailureCount ?? 0) + Number(summary.projectApiRequestFailedCount ?? 0), groups: report.projectApiByPath.slice(0, 12), valuesRedacted: true });
}
if (Number(summary.projectApiSlowPathCount ?? 0) > 0) {
findings.push({ id: "project-management-api-slow", severity: "red", summary: "project-management API resource timing exceeded YAML projectManagement.slowApiBudgetMs", count: summary.projectApiSlowPathCount, budgetMs: summary.slowApiBudgetMs, groups: report.slowProjectApiPerformance.slice(0, 12), valuesRedacted: true });
}
if (Number(summary.launchFailureCount ?? 0) > 0) {
findings.push({ id: "mdtodo-workbench-launch-failed", severity: "red", summary: "launchWorkbenchFromTask command failed or returned an HTTP error", count: summary.launchFailureCount, commands: report.launchCommands.filter((item) => item.phase === "failed" || Number(item.launchStatus ?? 200) >= 400).slice(0, 12), valuesRedacted: true });
}
if (Number(summary.launchSuccessCount ?? 0) > 0 && Number(summary.launchWithOtelTraceHeaderCount ?? 0) === 0) {
findings.push({ id: "mdtodo-workbench-launch-otel-trace-missing", severity: "amber", summary: "Workbench launch succeeded but no x-hwlab-otel-trace-id header was captured for Tempo drill-down", count: summary.launchSuccessCount, commands: report.launchCommands.slice(0, 12), valuesRedacted: true });
}
return findings;
}
function countBy(values) {
const out = {};
for (const value of values || []) out[value] = (out[value] || 0) + 1;
return out;
}
function maxNumber(values) {
const numeric = (values || []).map((value) => Number(value)).filter(Number.isFinite);
return numeric.length > 0 ? Math.max(...numeric) : 0;
}
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = []) {
const findings = [];
if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) });
@@ -2140,6 +2480,7 @@ function prioritizeFindings(findings) {
};
const kindRank = (item) => {
const id = String(item?.id ?? item?.kind ?? item?.code ?? "");
if (id.startsWith("project-management-") || id.startsWith("mdtodo-") || id === "workbench-launch-button-unavailable") return 0;
if (id === "page-performance-slow-same-origin-api") return 0;
if (id === "session-rail-title-fallback-majority") return 0.5;
if (id.startsWith("code-agent-card-")) return 0.8;
@@ -2133,6 +2133,17 @@ function renderMarkdown(report) {
const traceOrder = report.sampleMetrics?.traceOrder || {};
const traceOrderSummary = traceOrder.summary || {};
const alertSummary = report.runtimeAlerts?.summary || {};
const projectManagement = report.projectManagement || {};
const projectSummary = projectManagement.summary || {};
const projectCommandLines = Array.isArray(projectManagement.commands) && projectManagement.commands.length > 0
? projectManagement.commands.slice(0, 40).map((item) => "- " + (item.ts || "-") + " " + (item.phase || "-") + " " + (item.type || "-") + " command=" + (item.commandId || "-") + " status=" + (item.launchStatus ?? "-") + " session=" + (item.sessionId || "-") + " otel=" + (item.otelTraceId || "-") + " taskHash=" + (item.selectedTaskRefHash || "-")).join("\n")
: "- 无项目管理控制命令。";
const projectApiLines = Array.isArray(projectManagement.projectApiByPath) && projectManagement.projectApiByPath.length > 0
? projectManagement.projectApiByPath.slice(0, 40).map((item) => "- " + (item.method || "-") + " " + (item.path || "-") + " type=" + (item.type || "-") + " status=" + (item.status ?? "-") + " count=" + (item.count ?? 0) + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n")
: "- 无项目管理自然 API 记录。";
const projectSampleLines = Array.isArray(projectManagement.samples) && projectManagement.samples.length > 0
? projectManagement.samples.slice(-40).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " role=" + (item.pageRole || "-") + " kind=" + (item.pageKind || "-") + " src=" + (item.sourceCount ?? "-") + " files=" + (item.fileCount ?? "-") + " tasks=" + (item.taskCount ?? "-") + " selectedTask=" + (item.selectedTaskRefHash || "-") + " launchEnabled=" + String(item.launchButtonEnabled === true) + " links=" + (item.workbenchLinkCount ?? 0)).join("\n")
: "- 无项目管理 DOM 采样。";
const httpAlertLines = Array.isArray(report.runtimeAlerts?.networkHttpErrorsByPath) && report.runtimeAlerts.networkHttpErrorsByPath.length > 0
? report.runtimeAlerts.networkHttpErrorsByPath.slice(0, 40).map((item) => "- HTTP " + (item.status ?? "-") + " " + item.method + " " + item.urlPath + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n")
: "- 无 HTTP 错误。";
@@ -2225,6 +2236,19 @@ function renderMarkdown(report) {
+ "- console: " + (report.counts.console ?? 0) + "\n"
+ "- errors: " + report.counts.errors + "\n\n"
+ "## Findings\n\n" + findingLines + "\n\n"
+ "## Project management\n\n"
+ "- enabled: " + String(projectSummary.enabled === true) + "\n"
+ "- projectSampleCount: " + (projectSummary.projectSampleCount ?? 0) + "\n"
+ "- mdtodoSampleCount: " + (projectSummary.mdtodoSampleCount ?? 0) + "\n"
+ "- latestPageKind: " + (projectSummary.latestPageKind || "-") + "\n"
+ "- latestPath: " + (projectSummary.latestPath || "-") + "\n"
+ "- latestCounts: source=" + (projectSummary.latestSourceCount ?? "-") + " file=" + (projectSummary.latestFileCount ?? "-") + " task=" + (projectSummary.latestTaskCount ?? "-") + "\n"
+ "- latestSelectedTaskRefHash: " + (projectSummary.latestSelectedTaskRefHash || "-") + "\n"
+ "- launch: commands=" + (projectSummary.launchCommandCount ?? 0) + " success=" + (projectSummary.launchSuccessCount ?? 0) + " failure=" + (projectSummary.launchFailureCount ?? 0) + " otelTraceHeader=" + (projectSummary.launchWithOtelTraceHeaderCount ?? 0) + "\n"
+ "- projectApi: responses=" + (projectSummary.projectApiResponseCount ?? 0) + " failures=" + (projectSummary.projectApiFailureCount ?? 0) + " requestfailed=" + (projectSummary.projectApiRequestFailedCount ?? 0) + " slowPaths=" + (projectSummary.projectApiSlowPathCount ?? 0) + "\n\n"
+ "### Project samples\n\n" + projectSampleLines + "\n\n"
+ "### Project commands\n\n" + projectCommandLines + "\n\n"
+ "### Project API\n\n" + projectApiLines + "\n\n"
+ "## Command failures\n\n" + commandFailureLines + "\n\n"
+ "## Sample metrics\n\n"
+ "- sampleCount: " + (metricSummary.sampleCount ?? 0) + "\n"
+46 -3
View File
@@ -2,11 +2,11 @@
// Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts.
import { shellQuote } from "./ssh";
export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame";
export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame" | "project-summary";
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}`);
if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "project-summary") return value;
throw new Error(`web-probe observe collect --view must be files, turn-summary, trace-frame, or project-summary; got ${value}`);
}
export function nodeWebObserveCollectViewNodeScript(input: {
@@ -322,7 +322,50 @@ function renderTraceFrame(sample,rows){
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||'-'),...bodyRows,'==========================','Final Response',finalResponse.preview||'(空内容)'].join('\\n');
return {ok:!missingRows,renderedText:rendered,blocker:missingRows?'trace-rows-missing':null,sampleSeq:sample.seq??null,traceId,finalResponse,traceDiagnostic:missingRows?{pageRole:sample.pageRole||null,pageId:sample.pageId||null,traceRows:Array.isArray(sample.traceRows)?sample.traceRows.length:0,turns:Array.isArray(sample.turns)?sample.turns.length:0,messages:Array.isArray(sample.messages)?sample.messages.length:0,sampleTraceIds:traceIdsFromSamples([sample]).slice(0,12)}:null,valuesRedacted:true};
}
function projectSummaryFromSamples(){
const projectSamples=samples.filter((sample)=>sample?.projectManagement&&typeof sample.projectManagement==='object');
const latest=projectSamples[projectSamples.length-1]||null;
const latestProject=latest?.projectManagement||null;
const launches=control.filter((item)=>item.type==='launchWorkbenchFromTask'&&(item.phase==='completed'||item.phase==='failed')).map((item)=>{
const detail=item.detail&&typeof item.detail==='object'?item.detail:{};
const error=detail.error&&typeof detail.error==='object'?detail.error:{};
return {ts:item.ts||null,phase:item.phase||null,commandId:item.commandId||null,status:detail.launchStatus??error.details?.launchStatus??null,sessionId:detail.sessionId??error.details?.sessionId??null,workbenchUrl:detail.workbenchUrl??error.details?.workbenchUrl??null,otelTraceId:detail.otelTraceId??error.details?.otelTraceId??null,taskHash:detail.selectedTask?.hash??detail.projectBeforeClick?.selectedTaskRef?.hash??null,message:error.message?short(error.message,160):null,valuesRedacted:true};
});
const findings=Array.isArray(report.findings)?report.findings.filter((item)=>String(item?.id||item?.kind||'').match(/project-management|mdtodo|workbench-launch/u)).slice(0,20):[];
const summary=report.projectManagement?.summary||{};
const mdtodoSampleCount=projectSamples.filter((sample)=>sample.projectManagement?.pageKind==='project-management-mdtodo').length;
const derived={enabled:summary.enabled===true||projectSamples.length>0,projectSampleCount:Math.max(Number(summary.projectSampleCount??0),projectSamples.length),mdtodoSampleCount:Math.max(Number(summary.mdtodoSampleCount??0),mdtodoSampleCount),latestPageKind:summary.latestPageKind??latestProject?.pageKind??null,latestPath:summary.latestPath??latest?.path??null,latestSeq:summary.latestSeq??latest?.seq??null,latestTs:summary.latestTs??latest?.ts??null,latestSourceCount:summary.latestSourceCount??latestProject?.sourceCount??null,latestFileCount:summary.latestFileCount??latestProject?.fileCount??null,latestTaskCount:summary.latestTaskCount??latestProject?.taskCount??null,latestSelectedTaskRefHash:summary.latestSelectedTaskRefHash??latestProject?.selectedTaskRef?.hash??null,latestSelectedTaskRefPreview:summary.latestSelectedTaskRefPreview??latestProject?.selectedTaskRef?.preview??null,launchCommandCount:summary.launchCommandCount??launches.length,launchSuccessCount:summary.launchSuccessCount??launches.filter((item)=>Number(item.status)>=200&&Number(item.status)<300).length,launchFailureCount:summary.launchFailureCount??launches.filter((item)=>item.phase==='failed'||Number(item.status)>=400).length,launchWithOtelTraceHeaderCount:summary.launchWithOtelTraceHeaderCount??launches.filter((item)=>item.otelTraceId).length,projectApiResponseCount:summary.projectApiResponseCount??null,projectApiFailureCount:summary.projectApiFailureCount??null,projectApiRequestFailedCount:summary.projectApiRequestFailedCount??null,projectApiSlowPathCount:summary.projectApiSlowPathCount??null,valuesRedacted:true};
return {summary:derived,launches:launches.slice(-12),findings,sampleRows:projectSamples.slice(-12).map((sample)=>({seq:sample.seq??null,ts:sample.ts??null,pageRole:sample.pageRole??null,path:sample.path??null,pageKind:sample.projectManagement?.pageKind??null,sourceCount:sample.projectManagement?.sourceCount??null,fileCount:sample.projectManagement?.fileCount??null,taskCount:sample.projectManagement?.taskCount??null,selectedTaskRefHash:sample.projectManagement?.selectedTaskRef?.hash??null,selectedTaskStatus:sample.projectManagement?.selectedTaskStatus??null,launchButtonEnabled:sample.projectManagement?.launchButtonEnabled===true,workbenchLinkCount:sample.projectManagement?.workbenchLinkCount??0,valuesRedacted:true})),valuesRedacted:true};
}
function targetNodeFromStateDir(){
const parts=String(dir||'').split(/[\\\\/]+/u);
const index=parts.lastIndexOf('web-observe');
return index>=0&&parts[index+1]?parts[index+1]:null;
}
function renderProjectSummary(project){
const s=project.summary||{};
const lines=['Project management observer '+(manifest.jobId||'-'),'=======================================================','enabled='+String(s.enabled===true)+' samples='+String(s.projectSampleCount??0)+' mdtodo='+String(s.mdtodoSampleCount??0)+' latest='+String(s.latestPageKind||'-')+' path='+String(s.latestPath||'-'),'counts source='+String(s.latestSourceCount??'-')+' file='+String(s.latestFileCount??'-')+' task='+String(s.latestTaskCount??'-')+' selectedTask='+String(s.latestSelectedTaskRefHash||'-'),'launch commands='+String(s.launchCommandCount??0)+' success='+String(s.launchSuccessCount??0)+' failure='+String(s.launchFailureCount??0)+' otelTraceHeader='+String(s.launchWithOtelTraceHeaderCount??0),'api responses='+String(s.projectApiResponseCount??'-')+' failures='+String(s.projectApiFailureCount??'-')+'/'+String(s.projectApiRequestFailedCount??'-')+' slowPaths='+String(s.projectApiSlowPathCount??'-'),'','Recent samples'];
for(const row of project.sampleRows.slice(-12)) lines.push('#'+String(row.seq??'-')+' '+String(row.ts||'-')+' '+String(row.pageRole||'-')+' '+String(row.pageKind||'-')+' src='+String(row.sourceCount??'-')+' files='+String(row.fileCount??'-')+' tasks='+String(row.taskCount??'-')+' selected='+String(row.selectedTaskRefHash||'-')+' launch='+String(row.launchButtonEnabled===true)+' links='+String(row.workbenchLinkCount??0));
lines.push('','Launches');
if(project.launches.length===0) lines.push('-');
for(const item of project.launches.slice(-12)) lines.push(String(item.ts||'-')+' '+String(item.phase||'-')+' status='+String(item.status??'-')+' session='+String(item.sessionId||'-')+' otel='+String(item.otelTraceId||'-')+' task='+String(item.taskHash||'-'));
const otelLaunches=project.launches.filter((item)=>item.otelTraceId).slice(-4);
if(otelLaunches.length>0){
const target=targetNodeFromStateDir()||'<target>';
lines.push('','OTel trace drill-down');
for(const item of otelLaunches) lines.push('bun scripts/cli.ts platform-infra observability trace --target '+target+' --trace-id '+String(item.otelTraceId));
}
lines.push('','Findings');
if(project.findings.length===0) lines.push('-');
for(const item of project.findings.slice(0,12)) lines.push(String(item.severity||'-')+': '+String(item.id||item.kind||'-')+' count='+String(item.count??'-')+' '+short(item.summary||item.message||'',140));
return lines.join('\\n');
}
const rows=turnSummaryRows();
if(view==='project-summary'){
const project=projectSummaryFromSamples();
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,summary:project.summary,sampleRowCount:project.sampleRows.length,launchCount:project.launches.length,findingCount:project.findings.length,renderedText:renderProjectSummary(project),sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true}));
process.exit(0);
}
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);
@@ -25,6 +25,7 @@ const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERV
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
const playwrightProxy = proxyConfigFromEnv(baseUrl);
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
const pageId = "control-" + randomBytes(4).toString("hex");
@@ -177,6 +178,7 @@ async function writeManifest(extra = {}) {
pageProvenance: compactPageProvenance(currentPageProvenance),
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
alertThresholds,
projectManagement,
jsonlRotation,
commandDirs: dirs,
artifacts: files,
@@ -346,6 +348,10 @@ async function processCommand(command) {
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
case "selectProjectSource": return selectProjectSource(command);
case "selectMdtodoFile": return selectMdtodoFile(command);
case "selectMdtodoTask": return selectMdtodoTask(command);
case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask");
case "screenshot": return captureScreenshot(command.reason || "manual", command.imageType || "png");
case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId };
case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId };
@@ -377,7 +383,11 @@ async function syncObserverPageToControlSession(reason, explicitSessionId = null
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: 15000 });
if (!readiness.ok) {
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
}
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
lastObserverRefreshAtMs = Date.now();
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, valuesRedacted: true };
}
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 15000 });
lastObserverRefreshAtMs = Date.now();
@@ -877,6 +887,30 @@ function redactErrorMessage(message) {
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
const targetPathname = safeUrlPath(targetUrl) || "";
if (isProjectManagementPathname(targetPathname)) {
const started = Date.now();
const selectors = projectManagement.readinessSelectors;
await targetPage.waitForFunction((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return input.selectors.some((selector) => {
try { return visible(document.querySelector(selector)); } catch { return false; }
});
}, { selectors }, { timeout: timeoutMs }).catch(() => null);
const snapshot = await projectManagementReadinessSnapshot(targetPage);
const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true;
return {
ok,
reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready",
durationMs: Date.now() - started,
snapshot,
valuesRedacted: true
};
}
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
const started = Date.now();
await targetPage.waitForFunction(() => {
@@ -930,11 +964,49 @@ async function workbenchReadinessSnapshot(targetPage) {
return snapshot;
}
async function projectManagementReadinessSnapshot(targetPage) {
const selectors = projectManagement.readinessSelectors;
return targetPage.evaluate((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const selectorStates = input.selectors.map((selector) => {
let matched = false;
let visibleMatched = false;
try {
const element = document.querySelector(selector);
matched = Boolean(element);
visibleMatched = visible(element);
} catch {}
return { selector, matched, visible: visibleMatched };
});
return {
url: window.location.href,
path: window.location.pathname,
readyState: document.readyState,
projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')),
mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')),
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
selectorStates,
valuesRedacted: true
};
}, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
}
function isWorkbenchPathname(value) {
const pathname = String(value || "");
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
}
function isProjectManagementPathname(value) {
if (projectManagement.enabled !== true) return false;
const pathname = String(value || "");
return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/"));
}
async function createSessionFromUi() {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
@@ -1428,6 +1500,208 @@ async function clickSession(sessionId) {
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
}
function ensureProjectManagementCommand(type) {
if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane");
if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane");
}
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector }) {
ensureProjectManagementCommand(type);
const beforeUrl = currentPageUrl();
const beforeProject = await projectManagementCommandSnapshot();
const targetValue = typeof value === "string" && value.trim() ? value.trim() : null;
const selector = targetValue ? "[" + attr + "=\"" + cssEscape(targetValue) + "\"]" : fallbackSelector;
const locator = page.locator(selector).first();
await locator.waitFor({ state: "visible", timeout: 15000 });
const clickedValue = await locator.evaluate((element, name) => element.getAttribute(name), attr).catch(() => targetValue);
await locator.click();
await page.waitForTimeout(700);
const afterProject = await projectManagementCommandSnapshot();
return {
beforeUrl,
afterUrl: currentPageUrl(),
type,
attr,
selected: opaqueIdSummary(clickedValue || targetValue),
beforeProject,
afterProject,
pageId,
valuesRedacted: true
};
}
async function selectProjectSource(command) {
return clickProjectItemByAttr({
type: "selectProjectSource",
attr: "data-source-id",
value: command.sourceId || command.value || command.text || "",
fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]'
});
}
async function selectMdtodoFile(command) {
return clickProjectItemByAttr({
type: "selectMdtodoFile",
attr: "data-file-ref",
value: command.fileRef || command.value || command.text || "",
fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]'
});
}
async function selectMdtodoTask(command) {
return clickProjectItemByAttr({
type: "selectMdtodoTask",
attr: "data-task-ref",
value: command.taskRef || command.value || command.text || "",
fallbackSelector: '[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]'
});
}
async function launchWorkbenchFromTask(command) {
ensureProjectManagementCommand("launchWorkbenchFromTask");
const beforeUrl = currentPageUrl();
const beforeProject = await projectManagementCommandSnapshot({ includeRaw: true });
const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null;
if (requestedTaskRef && beforeProject.selectedTaskRefRaw !== requestedTaskRef) {
await selectMdtodoTask({ ...command, taskRef: requestedTaskRef });
}
const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true });
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first();
await button.waitFor({ state: "visible", timeout: 15000 });
const buttonState = await button.evaluate((element) => ({
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
textHash: element.textContent ? null : null,
testId: element.getAttribute("data-testid") || null,
action: element.getAttribute("data-action") || null,
valuesRedacted: true
})).catch((error) => ({ disabled: null, error: errorSummary(error), valuesRedacted: true }));
if (buttonState.disabled === true) {
const error = new Error("launchWorkbenchFromTask button is disabled");
error.details = { beforeUrl, project: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, valuesRedacted: true };
throw error;
}
const launchPath = projectManagement.launchRoute;
const launchResponsePromise = page.waitForResponse((response) => {
const request = response.request();
if (request.method().toUpperCase() !== "POST") return false;
try {
return new URL(response.url()).pathname === launchPath;
} catch {
return false;
}
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
await button.click();
const launchResponse = await launchResponsePromise;
if (launchResponse?.waitError) {
const error = new Error("launchWorkbenchFromTask did not observe POST " + launchPath + " response after button click");
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchPath, project: sanitizeProjectCommandSnapshot(projectBeforeClick), waitError: launchResponse.waitError, valuesRedacted: true };
throw error;
}
const launchStatus = launchResponse.status();
const headers = launchResponse.headers();
const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null;
let payload = null;
let responseParseError = null;
try {
payload = await launchResponse.json();
} catch (error) {
responseParseError = errorSummary(error);
}
const sessionId = sessionIdFromAgentSessionPayload(payload);
const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || "");
const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null;
if (launchStatus < 200 || launchStatus >= 300 || !sessionId) {
const error = new Error("launchWorkbenchFromTask did not receive a successful authoritative Workbench session");
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchStatus, statusText: launchResponse.statusText(), contractVersion, responseParsed: payload !== null, responseParseError, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, valuesRedacted: true };
throw error;
}
if (workbenchUrl) {
await page.waitForFunction((expectedPath) => window.location.pathname === expectedPath, workbenchUrl, { timeout: 20000 }).catch(() => null);
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
launchPath,
launchStatus,
statusText: launchResponse.statusText(),
contractVersion,
sessionId,
workbenchUrl,
otelTraceId: launchTraceHeader,
selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw),
projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick),
buttonState,
responseParsed: payload !== null,
responseParseError,
pageId,
valuesRedacted: true
};
}
async function projectManagementCommandSnapshot(options = {}) {
const raw = await page.evaluate(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const text = (element) => String(element?.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240);
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
return {
path: window.location.pathname,
pageKind: visible(document.querySelector('[data-testid="project-management-mdtodo"]')) ? "project-management-mdtodo" : visible(document.querySelector('[data-testid="project-management-root"]')) ? "project-management-root" : null,
sourceCount: Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length,
fileCount: Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length,
taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length,
selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || null,
selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || null,
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
launchButtonVisible: visible(launch),
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
launchButtonText: text(launch),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length,
valuesRedacted: true
};
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
if (options.includeRaw === true) return raw;
return sanitizeProjectCommandSnapshot(raw);
}
function sanitizeProjectCommandSnapshot(value) {
if (!value || typeof value !== "object") return value;
return {
...value,
selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw),
selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw),
selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw),
selectedSourceIdRaw: undefined,
selectedFileRefRaw: undefined,
selectedTaskRefRaw: undefined,
launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null,
launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null,
launchButtonText: undefined,
blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [],
valuesRedacted: true
};
}
function opaqueIdSummary(value) {
const text = String(value || "");
if (!text) return null;
return {
hash: sha256Text(text),
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
bytes: Buffer.byteLength(text),
valuesRedacted: true
};
}
async function preflightSummary() {
return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) };
}
@@ -1447,9 +1721,10 @@ async function samplePage(reason, options = {}) {
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
sampleSeq += 1;
const dom = await targetPage.evaluate(() => {
const dom = await targetPage.evaluate((input) => {
const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
@@ -1789,6 +2064,60 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
description: String(item.description || "").slice(0, 120)
})),
});
const opaqueDomId = (value) => String(value || "").trim();
const collectProjectManagement = () => {
const config = input?.projectManagement || {};
const targetPaths = Array.isArray(config.targetPaths) ? config.targetPaths : [];
const path = location.pathname;
const configuredPath = targetPaths.some((target) => path === target || path.startsWith(String(target) + "/"));
const root = document.querySelector('[data-testid="project-management-root"]');
const mdtodoRoot = document.querySelector('[data-testid="project-management-mdtodo"]');
const rootVisible = visible(root);
const mdtodoVisible = visible(mdtodoRoot);
if (!configuredPath && !rootVisible && !mdtodoVisible) return null;
const sourceItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]')).filter(visible);
const fileItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]')).filter(visible);
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
const statusCounts = {};
for (const task of taskItems) {
const status = task.getAttribute("data-task-status") || "unknown";
statusCounts[status] = (statusCounts[status] || 0) + 1;
}
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
index,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
text: trim(element.textContent || "", 260),
})).filter((item) => item.text);
const workbenchLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible);
return {
pageKind: mdtodoVisible || path.startsWith("/projects/mdtodo") ? "project-management-mdtodo" : rootVisible || path === "/projects" || path.startsWith("/projects/") ? "project-management-root" : "project-management-unknown",
configuredPath,
rootVisible,
mdtodoVisible,
sourceCount: sourceItems.length,
fileCount: fileItems.length,
taskCount: taskItems.length,
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id")),
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref")),
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
taskStatusCounts: statusCounts,
launchButtonVisible: visible(launch),
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
launchButtonText: trim(launch?.textContent || "", 120),
blockerCount: blockers.length,
blockers,
workbenchLinkCount: workbenchLinks.length,
valuesRedacted: true,
};
};
const url = location.href;
const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u);
const activeSession = document.querySelector('[data-active="true"][data-session-id], [aria-selected="true"][data-session-id], .active[data-session-id]');
@@ -1870,6 +2199,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
sessionRail,
diagnostics,
turns,
projectManagement: collectProjectManagement(),
pageProvenance: {
url: location.href,
path: location.pathname,
@@ -1903,7 +2233,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
},
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
};
}).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
}, { projectManagement }).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
const sample = {
seq: sampleSeq,
sampleGroupSeq: groupSeq,
@@ -1927,9 +2257,55 @@ function digestDom(dom, pageRole = "control") {
const sessionRail = digestSessionRail(dom.sessionRail);
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : [];
const projectManagementSample = digestProjectManagement(dom.projectManagement);
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
if (pageRole === "control") currentPageProvenance = pageProvenance;
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) };
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, projectManagement: projectManagementSample, pageProvenance: compactPageProvenance(pageProvenance) };
}
function digestProjectManagement(value) {
if (!value || typeof value !== "object") return null;
const opaque = (raw) => {
const text = String(raw || "");
if (!text) return null;
return {
hash: sha256Text(text),
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
bytes: Buffer.byteLength(text),
valuesRedacted: true
};
};
const textDigest = (raw, limit = 160) => {
const text = String(raw || "");
return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true };
};
return {
pageKind: value.pageKind ?? null,
configuredPath: value.configuredPath === true,
rootVisible: value.rootVisible === true,
mdtodoVisible: value.mdtodoVisible === true,
sourceCount: Number.isFinite(Number(value.sourceCount)) ? Number(value.sourceCount) : 0,
fileCount: Number.isFinite(Number(value.fileCount)) ? Number(value.fileCount) : 0,
taskCount: Number.isFinite(Number(value.taskCount)) ? Number(value.taskCount) : 0,
taskRefMissingCount: Number.isFinite(Number(value.taskRefMissingCount)) ? Number(value.taskRefMissingCount) : 0,
selectedSourceId: opaque(value.selectedSourceId),
selectedFileRef: opaque(value.selectedFileRef),
selectedTaskRef: opaque(value.selectedTaskRef),
selectedTaskStatus: value.selectedTaskStatus ?? null,
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
launchButtonVisible: value.launchButtonVisible === true,
launchButtonEnabled: value.launchButtonEnabled === true,
launchButtonText: value.launchButtonText ? textDigest(value.launchButtonText, 120) : null,
blockerCount: Number.isFinite(Number(value.blockerCount)) ? Number(value.blockerCount) : 0,
blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 12).map((item) => ({
index: item?.index ?? null,
testId: item?.testId ?? null,
role: item?.role ?? null,
...textDigest(item?.text || "", 160),
})) : [],
workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0,
valuesRedacted: true
};
}
function digestSessionRail(value) {
@@ -2003,12 +2379,25 @@ function controlRecord(command, phase, detail) {
function commandInputSummary(command) {
const text = typeof command.text === "string" ? command.text : null;
const opaque = (value) => {
const raw = typeof value === "string" ? value : null;
if (!raw) return null;
return {
hash: sha256Text(raw),
preview: raw.length <= 18 ? raw : raw.slice(0, 10) + "..." + raw.slice(-5),
bytes: Buffer.byteLength(raw),
valuesRedacted: true
};
};
return {
type: command.type,
path: command.path || null,
url: command.url ? safeUrl(command.url) : null,
sessionId: command.sessionId || command.value || null,
provider: command.provider || null,
sourceId: opaque(command.sourceId),
fileRef: opaque(command.fileRef),
taskRef: opaque(command.taskRef),
label: command.label ? truncate(command.label, 200) : null,
textHash: text === null ? null : sha256Text(text),
textBytes: text === null ? null : Buffer.byteLength(text),
@@ -2124,6 +2513,54 @@ function parseAlertThresholds(value) {
};
}
function parseProjectManagementConfig(value) {
if (!value || value === "null") {
return {
enabled: false,
targetPaths: [],
readinessSelectors: [],
naturalApiPathPrefixes: [],
commandAllowlist: [],
launchRoute: "",
slowApiBudgetMs: 0,
source: "yaml-env",
valuesRedacted: true
};
}
const raw = (() => {
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
})();
const stringList = (key) => {
const list = raw?.[key];
if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
return list;
};
const positive = (key) => {
const numeric = Number(raw?.[key]);
if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
return numeric;
};
if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled");
if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true };
const targetPaths = stringList("targetPaths");
const readinessSelectors = stringList("readinessSelectors");
const naturalApiPathPrefixes = stringList("naturalApiPathPrefixes");
const commandAllowlist = stringList("commandAllowlist");
const launchRoute = String(raw.launchRoute || "");
if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path");
return {
enabled: true,
targetPaths,
readinessSelectors,
naturalApiPathPrefixes,
commandAllowlist,
launchRoute,
slowApiBudgetMs: positive("slowApiBudgetMs"),
source: "yaml-env",
valuesRedacted: true
};
}
function sha256Text(value) {
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
}