diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 2943982d..a8f0c2d6 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -220,6 +220,9 @@ lanes: domEvaluateTimeoutRedWindowMs: 30000 screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 + longTaskRedMs: 1000 + longAnimationFrameRedMs: 1000 + eventLoopGapRedMs: 1000 browserProcessSampleIntervalMs: 1000 requestRateBucketMs: 10000 requestRateTotalRedPerMinute: 300 @@ -649,6 +652,9 @@ lanes: domEvaluateTimeoutRedWindowMs: 30000 screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 + longTaskRedMs: 1000 + longAnimationFrameRedMs: 1000 + eventLoopGapRedMs: 1000 browserProcessSampleIntervalMs: 1000 requestRateBucketMs: 10000 requestRateTotalRedPerMinute: 300 @@ -1008,6 +1014,9 @@ lanes: domEvaluateTimeoutRedWindowMs: 30000 screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 + longTaskRedMs: 1000 + longAnimationFrameRedMs: 1000 + eventLoopGapRedMs: 1000 browserProcessSampleIntervalMs: 1000 requestRateBucketMs: 10000 requestRateTotalRedPerMinute: 300 diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml index 29f21f16..d38e34d5 100644 --- a/config/hwlab-web-probe-sentinel/check-catalog.yaml +++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml @@ -827,3 +827,43 @@ sentinel: actionZh: 查看对应 API path 曲线、调用页面和峰值时间点。 blocking: true order: 1010 + - code: WBC-102 + id: frontend-longtask-red + level: error + titleZh: 页面主线程长任务 + summaryZh: 浏览器 PerformanceObserver 捕获到超过 YAML 预算的主线程长任务。 + actionZh: 查看 LongTask/LoAF 事件和 CPU profile 热点。 + blocking: true + order: 1020 + - code: WBC-103 + id: frontend-long-animation-frame-red + level: error + titleZh: 页面长动画帧 + summaryZh: 浏览器捕获到超过 YAML 预算的 Long Animation Frame,并可能包含脚本归因。 + actionZh: 查看 LoAF scripts 热点、页面路径和触发命令。 + blocking: true + order: 1030 + - code: WBC-104 + id: frontend-event-loop-gap-red + level: error + titleZh: 页面事件循环卡顿 + summaryZh: 页面侧事件循环探针观察到超过 YAML 预算的主线程停顿。 + actionZh: 对照事件时间窗、命令记录和 CPU profile 热点。 + blocking: true + order: 1040 + - code: WBC-105 + id: frontend-cpu-profile-hotspots + level: info + titleZh: 已采集 CPU 热点 + summaryZh: 显式性能采集生成了可离线分析的函数级 CPU profile 热点。 + actionZh: 查看 profileFunctions/profileStacks 定位热点函数。 + blocking: false + order: 1050 + - code: WBC-106 + id: frontend-performance-probe-drain-errors + level: warning + titleZh: 性能探针读取异常 + summaryZh: 页面性能探针数据读取失败,不能仅凭没有长任务事件判断页面不卡顿。 + actionZh: 先修复性能探针采集可见性,再继续性能结论。 + blocking: false + order: 1060 diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 5d5e3f27..3d301e73 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -66,9 +66,11 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe observe command webobs-xxxx --type toggleMdtodoReportFullscreen --text toggle", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type editMdtodoTaskInline --task R1 --field body --text 'body updated through web-probe command'", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type launchWorkbenchFromMdtodo --filename 20260610-LVDT-旧过程归档.md --task R5.1 --provider dsflash-go", + "bun scripts/cli.ts web-probe observe command webobs-xxxx --type performanceCapture --duration-ms 5000 --wait-ms 8000", "bun scripts/cli.ts web-probe observe status webobs-xxxx", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view workbench-triad --trace-id trc_xxxx", + "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view performance-summary", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --command-id cmd-xxxx", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary", "bun scripts/cli.ts web-probe observe analyze webobs-xxxx", @@ -100,6 +102,7 @@ export function hwlabNodeWebProbeHelp(): Record { "observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.", "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", + "performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.", "analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.", "`web-probe sentinel report --raw` returns bounded issue evidence JSON, including report/artifact SHA and root-cause signal summaries when available; use `--full` for the full indexed service payload.", "When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel `; plan/status without it show the registry drill-down.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 6283d856..7bb947be 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -219,6 +219,9 @@ export interface HwlabRuntimeWebProbeAlertThresholdsSpec { readonly domEvaluateTimeoutRedWindowMs: number; readonly screenshotTimeoutRedCount: number; readonly pageErrorRedCount: number; + readonly longTaskRedMs: number; + readonly longAnimationFrameRedMs: number; + readonly eventLoopGapRedMs: number; readonly browserProcessSampleIntervalMs: number; readonly requestRateBucketMs: number; readonly requestRateTotalRedPerMinute: number; @@ -1335,6 +1338,9 @@ function webProbeAlertThresholdsConfig(value: unknown, path: string): HwlabRunti domEvaluateTimeoutRedWindowMs: positiveNumberField(raw, "domEvaluateTimeoutRedWindowMs", path), screenshotTimeoutRedCount: positiveNumberField(raw, "screenshotTimeoutRedCount", path), pageErrorRedCount: positiveNumberField(raw, "pageErrorRedCount", path), + longTaskRedMs: positiveNumberField(raw, "longTaskRedMs", path), + longAnimationFrameRedMs: positiveNumberField(raw, "longAnimationFrameRedMs", path), + eventLoopGapRedMs: positiveNumberField(raw, "eventLoopGapRedMs", path), browserProcessSampleIntervalMs: positiveNumberField(raw, "browserProcessSampleIntervalMs", path), requestRateBucketMs: positiveNumberField(raw, "requestRateBucketMs", path), requestRateTotalRedPerMinute: positiveNumberField(raw, "requestRateTotalRedPerMinute", path), diff --git a/scripts/src/hwlab-node-web-observe-analyzer-api-dom-lag-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-api-dom-lag-source.ts new file mode 100644 index 00000000..dce90389 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-api-dom-lag-source.ts @@ -0,0 +1,425 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer API-to-DOM lag and trace-events page-read source fragment. + +export function nodeWebObserveAnalyzerApiDomLagSource(): string { + return String.raw`function buildApiDomLagReport(samples, network) { + const windowMs = 30_000; + const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; + const sampleRows = (Array.isArray(samples) ? samples : []) + .map((sample) => { + const tsMs = timestampMs(sample?.ts); + return { + sample, + tsMs, + pageKey: samplePageKey(sample), + digest: digestSample(sample), + sessionIds: new Set([sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String)), + traceIds: sampleTraceIds(sample) + }; + }) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const samplesByPage = new Map(); + for (const row of sampleRows) { + const rows = samplesByPage.get(row.pageKey) || []; + rows.push(row); + samplesByPage.set(row.pageKey, rows); + } + const naturalApiResponses = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && item?.type === "response" && isApiLikePath(urlPath(item?.url))); + const telemetryExcluded = []; + const nonStateRelevant = []; + const stateRelevantResponses = []; + for (const item of naturalApiResponses) { + const event = compactApiDomLagResponseEvent(item); + if (!Number.isFinite(event.tsMs)) { + nonStateRelevant.push(event); + continue; + } + if (isApiDomLagTelemetryPath(event.path)) telemetryExcluded.push(event); + else if (!isApiDomLagStateRelevantPath(event.path)) nonStateRelevant.push(event); + else stateRelevantResponses.push(event); + } + const candidates = []; + for (const event of stateRelevantResponses) { + const pageSamples = samplesByPage.get(event.pageKey) || []; + const before = lastSampleAtOrBefore(pageSamples, event.tsMs, event); + const firstAfter = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); + const baselineDigest = before?.digest ?? null; + const change = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => !baselineDigest || row.digest !== baselineDigest); + candidates.push({ + ...event, + windowMs, + budgetMs, + firstSampleDeltaMs: firstAfter ? Math.max(0, Math.round(firstAfter.tsMs - event.tsMs)) : null, + domChangeDeltaMs: change ? Math.max(0, Math.round(change.tsMs - event.tsMs)) : null, + overBudget: change ? (change.tsMs - event.tsMs) > budgetMs : false, + domChanged: Boolean(change), + noDomChangeWithinWindow: !change, + beforeSample: compactApiDomLagSample(before), + firstAfterSample: compactApiDomLagSample(firstAfter), + changeSample: compactApiDomLagSample(change), + confidence: apiDomLagConfidence(event.path), + valuesRedacted: true + }); + } + const changedDeltas = candidates.map((item) => nullableNumber(item.domChangeDeltaMs)).filter(Number.isFinite).sort((a, b) => a - b); + const groups = groupApiDomLagCandidates(candidates); + const overBudget = candidates.filter((item) => item.overBudget === true); + return { + summary: { + windowMs, + budgetMs, + naturalApiResponseCount: naturalApiResponses.length, + telemetryExcludedCount: telemetryExcluded.length, + nonStateRelevantResponseCount: nonStateRelevant.length, + stateRelevantResponseCount: stateRelevantResponses.length, + candidateCount: candidates.length, + domChangedCount: changedDeltas.length, + noDomChangeWithinWindowCount: candidates.filter((item) => item.noDomChangeWithinWindow === true).length, + lowConfidenceStreamOpenCount: candidates.filter((item) => item.confidence === "low-stream-open-only").length, + overBudgetCount: overBudget.length, + p50DomChangeDeltaMs: percentile(changedDeltas, 50), + p95DomChangeDeltaMs: percentile(changedDeltas, 95), + maxDomChangeDeltaMs: changedDeltas.length > 0 ? Math.max(...changedDeltas) : null, + groupCount: groups.length, + valuesRedacted: true + }, + groups, + worstCandidates: candidates + .filter((item) => Number.isFinite(nullableNumber(item.domChangeDeltaMs))) + .sort((a, b) => nullableNumber(b.domChangeDeltaMs) - nullableNumber(a.domChangeDeltaMs)) + .slice(0, 20), + recentCandidates: candidates.slice(-40), + telemetryExcluded: telemetryExcluded.slice(0, 20), + nonStateRelevant: nonStateRelevant.slice(0, 20), + valuesRedacted: true + }; +} + +function compactApiDomLagResponseEvent(item) { + const parsed = parseApiDomLagUrl(item?.url); + const tsMs = timestampMs(item?.ts); + return { + ts: item?.ts ?? null, + tsMs, + pageRole: item?.pageRole ?? null, + pageId: item?.pageId ?? null, + pageKey: String(item?.pageRole || "control") + ":" + String(item?.pageId || "default"), + commandId: item?.commandId ?? null, + method: String(item?.method || "GET").toUpperCase(), + status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, + path: parsed.path, + rawPath: parsed.rawPath, + queryKeys: parsed.queryKeys, + sessionId: parsed.sessionId, + traceId: parsed.traceId, + urlHash: item?.url ? sha256(item.url) : null, + routeKind: apiDomLagRouteKind(parsed.path), + valuesRedacted: true + }; +} + +function parseApiDomLagUrl(value) { + try { + const parsed = new URL(String(value || "http://invalid.local/")); + const rawPath = parsed.pathname || "-"; + const queryKeys = Array.from(parsed.searchParams.keys()).sort().slice(0, 12); + const sessionId = parsed.searchParams.get("sessionId") || parsed.searchParams.get("includeSessionId") || firstIdInText(parsed.pathname + " " + parsed.search, /\bses_[A-Za-z0-9_-]+\b/u); + const traceId = parsed.searchParams.get("traceId") || firstIdInText(parsed.pathname + " " + parsed.search, /\btrc_[A-Za-z0-9_-]+\b/u); + return { + rawPath, + path: normalizeApiPath(rawPath), + queryKeys, + sessionId, + traceId + }; + } catch { + const rawPath = urlPath(value); + return { + rawPath, + path: normalizeApiPath(rawPath), + queryKeys: [], + sessionId: firstIdInText(String(value || ""), /\bses_[A-Za-z0-9_-]+\b/u), + traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u) + }; + } +} + +function firstIdInText(text, pattern) { + const match = String(text || "").match(pattern); + return match ? match[0] : null; +} + +function nullableNumber(value) { + if (value === null || value === undefined || value === "") return NaN; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : NaN; +} + +function isApiDomLagTelemetryPath(path) { + const value = String(path || ""); + return value === "/v1/web-performance" || value === "/v1/health" || value === "/health"; +} + +function isApiDomLagStateRelevantPath(path) { + const value = String(path || ""); + return value.startsWith("/auth/") || value.startsWith("/v1/workbench/") || value === "/v1/agent/chat" || value === "/v1/agent/chat/steer"; +} + +function apiDomLagRouteKind(path) { + const value = String(path || ""); + if (value === "/v1/workbench/events") return "workbench-events-stream"; + if (value.startsWith("/v1/workbench/sessions")) return "workbench-sessions"; + if (value.startsWith("/v1/workbench/traces")) return "workbench-traces"; + if (value.startsWith("/v1/workbench/turns")) return "workbench-turns"; + if (value === "/v1/agent/chat" || value === "/v1/agent/chat/steer") return "agent-chat-submit"; + if (value.startsWith("/auth/")) return "auth"; + return "state-api"; +} + +function apiDomLagConfidence(path) { + return String(path || "") === "/v1/workbench/events" ? "low-stream-open-only" : "medium-response-to-dom"; +} + +function sampleTraceIds(sample) { + const ids = new Set(); + for (const group of [sample?.messages, sample?.traceRows, sample?.turns, sample?.diagnostics]) { + if (!Array.isArray(group)) continue; + for (const item of group) if (item?.traceId) ids.add(String(item.traceId)); + } + return ids; +} + +function lastSampleAtOrBefore(rows, tsMs, event) { + let result = null; + for (const row of rows) { + if (row.tsMs > tsMs) break; + if (apiDomLagSampleMatchesEvent(row, event)) result = row; + } + return result; +} + +function firstSampleAfter(rows, startMs, endMs, event, predicate = null) { + for (const row of rows) { + if (row.tsMs < startMs) continue; + if (row.tsMs > endMs) break; + if (!apiDomLagSampleMatchesEvent(row, event)) continue; + if (typeof predicate === "function" && !predicate(row)) continue; + return row; + } + return null; +} + +function apiDomLagSampleMatchesEvent(row, event) { + if (!row || !event) return false; + if (event.sessionId && !row.sessionIds.has(String(event.sessionId))) return false; + if (event.traceId && row.traceIds.size > 0 && !row.traceIds.has(String(event.traceId))) return false; + return true; +} + +function compactApiDomLagSample(row) { + if (!row) return null; + const sample = row.sample || {}; + return { + seq: sample.seq ?? null, + ts: sample.ts ?? null, + pageRole: sample.pageRole ?? null, + pageId: sample.pageId ?? null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + messageCount: Array.isArray(sample.messages) ? sample.messages.length : null, + traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : null, + diagnosticCount: Array.isArray(sample.diagnostics) ? sample.diagnostics.length : null, + valuesRedacted: true + }; +} + +function groupApiDomLagCandidates(candidates) { + const groups = new Map(); + for (const item of candidates || []) { + const key = [item.method || "-", item.path || "-", item.status ?? "-", item.confidence || "-"].join(" "); + const group = groups.get(key) || { + method: item.method ?? null, + path: item.path ?? "-", + routeKind: item.routeKind ?? null, + status: item.status ?? null, + confidence: item.confidence ?? null, + count: 0, + domChangedCount: 0, + noDomChangeWithinWindowCount: 0, + overBudgetCount: 0, + firstAt: item.ts ?? null, + lastAt: item.ts ?? null, + deltas: [], + examples: [] + }; + group.count += 1; + group.firstAt = minIso(group.firstAt, item.ts ?? null); + group.lastAt = maxIso(group.lastAt, item.ts ?? null); + if (item.domChanged === true && Number.isFinite(Number(item.domChangeDeltaMs))) { + group.domChangedCount += 1; + group.deltas.push(Number(item.domChangeDeltaMs)); + } + if (item.noDomChangeWithinWindow === true) group.noDomChangeWithinWindowCount += 1; + if (item.overBudget === true) group.overBudgetCount += 1; + if (group.examples.length < 6) { + group.examples.push({ + ts: item.ts ?? null, + sessionId: item.sessionId ?? null, + traceId: item.traceId ?? null, + domChangeDeltaMs: item.domChangeDeltaMs ?? null, + firstSampleDeltaMs: item.firstSampleDeltaMs ?? null, + changeSeq: item.changeSample?.seq ?? null, + beforeSeq: item.beforeSample?.seq ?? null, + valuesRedacted: true + }); + } + groups.set(key, group); + } + return Array.from(groups.values()) + .map((item) => { + const deltas = item.deltas.slice().sort((a, b) => a - b); + return { + method: item.method, + path: item.path, + routeKind: item.routeKind, + status: item.status, + confidence: item.confidence, + count: item.count, + domChangedCount: item.domChangedCount, + noDomChangeWithinWindowCount: item.noDomChangeWithinWindowCount, + overBudgetCount: item.overBudgetCount, + p50DomChangeDeltaMs: percentile(deltas, 50), + p95DomChangeDeltaMs: percentile(deltas, 95), + maxDomChangeDeltaMs: deltas.length > 0 ? Math.max(...deltas) : null, + firstAt: item.firstAt, + lastAt: item.lastAt, + examples: item.examples, + valuesRedacted: true + }; + }) + .sort((a, b) => Number(b.maxDomChangeDeltaMs ?? -1) - Number(a.maxDomChangeDeltaMs ?? -1) || b.count - a.count || String(a.path).localeCompare(String(b.path))); +} + +function detectTraceEventsPageReadIssues(network) { + const events = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && (item?.type === "response" || item?.type === "requestfailed")) + .map(compactTraceEventsPageReadEvent) + .filter((item) => item !== null); + const http404 = events.filter((item) => item.type === "response" && Number(item.status) === 404); + const httpErrors = events.filter((item) => item.type === "response" && Number(item.status) >= 400 && Number(item.status) !== 404); + const requestFailed = events.filter((item) => item.type === "requestfailed"); + return { + events, + http404, + httpErrors, + requestFailed, + summary: traceEventsPageReadIssueSummary(events), + valuesRedacted: true + }; +} + +function compactTraceEventsPageReadEvent(item) { + const parsed = parseTraceEventsPageReadUrl(item?.url); + if (!parsed.match) return null; + const failureText = item?.failureKind ?? item?.failure ?? item?.errorText ?? null; + return { + ts: item?.ts ?? null, + pageRole: item?.pageRole ?? null, + pageId: item?.pageId ?? null, + commandId: item?.commandId ?? null, + method: String(item?.method || "GET").toUpperCase(), + type: item?.type ?? null, + status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, + path: "/v1/workbench/traces/:traceId/events", + rawPath: parsed.rawPath, + traceId: parsed.traceId, + afterProjectedSeq: parsed.afterProjectedSeq, + sinceSeq: parsed.sinceSeq, + limit: parsed.limit, + tail: parsed.tail, + queryKeys: parsed.queryKeys, + failureKind: failureText ? limitText(String(failureText), 120) : null, + urlHash: item?.url ? sha256(item.url) : null, + valuesRedacted: true + }; +} + +function parseTraceEventsPageReadUrl(value) { + const fallback = { + match: false, + rawPath: urlPath(value), + traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u), + afterProjectedSeq: null, + sinceSeq: null, + limit: null, + tail: null, + queryKeys: [], + }; + try { + const parsed = new URL(String(value || ""), "http://invalid.local/"); + const rawPath = parsed.pathname || "-"; + const match = rawPath.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); + return { + match: Boolean(match), + rawPath, + traceId: match ? decodeURIComponent(match[1]) : fallback.traceId, + afterProjectedSeq: numericSearchParam(parsed.searchParams, "afterProjectedSeq"), + sinceSeq: numericSearchParam(parsed.searchParams, "sinceSeq") ?? numericSearchParam(parsed.searchParams, "afterSeq"), + limit: numericSearchParam(parsed.searchParams, "limit"), + tail: numericSearchParam(parsed.searchParams, "tail"), + queryKeys: Array.from(parsed.searchParams.keys()).sort().slice(0, 12), + }; + } catch { + return fallback; + } +} + +function numericSearchParam(searchParams, key) { + const raw = searchParams?.get?.(key); + if (raw === null || raw === undefined || raw === "") return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function traceEventsPageReadIssueSummary(events) { + const items = Array.isArray(events) ? events : []; + const statuses = uniqueSorted(items.map((item) => item.status).filter((item) => item !== null && item !== undefined).map(String)); + const traceIds = uniqueSorted(items.map((item) => item.traceId).filter(Boolean).map(String)).slice(0, 8); + const afterProjectedSeqs = uniqueSorted(items.map((item) => item.afterProjectedSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); + const sinceSeqs = uniqueSorted(items.map((item) => item.sinceSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); + const failureKinds = uniqueSorted(items.map((item) => item.failureKind).filter(Boolean).map(String)).slice(0, 6); + return { + eventCount: items.length, + responseErrorCount: items.filter((item) => item.type === "response" && Number(item.status) >= 400).length, + http404Count: items.filter((item) => item.type === "response" && Number(item.status) === 404).length, + requestFailedCount: items.filter((item) => item.type === "requestfailed").length, + statuses, + traceIds, + afterProjectedSeqs, + sinceSeqs, + failureKinds, + firstAt: items.reduce((value, item) => minIso(value, item.ts ?? null), null), + lastAt: items.reduce((value, item) => maxIso(value, item.ts ?? null), null), + rootCauseVisibility: "browser network rows identify trace-events page-read path; OTel trace_events_read should confirm backend paging fields", + valuesRedacted: true + }; +} + +function uniqueSorted(values) { + return Array.from(new Set((values || []).filter((item) => item !== null && item !== undefined).map(String))).sort(); +} + +function compactApiDomLagForOutput(report) { + if (!report || typeof report !== "object") return null; + return { + summary: report.summary ?? null, + groups: Array.isArray(report.groups) ? report.groups.slice(0, 8) : [], + worstCandidates: Array.isArray(report.worstCandidates) ? report.worstCandidates.slice(0, 8) : [], + recentCandidates: Array.isArray(report.recentCandidates) ? report.recentCandidates.slice(-8) : [], + valuesRedacted: true + }; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-browser-process-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-browser-process-source.ts new file mode 100644 index 00000000..83cb6fa9 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-browser-process-source.ts @@ -0,0 +1,598 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer browser process, memory, responsiveness, and root-cause signal source fragment. + +export function nodeWebObserveAnalyzerBrowserProcessSource(): string { + return String.raw`function buildBrowserProcessReport(rows) { + const blockerEvents = (Array.isArray(rows) ? rows : []) + .filter((item) => item && item.type === "browser-freeze-blocker") + .map(compactBrowserFreezeBlockerEvent) + .filter(Boolean); + const samples = (Array.isArray(rows) ? rows : []) + .filter((item) => item && (item.type === "browser-process-sample" || item.process || item.pages)) + .map((item) => ({ ...item, tsMs: Date.parse(String(item.ts || "")) })) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const memorySamples = samples.map((item) => { + const process = item.process && typeof item.process === "object" ? item.process : {}; + const growth = item.growth && typeof item.growth === "object" ? item.growth : {}; + const totalRssBytes = browserMetricNumber(process.totalRssBytes); + const maxProcessRssBytes = browserMetricNumber(process.maxProcessRssBytes); + return { + ts: item.ts, + tsMs: item.tsMs, + seq: item.seq ?? null, + sampleSeq: item.sampleSeq ?? null, + commandId: item.commandId ?? null, + processCount: browserMetricNumber(process.chromiumProcessCount), + totalRssBytes, + totalRssMb: bytesToMb(totalRssBytes), + maxProcessRssBytes, + maxProcessRssMb: bytesToMb(maxProcessRssBytes), + maxProcess: compactBrowserProcessRow(process.maxProcess), + totalRssGrowthBytes: browserMetricNumber(growth.totalRssGrowthBytes), + totalRssGrowthMb: bytesToMb(growth.totalRssGrowthBytes), + maxProcessRssGrowthBytes: browserMetricNumber(growth.maxProcessRssGrowthBytes), + maxProcessRssGrowthMb: bytesToMb(growth.maxProcessRssGrowthBytes), + roles: process.roles && typeof process.roles === "object" ? process.roles : {}, + valuesRedacted: true, + }; + }); + const computedGrowth = computeBrowserProcessGrowth(memorySamples, alertThresholds.browserRssGrowthWindowMs); + const pageEvents = []; + const cdpTimeoutEvents = []; + for (const sample of samples) { + for (const page of Array.isArray(sample.pages) ? sample.pages : []) { + const responsiveness = page?.responsiveness && typeof page.responsiveness === "object" ? page.responsiveness : {}; + const cdp = page?.cdp && typeof page.cdp === "object" ? page.cdp : {}; + const calls = Array.isArray(cdp.calls) ? cdp.calls : []; + const event = { + ts: sample.ts, + tsMs: sample.tsMs, + seq: sample.seq ?? null, + sampleSeq: sample.sampleSeq ?? null, + commandId: sample.commandId ?? null, + pageRole: page?.pageRole ?? null, + pageId: page?.pageId ?? null, + pageEpoch: page?.pageEpoch ?? null, + url: safeReportUrl(page?.url), + timeoutMs: browserMetricNumber(page?.timeoutMs), + responsivenessOk: responsiveness.ok === true, + responsivenessTimeout: responsiveness.timeout === true, + responsivenessLatencyMs: browserMetricNumber(responsiveness.latencyMs), + cdpTimeoutCount: browserMetricNumber(cdp.timeoutCount), + cdpErrorCount: browserMetricNumber(cdp.errorCount), + heapUsedMb: page?.heapUsage ? bytesToMb(page.heapUsage.usedSize) : null, + heapTotalMb: page?.heapUsage ? bytesToMb(page.heapUsage.totalSize) : null, + effectiveHeapUsedMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.effectiveHeapUsedMb) : null, + effectiveJsHeapUsedMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.effectiveJsHeapUsedMb) : null, + heapUsedGrowthMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.heapUsedGrowthMb) : null, + jsHeapUsedGrowthMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.jsHeapUsedGrowthMb) : null, + baselineCapturedAt: page?.baseline?.capturedAt ?? null, + domNodes: page?.domCounters ? browserMetricNumber(page.domCounters.nodes) : null, + valuesRedacted: true, + }; + pageEvents.push(event); + for (const call of calls) { + if (call?.timeout !== true || call?.method === "Runtime.evaluate") continue; + cdpTimeoutEvents.push({ + ...event, + method: call.method ?? null, + latencyMs: browserMetricNumber(call.latencyMs), + errorMessage: limitText(call.error?.message ?? "", 180), + valuesRedacted: true, + }); + } + const callTimeoutCount = calls.filter((call) => call?.timeout === true && call?.method !== "Runtime.evaluate").length; + if (Number(event.cdpTimeoutCount || 0) > callTimeoutCount && calls.length === 0) { + cdpTimeoutEvents.push({ ...event, method: "cdp-session", latencyMs: event.responsivenessLatencyMs, errorMessage: "CDP session or metric collection timed out", valuesRedacted: true }); + } + } + } + const responsivenessEvents = pageEvents.filter((item) => item.responsivenessTimeout === true || Number(item.responsivenessLatencyMs ?? 0) >= alertThresholds.playwrightResponsivenessRedMs); + const maxTotal = maxByNumber(memorySamples, (item) => item.totalRssBytes); + const maxProcess = maxByNumber(memorySamples, (item) => item.maxProcessRssBytes); + const maxGrowth = maxByNumber(computedGrowth, (item) => item.totalRssGrowthBytes); + const maxProcessGrowth = maxByNumber(computedGrowth, (item) => item.maxProcessRssGrowthBytes); + return { + summary: { + sampleCount: samples.length, + memorySampleCount: memorySamples.length, + pageMetricCount: pageEvents.length, + cdpMetricsTimeoutCount: cdpTimeoutEvents.length, + responsivenessSlowCount: responsivenessEvents.length, + responsivenessTimeoutCount: responsivenessEvents.filter((item) => item.responsivenessTimeout === true).length, + maxTotalRssMb: maxTotal ? bytesToMb(maxTotal.totalRssBytes) : null, + maxProcessRssMb: maxProcess ? bytesToMb(maxProcess.maxProcessRssBytes) : null, + maxTotalRssGrowthMb: maxGrowth ? bytesToMb(maxGrowth.totalRssGrowthBytes) : null, + maxProcessRssGrowthMb: maxProcessGrowth ? bytesToMb(maxProcessGrowth.maxProcessRssGrowthBytes) : null, + totalRssRedMb: alertThresholds.browserTotalRssRedMb, + processRssRedMb: alertThresholds.browserProcessRssRedMb, + growthRedMb: alertThresholds.browserRssGrowthRedMb, + growthWindowMs: alertThresholds.browserRssGrowthWindowMs, + responsivenessRedMs: alertThresholds.playwrightResponsivenessRedMs, + memoryRedPolicyScope: "per-page-effective-memory", + freezeBlockerCount: blockerEvents.length, + browserFreezePolicy, + valuesRedacted: true, + }, + blockerEvents: blockerEvents.slice(-20), + memorySamples: memorySamples.slice(-60), + growthSamples: computedGrowth.slice(-60), + maxTotalRssSample: maxTotal, + maxProcessRssSample: maxProcess, + maxGrowthSample: maxGrowth, + maxProcessGrowthSample: maxProcessGrowth, + responsivenessEvents: responsivenessEvents.slice(-80), + cdpTimeoutEvents: cdpTimeoutEvents.slice(-80), + latestPageEvents: pageEvents.slice(-20), + valuesRedacted: true, + }; +} + +function compactBrowserFreezeBlockerEvent(item) { + if (!item || typeof item !== "object") return null; + const observed = objectValue(item.observed); + const threshold = objectValue(item.threshold); + const sample = objectValue(item.sample); + const page = objectValue(item.page); + const browserKill = objectValue(item.browserKill); + return { + ts: item.ts ?? null, + seq: item.seq ?? null, + sampleSeq: item.sampleSeq ?? sample.sampleSeq ?? null, + kind: item.kind ?? null, + severity: item.severity ?? "red", + blocking: item.blocking === true, + summary: item.summary ? String(item.summary).slice(0, 240) : null, + rootCause: item.rootCause ?? null, + rootCauseStatus: item.rootCauseStatus ?? null, + rootCauseConfidence: item.rootCauseConfidence ?? null, + policySource: item.policySource ?? null, + fallbackAllowed: item.fallbackAllowed === true, + observerRefreshAllowed: item.observerRefreshAllowed === true, + observed: { + totalRssMb: numberOrNull(observed.totalRssMb), + processRssMb: numberOrNull(observed.processRssMb), + totalGrowthMb: numberOrNull(observed.totalGrowthMb), + processGrowthMb: numberOrNull(observed.processGrowthMb), + effectiveHeapUsedMb: numberOrNull(observed.effectiveHeapUsedMb), + effectiveJsHeapUsedMb: numberOrNull(observed.effectiveJsHeapUsedMb), + heapGrowthMb: numberOrNull(observed.heapGrowthMb), + jsHeapGrowthMb: numberOrNull(observed.jsHeapGrowthMb), + responsivenessLatencyMs: numberOrNull(observed.responsivenessLatencyMs), + responsivenessTimeout: observed.responsivenessTimeout === true, + cdpMetricsTimeoutCount: numberOrNull(observed.cdpMetricsTimeoutCount), + methods: arrayStrings(observed.methods).slice(0, 8), + valuesRedacted: true, + }, + threshold: { + totalRssBlockerMb: numberOrNull(threshold.totalRssBlockerMb), + processRssBlockerMb: numberOrNull(threshold.processRssBlockerMb), + growthBlockerMb: numberOrNull(threshold.growthBlockerMb), + latencyBlockerMs: numberOrNull(threshold.latencyBlockerMs), + eventBlockerCount: numberOrNull(threshold.eventBlockerCount), + metricsTimeoutBlockerCount: numberOrNull(threshold.metricsTimeoutBlockerCount), + windowMs: numberOrNull(threshold.windowMs), + valuesRedacted: true, + }, + sample: { + ts: sample.ts ?? null, + seq: sample.seq ?? null, + sampleSeq: sample.sampleSeq ?? null, + browserPid: numberOrNull(sample.browserPid), + totalRssMb: numberOrNull(sample.totalRssMb), + maxProcessRssMb: numberOrNull(sample.maxProcessRssMb), + totalRssGrowthMb: numberOrNull(sample.totalRssGrowthMb), + maxProcessRssGrowthMb: numberOrNull(sample.maxProcessRssGrowthMb), + valuesRedacted: true, + }, + page: { + pageRole: page.pageRole ?? null, + pageId: page.pageId ?? null, + pageEpoch: numberOrNull(page.pageEpoch), + timeoutMs: numberOrNull(page.timeoutMs), + responsivenessLatencyMs: numberOrNull(page.responsivenessLatencyMs), + responsivenessTimeout: page.responsivenessTimeout === true, + cdpTimeoutCount: numberOrNull(page.cdpTimeoutCount), + cdpErrorCount: numberOrNull(page.cdpErrorCount), + effectiveHeapUsedMb: numberOrNull(page.effectiveHeapUsedMb), + effectiveJsHeapUsedMb: numberOrNull(page.effectiveJsHeapUsedMb), + heapUsedGrowthMb: numberOrNull(page.heapUsedGrowthMb), + jsHeapUsedGrowthMb: numberOrNull(page.jsHeapUsedGrowthMb), + baselineCapturedAt: page.baselineCapturedAt ?? null, + valuesRedacted: true, + }, + browserKill: { + ok: browserKill.ok === true, + pending: browserKill.pending === true, + skipped: browserKill.skipped === true, + reason: browserKill.reason ?? null, + pid: numberOrNull(browserKill.pid), + gracefulSignal: browserKill.gracefulSignal ?? null, + forceSignal: browserKill.forceSignal ?? null, + gracefulSent: browserKill.gracefulSent === true, + forceSent: browserKill.forceSent === true, + exitedAfterGrace: browserKill.exitedAfterGrace === true, + exitedAfterForce: browserKill.exitedAfterForce === true, + valuesRedacted: true, + }, + valuesRedacted: true, + }; +} + +function buildBrowserProcessFindings(report, runtimeAlerts = null) { + const summary = report?.summary || {}; + const findings = []; + const blockerEvents = Array.isArray(report?.blockerEvents) ? report.blockerEvents : []; + if (blockerEvents.length > 0) { + const first = blockerEvents[0] || {}; + findings.push({ + id: "frontend-browser-freeze-runner-blocker", + severity: "red", + summary: "web-probe runner matched YAML browserFreezePolicy, killed/stopped Chromium, and failed the observer run; do not clear this by refresh or fallback", + count: blockerEvents.length, + blocking: true, + rootCause: first.rootCause ?? "frontend_browser_freeze_policy_blocker", + rootCauseStatus: "confirmed-from-runner-browser-freeze-policy", + rootCauseConfidence: "high", + policySource: first.policySource ?? "config/hwlab-node-lanes.yaml#webProbe.browserFreezePolicy", + fallbackAllowed: false, + observerRefreshAllowed: false, + browserKilled: first.browserKill ?? null, + events: blockerEvents.slice(0, 20), + valuesRedacted: true, + }); + } + if (!summary || Number(summary.sampleCount ?? 0) <= 0) return findings; + const rootCauseSignals = browserRootCauseSignals(report, runtimeAlerts); + const pageEvents = Array.isArray(report.latestPageEvents) ? report.latestPageEvents : []; + const maxEffectiveHeapEvent = maxByNumber(pageEvents, (item) => Math.max(Number(item.effectiveHeapUsedMb ?? 0), Number(item.effectiveJsHeapUsedMb ?? 0))); + const maxEffectiveHeapMb = maxEffectiveHeapEvent ? Math.max(Number(maxEffectiveHeapEvent.effectiveHeapUsedMb ?? 0), Number(maxEffectiveHeapEvent.effectiveJsHeapUsedMb ?? 0)) : 0; + if (maxEffectiveHeapMb >= alertThresholds.browserProcessRssRedMb) { + findings.push({ + id: "frontend-browser-memory-rss-red", + severity: "red", + summary: "Page effective memory exceeded YAML red threshold after subtracting page baseline; process RSS remains diagnostic evidence only", + maxEffectiveHeapMb, + processRssRedMb: alertThresholds.browserProcessRssRedMb, + memoryRedPolicyScope: "per-page-effective-memory", + maxEffectiveHeapEvent, + maxTotalRssSample: report.maxTotalRssSample, + maxProcessRssSample: report.maxProcessRssSample, + rootCause: "frontend_browser_page_effective_memory_pressure", + rootCauseStatus: "confirmed-from-runner-page-effective-memory", + rootCauseConfidence: "high", + rootCauseSignals, + fallbackAllowed: false, + valuesRedacted: true, + }); + } + const maxEffectiveGrowthEvent = maxByNumber(pageEvents, (item) => Math.max(Number(item.heapUsedGrowthMb ?? 0), Number(item.jsHeapUsedGrowthMb ?? 0))); + const maxEffectiveGrowthMb = maxEffectiveGrowthEvent ? Math.max(Number(maxEffectiveGrowthEvent.heapUsedGrowthMb ?? 0), Number(maxEffectiveGrowthEvent.jsHeapUsedGrowthMb ?? 0)) : 0; + if (maxEffectiveGrowthMb >= alertThresholds.browserRssGrowthRedMb) { + findings.push({ + id: "frontend-browser-memory-growth-red", + severity: "red", + summary: "Page effective memory grew beyond YAML window budget; this matches the reported freeze pattern without counting multi-page startup RSS", + maxEffectiveGrowthMb, + growthRedMb: alertThresholds.browserRssGrowthRedMb, + windowMs: alertThresholds.browserRssGrowthWindowMs, + memoryRedPolicyScope: "per-page-effective-memory", + maxEffectiveGrowthEvent, + maxGrowthSample: report.maxGrowthSample, + maxProcessGrowthSample: report.maxProcessGrowthSample, + rootCause: "frontend_browser_page_memory_leak_or_unbounded_render_growth", + rootCauseStatus: "confirmed-from-runner-page-effective-memory-growth", + rootCauseConfidence: "high", + rootCauseSignals, + fallbackAllowed: false, + valuesRedacted: true, + }); + } + const responsivenessEvents = Array.isArray(report.responsivenessEvents) ? report.responsivenessEvents : []; + const severeLatencyEvents = responsivenessEvents.filter((item) => item.responsivenessTimeout !== true && Number(item.responsivenessLatencyMs ?? 0) >= alertThresholds.playwrightResponsivenessRedMs); + const responsivenessTimeoutBurst = firstBurst( + responsivenessEvents.filter((item) => item.responsivenessTimeout === true), + alertThresholds.playwrightResponsivenessTimeoutRedCount, + alertThresholds.domEvaluateTimeoutRedWindowMs, + ); + if (severeLatencyEvents.length > 0 || responsivenessTimeoutBurst) { + const events = severeLatencyEvents.length > 0 ? severeLatencyEvents : responsivenessTimeoutBurst; + findings.push({ + id: "frontend-playwright-responsiveness-red", + severity: "red", + summary: "Playwright/CDP responsiveness probe exceeded YAML budget; treat the browser page as frozen instead of refreshing or falling back", + count: events.length, + latencyRedMs: alertThresholds.playwrightResponsivenessRedMs, + timeoutThresholdCount: alertThresholds.playwrightResponsivenessTimeoutRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + events: events.slice(0, 20).map(browserProcessEventRef), + rootCause: "frontend_browser_page_unresponsive_to_playwright", + rootCauseStatus: "confirmed-from-cdp-runtime-evaluate", + rootCauseConfidence: "high", + rootCauseSignals, + fallbackAllowed: false, + valuesRedacted: true, + }); + } + const cdpTimeoutEvents = Array.isArray(report.cdpTimeoutEvents) ? report.cdpTimeoutEvents : []; + const cdpTimeoutBurst = firstBurst(cdpTimeoutEvents, alertThresholds.cdpMetricsTimeoutRedCount, alertThresholds.domEvaluateTimeoutRedWindowMs); + if (cdpTimeoutBurst) { + findings.push({ + id: "frontend-cdp-metrics-timeout-red", + severity: "red", + summary: "CDP browser metrics timed out repeatedly; the sentinel has direct browser-side evidence of a hung page/runtime", + count: cdpTimeoutBurst.length, + thresholdCount: alertThresholds.cdpMetricsTimeoutRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + events: cdpTimeoutBurst.slice(0, 20).map(browserProcessEventRef), + rootCause: "frontend_browser_cdp_metrics_unresponsive", + rootCauseStatus: "confirmed-from-cdp-metrics-timeouts", + rootCauseConfidence: "high", + rootCauseSignals, + fallbackAllowed: false, + valuesRedacted: true, + }); + } + return findings; +} + +function browserRootCauseSignals(report, runtimeAlerts) { + const browserSummary = report?.summary || {}; + const runtimeSummary = runtimeAlerts?.summary || {}; + const sampleCount = Number(browserSummary.sampleCount ?? 0); + const sessionListReadCount = Number(runtimeSummary.workbenchSessionListReadCount ?? 0); + const traceEventsReadCount = Number(runtimeSummary.workbenchTraceEventsReadCount ?? 0); + const webPerformanceBeaconFailureCount = Number(runtimeSummary.webPerformanceBeaconFailureCount ?? 0); + const eventSourceFailureCount = Number(runtimeSummary.workbenchEventSourceFailureCount ?? 0); + const suspectedFrontendRefreshStorm = sessionListReadCount >= Math.max(20, sampleCount * 2); + return { + suspectedFrontendRefreshStorm, + sessionListReadCount, + traceEventsReadCount, + webPerformanceBeaconFailureCount, + eventSourceFailureCount, + requestFailedCount: runtimeSummary.significantRequestFailedCount ?? runtimeSummary.requestFailedCount ?? 0, + httpErrorCount: runtimeSummary.httpErrorCount ?? 0, + topRequestFailedPaths: (runtimeAlerts?.networkSignificantRequestFailedByPath ?? runtimeAlerts?.networkRequestFailedByPath ?? []).slice(0, 5), + topHttpErrorPaths: (runtimeAlerts?.networkHttpErrorsByPath ?? []).slice(0, 5), + note: suspectedFrontendRefreshStorm + ? "suspected frontend refresh storm: session list reads exceed the sample-derived budget during a browser red finding" + : "root-cause signals are included so memory/responsiveness/CDP red findings can be correlated without manual grep", + valuesRedacted: true, + }; +} + +function computeBrowserProcessGrowth(samples, windowMs) { + const budgetMs = Math.max(1000, Number(windowMs || 0)); + const sorted = (samples || []).filter((item) => Number.isFinite(item.tsMs)).sort((a, b) => a.tsMs - b.tsMs); + return sorted.map((item, index) => { + const candidates = sorted.slice(0, index + 1).filter((candidate) => item.tsMs - candidate.tsMs <= budgetMs); + const totalBaseline = minByNumber(candidates, (candidate) => candidate.totalRssBytes); + const processBaseline = minByNumber(candidates, (candidate) => candidate.maxProcessRssBytes); + const totalGrowth = Number(item.totalRssBytes || 0) - Number(totalBaseline?.totalRssBytes || 0); + const processGrowth = Number(item.maxProcessRssBytes || 0) - Number(processBaseline?.maxProcessRssBytes || 0); + return { + ...item, + windowMs: budgetMs, + totalBaselineAt: totalBaseline?.ts ?? null, + processBaselineAt: processBaseline?.ts ?? null, + totalRssGrowthBytes: totalGrowth, + totalRssGrowthMb: bytesToMb(totalGrowth), + maxProcessRssGrowthBytes: processGrowth, + maxProcessRssGrowthMb: bytesToMb(processGrowth), + valuesRedacted: true, + }; + }); +} + +function browserProcessEventRef(item) { + return { + ts: item?.ts ?? null, + seq: item?.seq ?? null, + sampleSeq: item?.sampleSeq ?? null, + commandId: item?.commandId ?? null, + pageRole: item?.pageRole ?? null, + pageId: item?.pageId ?? null, + pageEpoch: item?.pageEpoch ?? null, + timeoutMs: item?.timeoutMs ?? null, + responsivenessLatencyMs: item?.responsivenessLatencyMs ?? item?.latencyMs ?? null, + responsivenessTimeout: item?.responsivenessTimeout === true, + cdpTimeoutCount: item?.cdpTimeoutCount ?? null, + method: item?.method ?? null, + errorMessage: item?.errorMessage ?? null, + valuesRedacted: true, + }; +} + +function compactBrowserProcessRow(item) { + if (!item || typeof item !== "object") return null; + return { + pid: item.pid ?? null, + role: item.role ?? null, + name: item.name ?? null, + rssMb: bytesToMb(item.rssBytes), + vmSizeMb: bytesToMb(item.vmSizeBytes), + commandHash: item.commandHash ?? null, + valuesRedacted: true, + }; +} + +function browserMetricNumber(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function bytesToMb(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Number((numeric / 1024 / 1024).toFixed(1)); +} + +function maxByNumber(items, getter) { + let selected = null; + let selectedValue = Number.NEGATIVE_INFINITY; + for (const item of Array.isArray(items) ? items : []) { + const value = Number(getter(item)); + if (!Number.isFinite(value)) continue; + if (!selected || value > selectedValue) { + selected = item; + selectedValue = value; + } + } + return selected; +} + +function minByNumber(items, getter) { + let selected = null; + let selectedValue = Number.POSITIVE_INFINITY; + for (const item of Array.isArray(items) ? items : []) { + const value = Number(getter(item)); + if (!Number.isFinite(value)) continue; + if (!selected || value < selectedValue) { + selected = item; + selectedValue = value; + } + } + return selected; +} + +function safeReportUrl(value) { + if (!value) return null; + try { + const url = new URL(String(value)); + return url.origin + url.pathname; + } catch { + return limitText(String(value), 200); + } +} + +function frontendFreezeErrorEvent(item, promptTimes) { + const details = objectValue(item?.error?.details); + const message = String(item?.error?.message ?? item?.message ?? item?.error ?? ""); + const type = String(item?.type || ""); + const tsMs = Date.parse(String(item?.ts || "")); + if (!Number.isFinite(tsMs)) return null; + const kind = classifyFrontendFreezeError(type, message); + if (!kind) return null; + return { + ts: item.ts ?? null, + tsMs, + promptIndex: promptIndexForTs(promptTimes, item.ts), + kind, + type: item.type ?? null, + pageRole: stringOrNull(item?.pageRole) ?? stringOrNull(details.pageRole) ?? pageRoleFromErrorType(type), + pageId: stringOrNull(item?.pageId) ?? stringOrNull(details.pageId), + routeSessionId: stringOrNull(item?.routeSessionId) ?? stringOrNull(details.routeSessionId), + activeSessionId: stringOrNull(item?.activeSessionId) ?? stringOrNull(details.activeSessionId), + commandId: stringOrNull(item?.commandId) ?? stringOrNull(details.commandId), + sampleSeq: numberOrNull(item?.sampleSeq ?? details.sampleSeq), + timeoutMs: timeoutMsFromMessage(message), + messageHash: message ? sha256(message) : null, + preview: limitText(message, 240), + valuesRedacted: true, + }; +} + +function pageRoleFromErrorType(type) { + const value = String(type || ""); + if (/^control-/iu.test(value)) return "control"; + if (/^observer-/iu.test(value)) return "observer"; + return null; +} + +function classifyFrontendFreezeError(type, message) { + const value = String(message || ""); + if (/sampleOnePage\s+DOM\s+evaluate\s+exceeded/iu.test(value) && /(?:control|observer)-sample-error/iu.test(type)) return "dom-evaluate-timeout"; + if (/screenshot|captureScreenshot|page\.screenshot/iu.test(type + " " + value) && /timeout|timed\s*out|exceeded/iu.test(value)) return "screenshot-timeout"; + if (/pageerror|uncaught|unhandledrejection/iu.test(type) || /^(?:Error|TypeError|ReferenceError|RangeError|SyntaxError):/u.test(value)) return "page-error"; + return null; +} + +function firstBurst(events, thresholdCount, windowMs) { + const count = Math.max(1, Math.floor(Number(thresholdCount || 0))); + const budgetMs = Math.max(1, Number(windowMs || 0)); + const sorted = (events || []).filter((item) => Number.isFinite(item?.tsMs)).sort((a, b) => a.tsMs - b.tsMs); + if (sorted.length < count) return null; + for (let start = 0; start <= sorted.length - count; start += 1) { + const end = start + count - 1; + if (sorted[end].tsMs - sorted[start].tsMs <= budgetMs) return sorted.slice(start, end + 1); + } + return null; +} + +function frontendFreezeBurstFinding({ id, summary, burst, thresholdCount, windowMs, pageRole }) { + const first = burst[0]; + const last = burst[burst.length - 1]; + const pageIds = uniqueStrings(burst.map((item) => item.pageId)); + const routeSessionIds = uniqueStrings(burst.map((item) => item.routeSessionId)); + const activeSessionIds = uniqueStrings(burst.map((item) => item.activeSessionId)); + return { + id, + severity: "red", + summary, + count: burst.length, + thresholdCount, + windowMs, + firstAt: first?.ts ?? null, + lastAt: last?.ts ?? null, + pageRole, + pageIds, + routeSessionIds, + activeSessionIds, + timeoutMsMax: maxPresentNumber(burst.map((item) => item.timeoutMs)), + rootCause: "frontend_page_freeze_or_runtime_exception", + rootCauseStatus: "confirmed-from-browser-observer-errors", + rootCauseConfidence: "high", + fallbackAllowed: false, + observerRefreshMayNotClear: true, + nextAction: "Keep this run red; do not auto-refresh, fallback, or mark healthy until OTel/browser evidence explains why the page stopped responding.", + events: burst.map((item) => ({ + ts: item.ts, + promptIndex: item.promptIndex, + type: item.type, + pageRole: item.pageRole, + pageId: item.pageId, + routeSessionId: item.routeSessionId, + activeSessionId: item.activeSessionId, + commandId: item.commandId, + sampleSeq: item.sampleSeq, + timeoutMs: item.timeoutMs, + messageHash: item.messageHash, + preview: item.preview, + valuesRedacted: true, + })), + valuesRedacted: true, + }; +} + +function stopCommandWindows(control) { + return (control || []) + .filter((item) => /^(?:stop|forceStop|cancel|close)$/iu.test(String(item?.type || item?.command || ""))) + .map((item) => { + const tsMs = Date.parse(String(item?.ts || "")); + return Number.isFinite(tsMs) ? { fromMs: tsMs - 1000, toMs: tsMs + 10000 } : null; + }) + .filter(Boolean); +} + +function errorInsideStopWindow(event, windows) { + return (windows || []).some((window) => event.tsMs >= window.fromMs && event.tsMs <= window.toMs); +} + +function timeoutMsFromMessage(value) { + const match = String(value || "").match(/\b(?:exceeded|timeout|timed\s*out\s*after)\s+(\d{2,})\s*ms\b/iu) + || String(value || "").match(/\b(\d{2,})\s*ms\b/iu); + return match ? Number(match[1]) : null; +} + +function uniqueStrings(values) { + return Array.from(new Set((values || []).filter((item) => typeof item === "string" && item.length > 0))).slice(0, 12); +} + +function maxPresentNumber(values) { + const numbers = (values || []).filter((item) => item !== null && item !== undefined && Number.isFinite(Number(item))).map((item) => Number(item)); + return numbers.length > 0 ? Math.max(...numbers) : null; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts new file mode 100644 index 00000000..35552e4a --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts @@ -0,0 +1,565 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer top-level findings, cross-page projection drilldown, and frontend freeze source fragment. + +export function nodeWebObserveAnalyzerFindingsSource(): string { + return String.raw`function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null, frontendPerformance = null) { + const findings = []; + const effectiveApiDomLag = apiDomLag || buildApiDomLagReport(samples, network); + 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) }); + findings.push(...buildFrontendFreezeFindings(errors, control)); + findings.push(...buildBrowserProcessFindings(browserProcess, runtimeAlerts)); + findings.push(...buildFrontendPerformanceFindings(frontendPerformance)); + findings.push(...buildControlledNavigationRootCauseFindings(control, manifest)); + findings.push(...buildSessionInvariantFindings(control, manifest)); + const commandTimes = control + .filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh") + .map((item) => Date.parse(item.ts)) + .filter(Number.isFinite); + const controlledNavigationWindows = sessionInvariantNavigationWindows(control); + const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean)); + const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean)); + const routeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "routeSessionId", controlledNavigationWindows); + const activeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "activeSessionId", controlledNavigationWindows); + if (routeSessions.size > 1 && routeSessionUnexpected.length > 0) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed outside controlled session-invariance navigation windows", routeSessionCount: routeSessions.size, samples: sampleRefs(routeSessionUnexpected, (item) => item.routeSessionId) }); + if (activeSessions.size > 1 && activeSessionUnexpected.length > 0) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed outside controlled session-invariance navigation windows", activeSessionCount: activeSessions.size, samples: sampleRefs(activeSessionUnexpected, (item) => item.activeSessionId) }); + const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId && !sampleInControlledNavigationWindow(item, controlledNavigationWindows)); + if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) }); + const uncommandedChanges = []; + const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined)); + const previousDigestByPage = new Map(); + for (const sample of samples) { + const pageKey = samplePageKey(sample); + const previous = previousDigestByPage.get(pageKey); + const next = digestSample(sample); + if (previous && previous.digest !== next && !commandedPromptSeqs.has(sample?.seq) && !nearCommand(sample, commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) { + uncommandedChanges.push({ + ...ref(sample), + previousSeq: previous.sample?.seq ?? null, + previousTs: previous.sample?.timestamp ?? previous.sample?.ts ?? null, + fromDigest: previous.digest, + toDigest: next, + fromMessageCount: previous.sample?.messageCount ?? previous.sample?.messages?.length ?? null, + toMessageCount: sample?.messageCount ?? sample?.messages?.length ?? null, + fromTraceRowCount: Array.isArray(previous.sample?.traceRows) ? previous.sample.traceRows.length : previous.sample?.traceRowCount ?? null, + toTraceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : sample?.traceRowCount ?? null, + fromPath: previous.sample?.path ?? previous.sample?.urlPath ?? null, + toPath: sample?.path ?? sample?.urlPath ?? null, + detail: "visible digest changed without nearby command", + }); + } + previousDigestByPage.set(pageKey, { digest: next, sample }); + } + if (uncommandedChanges.length > 0) findings.push({ id: "uncommanded-visible-state-change", severity: "amber", summary: "visible message/trace digest changed without a nearby command", count: uncommandedChanges.length, samples: uncommandedChanges.slice(0, 20) }); + const finalFlicker = detectFinalFlicker(samples); + if (finalFlicker.length > 0) findings.push({ id: "final-response-flicker", severity: "red", summary: "message text digest disappeared or switched to diagnostic-like text after non-empty final text", count: finalFlicker.length, samples: finalFlicker.slice(0, 20) }); + const terminalZeroElapsed = detectTerminalZeroElapsed(samples); + if (terminalZeroElapsed.length > 0) findings.push({ id: "turn-terminal-zero-elapsed", severity: "amber", summary: "terminal Code Agent card displayed 耗时 0 秒; terminal duration issue is a non-blocking timing alert", count: terminalZeroElapsed.length, samples: terminalZeroElapsed.slice(0, 20) }); + const cardTiming = sampleMetrics?.codeAgentCardTiming || {}; + const cardTimingSummary = cardTiming.summary || {}; + if (Number(cardTimingSummary.missingElapsedCount ?? 0) > 0) findings.push({ id: "code-agent-card-elapsed-missing", severity: "amber", summary: "visible Code Agent card did not display total elapsed time; elapsed visibility is a non-blocking timing alert", count: cardTimingSummary.missingElapsedCount, samples: (cardTiming.missingElapsed || []).slice(0, 20) }); + if (Number(cardTimingSummary.missingRecentUpdateCount ?? 0) > 0) findings.push({ id: "code-agent-card-running-recent-update-missing", severity: "amber", summary: "non-terminal Code Agent card did not display 最近更新; recent-update visibility is a non-blocking timing alert", count: cardTimingSummary.missingRecentUpdateCount, samples: (cardTiming.missingRecentUpdate || []).slice(0, 20) }); + const roundCompletion = cardTiming.roundCompletion || {}; + if (Number(cardTimingSummary.durationUnderreportedCount ?? 0) > 0) { + findings.push({ + id: "code-agent-card-duration-underreported", + severity: "amber", + summary: "completed Code Agent card total elapsed is shorter than trace/final-response duration evidence; timing mismatch is a non-blocking alert", + timingSourceOfTruth: "trace-completion-total-or-final-response-duration", + timingStatus: timingStatusFromRows(cardTiming.durationUnderreported, "business-turn-completed"), + timingAlert: true, + count: cardTimingSummary.durationUnderreportedCount, + samples: (cardTiming.durationUnderreported || []).slice(0, 20), + }); + } + if (Number(cardTimingSummary.durationMismatchCount ?? 0) > 0) { + findings.push({ + id: "code-agent-card-duration-mismatch", + severity: "amber", + summary: "completed Code Agent card total elapsed does not match sealed completion/final-response timing evidence; timing mismatch is a non-blocking alert", + timingSourceOfTruth: "trace-completion-total-or-final-response-duration", + timingStatus: timingStatusFromRows(cardTiming.durationMismatches, "business-turn-completed"), + timingAlert: true, + count: cardTimingSummary.durationMismatchCount, + samples: (cardTiming.durationMismatches || []).slice(0, 20), + }); + } + const traceOrder = sampleMetrics?.traceOrder || {}; + const traceOrderSummary = traceOrder.summary || {}; + if (Number(traceOrderSummary.orderAnomalyCount ?? 0) > 0) { + findings.push({ + id: "trace-row-order-nonmonotonic", + severity: "red", + summary: "visible trace rows are not monotonic by total time, clock time, or projected sequence in DOM order", + count: traceOrderSummary.orderAnomalyCount, + samples: (traceOrder.orderAnomalies || []).slice(0, 20), + }); + } + if (Number(traceOrderSummary.completionNotLastCount ?? 0) > 0) { + findings.push({ + id: "trace-completion-row-not-last", + severity: "red", + summary: "visible trace shows a completion row before later trace rows for the same trace", + count: traceOrderSummary.completionNotLastCount, + samples: (traceOrder.completionNotLast || []).slice(0, 20), + }); + } + if (Number(cardTimingSummary.roundCompletionElapsedMismatchCount ?? 0) > 0) findings.push({ id: "round-completion-elapsed-mismatch", severity: "amber", summary: "Trace row 轮次完成(总耗时 ...) does not match the visible Code Agent card total elapsed time within YAML timing slack; timing mismatch is a non-blocking alert", timingSourceOfTruth: "trace-round-completion-total", timingStatus: timingStatusFromRows(roundCompletion.elapsedMismatches, "business-turn-completed"), timingAlert: true, count: cardTimingSummary.roundCompletionElapsedMismatchCount, toleranceSeconds: cardTimingSummary.elapsedMismatchToleranceSeconds, samples: (roundCompletion.elapsedMismatches || []).slice(0, 20) }); + if (Number(cardTimingSummary.roundCompletionFinalResponseMissingCount ?? 0) > 0) findings.push({ id: "round-completion-final-response-missing", severity: "red", summary: "Trace row showed 轮次完成, but no final response was visible in the Code Agent card afterward", count: cardTimingSummary.roundCompletionFinalResponseMissingCount, samples: (roundCompletion.finalResponseMissing || []).slice(0, 20) }); + if (Number(cardTimingSummary.roundCompletionPostTimingChangeCount ?? 0) > 0) findings.push({ id: "round-completion-post-timing-change", severity: "amber", summary: "After 轮次完成, card total elapsed or 最近更新 continued changing; terminal timing alert is non-blocking", count: cardTimingSummary.roundCompletionPostTimingChangeCount, samples: (roundCompletion.postCompletionTimingChanges || []).slice(0, 20) }); + if (Number(cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount ?? 0) > 0) findings.push({ id: "round-completion-recent-update-still-visible", severity: "info", summary: "最近更新 was still visible after 轮次完成; inspect whether terminal cards should hide activity age or keep it sealed", count: cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount, samples: (roundCompletion.postCompletionRecentUpdateVisible || []).slice(0, 20) }); + const scrollJumps = []; + for (let i = 1; i < samples.length; i += 1) { + const prevY = Number(samples[i - 1]?.scroll?.y ?? 0); + const nextY = Number(samples[i]?.scroll?.y ?? 0); + if (prevY > alertThresholds.scrollJumpFromY && nextY < alertThresholds.scrollJumpToY && !nearCommand(samples[i], commandTimes, alertThresholds.scrollJumpCommandWindowMs)) scrollJumps.push({ from: ref(samples[i - 1]), to: ref(samples[i]) }); + } + if (scrollJumps.length > 0) findings.push({ id: "scroll-jump-top", severity: "amber", summary: "scroll position jumped near top without nearby command", count: scrollJumps.length, samples: scrollJumps.slice(0, 10) }); + const traceTerminal = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.some((row) => isTerminalTraceText((row.status || "") + " " + (row.textPreview || "")))); + const traceSeen = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0); + if (traceSeen && !traceTerminal) findings.push({ id: "trace-without-terminal", severity: "amber", summary: "trace rows were visible but no terminal status was sampled", firstTraceSample: ref(samples.find((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0)) }); + const workbenchInPlaceProjectionLag = detectWorkbenchInPlaceProjectionLag(samples, network, control); + if (workbenchInPlaceProjectionLag.terminalTraceMissing.length > 0) findings.push({ + id: "workbench-terminal-trace-not-hydrated-in-place", + severity: "red", + summary: "Workbench rendered a terminal turn/message in-place while the same trace still had no visible run-record trace rows", + count: workbenchInPlaceProjectionLag.terminalTraceMissing.length, + samples: workbenchInPlaceProjectionLag.terminalTraceMissing.slice(0, 20), + rootCause: "workbench_trace_projection_not_hydrated_in_place", + rootCauseStatus: "confirmed-from-dom-samples", + rootCauseConfidence: "high", + valuesRedacted: true + }); + if (workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.length > 0) findings.push({ + id: "workbench-terminal-api-dom-not-refreshed-in-place", + severity: "red", + summary: "Workbench REST returned terminal/final trace evidence but the same in-place page did not render terminal message plus trace rows within the configured budget", + count: workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.length, + budgetMs: workbenchInPlaceProjectionLag.terminalApiDomLag.summary.budgetMs, + windowMs: workbenchInPlaceProjectionLag.terminalApiDomLag.summary.windowMs, + samples: workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.slice(0, 20), + rootCause: "workbench_rest_terminal_projection_dom_lag", + rootCauseStatus: "confirmed-from-network-body-summary-and-dom-samples", + rootCauseConfidence: "high", + valuesRedacted: true + }); + const turnStateTriad = sampleMetrics?.workbenchTurnStateTriad || {}; + const turnStateTriadSummary = turnStateTriad.summary || {}; + const turnStateTriadRows = [ + ...(Array.isArray(turnStateTriad.invalidFullTriads) ? turnStateTriad.invalidFullTriads : []), + ...(Array.isArray(turnStateTriad.cardFinalResponseMismatches) ? turnStateTriad.cardFinalResponseMismatches : []) + ]; + if (Number(turnStateTriadSummary.invalidRowCount ?? 0) > 0) { + const drilldown = turnStateTriad.drilldown ?? buildWorkbenchTurnStateTriadDrilldown(turnStateTriadRows); + const rootCause = workbenchTriadRootCauseFromDrilldown(drilldown, turnStateTriadSummary); + findings.push({ + id: "workbench-turn-state-triad-inconsistent", + severity: "red", + summary: rootCause.summary, + count: turnStateTriadSummary.invalidRowCount, + fullTriadCount: turnStateTriadSummary.fullTriadRowCount, + invalidFullTriadCount: turnStateTriadSummary.invalidFullTriadCount, + cardFinalResponseMismatchCount: turnStateTriadSummary.cardFinalResponseMismatchCount, + legacyCollectorMissingCount: turnStateTriadSummary.collectorMissingRowCount, + collectorMissingFields: Array.isArray(turnStateTriadSummary.collectorMissingFields) ? turnStateTriadSummary.collectorMissingFields : [], + dominantMismatchKind: rootCause.dominantMismatchKind, + allowedTuples: [ + { railStatus: "completed", cardStatus: "completed", finalResponsePresent: true }, + { railStatus: "running", cardStatus: "running", finalResponsePresent: false } + ], + samples: turnStateTriadRows.slice(0, 20), + drilldown, + collectorMissingSamples: Array.isArray(turnStateTriad.collectorMissingRows) ? turnStateTriad.collectorMissingRows.slice(0, 10) : [], + sourceOfTruth: rootCause.sourceOfTruth + "; do not repair via DOM fallback or GET-side state mutation", + nextAction: rootCause.nextAction, + rootCause: rootCause.rootCause, + rootCauseStatus: rootCause.rootCauseStatus, + rootCauseConfidence: rootCause.rootCauseConfidence, + valuesRedacted: true + }); + } + const promptFailures = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.chatPostOk === false && !promptCommandHasAuthoritativeSubmitSideEffect(control, item)) : []; + if (promptFailures.length > 0) findings.push({ id: "prompt-chat-submit-failed", severity: "red", summary: "sendPrompt command had no successful /v1/agent/chat or /v1/agent/chat/steer POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) }); + const promptSteerRounds = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.steerUsed === true) : []; + if (promptSteerRounds.length > 0) findings.push({ id: "prompt-routed-to-steer", severity: "amber", summary: "sendPrompt was submitted through /v1/agent/chat/steer; verify the previous turn was truly in-flight and not an unsealed terminal failure", count: promptSteerRounds.length, rounds: promptSteerRounds.slice(0, 10) }); + const elapsedZeroResets = Array.isArray(sampleMetrics?.turnTimingElapsedZeroResets) ? sampleMetrics.turnTimingElapsedZeroResets : []; + if (elapsedZeroResets.length > 0) findings.push({ id: "turn-timing-total-elapsed-zero-reset", severity: "amber", summary: "Code Agent total elapsed jumped from a non-zero value back to 0 seconds; timing reset is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedZeroResets), timingAlert: true, count: elapsedZeroResets.length, samples: elapsedZeroResets.slice(0, 20) }); + const elapsedDecreases = Array.isArray(sampleMetrics?.turnTimingNonMonotonic) + ? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds" && item.anomaly !== "zero-reset") + : []; + if (elapsedDecreases.length > 0) findings.push({ id: "turn-timing-total-elapsed-decrease", severity: "amber", summary: "Code Agent total elapsed decreased between adjacent samples; timing decrease is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedDecreases), timingAlert: true, count: elapsedDecreases.length, samples: elapsedDecreases.slice(0, 20) }); + const elapsedForwardJumps = Array.isArray(sampleMetrics?.turnTimingTotalElapsedForwardJumps) ? sampleMetrics.turnTimingTotalElapsedForwardJumps : []; + if (elapsedForwardJumps.length > 0) findings.push({ id: "turn-timing-total-elapsed-forward-jump", severity: "amber", summary: "Code Agent total elapsed jumped forward faster than browser sample interval; timing jump is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedForwardJumps), timingAlert: true, count: elapsedForwardJumps.length, samples: elapsedForwardJumps.slice(0, 20) }); + const terminalElapsedGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : []; + if (terminalElapsedGrowth.length > 0) findings.push({ id: "turn-timing-terminal-elapsed-growth", severity: "amber", summary: "terminal Code Agent card total elapsed changed after terminal status; terminal timing alert is non-blocking", timingSourceOfTruth: "terminal-card-total-elapsed-seal", timingStatus: timingStatusFromRows(terminalElapsedGrowth, "business-turn-completed"), timingAlert: true, count: terminalElapsedGrowth.length, samples: terminalElapsedGrowth.slice(0, 20) }); + const recentUpdateSawtoothJumps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateSawtoothJumps) + ? sampleMetrics.turnTimingRecentUpdateSawtoothJumps + : Array.isArray(sampleMetrics?.turnTimingNonMonotonic) + ? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump") + : []; + if (recentUpdateSawtoothJumps.length > 0) findings.push({ id: "turn-timing-recent-update-sawtooth-jump", severity: "amber", summary: "最近更新 value jumped faster than sample interval; expected sawtooth increase-or-reset", count: recentUpdateSawtoothJumps.length, samples: recentUpdateSawtoothJumps.slice(0, 20) }); + const severeTimeoutRounds = Array.isArray(sampleMetrics?.rounds) ? sampleMetrics.rounds.filter((item) => Number(item.maxTotalElapsedSeconds) > alertThresholds.turnElapsedSevereTimeoutSeconds) : []; + const severeTimeoutSamples = Array.isArray(sampleMetrics?.timeline) ? sampleMetrics.timeline.filter((item) => Number(item.totalElapsedSeconds) > alertThresholds.turnElapsedSevereTimeoutSeconds) : []; + if (severeTimeoutRounds.length > 0 || severeTimeoutSamples.length > 0) findings.push({ id: "turn-elapsed-severe-timeout", severity: "amber", summary: "turn total elapsed exceeded the YAML-configured elapsed alert threshold; timing is a non-blocking alert unless the turn fails to complete or breaks multi-round continuity", timingSourceOfTruth: "dom-card-total-elapsed-yaml-threshold", timingStatus: timingStatusFromRows([...severeTimeoutRounds, ...severeTimeoutSamples], "observer-timeout"), timingAlert: true, thresholdSeconds: alertThresholds.turnElapsedSevereTimeoutSeconds, count: Math.max(severeTimeoutRounds.length, severeTimeoutSamples.length), rounds: severeTimeoutRounds.slice(0, 20), samples: severeTimeoutSamples.slice(0, 20) }); + const loadingSummary = sampleMetrics?.loading?.summary || {}; + const visibleLoadingSlowSeconds = alertThresholds.visibleLoadingSlowMs / 1000; + if (Number(loadingSummary.longestContinuousSeconds ?? 0) > visibleLoadingSlowSeconds) findings.push({ id: "page-loading-visible-over-budget", severity: "red", summary: "visible 加载中 stayed on screen longer than configured YAML budget; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overBudgetSegmentCount ?? loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, budgetSeconds: visibleLoadingSlowSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) }); + if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) }); + const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {}; + if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ + id: "session-rail-title-fallback-root-cause", + severity: "red", + summary: "INV-02 root cause visible: session rail is rendering fallback Session ses_* titles, so list projection/read model or rail binding is missing stable title/preview data before DOM render", + rootCause: "session_title_fallback_from_facts", + rootCauseStatus: "confirmed-from-dom-session-rail", + rootCauseConfidence: "high", + nextAction: "Check OTel session_list_read fallbackTitleCount/fallbackTitleRatio and emptyPreviewCount for the same run; fix session list projection/read model title/preview before changing DOM fallback text.", + count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, + thresholdRatio: alertThresholds.sessionRailFallbackRatio, + maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, + maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, + evidence: { + thresholdRatio: alertThresholds.sessionRailFallbackRatio, + maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, + maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, + overThresholdSampleCount: sessionRailTitleSummary.overThresholdSampleCount ?? null, + majorityFallbackSampleCount: sessionRailTitleSummary.majorityFallbackSampleCount ?? null, + valuesRedacted: true + }, + samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), + examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20), + valuesRedacted: true + }); + const traceEventsPageReadIssues = detectTraceEventsPageReadIssues(network); + if (traceEventsPageReadIssues.http404.length > 0) findings.push({ + id: "trace-events-page-read-404-root-cause", + severity: "red", + summary: "INV-07 root cause visible: /v1/workbench/traces/:traceId/events returned HTTP 404 for a trace event page read, so the failure is in the trace-events API paging/read-model contract before DOM rendering", + rootCause: "trace_events_paging_contract_mismatch", + rootCauseStatus: "confirmed-from-browser-network", + rootCauseConfidence: "high", + nextAction: "Use OTel trace_events_read for the same trace to compare sinceSeq/afterProjectedSeq, returnedEvents, range, totalEvents, hasMore and fullTraceLoaded; fix backend paging contract or add missing instrumentation before changing renderer behavior.", + count: traceEventsPageReadIssues.http404.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.http404.slice(0, 20), + valuesRedacted: true + }); + if (traceEventsPageReadIssues.httpErrors.length > 0) findings.push({ + id: "trace-events-page-read-http-error-root-cause", + severity: "red", + summary: "trace events page read returned HTTP error status; monitor can localize this to the trace-events API instead of a generic Workbench render failure", + rootCause: "trace-events-api-page-read-http-error", + rootCauseStatus: "confirmed-from-browser-network", + rootCauseConfidence: "high", + nextAction: "Drill down by traceId and afterProjectedSeq, then compare with OTel trace_events_read paging fields.", + count: traceEventsPageReadIssues.httpErrors.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.httpErrors.slice(0, 20), + valuesRedacted: true + }); + if (traceEventsPageReadIssues.requestFailed.length > 0) findings.push({ + id: "trace-events-page-read-requestfailed-root-cause", + severity: "amber", + summary: "trace events page read failed at browser/network level; monitor localized the failure path, but HTTP status is unavailable so OTel/API instrumentation is needed to confirm the backend root cause", + rootCause: "trace-events-api-page-read-network-failed", + rootCauseStatus: "network-signal-needs-otel-confirmation", + rootCauseConfidence: "medium", + nextAction: "Check whether this happened during observer refresh/navigation; if not, query OTel by /v1/workbench/traces/:traceId/events and add route span fields when status/afterProjectedSeq are missing.", + count: traceEventsPageReadIssues.requestFailed.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.requestFailed.slice(0, 20), + valuesRedacted: true + }); + if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) }); + if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) }); + if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); + if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) }); + if ((runtimeAlerts?.summary?.significantConsoleAlertCount ?? runtimeAlerts?.summary?.consoleAlertCount ?? 0) > 0) findings.push({ id: "runtime-console-alerts", severity: "amber", summary: "browser console warning/error entries were captured during observation", count: runtimeAlerts.summary.significantConsoleAlertCount ?? runtimeAlerts.summary.consoleAlertCount, groups: (runtimeAlerts.significantConsoleAlertsByPath ?? runtimeAlerts.consoleAlertsByPath).slice(0, 12) }); + const crossPageDiffs = mergeCrossPageDiffRows( + detectCrossPageProjectionDiffs(samples), + detectAdjacentCrossPageProjectionDiffs(samples) + ); + const crossPageProjectionDiffs = crossPageDiffs.filter((item) => item.diffKind !== "trace-visibility"); + const crossPageTraceVisibilityDiffs = crossPageDiffs.filter((item) => item.diffKind === "trace-visibility"); + const crossPageProjectionBudgetMs = alertThresholds.crossPageProjectionDivergenceRedMs; + const sampleBySeq = new Map(samples.map((item) => [Number(item?.seq), item]).filter(([seq]) => Number.isFinite(seq))); + const appShellNotReadyRows = detectWorkbenchAppShellNotReady(samples); + const persistentAppShellNotReadyRows = appShellNotReadyRows.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs); + const transientAppShellNotReadyRows = appShellNotReadyRows.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs); + if (persistentAppShellNotReadyRows.length > 0) findings.push({ + id: "workbench-app-shell-not-ready", + severity: "red", + summary: "Workbench route document loaded but the app shell never mounted or hydrated within the configured budget; treat this as page/runtime startup evidence, not a Workbench projection divergence", + count: persistentAppShellNotReadyRows.length, + budgetMs: crossPageProjectionBudgetMs, + samples: persistentAppShellNotReadyRows.slice(0, 20), + rootCause: "workbench_page_app_shell_not_ready", + rootCauseStatus: "confirmed-from-dom-and-asset-provenance", + rootCauseConfidence: "high", + valuesRedacted: true + }); + if (transientAppShellNotReadyRows.length > 0) findings.push({ id: "workbench-app-shell-transient-not-ready", severity: "info", summary: "Workbench route briefly had a document and assets but no mounted app shell; retained as startup context", count: transientAppShellNotReadyRows.length, budgetMs: crossPageProjectionBudgetMs, samples: transientAppShellNotReadyRows.slice(0, 20) }); + const timedCrossPageProjectionDiffs = annotateCrossPageDiffTiming(crossPageProjectionDiffs); + const controlledNavigationHydrationProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq)); + const appShellNotReadyProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => !controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq) && crossPageDiffHasWorkbenchAppShellNotReady(item, sampleBySeq)); + const evaluatedCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => !controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq) && !crossPageDiffHasWorkbenchAppShellNotReady(item, sampleBySeq)); + const persistentCrossPageProjectionDiffs = evaluatedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs); + const transientCrossPageProjectionDiffs = evaluatedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs); + if (persistentCrossPageProjectionDiffs.length > 0) findings.push({ + id: "cross-page-projection-divergence", + severity: "red", + summary: "control and observer pages saw different projection state for the same sampled session beyond the configured budget", + count: persistentCrossPageProjectionDiffs.length, + budgetMs: crossPageProjectionBudgetMs, + samples: persistentCrossPageProjectionDiffs.slice(0, 20), + drilldown: buildCrossPageProjectionDrilldown(persistentCrossPageProjectionDiffs), + sourceOfTruth: "session list/detail/messages/turn APIs must read from the same durable projection snapshot across pages", + rootCause: "workbench_cross_page_projection_not_single_source", + rootCauseStatus: "confirmed-from-control-observer-dom-samples", + rootCauseConfidence: "high", + nextAction: "Use drilldown.traceIds with diagnose-code-agent and compare session_list_read/session_messages_read/turn_status_read rows before changing renderer fallback behavior.", + valuesRedacted: true + }); + if (transientCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-transient-divergence", severity: "info", summary: "control and observer pages briefly differed near a sampled transition; retained as transient evidence but not treated as persistent projection failure", count: transientCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: transientCrossPageProjectionDiffs.slice(0, 20) }); + if (controlledNavigationHydrationProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-controlled-navigation-hydration", severity: "info", summary: "control and observer pages differed while a non-blocking session-invariance navigation command still had an unhydrated blank page; retained as context but not treated as a red projection blocker", count: controlledNavigationHydrationProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: controlledNavigationHydrationProjectionDiffs.slice(0, 20) }); + if (appShellNotReadyProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-app-shell-not-ready", severity: "info", summary: "cross-page projection differences were explained by a page whose Workbench app shell was not mounted; see workbench-app-shell-not-ready for the blocking root cause", count: appShellNotReadyProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: appShellNotReadyProjectionDiffs.slice(0, 20) }); + if (crossPageTraceVisibilityDiffs.length > 0) findings.push({ id: "cross-page-trace-visibility-divergence", severity: "info", summary: "control and observer pages differed only in visible trace row count; this is local disclosure/hydration visibility, not session/message projection divergence", count: crossPageTraceVisibilityDiffs.length, samples: crossPageTraceVisibilityDiffs.slice(0, 20) }); + const traceMessageDuplicates = detectTraceMessageDuplication(samples); + if (traceMessageDuplicates.length > 0) findings.push({ id: "trace-assistant-message-duplicates-final-response", severity: "amber", summary: "trace-frame rendered duplicate visible assistant final rows; the fixed Final Response renderer summary block is excluded", count: traceMessageDuplicates.length, finalResponseSummaryBlockCounted: false, traceFrameSource: "traceRows-only", samples: traceMessageDuplicates.slice(0, 20) }); + const turnTraceMissing = detectTurnTraceIdMissing(samples); + if (turnTraceMissing.length > 0) findings.push({ id: "turn-trace-id-missing", severity: "red", summary: "Code Agent turn/card was visible without a trace id, so historical trace hydration cannot be reliable", count: turnTraceMissing.length, samples: turnTraceMissing.slice(0, 20) }); + const pagePerformanceItems = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath : []; + const slowApi = pagePerformanceItems.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0); + if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded configured YAML usability budget", count: slowApi.length, budgetMs: alertThresholds.sameOriginApiSlowMs, groups: slowApi.slice(0, 20) }); + const longLivedStreams = pagePerformanceItems.filter((item) => item.isLongLivedStream); + const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0); + if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) }); + if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) }); + const requestRatePeaks = Array.isArray(requestRate?.peaks) ? requestRate.peaks : []; + const totalRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "total" && item.overThreshold === true); + const pageRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "page" && item.overThreshold === true); + const apiPathRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "apiPath" && item.overThreshold === true); + if (totalRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-total-peak", + severity: "red", + summary: "same-origin API request rate exceeded the YAML total peak threshold; this is request storm evidence from browser network events", + count: totalRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRateTotalRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: totalRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); + if (pageRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-page-peak", + severity: "red", + summary: "a page-level same-origin API request curve exceeded the YAML peak threshold; inspect the page and top API paths before changing probe sampling", + count: pageRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRatePageRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: pageRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); + if (apiPathRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-api-path-peak", + severity: "red", + summary: "an API-path request curve exceeded the YAML peak threshold; this pinpoints the route most likely involved in a request storm", + count: apiPathRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: apiPathRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); + if ((pageProvenance?.summary?.segmentCount ?? 0) > 1) findings.push({ id: "page-provenance-segments", severity: "info", summary: "observer crossed page asset provenance segments; interpret runtime findings by segment", segmentCount: pageProvenance.summary.segmentCount, segments: pageProvenance.segments.slice(0, 20) }); + const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || ""))); + const apiDomLagSummary = effectiveApiDomLag?.summary || {}; + findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for API-to-DOM lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length, apiDomLag: apiDomLagSummary }); + findings.push({ + id: "natural-api-dom-lag-candidates", + severity: Number(apiDomLagSummary.overBudgetCount ?? 0) > 0 ? "amber" : "info", + summary: "state-relevant natural API responses were correlated to the first subsequent DOM digest change; over-budget lag is a non-blocking investigation alert", + count: apiDomLagSummary.candidateCount ?? 0, + domChangedCount: apiDomLagSummary.domChangedCount ?? 0, + noDomChangeWithinWindowCount: apiDomLagSummary.noDomChangeWithinWindowCount ?? 0, + overBudgetCount: apiDomLagSummary.overBudgetCount ?? 0, + budgetMs: apiDomLagSummary.budgetMs ?? null, + p95DomChangeDeltaMs: apiDomLagSummary.p95DomChangeDeltaMs ?? null, + maxDomChangeDeltaMs: apiDomLagSummary.maxDomChangeDeltaMs ?? null, + groups: Array.isArray(effectiveApiDomLag?.groups) ? effectiveApiDomLag.groups.slice(0, 12) : [], + }); + if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) }); + if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" }); + return findings; +} + +function buildCrossPageProjectionDrilldown(rows) { + const sourceRows = Array.isArray(rows) ? rows.filter(Boolean) : []; + const traceIds = uniqueSorted(sourceRows.flatMap((row) => collectIdsFromUnknown(row, "trace")).filter(Boolean)).slice(0, 12); + const sessionIds = uniqueSorted(sourceRows.flatMap((row) => collectIdsFromUnknown(row, "session")).filter(Boolean)).slice(0, 12); + const diffKinds = uniqueSorted(sourceRows.map((row) => row?.diffKind).filter(Boolean)); + const groupsByKey = new Map(); + for (const row of sourceRows) { + const rowTraceIds = collectIdsFromUnknown(row, "trace").slice(0, 8); + const rowSessionIds = collectIdsFromUnknown(row, "session").slice(0, 8); + const key = [ + row?.diffKind || "projection", + rowSessionIds[0] || row?.sessionIdPrefix || "-", + rowTraceIds[0] || "-", + ].join("|"); + let group = groupsByKey.get(key); + if (!group) { + group = { + keyHash: sha256(key), + diffKind: row?.diffKind ?? null, + traceIds: new Set(), + sessionIds: new Set(), + count: 0, + firstSeq: row?.firstSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq ?? null, + lastSeq: row?.lastSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq ?? null, + firstAt: row?.firstAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null, + lastAt: row?.lastAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null, + maxObservedSpanMs: 0, + examples: [], + }; + groupsByKey.set(key, group); + } + group.count += 1; + for (const traceId of rowTraceIds) group.traceIds.add(traceId); + for (const sessionId of rowSessionIds) group.sessionIds.add(sessionId); + group.firstSeq = minNumberOrValue(group.firstSeq, row?.firstSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq); + group.lastSeq = maxNumberOrValue(group.lastSeq, row?.lastSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq); + group.firstAt = minIso(group.firstAt, row?.firstAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null); + group.lastAt = maxIso(group.lastAt, row?.lastAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null); + group.maxObservedSpanMs = Math.max(group.maxObservedSpanMs, Number(row?.observedSpanMs ?? 0) || 0); + if (group.examples.length < 3) { + group.examples.push({ + diffKind: row?.diffKind ?? null, + firstSeq: row?.firstSeq ?? null, + lastSeq: row?.lastSeq ?? null, + observedSpanMs: row?.observedSpanMs ?? null, + controlMessageCount: row?.controlMessageCount ?? null, + observerMessageCount: row?.observerMessageCount ?? null, + traceIds: rowTraceIds.slice(0, 6), + sessionIds: rowSessionIds.slice(0, 4), + valuesRedacted: true, + }); + } + } + const groups = Array.from(groupsByKey.values()).map((group) => ({ + keyHash: group.keyHash, + diffKind: group.diffKind, + traceIds: Array.from(group.traceIds).slice(0, 8), + sessionIds: Array.from(group.sessionIds).slice(0, 6), + count: group.count, + firstSeq: group.firstSeq, + lastSeq: group.lastSeq, + firstAt: group.firstAt, + lastAt: group.lastAt, + observedSpanMs: Math.max(group.maxObservedSpanMs, isoSpanMs(group.firstAt, group.lastAt)), + examples: group.examples, + valuesRedacted: true, + })).sort((left, right) => Number(right.observedSpanMs ?? 0) - Number(left.observedSpanMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0)); + return { + summary: { + rowCount: sourceRows.length, + groupCount: groups.length, + diffKinds, + traceIds, + sessionIds, + maxObservedSpanMs: groups.reduce((value, item) => Math.max(value, Number(item.observedSpanMs ?? 0)), 0), + valuesRedacted: true, + }, + traceIds, + sessionIds, + groups: groups.slice(0, 12), + otelDrilldown: buildWorkbenchTriadOtelDrilldown(traceIds.slice(0, 4)), + staticSourceHints: workbenchTriadStaticSourceHints(), + unitTestReproHints: [ + "two independent Workbench pages must converge to identical session list/detail/messages/turn projection after the same durable events", + "late session-list or message refresh must not drop a trace that another page already read from durable projection", + "read-side APIs must expose enough OTel fields to compare projection version/cursor and trace ids across pages", + ], + valuesRedacted: true, + }; +} + +function collectIdsFromUnknown(value, kind, output = new Set(), depth = 0) { + if (depth > 4 || value === null || value === undefined) return Array.from(output); + if (typeof value === "string") { + const pattern = kind === "session" ? /\bses_[A-Za-z0-9_-]+\b/gu : /\btrc_[A-Za-z0-9_-]+\b/gu; + for (const match of value.match(pattern) || []) output.add(match); + return Array.from(output); + } + if (Array.isArray(value)) { + for (const item of value.slice(0, 40)) collectIdsFromUnknown(item, kind, output, depth + 1); + return Array.from(output); + } + if (typeof value === "object") { + for (const item of Object.values(value).slice(0, 80)) collectIdsFromUnknown(item, kind, output, depth + 1); + } + return Array.from(output); +} + +function buildFrontendFreezeFindings(errors, control) { + const findings = []; + const promptTimes = (control || []) + .filter((item) => item.type === "sendPrompt" && item.phase === "completed") + .map((item) => Date.parse(item.ts)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + const stopWindows = stopCommandWindows(control); + const events = (errors || []) + .map((item) => frontendFreezeErrorEvent(item, promptTimes)) + .filter((item) => item && !errorInsideStopWindow(item, stopWindows)); + const domEvents = events.filter((item) => item.kind === "dom-evaluate-timeout"); + const controlDomBurst = firstBurst( + domEvents.filter((item) => item.pageRole === "control" || item.pageRole === null), + alertThresholds.domEvaluateTimeoutRedCount, + alertThresholds.domEvaluateTimeoutRedWindowMs, + ); + if (controlDomBurst) findings.push(frontendFreezeBurstFinding({ + id: "frontend-control-dom-evaluate-timeout-red", + summary: "control page DOM evaluation timed out repeatedly; treat the browser page as frozen and keep the sentinel red instead of refreshing or falling back", + burst: controlDomBurst, + thresholdCount: alertThresholds.domEvaluateTimeoutRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + pageRole: "control", + })); + const observerDomBurst = firstBurst( + domEvents.filter((item) => item.pageRole === "observer"), + alertThresholds.domEvaluateTimeoutRedCount, + alertThresholds.domEvaluateTimeoutRedWindowMs, + ); + if (observerDomBurst) findings.push(frontendFreezeBurstFinding({ + id: "frontend-observer-dom-evaluate-timeout-red", + summary: "observer page DOM evaluation timed out repeatedly; the observer page is frozen and later periodic refresh evidence must not clear this run", + burst: observerDomBurst, + thresholdCount: alertThresholds.domEvaluateTimeoutRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + pageRole: "observer", + })); + const screenshotBurst = firstBurst( + events.filter((item) => item.kind === "screenshot-timeout"), + alertThresholds.screenshotTimeoutRedCount, + alertThresholds.domEvaluateTimeoutRedWindowMs, + ); + if (screenshotBurst) findings.push(frontendFreezeBurstFinding({ + id: "frontend-screenshot-timeout-red", + summary: "browser screenshot capture timed out repeatedly; this is freeze evidence and the sentinel must stay red until investigated", + burst: screenshotBurst, + thresholdCount: alertThresholds.screenshotTimeoutRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + pageRole: null, + })); + const pageErrors = events.filter((item) => item.kind === "page-error"); + const pageErrorBurst = firstBurst(pageErrors, alertThresholds.pageErrorRedCount, alertThresholds.domEvaluateTimeoutRedWindowMs); + if (pageErrorBurst) findings.push(frontendFreezeBurstFinding({ + id: "frontend-page-error-red", + summary: "browser pageerror entries exceeded the YAML threshold; page runtime exceptions are blocking when repeated in the observation window", + burst: pageErrorBurst, + thresholdCount: alertThresholds.pageErrorRedCount, + windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, + pageRole: null, + })); + return findings; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-io-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-io-source.ts new file mode 100644 index 00000000..11ac94d3 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-io-source.ts @@ -0,0 +1,801 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer artifact IO, YAML-derived config parsing, JSONL tailing, focus windows, and sample compaction source fragment. + +export function nodeWebObserveAnalyzerIoSource(): string { + return String.raw`async function readJson(file) { + try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } +} + +async function readCommandState(rootDir) { + const buckets = {}; + for (const bucket of ["pending", "processing", "done", "failed", "abandoned"]) { + buckets[bucket] = await readCommandBucket(path.join(rootDir, "commands", bucket), bucket); + } + return { + pendingCount: buckets.pending.count, + processingCount: buckets.processing.count, + doneCount: buckets.done.count, + failedCount: buckets.failed.count, + abandonedCount: buckets.abandoned.count, + pending: buckets.pending.items, + processing: buckets.processing.items, + failed: buckets.failed.items.slice(0, 12), + abandoned: buckets.abandoned.items.slice(0, 20), + summary: { + backlogCount: buckets.pending.count + buckets.processing.count, + oldestPendingAgeSeconds: buckets.pending.oldestAgeSeconds, + oldestProcessingAgeSeconds: buckets.processing.oldestAgeSeconds, + valuesRedacted: true + }, + valuesRedacted: true + }; +} + +async function readCommandBucket(dir, bucket) { + let names = []; + try { + names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort(); + } catch (error) { + if (!error || error.code !== "ENOENT") jsonlReadIssues.push({ file: path.basename(dir), kind: "command-dir-read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); + return { bucket, count: 0, oldestAgeSeconds: null, items: [] }; + } + const items = []; + for (const name of names.slice(0, 200)) { + const file = path.join(dir, name); + const parsed = await readJson(file) || {}; + let meta = null; + try { meta = await stat(file); } catch {} + const timestamp = parsed.createdAt || parsed.abandonedAt || parsed.completedAt || parsed.failedAt || (meta ? meta.mtime.toISOString() : null); + const timestampMs = Date.parse(String(timestamp || "")); + items.push({ + bucket, + id: parsed.id || parsed.commandId || name.replace(/[.]json$/u, ""), + type: parsed.type || parsed.command?.type || null, + createdAt: parsed.createdAt || null, + completedAt: parsed.completedAt || null, + failedAt: parsed.failedAt || null, + abandonedAt: parsed.abandonedAt || null, + reason: parsed.reason || parsed.error?.message || null, + ageSeconds: Number.isFinite(timestampMs) ? Math.max(0, Math.round((Date.now() - timestampMs) / 1000)) : null, + mtime: meta ? meta.mtime.toISOString() : null, + valuesRedacted: true + }); + } + const ages = items.map((item) => item.ageSeconds).filter((value) => Number.isFinite(value)); + return { bucket, count: names.length, oldestAgeSeconds: ages.length > 0 ? Math.max(...ages) : null, items }; +} + +function buildToolFindings({ manifest, heartbeat, commandState }) { + const findings = []; + const diagnostics = heartbeatDiagnostics(manifest, heartbeat); + if (diagnostics.heartbeatStale) { + findings.push({ + id: "tool-runner-heartbeat-stale", + severity: "red", + summary: "web-probe observe runner heartbeat is stale; treat this as observer tooling failure, not Workbench behavior", + count: 1, + diagnostics, + valuesRedacted: true + }); + } + if ((commandState?.pendingCount ?? 0) > 0 || (commandState?.processingCount ?? 0) > 0) { + findings.push({ + id: "tool-pending-commands-unconsumed", + severity: "red", + summary: "web-probe observe has pending/processing control commands that were not consumed by the runner", + count: (commandState?.pendingCount ?? 0) + (commandState?.processingCount ?? 0), + pending: (commandState?.pending ?? []).slice(0, 12), + processing: (commandState?.processing ?? []).slice(0, 12), + valuesRedacted: true + }); + } + if ((commandState?.abandonedCount ?? 0) > 0) { + findings.push({ + id: "tool-commands-abandoned", + severity: "info", + summary: "web-probe observe force-stop abandoned queued commands; do not interpret these as Workbench command failures", + count: commandState.abandonedCount, + commands: (commandState.abandoned ?? []).slice(0, 20), + valuesRedacted: true + }); + } + if (heartbeat?.forceStop || manifest?.forceStop) { + findings.push({ + id: "tool-runner-force-stopped", + severity: "info", + summary: "web-probe observe runner was stopped by the CLI outside the command queue", + count: 1, + forceStop: heartbeat?.forceStop || manifest?.forceStop, + valuesRedacted: true + }); + } + return findings; +} + +function heartbeatDiagnostics(manifest, heartbeat) { + const status = String(heartbeat?.status || manifest?.status || ""); + const terminal = /^(completed|failed|force-stopped|stopped|abandoned)$/u.test(status); + const sampleIntervalMs = Number(manifest?.sampling?.sampleIntervalMs) || 5000; + const staleAfterMs = Math.max(60000, sampleIntervalMs * 3); + const updatedAt = heartbeat?.updatedAt || heartbeat?.lastSampleAt || null; + const updatedMs = Date.parse(String(updatedAt || "")); + const ageSeconds = Number.isFinite(updatedMs) ? Math.max(0, Math.round((Date.now() - updatedMs) / 1000)) : null; + const heartbeatStale = !terminal && (!Number.isFinite(updatedMs) || Date.now() - updatedMs > staleAfterMs); + return { + status: status || null, + terminal, + updatedAt, + heartbeatAgeSeconds: ageSeconds, + heartbeatStale, + heartbeatStaleAfterSeconds: Math.round(staleAfterMs / 1000), + sampleSeq: heartbeat?.sampleSeq ?? null, + commandSeq: heartbeat?.commandSeq ?? null, + activeCommandId: heartbeat?.activeCommandId ?? null, + valuesRedacted: true + }; +} + +function safeArchivePrefix(value) { + const text = String(value || "").trim(); + if (!text) return ""; + if (!/^[A-Za-z0-9_.-]+$/u.test(text) || text.includes("..")) throw new Error("unsafe archive prefix: " + text); + return text; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function requiredPositiveThreshold(raw, key) { + const parsed = Number(raw?.[key]); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds"); + } + return parsed; +} + +function parseAlertThresholds(value) { + if (!value) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane"); + } + const raw = (() => { + try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } + })(); + const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio"); + if (sessionRailFallbackRatio > 1) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1"); + } + return { + sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"), + partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"), + longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"), + visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"), + turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"), + turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"), + domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"), + domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"), + screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), + pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), + longTaskRedMs: requiredPositiveThreshold(raw, "longTaskRedMs"), + longAnimationFrameRedMs: requiredPositiveThreshold(raw, "longAnimationFrameRedMs"), + eventLoopGapRedMs: requiredPositiveThreshold(raw, "eventLoopGapRedMs"), + browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), + requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), + requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), + requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), + requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), + browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), + browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), + browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), + browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"), + playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"), + playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"), + cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"), + uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"), + scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"), + scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), + scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), + sessionRailFallbackRatio, + crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), + source: "yaml-env", + }; +} + +function parseBrowserFreezePolicy(value) { + if (!value) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane"); + } + const raw = (() => { + try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } + })(); + const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy"); + const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy"); + const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy"); + const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy"); + return { + enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"), + blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"), + memory: { + totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"), + processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"), + growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"), + }, + responsiveness: { + latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"), + eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"), + }, + cdp: { + metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"), + }, + kill: { + enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"), + gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"), + forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"), + graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000), + pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000), + exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125), + }, + source: "yaml-env", + valuesRedacted: true, + }; +} + +function requiredPolicyRecord(raw, key, pathLabel) { + const value = raw?.[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyBoolean(raw, key, pathLabel) { + const value = raw?.[key]; + if (typeof value !== "boolean") { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyPositiveNumber(raw, key, pathLabel) { + const value = Number(raw?.[key]); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) { + const value = Number(raw?.[key]); + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max); + } + return value; +} + +function requiredPolicySignal(raw, key, pathLabel, expected) { + const value = String(raw?.[key] || ""); + if (value !== expected) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected); + } + return 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); + const rows = []; + try { + const input = createReadStream(file, { encoding: "utf8" }); + const lines = createInterface({ input, crlfDelay: Infinity }); + let lineNo = 0; + for await (const rawLine of lines) { + lineNo += 1; + const line = String(rawLine || "").trim(); + if (!line) continue; + try { + const parsed = JSON.parse(line); + rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed); + } catch (error) { + const item = { parseError: true, lineNo, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) }; + rows.push(item); + if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, rawHash: item.rawHash, errorMessage: item.errorMessage }); + } + } + return rows; + } catch (error) { + if (error && error.code === "ENOENT") return []; + if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); + return []; + } +} + +async function readJsonlTail(file, limit, options = {}) { + try { + const lines = await readTailLines(file, limit); + const rows = []; + let lineNo = 0; + for (const rawLine of lines) { + lineNo += 1; + const line = String(rawLine || "").trim(); + if (!line) continue; + try { + const parsed = JSON.parse(line); + rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed); + } catch (error) { + const item = { parseError: true, lineNo, tail: true, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) }; + rows.push(item); + if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, tail: true, rawHash: item.rawHash, errorMessage: item.errorMessage }); + } + } + return rows; + } catch (error) { + if (error && error.code === "ENOENT") return []; + if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", tail: true, code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); + return []; + } +} + +async function readTailLines(file, limit) { + const info = await stat(file); + if (!info.size || limit <= 0) return []; + const chunkSize = 64 * 1024; + const maxBytes = Math.max(32 * 1024 * 1024, Math.min(256 * 1024 * 1024, limit * 512 * 1024)); + const chunks = []; + let position = info.size; + let readBytes = 0; + let newlineCount = 0; + while (position > 0 && newlineCount <= limit && readBytes < maxBytes) { + const readSize = Math.min(chunkSize, position, maxBytes - readBytes); + position -= readSize; + const chunk = await readFileSlice(file, position, readSize); + chunks.unshift(chunk); + readBytes += chunk.length; + for (let index = 0; index < chunk.length; index += 1) { + if (chunk[index] === 10) newlineCount += 1; + } + } + if (position > 0 && newlineCount <= limit && jsonlReadIssues.length < 50) { + jsonlReadIssues.push({ file: path.basename(file), kind: "tail-scan-truncated", limit, readBytes, fileBytes: info.size }); + } + const text = Buffer.concat(chunks).toString("utf8"); + let lines = text.split(/\r?\n/u); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + if (position > 0 && lines.length > 0) lines.shift(); + return lines.slice(-limit); +} + +async function readFileSlice(file, start, length) { + const chunks = []; + await new Promise((resolve, reject) => { + const stream = createReadStream(file, { start, end: start + length - 1 }); + stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + stream.on("error", reject); + stream.on("end", resolve); + }); + return Buffer.concat(chunks); +} + +function sampleTimeWindow(samples, paddingMs) { + const times = samples + .map((item) => Date.parse(String(item && item.ts || ""))) + .filter((value) => Number.isFinite(value)); + if (times.length === 0) return { startMs: null, endMs: null, startAt: null, endAt: null, paddingMs }; + const startMs = Math.max(0, Math.min(...times) - Math.max(0, paddingMs || 0)); + const endMs = Math.max(...times) + Math.max(0, paddingMs || 0); + return { startMs, endMs, startAt: new Date(startMs).toISOString(), endAt: new Date(endMs).toISOString(), paddingMs }; +} + +function filterRowsByTimeWindow(rows, window) { + if (!window || !Number.isFinite(window.startMs) || !Number.isFinite(window.endMs)) return rows; + return rows.filter((item) => { + const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt); + const ms = Date.parse(String(value || "")); + if (!Number.isFinite(ms)) return true; + return ms >= window.startMs && ms <= window.endMs; + }); +} + +function analysisFocusFromControl(control) { + const completedNewSessions = (Array.isArray(control) ? control : []) + .filter((item) => item?.type === "newSession" && item?.phase === "completed" && Number.isFinite(Date.parse(String(item.ts || "")))) + .sort((a, b) => Date.parse(String(a.ts || "")) - Date.parse(String(b.ts || ""))); + const latest = completedNewSessions.at(-1) ?? null; + if (!latest) return { mode: "all", reason: "no-new-session-command", startAt: null, startMs: null, commandId: null, valuesRedacted: true }; + const startMs = Date.parse(String(latest.ts)); + return { + mode: "after-new-session", + reason: "latest-completed-new-session", + startAt: new Date(startMs).toISOString(), + startMs, + commandId: latest.commandId ?? null, + valuesRedacted: true + }; +} + +function applyAnalysisFocus(rows, focus, graceMs = 0) { + if (!focus || !Number.isFinite(focus.startMs)) return rows; + const minMs = focus.startMs - Math.max(0, Number(graceMs) || 0); + return (Array.isArray(rows) ? rows : []).filter((item) => { + const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt); + const ms = Date.parse(String(value || "")); + return Number.isFinite(ms) ? ms >= minMs : true; + }); +} + +function analysisControlWindow(sampleWindow, focus, graceMs = 0) { + if (!sampleWindow || !Number.isFinite(sampleWindow.endMs)) return sampleWindow; + if (!focus || !Number.isFinite(focus.startMs)) return sampleWindow; + const startMs = Math.max(0, Math.min(Number(sampleWindow.startMs ?? focus.startMs), focus.startMs - Math.max(0, Number(graceMs) || 0))); + return { + ...sampleWindow, + startMs, + startAt: new Date(startMs).toISOString() + }; +} + +function compactSampleForAnalysis(sample) { + if (!sample || typeof sample !== "object") return sample; + return { + seq: sample.seq ?? null, + ts: sample.ts ?? null, + reason: sample.reason ?? null, + sampleGroupSeq: sample.sampleGroupSeq ?? null, + pageId: sample.pageId ?? null, + pageRole: sample.pageRole ?? null, + commandId: sample.commandId ?? null, + observerInitiated: sample.observerInitiated ?? null, + url: sample.url ?? null, + path: sample.path ?? null, + title: sample.title ?? null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + messages: compactDomItems(sample.messages), + traceRows: compactDomItems(sample.traceRows), + loadings: compactLoadingItems(sample.loadings), + sessionRail: compactSessionRail(sample.sessionRail), + turns: compactDomItems(sample.turns), + diagnostics: compactDomItems(sample.diagnostics), + composer: compactComposer(sample.composer), + 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, + sourceSelectVisible: value.sourceSelectVisible === true, + fileSelectVisible: value.fileSelectVisible === true, + sourceConfigVisible: value.sourceConfigVisible === true, + taskEditorVisible: value.taskEditorVisible === true, + taskBodyVisible: value.taskBodyVisible === true, + taskBody: value.taskBody ?? null, + reportLinkCount: value.reportLinkCount ?? 0, + reportPreviewVisible: value.reportPreviewVisible === true, + reportPreview: value.reportPreview ?? null, + reportFullscreenVisible: value.reportFullscreenVisible === true, + newTaskDraftVisible: value.newTaskDraftVisible === true, + 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) : [], + paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({ + name: item?.name ?? null, + visible: item?.visible === true, + widthPx: item?.widthPx ?? null, + heightPx: item?.heightPx ?? null, + bottomGapPx: item?.bottomGapPx ?? null, + bottomGapRatio: item?.bottomGapRatio ?? null, + contentNodeCount: item?.contentNodeCount ?? null, + valuesRedacted: true + })) : [], + 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) => ({ + index: item?.index ?? null, + tag: item?.tag ?? null, + testId: item?.testId ?? null, + role: item?.role ?? null, + active: item?.active === true, + status: item?.status ?? null, + dataStatus: item?.dataStatus ?? null, + running: item?.running === true, + dataRunning: item?.dataRunning ?? null, + ariaBusy: item?.ariaBusy ?? null, + sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), + fallbackTitle: item?.fallbackTitle === true, + titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180), + titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")), + titleBytes: item?.titleBytes ?? null, + })) : []; + const fallbackItems = Array.isArray(value.fallbackItems) + ? value.fallbackItems.slice(0, 20).map((item) => ({ + index: item?.index ?? null, + active: item?.active === true, + sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), + titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180), + titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")), + })) + : items.filter((item) => item.fallbackTitle).slice(0, 20); + const visibleCount = Number(value.visibleCount ?? items.length); + const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length); + return { + visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length, + fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length, + fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0), + activeItem: items.find((item) => item.active) || null, + items, + fallbackItems, + valuesRedacted: true + }; +} + +function compactComposer(value) { + if (!value || typeof value !== "object") return null; + return { + inputPresent: value.inputPresent === true, + inputDisabled: value.inputDisabled === true, + warningPresent: value.warningPresent === true, + submitPresent: value.submitPresent === true, + submitDisabled: value.submitDisabled === true, + submitAction: value.submitAction ?? null, + activeStatus: value.activeStatus ?? null, + valuesRedacted: true + }; +} + +function compactLoadingItems(items) { + if (!Array.isArray(items)) return []; + return items.map((item) => { + if (!item || typeof item !== "object") return item; + const rawText = String(item.text ?? item.textPreview ?? ""); + return { + index: item.index ?? null, + tag: item.tag ?? null, + testId: item.testId ?? null, + role: item.role ?? null, + ownerKind: item.ownerKind ?? null, + ownerKey: item.ownerKey ?? null, + ownerLabel: item.ownerLabel ?? null, + owner: item.owner && typeof item.owner === "object" ? { + tag: item.owner.tag ?? null, + testId: item.owner.testId ?? null, + role: item.owner.role ?? null, + id: item.owner.id ?? null, + className: item.owner.className ?? null, + status: item.owner.status ?? null, + sessionId: item.owner.sessionId ?? null, + messageId: item.owner.messageId ?? null, + traceId: item.owner.traceId ?? null, + ariaLabel: item.owner.ariaLabel ?? null, + } : null, + text: limitText(rawText, 400), + textPreview: limitText(String(item.textPreview ?? rawText), 240), + textHash: item.textHash ?? sha256(rawText), + textBytes: item.textBytes ?? Buffer.byteLength(rawText), + ownerTextHash: item.ownerTextHash ?? null, + ownerTextPreview: item.ownerTextPreview ? limitText(item.ownerTextPreview, 240) : null, + }; + }); +} + +function compactDomItems(items) { + if (!Array.isArray(items)) return []; + return items.map(compactDomItem); +} + +function compactDomItem(item) { + if (!item || typeof item !== "object") return item; + const rawText = String(item.text ?? item.textPreview ?? ""); + const preview = String(item.textPreview ?? limitText(rawText, 240)); + const hasBodyTextPresent = Object.prototype.hasOwnProperty.call(item, "bodyTextPresent"); + const hasFinalResponsePresent = Object.prototype.hasOwnProperty.call(item, "finalResponsePresent"); + return { + index: item.index ?? null, + tag: item.tag ?? null, + testId: item.testId ?? null, + role: item.role ?? null, + dataRole: item.dataRole ?? null, + status: item.status ?? null, + sessionId: item.sessionId ?? null, + messageId: item.messageId ?? null, + traceId: item.traceId ?? null, + turnId: item.turnId ?? null, + projectedSeq: Number.isFinite(Number(item.projectedSeq)) ? Number(item.projectedSeq) : null, + sourceSeq: Number.isFinite(Number(item.sourceSeq)) ? Number(item.sourceSeq) : null, + eventSeq: Number.isFinite(Number(item.eventSeq)) ? Number(item.eventSeq) : null, + eventTimestamp: item.eventTimestamp ?? null, + eventTimeText: item.eventTimeText ?? null, + eventKind: item.eventKind ?? null, + durationText: item.durationText ?? null, + activityText: item.activityText ?? null, + bodyTextSource: item.bodyTextSource ?? null, + bodyTextCandidateCount: Number.isFinite(Number(item.bodyTextCandidateCount)) ? Number(item.bodyTextCandidateCount) : null, + bodyTextPresent: hasBodyTextPresent ? item.bodyTextPresent === true : null, + bodyTextPreview: item.bodyTextPreview ? limitText(item.bodyTextPreview, 600) : undefined, + bodyTextHash: item.bodyTextHash ?? null, + bodyTextBytes: Number.isFinite(Number(item.bodyTextBytes)) ? Number(item.bodyTextBytes) : null, + finalResponsePresent: hasFinalResponsePresent ? item.finalResponsePresent === true : null, + finalResponseTextSource: item.finalResponseTextSource ?? null, + finalResponseCandidateCount: Number.isFinite(Number(item.finalResponseCandidateCount)) ? Number(item.finalResponseCandidateCount) : null, + finalResponseTextPreview: item.finalResponseTextPreview ? limitText(item.finalResponseTextPreview, 600) : undefined, + finalResponseTextHash: item.finalResponseTextHash ?? null, + finalResponseTextBytes: Number.isFinite(Number(item.finalResponseTextBytes)) ? Number(item.finalResponseTextBytes) : null, + className: item.className ?? null, + diagnosticCode: item.diagnosticCode ?? null, + source: item.source ?? null, + sources: Array.isArray(item.sources) ? item.sources.slice(0, 8) : undefined, + text: limitText(rawText, 4000), + textPreview: limitText(preview, 600), + textHash: item.textHash ?? sha256(rawText), + textBytes: item.textBytes ?? Buffer.byteLength(rawText) + }; +} + +function compactPerformanceItems(items) { + if (!Array.isArray(items)) return []; + return items.map((item) => ({ + name: item?.name ?? null, + initiatorType: item?.initiatorType ?? null, + startTime: item?.startTime ?? null, + duration: item?.duration ?? null, + workerStart: item?.workerStart ?? null, + redirectStart: item?.redirectStart ?? null, + redirectEnd: item?.redirectEnd ?? null, + fetchStart: item?.fetchStart ?? null, + domainLookupStart: item?.domainLookupStart ?? null, + domainLookupEnd: item?.domainLookupEnd ?? null, + connectStart: item?.connectStart ?? null, + connectEnd: item?.connectEnd ?? null, + secureConnectionStart: item?.secureConnectionStart ?? null, + requestStart: item?.requestStart ?? null, + responseStart: item?.responseStart ?? null, + responseEnd: item?.responseEnd ?? null, + transferSize: item?.transferSize ?? null, + encodedBodySize: item?.encodedBodySize ?? null, + decodedBodySize: item?.decodedBodySize ?? null, + nextHopProtocol: item?.nextHopProtocol ?? null, + responseStatus: Number.isFinite(Number(item?.responseStatus)) ? Number(item.responseStatus) : null + })); +} + +function compactLoadingMetricsForOutput(value) { + if (!value || typeof value !== "object") return null; + return { + summary: value.summary ?? null, + longestSegments: Array.isArray(value.segments) ? value.segments.slice(0, 8) : [], + owners: Array.isArray(value.owners) ? value.owners.slice(0, 8) : [], + timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [], + valuesRedacted: true + }; +} + +function compactSessionRailTitleMetricsForOutput(value) { + if (!value || typeof value !== "object") return null; + return { + summary: value.summary ?? null, + samples: Array.isArray(value.samples) ? value.samples.slice(0, 12) : [], + examples: Array.isArray(value.examples) ? value.examples.slice(0, 12) : [], + timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [], + valuesRedacted: true + }; +} + +function compactWorkbenchTurnStateTriadForOutput(value) { + if (!value || typeof value !== "object") return null; + return { + summary: value.summary ?? null, + drilldown: value.drilldown ?? null, + invalidFullTriads: Array.isArray(value.invalidFullTriads) ? value.invalidFullTriads.slice(0, 8) : [], + cardFinalResponseMismatches: Array.isArray(value.cardFinalResponseMismatches) ? value.cardFinalResponseMismatches.slice(0, 8) : [], + collectorMissingRows: Array.isArray(value.collectorMissingRows) ? value.collectorMissingRows.slice(0, 8) : [], + valuesRedacted: true + }; +} + +function compactSamplePageProvenance(value) { + if (!value || typeof value !== "object") return null; + return { + pageLoadSeq: value.pageLoadSeq ?? null, + reason: value.reason ?? null, + observedAt: value.observedAt ?? null, + urlPath: value.urlPath ?? null, + documentReadyState: value.documentReadyState ?? null, + timeOrigin: value.timeOrigin ?? null, + httpStatus: value.httpStatus ?? null, + assetFingerprint: value.assetFingerprint ?? null, + scriptCount: value.scriptCount ?? null, + stylesheetCount: value.stylesheetCount ?? null, + metaCount: value.metaCount ?? null, + scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 20) : [], + stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 20) : [], + error: value.error ?? null, + valuesRedacted: true + }; +} + +function summarizeCommandFailures(control) { + return control.filter((item) => 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 : detail; + return { + ts: item.ts ?? null, + commandId: item.commandId ?? null, + type: item.type ?? item.input?.type ?? null, + source: item.source ?? null, + durationMs: detail.durationMs ?? null, + beforePath: urlPath(detail.beforeUrl || item.beforeUrl), + afterPath: urlPath(detail.afterUrl || item.afterUrl), + name: error?.name ?? null, + message: limitText(error?.message ?? detail?.message ?? "", 240), + failureKind: error?.failureKind ?? detail?.failureKind ?? null, + failureSampleOk: detail?.failureSample?.ok === true, + sampleSeq: detail?.failureSample?.sampleSeq ?? null, + valuesRedacted: true + }; + }); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts new file mode 100644 index 00000000..968f619a --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts @@ -0,0 +1,303 @@ +// SPEC: pikasTech/unidesk#1436 WEB-PROBE performance analyzer. +// Responsibility: Source string for offline frontend long-task/LoAF/profile hotspot analysis. + +export function nodeWebObserveAnalyzerPerformanceSource(): string { + return String.raw` +function buildFrontendPerformanceReport(rows, artifacts) { + const sourceRows = Array.isArray(rows) ? rows : []; + const events = []; + const drainErrors = []; + const captures = []; + const scriptGroups = new Map(); + const profileFunctionGroups = new Map(); + const profileStackGroups = new Map(); + for (const row of sourceRows) { + if (!row || typeof row !== "object") continue; + if (row.type === "performance-drain-error") { + drainErrors.push(compactPerformanceDrainError(row)); + continue; + } + if (row.type === "performance-capture-completed") { + const capture = compactPerformanceCapture(row); + captures.push(capture); + for (const fn of Array.isArray(row.summary?.topFunctions) ? row.summary.topFunctions : []) mergeProfileFunction(profileFunctionGroups, fn, capture); + for (const stack of Array.isArray(row.summary?.topStacks) ? row.summary.topStacks : []) mergeProfileStack(profileStackGroups, stack, capture); + continue; + } + if (row.type !== "performance-event") continue; + const perf = row.performance && typeof row.performance === "object" ? row.performance : {}; + const event = compactPerformanceEventRow(row, perf); + if (!event.kind) continue; + events.push(event); + for (const script of Array.isArray(perf.scripts) ? perf.scripts : []) mergeLoafScript(scriptGroups, script, event); + } + const longTasks = events.filter((item) => item.kind === "longtask"); + const loafs = events.filter((item) => item.kind === "long-animation-frame"); + const gaps = events.filter((item) => item.kind === "event-loop-gap"); + const scriptHotspots = Array.from(scriptGroups.values()) + .map(finalizeScriptHotspot) + .sort((left, right) => Number(right.totalDurationMs ?? 0) - Number(left.totalDurationMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0)) + .slice(0, 40); + const profileHotspots = Array.from(profileFunctionGroups.values()) + .map(finalizeProfileFunctionHotspot) + .sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0) || Number(right.totalTimeMs ?? 0) - Number(left.totalTimeMs ?? 0)) + .slice(0, 40); + const profileStacks = Array.from(profileStackGroups.values()) + .map(finalizeProfileStackHotspot) + .sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0)) + .slice(0, 30); + return { + summary: { + rowCount: sourceRows.length, + eventCount: events.length, + longTaskCount: longTasks.length, + longAnimationFrameCount: loafs.length, + eventLoopGapCount: gaps.length, + drainErrorCount: drainErrors.length, + captureCount: captures.length, + profileHotspotCount: profileHotspots.length, + scriptHotspotCount: scriptHotspots.length, + longTaskRedMs: alertThresholds.longTaskRedMs, + longAnimationFrameRedMs: alertThresholds.longAnimationFrameRedMs, + eventLoopGapRedMs: alertThresholds.eventLoopGapRedMs, + maxLongTaskMs: frontendPerformanceMaxNumber(longTasks, (item) => item.durationMs), + maxLongAnimationFrameMs: frontendPerformanceMaxNumber(loafs, (item) => item.durationMs), + maxEventLoopGapMs: frontendPerformanceMaxNumber(gaps, (item) => item.durationMs), + captureArtifacts: performanceCaptureArtifacts(artifacts), + valuesRedacted: true, + }, + longTasks: longTasks.sort(descDuration).slice(0, 40), + longAnimationFrames: loafs.sort(descDuration).slice(0, 40), + eventLoopGaps: gaps.sort(descDuration).slice(0, 40), + scriptHotspots, + profileHotspots, + profileStacks, + captures: captures.slice(-20), + drainErrors: drainErrors.slice(-20), + valuesRedacted: true, + }; +} + +function buildFrontendPerformanceFindings(report) { + const findings = []; + const summary = report?.summary || {}; + const severeLongTasks = (report?.longTasks || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.longTaskRedMs); + if (severeLongTasks.length > 0) findings.push({ + id: "frontend-longtask-red", + severity: "red", + summary: "PerformanceObserver captured long tasks over YAML budget; use LoAF scripts or CPU profile hotspots for function attribution", + count: severeLongTasks.length, + budgetMs: alertThresholds.longTaskRedMs, + maxDurationMs: summary.maxLongTaskMs, + events: severeLongTasks.slice(0, 20), + topScripts: (report?.scriptHotspots || []).slice(0, 8), + topProfileFunctions: (report?.profileHotspots || []).slice(0, 8), + valuesRedacted: true, + }); + const severeLoafs = (report?.longAnimationFrames || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.longAnimationFrameRedMs || Number(item.blockingDurationMs ?? 0) >= alertThresholds.longAnimationFrameRedMs); + if (severeLoafs.length > 0) findings.push({ + id: "frontend-long-animation-frame-red", + severity: "red", + summary: "Long Animation Frame entries exceeded YAML budget and include script attribution when Chromium exposes it", + count: severeLoafs.length, + budgetMs: alertThresholds.longAnimationFrameRedMs, + maxDurationMs: summary.maxLongAnimationFrameMs, + events: severeLoafs.slice(0, 20), + topScripts: (report?.scriptHotspots || []).slice(0, 12), + valuesRedacted: true, + }); + const severeGaps = (report?.eventLoopGaps || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.eventLoopGapRedMs); + if (severeGaps.length > 0) findings.push({ + id: "frontend-event-loop-gap-red", + severity: "red", + summary: "web-probe page-side event loop gap probe observed main-thread stalls over YAML budget", + count: severeGaps.length, + budgetMs: alertThresholds.eventLoopGapRedMs, + maxDurationMs: summary.maxEventLoopGapMs, + events: severeGaps.slice(0, 20), + valuesRedacted: true, + }); + if ((report?.profileHotspots || []).length > 0) findings.push({ + id: "frontend-cpu-profile-hotspots", + severity: severeLongTasks.length > 0 || severeLoafs.length > 0 || severeGaps.length > 0 ? "red" : "info", + summary: "CDP CPU profile capture produced function-level hotspots for offline attribution", + count: report.profileHotspots.length, + captures: (report?.captures || []).slice(-8), + topFunctions: report.profileHotspots.slice(0, 12), + topStacks: (report?.profileStacks || []).slice(0, 8), + valuesRedacted: true, + }); + if ((report?.drainErrors || []).length > 0) findings.push({ + id: "frontend-performance-probe-drain-errors", + severity: "amber", + summary: "page-side performance probe had drain errors; improve probe visibility before relying on absence of long-task events", + count: report.drainErrors.length, + errors: report.drainErrors.slice(0, 12), + valuesRedacted: true, + }); + return findings; +} + +function compactPerformanceEventRow(row, perf) { + return { + ts: row.ts ?? null, + seq: row.seq ?? null, + sampleSeq: row.sampleSeq ?? null, + sampleGroupSeq: row.sampleGroupSeq ?? null, + commandId: row.commandId ?? null, + pageRole: row.pageRole ?? null, + pageId: row.pageId ?? null, + pageEpoch: numberOrNull(row.pageEpoch), + kind: perf.kind ?? null, + name: limitText(perf.name ?? "", 120), + durationMs: numberOrNull(perf.duration), + blockingDurationMs: numberOrNull(perf.blockingDuration), + startTime: numberOrNull(perf.startTime), + thresholdMs: numberOrNull(perf.thresholdMs), + path: limitText(perf.path ?? "", 160), + url: safeReportUrl(perf.url), + scriptCount: Array.isArray(perf.scripts) ? perf.scripts.length : 0, + attributionCount: Array.isArray(perf.attribution) ? perf.attribution.length : 0, + scripts: (Array.isArray(perf.scripts) ? perf.scripts : []).slice(0, 8).map(compactLoafScript), + valuesRedacted: true, + }; +} + +function compactPerformanceDrainError(row) { + return { + ts: row?.ts ?? null, + sampleSeq: row?.sampleSeq ?? null, + pageRole: row?.pageRole ?? null, + pageId: row?.pageId ?? null, + reason: row?.drain?.reason ?? row?.reason ?? null, + message: limitText(row?.drain?.error?.message ?? row?.error?.message ?? "", 200), + valuesRedacted: true, + }; +} + +function compactPerformanceCapture(row) { + const artifact = row.artifact && typeof row.artifact === "object" ? row.artifact : {}; + const summary = row.summary && typeof row.summary === "object" ? row.summary : {}; + return { + ts: row.ts ?? null, + commandId: row.commandId ?? null, + captureId: row.captureId ?? artifact.captureId ?? null, + pageRole: row.pageRole ?? artifact.pageRole ?? null, + pageId: row.pageId ?? artifact.pageId ?? null, + durationMs: numberOrNull(artifact.durationMs), + profileTotalTimeMs: numberOrNull(summary.totalTimeMs), + profileSampleCount: numberOrNull(summary.sampleCount), + path: artifact.path ?? null, + summaryPath: artifact.summaryPath ?? null, + sha256: artifact.sha256 ?? null, + summarySha256: artifact.summarySha256 ?? null, + valuesRedacted: true, + }; +} + +function compactLoafScript(script) { + const value = script && typeof script === "object" ? script : {}; + return { + invoker: limitText(value.invoker ?? "", 160), + invokerType: limitText(value.invokerType ?? "", 80), + sourceURL: safeReportUrl(value.sourceURL), + sourceFunctionName: limitText(value.sourceFunctionName ?? "", 160), + lineNumber: numberOrNull(value.lineNumber), + columnNumber: numberOrNull(value.columnNumber), + durationMs: numberOrNull(value.duration), + forcedStyleAndLayoutDurationMs: numberOrNull(value.forcedStyleAndLayoutDuration), + pauseDurationMs: numberOrNull(value.pauseDuration), + valuesRedacted: true, + }; +} + +function mergeLoafScript(groups, script, event) { + const compact = compactLoafScript(script); + const key = [compact.sourceFunctionName || compact.invoker || "(anonymous)", compact.sourceURL || "", compact.lineNumber ?? "", compact.columnNumber ?? ""].join("@"); + const group = groups.get(key) || { ...compact, key, count: 0, totalDurationMs: 0, maxDurationMs: 0, firstAt: event.ts, lastAt: event.ts, examples: [], valuesRedacted: true }; + const duration = Number(compact.durationMs || 0); + group.count += 1; + group.totalDurationMs += duration; + group.maxDurationMs = Math.max(group.maxDurationMs, duration); + group.lastAt = event.ts; + if (group.examples.length < 8) group.examples.push({ ts: event.ts, sampleSeq: event.sampleSeq, durationMs: event.durationMs, scriptDurationMs: compact.durationMs, pageRole: event.pageRole, valuesRedacted: true }); + groups.set(key, group); +} + +function finalizeScriptHotspot(group) { + return { + ...group, + totalDurationMs: roundMs(group.totalDurationMs), + maxDurationMs: roundMs(group.maxDurationMs), + valuesRedacted: true, + }; +} + +function mergeProfileFunction(groups, fn, capture) { + const value = fn && typeof fn === "object" ? fn : {}; + const key = [value.functionName || "(anonymous)", value.url || value.scriptId || "", value.lineNumber ?? "", value.columnNumber ?? ""].join("@"); + const group = groups.get(key) || { functionName: value.functionName || "(anonymous)", url: safeReportUrl(value.url), scriptId: value.scriptId ?? null, lineNumber: numberOrNull(value.lineNumber), columnNumber: numberOrNull(value.columnNumber), key, captureCount: 0, sampleCount: 0, selfTimeMs: 0, totalTimeMs: 0, maxSelfTimeMs: 0, captures: [], valuesRedacted: true }; + const selfTime = Number(value.selfTimeMs || 0); + const totalTime = Number(value.totalTimeMs || 0); + group.captureCount += 1; + group.sampleCount += Number(value.sampleCount || 0); + group.selfTimeMs += selfTime; + group.totalTimeMs += totalTime; + group.maxSelfTimeMs = Math.max(group.maxSelfTimeMs, selfTime); + if (group.captures.length < 8) group.captures.push({ captureId: capture.captureId, commandId: capture.commandId, selfTimeMs: roundMs(selfTime), totalTimeMs: roundMs(totalTime), valuesRedacted: true }); + groups.set(key, group); +} + +function finalizeProfileFunctionHotspot(group) { + return { + ...group, + selfTimeMs: roundMs(group.selfTimeMs), + totalTimeMs: roundMs(group.totalTimeMs), + maxSelfTimeMs: roundMs(group.maxSelfTimeMs), + valuesRedacted: true, + }; +} + +function mergeProfileStack(groups, stack, capture) { + const value = stack && typeof stack === "object" ? stack : {}; + const key = String(value.key || "").slice(0, 800); + if (!key) return; + const group = groups.get(key) || { key, sampleCount: 0, selfTimeMs: 0, maxSelfTimeMs: 0, leaf: value.leaf ?? null, frames: Array.isArray(value.frames) ? value.frames.slice(-10) : [], captures: [], valuesRedacted: true }; + const selfTime = Number(value.selfTimeMs || 0); + group.sampleCount += Number(value.sampleCount || 0); + group.selfTimeMs += selfTime; + group.maxSelfTimeMs = Math.max(group.maxSelfTimeMs, selfTime); + if (group.captures.length < 6) group.captures.push({ captureId: capture.captureId, commandId: capture.commandId, selfTimeMs: roundMs(selfTime), valuesRedacted: true }); + groups.set(key, group); +} + +function finalizeProfileStackHotspot(group) { + return { + ...group, + selfTimeMs: roundMs(group.selfTimeMs), + maxSelfTimeMs: roundMs(group.maxSelfTimeMs), + valuesRedacted: true, + }; +} + +function performanceCaptureArtifacts(artifacts) { + return (Array.isArray(artifacts) ? artifacts : []) + .filter((item) => item && item.kind === "performance-cpu-profile") + .slice(-20) + .map((item) => ({ ts: item.ts ?? null, captureId: item.captureId ?? null, commandId: item.commandId ?? null, path: item.path ?? null, summaryPath: item.summaryPath ?? null, sha256: item.sha256 ?? null, summarySha256: item.summarySha256 ?? null, valuesRedacted: true })); +} + +function frontendPerformanceMaxNumber(items, getter) { + const selected = maxByNumber(items, getter); + return selected ? numberOrNull(getter(selected)) : null; +} + +function descDuration(left, right) { + return Number(right.durationMs ?? 0) - Number(left.durationMs ?? 0) || String(right.ts || "").localeCompare(String(left.ts || "")); +} + +function roundMs(value) { + return Number((Number(value || 0)).toFixed(2)); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts new file mode 100644 index 00000000..878ce9fb --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts @@ -0,0 +1,517 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer Project Management/MDTODO report and finding source fragment. + +export function nodeWebObserveAnalyzerProjectSource(): string { + return String.raw`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" || item.type === "launchWorkbenchFromMdtodo"); + 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"); + const selectedFileLabelBadSamples = projectSamples.filter((sample) => sample.projectManagement?.selectedFileLabel && sample.projectManagement?.selectedFileLabelLooksDirect === false); + const suspiciousFileLabelSamples = projectSamples.filter((sample) => Number(sample.projectManagement?.fileOptionSuspiciousLabelCount ?? 0) > 0); + const bodyVisibleSamples = selectedTaskSamples.filter((sample) => sample.projectManagement?.taskBodyVisible === true && Number(sample.projectManagement?.taskBody?.textBytes ?? 0) > 0); + const reportLinkSamples = projectSamples.filter((sample) => Number(sample.projectManagement?.reportLinkCount ?? 0) > 0); + const reportPreviewSamples = projectSamples.filter((sample) => sample.projectManagement?.reportPreviewVisible === true && Number(sample.projectManagement?.reportPreview?.textBytes ?? 0) > 0); + const reportFullscreenSamples = projectSamples.filter((sample) => sample.projectManagement?.reportFullscreenVisible === true); + const hwpodBlockerSamples = projectManagementHwpodBlockerRows(projectSamples); + const projectionReportSamples = projectManagementProjectionReportRows(projectSamples); + const hwpodApiFailures = projectManagementHwpodApiFailureRows(projectApiFailures); + const paneGapRows = projectManagementPaneGapRows(projectSamples); + const severePaneGapSamples = paneGapRows.actionable; + const ignoredPaneGapSamples = paneGapRows.ignored; + const previewCommands = commandRows.filter((item) => item.type === "openMdtodoReportPreview" || item.type === "toggleMdtodoReportFullscreen"); + const launchNonEmpty = launchSuccess.filter((item) => item.chatObserved === true && (Number(item.workbenchMessageCount ?? 0) > 0 || Number(item.workbenchTraceRowCount ?? 0) > 0)); + const launchEmpty = launchSuccess.filter((item) => item.chatObserved !== true || (Number(item.workbenchMessageCount ?? 0) === 0 && Number(item.workbenchTraceRowCount ?? 0) === 0)); + const minMdtodoTaskCount = minNumber(mdtodoSamples.map((sample) => sample.projectManagement?.taskCount)); + const maxMdtodoTaskCount = maxNumber(mdtodoSamples.map((sample) => sample.projectManagement?.taskCount)); + 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, + latestSelectedFileLabelPreview: latestProject?.selectedFileLabel?.textPreview ?? null, + latestSelectedFileLabelLooksDirect: latestProject?.selectedFileLabelLooksDirect ?? null, + selectedFileLabelBadSampleCount: selectedFileLabelBadSamples.length, + fileOptionSuspiciousLabelSampleCount: suspiciousFileLabelSamples.length, + maxFileOptionSuspiciousLabelCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.fileOptionSuspiciousLabelCount)), + latestSelectedTaskStatus: latestProject?.selectedTaskStatus ?? null, + latestTaskStatusCounts, + selectedTaskBodyVisibleSamples: bodyVisibleSamples.length, + reportLinkVisibleSamples: reportLinkSamples.length, + maxReportLinkCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.reportLinkCount)), + reportPreviewVisibleSamples: reportPreviewSamples.length, + reportFullscreenVisibleSamples: reportFullscreenSamples.length, + hwpodBlockerSampleCount: hwpodBlockerSamples.length, + projectionReportSampleCount: projectionReportSamples.length, + hwpodApiFailureCount: hwpodApiFailures.length, + severePaneGapSampleCount: severePaneGapSamples.length, + ignoredPaneGapSampleCount: ignoredPaneGapSamples.length, + maxPaneBottomGapPx: maxNumber(severePaneGapSamples.map((item) => item.maxBottomGapPx)), + maxPaneBottomGapRatio: maxNumber(severePaneGapSamples.map((item) => item.maxBottomGapRatio)), + 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, + launchNonEmptyCount: launchNonEmpty.length, + launchEmptyCount: launchEmpty.length, + launchWithOtelTraceHeaderCount: launchSuccess.filter((item) => item.otelTraceId).length, + reportPreviewCommandCount: previewCommands.length, + mdtodoTaskCountMin: minMdtodoTaskCount, + mdtodoTaskCountMax: maxMdtodoTaskCount, + 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, + selectedFileLabelPreview: sample.projectManagement?.selectedFileLabel?.textPreview ?? null, + selectedFileLabelLooksDirect: sample.projectManagement?.selectedFileLabelLooksDirect ?? null, + fileOptionSuspiciousLabelCount: sample.projectManagement?.fileOptionSuspiciousLabelCount ?? 0, + selectedTaskStatus: sample.projectManagement?.selectedTaskStatus ?? null, + taskBodyVisible: sample.projectManagement?.taskBodyVisible === true, + taskBodyBytes: sample.projectManagement?.taskBody?.textBytes ?? 0, + reportLinkCount: sample.projectManagement?.reportLinkCount ?? 0, + reportPreviewVisible: sample.projectManagement?.reportPreviewVisible === true, + reportPreviewBytes: sample.projectManagement?.reportPreview?.textBytes ?? 0, + reportFullscreenVisible: sample.projectManagement?.reportFullscreenVisible === true, + paneGaps: Array.isArray(sample.projectManagement?.paneGaps) ? sample.projectManagement.paneGaps.slice(0, 4) : [], + 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), + hwpodBlockerSamples: hwpodBlockerSamples.slice(0, 40), + projectionReportSamples: projectionReportSamples.slice(0, 40), + hwpodApiFailures: hwpodApiFailures.slice(0, 40), + severePaneGapSamples: severePaneGapSamples.slice(0, 40), + ignoredPaneGapSamples: ignoredPaneGapSamples.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, + chatObserved: item?.chatObserved ?? null, + chatStatus: item?.chatStatus ?? null, + chatTraceId: item?.chatTraceId ?? null, + workbenchMessageCount: item?.workbenchMessageCount ?? null, + workbenchTraceRowCount: item?.workbenchTraceRowCount ?? 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 + }); + const compactSample = (item) => ({ + seq: item?.seq ?? null, + ts: item?.ts ?? null, + pageRole: item?.pageRole ?? null, + path: item?.path ?? null, + selectedTaskRefHash: item?.selectedTaskRefHash ?? null, + selectedFileLabelPreview: item?.selectedFileLabelPreview ?? null, + pageKind: item?.pageKind ?? null, + reason: item?.reason ?? null, + severePaneCount: item?.severePaneCount ?? null, + maxBottomGapPx: item?.maxBottomGapPx ?? null, + maxBottomGapRatio: item?.maxBottomGapRatio ?? null, + paneGaps: Array.isArray(item?.paneGaps) ? item.paneGaps.slice(0, 4) : undefined, + 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) : [], + hwpodBlockerSamples: Array.isArray(report.hwpodBlockerSamples) ? report.hwpodBlockerSamples.slice(0, 8).map(compactSample) : [], + projectionReportSamples: Array.isArray(report.projectionReportSamples) ? report.projectionReportSamples.slice(0, 8).map(compactSample) : [], + hwpodApiFailures: Array.isArray(report.hwpodApiFailures) ? report.hwpodApiFailures.slice(0, 8).map(compactApiGroup) : [], + severePaneGapSamples: Array.isArray(report.severePaneGapSamples) ? report.severePaneGapSamples.slice(0, 8).map(compactSample) : [], + ignoredPaneGapSamples: Array.isArray(report.ignoredPaneGapSamples) ? report.ignoredPaneGapSamples.slice(0, 8).map(compactSample) : [], + 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 || []); + const mdtodoCommandTypes = new Set(["gotoProjectMdtodo", "openMdtodoSourceConfig", "configureMdtodoHwpodSource", "probeMdtodoSource", "reindexMdtodoSource", "expandMdtodoTask", "openMdtodoReportPreview", "toggleMdtodoReportFullscreen", "editMdtodoTaskInline", "editMdtodoTaskTitle", "editMdtodoTaskBody", "toggleMdtodoTaskStatus", "addMdtodoRootTask", "addMdtodoSubTask", "continueMdtodoTask", "deleteMdtodoTask", "launchWorkbenchFromMdtodo"]); + return (control || []) + .filter((item) => allowed.has(item?.type) || mdtodoCommandTypes.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, + chatObserved: detail.chatObserved ?? error.details?.chatObserved ?? null, + chatStatus: detail.chatStatus ?? error.details?.chatStatus ?? null, + chatSessionId: detail.chatSessionId ?? error.details?.chatSessionId ?? null, + chatTraceId: detail.chatTraceId ?? error.details?.chatTraceId ?? null, + chatOtelTraceId: detail.chatOtelTraceId ?? error.details?.chatOtelTraceId ?? null, + workbenchMessageCount: detail.workbenchSnapshot?.messageCount ?? error.details?.workbenchSnapshot?.messageCount ?? null, + workbenchTraceRowCount: detail.workbenchSnapshot?.traceRowCount ?? error.details?.workbenchSnapshot?.traceRowCount ?? null, + workbenchComposerReady: detail.workbenchSnapshot?.composerReady ?? error.details?.workbenchSnapshot?.composerReady ?? 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 projectManagementDigestText(value) { + if (!value || typeof value !== "object") return ""; + return String(value.textPreview ?? value.preview ?? value.text ?? "").trim(); +} + +function projectManagementSampleRef(sample) { + return { + seq: sample?.seq ?? null, + ts: sample?.ts ?? null, + pageRole: sample?.pageRole ?? null, + path: sample?.path ?? null, + pageKind: sample?.projectManagement?.pageKind ?? null, + selectedTaskRefHash: sample?.projectManagement?.selectedTaskRef?.hash ?? null, + selectedFileLabelPreview: sample?.projectManagement?.selectedFileLabel?.textPreview ?? null, + valuesRedacted: true + }; +} + +function projectManagementHwpodBlockerRows(projectSamples) { + const pattern = /(?:no outbound WebSocket hwpod-node|HWLAB_HWPOD_NODE_OPS_URL|hwpod-node-ops contract)/iu; + const rows = []; + for (const sample of projectSamples || []) { + const blockers = Array.isArray(sample?.projectManagement?.blockers) ? sample.projectManagement.blockers : []; + const matched = blockers + .filter((item) => pattern.test(projectManagementDigestText(item))) + .map((item) => ({ + index: item?.index ?? null, + testId: item?.testId ?? null, + role: item?.role ?? null, + textHash: item?.textHash ?? null, + textPreview: item?.textPreview ?? null, + valuesRedacted: true + })); + if (matched.length > 0) rows.push({ ...projectManagementSampleRef(sample), blockers: matched.slice(0, 4), valuesRedacted: true }); + } + return rows; +} + +function projectManagementProjectionReportRows(projectSamples) { + const pattern = /(?:报告索引待刷新|projection-only|任务投影确认存在报告链接)/iu; + return (projectSamples || []) + .filter((sample) => pattern.test(projectManagementDigestText(sample?.projectManagement?.reportPreview))) + .map((sample) => ({ + ...projectManagementSampleRef(sample), + reportPreviewHash: sample?.projectManagement?.reportPreview?.textHash ?? null, + reportPreviewPreview: sample?.projectManagement?.reportPreview?.textPreview ?? null, + reportPreviewBytes: sample?.projectManagement?.reportPreview?.textBytes ?? null, + valuesRedacted: true + })); +} + +function projectManagementHwpodApiFailureRows(projectApiFailures) { + const pattern = /^\/v1\/project-management\/mdtodo\/(?:task-detail|report-preview)\b/u; + return (projectApiFailures || []) + .filter((item) => pattern.test(String(item?.path || "")) && Number(item?.status ?? 0) >= 500) + .map((item) => ({ + ts: item?.ts ?? null, + method: item?.method ?? null, + path: item?.path ?? null, + status: item?.status ?? null, + type: item?.type ?? null, + failureKind: item?.failureKind ?? null, + valuesRedacted: true + })); +} + +function projectManagementPaneGapRows(projectSamples) { + const actionable = []; + const ignored = []; + for (const sample of projectSamples || []) { + const paneGaps = Array.isArray(sample?.projectManagement?.paneGaps) ? sample.projectManagement.paneGaps : []; + const severeGaps = paneGaps + .filter((item) => item?.visible === true) + .filter((item) => { + const bottomGapPx = Number(item?.bottomGapPx ?? 0); + const bottomGapRatio = Number(item?.bottomGapRatio ?? 0); + const heightPx = Number(item?.heightPx ?? 0); + return heightPx >= 120 && bottomGapPx >= 180 && bottomGapRatio >= 0.28; + }) + .map((item) => ({ + name: item?.name ?? null, + widthPx: item?.widthPx ?? null, + heightPx: item?.heightPx ?? null, + bottomGapPx: item?.bottomGapPx ?? null, + bottomGapRatio: item?.bottomGapRatio ?? null, + contentNodeCount: item?.contentNodeCount ?? null, + valuesRedacted: true + })); + if (severeGaps.length === 0) continue; + const maxGapPx = maxNumber(severeGaps.map((item) => item.bottomGapPx)); + const maxGapRatio = maxNumber(severeGaps.map((item) => item.bottomGapRatio)); + const multiPane = severeGaps.length >= 2; + const singleExtreme = maxGapPx >= 240 && maxGapRatio >= 0.45; + if (!multiPane && !singleExtreme) continue; + const selectedTaskRefHash = sample?.projectManagement?.selectedTaskRef?.hash ?? null; + const isMdtodo = sample?.projectManagement?.pageKind === "project-management-mdtodo"; + const isInitialEmptyDetail = isMdtodo && !selectedTaskRefHash; + const row = { + ...projectManagementSampleRef(sample), + severePaneCount: severeGaps.length, + maxBottomGapPx: maxGapPx, + maxBottomGapRatio: maxGapRatio, + paneGaps: severeGaps.slice(0, 4), + valuesRedacted: true + }; + if (isInitialEmptyDetail) { + ignored.push({ + ...row, + reason: "mdtodo-initial-empty-detail-no-selected-task", + valuesRedacted: true + }); + continue; + } + actionable.push(row); + } + return { actionable, ignored, valuesRedacted: true }; +} + +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.selectedFileLabelBadSampleCount ?? 0) > 0 || summary.latestSelectedFileLabelLooksDirect === false) { + findings.push({ id: "mdtodo-file-label-not-filename", severity: "red", summary: "MDTODO file dropdown selected label is not a direct markdown filename", count: summary.selectedFileLabelBadSampleCount, latestSelectedFileLabelPreview: summary.latestSelectedFileLabelPreview, samples: report.samples.filter((item) => item.selectedFileLabelLooksDirect === false).slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.maxFileOptionSuspiciousLabelCount ?? 0) > 0) { + findings.push({ id: "mdtodo-nondirect-files-visible", severity: "red", summary: "MDTODO file dropdown includes non-direct or report-like markdown labels; docs/MDTODO discovery must be direct files only", count: summary.maxFileOptionSuspiciousLabelCount, samples: report.samples.filter((item) => Number(item.fileOptionSuspiciousLabelCount ?? 0) > 0).slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.selectedTaskSampleCount ?? 0) > 0 && Number(summary.selectedTaskBodyVisibleSamples ?? 0) === 0) { + findings.push({ id: "mdtodo-task-body-not-visible", severity: "red", summary: "selected MDTODO task was sampled but no rendered task body was visible", count: summary.selectedTaskSampleCount, samples: report.samples.filter((item) => item.selectedTaskRefHash).slice(-12), valuesRedacted: true }); + } + if (Number(summary.hwpodBlockerSampleCount ?? 0) > 0) { + findings.push({ id: "mdtodo-hwpod-node-disconnected", severity: "red", summary: "MDTODO surfaced the hwpod-node disconnected / HWLAB_HWPOD_NODE_OPS_URL fallback blocker", count: summary.hwpodBlockerSampleCount, samples: report.hwpodBlockerSamples.slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.projectionReportSampleCount ?? 0) > 0) { + findings.push({ id: "mdtodo-report-projection-only", severity: "red", summary: "MDTODO report preview is projection-only instead of opening the full markdown report from the HWPOD source", count: summary.projectionReportSampleCount, samples: report.projectionReportSamples.slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.maxReportLinkCount ?? 0) > 0 && Number(summary.reportPreviewVisibleSamples ?? 0) === 0) { + const severity = Number(summary.reportPreviewCommandCount ?? 0) > 0 ? "red" : "amber"; + findings.push({ id: "mdtodo-report-preview-missing", severity, summary: "MDTODO report links were visible but no markdown report preview was sampled", count: summary.maxReportLinkCount, previewCommandCount: summary.reportPreviewCommandCount, samples: report.samples.filter((item) => Number(item.reportLinkCount ?? 0) > 0).slice(-12), valuesRedacted: true }); + } + if (Number(summary.reportPreviewCommandCount ?? 0) > 0 && Number(summary.reportFullscreenVisibleSamples ?? 0) === 0 && report.commands.some((item) => item.type === "toggleMdtodoReportFullscreen" && item.phase === "completed")) { + findings.push({ id: "mdtodo-report-fullscreen-missing", severity: "red", summary: "toggleMdtodoReportFullscreen command completed but fullscreen report dialog was never sampled", count: summary.reportPreviewCommandCount, commands: report.commands.filter((item) => item.type === "toggleMdtodoReportFullscreen").slice(-8), valuesRedacted: true }); + } + if (Number(summary.launchEmptyCount ?? 0) > 0) { + findings.push({ id: "mdtodo-workbench-launch-empty", severity: "red", summary: "MDTODO Workbench launch created a session without observing agent chat or visible message/trace content", count: summary.launchEmptyCount, commands: report.launchCommands.filter((item) => item.chatObserved !== true || (Number(item.workbenchMessageCount ?? 0) === 0 && Number(item.workbenchTraceRowCount ?? 0) === 0)).slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.mdtodoTaskCountMin ?? 0) > 0 && Number(summary.mdtodoTaskCountMax ?? 0) > 0 && (Number(summary.mdtodoTaskCountMax) - Number(summary.mdtodoTaskCountMin) >= 10 || Number(summary.mdtodoTaskCountMax) / Math.max(1, Number(summary.mdtodoTaskCountMin)) >= 2)) { + findings.push({ id: "mdtodo-task-count-diverged", severity: "amber", summary: "MDTODO task count varied sharply during observation; compare control commands and observer samples for projection divergence", minTaskCount: summary.mdtodoTaskCountMin, maxTaskCount: summary.mdtodoTaskCountMax, samples: report.samples.slice(-20), valuesRedacted: true }); + } + if (Number(summary.severePaneGapSampleCount ?? 0) > 0) { + findings.push({ id: "mdtodo-pane-bottom-gap", severity: "red", summary: "MDTODO task tree, main detail, or report sidebar left large unused bottom gaps in actionable selected-task samples", count: summary.severePaneGapSampleCount, ignoredInitialEmptyDetailCount: summary.ignoredPaneGapSampleCount, maxBottomGapPx: summary.maxPaneBottomGapPx, maxBottomGapRatio: summary.maxPaneBottomGapRatio, samples: report.severePaneGapSamples.slice(0, 12), valuesRedacted: true }); + } + if (Number(summary.hwpodApiFailureCount ?? 0) > 0) { + findings.push({ id: "project-management-hwpod-api-failed", severity: "red", summary: "HWPOD-backed MDTODO task detail or report preview API returned a server error during natural page use", count: summary.hwpodApiFailureCount, failures: report.hwpodApiFailures.slice(0, 12), 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: "MDTODO Workbench launch 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 minNumber(values) { + const numeric = (values || []).map((value) => Number(value)).filter(Number.isFinite); + return numeric.length > 0 ? Math.min(...numeric) : 0; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts new file mode 100644 index 00000000..840b1486 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts @@ -0,0 +1,915 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer request-rate, prompt network, runtime console/network alert, and diagnostic grouping source fragment. + +export function nodeWebObserveAnalyzerRequestRuntimeSource(): string { + return String.raw`function buildRequestRateReport(network) { + const bucketMs = Math.max(1000, Number(alertThresholds.requestRateBucketMs || 0)); + const naturalApiRequests = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && item?.type === "request") + .map((item) => requestRateEvent(item)) + .filter((item) => item !== null) + .sort((a, b) => a.tsMs - b.tsMs); + if (naturalApiRequests.length === 0) { + return { + summary: { + bucketMs, + bucketSeconds: Number((bucketMs / 1000).toFixed(3)), + requestCount: 0, + bucketCount: 0, + pageCount: 0, + apiPathCount: 0, + totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, + pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, + apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + totalPeakPerMinute: 0, + pagePeakPerMinute: 0, + apiPathPeakPerMinute: 0, + overThresholdPeakCount: 0, + valuesRedacted: true, + }, + buckets: [], + pageCurves: [], + apiPathCurves: [], + peaks: [], + valuesRedacted: true, + }; + } + const firstBucketMs = Math.floor(naturalApiRequests[0].tsMs / bucketMs) * bucketMs; + const lastBucketMs = Math.floor(naturalApiRequests[naturalApiRequests.length - 1].tsMs / bucketMs) * bucketMs; + const makeBucket = (bucketStartMs, extra = {}) => ({ + bucketStartMs, + bucketEndMs: bucketStartMs + bucketMs, + startAt: new Date(bucketStartMs).toISOString(), + endAt: new Date(bucketStartMs + bucketMs).toISOString(), + count: 0, + requestPerMinute: 0, + ...extra, + valuesRedacted: true, + }); + const totalBuckets = new Map(); + const pageBuckets = new Map(); + const apiPathBuckets = new Map(); + const pageTotals = new Map(); + const apiPathTotals = new Map(); + const allBucketStarts = []; + for (let bucketStartMs = firstBucketMs; bucketStartMs <= lastBucketMs; bucketStartMs += bucketMs) { + totalBuckets.set(bucketStartMs, makeBucket(bucketStartMs)); + allBucketStarts.push(bucketStartMs); + } + for (const item of naturalApiRequests) { + const bucketStartMs = Math.floor(item.tsMs / bucketMs) * bucketMs; + const totalBucket = totalBuckets.get(bucketStartMs) || makeBucket(bucketStartMs); + totalBucket.count += 1; + totalBuckets.set(bucketStartMs, totalBucket); + + const pageKey = item.pageKey; + const pageMapKey = pageKey + "|" + bucketStartMs; + const pageBucket = pageBuckets.get(pageMapKey) || makeBucket(bucketStartMs, { + pageKey, + pageRole: item.pageRole, + pageId: item.pageId, + pageEpoch: item.pageEpoch, + path: item.framePath, + }); + pageBucket.count += 1; + pageBuckets.set(pageMapKey, pageBucket); + const pageTotal = pageTotals.get(pageKey) || { pageKey, pageRole: item.pageRole, pageId: item.pageId, pageEpoch: item.pageEpoch, path: item.framePath, count: 0 }; + pageTotal.count += 1; + pageTotals.set(pageKey, pageTotal); + + const apiKey = item.method + " " + item.apiPath; + const apiMapKey = apiKey + "|" + bucketStartMs; + const apiBucket = apiPathBuckets.get(apiMapKey) || makeBucket(bucketStartMs, { + apiKey, + method: item.method, + path: item.apiPath, + pageKey, + pageRole: item.pageRole, + pageId: item.pageId, + pageEpoch: item.pageEpoch, + }); + apiBucket.count += 1; + apiPathBuckets.set(apiMapKey, apiBucket); + const apiTotal = apiPathTotals.get(apiKey) || { apiKey, method: item.method, path: item.apiPath, count: 0 }; + apiTotal.count += 1; + apiPathTotals.set(apiKey, apiTotal); + } + const finalizeBucket = (bucket) => ({ + ...bucket, + requestPerMinute: Number((Number(bucket.count || 0) * 60000 / bucketMs).toFixed(2)), + }); + const buckets = Array.from(totalBuckets.values()).map(finalizeBucket); + const pageBucketRows = Array.from(pageBuckets.values()).map(finalizeBucket); + const apiPathBucketRows = Array.from(apiPathBuckets.values()).map(finalizeBucket); + const peakForRows = (rows, thresholdPerMinute, scope, extra = {}) => rows + .filter((row) => Number(row.requestPerMinute ?? 0) >= Number(thresholdPerMinute || Infinity)) + .map((row) => ({ + scope, + thresholdPerMinute, + overThreshold: true, + bucketMs, + bucketStartMs: row.bucketStartMs, + bucketEndMs: row.bucketEndMs, + startAt: row.startAt, + endAt: row.endAt, + count: row.count, + requestPerMinute: row.requestPerMinute, + ...extra(row), + valuesRedacted: true, + })); + const totalPeaks = peakForRows(buckets, alertThresholds.requestRateTotalRedPerMinute, "total", () => ({})); + const pagePeaks = peakForRows(pageBucketRows, alertThresholds.requestRatePageRedPerMinute, "page", (row) => ({ + pageKey: row.pageKey, + pageRole: row.pageRole, + pageId: row.pageId, + pageEpoch: row.pageEpoch, + path: row.path, + })); + const apiPathPeaks = peakForRows(apiPathBucketRows, alertThresholds.requestRateApiPathRedPerMinute, "apiPath", (row) => ({ + apiKey: row.apiKey, + method: row.method, + path: row.path, + pageKey: row.pageKey, + pageRole: row.pageRole, + pageId: row.pageId, + pageEpoch: row.pageEpoch, + })); + const pageCurves = Array.from(pageTotals.values()) + .map((page) => { + const rows = pageBucketRows + .filter((row) => row.pageKey === page.pageKey) + .sort((a, b) => a.bucketStartMs - b.bucketStartMs); + const peak = maxByNumber(rows, (row) => row.requestPerMinute); + return { + ...page, + bucketCount: rows.length, + peakRequestPerMinute: peak?.requestPerMinute ?? 0, + peakBucket: peak ?? null, + buckets: rows.slice(-60), + valuesRedacted: true, + }; + }) + .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); + const apiPathCurves = Array.from(apiPathTotals.values()) + .map((api) => { + const rows = apiPathBucketRows + .filter((row) => row.apiKey === api.apiKey) + .sort((a, b) => a.bucketStartMs - b.bucketStartMs); + const peak = maxByNumber(rows, (row) => row.requestPerMinute); + return { + ...api, + bucketCount: rows.length, + peakRequestPerMinute: peak?.requestPerMinute ?? 0, + peakBucket: peak ?? null, + buckets: rows.slice(-60), + valuesRedacted: true, + }; + }) + .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); + const totalPeak = maxByNumber(buckets, (row) => row.requestPerMinute); + const pagePeak = pageCurves[0] ?? null; + const apiPathPeak = apiPathCurves[0] ?? null; + const peaks = [...totalPeaks, ...pagePeaks, ...apiPathPeaks] + .sort((a, b) => Number(b.requestPerMinute ?? 0) - Number(a.requestPerMinute ?? 0) || String(a.scope).localeCompare(String(b.scope))); + return { + summary: { + bucketMs, + bucketSeconds: Number((bucketMs / 1000).toFixed(3)), + requestCount: naturalApiRequests.length, + bucketCount: buckets.length, + pageCount: pageCurves.length, + apiPathCount: apiPathCurves.length, + firstAt: new Date(firstBucketMs).toISOString(), + lastAt: new Date(lastBucketMs + bucketMs).toISOString(), + totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, + pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, + apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + totalPeakPerMinute: totalPeak?.requestPerMinute ?? 0, + totalPeakCount: totalPeak?.count ?? 0, + totalPeakAt: totalPeak?.startAt ?? null, + pagePeakPerMinute: pagePeak?.peakRequestPerMinute ?? 0, + pagePeakKey: pagePeak?.pageKey ?? null, + pagePeakPath: pagePeak?.path ?? null, + apiPathPeakPerMinute: apiPathPeak?.peakRequestPerMinute ?? 0, + apiPathPeakKey: apiPathPeak?.apiKey ?? null, + overThresholdPeakCount: peaks.length, + valuesRedacted: true, + }, + buckets: buckets.slice(-120), + pageCurves: pageCurves.slice(0, 20), + apiPathCurves: apiPathCurves.slice(0, 40), + peaks: peaks.slice(0, 80), + valuesRedacted: true, + }; +} + +function requestRateEvent(item) { + const tsMs = Date.parse(String(item?.ts || "")); + const apiPath = normalizeApiRatePath(urlPath(item?.url)); + if (!Number.isFinite(tsMs) || !isApiLikePath(apiPath)) return null; + const framePath = normalizeRoutePath(urlPath(item?.frameUrl || item?.url)); + const pageRole = String(item?.pageRole || "unknown"); + const pageId = String(item?.pageId || "unknown"); + const pageEpoch = Number.isFinite(Number(item?.pageEpoch)) ? Number(item.pageEpoch) : null; + return { + ts: item.ts, + tsMs, + method: String(item?.method || "GET").toUpperCase(), + apiPath, + framePath, + pageRole, + pageId, + pageEpoch, + pageKey: [pageRole, pageId, pageEpoch ?? "-", framePath || "-"].join("|"), + valuesRedacted: true, + }; +} + +function normalizeApiRatePath(value) { + return normalizeRoutePath(value) + .replace(/\/sessions\/[^/?#]+/gu, "/sessions/:id") + .replace(/\/turns\/[^/?#]+/gu, "/turns/:id") + .replace(/\/traces\/[^/?#]+/gu, "/traces/:id") + .replace(/\/messages\/[^/?#]+/gu, "/messages/:id") + .replace(/\/files\/[^/?#]+/gu, "/files/:id") + .replace(/\/tasks\/[^/?#]+/gu, "/tasks/:id") + .replace(/\/runs\/[^/?#]+/gu, "/runs/:id") + .replace(/\/[0-9a-f]{8,}(?=\/|$)/giu, "/:id") + .replace(/\/[A-Za-z0-9_-]{24,}(?=\/|$)/gu, "/:id"); +} + +function normalizeRoutePath(value) { + const raw = String(value || ""); + if (!raw || raw === "-") return "-"; + return raw.replace(/\/+/gu, "/").replace(/\/$/u, "") || "/"; +} + +function compactRequestRateForOutput(report) { + if (!report || typeof report !== "object") return null; + return { + summary: report.summary ?? null, + buckets: Array.isArray(report.buckets) ? report.buckets.slice(-12) : [], + pageCurves: Array.isArray(report.pageCurves) ? report.pageCurves.slice(0, 8).map((item) => ({ + pageKey: item.pageKey ?? null, + pageRole: item.pageRole ?? null, + pageId: item.pageId ?? null, + pageEpoch: item.pageEpoch ?? null, + path: item.path ?? null, + count: item.count ?? null, + peakRequestPerMinute: item.peakRequestPerMinute ?? null, + peakBucket: item.peakBucket ?? null, + buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], + valuesRedacted: true, + })) : [], + apiPathCurves: Array.isArray(report.apiPathCurves) ? report.apiPathCurves.slice(0, 12).map((item) => ({ + apiKey: item.apiKey ?? null, + method: item.method ?? null, + path: item.path ?? null, + count: item.count ?? null, + peakRequestPerMinute: item.peakRequestPerMinute ?? null, + peakBucket: item.peakBucket ?? null, + buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], + valuesRedacted: true, + })) : [], + peaks: Array.isArray(report.peaks) ? report.peaks.slice(0, 12) : [], + valuesRedacted: true, + }; +} + +function buildPromptNetworkReport(control, network) { + const promptsById = new Map(); + for (const item of control) { + if (item?.type !== "sendPrompt" || !item.commandId) continue; + const existing = promptsById.get(item.commandId) || { + commandId: item.commandId, + promptIndex: promptsById.size + 1, + promptTextHash: item.input?.textHash ?? null, + promptTextBytes: item.input?.textBytes ?? null, + startedAt: null, + completedAt: null, + failedAt: null, + phase: null + }; + if (!existing.promptTextHash && item.input?.textHash) existing.promptTextHash = item.input.textHash; + if (!existing.promptTextBytes && item.input?.textBytes) existing.promptTextBytes = item.input.textBytes; + if (item.phase === "started") existing.startedAt = item.ts ?? existing.startedAt; + if (item.phase === "completed") existing.completedAt = item.ts ?? existing.completedAt; + if (item.phase === "failed") existing.failedAt = item.ts ?? existing.failedAt; + existing.phase = item.phase ?? existing.phase; + promptsById.set(item.commandId, existing); + } + const prompts = Array.from(promptsById.values()).sort((a, b) => Date.parse(a.startedAt || a.completedAt || a.failedAt || "") - Date.parse(b.startedAt || b.completedAt || b.failedAt || "")); + prompts.forEach((item, index) => { item.promptIndex = index + 1; }); + const chatEvents = network + .filter((item) => String(item?.method || "").toUpperCase() === "POST" && promptSubmitModeForUrl(item?.url) !== null) + .map((item) => { + const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null; + const urlPathValue = urlPath(item.url); + return { + ts: item.ts ?? null, + tsMs: Date.parse(item.ts), + type: item.type ?? null, + status: Number.isFinite(Number(item.status)) ? Number(item.status) : null, + commandId: item.commandId ?? null, + urlPath: urlPathValue, + submitMode: promptSubmitModeForUrl(item.url), + failureKind: failureText ? String(failureText) : null, + errorTextHash: failureText ? sha256(failureText) : null + }; + }) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const rounds = prompts.map((prompt) => { + const startMs = Date.parse(prompt.startedAt || prompt.completedAt || prompt.failedAt || ""); + const endAnchorMs = Date.parse(prompt.completedAt || prompt.failedAt || prompt.startedAt || ""); + const fromMs = Number.isFinite(startMs) ? startMs - 3000 : Number.NEGATIVE_INFINITY; + const toMs = Number.isFinite(endAnchorMs) ? endAnchorMs + 30000 : Number.POSITIVE_INFINITY; + const events = chatEvents.filter((event) => { + if (event.commandId && prompt.commandId && event.commandId === prompt.commandId) return true; + return event.tsMs >= fromMs && event.tsMs <= toMs; + }); + const responses = events.filter((event) => event.type === "response"); + const failures = events.filter((event) => event.type === "requestfailed"); + const responseStatuses = responses.map((event) => event.status).filter((status) => status !== null); + const submitModes = Array.from(new Set(events.map((event) => event.submitMode).filter(Boolean))).sort(); + const chatPostOk = responseStatuses.some((status) => status >= 200 && status < 300); + const failureKind = chatPostOk + ? null + : failures.length > 0 + ? "requestfailed" + : responseStatuses.length === 0 + ? "missing-response" + : "http-status"; + return { + promptIndex: prompt.promptIndex, + promptCommandId: prompt.commandId, + promptTextHash: prompt.promptTextHash, + promptTextBytes: prompt.promptTextBytes, + startedAt: prompt.startedAt, + completedAt: prompt.completedAt, + failedAt: prompt.failedAt, + chatPostOk, + failureKind, + requestCount: events.filter((event) => event.type === "request").length, + responseCount: responses.length, + requestFailedCount: failures.length, + responseStatuses, + submitModes, + steerUsed: submitModes.includes("steer"), + firstChatEventAt: events[0]?.ts ?? null, + lastChatEventAt: events[events.length - 1]?.ts ?? null, + events: events.slice(0, 12).map((event) => ({ ts: event.ts, type: event.type, status: event.status, urlPath: event.urlPath, submitMode: event.submitMode, failureKind: event.failureKind, errorTextHash: event.errorTextHash })) + }; + }); + return { + summary: { + promptCount: rounds.length, + chatPostOk: rounds.filter((item) => item.chatPostOk === true).length, + chatPostFailed: rounds.filter((item) => item.chatPostOk === false).length, + chatPostMissing: rounds.filter((item) => item.failureKind === "missing-response").length + }, + rounds + }; +} + +function promptSubmitModeForUrl(value) { + const pathValue = urlPath(value); + if (pathValue === "/v1/agent/chat") return "chat"; + if (pathValue === "/v1/agent/chat/steer") return "steer"; + return null; +} + +function parseDomDiagnosticSummary(text) { + const value = String(text || ""); + const traceMatch = value.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu); + const httpStatusMatch = value.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); + const idleMatch = value.match(/\bidle\s+(\d+)s\b/iu); + const waitingForMatch = value.match(/\bwaitingFor=([^\s;;,,)]+)/iu); + const lastEventLabelMatch = value.match(/\blastEventLabel=([^\s;;,,)]+)/iu); + const diagnosticCode = httpStatusMatch + ? "http-" + httpStatusMatch[1] + : /turn\s*超过|无新活动/iu.test(value) + ? "turn-idle-no-activity" + : /Failed to fetch/iu.test(value) + ? "failed-to-fetch" + : "diagnostic"; + return { + diagnosticCode, + traceId: traceMatch?.[1] || null, + httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null, + idleSeconds: idleMatch ? Number(idleMatch[1]) : null, + waitingFor: waitingForMatch?.[1] || null, + lastEventLabel: lastEventLabelMatch?.[1] || null + }; +} + +function isDomDiagnosticSampleText(text) { + const value = String(text || "").replace(/\s+/g, " ").trim(); + if (!value) return false; + const strongDiagnostic = [ + /\bHTTP\s+[45][0-9]{2}\b(?:[\s\S]{0,120}\btrace_id=|\b)/iu, + /\btrace_id=(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu, + /workbench\s+turn\s*超过\s*\d+ms\s*无新活动/iu, + /\bturn\s*超过\b[\s\S]{0,120}\b无新活动\b/iu, + /\bprojection-resume:sync-failed\b/iu, + /\bAgentRun\s+GET\b[\s\S]*\/result\b[\s\S]*timed out after\s+\d+ms\b/iu, + /\bFailed to fetch\b/iu, + /\bFailed to load resource\b[\s\S]{0,180}\bstatus of\s+[45][0-9]{2}\b/iu, + /\bserver responded with a status of\s+[45][0-9]{2}\b/iu, + /Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束)/iu + ].some((pattern) => pattern.test(value)); + if (!strongDiagnostic) return false; + const looksLikeToolStdout = /\b(?:stdout|stderr):/iu.test(value) + && /(?:\becho\s+["']?===|\bnode\s+|\.tspy\b|tspy\/|===\s*[A-Za-z0-9_.-]+\s*===)/iu.test(value); + if (!looksLikeToolStdout) return true; + return /\b(?:trace_id=|HTTP\s+[45][0-9]{2}|workbench\s+turn\s*超过|projection-resume:sync-failed|Failed to fetch|Failed to load resource|server responded with a status of\s+[45][0-9]{2}|Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束))\b/iu.test(value); +} + +function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { + const promptTimes = control + .filter((item) => item.type === "sendPrompt" && item.phase === "completed") + .map((item) => Date.parse(item.ts)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + const observerRefreshTimes = control + .filter((item) => item.type === "observer-periodic-refresh") + .map((item) => Date.parse(item.ts)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + const naturalNetwork = network.filter((item) => item?.observerInitiated !== true); + const httpErrors = naturalNetwork + .filter((item) => item?.type === "response" && Number(item.status) >= 400) + .map((item) => networkAlertEvent(item, promptTimes)); + const requestFailed = naturalNetwork + .filter((item) => item?.type === "requestfailed") + .map((item) => networkAlertEvent(item, promptTimes)); + const workbenchSessionListReadCount = naturalNetwork + .filter((item) => urlPath(item?.url) === "/v1/workbench/sessions") + .length; + const workbenchTraceEventsReadCount = naturalNetwork + .filter((item) => /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath(item?.url))) + .length; + const webPerformanceBeaconFailureCount = naturalNetwork + .filter((item) => urlPath(item?.url) === "/v1/web-performance" && (item?.type === "requestfailed" || Number(item?.status) >= 400)) + .length; + const workbenchEventSourceFailureCount = naturalNetwork + .filter((item) => urlPath(item?.url) === "/v1/workbench/events" && (item?.type === "requestfailed" || Number(item?.status) >= 400)) + .length; + const significantRequestFailed = requestFailed.filter( + (item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes), + ); + const domDiagnostics = []; + const executionErrors = []; + const baselineExecutionErrors = []; + const canarySessionIds = sessionInvariantCanarySessionIds(control); + const firstPromptMs = promptTimes.length > 0 ? promptTimes[0] : Infinity; + const firstSeenExecutionErrorMs = new Map(); + for (const sample of samples) { + const tsMs = Date.parse(sample?.ts); + const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; + if (Array.isArray(sample?.diagnostics)) { + for (const diagnostic of sample.diagnostics.slice(0, 12)) { + const text = diagnostic?.textPreview || diagnostic?.text || ""; + if (!String(text).trim()) continue; + const parsedDiagnostic = parseDomDiagnosticSummary(text); + domDiagnostics.push({ + seq: sample.seq ?? null, + ts: sample.ts ?? null, + promptIndex, + source: "diagnostic-node", + className: diagnostic.className ?? null, + diagnosticCode: diagnostic.diagnosticCode ?? parsedDiagnostic.diagnosticCode, + traceId: diagnostic.traceId ?? parsedDiagnostic.traceId, + httpStatus: diagnostic.httpStatus ?? parsedDiagnostic.httpStatus, + idleSeconds: diagnostic.idleSeconds ?? parsedDiagnostic.idleSeconds, + waitingFor: diagnostic.waitingFor ?? parsedDiagnostic.waitingFor, + lastEventLabel: diagnostic.lastEventLabel ?? parsedDiagnostic.lastEventLabel, + compact: diagnostic.compact ?? null, + expanded: diagnostic.expanded ?? null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + textHash: diagnostic.textHash || sha256(text), + preview: limitText(text, 260) + }); + } + } + const texts = sampleTexts(sample).filter(isDomDiagnosticSampleText); + for (const text of texts.slice(0, 4)) { + const parsedDiagnostic = parseDomDiagnosticSummary(text); + domDiagnostics.push({ + seq: sample.seq ?? null, + ts: sample.ts ?? null, + promptIndex, + source: "sample-text", + diagnosticCode: parsedDiagnostic.diagnosticCode, + traceId: parsedDiagnostic.traceId, + httpStatus: parsedDiagnostic.httpStatus, + idleSeconds: parsedDiagnostic.idleSeconds, + waitingFor: parsedDiagnostic.waitingFor, + lastEventLabel: parsedDiagnostic.lastEventLabel, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + textHash: sha256(text), + preview: limitText(text, 220) + }); + } + const seenExecutionErrors = new Set(); + for (const candidate of sampleExecutionErrorCandidates(sample)) { + const parsed = parseExecutionErrorText(candidate.text); + if (!parsed) continue; + const textHash = sha256(candidate.text); + const dedupeKey = [candidate.source, candidate.traceId || "-", parsed.backend || "-", parsed.code || "-", parsed.status || "-", textHash].join("|"); + if (seenExecutionErrors.has(dedupeKey)) continue; + seenExecutionErrors.add(dedupeKey); + const firstSeenMs = firstSeenExecutionErrorMs.has(dedupeKey) ? firstSeenExecutionErrorMs.get(dedupeKey) : tsMs; + if (!firstSeenExecutionErrorMs.has(dedupeKey) && Number.isFinite(tsMs)) firstSeenExecutionErrorMs.set(dedupeKey, tsMs); + const baseline = Number.isFinite(firstSeenMs) && firstSeenMs < firstPromptMs; + const event = { + seq: sample.seq ?? null, + ts: sample.ts ?? null, + promptIndex, + baseline, + firstSeenAt: Number.isFinite(firstSeenMs) ? new Date(firstSeenMs).toISOString() : null, + source: candidate.source, + backend: parsed.backend, + status: parsed.status, + code: parsed.code, + rawCode: parsed.rawCode, + totalSeconds: parsed.totalSeconds, + traceId: candidate.traceId || parsed.traceId || null, + messageId: candidate.messageId || null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + textHash, + preview: limitText(candidate.text, 260) + }; + const eventSessions = [event.routeSessionId, event.activeSessionId].filter(Boolean); + const nonCanarySession = canarySessionIds.size > 0 && !eventSessions.some((sessionId) => canarySessionIds.has(sessionId)); + if (baseline || nonCanarySession) baselineExecutionErrors.push({ ...event, baseline: true, baselineReason: nonCanarySession ? "non-canary-session" : "pre-first-prompt" }); + else executionErrors.push(event); + domDiagnostics.push({ + seq: sample.seq ?? null, + ts: sample.ts ?? null, + promptIndex, + source: "execution-row", + diagnosticCode: parsed.rawCode || parsed.code || "execution-error", + traceId: candidate.traceId || parsed.traceId || null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + textHash, + preview: limitText(candidate.text, 220) + }); + } + } + const consoleAlerts = consoleEvents + .filter((item) => /error|warning|warn|assert/iu.test(String(item?.type || "")) || isDiagnosticText(item?.text)) + .map((item) => consoleAlertEvent(item, promptTimes)); + const significantConsoleAlerts = consoleAlerts.filter((item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes)); + const pageErrors = errors.map((item) => ({ + ts: item.ts ?? null, + promptIndex: promptIndexForTs(promptTimes, item.ts), + type: item.type ?? null, + pageRole: item.pageRole ?? item.error?.details?.pageRole ?? null, + pageId: item.pageId ?? item.error?.details?.pageId ?? null, + routeSessionId: item.routeSessionId ?? item.error?.details?.routeSessionId ?? null, + activeSessionId: item.activeSessionId ?? item.error?.details?.activeSessionId ?? null, + commandId: item.commandId ?? item.error?.details?.commandId ?? null, + sampleSeq: item.sampleSeq ?? item.error?.details?.sampleSeq ?? null, + timeoutMs: timeoutMsFromMessage(item.error?.message || item.message || item.error || ""), + errorName: item.error?.name ?? item.name ?? null, + messageHash: item.error?.message ? sha256(item.error.message) : item.message ? sha256(item.message) : null, + preview: limitText(item.error?.message || item.message || item.error || "", 220) + })); + return { + summary: { + httpErrorCount: httpErrors.length, + requestFailedCount: requestFailed.length, + significantRequestFailedCount: significantRequestFailed.length, + workbenchSessionListReadCount, + workbenchTraceEventsReadCount, + webPerformanceBeaconFailureCount, + workbenchEventSourceFailureCount, + benignLongLivedStreamClosureCount: requestFailed.length - significantRequestFailed.length, + domDiagnosticSampleCount: domDiagnostics.length, + domDiagnosticGroupCount: groupDomDiagnostics(domDiagnostics).length, + executionErrorCount: executionErrors.length, + baselineExecutionErrorCount: baselineExecutionErrors.length, + consoleAlertCount: consoleAlerts.length, + significantConsoleAlertCount: significantConsoleAlerts.length, + pageErrorCount: pageErrors.length, + networkErrorGroupCount: groupNetworkAlerts(httpErrors).length, + requestFailedGroupCount: groupNetworkAlerts(requestFailed).length, + significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length, + executionErrorGroupCount: groupExecutionErrors(executionErrors).length, + baselineExecutionErrorGroupCount: groupExecutionErrors(baselineExecutionErrors).length, + consoleAlertGroupCount: groupConsoleAlerts(consoleAlerts).length, + significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length + }, + networkHttpErrorsByPath: groupNetworkAlerts(httpErrors), + networkRequestFailedByPath: groupNetworkAlerts(requestFailed), + networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed), + domDiagnostics: domDiagnostics.slice(-80), + domDiagnosticsByText: groupDomDiagnostics(domDiagnostics), + domDiagnosticsByFingerprint: groupDomDiagnostics(domDiagnostics).slice(0, 80), + runtimeExecutionErrors: executionErrors.slice(0, 120), + runtimeExecutionErrorsByCode: groupExecutionErrors(executionErrors), + baselineRuntimeExecutionErrors: baselineExecutionErrors.slice(0, 80), + baselineRuntimeExecutionErrorsByCode: groupExecutionErrors(baselineExecutionErrors), + consoleAlerts: consoleAlerts.slice(0, 80), + consoleAlertsByPath: groupConsoleAlerts(consoleAlerts), + significantConsoleAlerts: significantConsoleAlerts.slice(0, 80), + significantConsoleAlertsByPath: groupConsoleAlerts(significantConsoleAlerts), + pageErrors: pageErrors.slice(0, 40) + }; +} + +function groupDomDiagnostics(events) { + const groups = new Map(); + for (const item of events || []) { + const preview = String(item?.preview || "").trim(); + if (!isReportableDomDiagnostic(item, preview)) continue; + const normalizedPreview = normalizeDiagnosticPreview(preview); + const key = [ + item?.diagnosticCode || "", + normalizedPreview + ].join("|"); + const existing = groups.get(key) || { + source: item?.source || null, + sources: new Set(), + diagnosticCode: item?.diagnosticCode || null, + textHash: item?.textHash || null, + normalizedPreview, + preview, + count: 0, + firstAt: item?.ts || null, + lastAt: item?.ts || null, + promptIndexes: new Set(), + traceIds: new Set(), + sampleSeqs: [] + }; + if (item?.source) existing.sources.add(String(item.source)); + existing.count += 1; + existing.firstAt = minIso(existing.firstAt, item?.ts || null); + existing.lastAt = maxIso(existing.lastAt, item?.ts || null); + if (Number.isFinite(Number(item?.promptIndex))) existing.promptIndexes.add(Number(item.promptIndex)); + for (const traceId of extractDiagnosticTraceIds(item, preview)) existing.traceIds.add(traceId); + if (existing.sampleSeqs.length < 12 && item?.seq !== undefined && item?.seq !== null) existing.sampleSeqs.push(item.seq); + groups.set(key, existing); + } + return Array.from(groups.values()) + .map((item) => ({ + source: item.source, + sources: Array.from(item.sources).sort(), + diagnosticCode: item.diagnosticCode, + textHash: item.textHash, + normalizedPreview: item.normalizedPreview, + preview: item.preview, + count: item.count, + firstAt: item.firstAt, + lastAt: item.lastAt, + promptIndexes: Array.from(item.promptIndexes).sort((a, b) => a - b), + traceIds: Array.from(item.traceIds).sort(), + sampleSeqs: item.sampleSeqs + })) + .sort((a, b) => (b.count - a.count) || String(a.firstAt || "").localeCompare(String(b.firstAt || ""))); +} + +function isReportableDomDiagnostic(item, preview) { + if (item?.source === "diagnostic-node" || item?.source === "execution-row") return true; + return /trace_id=|HTTP\s+\d{3}\b|Failed to load resource|ERR_[A-Z_]+|provider-unavailable|AgentRun error|超过\s*\d+\s*ms\s*无新活动|代理暂时无法连接上游|Trace 更新超时|加载失败/iu.test(String(preview || "")); +} + +function normalizeDiagnosticPreview(text) { + return String(text || "") + .replace(/trace_id=[A-Za-z0-9_-]+/gu, "trace_id=:traceId") + .replace(/\btrc_[A-Za-z0-9_-]+\b/gu, "trc_:traceId") + .replace(/\bses_[A-Za-z0-9_-]+\b/gu, "ses_:sessionId") + .replace(/\brun_[A-Za-z0-9_-]+\b/gu, "run_:runId") + .replace(/\bcmd_[A-Za-z0-9_-]+\b/gu, "cmd_:commandId") + .replace(/[!!]+$/gu, "") + .replace(/\s+/gu, " ") + .trim(); +} + +function extractDiagnosticTraceIds(item, preview) { + const ids = new Set(); + if (item?.traceId) ids.add(String(item.traceId)); + const text = String(preview || ""); + for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]); + for (const match of text.matchAll(/trace_id=([A-Za-z0-9_-]+)/gu)) ids.add(match[1]); + return ids; +} + +function minIso(a, b) { + if (!a) return b || null; + if (!b) return a || null; + return Date.parse(a) <= Date.parse(b) ? a : b; +} + +function maxIso(a, b) { + if (!a) return b || null; + if (!b) return a || null; + return Date.parse(a) >= Date.parse(b) ? a : b; +} + +function sampleExecutionErrorCandidates(sample) { + const candidates = []; + const add = (source, items) => { + if (!Array.isArray(items)) return; + for (const item of items) { + const text = String(item?.textPreview || item?.text || item?.preview || "").trim(); + if (!text) continue; + if (!parseExecutionErrorText(text)) continue; + candidates.push({ + source, + text, + traceId: item?.traceId ?? null, + messageId: item?.messageId ?? null, + status: item?.status ?? null + }); + } + }; + add("diagnostic-node", sample?.diagnostics); + add("message", sample?.messages); + add("trace-row", sample?.traceRows); + add("turn", sample?.turns); + const specific = candidates.filter((candidate) => { + const parsed = parseExecutionErrorText(candidate.text); + return parsed && parsed.code !== "error"; + }); + return specific.length > 0 ? specific : candidates; +} + +function parseExecutionErrorText(text) { + const value = String(text || ""); + const agentRunCodeMatch = value.match(/\bagentrun:error:([A-Za-z0-9_.:-]+)/u); + const agentRunText = /\bAgentRun\s+error\b|\bagentrun:error:/iu.test(value); + const providerUnavailable = /\bprovider[-_\s]*unavailable\b/iu.test(value); + if (!agentRunCodeMatch && !agentRunText && !providerUnavailable) return null; + const statusMatch = value.match(/\b(fail(?:ed)?|error|blocked|cancel(?:ed)?)\b/iu); + const traceMatch = value.match(/\btrc_[A-Za-z0-9_-]+\b/u); + const totalMatch = value.match(/\btotal\s*=\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)\b/iu) + || value.match(/总耗时\s*[::]?\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)/iu); + const agentRunCode = cleanExecutionCode(agentRunCodeMatch?.[1] || ""); + const rawCode = agentRunCode ? "agentrun:error:" + agentRunCode : providerUnavailable ? "provider-unavailable" : "agentrun:error"; + return { + backend: agentRunText || agentRunCodeMatch ? "agentrun" : "unknown", + status: normalizeExecutionStatus(statusMatch?.[1] || "error"), + code: agentRunCode || (providerUnavailable ? "provider-unavailable" : "error"), + rawCode, + totalSeconds: totalMatch ? parseClockDurationSeconds(totalMatch[1]) : null, + traceId: traceMatch?.[0] || null + }; +} + +function cleanExecutionCode(code) { + const value = String(code || "").replace(/(?:AgentRun|Error|Failed).*$/u, "").replace(/[^A-Za-z0-9_.:-].*$/u, ""); + return value || null; +} + +function normalizeExecutionStatus(status) { + const value = String(status || "").toLowerCase(); + if (value === "failed") return "fail"; + if (value === "cancelled" || value === "canceled") return "cancel"; + return value || "error"; +} + +function parseClockDurationSeconds(value) { + const parts = String(value || "").split(":").map((part) => Number(part)); + if (parts.length === 2 && parts.every(Number.isFinite)) return parts[0] * 60 + parts[1]; + if (parts.length === 3 && parts.every(Number.isFinite)) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return null; +} + +function groupExecutionErrors(events) { + const groups = new Map(); + for (const event of events) { + const key = [event.backend || "-", event.status || "-", event.code || "-"].join(" "); + const group = groups.get(key) || { + backend: event.backend ?? null, + status: event.status ?? null, + code: event.code ?? null, + rawCode: event.rawCode ?? null, + count: 0, + firstAt: event.ts, + lastAt: event.ts, + promptIndexes: [], + traceIds: [], + sources: [] + }; + group.count += 1; + group.lastAt = event.ts; + if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); + if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId); + if (event.source && !group.sources.includes(event.source)) group.sources.push(event.source); + groups.set(key, group); + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.code).localeCompare(String(b.code))); +} + +function consoleAlertEvent(item, promptTimes) { + const text = String(item?.text || ""); + const statusMatch = text.match(/\bstatus\s+of\s+([1-5][0-9]{2})\b/iu) || text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); + const location = compactLocation(item.location); + const traceMatch = (location?.urlPath || text).match(/\btrc_[A-Za-z0-9_-]+\b/u); + return { + ts: item.ts ?? null, + promptIndex: promptIndexForTs(promptTimes, item.ts), + type: item.type ?? null, + status: statusMatch ? Number(statusMatch[1]) : null, + urlPath: location?.urlPath || "-", + traceId: traceMatch?.[0] || null, + textHash: item.text ? sha256(item.text) : null, + preview: limitText(text, 220), + location + }; +} + +function groupConsoleAlerts(events) { + const groups = new Map(); + for (const event of events) { + const key = [event.type || "-", event.status ?? "-", event.urlPath || "-"].join(" "); + const group = groups.get(key) || { + type: event.type ?? null, + status: event.status ?? null, + urlPath: event.urlPath || "-", + count: 0, + firstAt: event.ts, + lastAt: event.ts, + promptIndexes: [], + traceIds: [] + }; + group.count += 1; + group.lastAt = event.ts; + if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); + if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId); + groups.set(key, group); + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath))); +} + +function isBenignLongLivedStreamClosureAlert(event) { + if (event?.urlPath !== "/v1/workbench/events") return false; + if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false; + const text = String(event.failureKind || event.errorPreview || event.preview || ""); + return /ERR_NETWORK_CHANGED|ERR_ABORTED|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed/iu.test(text); +} + +function isObserverRefreshClosureAlert(event, observerRefreshTimes) { + const urlPath = String(event?.urlPath || ""); + if (!["/v1/workbench/events", "/v1/web-performance"].includes(urlPath) && !/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath)) return false; + if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false; + const text = String(event.failureKind || event.errorPreview || event.preview || ""); + if (!/ERR_NETWORK_CHANGED|ERR_ABORTED|ERR_INCOMPLETE_CHUNKED_ENCODING|ERR_INVALID_CHUNKED_ENCODING|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed|incomplete chunked|invalid chunked/iu.test(text)) return false; + const ts = Date.parse(String(event.ts || "")); + return Number.isFinite(ts) && observerRefreshTimes.some((refreshTs) => Math.abs(ts - refreshTs) <= 8000); +} + +function networkAlertEvent(item, promptTimes) { + const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null; + return { + ts: item.ts ?? null, + promptIndex: promptIndexForTs(promptTimes, item.ts), + method: String(item.method || "GET").toUpperCase(), + status: Number.isFinite(Number(item.status)) ? Number(item.status) : null, + type: item.type ?? null, + urlPath: urlPath(item.url), + urlHash: item.url ? sha256(item.url) : null, + failureKind: failureText ? String(failureText) : null, + errorTextHash: failureText ? sha256(failureText) : null, + errorPreview: failureText ? limitText(failureText, 160) : null + }; +} + +function groupNetworkAlerts(events) { + const groups = new Map(); + for (const event of events) { + const key = [event.method, event.urlPath, event.status ?? "-", event.type].join(" "); + const group = groups.get(key) || { + method: event.method, + urlPath: event.urlPath, + status: event.status, + type: event.type, + count: 0, + firstAt: event.ts, + lastAt: event.ts, + promptIndexes: [], + failureKinds: [], + errorTextHashes: [] + }; + group.count += 1; + group.lastAt = event.ts; + if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); + if (event.failureKind && !group.failureKinds.includes(event.failureKind)) group.failureKinds.push(event.failureKind); + if (event.errorTextHash && !group.errorTextHashes.includes(event.errorTextHash)) group.errorTextHashes.push(event.errorTextHash); + groups.set(key, group); + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath))); +} + +function isDiagnosticText(text) { + const value = String(text || ""); + return /Failed to (?:fetch|load resource)|request failed|net::ERR_[A-Z0-9_:-]+|server responded with a status of [45][0-9]{2}|HTTP\s+[45][0-9]{2}\b|trace_id=|workbench turn\s*超过|turn\s*超过|无新活动|idle\s+\d+s|waitingFor=|lastEventLabel=|无法连接上游|代理暂时无法连接上游|provider-unavailable|agentrun:error|AgentRun error|projection-resume|sync-failed|durable projection store|realtime-gap|Trace 更新超时|加载失败|请求失败|请求已失败/iu.test(value); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-sample-metrics-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-sample-metrics-source.ts new file mode 100644 index 00000000..15ba7f1a --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-sample-metrics-source.ts @@ -0,0 +1,595 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer sample metrics, loading continuity, session rail title, and bounded report rows source fragment. + +export function nodeWebObserveAnalyzerSampleMetricsSource(): string { + return String.raw`function isTerminalTraceText(text) { + return /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(text || "")); +} + +function isFinalResultText(text) { + return /已完成第\d+轮|已按第\d+轮完成|final response|sealed final response|最终结果|已完成[::]|smoke\s*测试结果|benchmark|PVC\/workspace|修改文件|Results:/iu.test(String(text || "")); +} + +function buildSampleMetrics(samples, control) { + const promptCommands = buildSendPromptCommandTimeline(control); + const promptTimes = promptCommands.map((item) => item.tsMs); + const timeline = samples.map((sample) => { + const texts = sampleTexts(sample); + const timingTexts = sampleTurnTimingTexts(sample); + const tsMs = Date.parse(sample.ts); + const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; + const totalElapsedValues = timingTexts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite); + const recentUpdateValues = timingTexts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite); + const diagnosticTexts = texts.filter(isDiagnosticText).slice(0, 5); + const terminalTexts = texts.filter(isTerminalTraceText).slice(0, 5); + const finalResultTexts = texts.filter(isFinalResultText).slice(0, 5); + const loadings = Array.isArray(sample.loadings) ? sample.loadings : []; + const loadingOwners = uniqueLoadingOwners(loadings); + return { + seq: sample.seq ?? null, + ts: sample.ts ?? null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + promptIndex, + messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, + traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, + loadingCount: loadings.length, + loadingOwnerCount: loadingOwners.length, + loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12), + sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0), + sessionRailFallbackTitleCount: Number(sample?.sessionRail?.fallbackTitleCount ?? 0), + sessionRailFallbackTitleRatio: Number(sample?.sessionRail?.fallbackTitleRatio ?? 0), + totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, + recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, + terminalSeen: terminalTexts.length > 0, + finalResultTextSeen: finalResultTexts.length > 0, + diagnosticSeen: diagnosticTexts.length > 0, + diagnosticTextHashes: diagnosticTexts.map(sha256).slice(0, 5), + textDigest: digestSample(sample) + }; + }); + const turnTiming = buildTurnTimingTable(samples, timeline); + const traceOrder = buildTraceOrderMetrics(samples, timeline); + const codeAgentCardTiming = buildCodeAgentCardTimingMetrics(samples, timeline, turnTiming); + const codeAgentCardDurationUnderreported = buildCodeAgentCardDurationUnderreportedMetrics(samples, timeline); + const codeAgentCardDurationMismatches = buildCodeAgentCardDurationMismatchMetrics(samples, timeline); + if (codeAgentCardTiming && codeAgentCardTiming.summary) { + codeAgentCardTiming.summary.durationUnderreportedCount = codeAgentCardDurationUnderreported.length; + codeAgentCardTiming.summary.durationMismatchCount = codeAgentCardDurationMismatches.length; + codeAgentCardTiming.durationUnderreported = codeAgentCardDurationUnderreported; + codeAgentCardTiming.durationMismatches = codeAgentCardDurationMismatches; + } + const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); + const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : []; + const turnTimingElapsedZeroResets = Array.isArray(turnTiming.elapsedZeroResets) ? turnTiming.elapsedZeroResets : []; + const turnTimingTotalElapsedForwardJumps = Array.isArray(turnTiming.totalElapsedForwardJumps) ? turnTiming.totalElapsedForwardJumps : []; + const turnTimingRecentUpdateSawtoothJumps = turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump"); + const turnTimingTerminalElapsedGrowth = Array.isArray(turnTiming.terminalElapsedGrowth) ? turnTiming.terminalElapsedGrowth : []; + const turnTimingRecentUpdateResets = Array.isArray(turnTiming.recentUpdateResets) ? turnTiming.recentUpdateResets : []; + const turnTimingRecentUpdateSteps = Array.isArray(turnTiming.recentUpdateSteps) ? turnTiming.recentUpdateSteps : []; + const turnTimingTerminalElapsedGrowthDeltas = turnTimingTerminalElapsedGrowth + .map((item) => Number(item.delta)) + .filter((value) => Number.isFinite(value) && value > 0); + const turnTimingRecentUpdateLargestSteps = turnTimingRecentUpdateSteps + .filter((item) => Number.isFinite(Number(item.delta))) + .slice() + .sort((a, b) => Number(b.delta) - Number(a.delta)) + .slice(0, 200); + const turnTimingRecentUpdatePositiveSteps = turnTimingRecentUpdateSteps + .map((item) => Number(item.delta)) + .filter((value) => Number.isFinite(value) && value >= 0); + const turnTimingRecentUpdateExcessSteps = turnTimingRecentUpdateSteps + .map((item) => Number(item.excessiveIncreaseSeconds)) + .filter((value) => Number.isFinite(value) && value > 0); + const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length; + const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length; + const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; + const loading = buildLoadingMetrics(samples, timeline); + const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline); + const workbenchTurnStateTriad = buildWorkbenchTurnStateTriadMetrics(samples, timeline); + const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows); + const reportTimeline = boundedRowsForReport(timeline); + const rounds = buildRoundMetricSummaries(timeline, promptCommands, { + columns: turnTiming.columns, + rows: turnTiming.rows, + nonMonotonic: turnTimingNonMonotonic, + elapsedZeroResets: turnTimingElapsedZeroResets, + totalElapsedForwardJumps: turnTimingTotalElapsedForwardJumps, + terminalElapsedGrowth: turnTimingTerminalElapsedGrowth, + recentUpdateResets: turnTimingRecentUpdateResets, + recentUpdateSteps: turnTimingRecentUpdateSteps + }); + const recentUpdateJumpCount = turnTimingRecentUpdateSawtoothJumps.length; + return { + summary: { + sampleCount: timeline.length, + withTotalElapsed: withTotal, + withRecentUpdate: withRecent, + diagnostics, + loadingSampleCount: loading.summary.loadingSampleCount, + loadingMaxCount: loading.summary.maxSimultaneousCount, + loadingMaxOwnerCount: loading.summary.maxSimultaneousOwnerCount, + loadingOwnerCount: loading.summary.ownerCount, + loadingConcurrentSampleCount: loading.summary.concurrentLoadingSampleCount, + loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds, + loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds, + loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount, + loadingOverBudgetSegmentCount: loading.summary.overBudgetSegmentCount, + sessionRailSampleCount: sessionRailTitles.summary.sampleCount, + sessionRailVisibleSampleCount: sessionRailTitles.summary.visibleSampleCount, + sessionRailFallbackMajoritySampleCount: sessionRailTitles.summary.majorityFallbackSampleCount, + sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio, + sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount, + sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount, + workbenchTurnStateTriadRows: workbenchTurnStateTriad.summary.rowCount, + workbenchTurnStateTriadInvalidRows: workbenchTurnStateTriad.summary.invalidRowCount, + workbenchTurnStateTriadFullInvalidRows: workbenchTurnStateTriad.summary.invalidFullTriadCount, + workbenchTurnStateCardFinalResponseMismatchRows: workbenchTurnStateTriad.summary.cardFinalResponseMismatchCount, + workbenchTurnStateCollectorMissingRows: workbenchTurnStateTriad.summary.collectorMissingRowCount, + promptSegments: Math.max(0, promptTimes.length), + rounds: rounds.length, + turnColumns: turnTiming.columns.length, + turnTimingRows: turnTiming.rows.length, + turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length, + turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length, + turnTimingNonMonotonicCount: turnTimingNonMonotonic.length, + turnTimingTotalElapsedDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds").length, + turnTimingTotalElapsedZeroResetCount: turnTimingElapsedZeroResets.length, + turnTimingTotalElapsedForwardJumpCount: turnTimingTotalElapsedForwardJumps.length, + turnTimingTotalElapsedForwardJumpMaxSeconds: maxPositiveDelta(turnTimingTotalElapsedForwardJumps), + turnTimingTerminalElapsedGrowthCount: turnTimingTerminalElapsedGrowth.length, + turnTimingTerminalElapsedGrowthMaxSeconds: turnTimingTerminalElapsedGrowthDeltas.length > 0 ? Math.max(...turnTimingTerminalElapsedGrowthDeltas) : 0, + turnTimingRecentUpdateJumpCount: recentUpdateJumpCount, + turnTimingRecentUpdateSawtoothJumpCount: recentUpdateJumpCount, + turnTimingRecentUpdateStepCount: turnTimingRecentUpdateSteps.length, + turnTimingRecentUpdateMaxIncreaseSeconds: turnTimingRecentUpdatePositiveSteps.length > 0 ? Math.max(...turnTimingRecentUpdatePositiveSteps) : null, + turnTimingRecentUpdateMaxExcessSeconds: turnTimingRecentUpdateExcessSteps.length > 0 ? Math.max(...turnTimingRecentUpdateExcessSteps) : 0, + turnTimingRecentUpdateResetCount: turnTimingRecentUpdateResets.length, + turnTimingRecentUpdateDecreaseCount: turnTimingRecentUpdateResets.length, + codeAgentCardSampleCount: codeAgentCardTiming.summary.cardSampleCount, + codeAgentCardMissingElapsedCount: codeAgentCardTiming.summary.missingElapsedCount, + codeAgentCardMissingRecentUpdateCount: codeAgentCardTiming.summary.missingRecentUpdateCount, + roundCompletionEventCount: codeAgentCardTiming.summary.roundCompletionEventCount, + roundCompletionElapsedMismatchCount: codeAgentCardTiming.summary.roundCompletionElapsedMismatchCount, + roundCompletionFinalResponseMissingCount: codeAgentCardTiming.summary.roundCompletionFinalResponseMissingCount, + roundCompletionPostTimingChangeCount: codeAgentCardTiming.summary.roundCompletionPostTimingChangeCount, + codeAgentCardDurationUnderreportedCount: codeAgentCardTiming.summary.durationUnderreportedCount, + codeAgentCardDurationMismatchCount: codeAgentCardTiming.summary.durationMismatchCount, + traceRowCount: traceOrder.summary.traceRowCount, + traceRowOrderAnomalyCount: traceOrder.summary.orderAnomalyCount, + traceRowCompletionNotLastCount: traceOrder.summary.completionNotLastCount, + roundsWithTurnTimingNonMonotonic: rounds.filter((item) => item.turnTimingNonMonotonicCount > 0).length, + roundsWithTurnTimingTotalElapsedForwardJumps: rounds.filter((item) => item.turnTimingTotalElapsedForwardJumpCount > 0).length, + roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length, + roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length + }, + loading, + sessionRailTitles, + workbenchTurnStateTriad, + codeAgentCardTiming, + traceOrder, + rounds, + turnColumns: turnTiming.columns, + turnTimingTable: reportTurnTimingRows.rows, + turnTimingTableDisclosure: reportTurnTimingRows.disclosure, + turnTimingNonMonotonic, + turnTimingElapsedZeroResets, + turnTimingTotalElapsedForwardJumps, + turnTimingTerminalElapsedGrowth, + turnTimingRecentUpdateSawtoothJumps, + turnTimingRecentUpdateSteps, + turnTimingRecentUpdateLargestSteps, + turnTimingRecentUpdateResets, + timeline: reportTimeline.rows, + timelineDisclosure: reportTimeline.disclosure + }; +} + +function boundedRowsForReport(rows) { + const sourceRows = Array.isArray(rows) ? rows : []; + const maxRows = 1200; + const headRows = 120; + if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; + const tailRows = Math.max(0, maxRows - headRows); + return { + rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], + disclosure: { + truncated: true, + totalRows: sourceRows.length, + includedRows: maxRows, + omittedRows: Math.max(0, sourceRows.length - maxRows), + headRows, + tailRows, + policy: "report-bounded-head-tail; summary metrics are computed before truncation", + valuesRedacted: true + } + }; +} + +function buildSendPromptCommandTimeline(control) { + const byCommand = new Map(); + let ordinal = 0; + for (const item of Array.isArray(control) ? control : []) { + if (item?.type !== "sendPrompt") continue; + const commandId = item.commandId ? String(item.commandId) : "sendPrompt-" + String(ordinal++); + const existing = byCommand.get(commandId) || { + commandId, + startedAt: null, + startedTsMs: null, + completedAt: null, + completedTsMs: null, + failedAt: null, + failedTsMs: null, + textHash: null, + textBytes: null, + }; + if (!existing.textHash && item.input?.textHash) existing.textHash = item.input.textHash; + if (!existing.textBytes && item.input?.textBytes) existing.textBytes = item.input.textBytes; + const tsMs = Date.parse(item.ts); + if (item.phase === "started" && Number.isFinite(tsMs)) { + existing.startedAt = item.ts ?? existing.startedAt; + existing.startedTsMs = tsMs; + } else if (item.phase === "completed" && Number.isFinite(tsMs)) { + existing.completedAt = item.ts ?? existing.completedAt; + existing.completedTsMs = tsMs; + } else if (item.phase === "failed" && Number.isFinite(tsMs)) { + existing.failedAt = item.ts ?? existing.failedAt; + existing.failedTsMs = tsMs; + } + byCommand.set(commandId, existing); + } + return Array.from(byCommand.values()) + .map((item) => { + const tsMs = Number.isFinite(item.startedTsMs) ? item.startedTsMs : Number.isFinite(item.completedTsMs) ? item.completedTsMs : item.failedTsMs; + const ts = Number.isFinite(item.startedTsMs) ? item.startedAt : Number.isFinite(item.completedTsMs) ? item.completedAt : item.failedAt; + return { ...item, ts, tsMs }; + }) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs) + .map((item, index) => ({ ...item, promptIndex: index + 1 })); +} + +function buildSessionRailTitleMetrics(samples, timeline) { + const rows = []; + const examplesByHash = new Map(); + for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) { + const sample = samples[index]; + const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null; + if (!rail) continue; + const visibleCount = Number(rail.visibleCount ?? 0); + const fallbackTitleCount = Number(rail.fallbackTitleCount ?? 0); + const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0; + const safeFallbackTitleCount = Number.isFinite(fallbackTitleCount) && fallbackTitleCount > 0 ? fallbackTitleCount : 0; + const fallbackTitleRatio = safeVisibleCount > 0 ? Number((safeFallbackTitleCount / safeVisibleCount).toFixed(4)) : 0; + const fallbackItems = Array.isArray(rail.fallbackItems) ? rail.fallbackItems : []; + for (const item of fallbackItems) { + const hash = String(item?.titleHash || item?.titlePreview || item?.sessionIdPrefix || "").trim(); + if (!hash || examplesByHash.has(hash)) continue; + examplesByHash.set(hash, { + titleHash: item?.titleHash ?? null, + titlePreview: limitText(String(item?.titlePreview || ""), 160), + sessionIdPrefix: item?.sessionIdPrefix ?? null, + active: item?.active === true, + firstSeq: sample?.seq ?? null, + firstAt: sample?.ts ?? null, + pageRole: sample?.pageRole ?? null, + }); + } + rows.push({ + ...ref(sample), + promptIndex: timeline[index]?.promptIndex ?? 0, + visibleCount: safeVisibleCount, + fallbackTitleCount: safeFallbackTitleCount, + fallbackTitleRatio, + majorityFallback: safeVisibleCount > 0 && safeFallbackTitleCount > safeVisibleCount / 2, + overThreshold: safeVisibleCount > 0 && fallbackTitleRatio > alertThresholds.sessionRailFallbackRatio, + examples: fallbackItems.slice(0, 5).map((item) => ({ + titleHash: item?.titleHash ?? null, + titlePreview: limitText(String(item?.titlePreview || ""), 160), + sessionIdPrefix: item?.sessionIdPrefix ?? null, + active: item?.active === true, + })), + }); + } + const visibleRows = rows.filter((item) => item.visibleCount > 0); + const majorityRows = rows.filter((item) => item.majorityFallback); + const overThresholdRows = rows.filter((item) => item.overThreshold); + const fallbackRows = rows.filter((item) => item.fallbackTitleCount > 0); + const maxFallbackRatio = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleRatio) || 0)) : 0; + const maxVisibleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.visibleCount) || 0)) : 0; + const maxFallbackTitleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleCount) || 0)) : 0; + return { + summary: { + sampleCount: rows.length, + visibleSampleCount: visibleRows.length, + fallbackSampleCount: fallbackRows.length, + majorityFallbackSampleCount: majorityRows.length, + overThresholdSampleCount: overThresholdRows.length, + thresholdRatio: alertThresholds.sessionRailFallbackRatio, + maxFallbackRatio, + maxVisibleCount, + maxFallbackTitleCount, + }, + samples: majorityRows.slice(0, 80), + examples: Array.from(examplesByHash.values()).slice(0, 80), + timeline: rows.slice(-200), + valuesRedacted: true + }; +} + +function uniqueLoadingOwners(loadings) { + const groups = new Map(); + for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) { + const item = loadings[index]; + const ownerKey = loadingOwnerKey(item, index); + const ownerIdentity = loadingOwnerIdentity(item); + const existing = groups.get(ownerKey) || { + ownerKey, + ownerKind: item?.ownerKind ?? "unknown", + ownerLabel: loadingOwnerLabel(item, ownerKey), + ...ownerIdentity, + count: 0, + textHashes: [] + }; + existing.count += 1; + if (item?.textHash && !existing.textHashes.includes(item.textHash)) existing.textHashes.push(item.textHash); + for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) { + if (!existing[key] && ownerIdentity[key]) existing[key] = ownerIdentity[key]; + } + groups.set(ownerKey, existing); + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))); +} + +function loadingOwnerIdentity(item) { + const owner = item?.owner && typeof item.owner === "object" ? item.owner : {}; + return { + ownerSessionId: owner.sessionId ?? null, + ownerMessageId: owner.messageId ?? null, + ownerTraceId: owner.traceId ?? null, + }; +} + +function loadingOwnerKey(item, index = 0) { + const key = String(item?.ownerKey || "").trim(); + if (key) return key.slice(0, 240); + const owner = item?.owner && typeof item.owner === "object" ? item.owner : {}; + return [ + item?.ownerKind || "unknown", + owner.testId || item?.testId || owner.id || owner.role || owner.className || item?.role || item?.tag || "node", + owner.sessionId || owner.messageId || owner.traceId || item?.textHash || String(index) + ].filter(Boolean).join(":").slice(0, 240); +} + +function loadingOwnerLabel(item, fallback) { + return limitText(String(item?.ownerLabel || item?.owner?.ariaLabel || item?.owner?.testId || item?.owner?.className || fallback || "unknown"), 160); +} + +function buildLoadingMetrics(samples, timeline) { + const events = samples.map((sample, index) => { + const tsMs = Date.parse(sample?.ts); + const loadings = Array.isArray(sample?.loadings) ? sample.loadings : []; + const owners = uniqueLoadingOwners(loadings); + return { + seq: sample?.seq ?? null, + ts: sample?.ts ?? null, + tsMs, + promptIndex: timeline[index]?.promptIndex ?? 0, + routeSessionId: sample?.routeSessionId ?? null, + activeSessionId: sample?.activeSessionId ?? null, + loadingCount: loadings.length, + ownerCount: owners.length, + owners, + ownerKeys: owners.map((item) => item.ownerKey), + ownerLabels: owners.map((item) => item.ownerLabel).slice(0, 8) + }; + }).filter((item) => Number.isFinite(item.tsMs)); + const continuityThresholdMs = loadingContinuityThresholdMs(events); + const segments = buildLoadingSegments(events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners) + .sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0) || Number(b.maxCount ?? 0) - Number(a.maxCount ?? 0)); + const ownerMap = new Map(); + for (const event of events) { + for (const owner of event.owners) { + const existing = ownerMap.get(owner.ownerKey) || { + ownerKey: owner.ownerKey, + ownerKind: owner.ownerKind, + ownerLabel: owner.ownerLabel, + ownerSessionId: owner.ownerSessionId ?? null, + ownerMessageId: owner.ownerMessageId ?? null, + ownerTraceId: owner.ownerTraceId ?? null, + sampleCount: 0, + occurrenceCount: 0, + maxSimultaneousCount: 0, + firstAt: event.ts, + lastAt: event.ts, + firstSeq: event.seq, + lastSeq: event.seq, + promptIndexes: new Set(), + events: [] + }; + existing.sampleCount += 1; + existing.occurrenceCount += owner.count; + existing.maxSimultaneousCount = Math.max(existing.maxSimultaneousCount, owner.count); + existing.lastAt = event.ts; + existing.lastSeq = event.seq; + for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) { + if (!existing[key] && owner[key]) existing[key] = owner[key]; + } + if (Number.isFinite(Number(event.promptIndex))) existing.promptIndexes.add(Number(event.promptIndex)); + existing.events.push({ ...event, loadingCount: owner.count, owners: [owner] }); + ownerMap.set(owner.ownerKey, existing); + } + } + const owners = Array.from(ownerMap.values()).map((owner) => { + const ownerSegments = buildLoadingSegments(owner.events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners); + const longest = ownerSegments.reduce((max, item) => Math.max(max, Number(item.durationSeconds ?? 0)), 0); + return { + ownerKey: owner.ownerKey, + ownerKind: owner.ownerKind, + ownerLabel: owner.ownerLabel, + ownerSessionId: owner.ownerSessionId ?? null, + ownerMessageId: owner.ownerMessageId ?? null, + ownerTraceId: owner.ownerTraceId ?? null, + sampleCount: owner.sampleCount, + occurrenceCount: owner.occurrenceCount, + maxSimultaneousCount: owner.maxSimultaneousCount, + longestContinuousSeconds: longest, + firstAt: owner.firstAt, + lastAt: owner.lastAt, + firstSeq: owner.firstSeq, + lastSeq: owner.lastSeq, + promptIndexes: Array.from(owner.promptIndexes).sort((a, b) => a - b), + segments: ownerSegments.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0)).slice(0, 8), + valuesRedacted: true + }; + }).sort((a, b) => Number(b.longestContinuousSeconds ?? 0) - Number(a.longestContinuousSeconds ?? 0) || Number(b.occurrenceCount ?? 0) - Number(a.occurrenceCount ?? 0)); + const latest = events[events.length - 1] || null; + const currentSegment = latest && latest.loadingCount > 0 + ? segments.find((segment) => segment.ongoing === true && segment.lastSeq === latest.seq) || null + : null; + const timelineRows = events + .filter((event, index) => event.loadingCount > 0 || (index > 0 && events[index - 1]?.loadingCount > 0)) + .slice(0, 500) + .map((event) => ({ + seq: event.seq, + ts: event.ts, + promptIndex: event.promptIndex, + loadingCount: event.loadingCount, + ownerCount: event.ownerCount, + owners: event.owners.map((owner) => ({ ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, ownerSessionId: owner.ownerSessionId ?? null, ownerMessageId: owner.ownerMessageId ?? null, ownerTraceId: owner.ownerTraceId ?? null, count: owner.count })).slice(0, 8) + })); + return { + summary: { + sampleCount: events.length, + loadingSampleCount: events.filter((event) => event.loadingCount > 0).length, + maxSimultaneousCount: events.reduce((max, event) => Math.max(max, event.loadingCount), 0), + maxSimultaneousOwnerCount: events.reduce((max, event) => Math.max(max, event.ownerCount), 0), + concurrentLoadingSampleCount: events.filter((event) => event.loadingCount > 1).length, + ownerCount: owners.length, + segmentCount: segments.length, + overFiveSecondSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > 5).length, + overBudgetSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > alertThresholds.visibleLoadingSlowMs / 1000).length, + budgetSeconds: alertThresholds.visibleLoadingSlowMs / 1000, + longestContinuousSeconds: segments.length > 0 ? Number(segments[0].durationSeconds ?? 0) : 0, + currentContinuousSeconds: currentSegment ? Number(currentSegment.durationSeconds ?? 0) : 0, + continuityThresholdMs, + latestLoadingCount: latest?.loadingCount ?? 0, + latestOwnerCount: latest?.ownerCount ?? 0, + valuesRedacted: true + }, + segments: segments.slice(0, 80), + owners: owners.slice(0, 80), + timeline: timelineRows, + valuesRedacted: true + }; +} + +function loadingContinuityThresholdMs(events) { + const deltas = []; + for (let index = 1; index < events.length; index += 1) { + const delta = events[index].tsMs - events[index - 1].tsMs; + if (Number.isFinite(delta) && delta > 0) deltas.push(delta); + } + if (deltas.length === 0) return 5000; + const sorted = deltas.slice().sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + return Math.min(15000, Math.max(1500, Math.round(median * 2.5))); +} + +function buildLoadingSegments(events, continuityThresholdMs, countForEvent, ownersForEvent) { + const segments = []; + let segment = null; + let previousTsMs = null; + for (const event of events) { + const count = Number(countForEvent(event) ?? 0); + const gapOk = previousTsMs === null || !Number.isFinite(event.tsMs) || event.tsMs - previousTsMs <= continuityThresholdMs; + if (count > 0) { + if (!segment || !gapOk) { + if (segment) segments.push(finalizeLoadingSegment(segment, null)); + segment = { + firstAt: event.ts, + lastAt: event.ts, + firstSeq: event.seq, + lastSeq: event.seq, + promptIndexes: new Set(), + ownerKeys: new Set(), + ownerLabels: new Map(), + sampleCount: 0, + maxCount: 0, + ongoing: true + }; + } + segment.lastAt = event.ts; + segment.lastSeq = event.seq; + segment.sampleCount += 1; + segment.maxCount = Math.max(segment.maxCount, count); + if (Number.isFinite(Number(event.promptIndex))) segment.promptIndexes.add(Number(event.promptIndex)); + for (const owner of ownersForEvent(event) || []) { + if (!owner?.ownerKey) continue; + segment.ownerKeys.add(owner.ownerKey); + if (!segment.ownerLabels.has(owner.ownerKey)) segment.ownerLabels.set(owner.ownerKey, { ownerKey: owner.ownerKey, ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: 0 }); + const label = segment.ownerLabels.get(owner.ownerKey); + label.count += owner.count ?? 1; + } + } else if (segment) { + segment.ongoing = false; + segment.endedAt = event.ts; + segment.endSeq = event.seq; + segments.push(finalizeLoadingSegment(segment, event)); + segment = null; + } + previousTsMs = event.tsMs; + } + if (segment) segments.push(finalizeLoadingSegment(segment, null)); + return segments; +} + +function finalizeLoadingSegment(segment, absentEvent) { + const startMs = Date.parse(segment.firstAt || ""); + const lastMs = Date.parse(segment.lastAt || ""); + const absentMs = Date.parse(absentEvent?.ts || ""); + const durationSeconds = Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs >= startMs ? Number(((lastMs - startMs) / 1000).toFixed(3)) : 0; + const upperBoundSeconds = Number.isFinite(startMs) && Number.isFinite(absentMs) && absentMs >= startMs ? Number(((absentMs - startMs) / 1000).toFixed(3)) : durationSeconds; + const endedGapSeconds = Number.isFinite(lastMs) && Number.isFinite(absentMs) && absentMs >= lastMs ? Number(((absentMs - lastMs) / 1000).toFixed(3)) : null; + return { + firstAt: segment.firstAt, + lastAt: segment.lastAt, + endedAt: absentEvent?.ts ?? null, + firstSeq: segment.firstSeq, + lastSeq: segment.lastSeq, + endSeq: absentEvent?.seq ?? null, + durationSeconds, + upperBoundSeconds, + endedGapSeconds, + sampleCount: segment.sampleCount, + maxCount: segment.maxCount, + ownerCount: segment.ownerKeys.size, + owners: Array.from(segment.ownerLabels.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))).slice(0, 12), + promptIndexes: Array.from(segment.promptIndexes).sort((a, b) => a - b), + ongoing: absentEvent ? false : segment.ongoing === true, + valuesRedacted: true + }; +} + +function boundedTurnTimingRowsForReport(rows) { + const sourceRows = Array.isArray(rows) ? rows : []; + const maxRows = 1200; + const headRows = 120; + if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; + const tailRows = Math.max(0, maxRows - headRows); + return { + rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], + disclosure: { + truncated: true, + totalRows: sourceRows.length, + includedRows: maxRows, + omittedRows: Math.max(0, sourceRows.length - maxRows), + headRows, + tailRows, + policy: "report-bounded-head-tail; full anomaly counters are computed before truncation", + valuesRedacted: true + } + }; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-session-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-session-source.ts new file mode 100644 index 00000000..7faded76 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-session-source.ts @@ -0,0 +1,433 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer Workbench session invariant and controlled-navigation root-cause source fragment. + +export function nodeWebObserveAnalyzerSessionSource(): string { + return String.raw`function buildSessionInvariantFindings(control, manifest = {}) { + const findings = []; + for (const row of control || []) { + if (row?.type !== "assertSessionInvariant" || row?.phase !== "completed") continue; + const detail = objectValue(row.detail); + const messageOrder = objectValue(detail.messageOrder); + if (messageOrder.userClustered !== true) continue; + const afterRound = numberOrNull(detail.afterRound ?? row.input?.afterRound); + const consecutiveUserMessageCount = numberOrNull(messageOrder.consecutiveUserMessageCount) ?? 0; + const sentinelRange = stringOrNull(messageOrder.sentinelRange) ?? stringOrNull(detail.expectedSentinelRange); + const traceIds = arrayStrings(messageOrder.traceIds).slice(0, 12); + const findingId = stringOrNull(detail.findingId) ?? "workbench-message-order-user-clustered-after-navigation"; + const severity = stringOrNull(detail.severity) ?? "amber"; + const rootCause = "session_message_role_clustered"; + const rootCauseStatus = "confirmed-from-controlled-refresh-dom;check-otel-session_messages_read-role-sequence"; + const rootCauseConfidence = "medium"; + const nextAction = "Use OTel session_messages_read/session detail for the same canarySessionId and traceIds. Compare roleSequencePrefix and adjacentSameRoleCount; if the read model is already clustered, fix Workbench projection/read-model timeline ordering before changing renderer code."; + findings.push({ + id: findingId, + severity, + summary: "message-order root cause visible: controlled refresh/switch-back afterRound=" + (afterRound ?? "-") + " left consecutive user message cards without interleaved assistant/code-agent terminal cards" + (sentinelRange ? " (" + sentinelRange + ")" : ""), + rootCause, + rootCauseStatus, + rootCauseConfidence, + nextAction, + count: Math.max(1, consecutiveUserMessageCount), + blocking: detail.blocking === true ? true : false, + afterRound, + canarySessionId: stringOrNull(detail.canarySessionId), + routeSessionId: stringOrNull(detail.routeSessionId), + activeSessionId: stringOrNull(detail.activeSessionId), + consecutiveUserMessageCount, + sentinelRange, + sampleSeq: numberOrNull(detail.sampleSeq), + traceIds, + pageRole: stringOrNull(detail.pageRole) ?? "control", + pageId: stringOrNull(detail.pageId), + observerId: stringOrNull(manifest.jobId), + stateDir: stringOrNull(manifest.stateDir), + commandId: stringOrNull(row.commandId), + commandTs: stringOrNull(row.ts), + evidence: { + afterRound, + consecutiveUserMessageCount, + sentinelRange, + sampleSeq: numberOrNull(detail.sampleSeq), + traceIds, + canarySessionId: stringOrNull(detail.canarySessionId), + routeSessionId: stringOrNull(detail.routeSessionId), + activeSessionId: stringOrNull(detail.activeSessionId), + valuesRedacted: true, + }, + messageOrder: { + sequence: Array.isArray(messageOrder.sequence) ? messageOrder.sequence.slice(-20) : [], + clusters: Array.isArray(messageOrder.clusters) ? messageOrder.clusters.slice(0, 8) : [], + valuesRedacted: true, + }, + valuesRedacted: true, + }); + } + return findings; +} + +function buildControlledNavigationRootCauseFindings(control, manifest = {}) { + const commands = []; + for (const row of control || []) { + if (row?.phase !== "completed") continue; + if (row?.type !== "refreshCurrentSession" && row?.type !== "switchAwayAndBack") continue; + const detail = objectValue(row.detail); + const navigation = objectValue(detail.navigation); + const readiness = objectValue(navigation.readiness); + const snapshot = objectValue(readiness.snapshot); + const pageProvenance = objectValue(navigation.pageProvenance); + const blankShell = snapshot.workbenchShellVisible === false + && snapshot.sessionRailPresent === false + && snapshot.commandInputPresent === false + && snapshot.bodyTextHash === "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const shellOrComposerMissing = snapshot.workbenchShellVisible === false + || snapshot.sessionRailPresent === false + || snapshot.commandInputPresent === false; + const degraded = navigation.degraded === true + || readiness.ok === false + || detail.routeOk === false + || blankShell + || (detail.activeOk === false && shellOrComposerMissing) + || (detail.composerReady === false && snapshot.commandInputPresent === false); + if (!degraded) continue; + const rootCause = stringOrNull(navigation.degradedReason) + ?? (readiness.ok === false ? stringOrNull(readiness.reason) : null) + ?? (detail.routeOk === false ? "route-session-not-hydrated" : null) + ?? (blankShell ? "workbench-blank-shell-after-navigation" : null) + ?? (detail.activeOk === false && shellOrComposerMissing ? "active-session-not-hydrated" : null) + ?? (detail.composerReady === false && snapshot.commandInputPresent === false ? "composer-not-ready" : null) + ?? "controlled-navigation-degraded"; + commands.push({ + commandId: stringOrNull(row.commandId), + type: stringOrNull(row.type), + commandTs: stringOrNull(row.ts), + afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound), + rootCause, + blocking: true, + canarySessionId: stringOrNull(detail.canarySessionId), + routeSessionId: stringOrNull(detail.routeSessionId), + activeSessionId: stringOrNull(detail.activeSessionId), + routeOk: detail.routeOk === true, + activeOk: detail.activeOk === true, + composerReady: detail.composerReady === true, + navigation: { + httpStatus: numberOrNull(navigation.httpStatus), + degraded: navigation.degraded === true, + degradedReason: stringOrNull(navigation.degradedReason), + beforePath: urlPath(navigation.beforeUrl), + afterPath: urlPath(navigation.afterUrl), + valuesRedacted: true, + }, + readiness: { + ok: readiness.ok === true, + reason: stringOrNull(readiness.reason), + durationMs: numberOrNull(readiness.durationMs), + path: stringOrNull(snapshot.path), + readyState: stringOrNull(snapshot.readyState), + workbenchShellVisible: snapshot.workbenchShellVisible === true, + sessionCreatePresent: snapshot.sessionCreatePresent === true, + sessionRailPresent: snapshot.sessionRailPresent === true, + commandInputPresent: snapshot.commandInputPresent === true, + activeTabPresent: snapshot.activeTabPresent === true, + loginVisible: snapshot.loginVisible === true, + blankShell, + bodyTextHash: stringOrNull(snapshot.bodyTextHash), + valuesRedacted: true, + }, + pageProvenance: { + pageLoadSeq: numberOrNull(pageProvenance.pageLoadSeq), + reason: stringOrNull(pageProvenance.reason), + observedAt: stringOrNull(pageProvenance.observedAt), + urlPath: stringOrNull(pageProvenance.urlPath), + documentReadyState: stringOrNull(pageProvenance.documentReadyState), + timeOrigin: numberOrNull(pageProvenance.timeOrigin), + httpStatus: numberOrNull(pageProvenance.httpStatus), + assetFingerprint: stringOrNull(pageProvenance.assetFingerprint), + scriptCount: numberOrNull(pageProvenance.scriptCount), + stylesheetCount: numberOrNull(pageProvenance.stylesheetCount), + scripts: arrayStrings(pageProvenance.scripts).slice(0, 8), + stylesheets: arrayStrings(pageProvenance.stylesheets).slice(0, 8), + valuesRedacted: true, + }, + observer: { + ok: detail.observer?.ok === true, + pageRole: stringOrNull(detail.observer?.pageRole), + pageId: stringOrNull(detail.observer?.pageId), + changed: detail.observer?.changed === true, + valuesRedacted: true, + }, + observerId: stringOrNull(manifest.jobId), + stateDir: stringOrNull(manifest.stateDir), + valuesRedacted: true, + }); + } + if (commands.length === 0) return []; + return [{ + id: "workbench-controlled-navigation-degraded-root-cause", + severity: "red", + summary: "controlled Workbench refresh/switch completed degraded; route may be correct but app shell, active session, or composer was not ready, so later Code Agent turns cannot continue", + count: commands.length, + blocking: true, + rootCauses: Array.from(new Set(commands.map((item) => item.rootCause))).slice(0, 12), + commands: commands.slice(0, 20), + next: "Investigate the first degraded command, then correlate browser requestfailed/static asset failures and Workbench hydration state before changing Code Agent/provider logic.", + valuesRedacted: true, + }]; +} + +function sessionInvariantNavigationWindows(control) { + const started = new Map(); + const windows = []; + for (const row of control || []) { + if (row?.type !== "switchAwayAndBack" && row?.type !== "refreshCurrentSession") continue; + const commandId = stringOrNull(row.commandId) ?? String(row.seq ?? ""); + if (row.phase === "started") { + started.set(commandId, row); + continue; + } + if (row.phase !== "completed") continue; + const detail = objectValue(row.detail); + const canarySessionId = stringOrNull(detail.canarySessionId); + const alternateSessionId = stringOrNull(detail.alternateSessionId); + const startRow = started.get(commandId); + const startMs = timestampMs(startRow?.ts ?? row.ts); + const endMs = timestampMs(row.ts); + if (!canarySessionId || !Number.isFinite(startMs) || !Number.isFinite(endMs)) continue; + windows.push({ + commandId, + afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound), + startMs: Math.max(0, startMs - 1000), + endMs: endMs + 5000, + startAt: new Date(startMs).toISOString(), + endAt: new Date(endMs).toISOString(), + canarySessionId, + alternateSessionId, + routeOk: detail.routeOk === true, + activeOk: detail.activeOk === true, + valuesRedacted: true, + }); + } + return windows; +} + +function sessionInvariantCanarySessionIds(control) { + const ids = new Set(); + for (const row of control || []) { + const detail = objectValue(row?.detail); + if (row?.type === "newSession" && row?.phase === "completed") { + const sessionId = stringOrNull(detail.sessionId) + ?? stringOrNull(detail.result?.sessionId) + ?? stringOrNull(detail.createSession?.createdSessionId); + if (sessionId) ids.add(sessionId); + } + const canarySessionId = stringOrNull(detail.canarySessionId); + if (canarySessionId) ids.add(canarySessionId); + } + return ids; +} + +function sessionChangeSamplesOutsideControlledNavigation(samples, key, windows) { + const canaryIds = new Set((windows || []).map((item) => item.canarySessionId).filter(Boolean)); + if (canaryIds.size === 0) return samples.filter((item) => item?.[key]); + return (samples || []).filter((sample) => { + const value = stringOrNull(sample?.[key]); + if (!value) return false; + if (canaryIds.has(value)) return false; + return !sampleInControlledNavigationWindow(sample, windows); + }); +} + +function sampleInControlledNavigationWindow(sample, windows) { + const ms = timestampMs(sample?.ts); + if (!Number.isFinite(ms)) return false; + return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs); +} + +function sampleRefInControlledNavigationSessionWindow(sample, windows) { + const ms = timestampMs(sample?.ts); + if (!Number.isFinite(ms)) return false; + if (!sampleRefMatchesControlledNavigationSession(sample, windows)) return false; + return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs); +} + +function sampleRefMatchesControlledNavigationSession(sample, windows) { + const routeSessionId = stringOrNull(sample?.routeSessionId); + const activeSessionId = stringOrNull(sample?.activeSessionId); + return (windows || []).some((window) => { + const expected = [window.canarySessionId, window.alternateSessionId].filter(Boolean); + return expected.some((sessionId) => sessionId === routeSessionId || sessionId === activeSessionId); + }); +} + +function isBlankHydrationProjectionSample(sample) { + if (!sample) return false; + const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0); + const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0); + return !stringOrNull(sample.activeSessionId) + && Number(messageCount) === 0 + && Number(traceRowCount) === 0; +} + +function controlledNavigationHydrationCrossPageDiff(row, windows, sampleBySeq) { + if (row?.diffKind !== "projection") return false; + if (!sampleRefMatchesControlledNavigationSession(row.control, windows) || !sampleRefMatchesControlledNavigationSession(row.observer, windows)) return false; + const control = sampleBySeq.get(Number(row?.control?.seq)); + const observer = sampleBySeq.get(Number(row?.observer?.seq)); + return isBlankHydrationProjectionSample(control) || isBlankHydrationProjectionSample(observer); +} + +function crossPageDiffHasWorkbenchAppShellNotReady(row, sampleBySeq) { + const control = sampleBySeq.get(Number(row?.control?.seq)); + const observer = sampleBySeq.get(Number(row?.observer?.seq)); + return workbenchSampleAppShellNotReady(control) || workbenchSampleAppShellNotReady(observer); +} + +function workbenchSampleAppShellNotReady(sample) { + if (!sample || !isWorkbenchPathSample(sample)) return false; + const routeSessionId = stringOrNull(sample.routeSessionId) || workbenchSessionIdFromPath(samplePathname(sample)); + if (!routeSessionId) return false; + const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0); + const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0); + const turnCount = Array.isArray(sample.turns) ? sample.turns.length : Number(sample.turnCount ?? 0); + const loadingCount = Array.isArray(sample.loadings) ? sample.loadings.length : 0; + const diagnosticCount = Array.isArray(sample.diagnostics) ? sample.diagnostics.length : 0; + const railVisibleCount = Number(sample?.sessionRail?.visibleCount ?? 0); + const composer = objectValue(sample.composer); + const composerPresent = composer.inputPresent === true || composer.submitPresent === true; + const provenance = objectValue(sample.pageProvenance); + const hasWorkbenchAssets = Number(provenance.scriptCount ?? 0) > 0 || Number(provenance.stylesheetCount ?? 0) > 0; + return !stringOrNull(sample.activeSessionId) + && Number(messageCount) === 0 + && Number(traceRowCount) === 0 + && Number(turnCount) === 0 + && Number(loadingCount) === 0 + && Number(diagnosticCount) === 0 + && Number(railVisibleCount) === 0 + && !composerPresent + && hasWorkbenchAssets; +} + +function detectWorkbenchAppShellNotReady(samples) { + const rows = (Array.isArray(samples) ? samples : []) + .filter(workbenchSampleAppShellNotReady) + .map((sample) => workbenchAppShellNotReadyRef(sample)); + return annotateWorkbenchAppShellNotReadyTiming(rows); +} + +function annotateWorkbenchAppShellNotReadyTiming(rows) { + const groups = new Map(); + const splitGapMs = Math.max(1000, Number(alertThresholds.crossPageProjectionDivergenceRedMs || alertThresholds.visibleLoadingSlowMs || 10_000)); + for (const row of rows.slice().sort((a, b) => timestampMs(a.ts) - timestampMs(b.ts))) { + const ms = timestampMs(row.ts); + const key = [row.pageRole || "control", row.pageId || "default", row.routeSessionId || row.url || ""].join(":"); + const group = groups.get(key) || []; + let segment = group.at(-1); + if (!segment || !Number.isFinite(ms) || !Number.isFinite(segment.lastMs) || ms - segment.lastMs > splitGapMs) { + segment = { rows: [], firstMs: Number.isFinite(ms) ? ms : null, lastMs: Number.isFinite(ms) ? ms : null }; + group.push(segment); + } + segment.rows.push(row); + if (Number.isFinite(ms)) { + if (segment.firstMs === null || ms < segment.firstMs) segment.firstMs = ms; + if (segment.lastMs === null || ms > segment.lastMs) segment.lastMs = ms; + } + groups.set(key, group); + } + const result = []; + for (const group of groups.values()) { + for (let segmentIndex = 0; segmentIndex < group.length; segmentIndex += 1) { + const segment = group[segmentIndex]; + const observedSpanMs = segment.firstMs === null || segment.lastMs === null ? null : segment.lastMs - segment.firstMs; + for (const row of segment.rows) { + result.push({ + ...row, + segmentIndex, + observedFirstAt: segment.firstMs === null ? null : new Date(segment.firstMs).toISOString(), + observedLastAt: segment.lastMs === null ? null : new Date(segment.lastMs).toISOString(), + observedSpanMs, + }); + } + } + } + return result; +} + +function workbenchAppShellNotReadyRef(sample) { + const provenance = objectValue(sample?.pageProvenance); + const performance = workbenchAssetPerformanceSummary(sample); + return { + ...ref(sample), + title: stringOrNull(sample?.title), + messageCount: Array.isArray(sample?.messages) ? sample.messages.length : 0, + turnCount: Array.isArray(sample?.turns) ? sample.turns.length : 0, + traceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : 0, + sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0), + composerInputPresent: sample?.composer?.inputPresent === true, + composerSubmitPresent: sample?.composer?.submitPresent === true, + pageProvenance: { + documentReadyState: stringOrNull(provenance.documentReadyState), + pageLoadSeq: numberOrNull(provenance.pageLoadSeq), + timeOrigin: numberOrNull(provenance.timeOrigin), + assetFingerprint: stringOrNull(provenance.assetFingerprint), + scriptCount: numberOrNull(provenance.scriptCount), + stylesheetCount: numberOrNull(provenance.stylesheetCount), + scripts: arrayStrings(provenance.scripts).slice(0, 8), + stylesheets: arrayStrings(provenance.stylesheets).slice(0, 8), + valuesRedacted: true + }, + assetPerformance: performance, + valuesRedacted: true + }; +} + +function workbenchAssetPerformanceSummary(sample) { + const assets = (Array.isArray(sample?.performance) ? sample.performance : []) + .filter((entry) => /^(script|link|css)$/iu.test(String(entry?.initiatorType || "")) || /\.(?:js|css)$/iu.test(String(entry?.name || ""))) + .map((entry) => ({ + path: urlPath(entry?.name), + initiatorType: entry?.initiatorType ?? null, + duration: numberOrNull(entry?.duration), + responseStatus: numberOrNull(entry?.responseStatus), + transferSize: numberOrNull(entry?.transferSize), + encodedBodySize: numberOrNull(entry?.encodedBodySize), + decodedBodySize: numberOrNull(entry?.decodedBodySize), + nextHopProtocol: entry?.nextHopProtocol ?? null, + valuesRedacted: true + })); + const responseStatusCounts = {}; + for (const item of assets) { + const key = item.responseStatus === null ? "unknown" : String(item.responseStatus); + responseStatusCounts[key] = (responseStatusCounts[key] || 0) + 1; + } + return { + assetCount: assets.length, + zeroStatusCount: assets.filter((item) => item.responseStatus === 0).length, + missingStatusCount: assets.filter((item) => item.responseStatus === null).length, + responseStatusCounts, + assets: assets.slice(0, 12), + valuesRedacted: true + }; +} + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function stringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrNull(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function arrayStrings(value) { + return Array.isArray(value) ? value.map((item) => String(item || "")).filter(Boolean) : []; +} + +function timestampMs(value) { + const parsed = Date.parse(String(value || "")); + return Number.isFinite(parsed) ? parsed : NaN; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 26df3165..3538ed63 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -1,6 +1,17 @@ // SPEC: PJ2026-01040111 long-running Workbench observation. // Responsibility: Source string for the offline web-probe observe analyzer. import { nodeWebObserveAnalyzerTimingSource } from "./hwlab-node-web-observe-analyzer-timing-source"; +import { nodeWebObserveAnalyzerPerformanceSource } from "./hwlab-node-web-observe-analyzer-performance-source"; +import { nodeWebObserveAnalyzerIoSource } from "./hwlab-node-web-observe-analyzer-io-source"; +import { nodeWebObserveAnalyzerProjectSource } from "./hwlab-node-web-observe-analyzer-project-source"; +import { nodeWebObserveAnalyzerSessionSource } from "./hwlab-node-web-observe-analyzer-session-source"; +import { nodeWebObserveAnalyzerApiDomLagSource } from "./hwlab-node-web-observe-analyzer-api-dom-lag-source"; +import { nodeWebObserveAnalyzerWorkbenchTriadSource } from "./hwlab-node-web-observe-analyzer-workbench-triad-source"; +import { nodeWebObserveAnalyzerFindingsSource } from "./hwlab-node-web-observe-analyzer-findings-source"; +import { nodeWebObserveAnalyzerBrowserProcessSource } from "./hwlab-node-web-observe-analyzer-browser-process-source"; +import { nodeWebObserveAnalyzerWindowPageSource } from "./hwlab-node-web-observe-analyzer-window-page-source"; +import { nodeWebObserveAnalyzerRequestRuntimeSource } from "./hwlab-node-web-observe-analyzer-request-runtime-source"; +import { nodeWebObserveAnalyzerSampleMetricsSource } from "./hwlab-node-web-observe-analyzer-sample-metrics-source"; export function nodeWebObserveAnalyzerSource(): string { return String.raw`#!/usr/bin/env node @@ -45,6 +56,7 @@ const consoleEvents = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl( const errors = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("errors.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus); const artifacts = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("artifacts.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus); const browserProcessRows = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("browser-process.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus); +const performanceRows = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("performance-events.jsonl"), { tail: relatedJsonlTailLimit }), sampleWindow), analysisFocus); const manifest = await readJson(path.join(stateDir, "manifest.json")); const heartbeat = await readJson(path.join(stateDir, "heartbeat.json")); const commandState = await readCommandState(stateDir); @@ -59,6 +71,7 @@ const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); const apiDomLag = buildApiDomLagReport(samples, network); const browserProcess = buildBrowserProcessReport(browserProcessRows); +const frontendPerformance = buildFrontendPerformanceReport(performanceRows, artifacts); const projectManagement = buildProjectManagementReport(samples, control, network, pagePerformance, projectManagementConfig); const runnerErrors = errors.slice(-8).map((item) => { const details = item.error?.details && typeof item.error.details === "object" ? item.error.details : {}; @@ -109,9 +122,9 @@ const runnerErrors = errors.slice(-8).map((item) => { }); const commandFailures = summarizeCommandFailures(control); const toolFindings = buildToolFindings({ manifest, heartbeat, commandState }); -const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess)]; +const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess, frontendPerformance)]; 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, browserProcessRows, manifest }); +const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, 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 })); const report = { ok: findings.filter((item) => item.severity === "red").length === 0, @@ -123,7 +136,7 @@ const report = { browserFreezePolicy, manifest: compactManifest(manifest), heartbeat: compactHeartbeat(heartbeat), - counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length, browserProcess: browserProcessRows.length }, + counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length, browserProcess: browserProcessRows.length, performance: performanceRows.length }, commandTimeline, transitions, sampleMetrics, @@ -135,6 +148,7 @@ const report = { runtimeAlerts, apiDomLag, browserProcess, + frontendPerformance, runnerErrors, commandFailures, commandState, @@ -166,6 +180,7 @@ console.log(JSON.stringify({ runtimeAlerts: runtimeAlerts.summary, apiDomLag: apiDomLag.summary, browserProcess: browserProcess.summary, + frontendPerformance: frontendPerformance.summary, findingCount: findings.length, redFindingCount: findings.filter((item) => item.severity === "red").length, redFindings: prioritizeFindings(findings.filter((item) => item.severity === "red")).slice(0, 12).map((item) => ({ kind: item.id ?? item.kind ?? item.code, severity: item.severity, count: item.count ?? item.sampleCount ?? null, summary: String(item.summary ?? item.message ?? "").slice(0, 180) })), @@ -189,6 +204,13 @@ console.log(JSON.stringify({ runtimeAlerts: recentWindow.runtimeAlerts.summary, apiDomLag: compactApiDomLagForOutput(apiDomLag), browserProcess: recentWindow.browserProcess.summary, + frontendPerformance: recentWindow.frontendPerformance.summary, + frontendPerformanceHotspots: { + scripts: recentWindow.frontendPerformance.scriptHotspots.slice(0, 8), + profileFunctions: recentWindow.frontendPerformance.profileHotspots.slice(0, 8), + profileStacks: recentWindow.frontendPerformance.profileStacks.slice(0, 5), + valuesRedacted: true, + }, runnerErrors, commandFailures: commandFailures.slice(-8), commandState, @@ -400,5383 +422,25 @@ console.log(JSON.stringify({ valuesRedacted: true, })); -async function readJson(file) { - try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } -} +${nodeWebObserveAnalyzerIoSource()} -async function readCommandState(rootDir) { - const buckets = {}; - for (const bucket of ["pending", "processing", "done", "failed", "abandoned"]) { - buckets[bucket] = await readCommandBucket(path.join(rootDir, "commands", bucket), bucket); - } - return { - pendingCount: buckets.pending.count, - processingCount: buckets.processing.count, - doneCount: buckets.done.count, - failedCount: buckets.failed.count, - abandonedCount: buckets.abandoned.count, - pending: buckets.pending.items, - processing: buckets.processing.items, - failed: buckets.failed.items.slice(0, 12), - abandoned: buckets.abandoned.items.slice(0, 20), - summary: { - backlogCount: buckets.pending.count + buckets.processing.count, - oldestPendingAgeSeconds: buckets.pending.oldestAgeSeconds, - oldestProcessingAgeSeconds: buckets.processing.oldestAgeSeconds, - valuesRedacted: true - }, - valuesRedacted: true - }; -} +${nodeWebObserveAnalyzerProjectSource()} -async function readCommandBucket(dir, bucket) { - let names = []; - try { - names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort(); - } catch (error) { - if (!error || error.code !== "ENOENT") jsonlReadIssues.push({ file: path.basename(dir), kind: "command-dir-read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); - return { bucket, count: 0, oldestAgeSeconds: null, items: [] }; - } - const items = []; - for (const name of names.slice(0, 200)) { - const file = path.join(dir, name); - const parsed = await readJson(file) || {}; - let meta = null; - try { meta = await stat(file); } catch {} - const timestamp = parsed.createdAt || parsed.abandonedAt || parsed.completedAt || parsed.failedAt || (meta ? meta.mtime.toISOString() : null); - const timestampMs = Date.parse(String(timestamp || "")); - items.push({ - bucket, - id: parsed.id || parsed.commandId || name.replace(/[.]json$/u, ""), - type: parsed.type || parsed.command?.type || null, - createdAt: parsed.createdAt || null, - completedAt: parsed.completedAt || null, - failedAt: parsed.failedAt || null, - abandonedAt: parsed.abandonedAt || null, - reason: parsed.reason || parsed.error?.message || null, - ageSeconds: Number.isFinite(timestampMs) ? Math.max(0, Math.round((Date.now() - timestampMs) / 1000)) : null, - mtime: meta ? meta.mtime.toISOString() : null, - valuesRedacted: true - }); - } - const ages = items.map((item) => item.ageSeconds).filter((value) => Number.isFinite(value)); - return { bucket, count: names.length, oldestAgeSeconds: ages.length > 0 ? Math.max(...ages) : null, items }; -} +${nodeWebObserveAnalyzerSessionSource()} -function buildToolFindings({ manifest, heartbeat, commandState }) { - const findings = []; - const diagnostics = heartbeatDiagnostics(manifest, heartbeat); - if (diagnostics.heartbeatStale) { - findings.push({ - id: "tool-runner-heartbeat-stale", - severity: "red", - summary: "web-probe observe runner heartbeat is stale; treat this as observer tooling failure, not Workbench behavior", - count: 1, - diagnostics, - valuesRedacted: true - }); - } - if ((commandState?.pendingCount ?? 0) > 0 || (commandState?.processingCount ?? 0) > 0) { - findings.push({ - id: "tool-pending-commands-unconsumed", - severity: "red", - summary: "web-probe observe has pending/processing control commands that were not consumed by the runner", - count: (commandState?.pendingCount ?? 0) + (commandState?.processingCount ?? 0), - pending: (commandState?.pending ?? []).slice(0, 12), - processing: (commandState?.processing ?? []).slice(0, 12), - valuesRedacted: true - }); - } - if ((commandState?.abandonedCount ?? 0) > 0) { - findings.push({ - id: "tool-commands-abandoned", - severity: "info", - summary: "web-probe observe force-stop abandoned queued commands; do not interpret these as Workbench command failures", - count: commandState.abandonedCount, - commands: (commandState.abandoned ?? []).slice(0, 20), - valuesRedacted: true - }); - } - if (heartbeat?.forceStop || manifest?.forceStop) { - findings.push({ - id: "tool-runner-force-stopped", - severity: "info", - summary: "web-probe observe runner was stopped by the CLI outside the command queue", - count: 1, - forceStop: heartbeat?.forceStop || manifest?.forceStop, - valuesRedacted: true - }); - } - return findings; -} +${nodeWebObserveAnalyzerApiDomLagSource()} -function heartbeatDiagnostics(manifest, heartbeat) { - const status = String(heartbeat?.status || manifest?.status || ""); - const terminal = /^(completed|failed|force-stopped|stopped|abandoned)$/u.test(status); - const sampleIntervalMs = Number(manifest?.sampling?.sampleIntervalMs) || 5000; - const staleAfterMs = Math.max(60000, sampleIntervalMs * 3); - const updatedAt = heartbeat?.updatedAt || heartbeat?.lastSampleAt || null; - const updatedMs = Date.parse(String(updatedAt || "")); - const ageSeconds = Number.isFinite(updatedMs) ? Math.max(0, Math.round((Date.now() - updatedMs) / 1000)) : null; - const heartbeatStale = !terminal && (!Number.isFinite(updatedMs) || Date.now() - updatedMs > staleAfterMs); - return { - status: status || null, - terminal, - updatedAt, - heartbeatAgeSeconds: ageSeconds, - heartbeatStale, - heartbeatStaleAfterSeconds: Math.round(staleAfterMs / 1000), - sampleSeq: heartbeat?.sampleSeq ?? null, - commandSeq: heartbeat?.commandSeq ?? null, - activeCommandId: heartbeat?.activeCommandId ?? null, - valuesRedacted: true - }; -} +${nodeWebObserveAnalyzerWorkbenchTriadSource()} -function safeArchivePrefix(value) { - const text = String(value || "").trim(); - if (!text) return ""; - if (!/^[A-Za-z0-9_.-]+$/u.test(text) || text.includes("..")) throw new Error("unsafe archive prefix: " + text); - return text; -} +${nodeWebObserveAnalyzerFindingsSource()} -function positiveNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} +${nodeWebObserveAnalyzerBrowserProcessSource()} -function requiredPositiveThreshold(raw, key) { - const parsed = Number(raw?.[key]); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds"); - } - return parsed; -} +${nodeWebObserveAnalyzerWindowPageSource()} -function parseAlertThresholds(value) { - if (!value) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane"); - } - const raw = (() => { - try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } - })(); - const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio"); - if (sessionRailFallbackRatio > 1) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1"); - } - return { - sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"), - partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"), - longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"), - visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"), - turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"), - turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"), - domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"), - domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"), - screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), - pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), - browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), - requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), - requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), - requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), - requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), - browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), - browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), - browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), - browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"), - playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"), - playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"), - cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"), - uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"), - scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"), - scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), - scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), - sessionRailFallbackRatio, - crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), - source: "yaml-env", - }; -} +${nodeWebObserveAnalyzerRequestRuntimeSource()} -function parseBrowserFreezePolicy(value) { - if (!value) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane"); - } - const raw = (() => { - try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } - })(); - const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy"); - const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy"); - const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy"); - const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy"); - return { - enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"), - blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"), - memory: { - totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"), - processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"), - growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"), - }, - responsiveness: { - latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"), - eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"), - }, - cdp: { - metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"), - }, - kill: { - enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"), - gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"), - forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"), - graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000), - pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000), - exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125), - }, - source: "yaml-env", - valuesRedacted: true, - }; -} - -function requiredPolicyRecord(raw, key, pathLabel) { - const value = raw?.[key]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyBoolean(raw, key, pathLabel) { - const value = raw?.[key]; - if (typeof value !== "boolean") { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyPositiveNumber(raw, key, pathLabel) { - const value = Number(raw?.[key]); - if (!Number.isFinite(value) || value <= 0) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) { - const value = Number(raw?.[key]); - if (!Number.isInteger(value) || value < min || value > max) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max); - } - return value; -} - -function requiredPolicySignal(raw, key, pathLabel, expected) { - const value = String(raw?.[key] || ""); - if (value !== expected) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected); - } - return 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); - const rows = []; - try { - const input = createReadStream(file, { encoding: "utf8" }); - const lines = createInterface({ input, crlfDelay: Infinity }); - let lineNo = 0; - for await (const rawLine of lines) { - lineNo += 1; - const line = String(rawLine || "").trim(); - if (!line) continue; - try { - const parsed = JSON.parse(line); - rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed); - } catch (error) { - const item = { parseError: true, lineNo, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) }; - rows.push(item); - if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, rawHash: item.rawHash, errorMessage: item.errorMessage }); - } - } - return rows; - } catch (error) { - if (error && error.code === "ENOENT") return []; - if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); - return []; - } -} - -async function readJsonlTail(file, limit, options = {}) { - try { - const lines = await readTailLines(file, limit); - const rows = []; - let lineNo = 0; - for (const rawLine of lines) { - lineNo += 1; - const line = String(rawLine || "").trim(); - if (!line) continue; - try { - const parsed = JSON.parse(line); - rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed); - } catch (error) { - const item = { parseError: true, lineNo, tail: true, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) }; - rows.push(item); - if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, tail: true, rawHash: item.rawHash, errorMessage: item.errorMessage }); - } - } - return rows; - } catch (error) { - if (error && error.code === "ENOENT") return []; - if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", tail: true, code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) }); - return []; - } -} - -async function readTailLines(file, limit) { - const info = await stat(file); - if (!info.size || limit <= 0) return []; - const chunkSize = 64 * 1024; - const maxBytes = Math.max(32 * 1024 * 1024, Math.min(256 * 1024 * 1024, limit * 512 * 1024)); - const chunks = []; - let position = info.size; - let readBytes = 0; - let newlineCount = 0; - while (position > 0 && newlineCount <= limit && readBytes < maxBytes) { - const readSize = Math.min(chunkSize, position, maxBytes - readBytes); - position -= readSize; - const chunk = await readFileSlice(file, position, readSize); - chunks.unshift(chunk); - readBytes += chunk.length; - for (let index = 0; index < chunk.length; index += 1) { - if (chunk[index] === 10) newlineCount += 1; - } - } - if (position > 0 && newlineCount <= limit && jsonlReadIssues.length < 50) { - jsonlReadIssues.push({ file: path.basename(file), kind: "tail-scan-truncated", limit, readBytes, fileBytes: info.size }); - } - const text = Buffer.concat(chunks).toString("utf8"); - let lines = text.split(/\r?\n/u); - if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); - if (position > 0 && lines.length > 0) lines.shift(); - return lines.slice(-limit); -} - -async function readFileSlice(file, start, length) { - const chunks = []; - await new Promise((resolve, reject) => { - const stream = createReadStream(file, { start, end: start + length - 1 }); - stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - stream.on("error", reject); - stream.on("end", resolve); - }); - return Buffer.concat(chunks); -} - -function sampleTimeWindow(samples, paddingMs) { - const times = samples - .map((item) => Date.parse(String(item && item.ts || ""))) - .filter((value) => Number.isFinite(value)); - if (times.length === 0) return { startMs: null, endMs: null, startAt: null, endAt: null, paddingMs }; - const startMs = Math.max(0, Math.min(...times) - Math.max(0, paddingMs || 0)); - const endMs = Math.max(...times) + Math.max(0, paddingMs || 0); - return { startMs, endMs, startAt: new Date(startMs).toISOString(), endAt: new Date(endMs).toISOString(), paddingMs }; -} - -function filterRowsByTimeWindow(rows, window) { - if (!window || !Number.isFinite(window.startMs) || !Number.isFinite(window.endMs)) return rows; - return rows.filter((item) => { - const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt); - const ms = Date.parse(String(value || "")); - if (!Number.isFinite(ms)) return true; - return ms >= window.startMs && ms <= window.endMs; - }); -} - -function analysisFocusFromControl(control) { - const completedNewSessions = (Array.isArray(control) ? control : []) - .filter((item) => item?.type === "newSession" && item?.phase === "completed" && Number.isFinite(Date.parse(String(item.ts || "")))) - .sort((a, b) => Date.parse(String(a.ts || "")) - Date.parse(String(b.ts || ""))); - const latest = completedNewSessions.at(-1) ?? null; - if (!latest) return { mode: "all", reason: "no-new-session-command", startAt: null, startMs: null, commandId: null, valuesRedacted: true }; - const startMs = Date.parse(String(latest.ts)); - return { - mode: "after-new-session", - reason: "latest-completed-new-session", - startAt: new Date(startMs).toISOString(), - startMs, - commandId: latest.commandId ?? null, - valuesRedacted: true - }; -} - -function applyAnalysisFocus(rows, focus, graceMs = 0) { - if (!focus || !Number.isFinite(focus.startMs)) return rows; - const minMs = focus.startMs - Math.max(0, Number(graceMs) || 0); - return (Array.isArray(rows) ? rows : []).filter((item) => { - const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt); - const ms = Date.parse(String(value || "")); - return Number.isFinite(ms) ? ms >= minMs : true; - }); -} - -function analysisControlWindow(sampleWindow, focus, graceMs = 0) { - if (!sampleWindow || !Number.isFinite(sampleWindow.endMs)) return sampleWindow; - if (!focus || !Number.isFinite(focus.startMs)) return sampleWindow; - const startMs = Math.max(0, Math.min(Number(sampleWindow.startMs ?? focus.startMs), focus.startMs - Math.max(0, Number(graceMs) || 0))); - return { - ...sampleWindow, - startMs, - startAt: new Date(startMs).toISOString() - }; -} - -function compactSampleForAnalysis(sample) { - if (!sample || typeof sample !== "object") return sample; - return { - seq: sample.seq ?? null, - ts: sample.ts ?? null, - reason: sample.reason ?? null, - sampleGroupSeq: sample.sampleGroupSeq ?? null, - pageId: sample.pageId ?? null, - pageRole: sample.pageRole ?? null, - commandId: sample.commandId ?? null, - observerInitiated: sample.observerInitiated ?? null, - url: sample.url ?? null, - path: sample.path ?? null, - title: sample.title ?? null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - messages: compactDomItems(sample.messages), - traceRows: compactDomItems(sample.traceRows), - loadings: compactLoadingItems(sample.loadings), - sessionRail: compactSessionRail(sample.sessionRail), - turns: compactDomItems(sample.turns), - diagnostics: compactDomItems(sample.diagnostics), - composer: compactComposer(sample.composer), - 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, - sourceSelectVisible: value.sourceSelectVisible === true, - fileSelectVisible: value.fileSelectVisible === true, - sourceConfigVisible: value.sourceConfigVisible === true, - taskEditorVisible: value.taskEditorVisible === true, - taskBodyVisible: value.taskBodyVisible === true, - taskBody: value.taskBody ?? null, - reportLinkCount: value.reportLinkCount ?? 0, - reportPreviewVisible: value.reportPreviewVisible === true, - reportPreview: value.reportPreview ?? null, - reportFullscreenVisible: value.reportFullscreenVisible === true, - newTaskDraftVisible: value.newTaskDraftVisible === true, - 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) : [], - paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({ - name: item?.name ?? null, - visible: item?.visible === true, - widthPx: item?.widthPx ?? null, - heightPx: item?.heightPx ?? null, - bottomGapPx: item?.bottomGapPx ?? null, - bottomGapRatio: item?.bottomGapRatio ?? null, - contentNodeCount: item?.contentNodeCount ?? null, - valuesRedacted: true - })) : [], - 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) => ({ - index: item?.index ?? null, - tag: item?.tag ?? null, - testId: item?.testId ?? null, - role: item?.role ?? null, - active: item?.active === true, - status: item?.status ?? null, - dataStatus: item?.dataStatus ?? null, - running: item?.running === true, - dataRunning: item?.dataRunning ?? null, - ariaBusy: item?.ariaBusy ?? null, - sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), - fallbackTitle: item?.fallbackTitle === true, - titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180), - titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")), - titleBytes: item?.titleBytes ?? null, - })) : []; - const fallbackItems = Array.isArray(value.fallbackItems) - ? value.fallbackItems.slice(0, 20).map((item) => ({ - index: item?.index ?? null, - active: item?.active === true, - sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), - titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180), - titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")), - })) - : items.filter((item) => item.fallbackTitle).slice(0, 20); - const visibleCount = Number(value.visibleCount ?? items.length); - const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length); - return { - visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length, - fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length, - fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0), - activeItem: items.find((item) => item.active) || null, - items, - fallbackItems, - valuesRedacted: true - }; -} - -function compactComposer(value) { - if (!value || typeof value !== "object") return null; - return { - inputPresent: value.inputPresent === true, - inputDisabled: value.inputDisabled === true, - warningPresent: value.warningPresent === true, - submitPresent: value.submitPresent === true, - submitDisabled: value.submitDisabled === true, - submitAction: value.submitAction ?? null, - activeStatus: value.activeStatus ?? null, - valuesRedacted: true - }; -} - -function compactLoadingItems(items) { - if (!Array.isArray(items)) return []; - return items.map((item) => { - if (!item || typeof item !== "object") return item; - const rawText = String(item.text ?? item.textPreview ?? ""); - return { - index: item.index ?? null, - tag: item.tag ?? null, - testId: item.testId ?? null, - role: item.role ?? null, - ownerKind: item.ownerKind ?? null, - ownerKey: item.ownerKey ?? null, - ownerLabel: item.ownerLabel ?? null, - owner: item.owner && typeof item.owner === "object" ? { - tag: item.owner.tag ?? null, - testId: item.owner.testId ?? null, - role: item.owner.role ?? null, - id: item.owner.id ?? null, - className: item.owner.className ?? null, - status: item.owner.status ?? null, - sessionId: item.owner.sessionId ?? null, - messageId: item.owner.messageId ?? null, - traceId: item.owner.traceId ?? null, - ariaLabel: item.owner.ariaLabel ?? null, - } : null, - text: limitText(rawText, 400), - textPreview: limitText(String(item.textPreview ?? rawText), 240), - textHash: item.textHash ?? sha256(rawText), - textBytes: item.textBytes ?? Buffer.byteLength(rawText), - ownerTextHash: item.ownerTextHash ?? null, - ownerTextPreview: item.ownerTextPreview ? limitText(item.ownerTextPreview, 240) : null, - }; - }); -} - -function compactDomItems(items) { - if (!Array.isArray(items)) return []; - return items.map(compactDomItem); -} - -function compactDomItem(item) { - if (!item || typeof item !== "object") return item; - const rawText = String(item.text ?? item.textPreview ?? ""); - const preview = String(item.textPreview ?? limitText(rawText, 240)); - const hasBodyTextPresent = Object.prototype.hasOwnProperty.call(item, "bodyTextPresent"); - const hasFinalResponsePresent = Object.prototype.hasOwnProperty.call(item, "finalResponsePresent"); - return { - index: item.index ?? null, - tag: item.tag ?? null, - testId: item.testId ?? null, - role: item.role ?? null, - dataRole: item.dataRole ?? null, - status: item.status ?? null, - sessionId: item.sessionId ?? null, - messageId: item.messageId ?? null, - traceId: item.traceId ?? null, - turnId: item.turnId ?? null, - projectedSeq: Number.isFinite(Number(item.projectedSeq)) ? Number(item.projectedSeq) : null, - sourceSeq: Number.isFinite(Number(item.sourceSeq)) ? Number(item.sourceSeq) : null, - eventSeq: Number.isFinite(Number(item.eventSeq)) ? Number(item.eventSeq) : null, - eventTimestamp: item.eventTimestamp ?? null, - eventTimeText: item.eventTimeText ?? null, - eventKind: item.eventKind ?? null, - durationText: item.durationText ?? null, - activityText: item.activityText ?? null, - bodyTextSource: item.bodyTextSource ?? null, - bodyTextCandidateCount: Number.isFinite(Number(item.bodyTextCandidateCount)) ? Number(item.bodyTextCandidateCount) : null, - bodyTextPresent: hasBodyTextPresent ? item.bodyTextPresent === true : null, - bodyTextPreview: item.bodyTextPreview ? limitText(item.bodyTextPreview, 600) : undefined, - bodyTextHash: item.bodyTextHash ?? null, - bodyTextBytes: Number.isFinite(Number(item.bodyTextBytes)) ? Number(item.bodyTextBytes) : null, - finalResponsePresent: hasFinalResponsePresent ? item.finalResponsePresent === true : null, - finalResponseTextSource: item.finalResponseTextSource ?? null, - finalResponseCandidateCount: Number.isFinite(Number(item.finalResponseCandidateCount)) ? Number(item.finalResponseCandidateCount) : null, - finalResponseTextPreview: item.finalResponseTextPreview ? limitText(item.finalResponseTextPreview, 600) : undefined, - finalResponseTextHash: item.finalResponseTextHash ?? null, - finalResponseTextBytes: Number.isFinite(Number(item.finalResponseTextBytes)) ? Number(item.finalResponseTextBytes) : null, - className: item.className ?? null, - diagnosticCode: item.diagnosticCode ?? null, - source: item.source ?? null, - sources: Array.isArray(item.sources) ? item.sources.slice(0, 8) : undefined, - text: limitText(rawText, 4000), - textPreview: limitText(preview, 600), - textHash: item.textHash ?? sha256(rawText), - textBytes: item.textBytes ?? Buffer.byteLength(rawText) - }; -} - -function compactPerformanceItems(items) { - if (!Array.isArray(items)) return []; - return items.map((item) => ({ - name: item?.name ?? null, - initiatorType: item?.initiatorType ?? null, - startTime: item?.startTime ?? null, - duration: item?.duration ?? null, - workerStart: item?.workerStart ?? null, - redirectStart: item?.redirectStart ?? null, - redirectEnd: item?.redirectEnd ?? null, - fetchStart: item?.fetchStart ?? null, - domainLookupStart: item?.domainLookupStart ?? null, - domainLookupEnd: item?.domainLookupEnd ?? null, - connectStart: item?.connectStart ?? null, - connectEnd: item?.connectEnd ?? null, - secureConnectionStart: item?.secureConnectionStart ?? null, - requestStart: item?.requestStart ?? null, - responseStart: item?.responseStart ?? null, - responseEnd: item?.responseEnd ?? null, - transferSize: item?.transferSize ?? null, - encodedBodySize: item?.encodedBodySize ?? null, - decodedBodySize: item?.decodedBodySize ?? null, - nextHopProtocol: item?.nextHopProtocol ?? null, - responseStatus: Number.isFinite(Number(item?.responseStatus)) ? Number(item.responseStatus) : null - })); -} - -function compactLoadingMetricsForOutput(value) { - if (!value || typeof value !== "object") return null; - return { - summary: value.summary ?? null, - longestSegments: Array.isArray(value.segments) ? value.segments.slice(0, 8) : [], - owners: Array.isArray(value.owners) ? value.owners.slice(0, 8) : [], - timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [], - valuesRedacted: true - }; -} - -function compactSessionRailTitleMetricsForOutput(value) { - if (!value || typeof value !== "object") return null; - return { - summary: value.summary ?? null, - samples: Array.isArray(value.samples) ? value.samples.slice(0, 12) : [], - examples: Array.isArray(value.examples) ? value.examples.slice(0, 12) : [], - timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [], - valuesRedacted: true - }; -} - -function compactWorkbenchTurnStateTriadForOutput(value) { - if (!value || typeof value !== "object") return null; - return { - summary: value.summary ?? null, - drilldown: value.drilldown ?? null, - invalidFullTriads: Array.isArray(value.invalidFullTriads) ? value.invalidFullTriads.slice(0, 8) : [], - cardFinalResponseMismatches: Array.isArray(value.cardFinalResponseMismatches) ? value.cardFinalResponseMismatches.slice(0, 8) : [], - collectorMissingRows: Array.isArray(value.collectorMissingRows) ? value.collectorMissingRows.slice(0, 8) : [], - valuesRedacted: true - }; -} - -function compactSamplePageProvenance(value) { - if (!value || typeof value !== "object") return null; - return { - pageLoadSeq: value.pageLoadSeq ?? null, - reason: value.reason ?? null, - observedAt: value.observedAt ?? null, - urlPath: value.urlPath ?? null, - documentReadyState: value.documentReadyState ?? null, - timeOrigin: value.timeOrigin ?? null, - httpStatus: value.httpStatus ?? null, - assetFingerprint: value.assetFingerprint ?? null, - scriptCount: value.scriptCount ?? null, - stylesheetCount: value.stylesheetCount ?? null, - metaCount: value.metaCount ?? null, - scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 20) : [], - stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 20) : [], - error: value.error ?? null, - valuesRedacted: true - }; -} - -function summarizeCommandFailures(control) { - return control.filter((item) => 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 : detail; - return { - ts: item.ts ?? null, - commandId: item.commandId ?? null, - type: item.type ?? item.input?.type ?? null, - source: item.source ?? null, - durationMs: detail.durationMs ?? null, - beforePath: urlPath(detail.beforeUrl || item.beforeUrl), - afterPath: urlPath(detail.afterUrl || item.afterUrl), - name: error?.name ?? null, - message: limitText(error?.message ?? detail?.message ?? "", 240), - failureKind: error?.failureKind ?? detail?.failureKind ?? null, - failureSampleOk: detail?.failureSample?.ok === true, - sampleSeq: detail?.failureSample?.sampleSeq ?? null, - valuesRedacted: true - }; - }); -} - -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" || item.type === "launchWorkbenchFromMdtodo"); - 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"); - const selectedFileLabelBadSamples = projectSamples.filter((sample) => sample.projectManagement?.selectedFileLabel && sample.projectManagement?.selectedFileLabelLooksDirect === false); - const suspiciousFileLabelSamples = projectSamples.filter((sample) => Number(sample.projectManagement?.fileOptionSuspiciousLabelCount ?? 0) > 0); - const bodyVisibleSamples = selectedTaskSamples.filter((sample) => sample.projectManagement?.taskBodyVisible === true && Number(sample.projectManagement?.taskBody?.textBytes ?? 0) > 0); - const reportLinkSamples = projectSamples.filter((sample) => Number(sample.projectManagement?.reportLinkCount ?? 0) > 0); - const reportPreviewSamples = projectSamples.filter((sample) => sample.projectManagement?.reportPreviewVisible === true && Number(sample.projectManagement?.reportPreview?.textBytes ?? 0) > 0); - const reportFullscreenSamples = projectSamples.filter((sample) => sample.projectManagement?.reportFullscreenVisible === true); - const hwpodBlockerSamples = projectManagementHwpodBlockerRows(projectSamples); - const projectionReportSamples = projectManagementProjectionReportRows(projectSamples); - const hwpodApiFailures = projectManagementHwpodApiFailureRows(projectApiFailures); - const paneGapRows = projectManagementPaneGapRows(projectSamples); - const severePaneGapSamples = paneGapRows.actionable; - const ignoredPaneGapSamples = paneGapRows.ignored; - const previewCommands = commandRows.filter((item) => item.type === "openMdtodoReportPreview" || item.type === "toggleMdtodoReportFullscreen"); - const launchNonEmpty = launchSuccess.filter((item) => item.chatObserved === true && (Number(item.workbenchMessageCount ?? 0) > 0 || Number(item.workbenchTraceRowCount ?? 0) > 0)); - const launchEmpty = launchSuccess.filter((item) => item.chatObserved !== true || (Number(item.workbenchMessageCount ?? 0) === 0 && Number(item.workbenchTraceRowCount ?? 0) === 0)); - const minMdtodoTaskCount = minNumber(mdtodoSamples.map((sample) => sample.projectManagement?.taskCount)); - const maxMdtodoTaskCount = maxNumber(mdtodoSamples.map((sample) => sample.projectManagement?.taskCount)); - 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, - latestSelectedFileLabelPreview: latestProject?.selectedFileLabel?.textPreview ?? null, - latestSelectedFileLabelLooksDirect: latestProject?.selectedFileLabelLooksDirect ?? null, - selectedFileLabelBadSampleCount: selectedFileLabelBadSamples.length, - fileOptionSuspiciousLabelSampleCount: suspiciousFileLabelSamples.length, - maxFileOptionSuspiciousLabelCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.fileOptionSuspiciousLabelCount)), - latestSelectedTaskStatus: latestProject?.selectedTaskStatus ?? null, - latestTaskStatusCounts, - selectedTaskBodyVisibleSamples: bodyVisibleSamples.length, - reportLinkVisibleSamples: reportLinkSamples.length, - maxReportLinkCount: maxNumber(projectSamples.map((sample) => sample.projectManagement?.reportLinkCount)), - reportPreviewVisibleSamples: reportPreviewSamples.length, - reportFullscreenVisibleSamples: reportFullscreenSamples.length, - hwpodBlockerSampleCount: hwpodBlockerSamples.length, - projectionReportSampleCount: projectionReportSamples.length, - hwpodApiFailureCount: hwpodApiFailures.length, - severePaneGapSampleCount: severePaneGapSamples.length, - ignoredPaneGapSampleCount: ignoredPaneGapSamples.length, - maxPaneBottomGapPx: maxNumber(severePaneGapSamples.map((item) => item.maxBottomGapPx)), - maxPaneBottomGapRatio: maxNumber(severePaneGapSamples.map((item) => item.maxBottomGapRatio)), - 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, - launchNonEmptyCount: launchNonEmpty.length, - launchEmptyCount: launchEmpty.length, - launchWithOtelTraceHeaderCount: launchSuccess.filter((item) => item.otelTraceId).length, - reportPreviewCommandCount: previewCommands.length, - mdtodoTaskCountMin: minMdtodoTaskCount, - mdtodoTaskCountMax: maxMdtodoTaskCount, - 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, - selectedFileLabelPreview: sample.projectManagement?.selectedFileLabel?.textPreview ?? null, - selectedFileLabelLooksDirect: sample.projectManagement?.selectedFileLabelLooksDirect ?? null, - fileOptionSuspiciousLabelCount: sample.projectManagement?.fileOptionSuspiciousLabelCount ?? 0, - selectedTaskStatus: sample.projectManagement?.selectedTaskStatus ?? null, - taskBodyVisible: sample.projectManagement?.taskBodyVisible === true, - taskBodyBytes: sample.projectManagement?.taskBody?.textBytes ?? 0, - reportLinkCount: sample.projectManagement?.reportLinkCount ?? 0, - reportPreviewVisible: sample.projectManagement?.reportPreviewVisible === true, - reportPreviewBytes: sample.projectManagement?.reportPreview?.textBytes ?? 0, - reportFullscreenVisible: sample.projectManagement?.reportFullscreenVisible === true, - paneGaps: Array.isArray(sample.projectManagement?.paneGaps) ? sample.projectManagement.paneGaps.slice(0, 4) : [], - 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), - hwpodBlockerSamples: hwpodBlockerSamples.slice(0, 40), - projectionReportSamples: projectionReportSamples.slice(0, 40), - hwpodApiFailures: hwpodApiFailures.slice(0, 40), - severePaneGapSamples: severePaneGapSamples.slice(0, 40), - ignoredPaneGapSamples: ignoredPaneGapSamples.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, - chatObserved: item?.chatObserved ?? null, - chatStatus: item?.chatStatus ?? null, - chatTraceId: item?.chatTraceId ?? null, - workbenchMessageCount: item?.workbenchMessageCount ?? null, - workbenchTraceRowCount: item?.workbenchTraceRowCount ?? 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 - }); - const compactSample = (item) => ({ - seq: item?.seq ?? null, - ts: item?.ts ?? null, - pageRole: item?.pageRole ?? null, - path: item?.path ?? null, - selectedTaskRefHash: item?.selectedTaskRefHash ?? null, - selectedFileLabelPreview: item?.selectedFileLabelPreview ?? null, - pageKind: item?.pageKind ?? null, - reason: item?.reason ?? null, - severePaneCount: item?.severePaneCount ?? null, - maxBottomGapPx: item?.maxBottomGapPx ?? null, - maxBottomGapRatio: item?.maxBottomGapRatio ?? null, - paneGaps: Array.isArray(item?.paneGaps) ? item.paneGaps.slice(0, 4) : undefined, - 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) : [], - hwpodBlockerSamples: Array.isArray(report.hwpodBlockerSamples) ? report.hwpodBlockerSamples.slice(0, 8).map(compactSample) : [], - projectionReportSamples: Array.isArray(report.projectionReportSamples) ? report.projectionReportSamples.slice(0, 8).map(compactSample) : [], - hwpodApiFailures: Array.isArray(report.hwpodApiFailures) ? report.hwpodApiFailures.slice(0, 8).map(compactApiGroup) : [], - severePaneGapSamples: Array.isArray(report.severePaneGapSamples) ? report.severePaneGapSamples.slice(0, 8).map(compactSample) : [], - ignoredPaneGapSamples: Array.isArray(report.ignoredPaneGapSamples) ? report.ignoredPaneGapSamples.slice(0, 8).map(compactSample) : [], - 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 || []); - const mdtodoCommandTypes = new Set(["gotoProjectMdtodo", "openMdtodoSourceConfig", "configureMdtodoHwpodSource", "probeMdtodoSource", "reindexMdtodoSource", "expandMdtodoTask", "openMdtodoReportPreview", "toggleMdtodoReportFullscreen", "editMdtodoTaskInline", "editMdtodoTaskTitle", "editMdtodoTaskBody", "toggleMdtodoTaskStatus", "addMdtodoRootTask", "addMdtodoSubTask", "continueMdtodoTask", "deleteMdtodoTask", "launchWorkbenchFromMdtodo"]); - return (control || []) - .filter((item) => allowed.has(item?.type) || mdtodoCommandTypes.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, - chatObserved: detail.chatObserved ?? error.details?.chatObserved ?? null, - chatStatus: detail.chatStatus ?? error.details?.chatStatus ?? null, - chatSessionId: detail.chatSessionId ?? error.details?.chatSessionId ?? null, - chatTraceId: detail.chatTraceId ?? error.details?.chatTraceId ?? null, - chatOtelTraceId: detail.chatOtelTraceId ?? error.details?.chatOtelTraceId ?? null, - workbenchMessageCount: detail.workbenchSnapshot?.messageCount ?? error.details?.workbenchSnapshot?.messageCount ?? null, - workbenchTraceRowCount: detail.workbenchSnapshot?.traceRowCount ?? error.details?.workbenchSnapshot?.traceRowCount ?? null, - workbenchComposerReady: detail.workbenchSnapshot?.composerReady ?? error.details?.workbenchSnapshot?.composerReady ?? 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 projectManagementDigestText(value) { - if (!value || typeof value !== "object") return ""; - return String(value.textPreview ?? value.preview ?? value.text ?? "").trim(); -} - -function projectManagementSampleRef(sample) { - return { - seq: sample?.seq ?? null, - ts: sample?.ts ?? null, - pageRole: sample?.pageRole ?? null, - path: sample?.path ?? null, - pageKind: sample?.projectManagement?.pageKind ?? null, - selectedTaskRefHash: sample?.projectManagement?.selectedTaskRef?.hash ?? null, - selectedFileLabelPreview: sample?.projectManagement?.selectedFileLabel?.textPreview ?? null, - valuesRedacted: true - }; -} - -function projectManagementHwpodBlockerRows(projectSamples) { - const pattern = /(?:no outbound WebSocket hwpod-node|HWLAB_HWPOD_NODE_OPS_URL|hwpod-node-ops contract)/iu; - const rows = []; - for (const sample of projectSamples || []) { - const blockers = Array.isArray(sample?.projectManagement?.blockers) ? sample.projectManagement.blockers : []; - const matched = blockers - .filter((item) => pattern.test(projectManagementDigestText(item))) - .map((item) => ({ - index: item?.index ?? null, - testId: item?.testId ?? null, - role: item?.role ?? null, - textHash: item?.textHash ?? null, - textPreview: item?.textPreview ?? null, - valuesRedacted: true - })); - if (matched.length > 0) rows.push({ ...projectManagementSampleRef(sample), blockers: matched.slice(0, 4), valuesRedacted: true }); - } - return rows; -} - -function projectManagementProjectionReportRows(projectSamples) { - const pattern = /(?:报告索引待刷新|projection-only|任务投影确认存在报告链接)/iu; - return (projectSamples || []) - .filter((sample) => pattern.test(projectManagementDigestText(sample?.projectManagement?.reportPreview))) - .map((sample) => ({ - ...projectManagementSampleRef(sample), - reportPreviewHash: sample?.projectManagement?.reportPreview?.textHash ?? null, - reportPreviewPreview: sample?.projectManagement?.reportPreview?.textPreview ?? null, - reportPreviewBytes: sample?.projectManagement?.reportPreview?.textBytes ?? null, - valuesRedacted: true - })); -} - -function projectManagementHwpodApiFailureRows(projectApiFailures) { - const pattern = /^\/v1\/project-management\/mdtodo\/(?:task-detail|report-preview)\b/u; - return (projectApiFailures || []) - .filter((item) => pattern.test(String(item?.path || "")) && Number(item?.status ?? 0) >= 500) - .map((item) => ({ - ts: item?.ts ?? null, - method: item?.method ?? null, - path: item?.path ?? null, - status: item?.status ?? null, - type: item?.type ?? null, - failureKind: item?.failureKind ?? null, - valuesRedacted: true - })); -} - -function projectManagementPaneGapRows(projectSamples) { - const actionable = []; - const ignored = []; - for (const sample of projectSamples || []) { - const paneGaps = Array.isArray(sample?.projectManagement?.paneGaps) ? sample.projectManagement.paneGaps : []; - const severeGaps = paneGaps - .filter((item) => item?.visible === true) - .filter((item) => { - const bottomGapPx = Number(item?.bottomGapPx ?? 0); - const bottomGapRatio = Number(item?.bottomGapRatio ?? 0); - const heightPx = Number(item?.heightPx ?? 0); - return heightPx >= 120 && bottomGapPx >= 180 && bottomGapRatio >= 0.28; - }) - .map((item) => ({ - name: item?.name ?? null, - widthPx: item?.widthPx ?? null, - heightPx: item?.heightPx ?? null, - bottomGapPx: item?.bottomGapPx ?? null, - bottomGapRatio: item?.bottomGapRatio ?? null, - contentNodeCount: item?.contentNodeCount ?? null, - valuesRedacted: true - })); - if (severeGaps.length === 0) continue; - const maxGapPx = maxNumber(severeGaps.map((item) => item.bottomGapPx)); - const maxGapRatio = maxNumber(severeGaps.map((item) => item.bottomGapRatio)); - const multiPane = severeGaps.length >= 2; - const singleExtreme = maxGapPx >= 240 && maxGapRatio >= 0.45; - if (!multiPane && !singleExtreme) continue; - const selectedTaskRefHash = sample?.projectManagement?.selectedTaskRef?.hash ?? null; - const isMdtodo = sample?.projectManagement?.pageKind === "project-management-mdtodo"; - const isInitialEmptyDetail = isMdtodo && !selectedTaskRefHash; - const row = { - ...projectManagementSampleRef(sample), - severePaneCount: severeGaps.length, - maxBottomGapPx: maxGapPx, - maxBottomGapRatio: maxGapRatio, - paneGaps: severeGaps.slice(0, 4), - valuesRedacted: true - }; - if (isInitialEmptyDetail) { - ignored.push({ - ...row, - reason: "mdtodo-initial-empty-detail-no-selected-task", - valuesRedacted: true - }); - continue; - } - actionable.push(row); - } - return { actionable, ignored, valuesRedacted: true }; -} - -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.selectedFileLabelBadSampleCount ?? 0) > 0 || summary.latestSelectedFileLabelLooksDirect === false) { - findings.push({ id: "mdtodo-file-label-not-filename", severity: "red", summary: "MDTODO file dropdown selected label is not a direct markdown filename", count: summary.selectedFileLabelBadSampleCount, latestSelectedFileLabelPreview: summary.latestSelectedFileLabelPreview, samples: report.samples.filter((item) => item.selectedFileLabelLooksDirect === false).slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.maxFileOptionSuspiciousLabelCount ?? 0) > 0) { - findings.push({ id: "mdtodo-nondirect-files-visible", severity: "red", summary: "MDTODO file dropdown includes non-direct or report-like markdown labels; docs/MDTODO discovery must be direct files only", count: summary.maxFileOptionSuspiciousLabelCount, samples: report.samples.filter((item) => Number(item.fileOptionSuspiciousLabelCount ?? 0) > 0).slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.selectedTaskSampleCount ?? 0) > 0 && Number(summary.selectedTaskBodyVisibleSamples ?? 0) === 0) { - findings.push({ id: "mdtodo-task-body-not-visible", severity: "red", summary: "selected MDTODO task was sampled but no rendered task body was visible", count: summary.selectedTaskSampleCount, samples: report.samples.filter((item) => item.selectedTaskRefHash).slice(-12), valuesRedacted: true }); - } - if (Number(summary.hwpodBlockerSampleCount ?? 0) > 0) { - findings.push({ id: "mdtodo-hwpod-node-disconnected", severity: "red", summary: "MDTODO surfaced the hwpod-node disconnected / HWLAB_HWPOD_NODE_OPS_URL fallback blocker", count: summary.hwpodBlockerSampleCount, samples: report.hwpodBlockerSamples.slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.projectionReportSampleCount ?? 0) > 0) { - findings.push({ id: "mdtodo-report-projection-only", severity: "red", summary: "MDTODO report preview is projection-only instead of opening the full markdown report from the HWPOD source", count: summary.projectionReportSampleCount, samples: report.projectionReportSamples.slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.maxReportLinkCount ?? 0) > 0 && Number(summary.reportPreviewVisibleSamples ?? 0) === 0) { - const severity = Number(summary.reportPreviewCommandCount ?? 0) > 0 ? "red" : "amber"; - findings.push({ id: "mdtodo-report-preview-missing", severity, summary: "MDTODO report links were visible but no markdown report preview was sampled", count: summary.maxReportLinkCount, previewCommandCount: summary.reportPreviewCommandCount, samples: report.samples.filter((item) => Number(item.reportLinkCount ?? 0) > 0).slice(-12), valuesRedacted: true }); - } - if (Number(summary.reportPreviewCommandCount ?? 0) > 0 && Number(summary.reportFullscreenVisibleSamples ?? 0) === 0 && report.commands.some((item) => item.type === "toggleMdtodoReportFullscreen" && item.phase === "completed")) { - findings.push({ id: "mdtodo-report-fullscreen-missing", severity: "red", summary: "toggleMdtodoReportFullscreen command completed but fullscreen report dialog was never sampled", count: summary.reportPreviewCommandCount, commands: report.commands.filter((item) => item.type === "toggleMdtodoReportFullscreen").slice(-8), valuesRedacted: true }); - } - if (Number(summary.launchEmptyCount ?? 0) > 0) { - findings.push({ id: "mdtodo-workbench-launch-empty", severity: "red", summary: "MDTODO Workbench launch created a session without observing agent chat or visible message/trace content", count: summary.launchEmptyCount, commands: report.launchCommands.filter((item) => item.chatObserved !== true || (Number(item.workbenchMessageCount ?? 0) === 0 && Number(item.workbenchTraceRowCount ?? 0) === 0)).slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.mdtodoTaskCountMin ?? 0) > 0 && Number(summary.mdtodoTaskCountMax ?? 0) > 0 && (Number(summary.mdtodoTaskCountMax) - Number(summary.mdtodoTaskCountMin) >= 10 || Number(summary.mdtodoTaskCountMax) / Math.max(1, Number(summary.mdtodoTaskCountMin)) >= 2)) { - findings.push({ id: "mdtodo-task-count-diverged", severity: "amber", summary: "MDTODO task count varied sharply during observation; compare control commands and observer samples for projection divergence", minTaskCount: summary.mdtodoTaskCountMin, maxTaskCount: summary.mdtodoTaskCountMax, samples: report.samples.slice(-20), valuesRedacted: true }); - } - if (Number(summary.severePaneGapSampleCount ?? 0) > 0) { - findings.push({ id: "mdtodo-pane-bottom-gap", severity: "red", summary: "MDTODO task tree, main detail, or report sidebar left large unused bottom gaps in actionable selected-task samples", count: summary.severePaneGapSampleCount, ignoredInitialEmptyDetailCount: summary.ignoredPaneGapSampleCount, maxBottomGapPx: summary.maxPaneBottomGapPx, maxBottomGapRatio: summary.maxPaneBottomGapRatio, samples: report.severePaneGapSamples.slice(0, 12), valuesRedacted: true }); - } - if (Number(summary.hwpodApiFailureCount ?? 0) > 0) { - findings.push({ id: "project-management-hwpod-api-failed", severity: "red", summary: "HWPOD-backed MDTODO task detail or report preview API returned a server error during natural page use", count: summary.hwpodApiFailureCount, failures: report.hwpodApiFailures.slice(0, 12), 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: "MDTODO Workbench launch 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 minNumber(values) { - const numeric = (values || []).map((value) => Number(value)).filter(Number.isFinite); - return numeric.length > 0 ? Math.min(...numeric) : 0; -} - -function buildSessionInvariantFindings(control, manifest = {}) { - const findings = []; - for (const row of control || []) { - if (row?.type !== "assertSessionInvariant" || row?.phase !== "completed") continue; - const detail = objectValue(row.detail); - const messageOrder = objectValue(detail.messageOrder); - if (messageOrder.userClustered !== true) continue; - const afterRound = numberOrNull(detail.afterRound ?? row.input?.afterRound); - const consecutiveUserMessageCount = numberOrNull(messageOrder.consecutiveUserMessageCount) ?? 0; - const sentinelRange = stringOrNull(messageOrder.sentinelRange) ?? stringOrNull(detail.expectedSentinelRange); - const traceIds = arrayStrings(messageOrder.traceIds).slice(0, 12); - const findingId = stringOrNull(detail.findingId) ?? "workbench-message-order-user-clustered-after-navigation"; - const severity = stringOrNull(detail.severity) ?? "amber"; - const rootCause = "session_message_role_clustered"; - const rootCauseStatus = "confirmed-from-controlled-refresh-dom;check-otel-session_messages_read-role-sequence"; - const rootCauseConfidence = "medium"; - const nextAction = "Use OTel session_messages_read/session detail for the same canarySessionId and traceIds. Compare roleSequencePrefix and adjacentSameRoleCount; if the read model is already clustered, fix Workbench projection/read-model timeline ordering before changing renderer code."; - findings.push({ - id: findingId, - severity, - summary: "message-order root cause visible: controlled refresh/switch-back afterRound=" + (afterRound ?? "-") + " left consecutive user message cards without interleaved assistant/code-agent terminal cards" + (sentinelRange ? " (" + sentinelRange + ")" : ""), - rootCause, - rootCauseStatus, - rootCauseConfidence, - nextAction, - count: Math.max(1, consecutiveUserMessageCount), - blocking: detail.blocking === true ? true : false, - afterRound, - canarySessionId: stringOrNull(detail.canarySessionId), - routeSessionId: stringOrNull(detail.routeSessionId), - activeSessionId: stringOrNull(detail.activeSessionId), - consecutiveUserMessageCount, - sentinelRange, - sampleSeq: numberOrNull(detail.sampleSeq), - traceIds, - pageRole: stringOrNull(detail.pageRole) ?? "control", - pageId: stringOrNull(detail.pageId), - observerId: stringOrNull(manifest.jobId), - stateDir: stringOrNull(manifest.stateDir), - commandId: stringOrNull(row.commandId), - commandTs: stringOrNull(row.ts), - evidence: { - afterRound, - consecutiveUserMessageCount, - sentinelRange, - sampleSeq: numberOrNull(detail.sampleSeq), - traceIds, - canarySessionId: stringOrNull(detail.canarySessionId), - routeSessionId: stringOrNull(detail.routeSessionId), - activeSessionId: stringOrNull(detail.activeSessionId), - valuesRedacted: true, - }, - messageOrder: { - sequence: Array.isArray(messageOrder.sequence) ? messageOrder.sequence.slice(-20) : [], - clusters: Array.isArray(messageOrder.clusters) ? messageOrder.clusters.slice(0, 8) : [], - valuesRedacted: true, - }, - valuesRedacted: true, - }); - } - return findings; -} - -function buildControlledNavigationRootCauseFindings(control, manifest = {}) { - const commands = []; - for (const row of control || []) { - if (row?.phase !== "completed") continue; - if (row?.type !== "refreshCurrentSession" && row?.type !== "switchAwayAndBack") continue; - const detail = objectValue(row.detail); - const navigation = objectValue(detail.navigation); - const readiness = objectValue(navigation.readiness); - const snapshot = objectValue(readiness.snapshot); - const pageProvenance = objectValue(navigation.pageProvenance); - const blankShell = snapshot.workbenchShellVisible === false - && snapshot.sessionRailPresent === false - && snapshot.commandInputPresent === false - && snapshot.bodyTextHash === "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - const shellOrComposerMissing = snapshot.workbenchShellVisible === false - || snapshot.sessionRailPresent === false - || snapshot.commandInputPresent === false; - const degraded = navigation.degraded === true - || readiness.ok === false - || detail.routeOk === false - || blankShell - || (detail.activeOk === false && shellOrComposerMissing) - || (detail.composerReady === false && snapshot.commandInputPresent === false); - if (!degraded) continue; - const rootCause = stringOrNull(navigation.degradedReason) - ?? (readiness.ok === false ? stringOrNull(readiness.reason) : null) - ?? (detail.routeOk === false ? "route-session-not-hydrated" : null) - ?? (blankShell ? "workbench-blank-shell-after-navigation" : null) - ?? (detail.activeOk === false && shellOrComposerMissing ? "active-session-not-hydrated" : null) - ?? (detail.composerReady === false && snapshot.commandInputPresent === false ? "composer-not-ready" : null) - ?? "controlled-navigation-degraded"; - commands.push({ - commandId: stringOrNull(row.commandId), - type: stringOrNull(row.type), - commandTs: stringOrNull(row.ts), - afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound), - rootCause, - blocking: true, - canarySessionId: stringOrNull(detail.canarySessionId), - routeSessionId: stringOrNull(detail.routeSessionId), - activeSessionId: stringOrNull(detail.activeSessionId), - routeOk: detail.routeOk === true, - activeOk: detail.activeOk === true, - composerReady: detail.composerReady === true, - navigation: { - httpStatus: numberOrNull(navigation.httpStatus), - degraded: navigation.degraded === true, - degradedReason: stringOrNull(navigation.degradedReason), - beforePath: urlPath(navigation.beforeUrl), - afterPath: urlPath(navigation.afterUrl), - valuesRedacted: true, - }, - readiness: { - ok: readiness.ok === true, - reason: stringOrNull(readiness.reason), - durationMs: numberOrNull(readiness.durationMs), - path: stringOrNull(snapshot.path), - readyState: stringOrNull(snapshot.readyState), - workbenchShellVisible: snapshot.workbenchShellVisible === true, - sessionCreatePresent: snapshot.sessionCreatePresent === true, - sessionRailPresent: snapshot.sessionRailPresent === true, - commandInputPresent: snapshot.commandInputPresent === true, - activeTabPresent: snapshot.activeTabPresent === true, - loginVisible: snapshot.loginVisible === true, - blankShell, - bodyTextHash: stringOrNull(snapshot.bodyTextHash), - valuesRedacted: true, - }, - pageProvenance: { - pageLoadSeq: numberOrNull(pageProvenance.pageLoadSeq), - reason: stringOrNull(pageProvenance.reason), - observedAt: stringOrNull(pageProvenance.observedAt), - urlPath: stringOrNull(pageProvenance.urlPath), - documentReadyState: stringOrNull(pageProvenance.documentReadyState), - timeOrigin: numberOrNull(pageProvenance.timeOrigin), - httpStatus: numberOrNull(pageProvenance.httpStatus), - assetFingerprint: stringOrNull(pageProvenance.assetFingerprint), - scriptCount: numberOrNull(pageProvenance.scriptCount), - stylesheetCount: numberOrNull(pageProvenance.stylesheetCount), - scripts: arrayStrings(pageProvenance.scripts).slice(0, 8), - stylesheets: arrayStrings(pageProvenance.stylesheets).slice(0, 8), - valuesRedacted: true, - }, - observer: { - ok: detail.observer?.ok === true, - pageRole: stringOrNull(detail.observer?.pageRole), - pageId: stringOrNull(detail.observer?.pageId), - changed: detail.observer?.changed === true, - valuesRedacted: true, - }, - observerId: stringOrNull(manifest.jobId), - stateDir: stringOrNull(manifest.stateDir), - valuesRedacted: true, - }); - } - if (commands.length === 0) return []; - return [{ - id: "workbench-controlled-navigation-degraded-root-cause", - severity: "red", - summary: "controlled Workbench refresh/switch completed degraded; route may be correct but app shell, active session, or composer was not ready, so later Code Agent turns cannot continue", - count: commands.length, - blocking: true, - rootCauses: Array.from(new Set(commands.map((item) => item.rootCause))).slice(0, 12), - commands: commands.slice(0, 20), - next: "Investigate the first degraded command, then correlate browser requestfailed/static asset failures and Workbench hydration state before changing Code Agent/provider logic.", - valuesRedacted: true, - }]; -} - -function sessionInvariantNavigationWindows(control) { - const started = new Map(); - const windows = []; - for (const row of control || []) { - if (row?.type !== "switchAwayAndBack" && row?.type !== "refreshCurrentSession") continue; - const commandId = stringOrNull(row.commandId) ?? String(row.seq ?? ""); - if (row.phase === "started") { - started.set(commandId, row); - continue; - } - if (row.phase !== "completed") continue; - const detail = objectValue(row.detail); - const canarySessionId = stringOrNull(detail.canarySessionId); - const alternateSessionId = stringOrNull(detail.alternateSessionId); - const startRow = started.get(commandId); - const startMs = timestampMs(startRow?.ts ?? row.ts); - const endMs = timestampMs(row.ts); - if (!canarySessionId || !Number.isFinite(startMs) || !Number.isFinite(endMs)) continue; - windows.push({ - commandId, - afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound), - startMs: Math.max(0, startMs - 1000), - endMs: endMs + 5000, - startAt: new Date(startMs).toISOString(), - endAt: new Date(endMs).toISOString(), - canarySessionId, - alternateSessionId, - routeOk: detail.routeOk === true, - activeOk: detail.activeOk === true, - valuesRedacted: true, - }); - } - return windows; -} - -function sessionInvariantCanarySessionIds(control) { - const ids = new Set(); - for (const row of control || []) { - const detail = objectValue(row?.detail); - if (row?.type === "newSession" && row?.phase === "completed") { - const sessionId = stringOrNull(detail.sessionId) - ?? stringOrNull(detail.result?.sessionId) - ?? stringOrNull(detail.createSession?.createdSessionId); - if (sessionId) ids.add(sessionId); - } - const canarySessionId = stringOrNull(detail.canarySessionId); - if (canarySessionId) ids.add(canarySessionId); - } - return ids; -} - -function sessionChangeSamplesOutsideControlledNavigation(samples, key, windows) { - const canaryIds = new Set((windows || []).map((item) => item.canarySessionId).filter(Boolean)); - if (canaryIds.size === 0) return samples.filter((item) => item?.[key]); - return (samples || []).filter((sample) => { - const value = stringOrNull(sample?.[key]); - if (!value) return false; - if (canaryIds.has(value)) return false; - return !sampleInControlledNavigationWindow(sample, windows); - }); -} - -function sampleInControlledNavigationWindow(sample, windows) { - const ms = timestampMs(sample?.ts); - if (!Number.isFinite(ms)) return false; - return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs); -} - -function sampleRefInControlledNavigationSessionWindow(sample, windows) { - const ms = timestampMs(sample?.ts); - if (!Number.isFinite(ms)) return false; - if (!sampleRefMatchesControlledNavigationSession(sample, windows)) return false; - return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs); -} - -function sampleRefMatchesControlledNavigationSession(sample, windows) { - const routeSessionId = stringOrNull(sample?.routeSessionId); - const activeSessionId = stringOrNull(sample?.activeSessionId); - return (windows || []).some((window) => { - const expected = [window.canarySessionId, window.alternateSessionId].filter(Boolean); - return expected.some((sessionId) => sessionId === routeSessionId || sessionId === activeSessionId); - }); -} - -function isBlankHydrationProjectionSample(sample) { - if (!sample) return false; - const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0); - const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0); - return !stringOrNull(sample.activeSessionId) - && Number(messageCount) === 0 - && Number(traceRowCount) === 0; -} - -function controlledNavigationHydrationCrossPageDiff(row, windows, sampleBySeq) { - if (row?.diffKind !== "projection") return false; - if (!sampleRefMatchesControlledNavigationSession(row.control, windows) || !sampleRefMatchesControlledNavigationSession(row.observer, windows)) return false; - const control = sampleBySeq.get(Number(row?.control?.seq)); - const observer = sampleBySeq.get(Number(row?.observer?.seq)); - return isBlankHydrationProjectionSample(control) || isBlankHydrationProjectionSample(observer); -} - -function crossPageDiffHasWorkbenchAppShellNotReady(row, sampleBySeq) { - const control = sampleBySeq.get(Number(row?.control?.seq)); - const observer = sampleBySeq.get(Number(row?.observer?.seq)); - return workbenchSampleAppShellNotReady(control) || workbenchSampleAppShellNotReady(observer); -} - -function workbenchSampleAppShellNotReady(sample) { - if (!sample || !isWorkbenchPathSample(sample)) return false; - const routeSessionId = stringOrNull(sample.routeSessionId) || workbenchSessionIdFromPath(samplePathname(sample)); - if (!routeSessionId) return false; - const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0); - const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0); - const turnCount = Array.isArray(sample.turns) ? sample.turns.length : Number(sample.turnCount ?? 0); - const loadingCount = Array.isArray(sample.loadings) ? sample.loadings.length : 0; - const diagnosticCount = Array.isArray(sample.diagnostics) ? sample.diagnostics.length : 0; - const railVisibleCount = Number(sample?.sessionRail?.visibleCount ?? 0); - const composer = objectValue(sample.composer); - const composerPresent = composer.inputPresent === true || composer.submitPresent === true; - const provenance = objectValue(sample.pageProvenance); - const hasWorkbenchAssets = Number(provenance.scriptCount ?? 0) > 0 || Number(provenance.stylesheetCount ?? 0) > 0; - return !stringOrNull(sample.activeSessionId) - && Number(messageCount) === 0 - && Number(traceRowCount) === 0 - && Number(turnCount) === 0 - && Number(loadingCount) === 0 - && Number(diagnosticCount) === 0 - && Number(railVisibleCount) === 0 - && !composerPresent - && hasWorkbenchAssets; -} - -function detectWorkbenchAppShellNotReady(samples) { - const rows = (Array.isArray(samples) ? samples : []) - .filter(workbenchSampleAppShellNotReady) - .map((sample) => workbenchAppShellNotReadyRef(sample)); - return annotateWorkbenchAppShellNotReadyTiming(rows); -} - -function annotateWorkbenchAppShellNotReadyTiming(rows) { - const groups = new Map(); - const splitGapMs = Math.max(1000, Number(alertThresholds.crossPageProjectionDivergenceRedMs || alertThresholds.visibleLoadingSlowMs || 10_000)); - for (const row of rows.slice().sort((a, b) => timestampMs(a.ts) - timestampMs(b.ts))) { - const ms = timestampMs(row.ts); - const key = [row.pageRole || "control", row.pageId || "default", row.routeSessionId || row.url || ""].join(":"); - const group = groups.get(key) || []; - let segment = group.at(-1); - if (!segment || !Number.isFinite(ms) || !Number.isFinite(segment.lastMs) || ms - segment.lastMs > splitGapMs) { - segment = { rows: [], firstMs: Number.isFinite(ms) ? ms : null, lastMs: Number.isFinite(ms) ? ms : null }; - group.push(segment); - } - segment.rows.push(row); - if (Number.isFinite(ms)) { - if (segment.firstMs === null || ms < segment.firstMs) segment.firstMs = ms; - if (segment.lastMs === null || ms > segment.lastMs) segment.lastMs = ms; - } - groups.set(key, group); - } - const result = []; - for (const group of groups.values()) { - for (let segmentIndex = 0; segmentIndex < group.length; segmentIndex += 1) { - const segment = group[segmentIndex]; - const observedSpanMs = segment.firstMs === null || segment.lastMs === null ? null : segment.lastMs - segment.firstMs; - for (const row of segment.rows) { - result.push({ - ...row, - segmentIndex, - observedFirstAt: segment.firstMs === null ? null : new Date(segment.firstMs).toISOString(), - observedLastAt: segment.lastMs === null ? null : new Date(segment.lastMs).toISOString(), - observedSpanMs, - }); - } - } - } - return result; -} - -function workbenchAppShellNotReadyRef(sample) { - const provenance = objectValue(sample?.pageProvenance); - const performance = workbenchAssetPerformanceSummary(sample); - return { - ...ref(sample), - title: stringOrNull(sample?.title), - messageCount: Array.isArray(sample?.messages) ? sample.messages.length : 0, - turnCount: Array.isArray(sample?.turns) ? sample.turns.length : 0, - traceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : 0, - sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0), - composerInputPresent: sample?.composer?.inputPresent === true, - composerSubmitPresent: sample?.composer?.submitPresent === true, - pageProvenance: { - documentReadyState: stringOrNull(provenance.documentReadyState), - pageLoadSeq: numberOrNull(provenance.pageLoadSeq), - timeOrigin: numberOrNull(provenance.timeOrigin), - assetFingerprint: stringOrNull(provenance.assetFingerprint), - scriptCount: numberOrNull(provenance.scriptCount), - stylesheetCount: numberOrNull(provenance.stylesheetCount), - scripts: arrayStrings(provenance.scripts).slice(0, 8), - stylesheets: arrayStrings(provenance.stylesheets).slice(0, 8), - valuesRedacted: true - }, - assetPerformance: performance, - valuesRedacted: true - }; -} - -function workbenchAssetPerformanceSummary(sample) { - const assets = (Array.isArray(sample?.performance) ? sample.performance : []) - .filter((entry) => /^(script|link|css)$/iu.test(String(entry?.initiatorType || "")) || /\.(?:js|css)$/iu.test(String(entry?.name || ""))) - .map((entry) => ({ - path: urlPath(entry?.name), - initiatorType: entry?.initiatorType ?? null, - duration: numberOrNull(entry?.duration), - responseStatus: numberOrNull(entry?.responseStatus), - transferSize: numberOrNull(entry?.transferSize), - encodedBodySize: numberOrNull(entry?.encodedBodySize), - decodedBodySize: numberOrNull(entry?.decodedBodySize), - nextHopProtocol: entry?.nextHopProtocol ?? null, - valuesRedacted: true - })); - const responseStatusCounts = {}; - for (const item of assets) { - const key = item.responseStatus === null ? "unknown" : String(item.responseStatus); - responseStatusCounts[key] = (responseStatusCounts[key] || 0) + 1; - } - return { - assetCount: assets.length, - zeroStatusCount: assets.filter((item) => item.responseStatus === 0).length, - missingStatusCount: assets.filter((item) => item.responseStatus === null).length, - responseStatusCounts, - assets: assets.slice(0, 12), - valuesRedacted: true - }; -} - -function objectValue(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; -} - -function stringOrNull(value) { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function numberOrNull(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function arrayStrings(value) { - return Array.isArray(value) ? value.map((item) => String(item || "")).filter(Boolean) : []; -} - -function timestampMs(value) { - const parsed = Date.parse(String(value || "")); - return Number.isFinite(parsed) ? parsed : NaN; -} - -function buildApiDomLagReport(samples, network) { - const windowMs = 30_000; - const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; - const sampleRows = (Array.isArray(samples) ? samples : []) - .map((sample) => { - const tsMs = timestampMs(sample?.ts); - return { - sample, - tsMs, - pageKey: samplePageKey(sample), - digest: digestSample(sample), - sessionIds: new Set([sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String)), - traceIds: sampleTraceIds(sample) - }; - }) - .filter((item) => Number.isFinite(item.tsMs)) - .sort((a, b) => a.tsMs - b.tsMs); - const samplesByPage = new Map(); - for (const row of sampleRows) { - const rows = samplesByPage.get(row.pageKey) || []; - rows.push(row); - samplesByPage.set(row.pageKey, rows); - } - const naturalApiResponses = (Array.isArray(network) ? network : []) - .filter((item) => item?.observerInitiated !== true && item?.type === "response" && isApiLikePath(urlPath(item?.url))); - const telemetryExcluded = []; - const nonStateRelevant = []; - const stateRelevantResponses = []; - for (const item of naturalApiResponses) { - const event = compactApiDomLagResponseEvent(item); - if (!Number.isFinite(event.tsMs)) { - nonStateRelevant.push(event); - continue; - } - if (isApiDomLagTelemetryPath(event.path)) telemetryExcluded.push(event); - else if (!isApiDomLagStateRelevantPath(event.path)) nonStateRelevant.push(event); - else stateRelevantResponses.push(event); - } - const candidates = []; - for (const event of stateRelevantResponses) { - const pageSamples = samplesByPage.get(event.pageKey) || []; - const before = lastSampleAtOrBefore(pageSamples, event.tsMs, event); - const firstAfter = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); - const baselineDigest = before?.digest ?? null; - const change = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => !baselineDigest || row.digest !== baselineDigest); - candidates.push({ - ...event, - windowMs, - budgetMs, - firstSampleDeltaMs: firstAfter ? Math.max(0, Math.round(firstAfter.tsMs - event.tsMs)) : null, - domChangeDeltaMs: change ? Math.max(0, Math.round(change.tsMs - event.tsMs)) : null, - overBudget: change ? (change.tsMs - event.tsMs) > budgetMs : false, - domChanged: Boolean(change), - noDomChangeWithinWindow: !change, - beforeSample: compactApiDomLagSample(before), - firstAfterSample: compactApiDomLagSample(firstAfter), - changeSample: compactApiDomLagSample(change), - confidence: apiDomLagConfidence(event.path), - valuesRedacted: true - }); - } - const changedDeltas = candidates.map((item) => nullableNumber(item.domChangeDeltaMs)).filter(Number.isFinite).sort((a, b) => a - b); - const groups = groupApiDomLagCandidates(candidates); - const overBudget = candidates.filter((item) => item.overBudget === true); - return { - summary: { - windowMs, - budgetMs, - naturalApiResponseCount: naturalApiResponses.length, - telemetryExcludedCount: telemetryExcluded.length, - nonStateRelevantResponseCount: nonStateRelevant.length, - stateRelevantResponseCount: stateRelevantResponses.length, - candidateCount: candidates.length, - domChangedCount: changedDeltas.length, - noDomChangeWithinWindowCount: candidates.filter((item) => item.noDomChangeWithinWindow === true).length, - lowConfidenceStreamOpenCount: candidates.filter((item) => item.confidence === "low-stream-open-only").length, - overBudgetCount: overBudget.length, - p50DomChangeDeltaMs: percentile(changedDeltas, 50), - p95DomChangeDeltaMs: percentile(changedDeltas, 95), - maxDomChangeDeltaMs: changedDeltas.length > 0 ? Math.max(...changedDeltas) : null, - groupCount: groups.length, - valuesRedacted: true - }, - groups, - worstCandidates: candidates - .filter((item) => Number.isFinite(nullableNumber(item.domChangeDeltaMs))) - .sort((a, b) => nullableNumber(b.domChangeDeltaMs) - nullableNumber(a.domChangeDeltaMs)) - .slice(0, 20), - recentCandidates: candidates.slice(-40), - telemetryExcluded: telemetryExcluded.slice(0, 20), - nonStateRelevant: nonStateRelevant.slice(0, 20), - valuesRedacted: true - }; -} - -function compactApiDomLagResponseEvent(item) { - const parsed = parseApiDomLagUrl(item?.url); - const tsMs = timestampMs(item?.ts); - return { - ts: item?.ts ?? null, - tsMs, - pageRole: item?.pageRole ?? null, - pageId: item?.pageId ?? null, - pageKey: String(item?.pageRole || "control") + ":" + String(item?.pageId || "default"), - commandId: item?.commandId ?? null, - method: String(item?.method || "GET").toUpperCase(), - status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, - path: parsed.path, - rawPath: parsed.rawPath, - queryKeys: parsed.queryKeys, - sessionId: parsed.sessionId, - traceId: parsed.traceId, - urlHash: item?.url ? sha256(item.url) : null, - routeKind: apiDomLagRouteKind(parsed.path), - valuesRedacted: true - }; -} - -function parseApiDomLagUrl(value) { - try { - const parsed = new URL(String(value || "http://invalid.local/")); - const rawPath = parsed.pathname || "-"; - const queryKeys = Array.from(parsed.searchParams.keys()).sort().slice(0, 12); - const sessionId = parsed.searchParams.get("sessionId") || parsed.searchParams.get("includeSessionId") || firstIdInText(parsed.pathname + " " + parsed.search, /\bses_[A-Za-z0-9_-]+\b/u); - const traceId = parsed.searchParams.get("traceId") || firstIdInText(parsed.pathname + " " + parsed.search, /\btrc_[A-Za-z0-9_-]+\b/u); - return { - rawPath, - path: normalizeApiPath(rawPath), - queryKeys, - sessionId, - traceId - }; - } catch { - const rawPath = urlPath(value); - return { - rawPath, - path: normalizeApiPath(rawPath), - queryKeys: [], - sessionId: firstIdInText(String(value || ""), /\bses_[A-Za-z0-9_-]+\b/u), - traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u) - }; - } -} - -function firstIdInText(text, pattern) { - const match = String(text || "").match(pattern); - return match ? match[0] : null; -} - -function nullableNumber(value) { - if (value === null || value === undefined || value === "") return NaN; - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : NaN; -} - -function isApiDomLagTelemetryPath(path) { - const value = String(path || ""); - return value === "/v1/web-performance" || value === "/v1/health" || value === "/health"; -} - -function isApiDomLagStateRelevantPath(path) { - const value = String(path || ""); - return value.startsWith("/auth/") || value.startsWith("/v1/workbench/") || value === "/v1/agent/chat" || value === "/v1/agent/chat/steer"; -} - -function apiDomLagRouteKind(path) { - const value = String(path || ""); - if (value === "/v1/workbench/events") return "workbench-events-stream"; - if (value.startsWith("/v1/workbench/sessions")) return "workbench-sessions"; - if (value.startsWith("/v1/workbench/traces")) return "workbench-traces"; - if (value.startsWith("/v1/workbench/turns")) return "workbench-turns"; - if (value === "/v1/agent/chat" || value === "/v1/agent/chat/steer") return "agent-chat-submit"; - if (value.startsWith("/auth/")) return "auth"; - return "state-api"; -} - -function apiDomLagConfidence(path) { - return String(path || "") === "/v1/workbench/events" ? "low-stream-open-only" : "medium-response-to-dom"; -} - -function sampleTraceIds(sample) { - const ids = new Set(); - for (const group of [sample?.messages, sample?.traceRows, sample?.turns, sample?.diagnostics]) { - if (!Array.isArray(group)) continue; - for (const item of group) if (item?.traceId) ids.add(String(item.traceId)); - } - return ids; -} - -function lastSampleAtOrBefore(rows, tsMs, event) { - let result = null; - for (const row of rows) { - if (row.tsMs > tsMs) break; - if (apiDomLagSampleMatchesEvent(row, event)) result = row; - } - return result; -} - -function firstSampleAfter(rows, startMs, endMs, event, predicate = null) { - for (const row of rows) { - if (row.tsMs < startMs) continue; - if (row.tsMs > endMs) break; - if (!apiDomLagSampleMatchesEvent(row, event)) continue; - if (typeof predicate === "function" && !predicate(row)) continue; - return row; - } - return null; -} - -function apiDomLagSampleMatchesEvent(row, event) { - if (!row || !event) return false; - if (event.sessionId && !row.sessionIds.has(String(event.sessionId))) return false; - if (event.traceId && row.traceIds.size > 0 && !row.traceIds.has(String(event.traceId))) return false; - return true; -} - -function compactApiDomLagSample(row) { - if (!row) return null; - const sample = row.sample || {}; - return { - seq: sample.seq ?? null, - ts: sample.ts ?? null, - pageRole: sample.pageRole ?? null, - pageId: sample.pageId ?? null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - messageCount: Array.isArray(sample.messages) ? sample.messages.length : null, - traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : null, - diagnosticCount: Array.isArray(sample.diagnostics) ? sample.diagnostics.length : null, - valuesRedacted: true - }; -} - -function groupApiDomLagCandidates(candidates) { - const groups = new Map(); - for (const item of candidates || []) { - const key = [item.method || "-", item.path || "-", item.status ?? "-", item.confidence || "-"].join(" "); - const group = groups.get(key) || { - method: item.method ?? null, - path: item.path ?? "-", - routeKind: item.routeKind ?? null, - status: item.status ?? null, - confidence: item.confidence ?? null, - count: 0, - domChangedCount: 0, - noDomChangeWithinWindowCount: 0, - overBudgetCount: 0, - firstAt: item.ts ?? null, - lastAt: item.ts ?? null, - deltas: [], - examples: [] - }; - group.count += 1; - group.firstAt = minIso(group.firstAt, item.ts ?? null); - group.lastAt = maxIso(group.lastAt, item.ts ?? null); - if (item.domChanged === true && Number.isFinite(Number(item.domChangeDeltaMs))) { - group.domChangedCount += 1; - group.deltas.push(Number(item.domChangeDeltaMs)); - } - if (item.noDomChangeWithinWindow === true) group.noDomChangeWithinWindowCount += 1; - if (item.overBudget === true) group.overBudgetCount += 1; - if (group.examples.length < 6) { - group.examples.push({ - ts: item.ts ?? null, - sessionId: item.sessionId ?? null, - traceId: item.traceId ?? null, - domChangeDeltaMs: item.domChangeDeltaMs ?? null, - firstSampleDeltaMs: item.firstSampleDeltaMs ?? null, - changeSeq: item.changeSample?.seq ?? null, - beforeSeq: item.beforeSample?.seq ?? null, - valuesRedacted: true - }); - } - groups.set(key, group); - } - return Array.from(groups.values()) - .map((item) => { - const deltas = item.deltas.slice().sort((a, b) => a - b); - return { - method: item.method, - path: item.path, - routeKind: item.routeKind, - status: item.status, - confidence: item.confidence, - count: item.count, - domChangedCount: item.domChangedCount, - noDomChangeWithinWindowCount: item.noDomChangeWithinWindowCount, - overBudgetCount: item.overBudgetCount, - p50DomChangeDeltaMs: percentile(deltas, 50), - p95DomChangeDeltaMs: percentile(deltas, 95), - maxDomChangeDeltaMs: deltas.length > 0 ? Math.max(...deltas) : null, - firstAt: item.firstAt, - lastAt: item.lastAt, - examples: item.examples, - valuesRedacted: true - }; - }) - .sort((a, b) => Number(b.maxDomChangeDeltaMs ?? -1) - Number(a.maxDomChangeDeltaMs ?? -1) || b.count - a.count || String(a.path).localeCompare(String(b.path))); -} - -function detectTraceEventsPageReadIssues(network) { - const events = (Array.isArray(network) ? network : []) - .filter((item) => item?.observerInitiated !== true && (item?.type === "response" || item?.type === "requestfailed")) - .map(compactTraceEventsPageReadEvent) - .filter((item) => item !== null); - const http404 = events.filter((item) => item.type === "response" && Number(item.status) === 404); - const httpErrors = events.filter((item) => item.type === "response" && Number(item.status) >= 400 && Number(item.status) !== 404); - const requestFailed = events.filter((item) => item.type === "requestfailed"); - return { - events, - http404, - httpErrors, - requestFailed, - summary: traceEventsPageReadIssueSummary(events), - valuesRedacted: true - }; -} - -function compactTraceEventsPageReadEvent(item) { - const parsed = parseTraceEventsPageReadUrl(item?.url); - if (!parsed.match) return null; - const failureText = item?.failureKind ?? item?.failure ?? item?.errorText ?? null; - return { - ts: item?.ts ?? null, - pageRole: item?.pageRole ?? null, - pageId: item?.pageId ?? null, - commandId: item?.commandId ?? null, - method: String(item?.method || "GET").toUpperCase(), - type: item?.type ?? null, - status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, - path: "/v1/workbench/traces/:traceId/events", - rawPath: parsed.rawPath, - traceId: parsed.traceId, - afterProjectedSeq: parsed.afterProjectedSeq, - sinceSeq: parsed.sinceSeq, - limit: parsed.limit, - tail: parsed.tail, - queryKeys: parsed.queryKeys, - failureKind: failureText ? limitText(String(failureText), 120) : null, - urlHash: item?.url ? sha256(item.url) : null, - valuesRedacted: true - }; -} - -function parseTraceEventsPageReadUrl(value) { - const fallback = { - match: false, - rawPath: urlPath(value), - traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u), - afterProjectedSeq: null, - sinceSeq: null, - limit: null, - tail: null, - queryKeys: [], - }; - try { - const parsed = new URL(String(value || ""), "http://invalid.local/"); - const rawPath = parsed.pathname || "-"; - const match = rawPath.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); - return { - match: Boolean(match), - rawPath, - traceId: match ? decodeURIComponent(match[1]) : fallback.traceId, - afterProjectedSeq: numericSearchParam(parsed.searchParams, "afterProjectedSeq"), - sinceSeq: numericSearchParam(parsed.searchParams, "sinceSeq") ?? numericSearchParam(parsed.searchParams, "afterSeq"), - limit: numericSearchParam(parsed.searchParams, "limit"), - tail: numericSearchParam(parsed.searchParams, "tail"), - queryKeys: Array.from(parsed.searchParams.keys()).sort().slice(0, 12), - }; - } catch { - return fallback; - } -} - -function numericSearchParam(searchParams, key) { - const raw = searchParams?.get?.(key); - if (raw === null || raw === undefined || raw === "") return null; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : null; -} - -function traceEventsPageReadIssueSummary(events) { - const items = Array.isArray(events) ? events : []; - const statuses = uniqueSorted(items.map((item) => item.status).filter((item) => item !== null && item !== undefined).map(String)); - const traceIds = uniqueSorted(items.map((item) => item.traceId).filter(Boolean).map(String)).slice(0, 8); - const afterProjectedSeqs = uniqueSorted(items.map((item) => item.afterProjectedSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); - const sinceSeqs = uniqueSorted(items.map((item) => item.sinceSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); - const failureKinds = uniqueSorted(items.map((item) => item.failureKind).filter(Boolean).map(String)).slice(0, 6); - return { - eventCount: items.length, - responseErrorCount: items.filter((item) => item.type === "response" && Number(item.status) >= 400).length, - http404Count: items.filter((item) => item.type === "response" && Number(item.status) === 404).length, - requestFailedCount: items.filter((item) => item.type === "requestfailed").length, - statuses, - traceIds, - afterProjectedSeqs, - sinceSeqs, - failureKinds, - firstAt: items.reduce((value, item) => minIso(value, item.ts ?? null), null), - lastAt: items.reduce((value, item) => maxIso(value, item.ts ?? null), null), - rootCauseVisibility: "browser network rows identify trace-events page-read path; OTel trace_events_read should confirm backend paging fields", - valuesRedacted: true - }; -} - -function uniqueSorted(values) { - return Array.from(new Set((values || []).filter((item) => item !== null && item !== undefined).map(String))).sort(); -} - -function compactApiDomLagForOutput(report) { - if (!report || typeof report !== "object") return null; - return { - summary: report.summary ?? null, - groups: Array.isArray(report.groups) ? report.groups.slice(0, 8) : [], - worstCandidates: Array.isArray(report.worstCandidates) ? report.worstCandidates.slice(0, 8) : [], - recentCandidates: Array.isArray(report.recentCandidates) ? report.recentCandidates.slice(-8) : [], - valuesRedacted: true - }; -} - -function detectWorkbenchInPlaceProjectionLag(samples, network, control = []) { - const canarySessionIds = sessionInvariantCanarySessionIds(control); - const terminalTraceMissing = detectWorkbenchTerminalTraceMissing(samples, canarySessionIds); - const terminalApiDomLag = detectWorkbenchTerminalApiDomLag(samples, network, canarySessionIds); - return { - terminalTraceMissing, - terminalApiDomLag, - valuesRedacted: true - }; -} - -function detectWorkbenchTerminalTraceMissing(samples, canarySessionIds = new Set()) { - const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; - const rows = []; - const open = new Map(); - const sortedSamples = (Array.isArray(samples) ? samples : []) - .filter(isWorkbenchPathSample) - .filter((sample) => workbenchSampleMatchesCanarySessions(sample, canarySessionIds)) - .slice() - .sort((a, b) => Date.parse(a?.ts || "") - Date.parse(b?.ts || "")); - const closeSegment = (key, lastSample = null) => { - const segment = open.get(key); - if (!segment) return; - open.delete(key); - const firstMs = Date.parse(segment.first.ts || ""); - const lastMs = Date.parse((lastSample ?? segment.last).ts || ""); - const observedMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? Math.round(lastMs - firstMs) : 0; - if (observedMs <= budgetMs) return; - rows.push({ - ...ref(segment.first), - lastSeq: segment.last.seq ?? null, - lastAt: segment.last.ts ?? null, - traceId: segment.traceId, - messageCount: Array.isArray(segment.last.messages) ? segment.last.messages.length : 0, - turnCount: Array.isArray(segment.last.turns) ? segment.last.turns.length : 0, - traceRowCount: 0, - sampleCount: segment.sampleCount, - observedMs, - budgetMs, - finalMessageVisible: workbenchFinalMessageVisible(segment.last, segment.traceId), - terminalTurnVisible: workbenchTerminalTurnVisible(segment.last, segment.traceId), - detail: "terminal turn/message stayed visible for this trace while the same in-place Workbench page had zero trace rows beyond the configured budget", - valuesRedacted: true - }); - }; - for (const sample of sortedSamples) { - if (!isWorkbenchPathSample(sample)) continue; - const tsMs = Date.parse(sample?.ts || ""); - if (!Number.isFinite(tsMs)) continue; - const terminalTraceIds = workbenchTerminalTraceIdsFromDom(sample); - const presentKeys = new Set(); - for (const traceId of terminalTraceIds) { - const key = [samplePageKey(sample), sample?.routeSessionId ?? "", sample?.activeSessionId ?? "", traceId].join("|"); - presentKeys.add(key); - const traceRows = workbenchTraceRowsForTrace(sample, traceId); - if (traceRows.length > 0 || workbenchSampleHasTerminalProjection(sample, { traceIds: [traceId] })) { - closeSegment(key, sample); - continue; - } - const existing = open.get(key); - if (existing) { - existing.last = sample; - existing.sampleCount += 1; - } else { - open.set(key, { first: sample, last: sample, traceId, sampleCount: 1 }); - } - } - for (const [key, segment] of Array.from(open.entries())) { - if (samplePageKey(sample) === samplePageKey(segment.first) && !presentKeys.has(key)) closeSegment(key, sample); - } - } - for (const key of Array.from(open.keys())) closeSegment(key); - return rows; -} - -function detectWorkbenchTerminalApiDomLag(samples, network, canarySessionIds = new Set()) { - const windowMs = 30_000; - const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; - const sampleRows = (Array.isArray(samples) ? samples : []) - .filter(isWorkbenchPathSample) - .filter((sample) => workbenchSampleMatchesCanarySessions(sample, canarySessionIds)) - .map((sample) => ({ sample, tsMs: Date.parse(sample?.ts || ""), pageKey: samplePageKey(sample) })) - .filter((item) => Number.isFinite(item.tsMs)) - .sort((a, b) => a.tsMs - b.tsMs); - const rowsByPage = new Map(); - for (const row of sampleRows) { - const rows = rowsByPage.get(row.pageKey) || []; - rows.push(row); - rowsByPage.set(row.pageKey, rows); - } - const terminalEvents = (Array.isArray(network) ? network : []) - .map(compactWorkbenchTerminalApiEvent) - .filter((item) => workbenchTerminalEventMatchesCanarySessions(item, canarySessionIds)) - .filter((item) => item !== null); - const overBudget = []; - for (const event of terminalEvents) { - const pageSamples = rowsByPage.get(event.pageKey) || []; - if (pageSamples.length === 0 || event.tsMs < pageSamples[0].tsMs) continue; - const alreadyVisible = lastWorkbenchSampleAtOrBefore(pageSamples, event.tsMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); - if (alreadyVisible) continue; - const firstAfter = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); - const firstMiss = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + budgetMs, event, (row) => !workbenchSampleHasTerminalProjection(row.sample, event)); - const resolved = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); - const deltaMs = resolved ? Math.max(0, Math.round(resolved.tsMs - event.tsMs)) : null; - const unresolved = !resolved; - const exceedsBudget = unresolved || (Number.isFinite(deltaMs) && deltaMs > budgetMs); - if (!firstMiss) continue; - if (!exceedsBudget) continue; - overBudget.push({ - ts: event.ts, - pageRole: event.pageRole, - pageId: event.pageId, - routeKind: event.routeKind, - method: event.method, - path: event.path, - traceIds: event.traceIds.slice(0, 6), - sessionIds: event.sessionIds.slice(0, 4), - terminalEvidenceCount: event.terminalEvidenceCount, - traceEventLikeCount: event.traceEventLikeCount, - finalTextFieldCount: event.finalTextFieldCount, - budgetMs, - windowMs, - resolvedDeltaMs: deltaMs, - unresolvedWithinWindow: unresolved, - firstAfterSample: compactWorkbenchProjectionSample(firstAfter?.sample, event), - firstMissSample: compactWorkbenchProjectionSample(firstMiss?.sample, event), - resolvedSample: compactWorkbenchProjectionSample(resolved?.sample, event), - valuesRedacted: true - }); - } - return { - summary: { - terminalEventCount: terminalEvents.length, - overBudgetCount: overBudget.length, - budgetMs, - windowMs, - valuesRedacted: true - }, - overBudget, - terminalEvents: terminalEvents.slice(-20), - valuesRedacted: true - }; -} - -function workbenchSampleMatchesCanarySessions(sample, canarySessionIds) { - if (!(canarySessionIds instanceof Set) || canarySessionIds.size === 0) return true; - const sessionIds = [sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String); - return sessionIds.some((id) => canarySessionIds.has(id)); -} - -function workbenchTerminalEventMatchesCanarySessions(event, canarySessionIds) { - if (!event) return false; - if (!(canarySessionIds instanceof Set) || canarySessionIds.size === 0) return true; - const eventSessionIds = Array.isArray(event.sessionIds) ? event.sessionIds.map(String) : []; - return eventSessionIds.some((id) => canarySessionIds.has(id)); -} - -function compactWorkbenchTerminalApiEvent(item) { - if (!item || item.type !== "response" || item.observerInitiated === true) return null; - const summary = objectValue(item.bodySummary); - if (!summary || Number(summary.terminalEvidenceCount ?? 0) <= 0) return null; - const parsed = parseApiDomLagUrl(item.url); - if (!String(parsed.path || "").startsWith("/v1/workbench/") && parsed.path !== "/v1/agent/chat" && parsed.path !== "/v1/agent/chat/steer") return null; - const routeKind = summary.pathKind ?? apiDomLagRouteKind(parsed.path); - if (!isReliableWorkbenchTerminalApiEvent(summary, routeKind)) return null; - const terminalTraceIds = uniqueSorted(Array.isArray(summary.terminalTraceIds) ? summary.terminalTraceIds : []).slice(0, 12); - if (terminalTraceIds.length === 0) return null; - const tsMs = Date.parse(item.ts || ""); - if (!Number.isFinite(tsMs)) return null; - return { - ts: item.ts, - tsMs, - pageRole: item.pageRole ?? null, - pageId: item.pageId ?? null, - pageKey: String(item.pageRole || "control") + ":" + String(item.pageId || "default"), - method: String(item.method || "GET").toUpperCase(), - path: parsed.path, - routeKind, - traceIds: terminalTraceIds, - observedTraceIds: uniqueSorted([...(Array.isArray(summary.traceIds) ? summary.traceIds : []), parsed.traceId].filter(Boolean)).slice(0, 12), - sessionIds: uniqueSorted([...(Array.isArray(summary.sessionIds) ? summary.sessionIds : []), parsed.sessionId].filter(Boolean)).slice(0, 12), - terminalEvidenceCount: Number(summary.terminalEvidenceCount ?? 0), - terminalStatusCount: Number(summary.terminalStatusCount ?? 0), - terminalTextCount: Number(summary.terminalTextCount ?? 0), - traceEventLikeCount: Number(summary.traceEventLikeCount ?? 0), - finalTextFieldCount: Number(summary.finalTextFieldCount ?? 0), - valuesRedacted: true - }; -} - -function isReliableWorkbenchTerminalApiEvent(summary, routeKind) { - if (!summary || routeKind !== "workbench-turn") return false; - if (Number(summary.terminalEvidenceCount ?? 0) <= 0) return false; - return Number(summary.runningStatusCount ?? 0) <= 0; -} - -function lastWorkbenchSampleAtOrBefore(rows, tsMs, event, predicate = null) { - let result = null; - for (const row of rows || []) { - if (row.tsMs > tsMs) break; - if (!workbenchSampleMatchesTerminalEvent(row.sample, event)) continue; - if (typeof predicate === "function" && !predicate(row)) continue; - result = row; - } - return result; -} - -function firstWorkbenchSampleAfter(rows, startMs, endMs, event, predicate = null) { - for (const row of rows || []) { - if (row.tsMs < startMs) continue; - if (row.tsMs > endMs) break; - if (!workbenchSampleMatchesTerminalEvent(row.sample, event)) continue; - if (typeof predicate === "function" && !predicate(row)) continue; - return row; - } - return null; -} - -function workbenchSampleMatchesTerminalEvent(sample, event) { - if (!sample || !event) return false; - if (event.sessionIds.length > 0) { - const sessionIds = new Set([sample.routeSessionId, sample.activeSessionId].filter(Boolean).map(String)); - if (!event.sessionIds.some((id) => sessionIds.has(id))) return false; - } - if (event.traceIds.length > 0) { - const traces = sampleTraceIds(sample); - if (traces.size === 0) return false; - if (!event.traceIds.some((id) => traces.has(id))) return false; - } - return true; -} - -function workbenchSampleHasTerminalProjection(sample, event) { - const traceIds = event.traceIds.length > 0 ? event.traceIds : Array.from(sampleTraceIds(sample)); - if (traceIds.length === 0) return false; - return traceIds.some((traceId) => workbenchFinalMessageVisible(sample, traceId) || workbenchTerminalTurnVisible(sample, traceId)); -} - -function compactWorkbenchProjectionSample(sample, event = null) { - if (!sample) return null; - const eventTraceIds = event?.traceIds && event.traceIds.length > 0 ? event.traceIds : Array.from(sampleTraceIds(sample)); - const visibleTraceIds = eventTraceIds.slice(0, 6); - return { - ...ref(sample), - messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, - turnCount: Array.isArray(sample.turns) ? sample.turns.length : 0, - traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, - traceIds: visibleTraceIds, - finalMessageVisible: visibleTraceIds.some((traceId) => workbenchFinalMessageVisible(sample, traceId)), - terminalTurnVisible: visibleTraceIds.some((traceId) => workbenchTerminalTurnVisible(sample, traceId)), - valuesRedacted: true - }; -} - -function isWorkbenchPathSample(sample) { - return /\/workbench(?:\/|$)/u.test(String(sample?.path || sample?.url || "")); -} - -function workbenchTerminalTraceIdsFromDom(sample) { - const ids = new Set(); - for (const groupName of ["turns", "messages"]) { - const group = Array.isArray(sample?.[groupName]) ? sample[groupName] : []; - for (const item of group) { - const traceId = stringOrNull(item?.traceId); - if (!traceId) continue; - if (workbenchDomItemIsTerminal(item)) ids.add(traceId); - } - } - return Array.from(ids).sort(); -} - -function workbenchTraceRowsForTrace(sample, traceId) { - const rows = Array.isArray(sample?.traceRows) ? sample.traceRows : []; - return rows.filter((item) => !traceId || item?.traceId === traceId); -} - -function workbenchFinalMessageVisible(sample, traceId) { - const messages = Array.isArray(sample?.messages) ? sample.messages : []; - return messages.some((item) => (!traceId || item?.traceId === traceId) && workbenchDomItemLooksFinal(item)); -} - -function workbenchTerminalTurnVisible(sample, traceId) { - const turns = Array.isArray(sample?.turns) ? sample.turns : []; - return turns.some((item) => (!traceId || item?.traceId === traceId) && workbenchDomItemIsTerminal(item)); -} - -function workbenchDomItemIsTerminal(item) { - const status = String(item?.status || "").toLowerCase(); - if (/^(completed|complete|succeeded|success|failed|failure|error|canceled|cancelled|done)$/u.test(status)) return true; - return isTerminalTraceText([item?.status, item?.textPreview, item?.text, item?.durationText, item?.activityText].filter(Boolean).join(" ")); -} - -function workbenchDomItemLooksFinal(item) { - const text = [item?.status, item?.textPreview, item?.text].filter(Boolean).join(" "); - return workbenchDomItemIsTerminal(item) && isFinalResultText(text); -} - -function buildWorkbenchTurnStateTriadMetrics(samples, timeline = []) { - const rows = []; - const sourceSamples = Array.isArray(samples) ? samples : []; - for (let index = 0; index < sourceSamples.length; index += 1) { - const sample = sourceSamples[index]; - if (!isWorkbenchPathSample(sample)) continue; - const turns = Array.isArray(sample?.turns) ? sample.turns.filter((turn) => String(turn?.role || turn?.dataRole || "agent") === "agent") : []; - if (turns.length === 0) continue; - const rail = activeSessionRailItemForSample(sample); - for (const turn of turns.slice(-6)) { - const row = workbenchTurnStateTriadRow(sample, turn, rail, timeline[index]?.promptIndex ?? 0); - if (row) rows.push(row); - } - } - const invalidFullTriads = rows.filter((row) => row.invalid === true && row.fullTriad === true); - const cardFinalResponseMismatches = rows.filter((row) => row.cardFinalResponseMismatch === true || row.legacyCardFinalResponseMismatch === true); - const collectorMissingRows = rows.filter((row) => Array.isArray(row.collectorMissing) && row.collectorMissing.length > 0); - const invalidRowKeys = new Set([...invalidFullTriads, ...cardFinalResponseMismatches].map((row) => row.rowKey)); - const collectorMissingFields = uniqueSorted(collectorMissingRows.flatMap((row) => row.collectorMissing || [])); - const offendingRows = Array.from(new Map([...invalidFullTriads, ...cardFinalResponseMismatches].map((row) => [row.rowKey, row])).values()); - const drilldown = buildWorkbenchTurnStateTriadDrilldown(offendingRows); - return { - summary: { - sampleCount: sourceSamples.length, - rowCount: rows.length, - fullTriadRowCount: rows.filter((row) => row.fullTriad === true).length, - invalidRowCount: invalidRowKeys.size, - invalidFullTriadCount: invalidFullTriads.length, - cardFinalResponseMismatchCount: cardFinalResponseMismatches.length, - invalidGroupCount: drilldown.summary.groupCount, - invalidTraceIdCount: drilldown.summary.traceIds.length, - invalidMaxObservedSpanMs: drilldown.summary.maxObservedSpanMs, - collectorMissingRowCount: collectorMissingRows.length, - collectorMissingFields, - valuesRedacted: true - }, - drilldown, - invalidFullTriads: invalidFullTriads.slice(0, 120), - cardFinalResponseMismatches: cardFinalResponseMismatches.slice(0, 120), - collectorMissingRows: collectorMissingRows.slice(0, 120), - timeline: rows.slice(-300), - valuesRedacted: true - }; -} - -function buildWorkbenchTurnStateTriadDrilldown(rows) { - const uniqueRows = Array.from(new Map((Array.isArray(rows) ? rows : []) - .filter(Boolean) - .map((row) => [row?.rowKey || [row?.seq, row?.traceId, row?.messageId, row?.pageRole].join("|"), row])).values()); - const groupsByKey = new Map(); - let firstAt = null; - let lastAt = null; - for (const row of uniqueRows) { - const traceId = firstString(row?.traceId); - const mismatchKind = workbenchTriadMismatchKind(row); - const tuple = workbenchTriadTuple(row); - const key = [ - traceId || row?.messageId || row?.sessionIdPrefix || "-", - row?.sessionIdPrefix || "-", - row?.pageRole || "-", - mismatchKind, - tuple, - ].join("|"); - let group = groupsByKey.get(key); - if (!group) { - group = { - keyHash: sha256(key), - mismatchKind, - statusTuple: tuple, - traceId: traceId || null, - messageId: row?.messageId ?? null, - sessionIdPrefix: row?.sessionIdPrefix ?? null, - pageRole: row?.pageRole ?? null, - pageId: row?.pageId ?? null, - count: 0, - firstSeq: row?.seq ?? null, - lastSeq: row?.seq ?? null, - firstAt: row?.ts ?? null, - lastAt: row?.ts ?? null, - promptIndexes: new Set(), - commandIds: new Set(), - rawRailStatuses: new Set(), - rawCardStatuses: new Set(), - finalResponseTextBytes: new Set(), - examples: [], - valuesRedacted: true, - }; - groupsByKey.set(key, group); - } - group.count += 1; - group.firstSeq = minNumberOrValue(group.firstSeq, row?.seq); - group.lastSeq = maxNumberOrValue(group.lastSeq, row?.seq); - group.firstAt = minIso(group.firstAt, row?.ts ?? null); - group.lastAt = maxIso(group.lastAt, row?.ts ?? null); - firstAt = minIso(firstAt, row?.ts ?? null); - lastAt = maxIso(lastAt, row?.ts ?? null); - if (row?.promptIndex !== null && row?.promptIndex !== undefined) group.promptIndexes.add(String(row.promptIndex)); - if (row?.nearestCommandId) group.commandIds.add(String(row.nearestCommandId)); - if (row?.railStatusRaw) group.rawRailStatuses.add(String(row.railStatusRaw)); - if (row?.cardStatusRaw) group.rawCardStatuses.add(String(row.cardStatusRaw)); - if (row?.finalResponseTextBytes !== null && row?.finalResponseTextBytes !== undefined) group.finalResponseTextBytes.add(String(row.finalResponseTextBytes)); - if (group.examples.length < 3) { - group.examples.push({ - seq: row?.seq ?? null, - ts: row?.ts ?? null, - traceId, - railStatus: row?.railStatus ?? null, - railStatusRaw: row?.railStatusRaw ?? null, - cardStatus: row?.cardStatus ?? null, - cardStatusRaw: row?.cardStatusRaw ?? null, - finalResponsePresent: row?.finalResponsePresent ?? null, - finalResponseTextBytes: row?.finalResponseTextBytes ?? null, - nearestCommandId: row?.nearestCommandId ?? null, - valuesRedacted: true, - }); - } - } - const groups = Array.from(groupsByKey.values()).map((group) => { - const observedSpanMs = isoSpanMs(group.firstAt, group.lastAt); - return { - keyHash: group.keyHash, - mismatchKind: group.mismatchKind, - statusTuple: group.statusTuple, - traceId: group.traceId, - messageId: group.messageId, - sessionIdPrefix: group.sessionIdPrefix, - pageRole: group.pageRole, - pageId: group.pageId, - count: group.count, - firstSeq: group.firstSeq, - lastSeq: group.lastSeq, - firstAt: group.firstAt, - lastAt: group.lastAt, - observedSpanMs, - promptIndexes: Array.from(group.promptIndexes).slice(0, 8), - commandIds: Array.from(group.commandIds).slice(0, 8), - rawRailStatuses: Array.from(group.rawRailStatuses).slice(0, 8), - rawCardStatuses: Array.from(group.rawCardStatuses).slice(0, 8), - finalResponseTextBytes: Array.from(group.finalResponseTextBytes).slice(0, 8), - examples: group.examples, - valuesRedacted: true, - }; - }).sort((left, right) => Number(right.observedSpanMs ?? 0) - Number(left.observedSpanMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0)); - const traceIds = uniqueSorted(uniqueRows.map((row) => row?.traceId).filter(Boolean)).slice(0, 12); - const sessionPrefixes = uniqueSorted(uniqueRows.map((row) => row?.sessionIdPrefix).filter(Boolean)).slice(0, 12); - const mismatchKinds = uniqueSorted(uniqueRows.map(workbenchTriadMismatchKind).filter(Boolean)); - return { - summary: { - invalidUniqueRows: uniqueRows.length, - groupCount: groups.length, - traceIds, - sessionPrefixes, - mismatchKinds, - firstAt, - lastAt, - maxObservedSpanMs: groups.reduce((value, item) => Math.max(value, Number(item.observedSpanMs ?? 0)), 0), - valuesRedacted: true, - }, - groups: groups.slice(0, 12), - otelDrilldown: buildWorkbenchTriadOtelDrilldown(traceIds.slice(0, 4)), - staticSourceHints: workbenchTriadStaticSourceHints(), - unitTestReproHints: workbenchTriadUnitTestReproHints(), - valuesRedacted: true, - }; -} - -function buildWorkbenchTriadOtelDrilldown(traceIds) { - const target = otelTargetForAnalyzer(); - const ids = (Array.isArray(traceIds) ? traceIds : []).filter(Boolean).slice(0, 4); - return { - target, - commands: ids.flatMap((traceId) => [ - "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target " + target + " --business-trace-id " + traceId + " --full", - "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep session_ --limit 60 --full", - "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep turn_status_read --limit 40 --full", - "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep projection --limit 120 --full", - ]).slice(0, 16), - valuesRedacted: true, - }; -} - -function otelTargetForAnalyzer() { - const explicit = firstString(process.env.UNIDESK_WEB_OBSERVE_OTEL_TARGET, process.env.UNIDESK_OBSERVABILITY_TARGET); - if (explicit) return explicit; - const parts = String(stateDir || "").split(/[\\\/]+/u); - const index = parts.lastIndexOf("web-observe"); - if (index >= 0 && parts[index + 1]) return String(parts[index + 1]).toUpperCase(); - return "JD01"; -} - -function workbenchTriadMismatchKind(row) { - if (!row || typeof row !== "object") return "unknown"; - if (row.railCardMismatch === true) return "rail-card-status-mismatch"; - if (row.cardFinalResponseMismatch === true) return "completed-card-final-response-absent"; - if (row.legacyCardFinalResponseMismatch === true) return "completed-card-final-response-uncollected-or-absent"; - if (row.tupleAllowed === false) return "tuple-not-allowed"; - return "unknown"; -} - -function workbenchTriadRootCauseFromDrilldown(drilldown, summary = {}) { - const groups = Array.isArray(drilldown?.groups) ? drilldown.groups : []; - const hasStaleCompletedRail = groups.some((group) => group?.mismatchKind === "rail-card-status-mismatch" && String(group?.statusTuple || "").includes("rail=completed,card=running,final=false")); - if (hasStaleCompletedRail) return { - rootCause: "workbench_session_rail_status_stale_after_new_running_turn", - rootCauseStatus: "confirmed-from-dom-samples", - rootCauseConfidence: "high", - dominantMismatchKind: "rail-card-status-mismatch", - summary: "Workbench session rail kept the previous completed terminal status while a newer turn card was running and Final Response was absent", - nextAction: "Inspect HWLAB frontend session status authority/reducer, especially workbench-server-state sessionStatusAuthorityFromMessages and SessionRail sessionToSessionTab status input; session rail must derive from the latest active turn/message authority rather than the previous sealed terminal message.", - sourceOfTruth: "latest durable Workbench turn/message projection for the active session", - valuesRedacted: true, - }; - const finalMismatchCount = Number(summary?.cardFinalResponseMismatchCount ?? 0); - const hasFinalMismatch = finalMismatchCount > 0 || groups.some((group) => /final=false/u.test(String(group?.statusTuple || "")) && group?.mismatchKind !== "rail-card-status-mismatch"); - if (hasFinalMismatch) return { - rootCause: "workbench_terminal_final_response_not_sealed", - rootCauseStatus: "confirmed-from-dom-samples", - rootCauseConfidence: "high", - dominantMismatchKind: "completed-card-final-response-absent", - summary: "Workbench terminal turn card did not expose a structured Final Response body", - nextAction: "Inspect HWLAB terminal message/finalResponse projection contract before changing renderer fallback behavior.", - sourceOfTruth: "durable Workbench terminal message projection", - valuesRedacted: true, - }; - const mismatchKinds = Array.isArray(drilldown?.summary?.mismatchKinds) ? drilldown.summary.mismatchKinds : []; - return { - rootCause: "workbench_projection_state_triad_not_sealed", - rootCauseStatus: "confirmed-from-dom-samples", - rootCauseConfidence: "high", - dominantMismatchKind: mismatchKinds[0] ?? "unknown", - summary: "Workbench session rail status, turn card status, and Final Response body presence diverged from the allowed state tuples", - nextAction: "Use drilldown.otelDrilldown.commands for the listed traceIds, then inspect staticSourceHints and add unit tests from unitTestReproHints before changing UI rendering.", - sourceOfTruth: "durable Workbench projection/read model", - valuesRedacted: true, - }; -} - -function workbenchTriadTuple(row) { - return [ - "rail=" + (row?.railStatus ?? "-"), - "card=" + (row?.cardStatus ?? "-"), - "final=" + String(row?.finalResponsePresent ?? "unknown"), - ].join(","); -} - -function minNumberOrValue(left, right) { - const l = Number(left); - const r = Number(right); - if (!Number.isFinite(l)) return right ?? left ?? null; - if (!Number.isFinite(r)) return left ?? right ?? null; - return Math.min(l, r); -} - -function maxNumberOrValue(left, right) { - const l = Number(left); - const r = Number(right); - if (!Number.isFinite(l)) return right ?? left ?? null; - if (!Number.isFinite(r)) return left ?? right ?? null; - return Math.max(l, r); -} - -function isoSpanMs(firstAt, lastAt) { - const start = Date.parse(firstAt || ""); - const end = Date.parse(lastAt || ""); - if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; - return end - start; -} - -function workbenchTriadStaticSourceHints() { - return [ - { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-session.ts", reason: "session rail/list status and active session projection source" }, - { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-server-state.ts", reason: "server snapshot merge path that can overwrite sealed turn state" }, - { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts", reason: "turn card and Final Response projection/runtime merge path" }, - { repo: "pikasTech/HWLAB", path: "internal/cloud/server-workbench-http*.go", reason: "session list/detail/messages/turn REST read-model contract" }, - { repo: "pikasTech/HWLAB", path: "internal/cloud/workbench-projection-*.go", reason: "durable projection writer and terminal seal contract" }, - ]; -} - -function workbenchTriadUnitTestReproHints() { - return [ - "frontend reducer: when a second trace is running in the same session, session rail status must not stay on the previous completed terminal trace", - "backend projector: terminal event must produce a single sealed turn tuple consumed by session list, session detail, messages and turn-status APIs", - "backend read model: completed rail status must not coexist with running turn card or missing Final Response for the same trace", - "frontend server-state merge: stale running/empty snapshots must not overwrite a sealed completed+Final Response turn", - "frontend projection runtime: cross-page hydration must converge from the same durable projection without DOM-only repair", - ]; -} - -function workbenchTurnStateTriadRow(sample, turn, rail, promptIndex) { - const collectorMissing = []; - const railRawStatus = firstString(rail?.status, rail?.dataStatus); - const railRunning = rail?.running === true || String(rail?.dataRunning || rail?.ariaBusy || "").toLowerCase() === "true"; - const railStatus = normalizeWorkbenchTriadStatus(railRawStatus, railRunning); - if (!rail) collectorMissing.push("sessionRail.activeItem"); - if (!railRawStatus && railRunning !== true) collectorMissing.push("sessionRail.status"); - const cardRawStatus = firstString(turn?.status); - const cardStatus = normalizeWorkbenchTriadStatus(cardRawStatus, false) || (workbenchDomItemIsTerminal(turn) ? "completed" : null); - if (!cardRawStatus && !cardStatus) collectorMissing.push("turn.status"); - const finalPresence = finalResponsePresenceFromTurn(turn); - if (finalPresence.known !== true) collectorMissing.push("turn.finalResponseTextBytes"); - const fullTriad = Boolean(railStatus && cardStatus && finalPresence.known === true); - const finalResponsePresent = finalPresence.known === true ? finalPresence.present : null; - const tupleAllowed = fullTriad - ? (railStatus === "completed" && cardStatus === "completed" && finalResponsePresent === true) - || (railStatus === "running" && cardStatus === "running" && finalResponsePresent === false) - : null; - const cardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known === true && finalResponsePresent !== true; - const legacyCardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known !== true && !workbenchDomItemLooksFinal(turn); - const railCardMismatch = Boolean(railStatus && cardStatus && railStatus !== cardStatus); - const invalid = tupleAllowed === false || cardFinalResponseMismatch || legacyCardFinalResponseMismatch || railCardMismatch; - const traceId = firstString(turn?.traceId, turn?.turnId); - const messageId = firstString(turn?.messageId); - const rowKey = [ - sample?.seq ?? "-", - sample?.pageRole ?? "control", - sample?.pageId ?? "default", - sample?.routeSessionId ?? sample?.activeSessionId ?? rail?.sessionIdPrefix ?? "-", - traceId ?? messageId ?? turn?.index ?? "-" - ].join("|"); - return { - ...ref(sample), - rowKey, - promptIndex, - sessionIdPrefix: sessionPrefixForSample(sample, rail), - traceId, - messageId, - turnId: firstString(turn?.turnId), - turnIndex: turn?.index ?? null, - railStatus, - railStatusRaw: railRawStatus ?? null, - railRunning: railRunning === true, - railSource: rail ? "sessionRail.activeItem" : null, - cardStatus, - cardStatusRaw: cardRawStatus ?? null, - finalResponsePresent, - finalResponseTextBytes: turn?.finalResponseTextBytes !== null && turn?.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes)) ? Number(turn.finalResponseTextBytes) : null, - finalResponseSource: turn?.finalResponseTextSource ?? null, - finalResponseTextHash: turn?.finalResponseTextHash ?? null, - finalResponseTextPreview: turn?.finalResponseTextPreview ? limitText(turn.finalResponseTextPreview, 160) : null, - fullTriad, - tupleAllowed, - invalid, - cardFinalResponseMismatch, - legacyCardFinalResponseMismatch, - railCardMismatch, - collectorMissing, - nearestCommandId: sample?.commandId ?? null, - relatedChecks: ["WBC-001", "WBC-003", "WBC-011", "WBC-022"], - valuesRedacted: true - }; -} - -function activeSessionRailItemForSample(sample) { - const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null; - if (!rail) return null; - const items = Array.isArray(rail.items) ? rail.items : []; - if (rail.activeItem) return rail.activeItem; - const routeSessionId = String(sample?.routeSessionId || ""); - const activeSessionId = String(sample?.activeSessionId || ""); - return items.find((item) => item?.active === true) - || items.find((item) => sessionRailItemMatchesSession(item, activeSessionId)) - || items.find((item) => sessionRailItemMatchesSession(item, routeSessionId)) - || null; -} - -function sessionRailItemMatchesSession(item, sessionId) { - const id = String(sessionId || ""); - const prefix = String(item?.sessionIdPrefix || item?.sessionId || ""); - return Boolean(id && prefix && (id === prefix || id.startsWith(prefix) || prefix.startsWith(id))); -} - -function sessionPrefixForSample(sample, rail) { - const id = firstString(sample?.activeSessionId, sample?.routeSessionId, rail?.sessionIdPrefix, rail?.sessionId); - return id ? String(id).slice(0, 12) : null; -} - -function finalResponsePresenceFromTurn(turn) { - if (!turn || typeof turn !== "object") return { known: false, present: null }; - if (turn.finalResponseTextBytes !== null && turn.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes))) { - const bytes = Number(turn.finalResponseTextBytes); - return { known: true, present: bytes > 0 }; - } - if (Object.prototype.hasOwnProperty.call(turn, "finalResponsePresent") && turn.finalResponsePresent !== null && turn.finalResponsePresent !== undefined) { - return { known: true, present: turn.finalResponsePresent === true }; - } - return { known: false, present: null }; -} - -function normalizeWorkbenchTriadStatus(status, running = false) { - const value = String(status || "").trim().toLowerCase().replace(/_/gu, "-"); - if (running === true) return "running"; - if (!value) return null; - if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(value)) return "completed"; - if (/^(failed|failure|error|blocked|timeout|canceled|cancelled|stale|thread-resume-failed|interrupted|expired|idle)$/u.test(value)) return "completed"; - if (/^(pending|running|active|busy|admitted|dispatching|executing|streaming|processing|queued|in-progress|creating)$/u.test(value)) return "running"; - return null; -} - -function firstString(...values) { - for (const value of values) { - if (typeof value !== "string") continue; - const text = value.trim(); - if (text) return text; - } - return null; -} - -function promptCommandHasAuthoritativeSubmitSideEffect(control, promptRound) { - const commandId = stringOrNull(promptRound?.promptCommandId); - if (!commandId) return false; - const row = (control || []).find((item) => item?.type === "sendPrompt" && item?.phase === "completed" && item?.commandId === commandId); - const detail = objectValue(row?.detail); - const chatSubmit = objectValue(detail.chatSubmit); - const sideEffect = objectValue(chatSubmit.sideEffect); - return chatSubmit.sideEffectObserved === true - || sideEffect.submitted === true - || Number(sideEffect.messageCountDelta ?? 0) > 0; -} - -function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null) { - const findings = []; - const effectiveApiDomLag = apiDomLag || buildApiDomLagReport(samples, network); - 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) }); - findings.push(...buildFrontendFreezeFindings(errors, control)); - findings.push(...buildBrowserProcessFindings(browserProcess, runtimeAlerts)); - findings.push(...buildControlledNavigationRootCauseFindings(control, manifest)); - findings.push(...buildSessionInvariantFindings(control, manifest)); - const commandTimes = control - .filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh") - .map((item) => Date.parse(item.ts)) - .filter(Number.isFinite); - const controlledNavigationWindows = sessionInvariantNavigationWindows(control); - const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean)); - const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean)); - const routeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "routeSessionId", controlledNavigationWindows); - const activeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "activeSessionId", controlledNavigationWindows); - if (routeSessions.size > 1 && routeSessionUnexpected.length > 0) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed outside controlled session-invariance navigation windows", routeSessionCount: routeSessions.size, samples: sampleRefs(routeSessionUnexpected, (item) => item.routeSessionId) }); - if (activeSessions.size > 1 && activeSessionUnexpected.length > 0) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed outside controlled session-invariance navigation windows", activeSessionCount: activeSessions.size, samples: sampleRefs(activeSessionUnexpected, (item) => item.activeSessionId) }); - const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId && !sampleInControlledNavigationWindow(item, controlledNavigationWindows)); - if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) }); - const uncommandedChanges = []; - const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined)); - const previousDigestByPage = new Map(); - for (const sample of samples) { - const pageKey = samplePageKey(sample); - const previous = previousDigestByPage.get(pageKey); - const next = digestSample(sample); - if (previous && previous.digest !== next && !commandedPromptSeqs.has(sample?.seq) && !nearCommand(sample, commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) { - uncommandedChanges.push({ - ...ref(sample), - previousSeq: previous.sample?.seq ?? null, - previousTs: previous.sample?.timestamp ?? previous.sample?.ts ?? null, - fromDigest: previous.digest, - toDigest: next, - fromMessageCount: previous.sample?.messageCount ?? previous.sample?.messages?.length ?? null, - toMessageCount: sample?.messageCount ?? sample?.messages?.length ?? null, - fromTraceRowCount: Array.isArray(previous.sample?.traceRows) ? previous.sample.traceRows.length : previous.sample?.traceRowCount ?? null, - toTraceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : sample?.traceRowCount ?? null, - fromPath: previous.sample?.path ?? previous.sample?.urlPath ?? null, - toPath: sample?.path ?? sample?.urlPath ?? null, - detail: "visible digest changed without nearby command", - }); - } - previousDigestByPage.set(pageKey, { digest: next, sample }); - } - if (uncommandedChanges.length > 0) findings.push({ id: "uncommanded-visible-state-change", severity: "amber", summary: "visible message/trace digest changed without a nearby command", count: uncommandedChanges.length, samples: uncommandedChanges.slice(0, 20) }); - const finalFlicker = detectFinalFlicker(samples); - if (finalFlicker.length > 0) findings.push({ id: "final-response-flicker", severity: "red", summary: "message text digest disappeared or switched to diagnostic-like text after non-empty final text", count: finalFlicker.length, samples: finalFlicker.slice(0, 20) }); - const terminalZeroElapsed = detectTerminalZeroElapsed(samples); - if (terminalZeroElapsed.length > 0) findings.push({ id: "turn-terminal-zero-elapsed", severity: "amber", summary: "terminal Code Agent card displayed 耗时 0 秒; terminal duration issue is a non-blocking timing alert", count: terminalZeroElapsed.length, samples: terminalZeroElapsed.slice(0, 20) }); - const cardTiming = sampleMetrics?.codeAgentCardTiming || {}; - const cardTimingSummary = cardTiming.summary || {}; - if (Number(cardTimingSummary.missingElapsedCount ?? 0) > 0) findings.push({ id: "code-agent-card-elapsed-missing", severity: "amber", summary: "visible Code Agent card did not display total elapsed time; elapsed visibility is a non-blocking timing alert", count: cardTimingSummary.missingElapsedCount, samples: (cardTiming.missingElapsed || []).slice(0, 20) }); - if (Number(cardTimingSummary.missingRecentUpdateCount ?? 0) > 0) findings.push({ id: "code-agent-card-running-recent-update-missing", severity: "amber", summary: "non-terminal Code Agent card did not display 最近更新; recent-update visibility is a non-blocking timing alert", count: cardTimingSummary.missingRecentUpdateCount, samples: (cardTiming.missingRecentUpdate || []).slice(0, 20) }); - const roundCompletion = cardTiming.roundCompletion || {}; - if (Number(cardTimingSummary.durationUnderreportedCount ?? 0) > 0) { - findings.push({ - id: "code-agent-card-duration-underreported", - severity: "amber", - summary: "completed Code Agent card total elapsed is shorter than trace/final-response duration evidence; timing mismatch is a non-blocking alert", - timingSourceOfTruth: "trace-completion-total-or-final-response-duration", - timingStatus: timingStatusFromRows(cardTiming.durationUnderreported, "business-turn-completed"), - timingAlert: true, - count: cardTimingSummary.durationUnderreportedCount, - samples: (cardTiming.durationUnderreported || []).slice(0, 20), - }); - } - if (Number(cardTimingSummary.durationMismatchCount ?? 0) > 0) { - findings.push({ - id: "code-agent-card-duration-mismatch", - severity: "amber", - summary: "completed Code Agent card total elapsed does not match sealed completion/final-response timing evidence; timing mismatch is a non-blocking alert", - timingSourceOfTruth: "trace-completion-total-or-final-response-duration", - timingStatus: timingStatusFromRows(cardTiming.durationMismatches, "business-turn-completed"), - timingAlert: true, - count: cardTimingSummary.durationMismatchCount, - samples: (cardTiming.durationMismatches || []).slice(0, 20), - }); - } - const traceOrder = sampleMetrics?.traceOrder || {}; - const traceOrderSummary = traceOrder.summary || {}; - if (Number(traceOrderSummary.orderAnomalyCount ?? 0) > 0) { - findings.push({ - id: "trace-row-order-nonmonotonic", - severity: "red", - summary: "visible trace rows are not monotonic by total time, clock time, or projected sequence in DOM order", - count: traceOrderSummary.orderAnomalyCount, - samples: (traceOrder.orderAnomalies || []).slice(0, 20), - }); - } - if (Number(traceOrderSummary.completionNotLastCount ?? 0) > 0) { - findings.push({ - id: "trace-completion-row-not-last", - severity: "red", - summary: "visible trace shows a completion row before later trace rows for the same trace", - count: traceOrderSummary.completionNotLastCount, - samples: (traceOrder.completionNotLast || []).slice(0, 20), - }); - } - if (Number(cardTimingSummary.roundCompletionElapsedMismatchCount ?? 0) > 0) findings.push({ id: "round-completion-elapsed-mismatch", severity: "amber", summary: "Trace row 轮次完成(总耗时 ...) does not match the visible Code Agent card total elapsed time within YAML timing slack; timing mismatch is a non-blocking alert", timingSourceOfTruth: "trace-round-completion-total", timingStatus: timingStatusFromRows(roundCompletion.elapsedMismatches, "business-turn-completed"), timingAlert: true, count: cardTimingSummary.roundCompletionElapsedMismatchCount, toleranceSeconds: cardTimingSummary.elapsedMismatchToleranceSeconds, samples: (roundCompletion.elapsedMismatches || []).slice(0, 20) }); - if (Number(cardTimingSummary.roundCompletionFinalResponseMissingCount ?? 0) > 0) findings.push({ id: "round-completion-final-response-missing", severity: "red", summary: "Trace row showed 轮次完成, but no final response was visible in the Code Agent card afterward", count: cardTimingSummary.roundCompletionFinalResponseMissingCount, samples: (roundCompletion.finalResponseMissing || []).slice(0, 20) }); - if (Number(cardTimingSummary.roundCompletionPostTimingChangeCount ?? 0) > 0) findings.push({ id: "round-completion-post-timing-change", severity: "amber", summary: "After 轮次完成, card total elapsed or 最近更新 continued changing; terminal timing alert is non-blocking", count: cardTimingSummary.roundCompletionPostTimingChangeCount, samples: (roundCompletion.postCompletionTimingChanges || []).slice(0, 20) }); - if (Number(cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount ?? 0) > 0) findings.push({ id: "round-completion-recent-update-still-visible", severity: "info", summary: "最近更新 was still visible after 轮次完成; inspect whether terminal cards should hide activity age or keep it sealed", count: cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount, samples: (roundCompletion.postCompletionRecentUpdateVisible || []).slice(0, 20) }); - const scrollJumps = []; - for (let i = 1; i < samples.length; i += 1) { - const prevY = Number(samples[i - 1]?.scroll?.y ?? 0); - const nextY = Number(samples[i]?.scroll?.y ?? 0); - if (prevY > alertThresholds.scrollJumpFromY && nextY < alertThresholds.scrollJumpToY && !nearCommand(samples[i], commandTimes, alertThresholds.scrollJumpCommandWindowMs)) scrollJumps.push({ from: ref(samples[i - 1]), to: ref(samples[i]) }); - } - if (scrollJumps.length > 0) findings.push({ id: "scroll-jump-top", severity: "amber", summary: "scroll position jumped near top without nearby command", count: scrollJumps.length, samples: scrollJumps.slice(0, 10) }); - const traceTerminal = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.some((row) => isTerminalTraceText((row.status || "") + " " + (row.textPreview || "")))); - const traceSeen = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0); - if (traceSeen && !traceTerminal) findings.push({ id: "trace-without-terminal", severity: "amber", summary: "trace rows were visible but no terminal status was sampled", firstTraceSample: ref(samples.find((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0)) }); - const workbenchInPlaceProjectionLag = detectWorkbenchInPlaceProjectionLag(samples, network, control); - if (workbenchInPlaceProjectionLag.terminalTraceMissing.length > 0) findings.push({ - id: "workbench-terminal-trace-not-hydrated-in-place", - severity: "red", - summary: "Workbench rendered a terminal turn/message in-place while the same trace still had no visible run-record trace rows", - count: workbenchInPlaceProjectionLag.terminalTraceMissing.length, - samples: workbenchInPlaceProjectionLag.terminalTraceMissing.slice(0, 20), - rootCause: "workbench_trace_projection_not_hydrated_in_place", - rootCauseStatus: "confirmed-from-dom-samples", - rootCauseConfidence: "high", - valuesRedacted: true - }); - if (workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.length > 0) findings.push({ - id: "workbench-terminal-api-dom-not-refreshed-in-place", - severity: "red", - summary: "Workbench REST returned terminal/final trace evidence but the same in-place page did not render terminal message plus trace rows within the configured budget", - count: workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.length, - budgetMs: workbenchInPlaceProjectionLag.terminalApiDomLag.summary.budgetMs, - windowMs: workbenchInPlaceProjectionLag.terminalApiDomLag.summary.windowMs, - samples: workbenchInPlaceProjectionLag.terminalApiDomLag.overBudget.slice(0, 20), - rootCause: "workbench_rest_terminal_projection_dom_lag", - rootCauseStatus: "confirmed-from-network-body-summary-and-dom-samples", - rootCauseConfidence: "high", - valuesRedacted: true - }); - const turnStateTriad = sampleMetrics?.workbenchTurnStateTriad || {}; - const turnStateTriadSummary = turnStateTriad.summary || {}; - const turnStateTriadRows = [ - ...(Array.isArray(turnStateTriad.invalidFullTriads) ? turnStateTriad.invalidFullTriads : []), - ...(Array.isArray(turnStateTriad.cardFinalResponseMismatches) ? turnStateTriad.cardFinalResponseMismatches : []) - ]; - if (Number(turnStateTriadSummary.invalidRowCount ?? 0) > 0) { - const drilldown = turnStateTriad.drilldown ?? buildWorkbenchTurnStateTriadDrilldown(turnStateTriadRows); - const rootCause = workbenchTriadRootCauseFromDrilldown(drilldown, turnStateTriadSummary); - findings.push({ - id: "workbench-turn-state-triad-inconsistent", - severity: "red", - summary: rootCause.summary, - count: turnStateTriadSummary.invalidRowCount, - fullTriadCount: turnStateTriadSummary.fullTriadRowCount, - invalidFullTriadCount: turnStateTriadSummary.invalidFullTriadCount, - cardFinalResponseMismatchCount: turnStateTriadSummary.cardFinalResponseMismatchCount, - legacyCollectorMissingCount: turnStateTriadSummary.collectorMissingRowCount, - collectorMissingFields: Array.isArray(turnStateTriadSummary.collectorMissingFields) ? turnStateTriadSummary.collectorMissingFields : [], - dominantMismatchKind: rootCause.dominantMismatchKind, - allowedTuples: [ - { railStatus: "completed", cardStatus: "completed", finalResponsePresent: true }, - { railStatus: "running", cardStatus: "running", finalResponsePresent: false } - ], - samples: turnStateTriadRows.slice(0, 20), - drilldown, - collectorMissingSamples: Array.isArray(turnStateTriad.collectorMissingRows) ? turnStateTriad.collectorMissingRows.slice(0, 10) : [], - sourceOfTruth: rootCause.sourceOfTruth + "; do not repair via DOM fallback or GET-side state mutation", - nextAction: rootCause.nextAction, - rootCause: rootCause.rootCause, - rootCauseStatus: rootCause.rootCauseStatus, - rootCauseConfidence: rootCause.rootCauseConfidence, - valuesRedacted: true - }); - } - const promptFailures = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.chatPostOk === false && !promptCommandHasAuthoritativeSubmitSideEffect(control, item)) : []; - if (promptFailures.length > 0) findings.push({ id: "prompt-chat-submit-failed", severity: "red", summary: "sendPrompt command had no successful /v1/agent/chat or /v1/agent/chat/steer POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) }); - const promptSteerRounds = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.steerUsed === true) : []; - if (promptSteerRounds.length > 0) findings.push({ id: "prompt-routed-to-steer", severity: "amber", summary: "sendPrompt was submitted through /v1/agent/chat/steer; verify the previous turn was truly in-flight and not an unsealed terminal failure", count: promptSteerRounds.length, rounds: promptSteerRounds.slice(0, 10) }); - const elapsedZeroResets = Array.isArray(sampleMetrics?.turnTimingElapsedZeroResets) ? sampleMetrics.turnTimingElapsedZeroResets : []; - if (elapsedZeroResets.length > 0) findings.push({ id: "turn-timing-total-elapsed-zero-reset", severity: "amber", summary: "Code Agent total elapsed jumped from a non-zero value back to 0 seconds; timing reset is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedZeroResets), timingAlert: true, count: elapsedZeroResets.length, samples: elapsedZeroResets.slice(0, 20) }); - const elapsedDecreases = Array.isArray(sampleMetrics?.turnTimingNonMonotonic) - ? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds" && item.anomaly !== "zero-reset") - : []; - if (elapsedDecreases.length > 0) findings.push({ id: "turn-timing-total-elapsed-decrease", severity: "amber", summary: "Code Agent total elapsed decreased between adjacent samples; timing decrease is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedDecreases), timingAlert: true, count: elapsedDecreases.length, samples: elapsedDecreases.slice(0, 20) }); - const elapsedForwardJumps = Array.isArray(sampleMetrics?.turnTimingTotalElapsedForwardJumps) ? sampleMetrics.turnTimingTotalElapsedForwardJumps : []; - if (elapsedForwardJumps.length > 0) findings.push({ id: "turn-timing-total-elapsed-forward-jump", severity: "amber", summary: "Code Agent total elapsed jumped forward faster than browser sample interval; timing jump is a non-blocking alert", timingSourceOfTruth: "dom-card-total-elapsed-sequence", timingStatus: timingStatusFromRows(elapsedForwardJumps), timingAlert: true, count: elapsedForwardJumps.length, samples: elapsedForwardJumps.slice(0, 20) }); - const terminalElapsedGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : []; - if (terminalElapsedGrowth.length > 0) findings.push({ id: "turn-timing-terminal-elapsed-growth", severity: "amber", summary: "terminal Code Agent card total elapsed changed after terminal status; terminal timing alert is non-blocking", timingSourceOfTruth: "terminal-card-total-elapsed-seal", timingStatus: timingStatusFromRows(terminalElapsedGrowth, "business-turn-completed"), timingAlert: true, count: terminalElapsedGrowth.length, samples: terminalElapsedGrowth.slice(0, 20) }); - const recentUpdateSawtoothJumps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateSawtoothJumps) - ? sampleMetrics.turnTimingRecentUpdateSawtoothJumps - : Array.isArray(sampleMetrics?.turnTimingNonMonotonic) - ? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump") - : []; - if (recentUpdateSawtoothJumps.length > 0) findings.push({ id: "turn-timing-recent-update-sawtooth-jump", severity: "amber", summary: "最近更新 value jumped faster than sample interval; expected sawtooth increase-or-reset", count: recentUpdateSawtoothJumps.length, samples: recentUpdateSawtoothJumps.slice(0, 20) }); - const severeTimeoutRounds = Array.isArray(sampleMetrics?.rounds) ? sampleMetrics.rounds.filter((item) => Number(item.maxTotalElapsedSeconds) > alertThresholds.turnElapsedSevereTimeoutSeconds) : []; - const severeTimeoutSamples = Array.isArray(sampleMetrics?.timeline) ? sampleMetrics.timeline.filter((item) => Number(item.totalElapsedSeconds) > alertThresholds.turnElapsedSevereTimeoutSeconds) : []; - if (severeTimeoutRounds.length > 0 || severeTimeoutSamples.length > 0) findings.push({ id: "turn-elapsed-severe-timeout", severity: "amber", summary: "turn total elapsed exceeded the YAML-configured elapsed alert threshold; timing is a non-blocking alert unless the turn fails to complete or breaks multi-round continuity", timingSourceOfTruth: "dom-card-total-elapsed-yaml-threshold", timingStatus: timingStatusFromRows([...severeTimeoutRounds, ...severeTimeoutSamples], "observer-timeout"), timingAlert: true, thresholdSeconds: alertThresholds.turnElapsedSevereTimeoutSeconds, count: Math.max(severeTimeoutRounds.length, severeTimeoutSamples.length), rounds: severeTimeoutRounds.slice(0, 20), samples: severeTimeoutSamples.slice(0, 20) }); - const loadingSummary = sampleMetrics?.loading?.summary || {}; - const visibleLoadingSlowSeconds = alertThresholds.visibleLoadingSlowMs / 1000; - if (Number(loadingSummary.longestContinuousSeconds ?? 0) > visibleLoadingSlowSeconds) findings.push({ id: "page-loading-visible-over-budget", severity: "red", summary: "visible 加载中 stayed on screen longer than configured YAML budget; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overBudgetSegmentCount ?? loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, budgetSeconds: visibleLoadingSlowSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) }); - if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) }); - const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {}; - if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ - id: "session-rail-title-fallback-root-cause", - severity: "red", - summary: "INV-02 root cause visible: session rail is rendering fallback Session ses_* titles, so list projection/read model or rail binding is missing stable title/preview data before DOM render", - rootCause: "session_title_fallback_from_facts", - rootCauseStatus: "confirmed-from-dom-session-rail", - rootCauseConfidence: "high", - nextAction: "Check OTel session_list_read fallbackTitleCount/fallbackTitleRatio and emptyPreviewCount for the same run; fix session list projection/read model title/preview before changing DOM fallback text.", - count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, - thresholdRatio: alertThresholds.sessionRailFallbackRatio, - maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, - maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, - evidence: { - thresholdRatio: alertThresholds.sessionRailFallbackRatio, - maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, - maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, - overThresholdSampleCount: sessionRailTitleSummary.overThresholdSampleCount ?? null, - majorityFallbackSampleCount: sessionRailTitleSummary.majorityFallbackSampleCount ?? null, - valuesRedacted: true - }, - samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), - examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20), - valuesRedacted: true - }); - const traceEventsPageReadIssues = detectTraceEventsPageReadIssues(network); - if (traceEventsPageReadIssues.http404.length > 0) findings.push({ - id: "trace-events-page-read-404-root-cause", - severity: "red", - summary: "INV-07 root cause visible: /v1/workbench/traces/:traceId/events returned HTTP 404 for a trace event page read, so the failure is in the trace-events API paging/read-model contract before DOM rendering", - rootCause: "trace_events_paging_contract_mismatch", - rootCauseStatus: "confirmed-from-browser-network", - rootCauseConfidence: "high", - nextAction: "Use OTel trace_events_read for the same trace to compare sinceSeq/afterProjectedSeq, returnedEvents, range, totalEvents, hasMore and fullTraceLoaded; fix backend paging contract or add missing instrumentation before changing renderer behavior.", - count: traceEventsPageReadIssues.http404.length, - evidence: traceEventsPageReadIssues.summary, - samples: traceEventsPageReadIssues.http404.slice(0, 20), - valuesRedacted: true - }); - if (traceEventsPageReadIssues.httpErrors.length > 0) findings.push({ - id: "trace-events-page-read-http-error-root-cause", - severity: "red", - summary: "trace events page read returned HTTP error status; monitor can localize this to the trace-events API instead of a generic Workbench render failure", - rootCause: "trace-events-api-page-read-http-error", - rootCauseStatus: "confirmed-from-browser-network", - rootCauseConfidence: "high", - nextAction: "Drill down by traceId and afterProjectedSeq, then compare with OTel trace_events_read paging fields.", - count: traceEventsPageReadIssues.httpErrors.length, - evidence: traceEventsPageReadIssues.summary, - samples: traceEventsPageReadIssues.httpErrors.slice(0, 20), - valuesRedacted: true - }); - if (traceEventsPageReadIssues.requestFailed.length > 0) findings.push({ - id: "trace-events-page-read-requestfailed-root-cause", - severity: "amber", - summary: "trace events page read failed at browser/network level; monitor localized the failure path, but HTTP status is unavailable so OTel/API instrumentation is needed to confirm the backend root cause", - rootCause: "trace-events-api-page-read-network-failed", - rootCauseStatus: "network-signal-needs-otel-confirmation", - rootCauseConfidence: "medium", - nextAction: "Check whether this happened during observer refresh/navigation; if not, query OTel by /v1/workbench/traces/:traceId/events and add route span fields when status/afterProjectedSeq are missing.", - count: traceEventsPageReadIssues.requestFailed.length, - evidence: traceEventsPageReadIssues.summary, - samples: traceEventsPageReadIssues.requestFailed.slice(0, 20), - valuesRedacted: true - }); - if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) }); - if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) }); - if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); - if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) }); - if ((runtimeAlerts?.summary?.significantConsoleAlertCount ?? runtimeAlerts?.summary?.consoleAlertCount ?? 0) > 0) findings.push({ id: "runtime-console-alerts", severity: "amber", summary: "browser console warning/error entries were captured during observation", count: runtimeAlerts.summary.significantConsoleAlertCount ?? runtimeAlerts.summary.consoleAlertCount, groups: (runtimeAlerts.significantConsoleAlertsByPath ?? runtimeAlerts.consoleAlertsByPath).slice(0, 12) }); - const crossPageDiffs = mergeCrossPageDiffRows( - detectCrossPageProjectionDiffs(samples), - detectAdjacentCrossPageProjectionDiffs(samples) - ); - const crossPageProjectionDiffs = crossPageDiffs.filter((item) => item.diffKind !== "trace-visibility"); - const crossPageTraceVisibilityDiffs = crossPageDiffs.filter((item) => item.diffKind === "trace-visibility"); - const crossPageProjectionBudgetMs = alertThresholds.crossPageProjectionDivergenceRedMs; - const sampleBySeq = new Map(samples.map((item) => [Number(item?.seq), item]).filter(([seq]) => Number.isFinite(seq))); - const appShellNotReadyRows = detectWorkbenchAppShellNotReady(samples); - const persistentAppShellNotReadyRows = appShellNotReadyRows.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs); - const transientAppShellNotReadyRows = appShellNotReadyRows.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs); - if (persistentAppShellNotReadyRows.length > 0) findings.push({ - id: "workbench-app-shell-not-ready", - severity: "red", - summary: "Workbench route document loaded but the app shell never mounted or hydrated within the configured budget; treat this as page/runtime startup evidence, not a Workbench projection divergence", - count: persistentAppShellNotReadyRows.length, - budgetMs: crossPageProjectionBudgetMs, - samples: persistentAppShellNotReadyRows.slice(0, 20), - rootCause: "workbench_page_app_shell_not_ready", - rootCauseStatus: "confirmed-from-dom-and-asset-provenance", - rootCauseConfidence: "high", - valuesRedacted: true - }); - if (transientAppShellNotReadyRows.length > 0) findings.push({ id: "workbench-app-shell-transient-not-ready", severity: "info", summary: "Workbench route briefly had a document and assets but no mounted app shell; retained as startup context", count: transientAppShellNotReadyRows.length, budgetMs: crossPageProjectionBudgetMs, samples: transientAppShellNotReadyRows.slice(0, 20) }); - const timedCrossPageProjectionDiffs = annotateCrossPageDiffTiming(crossPageProjectionDiffs); - const controlledNavigationHydrationProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq)); - const appShellNotReadyProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => !controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq) && crossPageDiffHasWorkbenchAppShellNotReady(item, sampleBySeq)); - const evaluatedCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => !controlledNavigationHydrationCrossPageDiff(item, controlledNavigationWindows, sampleBySeq) && !crossPageDiffHasWorkbenchAppShellNotReady(item, sampleBySeq)); - const persistentCrossPageProjectionDiffs = evaluatedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs); - const transientCrossPageProjectionDiffs = evaluatedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs); - if (persistentCrossPageProjectionDiffs.length > 0) findings.push({ - id: "cross-page-projection-divergence", - severity: "red", - summary: "control and observer pages saw different projection state for the same sampled session beyond the configured budget", - count: persistentCrossPageProjectionDiffs.length, - budgetMs: crossPageProjectionBudgetMs, - samples: persistentCrossPageProjectionDiffs.slice(0, 20), - drilldown: buildCrossPageProjectionDrilldown(persistentCrossPageProjectionDiffs), - sourceOfTruth: "session list/detail/messages/turn APIs must read from the same durable projection snapshot across pages", - rootCause: "workbench_cross_page_projection_not_single_source", - rootCauseStatus: "confirmed-from-control-observer-dom-samples", - rootCauseConfidence: "high", - nextAction: "Use drilldown.traceIds with diagnose-code-agent and compare session_list_read/session_messages_read/turn_status_read rows before changing renderer fallback behavior.", - valuesRedacted: true - }); - if (transientCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-transient-divergence", severity: "info", summary: "control and observer pages briefly differed near a sampled transition; retained as transient evidence but not treated as persistent projection failure", count: transientCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: transientCrossPageProjectionDiffs.slice(0, 20) }); - if (controlledNavigationHydrationProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-controlled-navigation-hydration", severity: "info", summary: "control and observer pages differed while a non-blocking session-invariance navigation command still had an unhydrated blank page; retained as context but not treated as a red projection blocker", count: controlledNavigationHydrationProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: controlledNavigationHydrationProjectionDiffs.slice(0, 20) }); - if (appShellNotReadyProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-app-shell-not-ready", severity: "info", summary: "cross-page projection differences were explained by a page whose Workbench app shell was not mounted; see workbench-app-shell-not-ready for the blocking root cause", count: appShellNotReadyProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: appShellNotReadyProjectionDiffs.slice(0, 20) }); - if (crossPageTraceVisibilityDiffs.length > 0) findings.push({ id: "cross-page-trace-visibility-divergence", severity: "info", summary: "control and observer pages differed only in visible trace row count; this is local disclosure/hydration visibility, not session/message projection divergence", count: crossPageTraceVisibilityDiffs.length, samples: crossPageTraceVisibilityDiffs.slice(0, 20) }); - const traceMessageDuplicates = detectTraceMessageDuplication(samples); - if (traceMessageDuplicates.length > 0) findings.push({ id: "trace-assistant-message-duplicates-final-response", severity: "amber", summary: "trace-frame rendered duplicate visible assistant final rows; the fixed Final Response renderer summary block is excluded", count: traceMessageDuplicates.length, finalResponseSummaryBlockCounted: false, traceFrameSource: "traceRows-only", samples: traceMessageDuplicates.slice(0, 20) }); - const turnTraceMissing = detectTurnTraceIdMissing(samples); - if (turnTraceMissing.length > 0) findings.push({ id: "turn-trace-id-missing", severity: "red", summary: "Code Agent turn/card was visible without a trace id, so historical trace hydration cannot be reliable", count: turnTraceMissing.length, samples: turnTraceMissing.slice(0, 20) }); - const pagePerformanceItems = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath : []; - const slowApi = pagePerformanceItems.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0); - if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded configured YAML usability budget", count: slowApi.length, budgetMs: alertThresholds.sameOriginApiSlowMs, groups: slowApi.slice(0, 20) }); - const longLivedStreams = pagePerformanceItems.filter((item) => item.isLongLivedStream); - const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0); - if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) }); - if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) }); - const requestRatePeaks = Array.isArray(requestRate?.peaks) ? requestRate.peaks : []; - const totalRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "total" && item.overThreshold === true); - const pageRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "page" && item.overThreshold === true); - const apiPathRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "apiPath" && item.overThreshold === true); - if (totalRequestRatePeaks.length > 0) findings.push({ - id: "request-rate-total-peak", - severity: "red", - summary: "same-origin API request rate exceeded the YAML total peak threshold; this is request storm evidence from browser network events", - count: totalRequestRatePeaks.length, - thresholdPerMinute: alertThresholds.requestRateTotalRedPerMinute, - bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, - peaks: totalRequestRatePeaks.slice(0, 20), - valuesRedacted: true - }); - if (pageRequestRatePeaks.length > 0) findings.push({ - id: "request-rate-page-peak", - severity: "red", - summary: "a page-level same-origin API request curve exceeded the YAML peak threshold; inspect the page and top API paths before changing probe sampling", - count: pageRequestRatePeaks.length, - thresholdPerMinute: alertThresholds.requestRatePageRedPerMinute, - bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, - peaks: pageRequestRatePeaks.slice(0, 20), - valuesRedacted: true - }); - if (apiPathRequestRatePeaks.length > 0) findings.push({ - id: "request-rate-api-path-peak", - severity: "red", - summary: "an API-path request curve exceeded the YAML peak threshold; this pinpoints the route most likely involved in a request storm", - count: apiPathRequestRatePeaks.length, - thresholdPerMinute: alertThresholds.requestRateApiPathRedPerMinute, - bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, - peaks: apiPathRequestRatePeaks.slice(0, 20), - valuesRedacted: true - }); - if ((pageProvenance?.summary?.segmentCount ?? 0) > 1) findings.push({ id: "page-provenance-segments", severity: "info", summary: "observer crossed page asset provenance segments; interpret runtime findings by segment", segmentCount: pageProvenance.summary.segmentCount, segments: pageProvenance.segments.slice(0, 20) }); - const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || ""))); - const apiDomLagSummary = effectiveApiDomLag?.summary || {}; - findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for API-to-DOM lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length, apiDomLag: apiDomLagSummary }); - findings.push({ - id: "natural-api-dom-lag-candidates", - severity: Number(apiDomLagSummary.overBudgetCount ?? 0) > 0 ? "amber" : "info", - summary: "state-relevant natural API responses were correlated to the first subsequent DOM digest change; over-budget lag is a non-blocking investigation alert", - count: apiDomLagSummary.candidateCount ?? 0, - domChangedCount: apiDomLagSummary.domChangedCount ?? 0, - noDomChangeWithinWindowCount: apiDomLagSummary.noDomChangeWithinWindowCount ?? 0, - overBudgetCount: apiDomLagSummary.overBudgetCount ?? 0, - budgetMs: apiDomLagSummary.budgetMs ?? null, - p95DomChangeDeltaMs: apiDomLagSummary.p95DomChangeDeltaMs ?? null, - maxDomChangeDeltaMs: apiDomLagSummary.maxDomChangeDeltaMs ?? null, - groups: Array.isArray(effectiveApiDomLag?.groups) ? effectiveApiDomLag.groups.slice(0, 12) : [], - }); - if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) }); - if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" }); - return findings; -} - -function buildCrossPageProjectionDrilldown(rows) { - const sourceRows = Array.isArray(rows) ? rows.filter(Boolean) : []; - const traceIds = uniqueSorted(sourceRows.flatMap((row) => collectIdsFromUnknown(row, "trace")).filter(Boolean)).slice(0, 12); - const sessionIds = uniqueSorted(sourceRows.flatMap((row) => collectIdsFromUnknown(row, "session")).filter(Boolean)).slice(0, 12); - const diffKinds = uniqueSorted(sourceRows.map((row) => row?.diffKind).filter(Boolean)); - const groupsByKey = new Map(); - for (const row of sourceRows) { - const rowTraceIds = collectIdsFromUnknown(row, "trace").slice(0, 8); - const rowSessionIds = collectIdsFromUnknown(row, "session").slice(0, 8); - const key = [ - row?.diffKind || "projection", - rowSessionIds[0] || row?.sessionIdPrefix || "-", - rowTraceIds[0] || "-", - ].join("|"); - let group = groupsByKey.get(key); - if (!group) { - group = { - keyHash: sha256(key), - diffKind: row?.diffKind ?? null, - traceIds: new Set(), - sessionIds: new Set(), - count: 0, - firstSeq: row?.firstSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq ?? null, - lastSeq: row?.lastSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq ?? null, - firstAt: row?.firstAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null, - lastAt: row?.lastAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null, - maxObservedSpanMs: 0, - examples: [], - }; - groupsByKey.set(key, group); - } - group.count += 1; - for (const traceId of rowTraceIds) group.traceIds.add(traceId); - for (const sessionId of rowSessionIds) group.sessionIds.add(sessionId); - group.firstSeq = minNumberOrValue(group.firstSeq, row?.firstSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq); - group.lastSeq = maxNumberOrValue(group.lastSeq, row?.lastSeq ?? row?.controlSeq ?? row?.observerSeq ?? row?.seq); - group.firstAt = minIso(group.firstAt, row?.firstAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null); - group.lastAt = maxIso(group.lastAt, row?.lastAt ?? row?.controlTs ?? row?.observerTs ?? row?.ts ?? null); - group.maxObservedSpanMs = Math.max(group.maxObservedSpanMs, Number(row?.observedSpanMs ?? 0) || 0); - if (group.examples.length < 3) { - group.examples.push({ - diffKind: row?.diffKind ?? null, - firstSeq: row?.firstSeq ?? null, - lastSeq: row?.lastSeq ?? null, - observedSpanMs: row?.observedSpanMs ?? null, - controlMessageCount: row?.controlMessageCount ?? null, - observerMessageCount: row?.observerMessageCount ?? null, - traceIds: rowTraceIds.slice(0, 6), - sessionIds: rowSessionIds.slice(0, 4), - valuesRedacted: true, - }); - } - } - const groups = Array.from(groupsByKey.values()).map((group) => ({ - keyHash: group.keyHash, - diffKind: group.diffKind, - traceIds: Array.from(group.traceIds).slice(0, 8), - sessionIds: Array.from(group.sessionIds).slice(0, 6), - count: group.count, - firstSeq: group.firstSeq, - lastSeq: group.lastSeq, - firstAt: group.firstAt, - lastAt: group.lastAt, - observedSpanMs: Math.max(group.maxObservedSpanMs, isoSpanMs(group.firstAt, group.lastAt)), - examples: group.examples, - valuesRedacted: true, - })).sort((left, right) => Number(right.observedSpanMs ?? 0) - Number(left.observedSpanMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0)); - return { - summary: { - rowCount: sourceRows.length, - groupCount: groups.length, - diffKinds, - traceIds, - sessionIds, - maxObservedSpanMs: groups.reduce((value, item) => Math.max(value, Number(item.observedSpanMs ?? 0)), 0), - valuesRedacted: true, - }, - traceIds, - sessionIds, - groups: groups.slice(0, 12), - otelDrilldown: buildWorkbenchTriadOtelDrilldown(traceIds.slice(0, 4)), - staticSourceHints: workbenchTriadStaticSourceHints(), - unitTestReproHints: [ - "two independent Workbench pages must converge to identical session list/detail/messages/turn projection after the same durable events", - "late session-list or message refresh must not drop a trace that another page already read from durable projection", - "read-side APIs must expose enough OTel fields to compare projection version/cursor and trace ids across pages", - ], - valuesRedacted: true, - }; -} - -function collectIdsFromUnknown(value, kind, output = new Set(), depth = 0) { - if (depth > 4 || value === null || value === undefined) return Array.from(output); - if (typeof value === "string") { - const pattern = kind === "session" ? /\bses_[A-Za-z0-9_-]+\b/gu : /\btrc_[A-Za-z0-9_-]+\b/gu; - for (const match of value.match(pattern) || []) output.add(match); - return Array.from(output); - } - if (Array.isArray(value)) { - for (const item of value.slice(0, 40)) collectIdsFromUnknown(item, kind, output, depth + 1); - return Array.from(output); - } - if (typeof value === "object") { - for (const item of Object.values(value).slice(0, 80)) collectIdsFromUnknown(item, kind, output, depth + 1); - } - return Array.from(output); -} - -function buildFrontendFreezeFindings(errors, control) { - const findings = []; - const promptTimes = (control || []) - .filter((item) => item.type === "sendPrompt" && item.phase === "completed") - .map((item) => Date.parse(item.ts)) - .filter(Number.isFinite) - .sort((a, b) => a - b); - const stopWindows = stopCommandWindows(control); - const events = (errors || []) - .map((item) => frontendFreezeErrorEvent(item, promptTimes)) - .filter((item) => item && !errorInsideStopWindow(item, stopWindows)); - const domEvents = events.filter((item) => item.kind === "dom-evaluate-timeout"); - const controlDomBurst = firstBurst( - domEvents.filter((item) => item.pageRole === "control" || item.pageRole === null), - alertThresholds.domEvaluateTimeoutRedCount, - alertThresholds.domEvaluateTimeoutRedWindowMs, - ); - if (controlDomBurst) findings.push(frontendFreezeBurstFinding({ - id: "frontend-control-dom-evaluate-timeout-red", - summary: "control page DOM evaluation timed out repeatedly; treat the browser page as frozen and keep the sentinel red instead of refreshing or falling back", - burst: controlDomBurst, - thresholdCount: alertThresholds.domEvaluateTimeoutRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - pageRole: "control", - })); - const observerDomBurst = firstBurst( - domEvents.filter((item) => item.pageRole === "observer"), - alertThresholds.domEvaluateTimeoutRedCount, - alertThresholds.domEvaluateTimeoutRedWindowMs, - ); - if (observerDomBurst) findings.push(frontendFreezeBurstFinding({ - id: "frontend-observer-dom-evaluate-timeout-red", - summary: "observer page DOM evaluation timed out repeatedly; the observer page is frozen and later periodic refresh evidence must not clear this run", - burst: observerDomBurst, - thresholdCount: alertThresholds.domEvaluateTimeoutRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - pageRole: "observer", - })); - const screenshotBurst = firstBurst( - events.filter((item) => item.kind === "screenshot-timeout"), - alertThresholds.screenshotTimeoutRedCount, - alertThresholds.domEvaluateTimeoutRedWindowMs, - ); - if (screenshotBurst) findings.push(frontendFreezeBurstFinding({ - id: "frontend-screenshot-timeout-red", - summary: "browser screenshot capture timed out repeatedly; this is freeze evidence and the sentinel must stay red until investigated", - burst: screenshotBurst, - thresholdCount: alertThresholds.screenshotTimeoutRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - pageRole: null, - })); - const pageErrors = events.filter((item) => item.kind === "page-error"); - const pageErrorBurst = firstBurst(pageErrors, alertThresholds.pageErrorRedCount, alertThresholds.domEvaluateTimeoutRedWindowMs); - if (pageErrorBurst) findings.push(frontendFreezeBurstFinding({ - id: "frontend-page-error-red", - summary: "browser pageerror entries exceeded the YAML threshold; page runtime exceptions are blocking when repeated in the observation window", - burst: pageErrorBurst, - thresholdCount: alertThresholds.pageErrorRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - pageRole: null, - })); - return findings; -} - -function buildBrowserProcessReport(rows) { - const blockerEvents = (Array.isArray(rows) ? rows : []) - .filter((item) => item && item.type === "browser-freeze-blocker") - .map(compactBrowserFreezeBlockerEvent) - .filter(Boolean); - const samples = (Array.isArray(rows) ? rows : []) - .filter((item) => item && (item.type === "browser-process-sample" || item.process || item.pages)) - .map((item) => ({ ...item, tsMs: Date.parse(String(item.ts || "")) })) - .filter((item) => Number.isFinite(item.tsMs)) - .sort((a, b) => a.tsMs - b.tsMs); - const memorySamples = samples.map((item) => { - const process = item.process && typeof item.process === "object" ? item.process : {}; - const growth = item.growth && typeof item.growth === "object" ? item.growth : {}; - const totalRssBytes = browserMetricNumber(process.totalRssBytes); - const maxProcessRssBytes = browserMetricNumber(process.maxProcessRssBytes); - return { - ts: item.ts, - tsMs: item.tsMs, - seq: item.seq ?? null, - sampleSeq: item.sampleSeq ?? null, - commandId: item.commandId ?? null, - processCount: browserMetricNumber(process.chromiumProcessCount), - totalRssBytes, - totalRssMb: bytesToMb(totalRssBytes), - maxProcessRssBytes, - maxProcessRssMb: bytesToMb(maxProcessRssBytes), - maxProcess: compactBrowserProcessRow(process.maxProcess), - totalRssGrowthBytes: browserMetricNumber(growth.totalRssGrowthBytes), - totalRssGrowthMb: bytesToMb(growth.totalRssGrowthBytes), - maxProcessRssGrowthBytes: browserMetricNumber(growth.maxProcessRssGrowthBytes), - maxProcessRssGrowthMb: bytesToMb(growth.maxProcessRssGrowthBytes), - roles: process.roles && typeof process.roles === "object" ? process.roles : {}, - valuesRedacted: true, - }; - }); - const computedGrowth = computeBrowserProcessGrowth(memorySamples, alertThresholds.browserRssGrowthWindowMs); - const pageEvents = []; - const cdpTimeoutEvents = []; - for (const sample of samples) { - for (const page of Array.isArray(sample.pages) ? sample.pages : []) { - const responsiveness = page?.responsiveness && typeof page.responsiveness === "object" ? page.responsiveness : {}; - const cdp = page?.cdp && typeof page.cdp === "object" ? page.cdp : {}; - const calls = Array.isArray(cdp.calls) ? cdp.calls : []; - const event = { - ts: sample.ts, - tsMs: sample.tsMs, - seq: sample.seq ?? null, - sampleSeq: sample.sampleSeq ?? null, - commandId: sample.commandId ?? null, - pageRole: page?.pageRole ?? null, - pageId: page?.pageId ?? null, - pageEpoch: page?.pageEpoch ?? null, - url: safeReportUrl(page?.url), - timeoutMs: browserMetricNumber(page?.timeoutMs), - responsivenessOk: responsiveness.ok === true, - responsivenessTimeout: responsiveness.timeout === true, - responsivenessLatencyMs: browserMetricNumber(responsiveness.latencyMs), - cdpTimeoutCount: browserMetricNumber(cdp.timeoutCount), - cdpErrorCount: browserMetricNumber(cdp.errorCount), - heapUsedMb: page?.heapUsage ? bytesToMb(page.heapUsage.usedSize) : null, - heapTotalMb: page?.heapUsage ? bytesToMb(page.heapUsage.totalSize) : null, - effectiveHeapUsedMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.effectiveHeapUsedMb) : null, - effectiveJsHeapUsedMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.effectiveJsHeapUsedMb) : null, - heapUsedGrowthMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.heapUsedGrowthMb) : null, - jsHeapUsedGrowthMb: page?.effectiveMemory ? browserMetricNumber(page.effectiveMemory.jsHeapUsedGrowthMb) : null, - baselineCapturedAt: page?.baseline?.capturedAt ?? null, - domNodes: page?.domCounters ? browserMetricNumber(page.domCounters.nodes) : null, - valuesRedacted: true, - }; - pageEvents.push(event); - for (const call of calls) { - if (call?.timeout !== true || call?.method === "Runtime.evaluate") continue; - cdpTimeoutEvents.push({ - ...event, - method: call.method ?? null, - latencyMs: browserMetricNumber(call.latencyMs), - errorMessage: limitText(call.error?.message ?? "", 180), - valuesRedacted: true, - }); - } - const callTimeoutCount = calls.filter((call) => call?.timeout === true && call?.method !== "Runtime.evaluate").length; - if (Number(event.cdpTimeoutCount || 0) > callTimeoutCount && calls.length === 0) { - cdpTimeoutEvents.push({ ...event, method: "cdp-session", latencyMs: event.responsivenessLatencyMs, errorMessage: "CDP session or metric collection timed out", valuesRedacted: true }); - } - } - } - const responsivenessEvents = pageEvents.filter((item) => item.responsivenessTimeout === true || Number(item.responsivenessLatencyMs ?? 0) >= alertThresholds.playwrightResponsivenessRedMs); - const maxTotal = maxByNumber(memorySamples, (item) => item.totalRssBytes); - const maxProcess = maxByNumber(memorySamples, (item) => item.maxProcessRssBytes); - const maxGrowth = maxByNumber(computedGrowth, (item) => item.totalRssGrowthBytes); - const maxProcessGrowth = maxByNumber(computedGrowth, (item) => item.maxProcessRssGrowthBytes); - return { - summary: { - sampleCount: samples.length, - memorySampleCount: memorySamples.length, - pageMetricCount: pageEvents.length, - cdpMetricsTimeoutCount: cdpTimeoutEvents.length, - responsivenessSlowCount: responsivenessEvents.length, - responsivenessTimeoutCount: responsivenessEvents.filter((item) => item.responsivenessTimeout === true).length, - maxTotalRssMb: maxTotal ? bytesToMb(maxTotal.totalRssBytes) : null, - maxProcessRssMb: maxProcess ? bytesToMb(maxProcess.maxProcessRssBytes) : null, - maxTotalRssGrowthMb: maxGrowth ? bytesToMb(maxGrowth.totalRssGrowthBytes) : null, - maxProcessRssGrowthMb: maxProcessGrowth ? bytesToMb(maxProcessGrowth.maxProcessRssGrowthBytes) : null, - totalRssRedMb: alertThresholds.browserTotalRssRedMb, - processRssRedMb: alertThresholds.browserProcessRssRedMb, - growthRedMb: alertThresholds.browserRssGrowthRedMb, - growthWindowMs: alertThresholds.browserRssGrowthWindowMs, - responsivenessRedMs: alertThresholds.playwrightResponsivenessRedMs, - memoryRedPolicyScope: "per-page-effective-memory", - freezeBlockerCount: blockerEvents.length, - browserFreezePolicy, - valuesRedacted: true, - }, - blockerEvents: blockerEvents.slice(-20), - memorySamples: memorySamples.slice(-60), - growthSamples: computedGrowth.slice(-60), - maxTotalRssSample: maxTotal, - maxProcessRssSample: maxProcess, - maxGrowthSample: maxGrowth, - maxProcessGrowthSample: maxProcessGrowth, - responsivenessEvents: responsivenessEvents.slice(-80), - cdpTimeoutEvents: cdpTimeoutEvents.slice(-80), - latestPageEvents: pageEvents.slice(-20), - valuesRedacted: true, - }; -} - -function compactBrowserFreezeBlockerEvent(item) { - if (!item || typeof item !== "object") return null; - const observed = objectValue(item.observed); - const threshold = objectValue(item.threshold); - const sample = objectValue(item.sample); - const page = objectValue(item.page); - const browserKill = objectValue(item.browserKill); - return { - ts: item.ts ?? null, - seq: item.seq ?? null, - sampleSeq: item.sampleSeq ?? sample.sampleSeq ?? null, - kind: item.kind ?? null, - severity: item.severity ?? "red", - blocking: item.blocking === true, - summary: item.summary ? String(item.summary).slice(0, 240) : null, - rootCause: item.rootCause ?? null, - rootCauseStatus: item.rootCauseStatus ?? null, - rootCauseConfidence: item.rootCauseConfidence ?? null, - policySource: item.policySource ?? null, - fallbackAllowed: item.fallbackAllowed === true, - observerRefreshAllowed: item.observerRefreshAllowed === true, - observed: { - totalRssMb: numberOrNull(observed.totalRssMb), - processRssMb: numberOrNull(observed.processRssMb), - totalGrowthMb: numberOrNull(observed.totalGrowthMb), - processGrowthMb: numberOrNull(observed.processGrowthMb), - effectiveHeapUsedMb: numberOrNull(observed.effectiveHeapUsedMb), - effectiveJsHeapUsedMb: numberOrNull(observed.effectiveJsHeapUsedMb), - heapGrowthMb: numberOrNull(observed.heapGrowthMb), - jsHeapGrowthMb: numberOrNull(observed.jsHeapGrowthMb), - responsivenessLatencyMs: numberOrNull(observed.responsivenessLatencyMs), - responsivenessTimeout: observed.responsivenessTimeout === true, - cdpMetricsTimeoutCount: numberOrNull(observed.cdpMetricsTimeoutCount), - methods: arrayStrings(observed.methods).slice(0, 8), - valuesRedacted: true, - }, - threshold: { - totalRssBlockerMb: numberOrNull(threshold.totalRssBlockerMb), - processRssBlockerMb: numberOrNull(threshold.processRssBlockerMb), - growthBlockerMb: numberOrNull(threshold.growthBlockerMb), - latencyBlockerMs: numberOrNull(threshold.latencyBlockerMs), - eventBlockerCount: numberOrNull(threshold.eventBlockerCount), - metricsTimeoutBlockerCount: numberOrNull(threshold.metricsTimeoutBlockerCount), - windowMs: numberOrNull(threshold.windowMs), - valuesRedacted: true, - }, - sample: { - ts: sample.ts ?? null, - seq: sample.seq ?? null, - sampleSeq: sample.sampleSeq ?? null, - browserPid: numberOrNull(sample.browserPid), - totalRssMb: numberOrNull(sample.totalRssMb), - maxProcessRssMb: numberOrNull(sample.maxProcessRssMb), - totalRssGrowthMb: numberOrNull(sample.totalRssGrowthMb), - maxProcessRssGrowthMb: numberOrNull(sample.maxProcessRssGrowthMb), - valuesRedacted: true, - }, - page: { - pageRole: page.pageRole ?? null, - pageId: page.pageId ?? null, - pageEpoch: numberOrNull(page.pageEpoch), - timeoutMs: numberOrNull(page.timeoutMs), - responsivenessLatencyMs: numberOrNull(page.responsivenessLatencyMs), - responsivenessTimeout: page.responsivenessTimeout === true, - cdpTimeoutCount: numberOrNull(page.cdpTimeoutCount), - cdpErrorCount: numberOrNull(page.cdpErrorCount), - effectiveHeapUsedMb: numberOrNull(page.effectiveHeapUsedMb), - effectiveJsHeapUsedMb: numberOrNull(page.effectiveJsHeapUsedMb), - heapUsedGrowthMb: numberOrNull(page.heapUsedGrowthMb), - jsHeapUsedGrowthMb: numberOrNull(page.jsHeapUsedGrowthMb), - baselineCapturedAt: page.baselineCapturedAt ?? null, - valuesRedacted: true, - }, - browserKill: { - ok: browserKill.ok === true, - pending: browserKill.pending === true, - skipped: browserKill.skipped === true, - reason: browserKill.reason ?? null, - pid: numberOrNull(browserKill.pid), - gracefulSignal: browserKill.gracefulSignal ?? null, - forceSignal: browserKill.forceSignal ?? null, - gracefulSent: browserKill.gracefulSent === true, - forceSent: browserKill.forceSent === true, - exitedAfterGrace: browserKill.exitedAfterGrace === true, - exitedAfterForce: browserKill.exitedAfterForce === true, - valuesRedacted: true, - }, - valuesRedacted: true, - }; -} - -function buildBrowserProcessFindings(report, runtimeAlerts = null) { - const summary = report?.summary || {}; - const findings = []; - const blockerEvents = Array.isArray(report?.blockerEvents) ? report.blockerEvents : []; - if (blockerEvents.length > 0) { - const first = blockerEvents[0] || {}; - findings.push({ - id: "frontend-browser-freeze-runner-blocker", - severity: "red", - summary: "web-probe runner matched YAML browserFreezePolicy, killed/stopped Chromium, and failed the observer run; do not clear this by refresh or fallback", - count: blockerEvents.length, - blocking: true, - rootCause: first.rootCause ?? "frontend_browser_freeze_policy_blocker", - rootCauseStatus: "confirmed-from-runner-browser-freeze-policy", - rootCauseConfidence: "high", - policySource: first.policySource ?? "config/hwlab-node-lanes.yaml#webProbe.browserFreezePolicy", - fallbackAllowed: false, - observerRefreshAllowed: false, - browserKilled: first.browserKill ?? null, - events: blockerEvents.slice(0, 20), - valuesRedacted: true, - }); - } - if (!summary || Number(summary.sampleCount ?? 0) <= 0) return findings; - const rootCauseSignals = browserRootCauseSignals(report, runtimeAlerts); - const pageEvents = Array.isArray(report.latestPageEvents) ? report.latestPageEvents : []; - const maxEffectiveHeapEvent = maxByNumber(pageEvents, (item) => Math.max(Number(item.effectiveHeapUsedMb ?? 0), Number(item.effectiveJsHeapUsedMb ?? 0))); - const maxEffectiveHeapMb = maxEffectiveHeapEvent ? Math.max(Number(maxEffectiveHeapEvent.effectiveHeapUsedMb ?? 0), Number(maxEffectiveHeapEvent.effectiveJsHeapUsedMb ?? 0)) : 0; - if (maxEffectiveHeapMb >= alertThresholds.browserProcessRssRedMb) { - findings.push({ - id: "frontend-browser-memory-rss-red", - severity: "red", - summary: "Page effective memory exceeded YAML red threshold after subtracting page baseline; process RSS remains diagnostic evidence only", - maxEffectiveHeapMb, - processRssRedMb: alertThresholds.browserProcessRssRedMb, - memoryRedPolicyScope: "per-page-effective-memory", - maxEffectiveHeapEvent, - maxTotalRssSample: report.maxTotalRssSample, - maxProcessRssSample: report.maxProcessRssSample, - rootCause: "frontend_browser_page_effective_memory_pressure", - rootCauseStatus: "confirmed-from-runner-page-effective-memory", - rootCauseConfidence: "high", - rootCauseSignals, - fallbackAllowed: false, - valuesRedacted: true, - }); - } - const maxEffectiveGrowthEvent = maxByNumber(pageEvents, (item) => Math.max(Number(item.heapUsedGrowthMb ?? 0), Number(item.jsHeapUsedGrowthMb ?? 0))); - const maxEffectiveGrowthMb = maxEffectiveGrowthEvent ? Math.max(Number(maxEffectiveGrowthEvent.heapUsedGrowthMb ?? 0), Number(maxEffectiveGrowthEvent.jsHeapUsedGrowthMb ?? 0)) : 0; - if (maxEffectiveGrowthMb >= alertThresholds.browserRssGrowthRedMb) { - findings.push({ - id: "frontend-browser-memory-growth-red", - severity: "red", - summary: "Page effective memory grew beyond YAML window budget; this matches the reported freeze pattern without counting multi-page startup RSS", - maxEffectiveGrowthMb, - growthRedMb: alertThresholds.browserRssGrowthRedMb, - windowMs: alertThresholds.browserRssGrowthWindowMs, - memoryRedPolicyScope: "per-page-effective-memory", - maxEffectiveGrowthEvent, - maxGrowthSample: report.maxGrowthSample, - maxProcessGrowthSample: report.maxProcessGrowthSample, - rootCause: "frontend_browser_page_memory_leak_or_unbounded_render_growth", - rootCauseStatus: "confirmed-from-runner-page-effective-memory-growth", - rootCauseConfidence: "high", - rootCauseSignals, - fallbackAllowed: false, - valuesRedacted: true, - }); - } - const responsivenessEvents = Array.isArray(report.responsivenessEvents) ? report.responsivenessEvents : []; - const severeLatencyEvents = responsivenessEvents.filter((item) => item.responsivenessTimeout !== true && Number(item.responsivenessLatencyMs ?? 0) >= alertThresholds.playwrightResponsivenessRedMs); - const responsivenessTimeoutBurst = firstBurst( - responsivenessEvents.filter((item) => item.responsivenessTimeout === true), - alertThresholds.playwrightResponsivenessTimeoutRedCount, - alertThresholds.domEvaluateTimeoutRedWindowMs, - ); - if (severeLatencyEvents.length > 0 || responsivenessTimeoutBurst) { - const events = severeLatencyEvents.length > 0 ? severeLatencyEvents : responsivenessTimeoutBurst; - findings.push({ - id: "frontend-playwright-responsiveness-red", - severity: "red", - summary: "Playwright/CDP responsiveness probe exceeded YAML budget; treat the browser page as frozen instead of refreshing or falling back", - count: events.length, - latencyRedMs: alertThresholds.playwrightResponsivenessRedMs, - timeoutThresholdCount: alertThresholds.playwrightResponsivenessTimeoutRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - events: events.slice(0, 20).map(browserProcessEventRef), - rootCause: "frontend_browser_page_unresponsive_to_playwright", - rootCauseStatus: "confirmed-from-cdp-runtime-evaluate", - rootCauseConfidence: "high", - rootCauseSignals, - fallbackAllowed: false, - valuesRedacted: true, - }); - } - const cdpTimeoutEvents = Array.isArray(report.cdpTimeoutEvents) ? report.cdpTimeoutEvents : []; - const cdpTimeoutBurst = firstBurst(cdpTimeoutEvents, alertThresholds.cdpMetricsTimeoutRedCount, alertThresholds.domEvaluateTimeoutRedWindowMs); - if (cdpTimeoutBurst) { - findings.push({ - id: "frontend-cdp-metrics-timeout-red", - severity: "red", - summary: "CDP browser metrics timed out repeatedly; the sentinel has direct browser-side evidence of a hung page/runtime", - count: cdpTimeoutBurst.length, - thresholdCount: alertThresholds.cdpMetricsTimeoutRedCount, - windowMs: alertThresholds.domEvaluateTimeoutRedWindowMs, - events: cdpTimeoutBurst.slice(0, 20).map(browserProcessEventRef), - rootCause: "frontend_browser_cdp_metrics_unresponsive", - rootCauseStatus: "confirmed-from-cdp-metrics-timeouts", - rootCauseConfidence: "high", - rootCauseSignals, - fallbackAllowed: false, - valuesRedacted: true, - }); - } - return findings; -} - -function browserRootCauseSignals(report, runtimeAlerts) { - const browserSummary = report?.summary || {}; - const runtimeSummary = runtimeAlerts?.summary || {}; - const sampleCount = Number(browserSummary.sampleCount ?? 0); - const sessionListReadCount = Number(runtimeSummary.workbenchSessionListReadCount ?? 0); - const traceEventsReadCount = Number(runtimeSummary.workbenchTraceEventsReadCount ?? 0); - const webPerformanceBeaconFailureCount = Number(runtimeSummary.webPerformanceBeaconFailureCount ?? 0); - const eventSourceFailureCount = Number(runtimeSummary.workbenchEventSourceFailureCount ?? 0); - const suspectedFrontendRefreshStorm = sessionListReadCount >= Math.max(20, sampleCount * 2); - return { - suspectedFrontendRefreshStorm, - sessionListReadCount, - traceEventsReadCount, - webPerformanceBeaconFailureCount, - eventSourceFailureCount, - requestFailedCount: runtimeSummary.significantRequestFailedCount ?? runtimeSummary.requestFailedCount ?? 0, - httpErrorCount: runtimeSummary.httpErrorCount ?? 0, - topRequestFailedPaths: (runtimeAlerts?.networkSignificantRequestFailedByPath ?? runtimeAlerts?.networkRequestFailedByPath ?? []).slice(0, 5), - topHttpErrorPaths: (runtimeAlerts?.networkHttpErrorsByPath ?? []).slice(0, 5), - note: suspectedFrontendRefreshStorm - ? "suspected frontend refresh storm: session list reads exceed the sample-derived budget during a browser red finding" - : "root-cause signals are included so memory/responsiveness/CDP red findings can be correlated without manual grep", - valuesRedacted: true, - }; -} - -function computeBrowserProcessGrowth(samples, windowMs) { - const budgetMs = Math.max(1000, Number(windowMs || 0)); - const sorted = (samples || []).filter((item) => Number.isFinite(item.tsMs)).sort((a, b) => a.tsMs - b.tsMs); - return sorted.map((item, index) => { - const candidates = sorted.slice(0, index + 1).filter((candidate) => item.tsMs - candidate.tsMs <= budgetMs); - const totalBaseline = minByNumber(candidates, (candidate) => candidate.totalRssBytes); - const processBaseline = minByNumber(candidates, (candidate) => candidate.maxProcessRssBytes); - const totalGrowth = Number(item.totalRssBytes || 0) - Number(totalBaseline?.totalRssBytes || 0); - const processGrowth = Number(item.maxProcessRssBytes || 0) - Number(processBaseline?.maxProcessRssBytes || 0); - return { - ...item, - windowMs: budgetMs, - totalBaselineAt: totalBaseline?.ts ?? null, - processBaselineAt: processBaseline?.ts ?? null, - totalRssGrowthBytes: totalGrowth, - totalRssGrowthMb: bytesToMb(totalGrowth), - maxProcessRssGrowthBytes: processGrowth, - maxProcessRssGrowthMb: bytesToMb(processGrowth), - valuesRedacted: true, - }; - }); -} - -function browserProcessEventRef(item) { - return { - ts: item?.ts ?? null, - seq: item?.seq ?? null, - sampleSeq: item?.sampleSeq ?? null, - commandId: item?.commandId ?? null, - pageRole: item?.pageRole ?? null, - pageId: item?.pageId ?? null, - pageEpoch: item?.pageEpoch ?? null, - timeoutMs: item?.timeoutMs ?? null, - responsivenessLatencyMs: item?.responsivenessLatencyMs ?? item?.latencyMs ?? null, - responsivenessTimeout: item?.responsivenessTimeout === true, - cdpTimeoutCount: item?.cdpTimeoutCount ?? null, - method: item?.method ?? null, - errorMessage: item?.errorMessage ?? null, - valuesRedacted: true, - }; -} - -function compactBrowserProcessRow(item) { - if (!item || typeof item !== "object") return null; - return { - pid: item.pid ?? null, - role: item.role ?? null, - name: item.name ?? null, - rssMb: bytesToMb(item.rssBytes), - vmSizeMb: bytesToMb(item.vmSizeBytes), - commandHash: item.commandHash ?? null, - valuesRedacted: true, - }; -} - -function browserMetricNumber(value) { - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; -} - -function bytesToMb(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - return Number((numeric / 1024 / 1024).toFixed(1)); -} - -function maxByNumber(items, getter) { - let selected = null; - let selectedValue = Number.NEGATIVE_INFINITY; - for (const item of Array.isArray(items) ? items : []) { - const value = Number(getter(item)); - if (!Number.isFinite(value)) continue; - if (!selected || value > selectedValue) { - selected = item; - selectedValue = value; - } - } - return selected; -} - -function minByNumber(items, getter) { - let selected = null; - let selectedValue = Number.POSITIVE_INFINITY; - for (const item of Array.isArray(items) ? items : []) { - const value = Number(getter(item)); - if (!Number.isFinite(value)) continue; - if (!selected || value < selectedValue) { - selected = item; - selectedValue = value; - } - } - return selected; -} - -function safeReportUrl(value) { - if (!value) return null; - try { - const url = new URL(String(value)); - return url.origin + url.pathname; - } catch { - return limitText(String(value), 200); - } -} - -function frontendFreezeErrorEvent(item, promptTimes) { - const details = objectValue(item?.error?.details); - const message = String(item?.error?.message ?? item?.message ?? item?.error ?? ""); - const type = String(item?.type || ""); - const tsMs = Date.parse(String(item?.ts || "")); - if (!Number.isFinite(tsMs)) return null; - const kind = classifyFrontendFreezeError(type, message); - if (!kind) return null; - return { - ts: item.ts ?? null, - tsMs, - promptIndex: promptIndexForTs(promptTimes, item.ts), - kind, - type: item.type ?? null, - pageRole: stringOrNull(item?.pageRole) ?? stringOrNull(details.pageRole) ?? pageRoleFromErrorType(type), - pageId: stringOrNull(item?.pageId) ?? stringOrNull(details.pageId), - routeSessionId: stringOrNull(item?.routeSessionId) ?? stringOrNull(details.routeSessionId), - activeSessionId: stringOrNull(item?.activeSessionId) ?? stringOrNull(details.activeSessionId), - commandId: stringOrNull(item?.commandId) ?? stringOrNull(details.commandId), - sampleSeq: numberOrNull(item?.sampleSeq ?? details.sampleSeq), - timeoutMs: timeoutMsFromMessage(message), - messageHash: message ? sha256(message) : null, - preview: limitText(message, 240), - valuesRedacted: true, - }; -} - -function pageRoleFromErrorType(type) { - const value = String(type || ""); - if (/^control-/iu.test(value)) return "control"; - if (/^observer-/iu.test(value)) return "observer"; - return null; -} - -function classifyFrontendFreezeError(type, message) { - const value = String(message || ""); - if (/sampleOnePage\s+DOM\s+evaluate\s+exceeded/iu.test(value) && /(?:control|observer)-sample-error/iu.test(type)) return "dom-evaluate-timeout"; - if (/screenshot|captureScreenshot|page\.screenshot/iu.test(type + " " + value) && /timeout|timed\s*out|exceeded/iu.test(value)) return "screenshot-timeout"; - if (/pageerror|uncaught|unhandledrejection/iu.test(type) || /^(?:Error|TypeError|ReferenceError|RangeError|SyntaxError):/u.test(value)) return "page-error"; - return null; -} - -function firstBurst(events, thresholdCount, windowMs) { - const count = Math.max(1, Math.floor(Number(thresholdCount || 0))); - const budgetMs = Math.max(1, Number(windowMs || 0)); - const sorted = (events || []).filter((item) => Number.isFinite(item?.tsMs)).sort((a, b) => a.tsMs - b.tsMs); - if (sorted.length < count) return null; - for (let start = 0; start <= sorted.length - count; start += 1) { - const end = start + count - 1; - if (sorted[end].tsMs - sorted[start].tsMs <= budgetMs) return sorted.slice(start, end + 1); - } - return null; -} - -function frontendFreezeBurstFinding({ id, summary, burst, thresholdCount, windowMs, pageRole }) { - const first = burst[0]; - const last = burst[burst.length - 1]; - const pageIds = uniqueStrings(burst.map((item) => item.pageId)); - const routeSessionIds = uniqueStrings(burst.map((item) => item.routeSessionId)); - const activeSessionIds = uniqueStrings(burst.map((item) => item.activeSessionId)); - return { - id, - severity: "red", - summary, - count: burst.length, - thresholdCount, - windowMs, - firstAt: first?.ts ?? null, - lastAt: last?.ts ?? null, - pageRole, - pageIds, - routeSessionIds, - activeSessionIds, - timeoutMsMax: maxPresentNumber(burst.map((item) => item.timeoutMs)), - rootCause: "frontend_page_freeze_or_runtime_exception", - rootCauseStatus: "confirmed-from-browser-observer-errors", - rootCauseConfidence: "high", - fallbackAllowed: false, - observerRefreshMayNotClear: true, - nextAction: "Keep this run red; do not auto-refresh, fallback, or mark healthy until OTel/browser evidence explains why the page stopped responding.", - events: burst.map((item) => ({ - ts: item.ts, - promptIndex: item.promptIndex, - type: item.type, - pageRole: item.pageRole, - pageId: item.pageId, - routeSessionId: item.routeSessionId, - activeSessionId: item.activeSessionId, - commandId: item.commandId, - sampleSeq: item.sampleSeq, - timeoutMs: item.timeoutMs, - messageHash: item.messageHash, - preview: item.preview, - valuesRedacted: true, - })), - valuesRedacted: true, - }; -} - -function stopCommandWindows(control) { - return (control || []) - .filter((item) => /^(?:stop|forceStop|cancel|close)$/iu.test(String(item?.type || item?.command || ""))) - .map((item) => { - const tsMs = Date.parse(String(item?.ts || "")); - return Number.isFinite(tsMs) ? { fromMs: tsMs - 1000, toMs: tsMs + 10000 } : null; - }) - .filter(Boolean); -} - -function errorInsideStopWindow(event, windows) { - return (windows || []).some((window) => event.tsMs >= window.fromMs && event.tsMs <= window.toMs); -} - -function timeoutMsFromMessage(value) { - const match = String(value || "").match(/\b(?:exceeded|timeout|timed\s*out\s*after)\s+(\d{2,})\s*ms\b/iu) - || String(value || "").match(/\b(\d{2,})\s*ms\b/iu); - return match ? Number(match[1]) : null; -} - -function uniqueStrings(values) { - return Array.from(new Set((values || []).filter((item) => typeof item === "string" && item.length > 0))).slice(0, 12); -} - -function maxPresentNumber(values) { - const numbers = (values || []).filter((item) => item !== null && item !== undefined && Number.isFinite(Number(item))).map((item) => Number(item)); - return numbers.length > 0 ? Math.max(...numbers) : null; -} - -function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, browserProcessRows, manifest }) { - const latestSampleMs = latestTimestampMs(samples); - const windowMs = 5 * 60 * 1000; - const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY; - const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY; - const inWindow = (item) => { - const tsMs = Date.parse(item?.ts); - return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs; - }; - const windowSamples = samples.filter(inWindow); - const windowControl = control.filter(inWindow); - const windowNetwork = network.filter(inWindow); - const windowConsole = consoleEvents.filter(inWindow); - const windowErrors = errors.filter(inWindow); - const windowBrowserProcessRows = (browserProcessRows || []).filter(inWindow); - const sampleMetrics = buildSampleMetrics(windowSamples, control); - const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest); - const pagePerformance = buildPagePerformanceReport(windowSamples, manifest); - const requestRate = buildRequestRateReport(windowNetwork); - const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork); - const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); - const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork); - const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows); - const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess); - return { - summary: { - name: "recent-5m", - windowMs, - fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null, - toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null, - samples: windowSamples.length, - control: windowControl.length, - network: windowNetwork.length, - console: windowConsole.length, - errors: windowErrors.length, - browserProcess: windowBrowserProcessRows.length, - valuesRedacted: true - }, - sampleMetrics, - pageProvenance, - pagePerformance, - requestRate, - promptNetwork, - runtimeAlerts, - apiDomLag, - browserProcess, - findings, - valuesRedacted: true - }; -} - -function latestTimestampMs(items) { - let latest = Number.NEGATIVE_INFINITY; - for (const item of items || []) { - const tsMs = Date.parse(item?.ts); - if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs; - } - return latest; -} - -function buildPageProvenanceReport(samples, control, manifest) { - const groups = new Map(); - for (const sample of samples) { - const provenance = sample?.pageProvenance; - if (!provenance) continue; - const key = provenance.assetFingerprint || "unknown"; - const group = groups.get(key) || { - assetFingerprint: provenance.assetFingerprint || null, - pageLoadSeqs: [], - sampleCount: 0, - firstSeq: sample.seq ?? null, - lastSeq: sample.seq ?? null, - firstAt: sample.ts ?? null, - lastAt: sample.ts ?? null, - urlPaths: [], - scriptCount: provenance.scriptCount ?? null, - stylesheetCount: provenance.stylesheetCount ?? null, - metaCount: provenance.metaCount ?? null, - scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [], - stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [], - valuesRedacted: true - }; - group.sampleCount += 1; - group.lastSeq = sample.seq ?? null; - group.lastAt = sample.ts ?? null; - if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq); - if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath); - groups.set(key, group); - } - const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0)); - const controlSegments = control - .filter((item) => item.type === "page-provenance" || item?.pageProvenance) - .map((item) => ({ - ts: item.ts ?? null, - reason: item.reason ?? item.detail?.reason ?? null, - httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null, - pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null, - })) - .slice(0, 80); - return { - summary: { - segmentCount: segments.length, - sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0), - manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null, - controlSegmentCount: controlSegments.length - }, - segments, - controlSegments, - valuesRedacted: true - }; -} - -function buildPagePerformanceReport(samples, manifest) { - const base = manifest?.baseUrl || "http://invalid.local"; - const seen = new Set(); - const groups = new Map(); - const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite); - const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null; - const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null; - for (const sample of samples) { - const entries = Array.isArray(sample?.performance) ? sample.performance : []; - for (const entry of entries) { - const durationMs = Number(entry?.duration); - if (!Number.isFinite(durationMs) || durationMs < 0) continue; - const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry); - if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue; - if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue; - const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString(); - const parsed = parsePerformanceUrl(entry?.name, base); - if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue; - const normalizedPath = normalizeApiPath(parsed.path); - const routeKind = classifyApiPerformanceRoute(normalizedPath, entry); - const isLongLivedStream = routeKind === "same-origin-api-stream"; - const streamOpenMs = streamOpenLatencyMs(entry); - const timingStatus = resourceTimingPhaseStatus(entry); - const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|"); - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - const group = groups.get(normalizedPath) || { - routeKind, - path: normalizedPath, - isLongLivedStream, - budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs", - rawPathSamples: [], - sampleCount: 0, - completeTimingSampleCount: 0, - partialTimingSampleCount: 0, - durationsMs: [], - streamOpenDurationsMs: [], - overFiveSecondCount: 0, - overBudgetCount: 0, - partialOverFiveSecondCount: 0, - partialOverBudgetCount: 0, - streamLifetimeOverFiveSecondCount: 0, - streamOpenOverFiveSecondCount: 0, - streamOpenOverBudgetCount: 0, - firstAt: entryTs, - lastAt: entryTs, - firstSeq: sample.seq ?? null, - lastSeq: sample.seq ?? null, - initiatorTypes: [], - pageAssetFingerprints: [], - slowSamples: [], - partialSamples: [], - valuesRedacted: true - }; - group.sampleCount += 1; - const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete"; - let overBudget = false; - if (partialOrdinaryTiming) { - group.partialTimingSampleCount += 1; - if (durationMs > 5000) group.partialOverFiveSecondCount += 1; - if (durationMs > alertThresholds.partialApiSlowMs) { - group.partialOverBudgetCount += 1; - if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); - } - } else { - group.completeTimingSampleCount += 1; - group.durationsMs.push(durationMs); - if (isLongLivedStream) { - if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1; - if (streamOpenMs !== null) { - group.streamOpenDurationsMs.push(streamOpenMs); - if (streamOpenMs > 5000) { - group.streamOpenOverFiveSecondCount += 1; - group.overFiveSecondCount += 1; - } - if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) { - group.streamOpenOverBudgetCount += 1; - group.overBudgetCount += 1; - overBudget = true; - } - } - } else { - if (durationMs > 5000) group.overFiveSecondCount += 1; - if (durationMs > alertThresholds.sameOriginApiSlowMs) { - group.overBudgetCount += 1; - overBudget = true; - } - } - } - if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); - group.lastAt = entryTs; - group.lastSeq = sample.seq ?? null; - if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path); - if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType); - const assetFingerprint = sample?.pageProvenance?.assetFingerprint; - if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint); - groups.set(normalizedPath, group); - } - } - const sameOriginApiByPath = Array.from(groups.values()).map((group) => { - const durations = group.durationsMs.slice().sort((a, b) => a - b); - const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b); - return { - routeKind: group.routeKind, - path: group.path, - isLongLivedStream: group.isLongLivedStream === true, - budgetMetric: group.budgetMetric, - sampleCount: group.sampleCount, - budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs, - partialBudgetMs: alertThresholds.partialApiSlowMs, - streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs, - completeTimingSampleCount: group.completeTimingSampleCount, - partialTimingSampleCount: group.partialTimingSampleCount, - p50Ms: percentile(durations, 50), - p75Ms: percentile(durations, 75), - p95Ms: percentile(durations, 95), - maxMs: durations.length > 0 ? durations[durations.length - 1] : null, - streamOpenSampleCount: streamOpenDurations.length, - streamOpenP50Ms: percentile(streamOpenDurations, 50), - streamOpenP75Ms: percentile(streamOpenDurations, 75), - streamOpenP95Ms: percentile(streamOpenDurations, 95), - streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null, - streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount, - streamOpenOverBudgetCount: group.streamOpenOverBudgetCount, - streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount, - overFiveSecondCount: group.overFiveSecondCount, - overBudgetCount: group.overBudgetCount, - partialOverFiveSecondCount: group.partialOverFiveSecondCount, - partialOverBudgetCount: group.partialOverBudgetCount, - overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0, - overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0, - firstAt: group.firstAt, - lastAt: group.lastAt, - firstSeq: group.firstSeq, - lastSeq: group.lastSeq, - initiatorTypes: group.initiatorTypes, - rawPathSamples: group.rawPathSamples.slice(0, 8), - pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8), - slowSamples: group.slowSamples - .slice() - .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) - .slice(0, 12), - partialSamples: group.partialSamples - .slice() - .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) - .slice(0, 12), - valuesRedacted: true - }; - }).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path)); - const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream); - const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true); - const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0); - const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0); - const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0); - const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0); - const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0); - const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0); - const budgetP95Values = sameOriginApiByPath - .map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0))) - .filter((value) => Number.isFinite(value)); - return { - summary: { - budgetMs: alertThresholds.sameOriginApiSlowMs, - alertThresholds, - sameOriginApiPathCount: sameOriginApiByPath.length, - sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0), - longLivedStreamPathCount: longLivedStreams.length, - longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0), - longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length, - longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0), - longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length, - longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0), - longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0), - slowPathCount: slow.length, - slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0), - overFiveSecondPathCount: slowFiveSecond.length, - overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0), - partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0), - partialOverFiveSecondPathCount: partialFiveSecond.length, - partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0), - partialOverBudgetPathCount: partialSlow.length, - partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0), - worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null, - valuesRedacted: true - }, - sameOriginApiByPath, - valuesRedacted: true - }; -} - -function performanceEntryCompletedEpochMs(sample, entry) { - const origin = Number(sample?.pageProvenance?.timeOrigin); - const responseEnd = Number(entry?.responseEnd); - const startTime = Number(entry?.startTime); - const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime; - if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset); - const sampleTs = Date.parse(sample?.ts || ""); - return Number.isFinite(sampleTs) ? sampleTs : null; -} - -function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) { - const timingStatus = resourceTimingPhaseStatus(entry); - const serverTiming = compactServerTiming(entry?.serverTiming); - return { - ts: entryTs ?? sample?.ts ?? null, - sampleTs: sample?.ts ?? null, - seq: sample?.seq ?? null, - path: normalizedPath ?? null, - rawPath: rawPath ?? null, - initiatorType: entry?.initiatorType ?? null, - durationMs: roundFinite(durationMs), - startTimeMs: roundFinite(entry?.startTime), - fetchStartMs: roundFinite(entry?.fetchStart), - requestStartMs: roundFinite(entry?.requestStart), - responseStartMs: roundFinite(entry?.responseStart), - responseEndMs: roundFinite(entry?.responseEnd), - streamOpenMs: roundFinite(streamOpenMs), - dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"), - tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"), - tlsStartMs: roundFinite(entry?.secureConnectionStart), - requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"), - responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"), - timingStatus: timingStatus.status, - invalidTimingPhases: timingStatus.invalidPhases, - partialTimingPhases: timingStatus.partialPhases, - transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null, - encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null, - decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null, - nextHopProtocol: entry?.nextHopProtocol ?? null, - serverTiming, - serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8), - otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming), - valuesRedacted: true - }; -} - -function phaseDeltaMs(entry, endKey, startKey) { - const end = Number(entry?.[endKey]); - const start = Number(entry?.[startKey]); - if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null; - return Math.round(end - start); -} - -function resourceTimingPhaseStatus(entry) { - const pairs = [ - ["requestToResponseStart", "requestStart", "responseStart"], - ["responseTransfer", "responseStart", "responseEnd"], - ]; - const invalidPhases = []; - const partialPhases = []; - for (const [label, startKey, endKey] of pairs) { - const start = Number(entry?.[startKey]); - const end = Number(entry?.[endKey]); - if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) { - partialPhases.push(label); - } else if (end < start) { - invalidPhases.push(label); - } - } - return { - status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"), - invalidPhases, - partialPhases, - }; -} - -function compactServerTiming(value) { - const items = Array.isArray(value) ? value : []; - return items.slice(0, 8).map((item) => ({ - name: truncate(String(item?.name || ""), 80), - duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null, - description: truncate(String(item?.description || ""), 120), - })).filter((item) => item.name || item.description || item.duration !== null); -} - -function extractOtelTraceIdFromServerTiming(items) { - const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" "); - const match = text.match(/\b[0-9a-f]{32}\b/iu); - return match ? match[0].toLowerCase() : null; -} - -function roundFinite(value) { - const numeric = Number(value); - return Number.isFinite(numeric) ? Math.round(numeric) : null; -} - -function classifyApiPerformanceRoute(normalizedPath, entry = {}) { - if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream"; - if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream"; - return "same-origin-api"; -} - -function streamOpenLatencyMs(entry = {}) { - const responseStart = Number(entry?.responseStart); - const startTime = Number(entry?.startTime); - if (!Number.isFinite(responseStart) || responseStart <= 0) return null; - if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart); - if (responseStart < startTime) return null; - return Math.max(0, responseStart - startTime); -} - -function parsePerformanceUrl(value, base) { - try { - const url = new URL(String(value || ""), base); - const origin = new URL(String(base || "http://invalid.local")).origin; - return { sameOrigin: url.origin === origin, path: url.pathname }; - } catch { - return { sameOrigin: false, path: "-" }; - } -} - -function isApiLikePath(path) { - return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || "")); -} - -function normalizeApiPath(path) { - return String(path || "-") - .replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id") - .replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId") - .replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId") - .replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id") - .replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id")); -} - -function percentile(sortedValues, percentileValue) { - if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null; - if (sortedValues.length === 1) return Math.round(sortedValues[0]); - const rank = (percentileValue / 100) * (sortedValues.length - 1); - const lower = Math.floor(rank); - const upper = Math.ceil(rank); - if (lower === upper) return Math.round(sortedValues[lower]); - const weight = rank - lower; - return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight); -} - -function buildRequestRateReport(network) { - const bucketMs = Math.max(1000, Number(alertThresholds.requestRateBucketMs || 0)); - const naturalApiRequests = (Array.isArray(network) ? network : []) - .filter((item) => item?.observerInitiated !== true && item?.type === "request") - .map((item) => requestRateEvent(item)) - .filter((item) => item !== null) - .sort((a, b) => a.tsMs - b.tsMs); - if (naturalApiRequests.length === 0) { - return { - summary: { - bucketMs, - bucketSeconds: Number((bucketMs / 1000).toFixed(3)), - requestCount: 0, - bucketCount: 0, - pageCount: 0, - apiPathCount: 0, - totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, - pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, - apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, - totalPeakPerMinute: 0, - pagePeakPerMinute: 0, - apiPathPeakPerMinute: 0, - overThresholdPeakCount: 0, - valuesRedacted: true, - }, - buckets: [], - pageCurves: [], - apiPathCurves: [], - peaks: [], - valuesRedacted: true, - }; - } - const firstBucketMs = Math.floor(naturalApiRequests[0].tsMs / bucketMs) * bucketMs; - const lastBucketMs = Math.floor(naturalApiRequests[naturalApiRequests.length - 1].tsMs / bucketMs) * bucketMs; - const makeBucket = (bucketStartMs, extra = {}) => ({ - bucketStartMs, - bucketEndMs: bucketStartMs + bucketMs, - startAt: new Date(bucketStartMs).toISOString(), - endAt: new Date(bucketStartMs + bucketMs).toISOString(), - count: 0, - requestPerMinute: 0, - ...extra, - valuesRedacted: true, - }); - const totalBuckets = new Map(); - const pageBuckets = new Map(); - const apiPathBuckets = new Map(); - const pageTotals = new Map(); - const apiPathTotals = new Map(); - const allBucketStarts = []; - for (let bucketStartMs = firstBucketMs; bucketStartMs <= lastBucketMs; bucketStartMs += bucketMs) { - totalBuckets.set(bucketStartMs, makeBucket(bucketStartMs)); - allBucketStarts.push(bucketStartMs); - } - for (const item of naturalApiRequests) { - const bucketStartMs = Math.floor(item.tsMs / bucketMs) * bucketMs; - const totalBucket = totalBuckets.get(bucketStartMs) || makeBucket(bucketStartMs); - totalBucket.count += 1; - totalBuckets.set(bucketStartMs, totalBucket); - - const pageKey = item.pageKey; - const pageMapKey = pageKey + "|" + bucketStartMs; - const pageBucket = pageBuckets.get(pageMapKey) || makeBucket(bucketStartMs, { - pageKey, - pageRole: item.pageRole, - pageId: item.pageId, - pageEpoch: item.pageEpoch, - path: item.framePath, - }); - pageBucket.count += 1; - pageBuckets.set(pageMapKey, pageBucket); - const pageTotal = pageTotals.get(pageKey) || { pageKey, pageRole: item.pageRole, pageId: item.pageId, pageEpoch: item.pageEpoch, path: item.framePath, count: 0 }; - pageTotal.count += 1; - pageTotals.set(pageKey, pageTotal); - - const apiKey = item.method + " " + item.apiPath; - const apiMapKey = apiKey + "|" + bucketStartMs; - const apiBucket = apiPathBuckets.get(apiMapKey) || makeBucket(bucketStartMs, { - apiKey, - method: item.method, - path: item.apiPath, - pageKey, - pageRole: item.pageRole, - pageId: item.pageId, - pageEpoch: item.pageEpoch, - }); - apiBucket.count += 1; - apiPathBuckets.set(apiMapKey, apiBucket); - const apiTotal = apiPathTotals.get(apiKey) || { apiKey, method: item.method, path: item.apiPath, count: 0 }; - apiTotal.count += 1; - apiPathTotals.set(apiKey, apiTotal); - } - const finalizeBucket = (bucket) => ({ - ...bucket, - requestPerMinute: Number((Number(bucket.count || 0) * 60000 / bucketMs).toFixed(2)), - }); - const buckets = Array.from(totalBuckets.values()).map(finalizeBucket); - const pageBucketRows = Array.from(pageBuckets.values()).map(finalizeBucket); - const apiPathBucketRows = Array.from(apiPathBuckets.values()).map(finalizeBucket); - const peakForRows = (rows, thresholdPerMinute, scope, extra = {}) => rows - .filter((row) => Number(row.requestPerMinute ?? 0) >= Number(thresholdPerMinute || Infinity)) - .map((row) => ({ - scope, - thresholdPerMinute, - overThreshold: true, - bucketMs, - bucketStartMs: row.bucketStartMs, - bucketEndMs: row.bucketEndMs, - startAt: row.startAt, - endAt: row.endAt, - count: row.count, - requestPerMinute: row.requestPerMinute, - ...extra(row), - valuesRedacted: true, - })); - const totalPeaks = peakForRows(buckets, alertThresholds.requestRateTotalRedPerMinute, "total", () => ({})); - const pagePeaks = peakForRows(pageBucketRows, alertThresholds.requestRatePageRedPerMinute, "page", (row) => ({ - pageKey: row.pageKey, - pageRole: row.pageRole, - pageId: row.pageId, - pageEpoch: row.pageEpoch, - path: row.path, - })); - const apiPathPeaks = peakForRows(apiPathBucketRows, alertThresholds.requestRateApiPathRedPerMinute, "apiPath", (row) => ({ - apiKey: row.apiKey, - method: row.method, - path: row.path, - pageKey: row.pageKey, - pageRole: row.pageRole, - pageId: row.pageId, - pageEpoch: row.pageEpoch, - })); - const pageCurves = Array.from(pageTotals.values()) - .map((page) => { - const rows = pageBucketRows - .filter((row) => row.pageKey === page.pageKey) - .sort((a, b) => a.bucketStartMs - b.bucketStartMs); - const peak = maxByNumber(rows, (row) => row.requestPerMinute); - return { - ...page, - bucketCount: rows.length, - peakRequestPerMinute: peak?.requestPerMinute ?? 0, - peakBucket: peak ?? null, - buckets: rows.slice(-60), - valuesRedacted: true, - }; - }) - .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); - const apiPathCurves = Array.from(apiPathTotals.values()) - .map((api) => { - const rows = apiPathBucketRows - .filter((row) => row.apiKey === api.apiKey) - .sort((a, b) => a.bucketStartMs - b.bucketStartMs); - const peak = maxByNumber(rows, (row) => row.requestPerMinute); - return { - ...api, - bucketCount: rows.length, - peakRequestPerMinute: peak?.requestPerMinute ?? 0, - peakBucket: peak ?? null, - buckets: rows.slice(-60), - valuesRedacted: true, - }; - }) - .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); - const totalPeak = maxByNumber(buckets, (row) => row.requestPerMinute); - const pagePeak = pageCurves[0] ?? null; - const apiPathPeak = apiPathCurves[0] ?? null; - const peaks = [...totalPeaks, ...pagePeaks, ...apiPathPeaks] - .sort((a, b) => Number(b.requestPerMinute ?? 0) - Number(a.requestPerMinute ?? 0) || String(a.scope).localeCompare(String(b.scope))); - return { - summary: { - bucketMs, - bucketSeconds: Number((bucketMs / 1000).toFixed(3)), - requestCount: naturalApiRequests.length, - bucketCount: buckets.length, - pageCount: pageCurves.length, - apiPathCount: apiPathCurves.length, - firstAt: new Date(firstBucketMs).toISOString(), - lastAt: new Date(lastBucketMs + bucketMs).toISOString(), - totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, - pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, - apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, - totalPeakPerMinute: totalPeak?.requestPerMinute ?? 0, - totalPeakCount: totalPeak?.count ?? 0, - totalPeakAt: totalPeak?.startAt ?? null, - pagePeakPerMinute: pagePeak?.peakRequestPerMinute ?? 0, - pagePeakKey: pagePeak?.pageKey ?? null, - pagePeakPath: pagePeak?.path ?? null, - apiPathPeakPerMinute: apiPathPeak?.peakRequestPerMinute ?? 0, - apiPathPeakKey: apiPathPeak?.apiKey ?? null, - overThresholdPeakCount: peaks.length, - valuesRedacted: true, - }, - buckets: buckets.slice(-120), - pageCurves: pageCurves.slice(0, 20), - apiPathCurves: apiPathCurves.slice(0, 40), - peaks: peaks.slice(0, 80), - valuesRedacted: true, - }; -} - -function requestRateEvent(item) { - const tsMs = Date.parse(String(item?.ts || "")); - const apiPath = normalizeApiRatePath(urlPath(item?.url)); - if (!Number.isFinite(tsMs) || !isApiLikePath(apiPath)) return null; - const framePath = normalizeRoutePath(urlPath(item?.frameUrl || item?.url)); - const pageRole = String(item?.pageRole || "unknown"); - const pageId = String(item?.pageId || "unknown"); - const pageEpoch = Number.isFinite(Number(item?.pageEpoch)) ? Number(item.pageEpoch) : null; - return { - ts: item.ts, - tsMs, - method: String(item?.method || "GET").toUpperCase(), - apiPath, - framePath, - pageRole, - pageId, - pageEpoch, - pageKey: [pageRole, pageId, pageEpoch ?? "-", framePath || "-"].join("|"), - valuesRedacted: true, - }; -} - -function normalizeApiRatePath(value) { - return normalizeRoutePath(value) - .replace(/\/sessions\/[^/?#]+/gu, "/sessions/:id") - .replace(/\/turns\/[^/?#]+/gu, "/turns/:id") - .replace(/\/traces\/[^/?#]+/gu, "/traces/:id") - .replace(/\/messages\/[^/?#]+/gu, "/messages/:id") - .replace(/\/files\/[^/?#]+/gu, "/files/:id") - .replace(/\/tasks\/[^/?#]+/gu, "/tasks/:id") - .replace(/\/runs\/[^/?#]+/gu, "/runs/:id") - .replace(/\/[0-9a-f]{8,}(?=\/|$)/giu, "/:id") - .replace(/\/[A-Za-z0-9_-]{24,}(?=\/|$)/gu, "/:id"); -} - -function normalizeRoutePath(value) { - const raw = String(value || ""); - if (!raw || raw === "-") return "-"; - return raw.replace(/\/+/gu, "/").replace(/\/$/u, "") || "/"; -} - -function compactRequestRateForOutput(report) { - if (!report || typeof report !== "object") return null; - return { - summary: report.summary ?? null, - buckets: Array.isArray(report.buckets) ? report.buckets.slice(-12) : [], - pageCurves: Array.isArray(report.pageCurves) ? report.pageCurves.slice(0, 8).map((item) => ({ - pageKey: item.pageKey ?? null, - pageRole: item.pageRole ?? null, - pageId: item.pageId ?? null, - pageEpoch: item.pageEpoch ?? null, - path: item.path ?? null, - count: item.count ?? null, - peakRequestPerMinute: item.peakRequestPerMinute ?? null, - peakBucket: item.peakBucket ?? null, - buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], - valuesRedacted: true, - })) : [], - apiPathCurves: Array.isArray(report.apiPathCurves) ? report.apiPathCurves.slice(0, 12).map((item) => ({ - apiKey: item.apiKey ?? null, - method: item.method ?? null, - path: item.path ?? null, - count: item.count ?? null, - peakRequestPerMinute: item.peakRequestPerMinute ?? null, - peakBucket: item.peakBucket ?? null, - buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], - valuesRedacted: true, - })) : [], - peaks: Array.isArray(report.peaks) ? report.peaks.slice(0, 12) : [], - valuesRedacted: true, - }; -} - -function buildPromptNetworkReport(control, network) { - const promptsById = new Map(); - for (const item of control) { - if (item?.type !== "sendPrompt" || !item.commandId) continue; - const existing = promptsById.get(item.commandId) || { - commandId: item.commandId, - promptIndex: promptsById.size + 1, - promptTextHash: item.input?.textHash ?? null, - promptTextBytes: item.input?.textBytes ?? null, - startedAt: null, - completedAt: null, - failedAt: null, - phase: null - }; - if (!existing.promptTextHash && item.input?.textHash) existing.promptTextHash = item.input.textHash; - if (!existing.promptTextBytes && item.input?.textBytes) existing.promptTextBytes = item.input.textBytes; - if (item.phase === "started") existing.startedAt = item.ts ?? existing.startedAt; - if (item.phase === "completed") existing.completedAt = item.ts ?? existing.completedAt; - if (item.phase === "failed") existing.failedAt = item.ts ?? existing.failedAt; - existing.phase = item.phase ?? existing.phase; - promptsById.set(item.commandId, existing); - } - const prompts = Array.from(promptsById.values()).sort((a, b) => Date.parse(a.startedAt || a.completedAt || a.failedAt || "") - Date.parse(b.startedAt || b.completedAt || b.failedAt || "")); - prompts.forEach((item, index) => { item.promptIndex = index + 1; }); - const chatEvents = network - .filter((item) => String(item?.method || "").toUpperCase() === "POST" && promptSubmitModeForUrl(item?.url) !== null) - .map((item) => { - const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null; - const urlPathValue = urlPath(item.url); - return { - ts: item.ts ?? null, - tsMs: Date.parse(item.ts), - type: item.type ?? null, - status: Number.isFinite(Number(item.status)) ? Number(item.status) : null, - commandId: item.commandId ?? null, - urlPath: urlPathValue, - submitMode: promptSubmitModeForUrl(item.url), - failureKind: failureText ? String(failureText) : null, - errorTextHash: failureText ? sha256(failureText) : null - }; - }) - .filter((item) => Number.isFinite(item.tsMs)) - .sort((a, b) => a.tsMs - b.tsMs); - const rounds = prompts.map((prompt) => { - const startMs = Date.parse(prompt.startedAt || prompt.completedAt || prompt.failedAt || ""); - const endAnchorMs = Date.parse(prompt.completedAt || prompt.failedAt || prompt.startedAt || ""); - const fromMs = Number.isFinite(startMs) ? startMs - 3000 : Number.NEGATIVE_INFINITY; - const toMs = Number.isFinite(endAnchorMs) ? endAnchorMs + 30000 : Number.POSITIVE_INFINITY; - const events = chatEvents.filter((event) => { - if (event.commandId && prompt.commandId && event.commandId === prompt.commandId) return true; - return event.tsMs >= fromMs && event.tsMs <= toMs; - }); - const responses = events.filter((event) => event.type === "response"); - const failures = events.filter((event) => event.type === "requestfailed"); - const responseStatuses = responses.map((event) => event.status).filter((status) => status !== null); - const submitModes = Array.from(new Set(events.map((event) => event.submitMode).filter(Boolean))).sort(); - const chatPostOk = responseStatuses.some((status) => status >= 200 && status < 300); - const failureKind = chatPostOk - ? null - : failures.length > 0 - ? "requestfailed" - : responseStatuses.length === 0 - ? "missing-response" - : "http-status"; - return { - promptIndex: prompt.promptIndex, - promptCommandId: prompt.commandId, - promptTextHash: prompt.promptTextHash, - promptTextBytes: prompt.promptTextBytes, - startedAt: prompt.startedAt, - completedAt: prompt.completedAt, - failedAt: prompt.failedAt, - chatPostOk, - failureKind, - requestCount: events.filter((event) => event.type === "request").length, - responseCount: responses.length, - requestFailedCount: failures.length, - responseStatuses, - submitModes, - steerUsed: submitModes.includes("steer"), - firstChatEventAt: events[0]?.ts ?? null, - lastChatEventAt: events[events.length - 1]?.ts ?? null, - events: events.slice(0, 12).map((event) => ({ ts: event.ts, type: event.type, status: event.status, urlPath: event.urlPath, submitMode: event.submitMode, failureKind: event.failureKind, errorTextHash: event.errorTextHash })) - }; - }); - return { - summary: { - promptCount: rounds.length, - chatPostOk: rounds.filter((item) => item.chatPostOk === true).length, - chatPostFailed: rounds.filter((item) => item.chatPostOk === false).length, - chatPostMissing: rounds.filter((item) => item.failureKind === "missing-response").length - }, - rounds - }; -} - -function promptSubmitModeForUrl(value) { - const pathValue = urlPath(value); - if (pathValue === "/v1/agent/chat") return "chat"; - if (pathValue === "/v1/agent/chat/steer") return "steer"; - return null; -} - -function parseDomDiagnosticSummary(text) { - const value = String(text || ""); - const traceMatch = value.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu); - const httpStatusMatch = value.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); - const idleMatch = value.match(/\bidle\s+(\d+)s\b/iu); - const waitingForMatch = value.match(/\bwaitingFor=([^\s;;,,)]+)/iu); - const lastEventLabelMatch = value.match(/\blastEventLabel=([^\s;;,,)]+)/iu); - const diagnosticCode = httpStatusMatch - ? "http-" + httpStatusMatch[1] - : /turn\s*超过|无新活动/iu.test(value) - ? "turn-idle-no-activity" - : /Failed to fetch/iu.test(value) - ? "failed-to-fetch" - : "diagnostic"; - return { - diagnosticCode, - traceId: traceMatch?.[1] || null, - httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null, - idleSeconds: idleMatch ? Number(idleMatch[1]) : null, - waitingFor: waitingForMatch?.[1] || null, - lastEventLabel: lastEventLabelMatch?.[1] || null - }; -} - -function isDomDiagnosticSampleText(text) { - const value = String(text || "").replace(/\s+/g, " ").trim(); - if (!value) return false; - const strongDiagnostic = [ - /\bHTTP\s+[45][0-9]{2}\b(?:[\s\S]{0,120}\btrace_id=|\b)/iu, - /\btrace_id=(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu, - /workbench\s+turn\s*超过\s*\d+ms\s*无新活动/iu, - /\bturn\s*超过\b[\s\S]{0,120}\b无新活动\b/iu, - /\bprojection-resume:sync-failed\b/iu, - /\bAgentRun\s+GET\b[\s\S]*\/result\b[\s\S]*timed out after\s+\d+ms\b/iu, - /\bFailed to fetch\b/iu, - /\bFailed to load resource\b[\s\S]{0,180}\bstatus of\s+[45][0-9]{2}\b/iu, - /\bserver responded with a status of\s+[45][0-9]{2}\b/iu, - /Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束)/iu - ].some((pattern) => pattern.test(value)); - if (!strongDiagnostic) return false; - const looksLikeToolStdout = /\b(?:stdout|stderr):/iu.test(value) - && /(?:\becho\s+["']?===|\bnode\s+|\.tspy\b|tspy\/|===\s*[A-Za-z0-9_.-]+\s*===)/iu.test(value); - if (!looksLikeToolStdout) return true; - return /\b(?:trace_id=|HTTP\s+[45][0-9]{2}|workbench\s+turn\s*超过|projection-resume:sync-failed|Failed to fetch|Failed to load resource|server responded with a status of\s+[45][0-9]{2}|Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束))\b/iu.test(value); -} - -function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { - const promptTimes = control - .filter((item) => item.type === "sendPrompt" && item.phase === "completed") - .map((item) => Date.parse(item.ts)) - .filter(Number.isFinite) - .sort((a, b) => a - b); - const observerRefreshTimes = control - .filter((item) => item.type === "observer-periodic-refresh") - .map((item) => Date.parse(item.ts)) - .filter(Number.isFinite) - .sort((a, b) => a - b); - const naturalNetwork = network.filter((item) => item?.observerInitiated !== true); - const httpErrors = naturalNetwork - .filter((item) => item?.type === "response" && Number(item.status) >= 400) - .map((item) => networkAlertEvent(item, promptTimes)); - const requestFailed = naturalNetwork - .filter((item) => item?.type === "requestfailed") - .map((item) => networkAlertEvent(item, promptTimes)); - const workbenchSessionListReadCount = naturalNetwork - .filter((item) => urlPath(item?.url) === "/v1/workbench/sessions") - .length; - const workbenchTraceEventsReadCount = naturalNetwork - .filter((item) => /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath(item?.url))) - .length; - const webPerformanceBeaconFailureCount = naturalNetwork - .filter((item) => urlPath(item?.url) === "/v1/web-performance" && (item?.type === "requestfailed" || Number(item?.status) >= 400)) - .length; - const workbenchEventSourceFailureCount = naturalNetwork - .filter((item) => urlPath(item?.url) === "/v1/workbench/events" && (item?.type === "requestfailed" || Number(item?.status) >= 400)) - .length; - const significantRequestFailed = requestFailed.filter( - (item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes), - ); - const domDiagnostics = []; - const executionErrors = []; - const baselineExecutionErrors = []; - const canarySessionIds = sessionInvariantCanarySessionIds(control); - const firstPromptMs = promptTimes.length > 0 ? promptTimes[0] : Infinity; - const firstSeenExecutionErrorMs = new Map(); - for (const sample of samples) { - const tsMs = Date.parse(sample?.ts); - const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; - if (Array.isArray(sample?.diagnostics)) { - for (const diagnostic of sample.diagnostics.slice(0, 12)) { - const text = diagnostic?.textPreview || diagnostic?.text || ""; - if (!String(text).trim()) continue; - const parsedDiagnostic = parseDomDiagnosticSummary(text); - domDiagnostics.push({ - seq: sample.seq ?? null, - ts: sample.ts ?? null, - promptIndex, - source: "diagnostic-node", - className: diagnostic.className ?? null, - diagnosticCode: diagnostic.diagnosticCode ?? parsedDiagnostic.diagnosticCode, - traceId: diagnostic.traceId ?? parsedDiagnostic.traceId, - httpStatus: diagnostic.httpStatus ?? parsedDiagnostic.httpStatus, - idleSeconds: diagnostic.idleSeconds ?? parsedDiagnostic.idleSeconds, - waitingFor: diagnostic.waitingFor ?? parsedDiagnostic.waitingFor, - lastEventLabel: diagnostic.lastEventLabel ?? parsedDiagnostic.lastEventLabel, - compact: diagnostic.compact ?? null, - expanded: diagnostic.expanded ?? null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - textHash: diagnostic.textHash || sha256(text), - preview: limitText(text, 260) - }); - } - } - const texts = sampleTexts(sample).filter(isDomDiagnosticSampleText); - for (const text of texts.slice(0, 4)) { - const parsedDiagnostic = parseDomDiagnosticSummary(text); - domDiagnostics.push({ - seq: sample.seq ?? null, - ts: sample.ts ?? null, - promptIndex, - source: "sample-text", - diagnosticCode: parsedDiagnostic.diagnosticCode, - traceId: parsedDiagnostic.traceId, - httpStatus: parsedDiagnostic.httpStatus, - idleSeconds: parsedDiagnostic.idleSeconds, - waitingFor: parsedDiagnostic.waitingFor, - lastEventLabel: parsedDiagnostic.lastEventLabel, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - textHash: sha256(text), - preview: limitText(text, 220) - }); - } - const seenExecutionErrors = new Set(); - for (const candidate of sampleExecutionErrorCandidates(sample)) { - const parsed = parseExecutionErrorText(candidate.text); - if (!parsed) continue; - const textHash = sha256(candidate.text); - const dedupeKey = [candidate.source, candidate.traceId || "-", parsed.backend || "-", parsed.code || "-", parsed.status || "-", textHash].join("|"); - if (seenExecutionErrors.has(dedupeKey)) continue; - seenExecutionErrors.add(dedupeKey); - const firstSeenMs = firstSeenExecutionErrorMs.has(dedupeKey) ? firstSeenExecutionErrorMs.get(dedupeKey) : tsMs; - if (!firstSeenExecutionErrorMs.has(dedupeKey) && Number.isFinite(tsMs)) firstSeenExecutionErrorMs.set(dedupeKey, tsMs); - const baseline = Number.isFinite(firstSeenMs) && firstSeenMs < firstPromptMs; - const event = { - seq: sample.seq ?? null, - ts: sample.ts ?? null, - promptIndex, - baseline, - firstSeenAt: Number.isFinite(firstSeenMs) ? new Date(firstSeenMs).toISOString() : null, - source: candidate.source, - backend: parsed.backend, - status: parsed.status, - code: parsed.code, - rawCode: parsed.rawCode, - totalSeconds: parsed.totalSeconds, - traceId: candidate.traceId || parsed.traceId || null, - messageId: candidate.messageId || null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - textHash, - preview: limitText(candidate.text, 260) - }; - const eventSessions = [event.routeSessionId, event.activeSessionId].filter(Boolean); - const nonCanarySession = canarySessionIds.size > 0 && !eventSessions.some((sessionId) => canarySessionIds.has(sessionId)); - if (baseline || nonCanarySession) baselineExecutionErrors.push({ ...event, baseline: true, baselineReason: nonCanarySession ? "non-canary-session" : "pre-first-prompt" }); - else executionErrors.push(event); - domDiagnostics.push({ - seq: sample.seq ?? null, - ts: sample.ts ?? null, - promptIndex, - source: "execution-row", - diagnosticCode: parsed.rawCode || parsed.code || "execution-error", - traceId: candidate.traceId || parsed.traceId || null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - textHash, - preview: limitText(candidate.text, 220) - }); - } - } - const consoleAlerts = consoleEvents - .filter((item) => /error|warning|warn|assert/iu.test(String(item?.type || "")) || isDiagnosticText(item?.text)) - .map((item) => consoleAlertEvent(item, promptTimes)); - const significantConsoleAlerts = consoleAlerts.filter((item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes)); - const pageErrors = errors.map((item) => ({ - ts: item.ts ?? null, - promptIndex: promptIndexForTs(promptTimes, item.ts), - type: item.type ?? null, - pageRole: item.pageRole ?? item.error?.details?.pageRole ?? null, - pageId: item.pageId ?? item.error?.details?.pageId ?? null, - routeSessionId: item.routeSessionId ?? item.error?.details?.routeSessionId ?? null, - activeSessionId: item.activeSessionId ?? item.error?.details?.activeSessionId ?? null, - commandId: item.commandId ?? item.error?.details?.commandId ?? null, - sampleSeq: item.sampleSeq ?? item.error?.details?.sampleSeq ?? null, - timeoutMs: timeoutMsFromMessage(item.error?.message || item.message || item.error || ""), - errorName: item.error?.name ?? item.name ?? null, - messageHash: item.error?.message ? sha256(item.error.message) : item.message ? sha256(item.message) : null, - preview: limitText(item.error?.message || item.message || item.error || "", 220) - })); - return { - summary: { - httpErrorCount: httpErrors.length, - requestFailedCount: requestFailed.length, - significantRequestFailedCount: significantRequestFailed.length, - workbenchSessionListReadCount, - workbenchTraceEventsReadCount, - webPerformanceBeaconFailureCount, - workbenchEventSourceFailureCount, - benignLongLivedStreamClosureCount: requestFailed.length - significantRequestFailed.length, - domDiagnosticSampleCount: domDiagnostics.length, - domDiagnosticGroupCount: groupDomDiagnostics(domDiagnostics).length, - executionErrorCount: executionErrors.length, - baselineExecutionErrorCount: baselineExecutionErrors.length, - consoleAlertCount: consoleAlerts.length, - significantConsoleAlertCount: significantConsoleAlerts.length, - pageErrorCount: pageErrors.length, - networkErrorGroupCount: groupNetworkAlerts(httpErrors).length, - requestFailedGroupCount: groupNetworkAlerts(requestFailed).length, - significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length, - executionErrorGroupCount: groupExecutionErrors(executionErrors).length, - baselineExecutionErrorGroupCount: groupExecutionErrors(baselineExecutionErrors).length, - consoleAlertGroupCount: groupConsoleAlerts(consoleAlerts).length, - significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length - }, - networkHttpErrorsByPath: groupNetworkAlerts(httpErrors), - networkRequestFailedByPath: groupNetworkAlerts(requestFailed), - networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed), - domDiagnostics: domDiagnostics.slice(-80), - domDiagnosticsByText: groupDomDiagnostics(domDiagnostics), - domDiagnosticsByFingerprint: groupDomDiagnostics(domDiagnostics).slice(0, 80), - runtimeExecutionErrors: executionErrors.slice(0, 120), - runtimeExecutionErrorsByCode: groupExecutionErrors(executionErrors), - baselineRuntimeExecutionErrors: baselineExecutionErrors.slice(0, 80), - baselineRuntimeExecutionErrorsByCode: groupExecutionErrors(baselineExecutionErrors), - consoleAlerts: consoleAlerts.slice(0, 80), - consoleAlertsByPath: groupConsoleAlerts(consoleAlerts), - significantConsoleAlerts: significantConsoleAlerts.slice(0, 80), - significantConsoleAlertsByPath: groupConsoleAlerts(significantConsoleAlerts), - pageErrors: pageErrors.slice(0, 40) - }; -} - -function groupDomDiagnostics(events) { - const groups = new Map(); - for (const item of events || []) { - const preview = String(item?.preview || "").trim(); - if (!isReportableDomDiagnostic(item, preview)) continue; - const normalizedPreview = normalizeDiagnosticPreview(preview); - const key = [ - item?.diagnosticCode || "", - normalizedPreview - ].join("|"); - const existing = groups.get(key) || { - source: item?.source || null, - sources: new Set(), - diagnosticCode: item?.diagnosticCode || null, - textHash: item?.textHash || null, - normalizedPreview, - preview, - count: 0, - firstAt: item?.ts || null, - lastAt: item?.ts || null, - promptIndexes: new Set(), - traceIds: new Set(), - sampleSeqs: [] - }; - if (item?.source) existing.sources.add(String(item.source)); - existing.count += 1; - existing.firstAt = minIso(existing.firstAt, item?.ts || null); - existing.lastAt = maxIso(existing.lastAt, item?.ts || null); - if (Number.isFinite(Number(item?.promptIndex))) existing.promptIndexes.add(Number(item.promptIndex)); - for (const traceId of extractDiagnosticTraceIds(item, preview)) existing.traceIds.add(traceId); - if (existing.sampleSeqs.length < 12 && item?.seq !== undefined && item?.seq !== null) existing.sampleSeqs.push(item.seq); - groups.set(key, existing); - } - return Array.from(groups.values()) - .map((item) => ({ - source: item.source, - sources: Array.from(item.sources).sort(), - diagnosticCode: item.diagnosticCode, - textHash: item.textHash, - normalizedPreview: item.normalizedPreview, - preview: item.preview, - count: item.count, - firstAt: item.firstAt, - lastAt: item.lastAt, - promptIndexes: Array.from(item.promptIndexes).sort((a, b) => a - b), - traceIds: Array.from(item.traceIds).sort(), - sampleSeqs: item.sampleSeqs - })) - .sort((a, b) => (b.count - a.count) || String(a.firstAt || "").localeCompare(String(b.firstAt || ""))); -} - -function isReportableDomDiagnostic(item, preview) { - if (item?.source === "diagnostic-node" || item?.source === "execution-row") return true; - return /trace_id=|HTTP\s+\d{3}\b|Failed to load resource|ERR_[A-Z_]+|provider-unavailable|AgentRun error|超过\s*\d+\s*ms\s*无新活动|代理暂时无法连接上游|Trace 更新超时|加载失败/iu.test(String(preview || "")); -} - -function normalizeDiagnosticPreview(text) { - return String(text || "") - .replace(/trace_id=[A-Za-z0-9_-]+/gu, "trace_id=:traceId") - .replace(/\btrc_[A-Za-z0-9_-]+\b/gu, "trc_:traceId") - .replace(/\bses_[A-Za-z0-9_-]+\b/gu, "ses_:sessionId") - .replace(/\brun_[A-Za-z0-9_-]+\b/gu, "run_:runId") - .replace(/\bcmd_[A-Za-z0-9_-]+\b/gu, "cmd_:commandId") - .replace(/[!!]+$/gu, "") - .replace(/\s+/gu, " ") - .trim(); -} - -function extractDiagnosticTraceIds(item, preview) { - const ids = new Set(); - if (item?.traceId) ids.add(String(item.traceId)); - const text = String(preview || ""); - for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]); - for (const match of text.matchAll(/trace_id=([A-Za-z0-9_-]+)/gu)) ids.add(match[1]); - return ids; -} - -function minIso(a, b) { - if (!a) return b || null; - if (!b) return a || null; - return Date.parse(a) <= Date.parse(b) ? a : b; -} - -function maxIso(a, b) { - if (!a) return b || null; - if (!b) return a || null; - return Date.parse(a) >= Date.parse(b) ? a : b; -} - -function sampleExecutionErrorCandidates(sample) { - const candidates = []; - const add = (source, items) => { - if (!Array.isArray(items)) return; - for (const item of items) { - const text = String(item?.textPreview || item?.text || item?.preview || "").trim(); - if (!text) continue; - if (!parseExecutionErrorText(text)) continue; - candidates.push({ - source, - text, - traceId: item?.traceId ?? null, - messageId: item?.messageId ?? null, - status: item?.status ?? null - }); - } - }; - add("diagnostic-node", sample?.diagnostics); - add("message", sample?.messages); - add("trace-row", sample?.traceRows); - add("turn", sample?.turns); - const specific = candidates.filter((candidate) => { - const parsed = parseExecutionErrorText(candidate.text); - return parsed && parsed.code !== "error"; - }); - return specific.length > 0 ? specific : candidates; -} - -function parseExecutionErrorText(text) { - const value = String(text || ""); - const agentRunCodeMatch = value.match(/\bagentrun:error:([A-Za-z0-9_.:-]+)/u); - const agentRunText = /\bAgentRun\s+error\b|\bagentrun:error:/iu.test(value); - const providerUnavailable = /\bprovider[-_\s]*unavailable\b/iu.test(value); - if (!agentRunCodeMatch && !agentRunText && !providerUnavailable) return null; - const statusMatch = value.match(/\b(fail(?:ed)?|error|blocked|cancel(?:ed)?)\b/iu); - const traceMatch = value.match(/\btrc_[A-Za-z0-9_-]+\b/u); - const totalMatch = value.match(/\btotal\s*=\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)\b/iu) - || value.match(/总耗时\s*[::]?\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)/iu); - const agentRunCode = cleanExecutionCode(agentRunCodeMatch?.[1] || ""); - const rawCode = agentRunCode ? "agentrun:error:" + agentRunCode : providerUnavailable ? "provider-unavailable" : "agentrun:error"; - return { - backend: agentRunText || agentRunCodeMatch ? "agentrun" : "unknown", - status: normalizeExecutionStatus(statusMatch?.[1] || "error"), - code: agentRunCode || (providerUnavailable ? "provider-unavailable" : "error"), - rawCode, - totalSeconds: totalMatch ? parseClockDurationSeconds(totalMatch[1]) : null, - traceId: traceMatch?.[0] || null - }; -} - -function cleanExecutionCode(code) { - const value = String(code || "").replace(/(?:AgentRun|Error|Failed).*$/u, "").replace(/[^A-Za-z0-9_.:-].*$/u, ""); - return value || null; -} - -function normalizeExecutionStatus(status) { - const value = String(status || "").toLowerCase(); - if (value === "failed") return "fail"; - if (value === "cancelled" || value === "canceled") return "cancel"; - return value || "error"; -} - -function parseClockDurationSeconds(value) { - const parts = String(value || "").split(":").map((part) => Number(part)); - if (parts.length === 2 && parts.every(Number.isFinite)) return parts[0] * 60 + parts[1]; - if (parts.length === 3 && parts.every(Number.isFinite)) return parts[0] * 3600 + parts[1] * 60 + parts[2]; - return null; -} - -function groupExecutionErrors(events) { - const groups = new Map(); - for (const event of events) { - const key = [event.backend || "-", event.status || "-", event.code || "-"].join(" "); - const group = groups.get(key) || { - backend: event.backend ?? null, - status: event.status ?? null, - code: event.code ?? null, - rawCode: event.rawCode ?? null, - count: 0, - firstAt: event.ts, - lastAt: event.ts, - promptIndexes: [], - traceIds: [], - sources: [] - }; - group.count += 1; - group.lastAt = event.ts; - if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); - if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId); - if (event.source && !group.sources.includes(event.source)) group.sources.push(event.source); - groups.set(key, group); - } - return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.code).localeCompare(String(b.code))); -} - -function consoleAlertEvent(item, promptTimes) { - const text = String(item?.text || ""); - const statusMatch = text.match(/\bstatus\s+of\s+([1-5][0-9]{2})\b/iu) || text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); - const location = compactLocation(item.location); - const traceMatch = (location?.urlPath || text).match(/\btrc_[A-Za-z0-9_-]+\b/u); - return { - ts: item.ts ?? null, - promptIndex: promptIndexForTs(promptTimes, item.ts), - type: item.type ?? null, - status: statusMatch ? Number(statusMatch[1]) : null, - urlPath: location?.urlPath || "-", - traceId: traceMatch?.[0] || null, - textHash: item.text ? sha256(item.text) : null, - preview: limitText(text, 220), - location - }; -} - -function groupConsoleAlerts(events) { - const groups = new Map(); - for (const event of events) { - const key = [event.type || "-", event.status ?? "-", event.urlPath || "-"].join(" "); - const group = groups.get(key) || { - type: event.type ?? null, - status: event.status ?? null, - urlPath: event.urlPath || "-", - count: 0, - firstAt: event.ts, - lastAt: event.ts, - promptIndexes: [], - traceIds: [] - }; - group.count += 1; - group.lastAt = event.ts; - if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); - if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId); - groups.set(key, group); - } - return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath))); -} - -function isBenignLongLivedStreamClosureAlert(event) { - if (event?.urlPath !== "/v1/workbench/events") return false; - if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false; - const text = String(event.failureKind || event.errorPreview || event.preview || ""); - return /ERR_NETWORK_CHANGED|ERR_ABORTED|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed/iu.test(text); -} - -function isObserverRefreshClosureAlert(event, observerRefreshTimes) { - const urlPath = String(event?.urlPath || ""); - if (!["/v1/workbench/events", "/v1/web-performance"].includes(urlPath) && !/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath)) return false; - if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false; - const text = String(event.failureKind || event.errorPreview || event.preview || ""); - if (!/ERR_NETWORK_CHANGED|ERR_ABORTED|ERR_INCOMPLETE_CHUNKED_ENCODING|ERR_INVALID_CHUNKED_ENCODING|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed|incomplete chunked|invalid chunked/iu.test(text)) return false; - const ts = Date.parse(String(event.ts || "")); - return Number.isFinite(ts) && observerRefreshTimes.some((refreshTs) => Math.abs(ts - refreshTs) <= 8000); -} - -function networkAlertEvent(item, promptTimes) { - const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null; - return { - ts: item.ts ?? null, - promptIndex: promptIndexForTs(promptTimes, item.ts), - method: String(item.method || "GET").toUpperCase(), - status: Number.isFinite(Number(item.status)) ? Number(item.status) : null, - type: item.type ?? null, - urlPath: urlPath(item.url), - urlHash: item.url ? sha256(item.url) : null, - failureKind: failureText ? String(failureText) : null, - errorTextHash: failureText ? sha256(failureText) : null, - errorPreview: failureText ? limitText(failureText, 160) : null - }; -} - -function groupNetworkAlerts(events) { - const groups = new Map(); - for (const event of events) { - const key = [event.method, event.urlPath, event.status ?? "-", event.type].join(" "); - const group = groups.get(key) || { - method: event.method, - urlPath: event.urlPath, - status: event.status, - type: event.type, - count: 0, - firstAt: event.ts, - lastAt: event.ts, - promptIndexes: [], - failureKinds: [], - errorTextHashes: [] - }; - group.count += 1; - group.lastAt = event.ts; - if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex); - if (event.failureKind && !group.failureKinds.includes(event.failureKind)) group.failureKinds.push(event.failureKind); - if (event.errorTextHash && !group.errorTextHashes.includes(event.errorTextHash)) group.errorTextHashes.push(event.errorTextHash); - groups.set(key, group); - } - return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath))); -} - -function isDiagnosticText(text) { - const value = String(text || ""); - return /Failed to (?:fetch|load resource)|request failed|net::ERR_[A-Z0-9_:-]+|server responded with a status of [45][0-9]{2}|HTTP\s+[45][0-9]{2}\b|trace_id=|workbench turn\s*超过|turn\s*超过|无新活动|idle\s+\d+s|waitingFor=|lastEventLabel=|无法连接上游|代理暂时无法连接上游|provider-unavailable|agentrun:error|AgentRun error|projection-resume|sync-failed|durable projection store|realtime-gap|Trace 更新超时|加载失败|请求失败|请求已失败/iu.test(value); -} +${nodeWebObserveAnalyzerPerformanceSource()} function prioritizeFindings(findings) { const items = Array.isArray(findings) ? findings : []; @@ -5790,6 +454,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.startsWith("frontend-long") || id.startsWith("frontend-event-loop-gap") || id === "frontend-cpu-profile-hotspots") return 0; if (id === "page-performance-slow-same-origin-api") return 0; if (id === "session-rail-title-fallback-majority") return 0.5; if (id === "workbench-turn-state-triad-inconsistent") return 0.55; @@ -5809,595 +474,8 @@ function prioritizeFindings(findings) { }); } -function isTerminalTraceText(text) { - return /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(text || "")); -} +${nodeWebObserveAnalyzerSampleMetricsSource()} -function isFinalResultText(text) { - return /已完成第\d+轮|已按第\d+轮完成|final response|sealed final response|最终结果|已完成[::]|smoke\s*测试结果|benchmark|PVC\/workspace|修改文件|Results:/iu.test(String(text || "")); -} - -function buildSampleMetrics(samples, control) { - const promptCommands = buildSendPromptCommandTimeline(control); - const promptTimes = promptCommands.map((item) => item.tsMs); - const timeline = samples.map((sample) => { - const texts = sampleTexts(sample); - const timingTexts = sampleTurnTimingTexts(sample); - const tsMs = Date.parse(sample.ts); - const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; - const totalElapsedValues = timingTexts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite); - const recentUpdateValues = timingTexts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite); - const diagnosticTexts = texts.filter(isDiagnosticText).slice(0, 5); - const terminalTexts = texts.filter(isTerminalTraceText).slice(0, 5); - const finalResultTexts = texts.filter(isFinalResultText).slice(0, 5); - const loadings = Array.isArray(sample.loadings) ? sample.loadings : []; - const loadingOwners = uniqueLoadingOwners(loadings); - return { - seq: sample.seq ?? null, - ts: sample.ts ?? null, - routeSessionId: sample.routeSessionId ?? null, - activeSessionId: sample.activeSessionId ?? null, - promptIndex, - messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, - traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, - loadingCount: loadings.length, - loadingOwnerCount: loadingOwners.length, - loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12), - sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0), - sessionRailFallbackTitleCount: Number(sample?.sessionRail?.fallbackTitleCount ?? 0), - sessionRailFallbackTitleRatio: Number(sample?.sessionRail?.fallbackTitleRatio ?? 0), - totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, - recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, - terminalSeen: terminalTexts.length > 0, - finalResultTextSeen: finalResultTexts.length > 0, - diagnosticSeen: diagnosticTexts.length > 0, - diagnosticTextHashes: diagnosticTexts.map(sha256).slice(0, 5), - textDigest: digestSample(sample) - }; - }); - const turnTiming = buildTurnTimingTable(samples, timeline); - const traceOrder = buildTraceOrderMetrics(samples, timeline); - const codeAgentCardTiming = buildCodeAgentCardTimingMetrics(samples, timeline, turnTiming); - const codeAgentCardDurationUnderreported = buildCodeAgentCardDurationUnderreportedMetrics(samples, timeline); - const codeAgentCardDurationMismatches = buildCodeAgentCardDurationMismatchMetrics(samples, timeline); - if (codeAgentCardTiming && codeAgentCardTiming.summary) { - codeAgentCardTiming.summary.durationUnderreportedCount = codeAgentCardDurationUnderreported.length; - codeAgentCardTiming.summary.durationMismatchCount = codeAgentCardDurationMismatches.length; - codeAgentCardTiming.durationUnderreported = codeAgentCardDurationUnderreported; - codeAgentCardTiming.durationMismatches = codeAgentCardDurationMismatches; - } - const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); - const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : []; - const turnTimingElapsedZeroResets = Array.isArray(turnTiming.elapsedZeroResets) ? turnTiming.elapsedZeroResets : []; - const turnTimingTotalElapsedForwardJumps = Array.isArray(turnTiming.totalElapsedForwardJumps) ? turnTiming.totalElapsedForwardJumps : []; - const turnTimingRecentUpdateSawtoothJumps = turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump"); - const turnTimingTerminalElapsedGrowth = Array.isArray(turnTiming.terminalElapsedGrowth) ? turnTiming.terminalElapsedGrowth : []; - const turnTimingRecentUpdateResets = Array.isArray(turnTiming.recentUpdateResets) ? turnTiming.recentUpdateResets : []; - const turnTimingRecentUpdateSteps = Array.isArray(turnTiming.recentUpdateSteps) ? turnTiming.recentUpdateSteps : []; - const turnTimingTerminalElapsedGrowthDeltas = turnTimingTerminalElapsedGrowth - .map((item) => Number(item.delta)) - .filter((value) => Number.isFinite(value) && value > 0); - const turnTimingRecentUpdateLargestSteps = turnTimingRecentUpdateSteps - .filter((item) => Number.isFinite(Number(item.delta))) - .slice() - .sort((a, b) => Number(b.delta) - Number(a.delta)) - .slice(0, 200); - const turnTimingRecentUpdatePositiveSteps = turnTimingRecentUpdateSteps - .map((item) => Number(item.delta)) - .filter((value) => Number.isFinite(value) && value >= 0); - const turnTimingRecentUpdateExcessSteps = turnTimingRecentUpdateSteps - .map((item) => Number(item.excessiveIncreaseSeconds)) - .filter((value) => Number.isFinite(value) && value > 0); - const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length; - const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length; - const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; - const loading = buildLoadingMetrics(samples, timeline); - const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline); - const workbenchTurnStateTriad = buildWorkbenchTurnStateTriadMetrics(samples, timeline); - const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows); - const reportTimeline = boundedRowsForReport(timeline); - const rounds = buildRoundMetricSummaries(timeline, promptCommands, { - columns: turnTiming.columns, - rows: turnTiming.rows, - nonMonotonic: turnTimingNonMonotonic, - elapsedZeroResets: turnTimingElapsedZeroResets, - totalElapsedForwardJumps: turnTimingTotalElapsedForwardJumps, - terminalElapsedGrowth: turnTimingTerminalElapsedGrowth, - recentUpdateResets: turnTimingRecentUpdateResets, - recentUpdateSteps: turnTimingRecentUpdateSteps - }); - const recentUpdateJumpCount = turnTimingRecentUpdateSawtoothJumps.length; - return { - summary: { - sampleCount: timeline.length, - withTotalElapsed: withTotal, - withRecentUpdate: withRecent, - diagnostics, - loadingSampleCount: loading.summary.loadingSampleCount, - loadingMaxCount: loading.summary.maxSimultaneousCount, - loadingMaxOwnerCount: loading.summary.maxSimultaneousOwnerCount, - loadingOwnerCount: loading.summary.ownerCount, - loadingConcurrentSampleCount: loading.summary.concurrentLoadingSampleCount, - loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds, - loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds, - loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount, - loadingOverBudgetSegmentCount: loading.summary.overBudgetSegmentCount, - sessionRailSampleCount: sessionRailTitles.summary.sampleCount, - sessionRailVisibleSampleCount: sessionRailTitles.summary.visibleSampleCount, - sessionRailFallbackMajoritySampleCount: sessionRailTitles.summary.majorityFallbackSampleCount, - sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio, - sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount, - sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount, - workbenchTurnStateTriadRows: workbenchTurnStateTriad.summary.rowCount, - workbenchTurnStateTriadInvalidRows: workbenchTurnStateTriad.summary.invalidRowCount, - workbenchTurnStateTriadFullInvalidRows: workbenchTurnStateTriad.summary.invalidFullTriadCount, - workbenchTurnStateCardFinalResponseMismatchRows: workbenchTurnStateTriad.summary.cardFinalResponseMismatchCount, - workbenchTurnStateCollectorMissingRows: workbenchTurnStateTriad.summary.collectorMissingRowCount, - promptSegments: Math.max(0, promptTimes.length), - rounds: rounds.length, - turnColumns: turnTiming.columns.length, - turnTimingRows: turnTiming.rows.length, - turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length, - turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length, - turnTimingNonMonotonicCount: turnTimingNonMonotonic.length, - turnTimingTotalElapsedDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds").length, - turnTimingTotalElapsedZeroResetCount: turnTimingElapsedZeroResets.length, - turnTimingTotalElapsedForwardJumpCount: turnTimingTotalElapsedForwardJumps.length, - turnTimingTotalElapsedForwardJumpMaxSeconds: maxPositiveDelta(turnTimingTotalElapsedForwardJumps), - turnTimingTerminalElapsedGrowthCount: turnTimingTerminalElapsedGrowth.length, - turnTimingTerminalElapsedGrowthMaxSeconds: turnTimingTerminalElapsedGrowthDeltas.length > 0 ? Math.max(...turnTimingTerminalElapsedGrowthDeltas) : 0, - turnTimingRecentUpdateJumpCount: recentUpdateJumpCount, - turnTimingRecentUpdateSawtoothJumpCount: recentUpdateJumpCount, - turnTimingRecentUpdateStepCount: turnTimingRecentUpdateSteps.length, - turnTimingRecentUpdateMaxIncreaseSeconds: turnTimingRecentUpdatePositiveSteps.length > 0 ? Math.max(...turnTimingRecentUpdatePositiveSteps) : null, - turnTimingRecentUpdateMaxExcessSeconds: turnTimingRecentUpdateExcessSteps.length > 0 ? Math.max(...turnTimingRecentUpdateExcessSteps) : 0, - turnTimingRecentUpdateResetCount: turnTimingRecentUpdateResets.length, - turnTimingRecentUpdateDecreaseCount: turnTimingRecentUpdateResets.length, - codeAgentCardSampleCount: codeAgentCardTiming.summary.cardSampleCount, - codeAgentCardMissingElapsedCount: codeAgentCardTiming.summary.missingElapsedCount, - codeAgentCardMissingRecentUpdateCount: codeAgentCardTiming.summary.missingRecentUpdateCount, - roundCompletionEventCount: codeAgentCardTiming.summary.roundCompletionEventCount, - roundCompletionElapsedMismatchCount: codeAgentCardTiming.summary.roundCompletionElapsedMismatchCount, - roundCompletionFinalResponseMissingCount: codeAgentCardTiming.summary.roundCompletionFinalResponseMissingCount, - roundCompletionPostTimingChangeCount: codeAgentCardTiming.summary.roundCompletionPostTimingChangeCount, - codeAgentCardDurationUnderreportedCount: codeAgentCardTiming.summary.durationUnderreportedCount, - codeAgentCardDurationMismatchCount: codeAgentCardTiming.summary.durationMismatchCount, - traceRowCount: traceOrder.summary.traceRowCount, - traceRowOrderAnomalyCount: traceOrder.summary.orderAnomalyCount, - traceRowCompletionNotLastCount: traceOrder.summary.completionNotLastCount, - roundsWithTurnTimingNonMonotonic: rounds.filter((item) => item.turnTimingNonMonotonicCount > 0).length, - roundsWithTurnTimingTotalElapsedForwardJumps: rounds.filter((item) => item.turnTimingTotalElapsedForwardJumpCount > 0).length, - roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length, - roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length - }, - loading, - sessionRailTitles, - workbenchTurnStateTriad, - codeAgentCardTiming, - traceOrder, - rounds, - turnColumns: turnTiming.columns, - turnTimingTable: reportTurnTimingRows.rows, - turnTimingTableDisclosure: reportTurnTimingRows.disclosure, - turnTimingNonMonotonic, - turnTimingElapsedZeroResets, - turnTimingTotalElapsedForwardJumps, - turnTimingTerminalElapsedGrowth, - turnTimingRecentUpdateSawtoothJumps, - turnTimingRecentUpdateSteps, - turnTimingRecentUpdateLargestSteps, - turnTimingRecentUpdateResets, - timeline: reportTimeline.rows, - timelineDisclosure: reportTimeline.disclosure - }; -} - -function boundedRowsForReport(rows) { - const sourceRows = Array.isArray(rows) ? rows : []; - const maxRows = 1200; - const headRows = 120; - if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; - const tailRows = Math.max(0, maxRows - headRows); - return { - rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], - disclosure: { - truncated: true, - totalRows: sourceRows.length, - includedRows: maxRows, - omittedRows: Math.max(0, sourceRows.length - maxRows), - headRows, - tailRows, - policy: "report-bounded-head-tail; summary metrics are computed before truncation", - valuesRedacted: true - } - }; -} - -function buildSendPromptCommandTimeline(control) { - const byCommand = new Map(); - let ordinal = 0; - for (const item of Array.isArray(control) ? control : []) { - if (item?.type !== "sendPrompt") continue; - const commandId = item.commandId ? String(item.commandId) : "sendPrompt-" + String(ordinal++); - const existing = byCommand.get(commandId) || { - commandId, - startedAt: null, - startedTsMs: null, - completedAt: null, - completedTsMs: null, - failedAt: null, - failedTsMs: null, - textHash: null, - textBytes: null, - }; - if (!existing.textHash && item.input?.textHash) existing.textHash = item.input.textHash; - if (!existing.textBytes && item.input?.textBytes) existing.textBytes = item.input.textBytes; - const tsMs = Date.parse(item.ts); - if (item.phase === "started" && Number.isFinite(tsMs)) { - existing.startedAt = item.ts ?? existing.startedAt; - existing.startedTsMs = tsMs; - } else if (item.phase === "completed" && Number.isFinite(tsMs)) { - existing.completedAt = item.ts ?? existing.completedAt; - existing.completedTsMs = tsMs; - } else if (item.phase === "failed" && Number.isFinite(tsMs)) { - existing.failedAt = item.ts ?? existing.failedAt; - existing.failedTsMs = tsMs; - } - byCommand.set(commandId, existing); - } - return Array.from(byCommand.values()) - .map((item) => { - const tsMs = Number.isFinite(item.startedTsMs) ? item.startedTsMs : Number.isFinite(item.completedTsMs) ? item.completedTsMs : item.failedTsMs; - const ts = Number.isFinite(item.startedTsMs) ? item.startedAt : Number.isFinite(item.completedTsMs) ? item.completedAt : item.failedAt; - return { ...item, ts, tsMs }; - }) - .filter((item) => Number.isFinite(item.tsMs)) - .sort((a, b) => a.tsMs - b.tsMs) - .map((item, index) => ({ ...item, promptIndex: index + 1 })); -} - -function buildSessionRailTitleMetrics(samples, timeline) { - const rows = []; - const examplesByHash = new Map(); - for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) { - const sample = samples[index]; - const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null; - if (!rail) continue; - const visibleCount = Number(rail.visibleCount ?? 0); - const fallbackTitleCount = Number(rail.fallbackTitleCount ?? 0); - const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0; - const safeFallbackTitleCount = Number.isFinite(fallbackTitleCount) && fallbackTitleCount > 0 ? fallbackTitleCount : 0; - const fallbackTitleRatio = safeVisibleCount > 0 ? Number((safeFallbackTitleCount / safeVisibleCount).toFixed(4)) : 0; - const fallbackItems = Array.isArray(rail.fallbackItems) ? rail.fallbackItems : []; - for (const item of fallbackItems) { - const hash = String(item?.titleHash || item?.titlePreview || item?.sessionIdPrefix || "").trim(); - if (!hash || examplesByHash.has(hash)) continue; - examplesByHash.set(hash, { - titleHash: item?.titleHash ?? null, - titlePreview: limitText(String(item?.titlePreview || ""), 160), - sessionIdPrefix: item?.sessionIdPrefix ?? null, - active: item?.active === true, - firstSeq: sample?.seq ?? null, - firstAt: sample?.ts ?? null, - pageRole: sample?.pageRole ?? null, - }); - } - rows.push({ - ...ref(sample), - promptIndex: timeline[index]?.promptIndex ?? 0, - visibleCount: safeVisibleCount, - fallbackTitleCount: safeFallbackTitleCount, - fallbackTitleRatio, - majorityFallback: safeVisibleCount > 0 && safeFallbackTitleCount > safeVisibleCount / 2, - overThreshold: safeVisibleCount > 0 && fallbackTitleRatio > alertThresholds.sessionRailFallbackRatio, - examples: fallbackItems.slice(0, 5).map((item) => ({ - titleHash: item?.titleHash ?? null, - titlePreview: limitText(String(item?.titlePreview || ""), 160), - sessionIdPrefix: item?.sessionIdPrefix ?? null, - active: item?.active === true, - })), - }); - } - const visibleRows = rows.filter((item) => item.visibleCount > 0); - const majorityRows = rows.filter((item) => item.majorityFallback); - const overThresholdRows = rows.filter((item) => item.overThreshold); - const fallbackRows = rows.filter((item) => item.fallbackTitleCount > 0); - const maxFallbackRatio = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleRatio) || 0)) : 0; - const maxVisibleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.visibleCount) || 0)) : 0; - const maxFallbackTitleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleCount) || 0)) : 0; - return { - summary: { - sampleCount: rows.length, - visibleSampleCount: visibleRows.length, - fallbackSampleCount: fallbackRows.length, - majorityFallbackSampleCount: majorityRows.length, - overThresholdSampleCount: overThresholdRows.length, - thresholdRatio: alertThresholds.sessionRailFallbackRatio, - maxFallbackRatio, - maxVisibleCount, - maxFallbackTitleCount, - }, - samples: majorityRows.slice(0, 80), - examples: Array.from(examplesByHash.values()).slice(0, 80), - timeline: rows.slice(-200), - valuesRedacted: true - }; -} - -function uniqueLoadingOwners(loadings) { - const groups = new Map(); - for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) { - const item = loadings[index]; - const ownerKey = loadingOwnerKey(item, index); - const ownerIdentity = loadingOwnerIdentity(item); - const existing = groups.get(ownerKey) || { - ownerKey, - ownerKind: item?.ownerKind ?? "unknown", - ownerLabel: loadingOwnerLabel(item, ownerKey), - ...ownerIdentity, - count: 0, - textHashes: [] - }; - existing.count += 1; - if (item?.textHash && !existing.textHashes.includes(item.textHash)) existing.textHashes.push(item.textHash); - for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) { - if (!existing[key] && ownerIdentity[key]) existing[key] = ownerIdentity[key]; - } - groups.set(ownerKey, existing); - } - return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))); -} - -function loadingOwnerIdentity(item) { - const owner = item?.owner && typeof item.owner === "object" ? item.owner : {}; - return { - ownerSessionId: owner.sessionId ?? null, - ownerMessageId: owner.messageId ?? null, - ownerTraceId: owner.traceId ?? null, - }; -} - -function loadingOwnerKey(item, index = 0) { - const key = String(item?.ownerKey || "").trim(); - if (key) return key.slice(0, 240); - const owner = item?.owner && typeof item.owner === "object" ? item.owner : {}; - return [ - item?.ownerKind || "unknown", - owner.testId || item?.testId || owner.id || owner.role || owner.className || item?.role || item?.tag || "node", - owner.sessionId || owner.messageId || owner.traceId || item?.textHash || String(index) - ].filter(Boolean).join(":").slice(0, 240); -} - -function loadingOwnerLabel(item, fallback) { - return limitText(String(item?.ownerLabel || item?.owner?.ariaLabel || item?.owner?.testId || item?.owner?.className || fallback || "unknown"), 160); -} - -function buildLoadingMetrics(samples, timeline) { - const events = samples.map((sample, index) => { - const tsMs = Date.parse(sample?.ts); - const loadings = Array.isArray(sample?.loadings) ? sample.loadings : []; - const owners = uniqueLoadingOwners(loadings); - return { - seq: sample?.seq ?? null, - ts: sample?.ts ?? null, - tsMs, - promptIndex: timeline[index]?.promptIndex ?? 0, - routeSessionId: sample?.routeSessionId ?? null, - activeSessionId: sample?.activeSessionId ?? null, - loadingCount: loadings.length, - ownerCount: owners.length, - owners, - ownerKeys: owners.map((item) => item.ownerKey), - ownerLabels: owners.map((item) => item.ownerLabel).slice(0, 8) - }; - }).filter((item) => Number.isFinite(item.tsMs)); - const continuityThresholdMs = loadingContinuityThresholdMs(events); - const segments = buildLoadingSegments(events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners) - .sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0) || Number(b.maxCount ?? 0) - Number(a.maxCount ?? 0)); - const ownerMap = new Map(); - for (const event of events) { - for (const owner of event.owners) { - const existing = ownerMap.get(owner.ownerKey) || { - ownerKey: owner.ownerKey, - ownerKind: owner.ownerKind, - ownerLabel: owner.ownerLabel, - ownerSessionId: owner.ownerSessionId ?? null, - ownerMessageId: owner.ownerMessageId ?? null, - ownerTraceId: owner.ownerTraceId ?? null, - sampleCount: 0, - occurrenceCount: 0, - maxSimultaneousCount: 0, - firstAt: event.ts, - lastAt: event.ts, - firstSeq: event.seq, - lastSeq: event.seq, - promptIndexes: new Set(), - events: [] - }; - existing.sampleCount += 1; - existing.occurrenceCount += owner.count; - existing.maxSimultaneousCount = Math.max(existing.maxSimultaneousCount, owner.count); - existing.lastAt = event.ts; - existing.lastSeq = event.seq; - for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) { - if (!existing[key] && owner[key]) existing[key] = owner[key]; - } - if (Number.isFinite(Number(event.promptIndex))) existing.promptIndexes.add(Number(event.promptIndex)); - existing.events.push({ ...event, loadingCount: owner.count, owners: [owner] }); - ownerMap.set(owner.ownerKey, existing); - } - } - const owners = Array.from(ownerMap.values()).map((owner) => { - const ownerSegments = buildLoadingSegments(owner.events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners); - const longest = ownerSegments.reduce((max, item) => Math.max(max, Number(item.durationSeconds ?? 0)), 0); - return { - ownerKey: owner.ownerKey, - ownerKind: owner.ownerKind, - ownerLabel: owner.ownerLabel, - ownerSessionId: owner.ownerSessionId ?? null, - ownerMessageId: owner.ownerMessageId ?? null, - ownerTraceId: owner.ownerTraceId ?? null, - sampleCount: owner.sampleCount, - occurrenceCount: owner.occurrenceCount, - maxSimultaneousCount: owner.maxSimultaneousCount, - longestContinuousSeconds: longest, - firstAt: owner.firstAt, - lastAt: owner.lastAt, - firstSeq: owner.firstSeq, - lastSeq: owner.lastSeq, - promptIndexes: Array.from(owner.promptIndexes).sort((a, b) => a - b), - segments: ownerSegments.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0)).slice(0, 8), - valuesRedacted: true - }; - }).sort((a, b) => Number(b.longestContinuousSeconds ?? 0) - Number(a.longestContinuousSeconds ?? 0) || Number(b.occurrenceCount ?? 0) - Number(a.occurrenceCount ?? 0)); - const latest = events[events.length - 1] || null; - const currentSegment = latest && latest.loadingCount > 0 - ? segments.find((segment) => segment.ongoing === true && segment.lastSeq === latest.seq) || null - : null; - const timelineRows = events - .filter((event, index) => event.loadingCount > 0 || (index > 0 && events[index - 1]?.loadingCount > 0)) - .slice(0, 500) - .map((event) => ({ - seq: event.seq, - ts: event.ts, - promptIndex: event.promptIndex, - loadingCount: event.loadingCount, - ownerCount: event.ownerCount, - owners: event.owners.map((owner) => ({ ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, ownerSessionId: owner.ownerSessionId ?? null, ownerMessageId: owner.ownerMessageId ?? null, ownerTraceId: owner.ownerTraceId ?? null, count: owner.count })).slice(0, 8) - })); - return { - summary: { - sampleCount: events.length, - loadingSampleCount: events.filter((event) => event.loadingCount > 0).length, - maxSimultaneousCount: events.reduce((max, event) => Math.max(max, event.loadingCount), 0), - maxSimultaneousOwnerCount: events.reduce((max, event) => Math.max(max, event.ownerCount), 0), - concurrentLoadingSampleCount: events.filter((event) => event.loadingCount > 1).length, - ownerCount: owners.length, - segmentCount: segments.length, - overFiveSecondSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > 5).length, - overBudgetSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > alertThresholds.visibleLoadingSlowMs / 1000).length, - budgetSeconds: alertThresholds.visibleLoadingSlowMs / 1000, - longestContinuousSeconds: segments.length > 0 ? Number(segments[0].durationSeconds ?? 0) : 0, - currentContinuousSeconds: currentSegment ? Number(currentSegment.durationSeconds ?? 0) : 0, - continuityThresholdMs, - latestLoadingCount: latest?.loadingCount ?? 0, - latestOwnerCount: latest?.ownerCount ?? 0, - valuesRedacted: true - }, - segments: segments.slice(0, 80), - owners: owners.slice(0, 80), - timeline: timelineRows, - valuesRedacted: true - }; -} - -function loadingContinuityThresholdMs(events) { - const deltas = []; - for (let index = 1; index < events.length; index += 1) { - const delta = events[index].tsMs - events[index - 1].tsMs; - if (Number.isFinite(delta) && delta > 0) deltas.push(delta); - } - if (deltas.length === 0) return 5000; - const sorted = deltas.slice().sort((a, b) => a - b); - const median = sorted[Math.floor(sorted.length / 2)]; - return Math.min(15000, Math.max(1500, Math.round(median * 2.5))); -} - -function buildLoadingSegments(events, continuityThresholdMs, countForEvent, ownersForEvent) { - const segments = []; - let segment = null; - let previousTsMs = null; - for (const event of events) { - const count = Number(countForEvent(event) ?? 0); - const gapOk = previousTsMs === null || !Number.isFinite(event.tsMs) || event.tsMs - previousTsMs <= continuityThresholdMs; - if (count > 0) { - if (!segment || !gapOk) { - if (segment) segments.push(finalizeLoadingSegment(segment, null)); - segment = { - firstAt: event.ts, - lastAt: event.ts, - firstSeq: event.seq, - lastSeq: event.seq, - promptIndexes: new Set(), - ownerKeys: new Set(), - ownerLabels: new Map(), - sampleCount: 0, - maxCount: 0, - ongoing: true - }; - } - segment.lastAt = event.ts; - segment.lastSeq = event.seq; - segment.sampleCount += 1; - segment.maxCount = Math.max(segment.maxCount, count); - if (Number.isFinite(Number(event.promptIndex))) segment.promptIndexes.add(Number(event.promptIndex)); - for (const owner of ownersForEvent(event) || []) { - if (!owner?.ownerKey) continue; - segment.ownerKeys.add(owner.ownerKey); - if (!segment.ownerLabels.has(owner.ownerKey)) segment.ownerLabels.set(owner.ownerKey, { ownerKey: owner.ownerKey, ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: 0 }); - const label = segment.ownerLabels.get(owner.ownerKey); - label.count += owner.count ?? 1; - } - } else if (segment) { - segment.ongoing = false; - segment.endedAt = event.ts; - segment.endSeq = event.seq; - segments.push(finalizeLoadingSegment(segment, event)); - segment = null; - } - previousTsMs = event.tsMs; - } - if (segment) segments.push(finalizeLoadingSegment(segment, null)); - return segments; -} - -function finalizeLoadingSegment(segment, absentEvent) { - const startMs = Date.parse(segment.firstAt || ""); - const lastMs = Date.parse(segment.lastAt || ""); - const absentMs = Date.parse(absentEvent?.ts || ""); - const durationSeconds = Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs >= startMs ? Number(((lastMs - startMs) / 1000).toFixed(3)) : 0; - const upperBoundSeconds = Number.isFinite(startMs) && Number.isFinite(absentMs) && absentMs >= startMs ? Number(((absentMs - startMs) / 1000).toFixed(3)) : durationSeconds; - const endedGapSeconds = Number.isFinite(lastMs) && Number.isFinite(absentMs) && absentMs >= lastMs ? Number(((absentMs - lastMs) / 1000).toFixed(3)) : null; - return { - firstAt: segment.firstAt, - lastAt: segment.lastAt, - endedAt: absentEvent?.ts ?? null, - firstSeq: segment.firstSeq, - lastSeq: segment.lastSeq, - endSeq: absentEvent?.seq ?? null, - durationSeconds, - upperBoundSeconds, - endedGapSeconds, - sampleCount: segment.sampleCount, - maxCount: segment.maxCount, - ownerCount: segment.ownerKeys.size, - owners: Array.from(segment.ownerLabels.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))).slice(0, 12), - promptIndexes: Array.from(segment.promptIndexes).sort((a, b) => a - b), - ongoing: absentEvent ? false : segment.ongoing === true, - valuesRedacted: true - }; -} - -function boundedTurnTimingRowsForReport(rows) { - const sourceRows = Array.isArray(rows) ? rows : []; - const maxRows = 1200; - const headRows = 120; - if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; - const tailRows = Math.max(0, maxRows - headRows); - return { - rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], - disclosure: { - truncated: true, - totalRows: sourceRows.length, - includedRows: maxRows, - omittedRows: Math.max(0, sourceRows.length - maxRows), - headRows, - tailRows, - policy: "report-bounded-head-tail; full anomaly counters are computed before truncation", - valuesRedacted: true - } - }; -} ` + nodeWebObserveAnalyzerTimingSource(); } diff --git a/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts new file mode 100644 index 00000000..0a4ee152 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts @@ -0,0 +1,458 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer recent-window, page provenance, and Navigation Timing API performance source fragment. + +export function nodeWebObserveAnalyzerWindowPageSource(): string { + return String.raw`function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) { + const latestSampleMs = latestTimestampMs(samples); + const windowMs = 5 * 60 * 1000; + const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY; + const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY; + const inWindow = (item) => { + const tsMs = Date.parse(item?.ts); + return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs; + }; + const windowSamples = samples.filter(inWindow); + const windowControl = control.filter(inWindow); + const windowNetwork = network.filter(inWindow); + const windowConsole = consoleEvents.filter(inWindow); + const windowErrors = errors.filter(inWindow); + const windowArtifacts = (artifacts || []).filter(inWindow); + const windowBrowserProcessRows = (browserProcessRows || []).filter(inWindow); + const windowPerformanceRows = (performanceRows || []).filter(inWindow); + const sampleMetrics = buildSampleMetrics(windowSamples, control); + const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest); + const pagePerformance = buildPagePerformanceReport(windowSamples, manifest); + const requestRate = buildRequestRateReport(windowNetwork); + const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork); + const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); + const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork); + const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows); + const frontendPerformance = buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts); + const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess, frontendPerformance); + return { + summary: { + name: "recent-5m", + windowMs, + fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null, + toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null, + samples: windowSamples.length, + control: windowControl.length, + network: windowNetwork.length, + console: windowConsole.length, + errors: windowErrors.length, + artifacts: windowArtifacts.length, + browserProcess: windowBrowserProcessRows.length, + performance: windowPerformanceRows.length, + valuesRedacted: true + }, + sampleMetrics, + pageProvenance, + pagePerformance, + requestRate, + promptNetwork, + runtimeAlerts, + apiDomLag, + browserProcess, + frontendPerformance, + findings, + valuesRedacted: true + }; +} + +function latestTimestampMs(items) { + let latest = Number.NEGATIVE_INFINITY; + for (const item of items || []) { + const tsMs = Date.parse(item?.ts); + if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs; + } + return latest; +} + +function buildPageProvenanceReport(samples, control, manifest) { + const groups = new Map(); + for (const sample of samples) { + const provenance = sample?.pageProvenance; + if (!provenance) continue; + const key = provenance.assetFingerprint || "unknown"; + const group = groups.get(key) || { + assetFingerprint: provenance.assetFingerprint || null, + pageLoadSeqs: [], + sampleCount: 0, + firstSeq: sample.seq ?? null, + lastSeq: sample.seq ?? null, + firstAt: sample.ts ?? null, + lastAt: sample.ts ?? null, + urlPaths: [], + scriptCount: provenance.scriptCount ?? null, + stylesheetCount: provenance.stylesheetCount ?? null, + metaCount: provenance.metaCount ?? null, + scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [], + stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [], + valuesRedacted: true + }; + group.sampleCount += 1; + group.lastSeq = sample.seq ?? null; + group.lastAt = sample.ts ?? null; + if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq); + if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath); + groups.set(key, group); + } + const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0)); + const controlSegments = control + .filter((item) => item.type === "page-provenance" || item?.pageProvenance) + .map((item) => ({ + ts: item.ts ?? null, + reason: item.reason ?? item.detail?.reason ?? null, + httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null, + pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null, + })) + .slice(0, 80); + return { + summary: { + segmentCount: segments.length, + sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0), + manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null, + controlSegmentCount: controlSegments.length + }, + segments, + controlSegments, + valuesRedacted: true + }; +} + +function buildPagePerformanceReport(samples, manifest) { + const base = manifest?.baseUrl || "http://invalid.local"; + const seen = new Set(); + const groups = new Map(); + const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite); + const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null; + const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null; + for (const sample of samples) { + const entries = Array.isArray(sample?.performance) ? sample.performance : []; + for (const entry of entries) { + const durationMs = Number(entry?.duration); + if (!Number.isFinite(durationMs) || durationMs < 0) continue; + const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry); + if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue; + if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue; + const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString(); + const parsed = parsePerformanceUrl(entry?.name, base); + if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue; + const normalizedPath = normalizeApiPath(parsed.path); + const routeKind = classifyApiPerformanceRoute(normalizedPath, entry); + const isLongLivedStream = routeKind === "same-origin-api-stream"; + const streamOpenMs = streamOpenLatencyMs(entry); + const timingStatus = resourceTimingPhaseStatus(entry); + const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|"); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + const group = groups.get(normalizedPath) || { + routeKind, + path: normalizedPath, + isLongLivedStream, + budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs", + rawPathSamples: [], + sampleCount: 0, + completeTimingSampleCount: 0, + partialTimingSampleCount: 0, + durationsMs: [], + streamOpenDurationsMs: [], + overFiveSecondCount: 0, + overBudgetCount: 0, + partialOverFiveSecondCount: 0, + partialOverBudgetCount: 0, + streamLifetimeOverFiveSecondCount: 0, + streamOpenOverFiveSecondCount: 0, + streamOpenOverBudgetCount: 0, + firstAt: entryTs, + lastAt: entryTs, + firstSeq: sample.seq ?? null, + lastSeq: sample.seq ?? null, + initiatorTypes: [], + pageAssetFingerprints: [], + slowSamples: [], + partialSamples: [], + valuesRedacted: true + }; + group.sampleCount += 1; + const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete"; + let overBudget = false; + if (partialOrdinaryTiming) { + group.partialTimingSampleCount += 1; + if (durationMs > 5000) group.partialOverFiveSecondCount += 1; + if (durationMs > alertThresholds.partialApiSlowMs) { + group.partialOverBudgetCount += 1; + if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); + } + } else { + group.completeTimingSampleCount += 1; + group.durationsMs.push(durationMs); + if (isLongLivedStream) { + if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1; + if (streamOpenMs !== null) { + group.streamOpenDurationsMs.push(streamOpenMs); + if (streamOpenMs > 5000) { + group.streamOpenOverFiveSecondCount += 1; + group.overFiveSecondCount += 1; + } + if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) { + group.streamOpenOverBudgetCount += 1; + group.overBudgetCount += 1; + overBudget = true; + } + } + } else { + if (durationMs > 5000) group.overFiveSecondCount += 1; + if (durationMs > alertThresholds.sameOriginApiSlowMs) { + group.overBudgetCount += 1; + overBudget = true; + } + } + } + if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); + group.lastAt = entryTs; + group.lastSeq = sample.seq ?? null; + if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path); + if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType); + const assetFingerprint = sample?.pageProvenance?.assetFingerprint; + if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint); + groups.set(normalizedPath, group); + } + } + const sameOriginApiByPath = Array.from(groups.values()).map((group) => { + const durations = group.durationsMs.slice().sort((a, b) => a - b); + const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b); + return { + routeKind: group.routeKind, + path: group.path, + isLongLivedStream: group.isLongLivedStream === true, + budgetMetric: group.budgetMetric, + sampleCount: group.sampleCount, + budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs, + partialBudgetMs: alertThresholds.partialApiSlowMs, + streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs, + completeTimingSampleCount: group.completeTimingSampleCount, + partialTimingSampleCount: group.partialTimingSampleCount, + p50Ms: percentile(durations, 50), + p75Ms: percentile(durations, 75), + p95Ms: percentile(durations, 95), + maxMs: durations.length > 0 ? durations[durations.length - 1] : null, + streamOpenSampleCount: streamOpenDurations.length, + streamOpenP50Ms: percentile(streamOpenDurations, 50), + streamOpenP75Ms: percentile(streamOpenDurations, 75), + streamOpenP95Ms: percentile(streamOpenDurations, 95), + streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null, + streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount, + streamOpenOverBudgetCount: group.streamOpenOverBudgetCount, + streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount, + overFiveSecondCount: group.overFiveSecondCount, + overBudgetCount: group.overBudgetCount, + partialOverFiveSecondCount: group.partialOverFiveSecondCount, + partialOverBudgetCount: group.partialOverBudgetCount, + overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0, + overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0, + firstAt: group.firstAt, + lastAt: group.lastAt, + firstSeq: group.firstSeq, + lastSeq: group.lastSeq, + initiatorTypes: group.initiatorTypes, + rawPathSamples: group.rawPathSamples.slice(0, 8), + pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8), + slowSamples: group.slowSamples + .slice() + .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) + .slice(0, 12), + partialSamples: group.partialSamples + .slice() + .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) + .slice(0, 12), + valuesRedacted: true + }; + }).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path)); + const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream); + const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true); + const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0); + const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0); + const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0); + const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0); + const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0); + const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0); + const budgetP95Values = sameOriginApiByPath + .map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0))) + .filter((value) => Number.isFinite(value)); + return { + summary: { + budgetMs: alertThresholds.sameOriginApiSlowMs, + alertThresholds, + sameOriginApiPathCount: sameOriginApiByPath.length, + sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0), + longLivedStreamPathCount: longLivedStreams.length, + longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0), + longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length, + longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0), + longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length, + longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0), + longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0), + slowPathCount: slow.length, + slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0), + overFiveSecondPathCount: slowFiveSecond.length, + overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0), + partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0), + partialOverFiveSecondPathCount: partialFiveSecond.length, + partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0), + partialOverBudgetPathCount: partialSlow.length, + partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0), + worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null, + valuesRedacted: true + }, + sameOriginApiByPath, + valuesRedacted: true + }; +} + +function performanceEntryCompletedEpochMs(sample, entry) { + const origin = Number(sample?.pageProvenance?.timeOrigin); + const responseEnd = Number(entry?.responseEnd); + const startTime = Number(entry?.startTime); + const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime; + if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset); + const sampleTs = Date.parse(sample?.ts || ""); + return Number.isFinite(sampleTs) ? sampleTs : null; +} + +function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) { + const timingStatus = resourceTimingPhaseStatus(entry); + const serverTiming = compactServerTiming(entry?.serverTiming); + return { + ts: entryTs ?? sample?.ts ?? null, + sampleTs: sample?.ts ?? null, + seq: sample?.seq ?? null, + path: normalizedPath ?? null, + rawPath: rawPath ?? null, + initiatorType: entry?.initiatorType ?? null, + durationMs: roundFinite(durationMs), + startTimeMs: roundFinite(entry?.startTime), + fetchStartMs: roundFinite(entry?.fetchStart), + requestStartMs: roundFinite(entry?.requestStart), + responseStartMs: roundFinite(entry?.responseStart), + responseEndMs: roundFinite(entry?.responseEnd), + streamOpenMs: roundFinite(streamOpenMs), + dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"), + tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"), + tlsStartMs: roundFinite(entry?.secureConnectionStart), + requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"), + responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"), + timingStatus: timingStatus.status, + invalidTimingPhases: timingStatus.invalidPhases, + partialTimingPhases: timingStatus.partialPhases, + transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null, + encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null, + decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null, + nextHopProtocol: entry?.nextHopProtocol ?? null, + serverTiming, + serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8), + otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming), + valuesRedacted: true + }; +} + +function phaseDeltaMs(entry, endKey, startKey) { + const end = Number(entry?.[endKey]); + const start = Number(entry?.[startKey]); + if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null; + return Math.round(end - start); +} + +function resourceTimingPhaseStatus(entry) { + const pairs = [ + ["requestToResponseStart", "requestStart", "responseStart"], + ["responseTransfer", "responseStart", "responseEnd"], + ]; + const invalidPhases = []; + const partialPhases = []; + for (const [label, startKey, endKey] of pairs) { + const start = Number(entry?.[startKey]); + const end = Number(entry?.[endKey]); + if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) { + partialPhases.push(label); + } else if (end < start) { + invalidPhases.push(label); + } + } + return { + status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"), + invalidPhases, + partialPhases, + }; +} + +function compactServerTiming(value) { + const items = Array.isArray(value) ? value : []; + return items.slice(0, 8).map((item) => ({ + name: truncate(String(item?.name || ""), 80), + duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null, + description: truncate(String(item?.description || ""), 120), + })).filter((item) => item.name || item.description || item.duration !== null); +} + +function extractOtelTraceIdFromServerTiming(items) { + const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" "); + const match = text.match(/\b[0-9a-f]{32}\b/iu); + return match ? match[0].toLowerCase() : null; +} + +function roundFinite(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.round(numeric) : null; +} + +function classifyApiPerformanceRoute(normalizedPath, entry = {}) { + if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream"; + if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream"; + return "same-origin-api"; +} + +function streamOpenLatencyMs(entry = {}) { + const responseStart = Number(entry?.responseStart); + const startTime = Number(entry?.startTime); + if (!Number.isFinite(responseStart) || responseStart <= 0) return null; + if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart); + if (responseStart < startTime) return null; + return Math.max(0, responseStart - startTime); +} + +function parsePerformanceUrl(value, base) { + try { + const url = new URL(String(value || ""), base); + const origin = new URL(String(base || "http://invalid.local")).origin; + return { sameOrigin: url.origin === origin, path: url.pathname }; + } catch { + return { sameOrigin: false, path: "-" }; + } +} + +function isApiLikePath(path) { + return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || "")); +} + +function normalizeApiPath(path) { + return String(path || "-") + .replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id") + .replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId") + .replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId") + .replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id") + .replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id")); +} + +function percentile(sortedValues, percentileValue) { + if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null; + if (sortedValues.length === 1) return Math.round(sortedValues[0]); + const rank = (percentileValue / 100) * (sortedValues.length - 1); + const lower = Math.floor(rank); + const upper = Math.ceil(rank); + if (lower === upper) return Math.round(sortedValues[lower]); + const weight = rank - lower; + return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-analyzer-workbench-triad-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-workbench-triad-source.ts new file mode 100644 index 00000000..c0386d6a --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-analyzer-workbench-triad-source.ts @@ -0,0 +1,721 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Analyzer Workbench terminal projection and three-state triad source fragment. + +export function nodeWebObserveAnalyzerWorkbenchTriadSource(): string { + return String.raw`function detectWorkbenchInPlaceProjectionLag(samples, network, control = []) { + const canarySessionIds = sessionInvariantCanarySessionIds(control); + const terminalTraceMissing = detectWorkbenchTerminalTraceMissing(samples, canarySessionIds); + const terminalApiDomLag = detectWorkbenchTerminalApiDomLag(samples, network, canarySessionIds); + return { + terminalTraceMissing, + terminalApiDomLag, + valuesRedacted: true + }; +} + +function detectWorkbenchTerminalTraceMissing(samples, canarySessionIds = new Set()) { + const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; + const rows = []; + const open = new Map(); + const sortedSamples = (Array.isArray(samples) ? samples : []) + .filter(isWorkbenchPathSample) + .filter((sample) => workbenchSampleMatchesCanarySessions(sample, canarySessionIds)) + .slice() + .sort((a, b) => Date.parse(a?.ts || "") - Date.parse(b?.ts || "")); + const closeSegment = (key, lastSample = null) => { + const segment = open.get(key); + if (!segment) return; + open.delete(key); + const firstMs = Date.parse(segment.first.ts || ""); + const lastMs = Date.parse((lastSample ?? segment.last).ts || ""); + const observedMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? Math.round(lastMs - firstMs) : 0; + if (observedMs <= budgetMs) return; + rows.push({ + ...ref(segment.first), + lastSeq: segment.last.seq ?? null, + lastAt: segment.last.ts ?? null, + traceId: segment.traceId, + messageCount: Array.isArray(segment.last.messages) ? segment.last.messages.length : 0, + turnCount: Array.isArray(segment.last.turns) ? segment.last.turns.length : 0, + traceRowCount: 0, + sampleCount: segment.sampleCount, + observedMs, + budgetMs, + finalMessageVisible: workbenchFinalMessageVisible(segment.last, segment.traceId), + terminalTurnVisible: workbenchTerminalTurnVisible(segment.last, segment.traceId), + detail: "terminal turn/message stayed visible for this trace while the same in-place Workbench page had zero trace rows beyond the configured budget", + valuesRedacted: true + }); + }; + for (const sample of sortedSamples) { + if (!isWorkbenchPathSample(sample)) continue; + const tsMs = Date.parse(sample?.ts || ""); + if (!Number.isFinite(tsMs)) continue; + const terminalTraceIds = workbenchTerminalTraceIdsFromDom(sample); + const presentKeys = new Set(); + for (const traceId of terminalTraceIds) { + const key = [samplePageKey(sample), sample?.routeSessionId ?? "", sample?.activeSessionId ?? "", traceId].join("|"); + presentKeys.add(key); + const traceRows = workbenchTraceRowsForTrace(sample, traceId); + if (traceRows.length > 0 || workbenchSampleHasTerminalProjection(sample, { traceIds: [traceId] })) { + closeSegment(key, sample); + continue; + } + const existing = open.get(key); + if (existing) { + existing.last = sample; + existing.sampleCount += 1; + } else { + open.set(key, { first: sample, last: sample, traceId, sampleCount: 1 }); + } + } + for (const [key, segment] of Array.from(open.entries())) { + if (samplePageKey(sample) === samplePageKey(segment.first) && !presentKeys.has(key)) closeSegment(key, sample); + } + } + for (const key of Array.from(open.keys())) closeSegment(key); + return rows; +} + +function detectWorkbenchTerminalApiDomLag(samples, network, canarySessionIds = new Set()) { + const windowMs = 30_000; + const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; + const sampleRows = (Array.isArray(samples) ? samples : []) + .filter(isWorkbenchPathSample) + .filter((sample) => workbenchSampleMatchesCanarySessions(sample, canarySessionIds)) + .map((sample) => ({ sample, tsMs: Date.parse(sample?.ts || ""), pageKey: samplePageKey(sample) })) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const rowsByPage = new Map(); + for (const row of sampleRows) { + const rows = rowsByPage.get(row.pageKey) || []; + rows.push(row); + rowsByPage.set(row.pageKey, rows); + } + const terminalEvents = (Array.isArray(network) ? network : []) + .map(compactWorkbenchTerminalApiEvent) + .filter((item) => workbenchTerminalEventMatchesCanarySessions(item, canarySessionIds)) + .filter((item) => item !== null); + const overBudget = []; + for (const event of terminalEvents) { + const pageSamples = rowsByPage.get(event.pageKey) || []; + if (pageSamples.length === 0 || event.tsMs < pageSamples[0].tsMs) continue; + const alreadyVisible = lastWorkbenchSampleAtOrBefore(pageSamples, event.tsMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); + if (alreadyVisible) continue; + const firstAfter = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); + const firstMiss = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + budgetMs, event, (row) => !workbenchSampleHasTerminalProjection(row.sample, event)); + const resolved = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); + const deltaMs = resolved ? Math.max(0, Math.round(resolved.tsMs - event.tsMs)) : null; + const unresolved = !resolved; + const exceedsBudget = unresolved || (Number.isFinite(deltaMs) && deltaMs > budgetMs); + if (!firstMiss) continue; + if (!exceedsBudget) continue; + overBudget.push({ + ts: event.ts, + pageRole: event.pageRole, + pageId: event.pageId, + routeKind: event.routeKind, + method: event.method, + path: event.path, + traceIds: event.traceIds.slice(0, 6), + sessionIds: event.sessionIds.slice(0, 4), + terminalEvidenceCount: event.terminalEvidenceCount, + traceEventLikeCount: event.traceEventLikeCount, + finalTextFieldCount: event.finalTextFieldCount, + budgetMs, + windowMs, + resolvedDeltaMs: deltaMs, + unresolvedWithinWindow: unresolved, + firstAfterSample: compactWorkbenchProjectionSample(firstAfter?.sample, event), + firstMissSample: compactWorkbenchProjectionSample(firstMiss?.sample, event), + resolvedSample: compactWorkbenchProjectionSample(resolved?.sample, event), + valuesRedacted: true + }); + } + return { + summary: { + terminalEventCount: terminalEvents.length, + overBudgetCount: overBudget.length, + budgetMs, + windowMs, + valuesRedacted: true + }, + overBudget, + terminalEvents: terminalEvents.slice(-20), + valuesRedacted: true + }; +} + +function workbenchSampleMatchesCanarySessions(sample, canarySessionIds) { + if (!(canarySessionIds instanceof Set) || canarySessionIds.size === 0) return true; + const sessionIds = [sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String); + return sessionIds.some((id) => canarySessionIds.has(id)); +} + +function workbenchTerminalEventMatchesCanarySessions(event, canarySessionIds) { + if (!event) return false; + if (!(canarySessionIds instanceof Set) || canarySessionIds.size === 0) return true; + const eventSessionIds = Array.isArray(event.sessionIds) ? event.sessionIds.map(String) : []; + return eventSessionIds.some((id) => canarySessionIds.has(id)); +} + +function compactWorkbenchTerminalApiEvent(item) { + if (!item || item.type !== "response" || item.observerInitiated === true) return null; + const summary = objectValue(item.bodySummary); + if (!summary || Number(summary.terminalEvidenceCount ?? 0) <= 0) return null; + const parsed = parseApiDomLagUrl(item.url); + if (!String(parsed.path || "").startsWith("/v1/workbench/") && parsed.path !== "/v1/agent/chat" && parsed.path !== "/v1/agent/chat/steer") return null; + const routeKind = summary.pathKind ?? apiDomLagRouteKind(parsed.path); + if (!isReliableWorkbenchTerminalApiEvent(summary, routeKind)) return null; + const terminalTraceIds = uniqueSorted(Array.isArray(summary.terminalTraceIds) ? summary.terminalTraceIds : []).slice(0, 12); + if (terminalTraceIds.length === 0) return null; + const tsMs = Date.parse(item.ts || ""); + if (!Number.isFinite(tsMs)) return null; + return { + ts: item.ts, + tsMs, + pageRole: item.pageRole ?? null, + pageId: item.pageId ?? null, + pageKey: String(item.pageRole || "control") + ":" + String(item.pageId || "default"), + method: String(item.method || "GET").toUpperCase(), + path: parsed.path, + routeKind, + traceIds: terminalTraceIds, + observedTraceIds: uniqueSorted([...(Array.isArray(summary.traceIds) ? summary.traceIds : []), parsed.traceId].filter(Boolean)).slice(0, 12), + sessionIds: uniqueSorted([...(Array.isArray(summary.sessionIds) ? summary.sessionIds : []), parsed.sessionId].filter(Boolean)).slice(0, 12), + terminalEvidenceCount: Number(summary.terminalEvidenceCount ?? 0), + terminalStatusCount: Number(summary.terminalStatusCount ?? 0), + terminalTextCount: Number(summary.terminalTextCount ?? 0), + traceEventLikeCount: Number(summary.traceEventLikeCount ?? 0), + finalTextFieldCount: Number(summary.finalTextFieldCount ?? 0), + valuesRedacted: true + }; +} + +function isReliableWorkbenchTerminalApiEvent(summary, routeKind) { + if (!summary || routeKind !== "workbench-turn") return false; + if (Number(summary.terminalEvidenceCount ?? 0) <= 0) return false; + return Number(summary.runningStatusCount ?? 0) <= 0; +} + +function lastWorkbenchSampleAtOrBefore(rows, tsMs, event, predicate = null) { + let result = null; + for (const row of rows || []) { + if (row.tsMs > tsMs) break; + if (!workbenchSampleMatchesTerminalEvent(row.sample, event)) continue; + if (typeof predicate === "function" && !predicate(row)) continue; + result = row; + } + return result; +} + +function firstWorkbenchSampleAfter(rows, startMs, endMs, event, predicate = null) { + for (const row of rows || []) { + if (row.tsMs < startMs) continue; + if (row.tsMs > endMs) break; + if (!workbenchSampleMatchesTerminalEvent(row.sample, event)) continue; + if (typeof predicate === "function" && !predicate(row)) continue; + return row; + } + return null; +} + +function workbenchSampleMatchesTerminalEvent(sample, event) { + if (!sample || !event) return false; + if (event.sessionIds.length > 0) { + const sessionIds = new Set([sample.routeSessionId, sample.activeSessionId].filter(Boolean).map(String)); + if (!event.sessionIds.some((id) => sessionIds.has(id))) return false; + } + if (event.traceIds.length > 0) { + const traces = sampleTraceIds(sample); + if (traces.size === 0) return false; + if (!event.traceIds.some((id) => traces.has(id))) return false; + } + return true; +} + +function workbenchSampleHasTerminalProjection(sample, event) { + const traceIds = event.traceIds.length > 0 ? event.traceIds : Array.from(sampleTraceIds(sample)); + if (traceIds.length === 0) return false; + return traceIds.some((traceId) => workbenchFinalMessageVisible(sample, traceId) || workbenchTerminalTurnVisible(sample, traceId)); +} + +function compactWorkbenchProjectionSample(sample, event = null) { + if (!sample) return null; + const eventTraceIds = event?.traceIds && event.traceIds.length > 0 ? event.traceIds : Array.from(sampleTraceIds(sample)); + const visibleTraceIds = eventTraceIds.slice(0, 6); + return { + ...ref(sample), + messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, + turnCount: Array.isArray(sample.turns) ? sample.turns.length : 0, + traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, + traceIds: visibleTraceIds, + finalMessageVisible: visibleTraceIds.some((traceId) => workbenchFinalMessageVisible(sample, traceId)), + terminalTurnVisible: visibleTraceIds.some((traceId) => workbenchTerminalTurnVisible(sample, traceId)), + valuesRedacted: true + }; +} + +function isWorkbenchPathSample(sample) { + return /\/workbench(?:\/|$)/u.test(String(sample?.path || sample?.url || "")); +} + +function workbenchTerminalTraceIdsFromDom(sample) { + const ids = new Set(); + for (const groupName of ["turns", "messages"]) { + const group = Array.isArray(sample?.[groupName]) ? sample[groupName] : []; + for (const item of group) { + const traceId = stringOrNull(item?.traceId); + if (!traceId) continue; + if (workbenchDomItemIsTerminal(item)) ids.add(traceId); + } + } + return Array.from(ids).sort(); +} + +function workbenchTraceRowsForTrace(sample, traceId) { + const rows = Array.isArray(sample?.traceRows) ? sample.traceRows : []; + return rows.filter((item) => !traceId || item?.traceId === traceId); +} + +function workbenchFinalMessageVisible(sample, traceId) { + const messages = Array.isArray(sample?.messages) ? sample.messages : []; + return messages.some((item) => (!traceId || item?.traceId === traceId) && workbenchDomItemLooksFinal(item)); +} + +function workbenchTerminalTurnVisible(sample, traceId) { + const turns = Array.isArray(sample?.turns) ? sample.turns : []; + return turns.some((item) => (!traceId || item?.traceId === traceId) && workbenchDomItemIsTerminal(item)); +} + +function workbenchDomItemIsTerminal(item) { + const status = String(item?.status || "").toLowerCase(); + if (/^(completed|complete|succeeded|success|failed|failure|error|canceled|cancelled|done)$/u.test(status)) return true; + return isTerminalTraceText([item?.status, item?.textPreview, item?.text, item?.durationText, item?.activityText].filter(Boolean).join(" ")); +} + +function workbenchDomItemLooksFinal(item) { + const text = [item?.status, item?.textPreview, item?.text].filter(Boolean).join(" "); + return workbenchDomItemIsTerminal(item) && isFinalResultText(text); +} + +function buildWorkbenchTurnStateTriadMetrics(samples, timeline = []) { + const rows = []; + const sourceSamples = Array.isArray(samples) ? samples : []; + for (let index = 0; index < sourceSamples.length; index += 1) { + const sample = sourceSamples[index]; + if (!isWorkbenchPathSample(sample)) continue; + const turns = Array.isArray(sample?.turns) ? sample.turns.filter((turn) => String(turn?.role || turn?.dataRole || "agent") === "agent") : []; + if (turns.length === 0) continue; + const rail = activeSessionRailItemForSample(sample); + for (const turn of turns.slice(-6)) { + const row = workbenchTurnStateTriadRow(sample, turn, rail, timeline[index]?.promptIndex ?? 0); + if (row) rows.push(row); + } + } + const invalidFullTriads = rows.filter((row) => row.invalid === true && row.fullTriad === true); + const cardFinalResponseMismatches = rows.filter((row) => row.cardFinalResponseMismatch === true || row.legacyCardFinalResponseMismatch === true); + const collectorMissingRows = rows.filter((row) => Array.isArray(row.collectorMissing) && row.collectorMissing.length > 0); + const invalidRowKeys = new Set([...invalidFullTriads, ...cardFinalResponseMismatches].map((row) => row.rowKey)); + const collectorMissingFields = uniqueSorted(collectorMissingRows.flatMap((row) => row.collectorMissing || [])); + const offendingRows = Array.from(new Map([...invalidFullTriads, ...cardFinalResponseMismatches].map((row) => [row.rowKey, row])).values()); + const drilldown = buildWorkbenchTurnStateTriadDrilldown(offendingRows); + return { + summary: { + sampleCount: sourceSamples.length, + rowCount: rows.length, + fullTriadRowCount: rows.filter((row) => row.fullTriad === true).length, + invalidRowCount: invalidRowKeys.size, + invalidFullTriadCount: invalidFullTriads.length, + cardFinalResponseMismatchCount: cardFinalResponseMismatches.length, + invalidGroupCount: drilldown.summary.groupCount, + invalidTraceIdCount: drilldown.summary.traceIds.length, + invalidMaxObservedSpanMs: drilldown.summary.maxObservedSpanMs, + collectorMissingRowCount: collectorMissingRows.length, + collectorMissingFields, + valuesRedacted: true + }, + drilldown, + invalidFullTriads: invalidFullTriads.slice(0, 120), + cardFinalResponseMismatches: cardFinalResponseMismatches.slice(0, 120), + collectorMissingRows: collectorMissingRows.slice(0, 120), + timeline: rows.slice(-300), + valuesRedacted: true + }; +} + +function buildWorkbenchTurnStateTriadDrilldown(rows) { + const uniqueRows = Array.from(new Map((Array.isArray(rows) ? rows : []) + .filter(Boolean) + .map((row) => [row?.rowKey || [row?.seq, row?.traceId, row?.messageId, row?.pageRole].join("|"), row])).values()); + const groupsByKey = new Map(); + let firstAt = null; + let lastAt = null; + for (const row of uniqueRows) { + const traceId = firstString(row?.traceId); + const mismatchKind = workbenchTriadMismatchKind(row); + const tuple = workbenchTriadTuple(row); + const key = [ + traceId || row?.messageId || row?.sessionIdPrefix || "-", + row?.sessionIdPrefix || "-", + row?.pageRole || "-", + mismatchKind, + tuple, + ].join("|"); + let group = groupsByKey.get(key); + if (!group) { + group = { + keyHash: sha256(key), + mismatchKind, + statusTuple: tuple, + traceId: traceId || null, + messageId: row?.messageId ?? null, + sessionIdPrefix: row?.sessionIdPrefix ?? null, + pageRole: row?.pageRole ?? null, + pageId: row?.pageId ?? null, + count: 0, + firstSeq: row?.seq ?? null, + lastSeq: row?.seq ?? null, + firstAt: row?.ts ?? null, + lastAt: row?.ts ?? null, + promptIndexes: new Set(), + commandIds: new Set(), + rawRailStatuses: new Set(), + rawCardStatuses: new Set(), + finalResponseTextBytes: new Set(), + examples: [], + valuesRedacted: true, + }; + groupsByKey.set(key, group); + } + group.count += 1; + group.firstSeq = minNumberOrValue(group.firstSeq, row?.seq); + group.lastSeq = maxNumberOrValue(group.lastSeq, row?.seq); + group.firstAt = minIso(group.firstAt, row?.ts ?? null); + group.lastAt = maxIso(group.lastAt, row?.ts ?? null); + firstAt = minIso(firstAt, row?.ts ?? null); + lastAt = maxIso(lastAt, row?.ts ?? null); + if (row?.promptIndex !== null && row?.promptIndex !== undefined) group.promptIndexes.add(String(row.promptIndex)); + if (row?.nearestCommandId) group.commandIds.add(String(row.nearestCommandId)); + if (row?.railStatusRaw) group.rawRailStatuses.add(String(row.railStatusRaw)); + if (row?.cardStatusRaw) group.rawCardStatuses.add(String(row.cardStatusRaw)); + if (row?.finalResponseTextBytes !== null && row?.finalResponseTextBytes !== undefined) group.finalResponseTextBytes.add(String(row.finalResponseTextBytes)); + if (group.examples.length < 3) { + group.examples.push({ + seq: row?.seq ?? null, + ts: row?.ts ?? null, + traceId, + railStatus: row?.railStatus ?? null, + railStatusRaw: row?.railStatusRaw ?? null, + cardStatus: row?.cardStatus ?? null, + cardStatusRaw: row?.cardStatusRaw ?? null, + finalResponsePresent: row?.finalResponsePresent ?? null, + finalResponseTextBytes: row?.finalResponseTextBytes ?? null, + nearestCommandId: row?.nearestCommandId ?? null, + valuesRedacted: true, + }); + } + } + const groups = Array.from(groupsByKey.values()).map((group) => { + const observedSpanMs = isoSpanMs(group.firstAt, group.lastAt); + return { + keyHash: group.keyHash, + mismatchKind: group.mismatchKind, + statusTuple: group.statusTuple, + traceId: group.traceId, + messageId: group.messageId, + sessionIdPrefix: group.sessionIdPrefix, + pageRole: group.pageRole, + pageId: group.pageId, + count: group.count, + firstSeq: group.firstSeq, + lastSeq: group.lastSeq, + firstAt: group.firstAt, + lastAt: group.lastAt, + observedSpanMs, + promptIndexes: Array.from(group.promptIndexes).slice(0, 8), + commandIds: Array.from(group.commandIds).slice(0, 8), + rawRailStatuses: Array.from(group.rawRailStatuses).slice(0, 8), + rawCardStatuses: Array.from(group.rawCardStatuses).slice(0, 8), + finalResponseTextBytes: Array.from(group.finalResponseTextBytes).slice(0, 8), + examples: group.examples, + valuesRedacted: true, + }; + }).sort((left, right) => Number(right.observedSpanMs ?? 0) - Number(left.observedSpanMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0)); + const traceIds = uniqueSorted(uniqueRows.map((row) => row?.traceId).filter(Boolean)).slice(0, 12); + const sessionPrefixes = uniqueSorted(uniqueRows.map((row) => row?.sessionIdPrefix).filter(Boolean)).slice(0, 12); + const mismatchKinds = uniqueSorted(uniqueRows.map(workbenchTriadMismatchKind).filter(Boolean)); + return { + summary: { + invalidUniqueRows: uniqueRows.length, + groupCount: groups.length, + traceIds, + sessionPrefixes, + mismatchKinds, + firstAt, + lastAt, + maxObservedSpanMs: groups.reduce((value, item) => Math.max(value, Number(item.observedSpanMs ?? 0)), 0), + valuesRedacted: true, + }, + groups: groups.slice(0, 12), + otelDrilldown: buildWorkbenchTriadOtelDrilldown(traceIds.slice(0, 4)), + staticSourceHints: workbenchTriadStaticSourceHints(), + unitTestReproHints: workbenchTriadUnitTestReproHints(), + valuesRedacted: true, + }; +} + +function buildWorkbenchTriadOtelDrilldown(traceIds) { + const target = otelTargetForAnalyzer(); + const ids = (Array.isArray(traceIds) ? traceIds : []).filter(Boolean).slice(0, 4); + return { + target, + commands: ids.flatMap((traceId) => [ + "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target " + target + " --business-trace-id " + traceId + " --full", + "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep session_ --limit 60 --full", + "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep turn_status_read --limit 40 --full", + "bun scripts/cli.ts platform-infra observability trace --target " + target + " --trace-id --grep projection --limit 120 --full", + ]).slice(0, 16), + valuesRedacted: true, + }; +} + +function otelTargetForAnalyzer() { + const explicit = firstString(process.env.UNIDESK_WEB_OBSERVE_OTEL_TARGET, process.env.UNIDESK_OBSERVABILITY_TARGET); + if (explicit) return explicit; + const parts = String(stateDir || "").split(/[\\\/]+/u); + const index = parts.lastIndexOf("web-observe"); + if (index >= 0 && parts[index + 1]) return String(parts[index + 1]).toUpperCase(); + return "JD01"; +} + +function workbenchTriadMismatchKind(row) { + if (!row || typeof row !== "object") return "unknown"; + if (row.railCardMismatch === true) return "rail-card-status-mismatch"; + if (row.cardFinalResponseMismatch === true) return "completed-card-final-response-absent"; + if (row.legacyCardFinalResponseMismatch === true) return "completed-card-final-response-uncollected-or-absent"; + if (row.tupleAllowed === false) return "tuple-not-allowed"; + return "unknown"; +} + +function workbenchTriadRootCauseFromDrilldown(drilldown, summary = {}) { + const groups = Array.isArray(drilldown?.groups) ? drilldown.groups : []; + const hasStaleCompletedRail = groups.some((group) => group?.mismatchKind === "rail-card-status-mismatch" && String(group?.statusTuple || "").includes("rail=completed,card=running,final=false")); + if (hasStaleCompletedRail) return { + rootCause: "workbench_session_rail_status_stale_after_new_running_turn", + rootCauseStatus: "confirmed-from-dom-samples", + rootCauseConfidence: "high", + dominantMismatchKind: "rail-card-status-mismatch", + summary: "Workbench session rail kept the previous completed terminal status while a newer turn card was running and Final Response was absent", + nextAction: "Inspect HWLAB frontend session status authority/reducer, especially workbench-server-state sessionStatusAuthorityFromMessages and SessionRail sessionToSessionTab status input; session rail must derive from the latest active turn/message authority rather than the previous sealed terminal message.", + sourceOfTruth: "latest durable Workbench turn/message projection for the active session", + valuesRedacted: true, + }; + const finalMismatchCount = Number(summary?.cardFinalResponseMismatchCount ?? 0); + const hasFinalMismatch = finalMismatchCount > 0 || groups.some((group) => /final=false/u.test(String(group?.statusTuple || "")) && group?.mismatchKind !== "rail-card-status-mismatch"); + if (hasFinalMismatch) return { + rootCause: "workbench_terminal_final_response_not_sealed", + rootCauseStatus: "confirmed-from-dom-samples", + rootCauseConfidence: "high", + dominantMismatchKind: "completed-card-final-response-absent", + summary: "Workbench terminal turn card did not expose a structured Final Response body", + nextAction: "Inspect HWLAB terminal message/finalResponse projection contract before changing renderer fallback behavior.", + sourceOfTruth: "durable Workbench terminal message projection", + valuesRedacted: true, + }; + const mismatchKinds = Array.isArray(drilldown?.summary?.mismatchKinds) ? drilldown.summary.mismatchKinds : []; + return { + rootCause: "workbench_projection_state_triad_not_sealed", + rootCauseStatus: "confirmed-from-dom-samples", + rootCauseConfidence: "high", + dominantMismatchKind: mismatchKinds[0] ?? "unknown", + summary: "Workbench session rail status, turn card status, and Final Response body presence diverged from the allowed state tuples", + nextAction: "Use drilldown.otelDrilldown.commands for the listed traceIds, then inspect staticSourceHints and add unit tests from unitTestReproHints before changing UI rendering.", + sourceOfTruth: "durable Workbench projection/read model", + valuesRedacted: true, + }; +} + +function workbenchTriadTuple(row) { + return [ + "rail=" + (row?.railStatus ?? "-"), + "card=" + (row?.cardStatus ?? "-"), + "final=" + String(row?.finalResponsePresent ?? "unknown"), + ].join(","); +} + +function minNumberOrValue(left, right) { + const l = Number(left); + const r = Number(right); + if (!Number.isFinite(l)) return right ?? left ?? null; + if (!Number.isFinite(r)) return left ?? right ?? null; + return Math.min(l, r); +} + +function maxNumberOrValue(left, right) { + const l = Number(left); + const r = Number(right); + if (!Number.isFinite(l)) return right ?? left ?? null; + if (!Number.isFinite(r)) return left ?? right ?? null; + return Math.max(l, r); +} + +function isoSpanMs(firstAt, lastAt) { + const start = Date.parse(firstAt || ""); + const end = Date.parse(lastAt || ""); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; + return end - start; +} + +function workbenchTriadStaticSourceHints() { + return [ + { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-session.ts", reason: "session rail/list status and active session projection source" }, + { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-server-state.ts", reason: "server snapshot merge path that can overwrite sealed turn state" }, + { repo: "pikasTech/HWLAB", path: "web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts", reason: "turn card and Final Response projection/runtime merge path" }, + { repo: "pikasTech/HWLAB", path: "internal/cloud/server-workbench-http*.go", reason: "session list/detail/messages/turn REST read-model contract" }, + { repo: "pikasTech/HWLAB", path: "internal/cloud/workbench-projection-*.go", reason: "durable projection writer and terminal seal contract" }, + ]; +} + +function workbenchTriadUnitTestReproHints() { + return [ + "frontend reducer: when a second trace is running in the same session, session rail status must not stay on the previous completed terminal trace", + "backend projector: terminal event must produce a single sealed turn tuple consumed by session list, session detail, messages and turn-status APIs", + "backend read model: completed rail status must not coexist with running turn card or missing Final Response for the same trace", + "frontend server-state merge: stale running/empty snapshots must not overwrite a sealed completed+Final Response turn", + "frontend projection runtime: cross-page hydration must converge from the same durable projection without DOM-only repair", + ]; +} + +function workbenchTurnStateTriadRow(sample, turn, rail, promptIndex) { + const collectorMissing = []; + const railRawStatus = firstString(rail?.status, rail?.dataStatus); + const railRunning = rail?.running === true || String(rail?.dataRunning || rail?.ariaBusy || "").toLowerCase() === "true"; + const railStatus = normalizeWorkbenchTriadStatus(railRawStatus, railRunning); + if (!rail) collectorMissing.push("sessionRail.activeItem"); + if (!railRawStatus && railRunning !== true) collectorMissing.push("sessionRail.status"); + const cardRawStatus = firstString(turn?.status); + const cardStatus = normalizeWorkbenchTriadStatus(cardRawStatus, false) || (workbenchDomItemIsTerminal(turn) ? "completed" : null); + if (!cardRawStatus && !cardStatus) collectorMissing.push("turn.status"); + const finalPresence = finalResponsePresenceFromTurn(turn); + if (finalPresence.known !== true) collectorMissing.push("turn.finalResponseTextBytes"); + const fullTriad = Boolean(railStatus && cardStatus && finalPresence.known === true); + const finalResponsePresent = finalPresence.known === true ? finalPresence.present : null; + const tupleAllowed = fullTriad + ? (railStatus === "completed" && cardStatus === "completed" && finalResponsePresent === true) + || (railStatus === "running" && cardStatus === "running" && finalResponsePresent === false) + : null; + const cardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known === true && finalResponsePresent !== true; + const legacyCardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known !== true && !workbenchDomItemLooksFinal(turn); + const railCardMismatch = Boolean(railStatus && cardStatus && railStatus !== cardStatus); + const invalid = tupleAllowed === false || cardFinalResponseMismatch || legacyCardFinalResponseMismatch || railCardMismatch; + const traceId = firstString(turn?.traceId, turn?.turnId); + const messageId = firstString(turn?.messageId); + const rowKey = [ + sample?.seq ?? "-", + sample?.pageRole ?? "control", + sample?.pageId ?? "default", + sample?.routeSessionId ?? sample?.activeSessionId ?? rail?.sessionIdPrefix ?? "-", + traceId ?? messageId ?? turn?.index ?? "-" + ].join("|"); + return { + ...ref(sample), + rowKey, + promptIndex, + sessionIdPrefix: sessionPrefixForSample(sample, rail), + traceId, + messageId, + turnId: firstString(turn?.turnId), + turnIndex: turn?.index ?? null, + railStatus, + railStatusRaw: railRawStatus ?? null, + railRunning: railRunning === true, + railSource: rail ? "sessionRail.activeItem" : null, + cardStatus, + cardStatusRaw: cardRawStatus ?? null, + finalResponsePresent, + finalResponseTextBytes: turn?.finalResponseTextBytes !== null && turn?.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes)) ? Number(turn.finalResponseTextBytes) : null, + finalResponseSource: turn?.finalResponseTextSource ?? null, + finalResponseTextHash: turn?.finalResponseTextHash ?? null, + finalResponseTextPreview: turn?.finalResponseTextPreview ? limitText(turn.finalResponseTextPreview, 160) : null, + fullTriad, + tupleAllowed, + invalid, + cardFinalResponseMismatch, + legacyCardFinalResponseMismatch, + railCardMismatch, + collectorMissing, + nearestCommandId: sample?.commandId ?? null, + relatedChecks: ["WBC-001", "WBC-003", "WBC-011", "WBC-022"], + valuesRedacted: true + }; +} + +function activeSessionRailItemForSample(sample) { + const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null; + if (!rail) return null; + const items = Array.isArray(rail.items) ? rail.items : []; + if (rail.activeItem) return rail.activeItem; + const routeSessionId = String(sample?.routeSessionId || ""); + const activeSessionId = String(sample?.activeSessionId || ""); + return items.find((item) => item?.active === true) + || items.find((item) => sessionRailItemMatchesSession(item, activeSessionId)) + || items.find((item) => sessionRailItemMatchesSession(item, routeSessionId)) + || null; +} + +function sessionRailItemMatchesSession(item, sessionId) { + const id = String(sessionId || ""); + const prefix = String(item?.sessionIdPrefix || item?.sessionId || ""); + return Boolean(id && prefix && (id === prefix || id.startsWith(prefix) || prefix.startsWith(id))); +} + +function sessionPrefixForSample(sample, rail) { + const id = firstString(sample?.activeSessionId, sample?.routeSessionId, rail?.sessionIdPrefix, rail?.sessionId); + return id ? String(id).slice(0, 12) : null; +} + +function finalResponsePresenceFromTurn(turn) { + if (!turn || typeof turn !== "object") return { known: false, present: null }; + if (turn.finalResponseTextBytes !== null && turn.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes))) { + const bytes = Number(turn.finalResponseTextBytes); + return { known: true, present: bytes > 0 }; + } + if (Object.prototype.hasOwnProperty.call(turn, "finalResponsePresent") && turn.finalResponsePresent !== null && turn.finalResponsePresent !== undefined) { + return { known: true, present: turn.finalResponsePresent === true }; + } + return { known: false, present: null }; +} + +function normalizeWorkbenchTriadStatus(status, running = false) { + const value = String(status || "").trim().toLowerCase().replace(/_/gu, "-"); + if (running === true) return "running"; + if (!value) return null; + if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(value)) return "completed"; + if (/^(failed|failure|error|blocked|timeout|canceled|cancelled|stale|thread-resume-failed|interrupted|expired|idle)$/u.test(value)) return "completed"; + if (/^(pending|running|active|busy|admitted|dispatching|executing|streaming|processing|queued|in-progress|creating)$/u.test(value)) return "running"; + return null; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return null; +} + +function promptCommandHasAuthoritativeSubmitSideEffect(control, promptRound) { + const commandId = stringOrNull(promptRound?.promptCommandId); + if (!commandId) return false; + const row = (control || []).find((item) => item?.type === "sendPrompt" && item?.phase === "completed" && item?.commandId === commandId); + const detail = objectValue(row?.detail); + const chatSubmit = objectValue(detail.chatSubmit); + const sideEffect = objectValue(chatSubmit.sideEffect); + return chatSubmit.sideEffectObserved === true + || sideEffect.submitted === true + || Number(sideEffect.messageCountDelta ?? 0) > 0; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index 76586e65..83ae5a0d 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -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" | "timeline" | "workbench-triad" | "project-summary" | "project-mdtodo-summary"; +export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame" | "timeline" | "workbench-triad" | "project-summary" | "project-mdtodo-summary" | "performance-summary"; export function parseNodeWebProbeObserveCollectView(value: string): NodeWebProbeObserveCollectView { - if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "timeline" || value === "workbench-triad" || value === "project-summary" || value === "project-mdtodo-summary") return value; - throw new Error(`web-probe observe collect --view must be files, turn-summary, trace-frame, timeline, workbench-triad, project-summary, or project-mdtodo-summary; got ${value}`); + if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "timeline" || value === "workbench-triad" || value === "project-summary" || value === "project-mdtodo-summary" || value === "performance-summary") return value; + throw new Error(`web-probe observe collect --view must be files, turn-summary, trace-frame, timeline, workbench-triad, project-summary, project-mdtodo-summary, or performance-summary; got ${value}`); } export function nodeWebObserveCollectViewNodeScript(input: { @@ -44,6 +44,7 @@ const samples=readJsonl('samples.jsonl'); const control=readJsonl('control.jsonl'); const network=readJsonl('network.jsonl'); const browserProcess=readJsonl('browser-process.jsonl'); +const performanceRows=readJsonl('performance-events.jsonl'); const manifest=readJson('manifest.json')||{}; const report=readJson('analysis/report.json')||{}; function unique(values){return Array.from(new Set(values.filter(Boolean)));} @@ -723,6 +724,43 @@ function targetNodeFromStateDir(){ const index=parts.lastIndexOf('web-observe'); return index>=0&&parts[index+1]?parts[index+1]:null; } +function performanceSummaryFromReport(){ + const perf=report.frontendPerformance&&typeof report.frontendPerformance==='object'?report.frontendPerformance:{}; + const summary=perf.summary&&typeof perf.summary==='object'?perf.summary:{}; + const findings=Array.isArray(report.findings)?report.findings.filter((item)=>String(item?.id||item?.kind||'').match(/^frontend-(?:long|event-loop|cpu-profile|performance)/u)).slice(0,20):[]; + const captureRows=Array.isArray(perf.captures)?perf.captures:[]; + return { + summary:{...summary, rawPerformanceRowCount:performanceRows.length, valuesRedacted:true}, + longTasks:Array.isArray(perf.longTasks)?perf.longTasks.slice(0,12):[], + longAnimationFrames:Array.isArray(perf.longAnimationFrames)?perf.longAnimationFrames.slice(0,12):[], + eventLoopGaps:Array.isArray(perf.eventLoopGaps)?perf.eventLoopGaps.slice(0,12):[], + scriptHotspots:Array.isArray(perf.scriptHotspots)?perf.scriptHotspots.slice(0,12):[], + profileHotspots:Array.isArray(perf.profileHotspots)?perf.profileHotspots.slice(0,12):[], + profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,8):[], + captures:captureRows.slice(-12), + findings, + valuesRedacted:true + }; +} +function renderPerformanceSummary(perf){ + const s=perf.summary||{}; + const lines=['WEB-PROBE frontend performance '+(manifest.jobId||'-'),'=======================================================']; + lines.push('events='+String(s.eventCount??0)+' rawRows='+String(s.rawPerformanceRowCount??0)+' longTask='+String(s.longTaskCount??0)+' loaf='+String(s.longAnimationFrameCount??0)+' eventLoopGap='+String(s.eventLoopGapCount??0)+' captures='+String(s.captureCount??0)); + lines.push('max longTask='+String(s.maxLongTaskMs??'-')+'ms budget='+String(s.longTaskRedMs??'-')+'ms; max LoAF='+String(s.maxLongAnimationFrameMs??'-')+'ms budget='+String(s.longAnimationFrameRedMs??'-')+'ms; max gap='+String(s.maxEventLoopGapMs??'-')+'ms budget='+String(s.eventLoopGapRedMs??'-')+'ms'); + lines.push('','CPU profile hotspots'); + if(perf.profileHotspots.length===0) lines.push('-'); + for(const item of perf.profileHotspots.slice(0,10)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' '+short(item.url||item.scriptId||'-',92)+' line='+String(item.lineNumber??'-')+' captures='+String(item.captureCount??'-')); + lines.push('','LoAF script hotspots'); + if(perf.scriptHotspots.length===0) lines.push('-'); + for(const item of perf.scriptHotspots.slice(0,10)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',44)+' '+short(item.sourceURL||'-',92)+' line='+String(item.lineNumber??'-')); + lines.push('','Longest events'); + for(const item of [...perf.longTasks.slice(0,4),...perf.longAnimationFrames.slice(0,4),...perf.eventLoopGaps.slice(0,4)].sort((a,b)=>Number(b.durationMs??0)-Number(a.durationMs??0)).slice(0,10)) lines.push(String(item.ts||'-')+' '+String(item.kind||'-')+' duration='+String(item.durationMs??'-')+'ms role='+String(item.pageRole||'-')+' sample='+String(item.sampleSeq??'-')+' scripts='+String(item.scriptCount??0)); + lines.push('','Findings'); + if(perf.findings.length===0) lines.push('-'); + for(const item of perf.findings.slice(0,12)) lines.push(String(item.severity||'-')+': '+String(item.id||item.kind||'-')+' count='+String(item.count??'-')+' '+short(item.summary||item.message||'',150)); + lines.push('','NEXT',' capture: bun scripts/cli.ts web-probe observe command '+(manifest.jobId||'')+' --type performanceCapture --duration-ms 5000 --wait-ms 8000',' analyze: bun scripts/cli.ts web-probe observe analyze '+(manifest.jobId||''),'DISCLOSURE source=existing artifacts valuesRedacted=true; this view does not start browser/probe or mutate runtime.'); + return lines.join('\\n'); +} function renderProjectSummary(project){ const s=project.summary||{}; const lines=['Project MDTODO 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||'-'),'fileLabel='+short(s.latestSelectedFileLabelPreview||'-',80)+' direct='+String(s.latestSelectedFileLabelLooksDirect??'-')+' bodyVisibleSamples='+String(s.selectedTaskBodyVisibleSamples??'-')+' reportPreviewSamples='+String(s.reportPreviewVisibleSamples??'-')+' reportFullscreenSamples='+String(s.reportFullscreenVisibleSamples??'-'),'paneGap actionable='+String(s.severePaneGapSampleCount??0)+' ignoredInitialEmptyDetail='+String(s.ignoredPaneGapSampleCount??0),'commands='+String(s.projectCommandCount??0)+' mutations='+String(s.mutationCommandCount??0)+' mutationFailures='+String(s.mutationFailureCount??0),'launch commands='+String(s.launchCommandCount??0)+' success='+String(s.launchSuccessCount??0)+' failure='+String(s.launchFailureCount??0)+' nonEmpty='+String(s.launchNonEmptyCount??'-')+' empty='+String(s.launchEmptyCount??'-')+' otelTraceHeader='+String(s.launchWithOtelTraceHeaderCount??0),'api responses='+String(s.projectApiResponseCount??'-')+' failures='+String(s.projectApiFailureCount??'-')+'/'+String(s.projectApiRequestFailedCount??'-')+' slowPaths='+String(s.projectApiSlowPathCount??'-'),'','Recent samples']; @@ -748,6 +786,11 @@ function renderProjectSummary(project){ return lines.join('\\n'); } const rows=turnSummaryRows(); +if(view==='performance-summary'){ + const perf=performanceSummaryFromReport(); + console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,summary:perf.summary,profileHotspots:perf.profileHotspots,profileStacks:perf.profileStacks,scriptHotspots:perf.scriptHotspots,longTasks:perf.longTasks,longAnimationFrames:perf.longAnimationFrames,eventLoopGaps:perf.eventLoopGaps,captures:perf.captures,findings:perf.findings,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,skippedFiles:skippedFiles.slice(0,20),renderedText:renderPerformanceSummary(perf),sourceFiles:['performance-events.jsonl','artifacts.jsonl','analysis/report.json'],valuesRedacted:true})); + process.exit(0); +} if(view==='project-summary'||view==='project-mdtodo-summary'){ const project=projectSummaryFromSamples(); const projectSummary={...project.summary,latestSelectedTaskRefPreview:short(project.summary?.latestSelectedTaskRefPreview,80)}; diff --git a/scripts/src/hwlab-node-web-observe-runner-control-source.ts b/scripts/src/hwlab-node-web-observe-runner-control-source.ts new file mode 100644 index 00000000..fe76261f --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-control-source.ts @@ -0,0 +1,1903 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Runner command queue, authentication, navigation, control page recovery, and prompt submission source fragment. + +export function nodeWebObserveRunnerControlSource(): string { + return String.raw`async function drainOneCommand() { + const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort(); + const name = entries[0]; + if (!name) return; + const pending = path.join(dirs.commandsPending, name); + const processing = path.join(dirs.commandsProcessing, name); + await rename(pending, processing).catch(() => null); + const raw = await readFile(processing, "utf8"); + const command = JSON.parse(raw); + const id = safeId(command.id || name.replace(/[.]json$/u, "")); + command.id = id; + const stopCommandSampler = startCommandActiveSampler(command); + try { + const result = await processCommand(command); + const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) }; + await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 }); + await appendJsonl(files.control, controlRecord(command, "completed", done.result)); + await unlink(processing).catch(() => {}); + } catch (error) { + const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false }) + .then(() => ({ ok: true, sampleSeq, valuesRedacted: true })) + .catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true })); + const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample }; + await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 }); + await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample })); + await unlink(processing).catch(() => {}); + } finally { + stopCommandSampler(); + activeCommandId = null; + await writeHeartbeat({ status: terminalStatus }); + } +} + +function startCommandActiveSampler(command) { + const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000); + const heartbeatIntervalMs = Math.min(5000, intervalMs); + let stopped = false; + let timer = null; + let heartbeatTimer = null; + let inFlight = false; + const heartbeat = () => { + if (stopped) return; + void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true }) + .catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))); + }; + const schedule = () => { + if (stopped) return; + timer = setTimeout(tick, intervalMs); + if (timer && typeof timer.unref === "function") timer.unref(); + }; + const tick = () => { + if (stopped) return; + if (inFlight) { + schedule(); + return; + } + inFlight = true; + samplePage("command-active", { refreshObserver: false, screenshot: false }) + .catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))) + .finally(() => { + inFlight = false; + schedule(); + }); + }; + heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs); + if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref(); + schedule(); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + if (heartbeatTimer) clearInterval(heartbeatTimer); + }; +} + +async function processCommand(command) { + commandSeq += 1; + activeCommandId = command.id; + await writeHeartbeat({ status: "running", activeCommandId }); + await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command))); + switch (command.type) { + case "login": return authenticate(context); + case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount"); + case "logout": return withObserverSync(await logoutAccount(command), "logout"); + case "listSessions": return withObserverSync(await listSessions(command), "listSessions"); + case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions"); + case "preflight": return preflightSummary(); + case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); + case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); + case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), { + expectedAction: "turn", + responsePath: "/v1/agent/chat", + alternateResponsePaths: ["/v1/agent/chat/steer"], + noActiveReason: "send-no-turn-composer", + throwOnActionMismatch: true, + expectedActionWaitMs: command.expectedActionWaitMs, + }), "sendPrompt"); + case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer"); + 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 "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession"); + case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack"); + case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant"); + case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo"); + case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command); + case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command); + case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command); + case "probeMdtodoSource": return probeMdtodoSource(command); + case "reindexMdtodoSource": return reindexMdtodoSource(command); + case "selectProjectSource": return selectProjectSource(command); + case "selectMdtodoSource": return selectMdtodoSource(command); + case "selectMdtodoFile": return selectMdtodoFile(command); + case "selectMdtodoTask": return selectMdtodoTask(command); + case "expandMdtodoTask": return expandMdtodoTask(command); + case "openMdtodoReportPreview": return openMdtodoReportPreview(command); + case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command); + case "editMdtodoTaskInline": return editMdtodoTaskInline(command); + case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command); + case "editMdtodoTaskBody": return editMdtodoTaskBody(command); + case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command); + case "addMdtodoRootTask": return addMdtodoRootTask(command); + case "addMdtodoSubTask": return addMdtodoSubTask(command); + case "continueMdtodoTask": return continueMdtodoTask(command); + case "deleteMdtodoTask": return deleteMdtodoTask(command); + case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask"); + case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo"); + case "performanceCapture": return capturePerformanceProfile(command); + case "screenshot": return captureCommandScreenshot(command); + case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId }; + case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId }; + default: throw new Error("unsupported observer command type: " + command.type); + } +} + +async function withObserverSync(result, reason) { + return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) }; +} + +async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) { + if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" }; + const forceRefresh = options?.forceRefresh === true; + const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000; + const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000; + const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 15000; + const shortCircuitReadinessTimeoutMs = Number.isFinite(Number(options.shortCircuitReadinessTimeoutMs)) ? Math.max(1, Number(options.shortCircuitReadinessTimeoutMs)) : 1000; + const shortCircuitHydrationTimeoutMs = Number.isFinite(Number(options.shortCircuitHydrationTimeoutMs)) ? Math.max(1, Number(options.shortCircuitHydrationTimeoutMs)) : 1000; + const snapshot = await workbenchSessionSnapshot(); + const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl()); + const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath; + const targetUrl = new URL(target, baseUrl).toString(); + const beforeUrl = pageUrl(observerPage); + const beforeSessionId = routeSessionIdFromUrl(beforeUrl); + const attempts = []; + if (sessionId && beforeSessionId === sessionId && !forceRefresh) { + const current = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: shortCircuitReadinessTimeoutMs, hydrationTimeoutMs: shortCircuitHydrationTimeoutMs }); + if (current.ok) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, readiness: current.readiness, hydration: current.hydration, valuesRedacted: true }; + attempts.push({ attempt: 0, ok: false, shortCircuitRejected: true, failureKind: current.failureKind, readiness: current.readiness, hydration: current.hydration, beforeUrl, afterUrl: pageUrl(observerPage), valuesRedacted: true }); + } + const maxAttempts = Number.isFinite(Number(options?.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const attemptBeforeUrl = pageUrl(observerPage); + observerPageEpoch += 1; + let status = null; + let statusText = null; + const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }).catch((error) => ({ observerGotoError: errorSummary(error) })); + if (response?.observerGotoError) { + attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(response.observerGotoError?.message || response.observerGotoError?.name || "observer-navigation-error"), beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), error: response.observerGotoError, valuesRedacted: true }); + if (attempt < maxAttempts && isRetryableNavigationError(response.observerGotoError?.message || response.observerGotoError?.name || "")) { + await recreateObserverPageForNavigation("observer-goto-error", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); + continue; + } + lastObserverRefreshAtMs = Date.now(); + return { ok: false, reason, changed: false, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, attempts, valuesRedacted: true }; + } + status = typeof response?.status === "function" ? response.status() : null; + statusText = typeof response?.statusText === "function" ? response.statusText() : null; + const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: readinessTimeoutMs }); + if (!readiness.ok) { + attempts.push({ attempt, ok: false, failureKind: readiness.reason || "observer-target-not-ready", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true }); + if (attempt < maxAttempts && observerReadinessRetryable(readiness)) { + await recreateObserverPageForNavigation(readiness.reason || "observer-target-not-ready", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); + continue; + } + 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, attempts, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true }; + } + if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) { + lastObserverRefreshAtMs = Date.now(); + attempts.push({ attempt, ok: true, beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true }); + 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, attempts, valuesRedacted: true }; + } + const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: hydrationTimeoutMs }); + attempts.push({ attempt, ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, hydration, valuesRedacted: true }); + if (hydration.ok === true) { + lastObserverRefreshAtMs = Date.now(); + return { ok: true, 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, attempts, failureKind: null, valuesRedacted: true }; + } + if (attempt < maxAttempts && observerHydrationRetryable(hydration)) { + await recreateObserverPageForNavigation(hydration.reason || "observer-session-hydration-failed", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); + continue; + } + 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, attempts, failureKind: hydration.reason || "observer-session-hydration-failed", valuesRedacted: true }; + } + 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, attempts, failureKind: "observer-sync-retry-exhausted", valuesRedacted: true }; +} + +async function observerSessionReadiness(targetUrl, sessionId, options = {}) { + const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: options.readinessTimeoutMs ?? 1000 }); + if (!readiness.ok) return { ok: false, failureKind: readiness.reason || "observer-target-not-ready", readiness, hydration: null, valuesRedacted: true }; + if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) return { ok: true, readiness, hydration: null, valuesRedacted: true }; + const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: options.hydrationTimeoutMs ?? 1000 }); + return { ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", readiness, hydration, valuesRedacted: true }; +} + +function observerReadinessRetryable(readiness) { + const reason = String(readiness?.reason || ""); + const snapshot = readiness?.snapshot || {}; + return /workbench-app-not-ready|observer-target-not-ready/iu.test(reason) + || snapshot.workbenchShellVisible === false + || snapshot.sessionRailPresent === false && snapshot.commandInputPresent === false; +} + +function observerHydrationRetryable(hydration) { + const reason = String(hydration?.reason || ""); + return /observer-session-hydration-timeout|observer-session-hydration-failed|active-session-not-hydrated|route-session-not-hydrated/iu.test(reason); +} + +async function recreateObserverPageForNavigation(reason, attempt) { + const before = pageUrl(observerPage); + if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {}); + observerPage = await context.newPage(); + attachPassiveListeners(observerPage, "observer", observerPageId); + await appendJsonl(files.control, eventRecord("observer-page-recreated", { reason, attempt, beforeUrl: before, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, valuesRedacted: true })); +} + +async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) { + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000; + const started = Date.now(); + const deadline = started + Math.max(1, timeoutMs); + let last = null; + while (Date.now() <= deadline) { + last = await workbenchSessionSnapshot(targetPage); + const expected = String(sessionId || "").trim(); + const observedPath = safeUrlPath(last?.url || pageUrl(targetPage)); + const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || ""); + const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || ""); + if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; + await targetPage.waitForTimeout(250).catch(() => {}); + } + return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true }; +} + +async function maybeRefreshObserverPage(reason) { + if (!observerPage || observerPage.isClosed()) return null; + if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null; + if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null; + const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true }); + await appendJsonl(files.control, eventRecord("observer-periodic-refresh", { + pageRole: "observer", + pageId: observerPageId, + reason, + intervalMs: observerRefreshIntervalMs, + result, + valuesRedacted: true + })); + return result; +} + +async function runControlCommand(command, fn) { + activeCommandId = command.id; + commandSeq += 1; + const beforeUrl = currentPageUrl(); + const started = Date.now(); + await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) })); + try { + const result = await fn(); + await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) })); + return result; + } catch (error) { + let failurePageProvenance = null; + let failureScreenshot = null; + try { + failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null)); + } catch (captureError) { + await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); + } + try { + failureScreenshot = await captureScreenshot("command-failed", "jpeg"); + } catch (captureError) { + await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); + } + await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot })); + throw error; + } finally { + activeCommandId = null; + } +} + +async function authenticate(browserContext) { + const loginUrl = new URL("/auth/login", baseUrl).toString(); + const attempts = []; + const maxAttempts = authLoginMaxAttempts; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const retryDelayMs = authRetryDelayMs(attempt, maxAttempts); + const retryLabel = attempt + "/" + maxAttempts; + await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); + try { + const response = await pageAuthLogin(browserContext, loginUrl); + const cookieState = await readAuthCookieState(browserContext); + const retryable = isRetryableAuthStatus(response.status); + const item = { + attempt, + retryAttempt: attempt, + retryMaxAttempts: maxAttempts, + retryLabel, + retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, + requestTimeoutMs: authLoginRequestTimeoutMs, + method: "api", + status: response.status, + statusText: response.statusText, + retryable, + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + valuesRedacted: true, + }; + attempts.push(item); + await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); + if (response.ok && cookieState.cookiePresent) { + return { + ok: true, + method: "api", + loginPath: new URL(loginUrl).pathname, + status: response.status, + statusText: response.statusText, + cookiePresent: true, + cookieNames: cookieState.cookieNames, + attempts, + retryCount: attempt - 1, + retryMaxAttempts: maxAttempts, + lastRetryLabel: attempt + "/" + maxAttempts, + retryExhausted: false, + retryable: false, + valuesRedacted: true, + }; + } + if (!retryable) break; + } catch (error) { + const retryable = isRetryableAuthError(error); + attempts.push({ + attempt, + retryAttempt: attempt, + retryMaxAttempts: maxAttempts, + retryLabel, + retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, + requestTimeoutMs: authLoginRequestTimeoutMs, + method: "api", + status: 0, + statusText: "request-error", + retryable, + error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500), + cookiePresent: false, + cookieNames: [], + valuesRedacted: true, + }); + const item = attempts[attempts.length - 1] || null; + await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {}); + if (!retryable) break; + } + if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs); + } + const cookieState = await readAuthCookieState(browserContext); + const last = attempts[attempts.length - 1] || null; + const retryable = attempts.some((attempt) => attempt && attempt.retryable === true); + const failure = { + ok: false, + method: "api", + loginPath: new URL(loginUrl).pathname, + status: typeof last?.status === "number" ? last.status : 0, + statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed", + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + attempts, + retryCount: Math.max(0, attempts.length - 1), + retryMaxAttempts: maxAttempts, + lastRetryLabel: last?.retryLabel || null, + retryExhausted: retryable && attempts.length >= maxAttempts, + retryable, + lastError: last?.error || null, + valuesRedacted: true, + }; + await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {}); + const error = new Error(authFailureMessage(failure)); + error.webProbeAuth = failure; + throw error; +} + +async function pageAuthLogin(browserContext, loginUrl, credential = { username, password }) { + if (!browserContext?.request) throw new Error("auth browser context request is not ready"); + const response = await browserContext.request.post(loginUrl, { + data: { username: credential.username, password: credential.password }, + headers: { accept: "application/json", "content-type": "application/json" }, + timeout: authLoginRequestTimeoutMs, + }); + await response.text().catch(() => ""); + return { + ok: response.ok(), + status: response.status(), + statusText: response.statusText() || "", + }; +} + +async function loginAccount(command) { + const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]); + const credential = credentialForAccount(accountId); + const loginUrl = new URL("/auth/login", baseUrl).toString(); + const before = await accountSessionSnapshot(); + const attempts = []; + let response = null; + let cookieState = null; + const maxAttempts = authLoginMaxAttempts; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const retryDelayMs = authRetryDelayMs(attempt, maxAttempts); + const retryLabel = attempt + "/" + maxAttempts; + try { + response = await pageAuthLogin(context, loginUrl, credential); + cookieState = await readAuthCookieState(context); + const retryable = isRetryableAuthStatus(response.status); + attempts.push({ + attempt, + retryAttempt: attempt, + retryMaxAttempts: maxAttempts, + retryLabel, + retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, + requestTimeoutMs: authLoginRequestTimeoutMs, + method: "api", + status: response.status, + statusText: response.statusText, + retryable, + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + credentialSource: credential.source, + valuesRedacted: true, + }); + if (response.ok && cookieState.cookiePresent) break; + if (!retryable) break; + } catch (error) { + const retryable = isRetryableAuthError(error); + attempts.push({ + attempt, + retryAttempt: attempt, + retryMaxAttempts: maxAttempts, + retryLabel, + retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, + requestTimeoutMs: authLoginRequestTimeoutMs, + method: "api", + status: 0, + statusText: "request-error", + retryable, + error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500), + cookiePresent: false, + cookieNames: [], + credentialSource: credential.source, + valuesRedacted: true, + }); + response = { ok: false, status: 0, statusText: "request-error" }; + cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] })); + if (!retryable) break; + } + if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs); + } + response = response ?? { ok: false, status: 0, statusText: "api-login-failed" }; + cookieState = cookieState ?? await readAuthCookieState(context); + if (!response.ok || !cookieState.cookiePresent) { + const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || "")); + const retryable = attempts.some((item) => item && item.retryable === true); + error.details = { + accountId, + status: response.status, + statusText: response.statusText, + cookiePresent: cookieState.cookiePresent, + credentialSource: credential.source, + attempts, + retryCount: Math.max(0, attempts.length - 1), + retryMaxAttempts: maxAttempts, + lastRetryLabel: attempts[attempts.length - 1]?.retryLabel || null, + retryExhausted: retryable && attempts.length >= maxAttempts, + retryable, + valuesRedacted: true, + }; + throw error; + } + const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath; + const navigation = await gotoTarget(target || targetPath); + const after = await accountSessionSnapshot(); + return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }; +} + +async function logoutAccount(command = {}) { + const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; + const before = await accountSessionSnapshot(); + const logoutUrl = new URL("/logout", baseUrl).toString(); + const response = await page.evaluate(async (input) => { + const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" }); + await res.text().catch(() => ""); + return { ok: res.ok, status: res.status, statusText: res.statusText || "" }; + }, { logoutUrl }); + await context.clearCookies().catch(() => {}); + const cookieState = await readAuthCookieState(context); + const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl()); + const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true }; + if (!result.ok) { + const error = new Error("logout failed status=" + response.status + " " + (response.statusText || "")); + error.details = result; + throw error; + } + return result; +} + +async function listSessions(command = {}) { + const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; + if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath); + const snapshot = await workbenchSessionSnapshot(); + const sessions = await page.evaluate(() => { + const seen = new Set(); + const rows = []; + for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) { + const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || ""; + if (!sessionId || seen.has(sessionId)) continue; + seen.add(sessionId); + rows.push({ + sessionId, + active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", + status: element.getAttribute("data-status") || null, + conversationId: element.getAttribute("data-conversation-id") || null, + }); + } + return rows.slice(0, 50); + }).catch(() => []); + return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true }; +} + +async function switchSessions(command) { + const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]); + const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]); + const before = await accountSessionSnapshot(); + if (fromAccountId) { + const beforeAccount = before.accountId || null; + await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true })); + } + const logout = await logoutAccount({ ...command, accountId: fromAccountId || null }); + const login = await loginAccount({ ...command, accountId: toAccountId }); + const sessions = await listSessions({ ...command, accountId: toAccountId }); + return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true }; +} + +async function accountSessionSnapshot() { + const cookieState = await readAuthCookieState(context); + const workbench = await workbenchSessionSnapshot().catch(() => null); + return { + url: currentPageUrl(), + path: safeUrlPath(currentPageUrl()), + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + activeSessionId: workbench?.activeSessionId || null, + routeSessionId: workbench?.routeSessionId || null, + tabCount: workbench?.tabCount ?? null, + messageCount: workbench?.messageCount ?? null, + valuesRedacted: true, + }; +} + +function requiredAccountId(command, keys) { + const accountId = commandValue(command, keys); + if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id"); + return accountId; +} + +function credentialForAccount(accountId) { + if (accountId === "bootstrap-admin" || accountId === "admin") { + if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS"); + return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true }; + } + const env = accountCredentialEnvCandidates(accountId); + for (const jsonKey of env.jsonKeys) { + const raw = process.env[jsonKey]; + if (!raw) continue; + const parsed = parseCredentialJson(raw); + if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true }; + } + for (const pair of env.pairs) { + const user = process.env[pair.userKey]; + const pass = process.env[pair.passKey]; + if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true }; + } + throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(",")); +} + +function accountCredentialEnvCandidates(accountId) { + const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); + return { + jsonKeys: [ + "HWLAB_WEB_" + segment + "_JSON", + "HWLAB_WEB_ACCOUNT_" + segment + "_JSON", + ], + pairs: [ + { userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" }, + { userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" }, + ], + }; +} + +function parseCredentialJson(raw) { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : ""; + const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : ""; + if (!user || !pass) return null; + return { username: user, password: pass, valuesRedacted: true }; + } catch { + return null; + } +} + +function isSafeAccountId(value) { + return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || "")); +} + +function publicAuth(value) { + if (!value) return null; + return { + ok: value.ok === true, + method: value.method, + status: value.status, + cookiePresent: value.cookiePresent === true, + cookieNames: value.cookieNames || [], + retryCount: value.retryCount ?? null, + retryMaxAttempts: value.retryMaxAttempts ?? null, + lastRetryLabel: value.lastRetryLabel ?? null, + retryExhausted: value.retryExhausted === true, + valuesRedacted: true + }; +} + +async function readAuthCookieState(browserContext) { + const cookies = await browserContext.cookies(baseUrl); + const cookieNames = cookies.map((cookie) => cookie.name).sort(); + return { + cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)), + cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)), + }; +} + +function isRetryableAuthStatus(status) { + return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500; +} + +function isRetryableAuthError(error) { + const message = error && error.message ? String(error.message) : String(error || ""); + return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message); +} + +function authRetryDelayMs(attempt, maxAttempts) { + return attempt < maxAttempts ? Math.min(authLoginMaxDelayMs, authLoginInitialDelayMs * (2 ** (attempt - 1))) : 0; +} + +function authFailureMessage(failure) { + const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null; + const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : ""; + const exhausted = failure.retryExhausted ? " exhausted=true" : ""; + const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : ""; + const error = last?.error ? " error=" + truncate(last.error, 160) : ""; + return ("auth login failed:" + retry + exhausted + status + error).trim(); +} + +function proxyConfigFromEnv(targetBaseUrl) { + if (browserProxyMode === "direct") return null; + let target; + try { + target = new URL(targetBaseUrl); + } catch { + return null; + } + const noProxy = process.env.NO_PROXY || process.env.no_proxy || ""; + if (noProxyMatches(target.hostname, noProxy)) return null; + const raw = target.protocol === "https:" + ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "" + : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || ""; + if (!raw) return null; + return { server: raw }; +} + +function chromiumLaunchOptionsForProxy(proxy) { + const baseArgs = chromiumLowResourceArgs(); + const base = { env: browserProcessEnvWithoutProxy() }; + if (proxy === null) return { ...base, args: [...baseArgs, "--no-proxy-server"] }; + return { ...base, proxy, args: baseArgs }; +} + +function chromiumLowResourceArgs() { + return []; +} + +function browserProcessEnvWithoutProxy() { + const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]); + const env = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!blocked.has(key) && value !== undefined) env[key] = value; + } + return env; +} + +function noProxyMatches(hostname, rawList) { + const host = String(hostname || "").toLowerCase(); + if (!host) return false; + return String(rawList || "").split(",").some((raw) => { + let item = raw.trim().toLowerCase(); + if (!item) return false; + if (item === "*") return true; + item = item.replace(/^\*\./u, "."); + const portIndex = item.lastIndexOf(":"); + if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex); + if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item); + return host === item; + }); +} + +function publicNetwork(proxy) { + return { + proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : { + enabled: true, + source: "env", + server: publicProxyServer(proxy.server), + valuesRedacted: true, + }, + browser: { + proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy", + requestedProxyMode: browserProxyMode, + proxyEnvCleared: true, + valuesRedacted: true, + }, + valuesRedacted: true, + }; +} + +function parseBrowserProxyMode(raw) { + if (raw === "auto" || raw === "direct") return raw; + return "auto"; +} + +function publicProxyServer(raw) { + try { + const parsed = new URL(String(raw || "")); + parsed.username = ""; + parsed.password = ""; + const value = parsed.toString(); + if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, ""); + return value; + } catch { + return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@"); + } +} + +async function gotoTarget(rawTarget, options = {}) { + const target = new URL(String(rawTarget || targetPath), baseUrl).toString(); + const beforeUrl = currentPageUrl(); + const attempts = []; + const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : navigationMaxAttempts; + const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000; + const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000; + const settleMs = Number.isFinite(Number(options.settleMs)) ? Math.max(0, Number(options.settleMs)) : 1000; + const lateReadinessTimeoutMs = Number.isFinite(Number(options.lateReadinessTimeoutMs)) ? Math.max(0, Number(options.lateReadinessTimeoutMs)) : 5000; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }); + if (settleMs > 0) await page.waitForTimeout(settleMs).catch(() => {}); + const httpStatus = response ? response.status() : null; + const readiness = await waitForTargetPageReady(page, target, { timeoutMs: readinessTimeoutMs }); + if (!readiness.ok) { + const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null); + attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" }); + return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; + } + const pageProvenance = await refreshPageProvenance("goto", httpStatus); + attempts.push({ attempt, ok: true, httpStatus, readiness }); + return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null }); + if (lateReadinessTimeoutMs > 0 && /workbench-app-not-ready|navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(message)) { + const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: lateReadinessTimeoutMs }).catch(() => null); + if (lateReadiness?.ok) { + const pageProvenance = await refreshPageProvenance("goto-late-ready", null); + attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness }); + return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts }; + } + } + if (attempt >= maxAttempts || !isRetryableNavigationError(message)) { + throw Object.assign(new Error(message), { attempts, target }); + } + if (!observerPage) { + await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); + } else { + await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); + } + await page.waitForTimeout(1500 * attempt).catch(() => {}); + } + } + return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts }; +} + +async function recreateControlPageForNavigation(reason, attempt) { + const before = currentPageUrl(); + if (page && !page.isClosed()) await withHardTimeout(page.close(), 3000, "control page close exceeded 3000ms").catch((error) => appendJsonl(files.errors, eventRecord("control-page-close-timeout", { reason, attempt, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch }))); + controlPageEpoch += 1; + page = await context.newPage(); + attachPassiveListeners(page, "control", pageId); + currentPageProvenance = null; + await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true })); +} + +async function recreateAuthenticatedContextForNavigation(reason, attempt) { + const before = currentPageUrl(); + if (page && !page.isClosed()) await page.close().catch(() => {}); + if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {}); + observerPage = null; + if (context) await context.close().catch(() => {}); + context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); + auth = await authenticate(context); + page = await context.newPage(); + attachPassiveListeners(page, "control", pageId); + currentPageProvenance = null; + await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true })); +} + +async function refreshPageProvenance(reason, httpStatus = null) { + if (!page || page.isClosed()) return currentPageProvenance; + const observed = await page.evaluate(() => { + const assetPath = (raw) => { + if (!raw) return null; + try { + const url = new URL(raw, location.href); + const keys = Array.from(url.searchParams.keys()).sort(); + return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); + } catch { + return null; + } + }; + const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ + key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), + content: String(element.getAttribute("content") || "").slice(0, 200), + })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)); + const navigation = performance.getEntriesByType("navigation")[0] || null; + return { + url: location.href, + path: location.pathname, + title: document.title, + readyState: document.readyState, + timeOrigin: Math.round(performance.timeOrigin || 0), + navigationStartTime: navigation ? Math.round(navigation.startTime) : null, + scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(), + stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(), + meta, + }; + }).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] })); + pageLoadSeq += 1; + currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq }); + await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) })); + return currentPageProvenance; +} + +function normalizePageProvenance(value, options = {}) { + const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : []; + const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : []; + const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({ + key: String(item?.key || "").slice(0, 120), + contentHash: sha256Text(String(item?.content || "")), + })).filter((item) => item.key) : []; + const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta }); + return { + pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq, + reason: options.reason || "sample", + observedAt: new Date().toISOString(), + urlPath: safeUrlPath(value?.url || currentPageUrl()), + documentPath: value?.path || null, + titleHash: sha256Text(String(value?.title || "")), + documentReadyState: value?.readyState || null, + timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null, + navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null, + httpStatus: options.httpStatus ?? null, + assetFingerprint: sha256Text(fingerprintInput), + scriptCount: scripts.length, + stylesheetCount: stylesheets.length, + metaCount: meta.length, + scripts: scripts.slice(0, 30), + stylesheets: stylesheets.slice(0, 30), + meta: meta.slice(0, 30), + error: value?.error || null, + valuesRedacted: true, + }; +} + +function compactPageProvenance(value) { + if (!value) return null; + return { + pageLoadSeq: value.pageLoadSeq ?? null, + reason: value.reason || null, + observedAt: value.observedAt || null, + urlPath: value.urlPath || null, + documentReadyState: value.documentReadyState || null, + timeOrigin: value.timeOrigin ?? null, + httpStatus: value.httpStatus ?? null, + assetFingerprint: value.assetFingerprint || null, + scriptCount: value.scriptCount ?? 0, + stylesheetCount: value.stylesheetCount ?? 0, + metaCount: value.metaCount ?? 0, + scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [], + stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [], + error: value.error || null, + valuesRedacted: true, + }; +} + +function isRetryableNavigationError(message) { + return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|net::ERR_NAME_NOT_RESOLVED|Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded|workbench-app-not-ready/iu.test(String(message || "")); +} + +function navigationFailureKind(message) { + const text = String(message || ""); + if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED"; + if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED"; + if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET"; + if (/net::ERR_NAME_NOT_RESOLVED/iu.test(text)) return "net::ERR_NAME_NOT_RESOLVED"; + if (/Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(text)) return "navigation-timeout"; + if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready"; + return "navigation-error"; +} + +function redactErrorMessage(message) { + return String(message || "") + .replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]") + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]"); +} + +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(() => { + 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 workspace = document.querySelector("#workspace, .workbench-route"); + const login = document.querySelector("form.login-card, .login-card, [data-testid='login']"); + return Boolean(visible(workspace) || visible(login)); + }, null, { timeout: timeoutMs }).catch(() => null); + const snapshot = await workbenchReadinessSnapshot(targetPage); + const ok = snapshot.workbenchShellVisible === true; + return { + ok, + reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready", + durationMs: Date.now() - started, + snapshot, + valuesRedacted: true + }; +} + +async function workbenchReadinessSnapshot(targetPage) { + const snapshot = await targetPage.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 sessionCreate = document.querySelector("#session-create"); + const sessionRail = document.querySelector("#session-sidebar"); + const sessionCollapseToggle = document.querySelector("#session-collapse-toggle"); + return { + url: window.location.href, + path: window.location.pathname, + readyState: document.readyState, + workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")), + sessionCreatePresent: Boolean(sessionCreate), + sessionCreateVisible: visible(sessionCreate), + sessionRailPresent: Boolean(sessionRail), + sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null, + sessionCollapseTogglePresent: Boolean(sessionCollapseToggle), + sessionCollapseToggleVisible: visible(sessionCollapseToggle), + sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null, + commandInputPresent: visible(document.querySelector("#command-input")), + activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")), + warningPresent: visible(document.querySelector(".composer-warning")), + loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")), + bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000), + valuesRedacted: true + }; + }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); + if (snapshot && typeof snapshot.bodyTextPreview === "string") { + snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview); + delete snapshot.bodyTextPreview; + } + 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 })); +} + +async function waitForProjectManagementCommandReady(options = {}) { + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000; + const started = Date.now(); + const deadline = started + timeoutMs; + let last = null; + while (Date.now() <= deadline) { + last = await projectManagementCommandSnapshot(); + const path = String(last?.path || safeUrlPath(currentPageUrl()) || ""); + const baseReady = last?.pageKind === "project-management-mdtodo" + && Number(last?.sourceCount || 0) > 0 + && Number(last?.fileCount || 0) > 0 + && Number(last?.taskCount || 0) > 0; + const needsTask = /\/tasks\//u.test(path); + const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true); + const needsReport = /\/reports\//u.test(path); + const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true; + if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; + await page.waitForTimeout(250).catch(() => {}); + } + return { ok: false, reason: "project-management-command-not-ready", durationMs: Date.now() - started, snapshot: last, 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 + "/")); +} + +function isAgentSessionCreateRequest(requestOrUrl) { + const method = typeof requestOrUrl?.method === "function" ? requestOrUrl.method().toUpperCase() : ""; + if (method && method !== "POST") return false; + const url = typeof requestOrUrl === "string" ? requestOrUrl : typeof requestOrUrl?.url === "function" ? requestOrUrl.url() : ""; + try { + return new URL(url).pathname === "/v1/agent/sessions"; + } catch { + return false; + } +} + +function requestFailureSummary(request) { + let failure = null; + try { + failure = request.failure(); + } catch {} + let urlPath = null; + try { + urlPath = new URL(request.url()).pathname; + } catch {} + return { + method: typeof request.method === "function" ? request.method().toUpperCase() : null, + urlPath, + failureText: failure?.errorText || null, + valuesRedacted: true + }; +} + +async function ensureSessionRailExpanded() { + const before = await workbenchReadinessSnapshot(page); + if (before?.sessionCreateVisible === true) { + return { ok: true, action: "already-visible", before, after: before, valuesRedacted: true }; + } + const toggle = page.locator("#session-collapse-toggle").first(); + const toggleVisible = await toggle.isVisible({ timeout: 2000 }).catch(() => false); + if (before?.sessionRailCollapsed !== true || !toggleVisible) { + return { ok: false, action: "not-expanded", reason: before?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before, after: before, valuesRedacted: true }; + } + await toggle.click(); + await page.waitForFunction(() => { + 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 rail = document.querySelector("#session-sidebar"); + return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false")); + }, null, { timeout: 5000 }).catch(() => null); + const after = await workbenchReadinessSnapshot(page); + return { + ok: after?.sessionCreateVisible === true, + action: "expanded-session-rail", + before, + after, + valuesRedacted: true + }; +} + +async function clickAndWaitForAgentSessionCreate(create) { + let removeRequestFailedListener = () => {}; + const requestFailedPromise = new Promise((resolve) => { + let timeout = null; + const handler = (request) => { + if (!isAgentSessionCreateRequest(request)) return; + removeRequestFailedListener(); + resolve({ kind: "requestfailed", requestFailure: requestFailureSummary(request) }); + }; + removeRequestFailedListener = () => { + page.off("requestfailed", handler); + if (timeout !== null) clearTimeout(timeout); + timeout = null; + }; + page.on("requestfailed", handler); + timeout = setTimeout(() => { + removeRequestFailedListener(); + resolve(null); + }, 45000); + }); + const createResponsePromise = page.waitForResponse((response) => { + const request = response.request(); + return isAgentSessionCreateRequest(request) || isAgentSessionCreateRequest(response.url()); + }, { timeout: 45000 }).then((response) => ({ kind: "response", response })).catch((error) => ({ kind: "wait-error", waitError: errorSummary(error) })); + await create.click(); + const outcome = await Promise.race([createResponsePromise, requestFailedPromise]); + removeRequestFailedListener(); + return outcome ?? await createResponsePromise; +} + +async function createSessionFromUi() { + const beforeUrl = currentPageUrl(); + const before = await workbenchSessionSnapshot(); + const attempts = []; + let createResponse = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const railExpansion = await ensureSessionRailExpanded(); + const readinessBeforeClick = railExpansion.after || await workbenchReadinessSnapshot(page); + const create = page.locator("#session-create").first(); + try { + await create.waitFor({ state: "visible", timeout: 15000 }); + } catch (error) { + const readinessAfterWait = await workbenchReadinessSnapshot(page); + const createError = new Error("newSession session create button is not visible"); + createError.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, attempt, readinessBeforeClick, readinessAfterWait, railExpansion, waitError: errorSummary(error), pageId, valuesRedacted: true }; + throw createError; + } + const createButtonState = await create.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + tag: element.tagName.toLowerCase(), + id: element.id || null, + disabled: Boolean(element.disabled), + ariaDisabled: element.getAttribute("aria-disabled") || null, + visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none", + rect: { width: Math.round(rect.width), height: Math.round(rect.height) }, + valuesRedacted: true + }; + }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); + const outcome = await clickAndWaitForAgentSessionCreate(create); + if (outcome?.kind === "response") { + createResponse = outcome.response; + attempts.push({ attempt, outcome: "response", readinessBeforeClick, railExpansion, createButtonState, valuesRedacted: true }); + break; + } + const afterAttempt = await workbenchSessionSnapshot(); + attempts.push({ + attempt, + outcome: outcome?.kind || "unknown", + readinessBeforeClick, + railExpansion, + createButtonState, + waitError: outcome?.waitError || null, + requestFailure: outcome?.requestFailure || null, + after: afterAttempt, + valuesRedacted: true + }); + if (attempt < 2 && outcome?.kind === "requestfailed") { + await page.waitForTimeout(1500); + continue; + } + const waitError = outcome?.waitError || outcome?.requestFailure || { name: outcome?.kind || "unknown", valuesRedacted: true }; + const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (waitError.message || waitError.failureText || waitError.name || "timeout")); + error.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, pageId, valuesRedacted: true }; + throw error; + } + if (createResponse === null) throw new Error("newSession did not produce an authoritative session create response"); + const createStatus = createResponse.status(); + let createPayload = null; + let createPayloadError = null; + try { + createPayload = await createResponse.json(); + } catch (error) { + createPayloadError = errorSummary(error); + } + const createdSessionId = sessionIdFromAgentSessionPayload(createPayload); + if (createStatus < 200 || createStatus >= 300 || !createdSessionId) { + const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions"); + error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }; + throw error; + } + await page.waitForFunction((expectedSessionId) => { + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const sessionId = activeTab?.getAttribute("data-session-id") || ""; + const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); + const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : ""; + const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; + const input = document.querySelector("#command-input"); + return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning); + }, createdSessionId, { timeout: 45000 }).catch(() => null); + const after = await workbenchSessionSnapshot(); + const afterSessionId = after?.activeSessionId || after?.routeSessionId || ""; + const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady); + if (!ok) { + const error = new Error("newSession did not select the authoritative newly created workbench session"); + error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, attempts, pageId, valuesRedacted: true }; + throw error; + } + return { + beforeUrl, + afterUrl: currentPageUrl(), + ok, + before, + after, + attempts, + sessionId: createdSessionId, + createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }, + pageId + }; +} + +function sessionIdFromAgentSessionPayload(payload) { + const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id; + const directText = String(direct || "").trim(); + if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText; + const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u); + return match ? match[0] : null; +} + +function traceIdFromAgentChatPayload(payload) { + const direct = payload?.traceId ?? payload?.turn?.traceId ?? payload?.message?.traceId ?? payload?.data?.traceId ?? payload?.data?.turn?.traceId ?? payload?.data?.message?.traceId; + const directText = String(direct || "").trim(); + if (/^(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})$/u.test(directText)) return directText; + const match = JSON.stringify(payload ?? "").match(/\b(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/u); + return match ? match[0] : null; +} + +async function workbenchSessionSnapshot(targetPage = page) { + return targetPage.evaluate(() => { + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); + const input = document.querySelector("#command-input"); + const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null; + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + return { + url: window.location.href, + routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null, + activeSessionId: activeTab?.getAttribute("data-session-id") || null, + activeConversationId: activeTab?.getAttribute("data-conversation-id") || null, + activeStatus: activeTab?.getAttribute("data-status") || null, + tabCount: document.querySelectorAll(".session-tab").length, + messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, + traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length, + loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length, + composerReady: Boolean(activeTab && input && !input.disabled && !warning), + warning + }; + }).catch(() => null); +} + +function controlPageRecoveryTarget(snapshot, beforeUrl) { + const sessionId = snapshot?.routeSessionId || snapshot?.activeSessionId || routeSessionIdFromUrl(beforeUrl); + if (sessionId) return { sessionId, targetPath: "/workbench/sessions/" + encodeURIComponent(sessionId), valuesRedacted: true }; + const path = safeUrlPath(beforeUrl); + if (isWorkbenchPathname(path || "")) return { sessionId: null, targetPath: path, valuesRedacted: true }; + return { sessionId: null, targetPath, valuesRedacted: true }; +} + +function controlPageProjectionMissingForCommand(snapshot, beforeUrl) { + const path = safeUrlPath(snapshot?.url || beforeUrl); + if (!isWorkbenchPathname(path || "")) return false; + const routeSessionId = snapshot?.routeSessionId || routeSessionIdFromUrl(snapshot?.url || beforeUrl); + if (!routeSessionId) return false; + return snapshot?.activeSessionId !== routeSessionId + && Number(snapshot?.tabCount || 0) === 0 + && Number(snapshot?.messageCount || 0) === 0 + && Number(snapshot?.traceRowCount || 0) === 0 + && snapshot?.composerReady !== true; +} + +async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) { + const started = Date.now(); + return withHardTimeout(workbenchSessionSnapshot(page), timeoutMs, "control page liveness snapshot exceeded " + timeoutMs + "ms") + .then((snapshot) => ({ + ok: snapshot !== null, + reason, + durationMs: Date.now() - started, + snapshot, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })) + .catch((error) => ({ + ok: false, + reason, + durationMs: Date.now() - started, + error: errorSummary(error), + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); +} + +async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null, options = {}) { + let navigation = null; + let hydration = null; + let afterLiveness = null; + const attempts = []; + let ok = false; + const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2; + const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 12000; + const hydrationHardTimeoutMs = Number.isFinite(Number(options.hydrationHardTimeoutMs)) ? Math.max(hydrationTimeoutMs, Number(options.hydrationHardTimeoutMs)) : hydrationTimeoutMs + 2000; + const navigationOptions = options.navigation || {}; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt); + try { + navigation = await gotoTarget(target.targetPath, navigationOptions); + } catch (error) { + navigation = { ok: false, targetPath: target.targetPath, error: errorSummary(error), valuesRedacted: true }; + } + if (!navigation?.error && target.sessionId) { + hydration = await withHardTimeout( + waitForWorkbenchSessionHydrated(page, target.sessionId, { timeoutMs: hydrationTimeoutMs }), + hydrationHardTimeoutMs, + "control page recovery hydration exceeded " + hydrationHardTimeoutMs + "ms" + ).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true })); + } else { + hydration = null; + } + afterLiveness = await controlPageLivenessSnapshot(reason + "-post-recovery", 3000); + ok = !navigation?.error && afterLiveness.ok === true && (!target.sessionId || hydration?.ok === true); + attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }); + if (ok) break; + } + return { + ok, + recovered: ok, + reason, + beforeUrl, + afterUrl: currentPageUrl(), + target, + liveness, + navigation, + hydration, + afterLiveness, + attempts, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + }; +} + +async function promoteObserverPageToControlForCommand(reason, target, liveness = null) { + const beforeUrl = currentPageUrl(); + const beforeObserverUrl = pageUrl(observerPage); + if (!observerPage || observerPage.isClosed()) { + return { ok: false, promoted: false, reason, failureKind: "observer-page-unavailable", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true }; + } + const sessionId = target?.sessionId || routeSessionIdFromUrl(beforeUrl); + if (!sessionId) { + return { ok: false, promoted: false, reason, failureKind: "observer-promotion-needs-session", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true }; + } + const observerSessionId = routeSessionIdFromUrl(beforeObserverUrl); + if (observerSessionId !== sessionId) { + return { ok: false, promoted: false, reason, failureKind: "observer-session-mismatch", beforeUrl, observerUrl: beforeObserverUrl, observerSessionId, sessionId, target, liveness, valuesRedacted: true }; + } + const targetUrl = new URL(target?.targetPath || ("/workbench/sessions/" + encodeURIComponent(sessionId)), baseUrl).toString(); + const readiness = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: 1000, hydrationTimeoutMs: 2000 }); + const observerComposerReady = readiness?.hydration?.snapshot?.composerReady === true; + if (readiness.ok !== true || observerComposerReady !== true) { + return { ok: false, promoted: false, reason, failureKind: readiness.failureKind || (observerComposerReady ? "observer-not-ready" : "observer-composer-not-ready"), beforeUrl, observerUrl: beforeObserverUrl, sessionId, target, liveness, readiness, valuesRedacted: true }; + } + const oldControlPage = page; + observerPageEpoch += 1; + controlPageEpoch += 1; + page = observerPage; + observerPage = null; + attachPassiveListeners(page, "control", pageId); + currentPageProvenance = null; + if (oldControlPage && !oldControlPage.isClosed() && oldControlPage !== page) { + await withHardTimeout(oldControlPage.close(), 2000, "old control page close exceeded 2000ms") + .catch((error) => appendJsonl(files.errors, eventRecord("old-control-page-close-timeout", { reason, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch }))); + } + observerPage = await context.newPage(); + attachPassiveListeners(observerPage, "observer", observerPageId); + const observerSync = await syncObserverPageToControlSession(reason + "-observer-recreated-after-promotion", sessionId, { + maxAttempts: 1, + navigationTimeoutMs: 8000, + readinessTimeoutMs: 3000, + hydrationTimeoutMs: 3000, + shortCircuitReadinessTimeoutMs: 1000, + shortCircuitHydrationTimeoutMs: 1000, + }); + return { + ok: true, + promoted: true, + reason, + beforeUrl, + beforeObserverUrl, + afterUrl: currentPageUrl(), + sessionId, + target, + liveness, + readiness, + observerSync, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + }; +} + +async function ensureControlPageResponsiveForCommand(reason) { + const beforeUrl = currentPageUrl(); + const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000); + const projectionMissing = liveness.ok === true && controlPageProjectionMissingForCommand(liveness.snapshot, beforeUrl); + if (liveness.ok && !projectionMissing) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; + const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); + await appendJsonl(files.control, eventRecord(projectionMissing ? "control-page-projection-missing-before-command" : "control-page-unresponsive-before-command", { + reason, + beforeUrl, + target, + liveness, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); + const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness); + await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-promoted-from-observer-before-command" : "control-page-observer-promotion-skipped-before-command", promotion)); + if (promotion.ok === true) return promotion; + const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, { + maxAttempts: 1, + navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 }, + hydrationTimeoutMs: 4000, + hydrationHardTimeoutMs: 5000, + }); + await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery)); + if (!recovery.ok) { + const error = new Error("control page recovery failed before " + reason); + error.details = recovery; + throw error; + } + return recovery; +} + +async function forceRecoverControlPageForCommand(reason) { + const beforeUrl = currentPageUrl(); + const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000); + const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); + await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", { + reason, + beforeUrl, + target, + liveness, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); + const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness); + await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-forced-promoted-from-observer-before-command" : "control-page-forced-observer-promotion-skipped-before-command", promotion)); + if (promotion.ok === true) return promotion; + const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, { + maxAttempts: 1, + navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 }, + hydrationTimeoutMs: 4000, + hydrationHardTimeoutMs: 5000, + }); + await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery)); + return recovery; +} + +async function sendPrompt(text, options = {}) { + if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); + const responsePath = options.responsePath || "/v1/agent/chat"; + const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt"); + const beforeUrl = currentPageUrl(); + const beforeEvidence = await promptSideEffectSnapshot(); + let editor = null; + let composerRecovery = null; + let editorWaitError = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const primaryEditor = page.locator("#command-input").last(); + const candidate = await primaryEditor.isVisible().catch(() => false) + ? primaryEditor + : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); + try { + await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 8000 }), 10000, "sendPrompt composer editor did not become visible within 10s"); + editor = candidate; + break; + } catch (error) { + editorWaitError = error; + if (attempt >= 2) break; + composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing"); + if (composerRecovery.ok !== true) break; + } + } + if (!editor) { + const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000); + const error = new Error("sendPrompt composer editor did not become visible"); + error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; + throw error; + } + editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery }); + const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'; + const primarySubmit = page.locator(primarySubmitSelector).last(); + const submit = await primarySubmit.isVisible().catch(() => false) + ? primarySubmit + : page.locator([ + 'button[type="submit"]', + 'button:has-text("发送")', + 'button:has-text("Send")', + '[data-testid*="send" i]', + '[aria-label*="send" i]', + '[aria-label*="发送"]' + ].join(", ")).last(); + await withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s"); + if (options.expectedAction) { + const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === "" + ? null + : Number(options.expectedActionWaitMs); + const actionWaitMs = Number.isFinite(configuredActionWaitMs) + ? Math.max(1000, Math.trunc(configuredActionWaitMs)) + : options.expectedAction === "turn" ? 180000 : 1000; + const actionDeadline = Date.now() + actionWaitMs; + let composer = null; + while (Date.now() <= actionDeadline) { + composer = await composerButtonState(submit); + if (composer.action === options.expectedAction && composer.disabled !== true) break; + await page.waitForTimeout(250); + } + composer = composer || await composerButtonState(submit); + if (composer.action !== options.expectedAction || composer.disabled === true) { + await clearComposerEditor(editor).catch(() => {}); + const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot()); + const blocked = { + beforeUrl, + afterUrl: currentPageUrl(), + textHash: sha256Text(text), + textBytes: Buffer.byteLength(text), + submitted: false, + blocked: true, + degradedReason: options.noActiveReason || "composer-action-mismatch", + composer, + chatSubmit: { + status: null, + statusText: null, + urlPath: responsePath, + responseObserved: false, + sideEffectObserved: sideEffectHasAuthoritativePromptSubmission(sideEffect), + sideEffect, + actionWaitMs, + expectedAction: options.expectedAction, + actualAction: composer.action, + valuesRedacted: true + }, + controlRecovery, + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + }; + if (options.throwOnActionMismatch === true) { + const error = new Error("sendPrompt composer action mismatch: expected " + options.expectedAction + " actual " + (composer.action || "null")); + error.details = blocked; + throw error; + } + return blocked; + } + } + const acceptedResponsePaths = [responsePath, ...(Array.isArray(options.alternateResponsePaths) ? options.alternateResponsePaths : [])]; + const chatResponsePromise = page.waitForResponse((response) => { + const request = response.request(); + if (request.method().toUpperCase() !== "POST") return false; + try { + return acceptedResponsePaths.includes(new URL(response.url()).pathname); + } catch { + return false; + } + }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); + await submit.click(); + const chatResponse = await chatResponsePromise; + await page.waitForTimeout(500); + if (chatResponse?.waitError) { + const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000); + if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) { + return { + beforeUrl, + afterUrl: currentPageUrl(), + textHash: sha256Text(text), + textBytes: Buffer.byteLength(text), + chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect }, + controlRecovery, + pageId, + pageEpoch: controlPageEpoch + }; + } + const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout")); + error.details = { + beforeUrl, + afterUrl: currentPageUrl(), + textHash: sha256Text(text), + textBytes: Buffer.byteLength(text), + chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect }, + controlRecovery, + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + }; + throw error; + } + const chatStatus = chatResponse.status(); + let chatUrlPath = responsePath; + try { + chatUrlPath = new URL(chatResponse.url()).pathname; + } catch { + chatUrlPath = responsePath; + } + if (chatStatus < 200 || chatStatus >= 300) { + throw new Error("sendPrompt observed POST " + chatUrlPath + " HTTP " + chatStatus + " " + chatResponse.statusText()); + } + let chatPayload = null; + let chatPayloadError = null; + try { + chatPayload = await chatResponse.json(); + } catch (error) { + chatPayloadError = errorSummary(error); + } + const payloadText = chatPayload ? JSON.stringify(chatPayload) : ""; + const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null; + const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId) + ? chatPayload.otelTrace.traceId + : null; + return { + beforeUrl, + afterUrl: currentPageUrl(), + textHash: sha256Text(text), + textBytes: Buffer.byteLength(text), + chatSubmit: { + status: chatStatus, + statusText: chatResponse.statusText(), + urlPath: chatUrlPath, + traceId, + otelTraceId, + resultUrl: safeUrlPath(chatPayload?.resultUrl), + turnUrl: safeUrlPath(chatPayload?.turnUrl), + streamUrl: safeUrlPath(chatPayload?.streamUrl), + responseParsed: chatPayload !== null, + responseParseError: chatPayloadError, + valuesRedacted: true + }, + controlRecovery, + pageId, + pageEpoch: controlPageEpoch + }; +} + +async function fillComposerEditorWithRetry(initialEditor, text, context = {}) { + let editor = initialEditor; + let lastError = null; + let recovery = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + if (attempt > 1 || !editor) editor = await resolveComposerEditor(8000); + await fillComposerEditor(editor, text, { timeoutMs: 12000 }); + const retained = await composerEditorRetainsText(editor, text); + if (retained) return editor; + throw new Error("sendPrompt composer editor did not retain filled text"); + } catch (error) { + lastError = error; + await appendJsonl(files.control, eventRecord("sendPrompt-composer-fill-retry", { + attempt, + willRetry: attempt < 2, + error: errorSummary(error), + beforeUrl: context.beforeUrl || null, + afterUrl: currentPageUrl(), + controlRecovery: context.controlRecovery || null, + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); + if (attempt >= 2) break; + recovery = await forceRecoverControlPageForCommand("sendPrompt-composer-fill-failed"); + if (recovery.ok !== true) await page.waitForTimeout(250); + editor = null; + } + } + const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-fill-failed-final", 3000); + const error = new Error("sendPrompt composer editor fill failed"); + error.details = { + beforeUrl: context.beforeUrl || null, + afterUrl: currentPageUrl(), + controlRecovery: context.controlRecovery || null, + recovery, + snapshot, + fillError: errorSummary(lastError), + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + }; + throw error; +} + +async function resolveComposerEditor(timeoutMs = 8000) { + const primaryEditor = page.locator("#command-input").last(); + const candidate = await primaryEditor.isVisible().catch(() => false) + ? primaryEditor + : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); + await candidate.waitFor({ state: "visible", timeout: timeoutMs }); + return candidate; +} + +async function fillComposerEditor(editor, text, options = {}) { + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1000, Math.trunc(Number(options.timeoutMs))) : 30000; + const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); + const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); + if (tag === "textarea" || tag === "input") await editor.fill(text, { timeout: timeoutMs }); + else if (editable) { + await editor.click({ timeout: timeoutMs }); + await page.keyboard.insertText(text); + } else { + await editor.click({ timeout: timeoutMs }); + await page.keyboard.insertText(text); + } +} + +async function composerEditorRetainsText(editor, expectedText) { + const expected = String(expectedText || ""); + return editor.evaluate((element, value) => { + if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return element.value === value; + return (element.textContent || "") === value; + }, expected).catch(() => false); +} + +async function clearComposerEditor(editor) { + const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); + const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); + if (tag === "textarea" || tag === "input") { + await editor.fill(""); + return; + } + if (editable) { + await editor.click(); + await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {}); + await page.keyboard.press("Backspace").catch(() => {}); + } +} + +async function composerButtonState(button) { + return button.evaluate((element) => ({ + action: element.getAttribute("data-action") || null, + disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true", + text: (element.textContent || "").trim().slice(0, 80), + title: element.getAttribute("title") || null, + ariaLabel: element.getAttribute("aria-label") || null, + testId: element.getAttribute("data-testid") || null, + })).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true })); +} + +function sideEffectHasAuthoritativePromptSubmission(sideEffect) { + return Boolean( + (Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0) + || (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0) + || Number(sideEffect?.messageCountDelta || 0) > 0 + ); +} + +async function waitForPromptSideEffect(beforeEvidence, timeoutMs) { + await page.waitForFunction((before) => { + const current = (() => { + const text = document.body?.innerText || ""; + const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); + const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); + const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text); + const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text); + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length; + const textBytes = new TextEncoder().encode(text).length; + return { runIds, traceIds, running, executionError, messageCount, textBytes }; + })(); + const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : []; + const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : []; + const newRun = current.runIds.some((id) => !beforeRuns.includes(id)); + const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id)); + const newMessage = current.messageCount > Number(before?.messageCount || 0); + return newRun || newTrace || newMessage; + }, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null); + const after = await promptSideEffectSnapshot(); + const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : []; + const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : []; + const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id)); + const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id)); + const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0)); + return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true }; +} + +async function promptSideEffectSnapshot() { + return withHardTimeout(page.evaluate(() => { + const text = document.body?.innerText || ""; + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + return { + runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), + traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), + running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text), + executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text), + messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, + textBytes: new TextEncoder().encode(text).length, + valuesRedacted: true + }; + }), 3000, "prompt side-effect snapshot exceeded 3000ms") + .catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true })); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-performance-source.ts b/scripts/src/hwlab-node-web-observe-runner-performance-source.ts new file mode 100644 index 00000000..d56ef14a --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-performance-source.ts @@ -0,0 +1,436 @@ +// SPEC: pikasTech/unidesk#1436 WEB-PROBE performance probe. +// Responsibility: Source string for page PerformanceObserver and CPU profile capture helpers. + +export function nodeWebObserveRunnerPerformanceSource(): string { + return String.raw` +async function installPagePerformanceProbe(targetPage, pageRole = "control", targetPageId = pageId) { + const installer = (input) => { + const win = window; + const existing = win.__UNIDESK_WEB_PROBE_PERFORMANCE__; + if (existing && existing.version === 1) { + existing.pageRole = input.pageRole; + existing.pageId = input.pageId; + existing.pageEpoch = input.pageEpoch; + existing.installedAgainAt = Date.now(); + return { ok: true, alreadyInstalled: true, supportedTypes: existing.supportedTypes || [], valuesRedacted: true }; + } + const buffer = []; + const supportedTypes = []; + const maxBufferSize = 2000; + const push = (event) => { + buffer.push({ + probeVersion: 1, + pageRole: input.pageRole, + pageId: input.pageId, + pageEpoch: input.pageEpoch, + url: location.href, + path: location.pathname, + timeOrigin: Math.round(performance.timeOrigin || 0), + now: Math.round(performance.now()), + observedAt: Date.now(), + ...event, + valuesRedacted: true + }); + while (buffer.length > maxBufferSize) buffer.shift(); + }; + const num = (value) => Number.isFinite(Number(value)) ? Number(value) : null; + const text = (value, limit = 240) => String(value || "").slice(0, limit); + const compactAttribution = (attribution) => Array.from(attribution || []).slice(0, 12).map((item) => ({ + name: text(item?.name, 120), + entryType: text(item?.entryType, 80), + startTime: num(item?.startTime), + duration: num(item?.duration), + containerType: text(item?.containerType, 80), + containerName: text(item?.containerName, 160), + containerId: text(item?.containerId, 120), + containerSrc: text(item?.containerSrc, 260), + valuesRedacted: true + })); + const compactScript = (script) => ({ + invoker: text(script?.invoker, 180), + invokerType: text(script?.invokerType, 80), + sourceURL: text(script?.sourceURL, 360), + sourceFunctionName: text(script?.sourceFunctionName, 180), + sourceCharPosition: num(script?.sourceCharPosition), + lineNumber: num(script?.lineNumber), + columnNumber: num(script?.columnNumber), + startTime: num(script?.startTime), + duration: num(script?.duration), + executionStart: num(script?.executionStart), + forcedStyleAndLayoutDuration: num(script?.forcedStyleAndLayoutDuration), + pauseDuration: num(script?.pauseDuration), + valuesRedacted: true + }); + const observe = (type, handler) => { + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + try { handler(entry); } catch (error) { push({ kind: "observer-error", observedEntryType: type, message: text(error && error.message, 240) }); } + } + }); + observer.observe({ type, buffered: true }); + supportedTypes.push(type); + return observer; + } catch { + return null; + } + }; + const observers = []; + const longTask = observe("longtask", (entry) => push({ + kind: "longtask", + name: text(entry.name, 120), + entryType: text(entry.entryType, 80), + startTime: num(entry.startTime), + duration: num(entry.duration), + attribution: compactAttribution(entry.attribution), + })); + if (longTask) observers.push(longTask); + const loaf = observe("long-animation-frame", (entry) => push({ + kind: "long-animation-frame", + name: text(entry.name, 120), + entryType: text(entry.entryType, 80), + startTime: num(entry.startTime), + duration: num(entry.duration), + renderStart: num(entry.renderStart), + styleAndLayoutStart: num(entry.styleAndLayoutStart), + blockingDuration: num(entry.blockingDuration), + firstUIEventTimestamp: num(entry.firstUIEventTimestamp), + scripts: Array.from(entry.scripts || []).slice(0, 24).map(compactScript), + })); + if (loaf) observers.push(loaf); + let lastTick = performance.now(); + const tickIntervalMs = Math.max(50, Number(input.eventLoopProbeIntervalMs || 250)); + const eventLoopGapRedMs = Math.max(100, Number(input.eventLoopGapRedMs || 1000)); + const timer = setInterval(() => { + const now = performance.now(); + const gap = now - lastTick - tickIntervalMs; + lastTick = now; + if (gap >= eventLoopGapRedMs) { + push({ kind: "event-loop-gap", startTime: Math.max(0, now - gap), duration: Math.round(gap), thresholdMs: eventLoopGapRedMs }); + } + }, tickIntervalMs); + win.__UNIDESK_WEB_PROBE_PERFORMANCE__ = { + version: 1, + pageRole: input.pageRole, + pageId: input.pageId, + pageEpoch: input.pageEpoch, + installedAt: Date.now(), + supportedTypes, + buffer, + observers, + timer, + drain() { + const items = buffer.splice(0, buffer.length); + return { ok: true, count: items.length, supportedTypes, items, valuesRedacted: true }; + } + }; + return { ok: true, alreadyInstalled: false, supportedTypes, valuesRedacted: true }; + }; + await targetPage.addInitScript(installer, { pageRole, pageId: targetPageId, pageEpoch: pageRole === "observer" ? observerPageEpoch : controlPageEpoch, eventLoopGapRedMs: alertThresholds.eventLoopGapRedMs }); + if (!targetPage.isClosed()) { + await withHardTimeout(targetPage.evaluate(installer, { pageRole, pageId: targetPageId, pageEpoch: pageRole === "observer" ? observerPageEpoch : controlPageEpoch, eventLoopGapRedMs: alertThresholds.eventLoopGapRedMs }), 3000, "installPagePerformanceProbe evaluate exceeded 3000ms"); + } +} + +async function drainPagePerformanceEvents(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) { + if (!targetPage || targetPage.isClosed()) return { ok: false, reason: "page-closed", count: 0, valuesRedacted: true }; + const timeoutMs = Math.max(1000, Math.min(3000, Number(alertThresholds.playwrightResponsivenessRedMs) || 3000)); + const drained = await withHardTimeout(targetPage.evaluate(() => { + const probe = window.__UNIDESK_WEB_PROBE_PERFORMANCE__; + if (!probe || typeof probe.drain !== "function") return { ok: false, reason: "performance-probe-not-installed", count: 0, valuesRedacted: true }; + return probe.drain(); + }), timeoutMs, "drainPagePerformanceEvents exceeded " + timeoutMs + "ms").catch((error) => ({ ok: false, reason: isTimeoutErrorMessage(error?.message) ? "drain-timeout" : "drain-error", error: errorSummary(error), count: 0, valuesRedacted: true })); + const items = Array.isArray(drained?.items) ? drained.items : []; + for (const item of items) { + await appendJsonl(files.performanceEvents, eventRecord("performance-event", { + reason, + sampleGroupSeq: groupSeq, + pageRole, + pageId: targetPageId, + pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, + performance: compactPerformanceEvent(item), + valuesRedacted: true, + })); + } + if (drained?.ok === false && drained.reason !== "performance-probe-not-installed") { + await appendJsonl(files.performanceEvents, eventRecord("performance-drain-error", { + reason, + sampleGroupSeq: groupSeq, + pageRole, + pageId: targetPageId, + pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, + drain: drained, + valuesRedacted: true, + })); + } + return { ok: drained?.ok === true, count: items.length, reason: drained?.reason ?? null, supportedTypes: Array.isArray(drained?.supportedTypes) ? drained.supportedTypes.slice(0, 8) : [], valuesRedacted: true }; +} + +function compactPerformanceEvent(item) { + const value = item && typeof item === "object" ? item : {}; + const scripts = Array.isArray(value.scripts) ? value.scripts.slice(0, 24).map(compactPerformanceScript) : []; + const attribution = Array.isArray(value.attribution) ? value.attribution.slice(0, 12).map(compactPerformanceAttribution) : []; + return { + kind: truncate(value.kind, 80), + name: truncate(value.name, 160), + entryType: truncate(value.entryType, 80), + startTime: numberOrNull(value.startTime), + duration: numberOrNull(value.duration), + blockingDuration: numberOrNull(value.blockingDuration), + renderStart: numberOrNull(value.renderStart), + styleAndLayoutStart: numberOrNull(value.styleAndLayoutStart), + firstUIEventTimestamp: numberOrNull(value.firstUIEventTimestamp), + thresholdMs: numberOrNull(value.thresholdMs), + timeOrigin: numberOrNull(value.timeOrigin), + observedAt: numberOrNull(value.observedAt), + now: numberOrNull(value.now), + path: truncate(value.path, 220), + url: safeUrl(value.url), + scripts, + attribution, + valuesRedacted: true, + }; +} + +function compactPerformanceScript(script) { + const value = script && typeof script === "object" ? script : {}; + return { + invoker: truncate(value.invoker, 180), + invokerType: truncate(value.invokerType, 80), + sourceURL: safeUrl(value.sourceURL), + sourceFunctionName: truncate(value.sourceFunctionName, 180), + sourceCharPosition: numberOrNull(value.sourceCharPosition), + lineNumber: numberOrNull(value.lineNumber), + columnNumber: numberOrNull(value.columnNumber), + startTime: numberOrNull(value.startTime), + duration: numberOrNull(value.duration), + executionStart: numberOrNull(value.executionStart), + forcedStyleAndLayoutDuration: numberOrNull(value.forcedStyleAndLayoutDuration), + pauseDuration: numberOrNull(value.pauseDuration), + valuesRedacted: true, + }; +} + +function compactPerformanceAttribution(item) { + const value = item && typeof item === "object" ? item : {}; + return { + name: truncate(value.name, 120), + entryType: truncate(value.entryType, 80), + startTime: numberOrNull(value.startTime), + duration: numberOrNull(value.duration), + containerType: truncate(value.containerType, 80), + containerName: truncate(value.containerName, 160), + containerId: truncate(value.containerId, 120), + containerSrc: safeUrl(value.containerSrc), + valuesRedacted: true, + }; +} + +async function capturePerformanceProfile(command) { + const durationMs = boundedInteger(command.durationMs, 5000, 100, 600000); + const timeoutMs = Math.max(3000, Math.min(15000, Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)); + const captureId = safeId(command.label || command.id || ("perf-" + Date.now().toString(36))) || ("perf-" + Date.now().toString(36)); + const targetPage = page && !page.isClosed() ? page : observerPage && !observerPage.isClosed() ? observerPage : null; + if (!targetPage) throw new Error("performanceCapture requires an open page"); + const targetPageRole = targetPage === observerPage ? "observer" : "control"; + const targetPageId = targetPageRole === "observer" ? observerPageId : pageId; + const targetPageEpoch = targetPageRole === "observer" ? observerPageEpoch : controlPageEpoch; + const captureDir = path.join(dirs.performanceCaptures, captureId); + await mkdir(captureDir, { recursive: true, mode: 0o700 }); + await installPagePerformanceProbe(targetPage, targetPageRole, targetPageId) + .catch((error) => appendJsonl(files.errors, eventRecord("performance-capture-probe-install-error", { commandId: command.id, pageRole: targetPageRole, pageId: targetPageId, error: errorSummary(error), valuesRedacted: true }))); + const beforeDrain = await drainPagePerformanceEvents(targetPage, { reason: "performanceCapture-before", groupSeq: sampleSeq, pageRole: targetPageRole, targetPageId, pageEpoch: targetPageEpoch }) + .catch((error) => ({ ok: false, error: errorSummary(error), count: 0, valuesRedacted: true })); + let session = null; + const startedAtMs = Date.now(); + const startedAt = new Date(startedAtMs).toISOString(); + let pageClockStart = null; + let stopped = null; + try { + pageClockStart = await withHardTimeout(targetPage.evaluate(() => ({ timeOrigin: Math.round(performance.timeOrigin || 0), now: Math.round(performance.now()), url: location.href, path: location.pathname, title: document.title, valuesRedacted: true })), timeoutMs, "performanceCapture page clock exceeded " + timeoutMs + "ms") + .catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); + session = await withHardTimeout(targetPage.context().newCDPSession(targetPage), timeoutMs, "performanceCapture newCDPSession exceeded " + timeoutMs + "ms"); + await withHardTimeout(session.send("Profiler.enable"), timeoutMs, "Profiler.enable exceeded " + timeoutMs + "ms"); + await withHardTimeout(session.send("Profiler.start"), timeoutMs, "Profiler.start exceeded " + timeoutMs + "ms"); + await appendJsonl(files.performanceEvents, eventRecord("performance-capture-started", { + captureId, + durationMs, + pageRole: targetPageRole, + pageId: targetPageId, + pageEpoch: targetPageEpoch, + pageClock: pageClockStart, + currentUrl: pageUrl(targetPage), + beforeDrain, + valuesRedacted: true, + })); + await sleep(durationMs); + stopped = await withHardTimeout(session.send("Profiler.stop"), Math.max(timeoutMs, 5000), "Profiler.stop exceeded " + Math.max(timeoutMs, 5000) + "ms"); + } finally { + if (session) { + await withHardTimeout(session.send("Profiler.disable"), 1000, "Profiler.disable exceeded 1000ms").catch(() => {}); + await withHardTimeout(session.detach(), 1000, "performanceCapture CDP session detach exceeded 1000ms").catch(() => {}); + } + } + const completedAtMs = Date.now(); + const afterDrain = await drainPagePerformanceEvents(targetPage, { reason: "performanceCapture-after", groupSeq: sampleSeq, pageRole: targetPageRole, targetPageId, pageEpoch: targetPageEpoch }) + .catch((error) => ({ ok: false, error: errorSummary(error), count: 0, valuesRedacted: true })); + const profile = stopped?.profile || null; + const summary = summarizeCpuProfile(profile); + const profileFile = path.join(captureDir, "profile.cpuprofile"); + const summaryFile = path.join(captureDir, "summary.json"); + const summaryPayload = { + ok: true, + captureId, + type: "performance-cpu-profile", + commandId: command.id, + label: truncate(command.label || "", 200), + startedAt, + completedAt: new Date(completedAtMs).toISOString(), + durationMs: completedAtMs - startedAtMs, + requestedDurationMs: durationMs, + pageRole: targetPageRole, + pageId: targetPageId, + pageEpoch: targetPageEpoch, + pageClockStart, + currentUrl: pageUrl(targetPage), + beforeDrain, + afterDrain, + profile: summary, + valuesRedacted: true, + }; + await writeFile(profileFile, JSON.stringify(profile || {}, null, 2) + "\n", { mode: 0o600 }); + await writeFile(summaryFile, JSON.stringify(summaryPayload, null, 2) + "\n", { mode: 0o600 }); + const [profileMeta, summaryMeta] = await Promise.all([fileMeta(profileFile), fileMeta(summaryFile)]); + artifactSeq += 1; + const artifact = { + seq: artifactSeq, + sampleSeq, + ts: new Date().toISOString(), + kind: "performance-cpu-profile", + captureId, + commandId: command.id, + path: profileFile, + summaryPath: summaryFile, + byteCount: profileMeta.byteCount, + sha256: profileMeta.sha256, + summaryByteCount: summaryMeta.byteCount, + summarySha256: summaryMeta.sha256, + pageRole: targetPageRole, + pageId: targetPageId, + durationMs: summaryPayload.durationMs, + topFunctions: summary.topFunctions.slice(0, 8), + topStacks: summary.topStacks.slice(0, 5), + valuesRedacted: true, + }; + await appendJsonl(files.artifacts, artifact); + await appendJsonl(files.performanceEvents, eventRecord("performance-capture-completed", { + captureId, + pageRole: targetPageRole, + pageId: targetPageId, + pageEpoch: targetPageEpoch, + artifact, + summary: { ...summary, topFunctions: summary.topFunctions.slice(0, 12), topStacks: summary.topStacks.slice(0, 8), valuesRedacted: true }, + valuesRedacted: true, + })); + return { ...summaryPayload, artifact, valuesRedacted: true }; +} + +function summarizeCpuProfile(profile) { + const nodes = Array.isArray(profile?.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile?.samples) ? profile.samples : []; + const timeDeltas = Array.isArray(profile?.timeDeltas) ? profile.timeDeltas : []; + const byId = new Map(); + const parentById = new Map(); + for (const node of nodes) { + const id = Number(node?.id); + if (!Number.isFinite(id)) continue; + byId.set(id, node); + for (const childIdRaw of Array.isArray(node.children) ? node.children : []) { + const childId = Number(childIdRaw); + if (Number.isFinite(childId)) parentById.set(childId, id); + } + } + const functionRows = new Map(); + const stackRows = new Map(); + const addFunction = (frame, deltaMs, field) => { + const compact = compactCpuCallFrame(frame); + const key = cpuFrameKey(compact); + const row = functionRows.get(key) || { ...compact, key, sampleCount: 0, selfTimeMs: 0, totalTimeMs: 0, valuesRedacted: true }; + if (field === "selfTimeMs") row.sampleCount += 1; + row[field] += deltaMs; + functionRows.set(key, row); + }; + const nodePath = (id) => { + const out = []; + const seen = new Set(); + let current = id; + while (Number.isFinite(Number(current)) && byId.has(Number(current)) && !seen.has(Number(current)) && out.length < 64) { + seen.add(Number(current)); + out.push(byId.get(Number(current))); + current = parentById.get(Number(current)); + } + return out.reverse(); + }; + let totalTimeMs = 0; + for (let index = 0; index < samples.length; index += 1) { + const nodeId = Number(samples[index]); + if (!byId.has(nodeId)) continue; + const deltaUs = Number(timeDeltas[index]); + const deltaMs = Number.isFinite(deltaUs) && deltaUs > 0 ? deltaUs / 1000 : 0; + totalTimeMs += deltaMs; + const pathNodes = nodePath(nodeId); + const leaf = pathNodes[pathNodes.length - 1] || byId.get(nodeId); + addFunction(leaf?.callFrame, deltaMs, "selfTimeMs"); + for (const item of pathNodes) addFunction(item?.callFrame, deltaMs, "totalTimeMs"); + const stackFrames = pathNodes.map((item) => compactCpuCallFrame(item?.callFrame)).filter((item) => item.functionName || item.url).slice(-10); + const stackKey = stackFrames.map(cpuFrameLabel).join(" <- "); + if (stackKey) { + const row = stackRows.get(stackKey) || { key: stackKey, sampleCount: 0, selfTimeMs: 0, leaf: stackFrames[stackFrames.length - 1] || null, frames: stackFrames, valuesRedacted: true }; + row.sampleCount += 1; + row.selfTimeMs += deltaMs; + stackRows.set(stackKey, row); + } + } + const rounded = (value) => Number((Number(value || 0)).toFixed(2)); + const topFunctions = Array.from(functionRows.values()) + .map((item) => ({ ...item, selfTimeMs: rounded(item.selfTimeMs), totalTimeMs: rounded(item.totalTimeMs), selfPercent: totalTimeMs > 0 ? Number(((item.selfTimeMs / totalTimeMs) * 100).toFixed(2)) : 0, totalPercent: totalTimeMs > 0 ? Number(((item.totalTimeMs / totalTimeMs) * 100).toFixed(2)) : 0 })) + .sort((left, right) => Number(right.selfTimeMs || 0) - Number(left.selfTimeMs || 0) || Number(right.totalTimeMs || 0) - Number(left.totalTimeMs || 0)) + .slice(0, 50); + const topStacks = Array.from(stackRows.values()) + .map((item) => ({ ...item, selfTimeMs: rounded(item.selfTimeMs), selfPercent: totalTimeMs > 0 ? Number(((item.selfTimeMs / totalTimeMs) * 100).toFixed(2)) : 0 })) + .sort((left, right) => Number(right.selfTimeMs || 0) - Number(left.selfTimeMs || 0)) + .slice(0, 40); + return { + nodeCount: nodes.length, + sampleCount: samples.length, + timeDeltaCount: timeDeltas.length, + totalTimeMs: rounded(totalTimeMs), + topFunctions, + topStacks, + valuesRedacted: true, + }; +} + +function compactCpuCallFrame(frame) { + const value = frame && typeof frame === "object" ? frame : {}; + return { + functionName: truncate(value.functionName || "(anonymous)", 180), + url: safeUrl(value.url || ""), + scriptId: value.scriptId === undefined || value.scriptId === null ? null : String(value.scriptId).slice(0, 80), + lineNumber: numberOrNull(value.lineNumber), + columnNumber: numberOrNull(value.columnNumber), + valuesRedacted: true, + }; +} + +function cpuFrameKey(frame) { + return [frame.functionName || "(anonymous)", frame.url || frame.scriptId || "", frame.lineNumber ?? "", frame.columnNumber ?? ""].join("@"); +} + +function cpuFrameLabel(frame) { + const location = frame.url || frame.scriptId || "-"; + const line = frame.lineNumber === null || frame.lineNumber === undefined ? "" : ":" + String(Number(frame.lineNumber) + 1); + return String(frame.functionName || "(anonymous)") + "@" + location + line; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts new file mode 100644 index 00000000..c5f65f0b --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts @@ -0,0 +1,855 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Runner filesystem setup, heartbeat, browser process monitoring, freeze policy, and passive page listeners source fragment. + +export function nodeWebObserveRunnerRuntimeSource(): string { + return String.raw`async function prepareDirs() { + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 }))); +} + +async function rotateExistingJsonlArtifacts() { + for (const [key, file] of Object.entries(files)) { + if (!file.endsWith(".jsonl")) continue; + let meta = null; + try { + meta = await stat(file); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + if (!meta?.isFile()) continue; + const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file)); + await rename(file, archiveFile); + jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size }); + } +} + +async function uniqueArchiveFile(name) { + let candidate = path.join(dirs.archive, name); + const parsed = path.parse(name); + for (let index = 1; ; index += 1) { + try { + await stat(candidate); + candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext); + } catch (error) { + if (error?.code === "ENOENT") return candidate; + throw error; + } + } +} + +function compactFileTimestamp(value) { + return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z"); +} + +async function writeManifest(extra = {}) { + const manifest = { + ok: extra.status !== "failed", + command: "web-probe-observe", + specRef, + jobId, + pid: process.pid, + stateDir, + baseUrl, + targetPath, + network: publicNetwork(playwrightProxy), + navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true }, + pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true }, + pageProvenance: compactPageProvenance(currentPageProvenance), + sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false }, + alertThresholds, + browserFreezePolicy, + projectManagement, + jsonlRotation, + commandDirs: dirs, + artifacts: files, + safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false }, + startedAt, + ...extra, + }; + await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); +} + +async function writeHeartbeat(extra = {}) { + const heartbeat = { + ok: terminalStatus !== "failed", + jobId, + pid: process.pid, + stateDir, + status: terminalStatus, + pageId, + observerPageId, + baseUrl, + currentUrl: currentPageUrl(), + observerUrl: pageUrl(observerPage), + observerRefreshIntervalMs, + lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null, + pageProvenance: compactPageProvenance(currentPageProvenance), + sampleSeq, + commandSeq, + activeCommandId, + updatedAt: new Date().toISOString(), + uptimeMs: Date.now() - startedAtMs, + ...extra, + }; + await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 }); +} + +function startHeartbeatPulse() { + const timer = setInterval(() => { + if (stopping) return; + void writeHeartbeat({ status: terminalStatus, heartbeatPulse: true }) + .catch((error) => appendJsonl(files.errors, eventRecord("heartbeat-pulse-error", { error: errorSummary(error) }))); + }, 5000); + if (timer && typeof timer.unref === "function") timer.unref(); + return timer; +} + +function startBrowserProcessMonitor() { + const intervalMs = Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000)); + let stopped = false; + let running = false; + const tick = async (reason) => { + if (stopped || running || stopping || browserFreezeBlocker) return; + running = true; + try { + await collectBrowserProcessSample(reason || "interval"); + } catch (error) { + await appendJsonl(files.errors, eventRecord("browser-process-monitor-error", { error: errorSummary(error), valuesRedacted: true })).catch(() => {}); + } finally { + running = false; + } + }; + void tick("startup"); + const timer = setInterval(() => { + void tick("interval"); + }, intervalMs); + if (timer && typeof timer.unref === "function") timer.unref(); + return () => { + stopped = true; + clearInterval(timer); + }; +} + +async function collectBrowserProcessSample(reason) { + browserProcessMonitorSeq += 1; + const tsMs = Date.now(); + const browserPid = browserProcessPid(); + const processSummary = await collectChromiumProcessSummary(browserPid); + const growth = updateBrowserProcessHistory(tsMs, processSummary); + const pages = []; + if (page && !page.isClosed()) { + pages.push(await collectBrowserPageRuntimeMetrics(page, { pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }).catch((error) => browserPageRuntimeMetricError("control", pageId, controlPageEpoch, error))); + } + if (observerPage && !observerPage.isClosed()) { + pages.push(await collectBrowserPageRuntimeMetrics(observerPage, { pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => browserPageRuntimeMetricError("observer", observerPageId, observerPageEpoch, error))); + } + const sample = eventRecord("browser-process-sample", { + seq: browserProcessMonitorSeq, + reason, + monitorIntervalMs: Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000)), + browserPid, + process: processSummary, + growth, + pages, + valuesRedacted: true, + }); + await appendJsonl(files.browserProcess, sample); + await enforceBrowserFreezePolicy(sample); +} + +async function enforceBrowserFreezePolicy(sample) { + if (browserFreezePolicy.enabled !== true || browserFreezeBlocker) return; + const processSummary = sample && typeof sample.process === "object" ? sample.process : {}; + const growth = sample && typeof sample.growth === "object" ? sample.growth : {}; + const totalRssMb = Number(processSummary.totalRssMb); + const processRssMb = Number(processSummary.maxProcessRssMb); + const totalGrowthMb = Number(growth.totalRssGrowthMb); + const processGrowthMb = Number(growth.maxProcessRssGrowthMb); + + for (const pageMetric of Array.isArray(sample.pages) ? sample.pages : []) { + const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {}; + const effectiveHeapUsedMb = Number(effectiveMemory.effectiveHeapUsedMb); + const effectiveJsHeapUsedMb = Number(effectiveMemory.effectiveJsHeapUsedMb); + const heapGrowthMb = Number(effectiveMemory.heapUsedGrowthMb); + const jsHeapGrowthMb = Number(effectiveMemory.jsHeapUsedGrowthMb); + if ( + (Number.isFinite(effectiveHeapUsedMb) && effectiveHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb) + || (Number.isFinite(effectiveJsHeapUsedMb) && effectiveJsHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb) + ) { + await triggerBrowserFreezeBlocker({ + kind: "memory-page-effective", + rootCause: "frontend_browser_page_effective_memory_pressure", + observed: { + pageRole: pageMetric?.pageRole ?? null, + pageId: pageMetric?.pageId ?? null, + pageEpoch: pageMetric?.pageEpoch ?? null, + totalRssMb, + processRssMb, + totalGrowthMb, + processGrowthMb, + effectiveHeapUsedMb: Number.isFinite(effectiveHeapUsedMb) ? effectiveHeapUsedMb : null, + effectiveJsHeapUsedMb: Number.isFinite(effectiveJsHeapUsedMb) ? effectiveJsHeapUsedMb : null, + baseline: pageMetric?.baseline ?? null, + valuesRedacted: true, + }, + threshold: { processRssBlockerMb: browserFreezePolicy.memory.processRssBlockerMb, policyScope: "per-page-effective-memory", valuesRedacted: true }, + sample: browserProcessSampleRef(sample), + page: browserPageMetricRef(pageMetric), + }); + return; + } + if ( + (Number.isFinite(heapGrowthMb) && heapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb) + || (Number.isFinite(jsHeapGrowthMb) && jsHeapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb) + ) { + await triggerBrowserFreezeBlocker({ + kind: "memory-page-effective-growth", + rootCause: "frontend_browser_page_memory_leak_or_unbounded_render_growth", + observed: { + pageRole: pageMetric?.pageRole ?? null, + pageId: pageMetric?.pageId ?? null, + pageEpoch: pageMetric?.pageEpoch ?? null, + totalRssMb, + processRssMb, + totalGrowthMb, + processGrowthMb, + heapGrowthMb: Number.isFinite(heapGrowthMb) ? heapGrowthMb : null, + jsHeapGrowthMb: Number.isFinite(jsHeapGrowthMb) ? jsHeapGrowthMb : null, + baseline: pageMetric?.baseline ?? null, + valuesRedacted: true, + }, + threshold: { growthBlockerMb: browserFreezePolicy.memory.growthBlockerMb, windowMs: browserFreezePolicy.blockerWindowMs, policyScope: "per-page-effective-memory", valuesRedacted: true }, + sample: browserProcessSampleRef(sample), + page: browserPageMetricRef(pageMetric), + }); + return; + } + const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {}; + const responsivenessLatencyMs = Number(responsiveness.latencyMs); + if (responsiveness.timeout === true || (Number.isFinite(responsivenessLatencyMs) && responsivenessLatencyMs >= browserFreezePolicy.responsiveness.latencyBlockerMs)) { + const signal = recordBrowserFreezeSignal("playwright-responsiveness", sample, pageMetric, { + rootCause: "frontend_browser_page_unresponsive_to_playwright", + observed: { + responsivenessLatencyMs: Number.isFinite(responsivenessLatencyMs) ? responsivenessLatencyMs : null, + responsivenessTimeout: responsiveness.timeout === true, + valuesRedacted: true, + }, + threshold: { + latencyBlockerMs: browserFreezePolicy.responsiveness.latencyBlockerMs, + eventBlockerCount: browserFreezePolicy.responsiveness.eventBlockerCount, + windowMs: browserFreezePolicy.blockerWindowMs, + valuesRedacted: true, + }, + }); + if (signal.burst.length >= browserFreezePolicy.responsiveness.eventBlockerCount) { + await triggerBrowserFreezeBlocker(signal); + return; + } + } + const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {}; + const calls = Array.isArray(cdp.calls) ? cdp.calls : []; + const metricTimeoutCalls = calls.filter((call) => call?.timeout === true && call?.method !== "Runtime.evaluate"); + const sessionTimeoutCount = calls.length === 0 ? Number(cdp.timeoutCount || 0) : 0; + const metricTimeoutCount = metricTimeoutCalls.length + (Number.isFinite(sessionTimeoutCount) ? sessionTimeoutCount : 0); + if (metricTimeoutCount > 0) { + const signal = recordBrowserFreezeSignal("cdp-metrics-timeout", sample, pageMetric, { + rootCause: "frontend_browser_cdp_metrics_unresponsive", + observed: { + cdpMetricsTimeoutCount: metricTimeoutCount, + methods: metricTimeoutCalls.map((call) => call.method || "unknown").slice(0, 8), + valuesRedacted: true, + }, + threshold: { + metricsTimeoutBlockerCount: browserFreezePolicy.cdp.metricsTimeoutBlockerCount, + windowMs: browserFreezePolicy.blockerWindowMs, + valuesRedacted: true, + }, + }); + if (signal.burst.length >= browserFreezePolicy.cdp.metricsTimeoutBlockerCount) { + await triggerBrowserFreezeBlocker(signal); + return; + } + } + } +} + +function recordBrowserFreezeSignal(kind, sample, pageMetric, detail) { + const tsMs = Date.parse(String(sample?.ts || "")); + const signal = { + kind, + ts: sample?.ts ?? new Date().toISOString(), + tsMs: Number.isFinite(tsMs) ? tsMs : Date.now(), + pageRole: pageMetric?.pageRole ?? null, + pageId: pageMetric?.pageId ?? null, + pageEpoch: pageMetric?.pageEpoch ?? null, + sample: browserProcessSampleRef(sample), + page: browserPageMetricRef(pageMetric), + ...detail, + valuesRedacted: true, + }; + browserFreezeSignalHistory.push(signal); + const windowMs = browserFreezePolicy.blockerWindowMs; + const cutoff = signal.tsMs - windowMs; + while (browserFreezeSignalHistory.length > 0 && Number(browserFreezeSignalHistory[0].tsMs || 0) < cutoff) browserFreezeSignalHistory.shift(); + return { + kind, + rootCause: detail.rootCause, + observed: detail.observed, + threshold: detail.threshold, + sample: browserProcessSampleRef(sample), + page: browserPageMetricRef(pageMetric), + burst: browserFreezeSignalHistory.filter((item) => item.kind === kind && item.tsMs >= cutoff).slice(-20), + valuesRedacted: true, + }; +} + +async function triggerBrowserFreezeBlocker(trigger) { + if (browserFreezeBlocker) return; + stopping = true; + terminalStatus = "failed"; + const base = { + id: "browser-freeze-policy-blocker", + severity: "red", + blocking: true, + kind: trigger.kind, + summary: "web-probe runner matched YAML browserFreezePolicy and stopped the browser; this run must stay red instead of refreshing or falling back", + rootCause: trigger.rootCause, + rootCauseStatus: "confirmed-from-runner-browser-freeze-policy", + rootCauseConfidence: "high", + fallbackAllowed: false, + observerRefreshAllowed: false, + policySource: "config/hwlab-node-lanes.yaml#webProbe.browserFreezePolicy via UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON", + observed: trigger.observed || null, + threshold: trigger.threshold || null, + sample: trigger.sample || null, + page: trigger.page || null, + burst: Array.isArray(trigger.burst) ? trigger.burst.slice(0, 20) : [], + valuesRedacted: true, + }; + browserFreezeBlocker = { ...base, browserKill: { ok: false, pending: true, valuesRedacted: true }, valuesRedacted: true }; + const browserKill = browserFreezePolicy.kill.enabled === true + ? await terminateBrowserForFreezeBlocker(base).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true })) + : { ok: true, skipped: true, reason: "kill-disabled-by-yaml-policy", valuesRedacted: true }; + browserFreezeBlocker = { ...base, browserKill, valuesRedacted: true }; + await appendJsonl(files.browserProcess, eventRecord("browser-freeze-blocker", browserFreezeBlocker)).catch(() => {}); + await appendJsonl(files.errors, eventRecord("browser-freeze-blocker", { + ...browserFreezeBlocker, + error: { + name: "BrowserFreezeBlocker", + message: "YAML browserFreezePolicy matched: " + String(trigger.kind || "unknown"), + details: browserFreezeBlocker, + valuesRedacted: true, + }, + })).catch(() => {}); + await writeHeartbeat({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {}); + await writeManifest({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {}); +} + +async function terminateBrowserForFreezeBlocker(blocker) { + const childProcess = browser && typeof browser.process === "function" ? browser.process() : null; + const pid = Number(childProcess?.pid); + if (!Number.isFinite(pid) || pid <= 0) { + return { ok: false, reason: "browser-process-unavailable", blockerKind: blocker.kind, valuesRedacted: true }; + } + const result = { + ok: false, + pid: Math.floor(pid), + gracefulSignal: browserFreezePolicy.kill.gracefulSignal, + forceSignal: browserFreezePolicy.kill.forceSignal, + graceMs: browserFreezePolicy.kill.graceMs, + pollIntervalMs: browserFreezePolicy.kill.pollIntervalMs, + gracefulSent: false, + forceSent: false, + exitedAfterGrace: false, + exitedAfterForce: false, + valuesRedacted: true, + }; + try { + childProcess.kill(browserFreezePolicy.kill.gracefulSignal); + result.gracefulSent = true; + } catch (error) { + result.gracefulError = errorSummary(error); + } + result.exitedAfterGrace = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs); + if (!result.exitedAfterGrace) { + try { + childProcess.kill(browserFreezePolicy.kill.forceSignal); + result.forceSent = true; + } catch (error) { + result.forceError = errorSummary(error); + } + result.exitedAfterForce = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs); + } + result.ok = result.exitedAfterGrace || result.exitedAfterForce; + return result; +} + +async function waitForPidExit(pid, timeoutMs, pollIntervalMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (!pidAlive(pid)) return true; + await sleep(pollIntervalMs); + } + return !pidAlive(pid); +} + +function pidAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function browserProcessSampleRef(sample) { + return { + ts: sample?.ts ?? null, + seq: sample?.seq ?? null, + sampleSeq: sample?.sampleSeq ?? null, + browserPid: sample?.browserPid ?? null, + totalRssMb: sample?.process?.totalRssMb ?? null, + maxProcessRssMb: sample?.process?.maxProcessRssMb ?? null, + totalRssGrowthMb: sample?.growth?.totalRssGrowthMb ?? null, + maxProcessRssGrowthMb: sample?.growth?.maxProcessRssGrowthMb ?? null, + valuesRedacted: true, + }; +} + +function browserPageMetricRef(pageMetric) { + const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {}; + const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {}; + const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {}; + return { + pageRole: pageMetric?.pageRole ?? null, + pageId: pageMetric?.pageId ?? null, + pageEpoch: pageMetric?.pageEpoch ?? null, + timeoutMs: pageMetric?.timeoutMs ?? null, + responsivenessLatencyMs: responsiveness.latencyMs ?? null, + responsivenessTimeout: responsiveness.timeout === true, + cdpTimeoutCount: cdp.timeoutCount ?? null, + cdpErrorCount: cdp.errorCount ?? null, + effectiveHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveHeapUsedMb)) ? Number(effectiveMemory.effectiveHeapUsedMb) : null, + effectiveJsHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveJsHeapUsedMb)) ? Number(effectiveMemory.effectiveJsHeapUsedMb) : null, + heapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.heapUsedGrowthMb)) ? Number(effectiveMemory.heapUsedGrowthMb) : null, + jsHeapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.jsHeapUsedGrowthMb)) ? Number(effectiveMemory.jsHeapUsedGrowthMb) : null, + baselineCapturedAt: pageMetric?.baseline?.capturedAt ?? null, + valuesRedacted: true, + }; +} + +function browserProcessPid() { + try { + const childProcess = browser && typeof browser.process === "function" ? browser.process() : null; + const pid = Number(childProcess?.pid); + return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : null; + } catch { + return null; + } +} + +async function collectChromiumProcessSummary(browserPid) { + const all = await readProcProcessTable(); + const childrenByPpid = new Map(); + for (const item of all) { + if (!Number.isFinite(item.ppid)) continue; + const list = childrenByPpid.get(item.ppid) || []; + list.push(item.pid); + childrenByPpid.set(item.ppid, list); + } + const roots = [process.pid, browserPid].filter((pid, index, items) => Number.isFinite(Number(pid)) && Number(pid) > 0 && items.indexOf(pid) === index).map((pid) => Number(pid)); + const descendants = new Set(roots); + const queue = roots.slice(); + while (queue.length > 0) { + const pid = queue.shift(); + for (const child of childrenByPpid.get(pid) || []) { + if (descendants.has(child)) continue; + descendants.add(child); + queue.push(child); + } + } + const processes = all + .filter((item) => descendants.has(item.pid)) + .map((item) => ({ ...item, role: classifyChromiumProcess(item) })) + .filter((item) => item.role) + .map((item) => ({ + pid: item.pid, + ppid: item.ppid, + name: item.name || null, + role: item.role, + rssBytes: item.rssBytes, + vmSizeBytes: item.vmSizeBytes, + commandHash: item.cmdline ? sha256Text(item.cmdline) : null, + commandPreview: redactProcessCommandPreview(item.cmdline), + valuesRedacted: true, + })) + .sort((a, b) => Number(b.rssBytes || 0) - Number(a.rssBytes || 0)); + const roles = {}; + for (const item of processes) { + const role = item.role || "unknown"; + const summary = roles[role] || { count: 0, rssBytes: 0, maxRssBytes: 0 }; + summary.count += 1; + summary.rssBytes += Number(item.rssBytes || 0); + summary.maxRssBytes = Math.max(summary.maxRssBytes, Number(item.rssBytes || 0)); + roles[role] = summary; + } + const totalRssBytes = processes.reduce((sum, item) => sum + Number(item.rssBytes || 0), 0); + const maxProcess = processes[0] || null; + return { + sampledRootPids: roots, + chromiumProcessCount: processes.length, + totalRssBytes, + totalRssMb: bytesToMb(totalRssBytes), + maxProcessRssBytes: maxProcess ? Number(maxProcess.rssBytes || 0) : 0, + maxProcessRssMb: maxProcess ? bytesToMb(maxProcess.rssBytes) : 0, + maxProcess, + roles, + processes: processes.slice(0, 20), + valuesRedacted: true, + }; +} + +async function readProcProcessTable() { + let entries = []; + try { + entries = await readdir("/proc"); + } catch { + return []; + } + const numeric = entries.map((name) => Number(name)).filter((pid) => Number.isInteger(pid) && pid > 0); + const rows = await Promise.all(numeric.map((pid) => readOneProcProcess(pid).catch(() => null))); + return rows.filter(Boolean); +} + +async function readOneProcProcess(pid) { + const [statText, statusText, cmdlineText] = await Promise.all([ + readFile(path.join("/proc", String(pid), "stat"), "utf8").catch(() => ""), + readFile(path.join("/proc", String(pid), "status"), "utf8").catch(() => ""), + readFile(path.join("/proc", String(pid), "cmdline"), "utf8").catch(() => ""), + ]); + if (!statText && !statusText) return null; + const ppid = procPpidFromStat(statText); + const name = procStatusField(statusText, "Name") || null; + const rssKb = procStatusKb(statusText, "VmRSS"); + const vmSizeKb = procStatusKb(statusText, "VmSize"); + return { + pid, + ppid, + name, + cmdline: String(cmdlineText || "").replace(/\0/gu, " ").replace(/\s+/gu, " ").trim(), + rssBytes: Number.isFinite(rssKb) ? rssKb * 1024 : 0, + vmSizeBytes: Number.isFinite(vmSizeKb) ? vmSizeKb * 1024 : 0, + }; +} + +function procPpidFromStat(value) { + const text = String(value || ""); + const end = text.lastIndexOf(")"); + if (end < 0) return null; + const parts = text.slice(end + 1).trim().split(/\s+/u); + const ppid = Number(parts[1]); + return Number.isFinite(ppid) ? ppid : null; +} + +function procStatusField(value, key) { + const prefix = String(key || "") + ":"; + for (const line of String(value || "").split(/\n/u)) { + if (line.startsWith(prefix)) return line.slice(prefix.length).trim(); + } + return null; +} + +function procStatusKb(value, key) { + const raw = procStatusField(value, key); + const match = String(raw || "").match(/^(\d+)\s+kB$/u); + return match ? Number(match[1]) : null; +} + +function classifyChromiumProcess(item) { + const cmdline = String(item?.cmdline || ""); + const combined = (cmdline + " " + String(item?.name || "")).toLowerCase(); + if (!/(?:chromium|chrome|headless_shell)/u.test(combined)) return null; + if (/--type=renderer\b/u.test(cmdline)) return "renderer"; + if (/--type=gpu-process\b/u.test(cmdline)) return "gpu"; + if (/--type=utility\b/u.test(cmdline)) return "utility"; + if (/--type=zygote\b/u.test(cmdline)) return "zygote"; + if (/--type=broker\b/u.test(cmdline)) return "broker"; + if (/--type=/u.test(cmdline)) return "other"; + return "browser"; +} + +function redactProcessCommandPreview(value) { + const text = String(value || ""); + if (!text) return null; + const parts = text.split(/\s+/u).slice(0, 18).map((part) => { + if (/--(?:proxy|token|secret|password|cookie|auth|key)[^=]*=/iu.test(part)) return part.replace(/=.*/u, "=[redacted]"); + return part.replace(/:\/\/[^/@\s]+@/u, "://[redacted]@"); + }); + return truncate(parts.join(" "), 260); +} + +function updateBrowserProcessHistory(tsMs, processSummary) { + const windowMs = Math.max(1000, Number(alertThresholds.browserRssGrowthWindowMs) || 30000); + const totalRssBytes = Number(processSummary?.totalRssBytes || 0); + const maxProcessRssBytes = Number(processSummary?.maxProcessRssBytes || 0); + browserProcessHistory.push({ tsMs, totalRssBytes, maxProcessRssBytes }); + const retentionMs = Math.max(windowMs * 3, 180000); + while (browserProcessHistory.length > 0 && tsMs - browserProcessHistory[0].tsMs > retentionMs) browserProcessHistory.shift(); + const windowStartMs = tsMs - windowMs; + const candidates = browserProcessHistory.filter((item) => item.tsMs >= windowStartMs && item.tsMs <= tsMs); + const baseline = candidates[0] || browserProcessHistory[0] || null; + const totalRssGrowthBytes = baseline ? totalRssBytes - Number(baseline.totalRssBytes || 0) : 0; + const maxProcessRssGrowthBytes = baseline ? maxProcessRssBytes - Number(baseline.maxProcessRssBytes || 0) : 0; + return { + windowMs, + baselineAt: baseline ? new Date(baseline.tsMs).toISOString() : null, + baselineTotalRssBytes: baseline ? baseline.totalRssBytes : null, + baselineMaxProcessRssBytes: baseline ? baseline.maxProcessRssBytes : null, + totalRssGrowthBytes, + totalRssGrowthMb: bytesToMb(totalRssGrowthBytes), + maxProcessRssGrowthBytes, + maxProcessRssGrowthMb: bytesToMb(maxProcessRssGrowthBytes), + valuesRedacted: true, + }; +} + +async function collectBrowserPageRuntimeMetrics(targetPage, { pageRole, targetPageId, pageEpoch }) { + const timeoutMs = Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)); + const startedAtMs = Date.now(); + let session = null; + const result = { + pageRole, + pageId: targetPageId, + pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, + url: pageUrl(targetPage), + timeoutMs, + responsiveness: null, + cdp: { timeoutCount: 0, errorCount: 0, calls: [] }, + valuesRedacted: true, + }; + try { + session = await withHardTimeout(targetPage.context().newCDPSession(targetPage), timeoutMs, "newCDPSession exceeded " + timeoutMs + "ms"); + const enable = await timedCdpSend(session, "Performance.enable", {}, timeoutMs); + if (enable.ok !== true) result.cdp.calls.push({ method: "Performance.enable", ...enable }); + result.responsiveness = await timedCdpSend(session, "Runtime.evaluate", { expression: "1", returnByValue: true }, timeoutMs); + result.cdp.calls.push({ method: "Runtime.evaluate", ...result.responsiveness }); + const performance = await timedCdpSend(session, "Performance.getMetrics", {}, timeoutMs); + result.cdp.calls.push({ method: "Performance.getMetrics", ...performance }); + result.performance = performance.value; + const heap = await timedCdpSend(session, "Runtime.getHeapUsage", {}, timeoutMs); + result.cdp.calls.push({ method: "Runtime.getHeapUsage", ...heap }); + result.heapUsage = heap.value; + const domCounters = await timedCdpSend(session, "Memory.getDOMCounters", {}, timeoutMs); + result.cdp.calls.push({ method: "Memory.getDOMCounters", ...domCounters }); + result.domCounters = domCounters.value; + result.cdp.timeoutCount = result.cdp.calls.filter((item) => item.timeout === true).length; + result.cdp.errorCount = result.cdp.calls.filter((item) => item.ok !== true).length; + } catch (error) { + result.sessionError = errorSummary(error); + result.cdp.timeoutCount = isTimeoutErrorMessage(error?.message) ? 1 : 0; + result.cdp.errorCount = 1; + } finally { + result.latencyMs = Date.now() - startedAtMs; + applyBrowserPageRuntimeBaseline(result); + if (session) await withHardTimeout(session.detach(), 1000, "CDP session detach exceeded 1000ms").catch(() => {}); + } + return result; +} + +function applyBrowserPageRuntimeBaseline(result) { + const key = [result.pageRole || "unknown", result.pageId || "unknown", Number.isFinite(Number(result.pageEpoch)) ? Number(result.pageEpoch) : 0].join(":"); + const current = browserPageRuntimeMemorySnapshot(result); + const existing = browserPageRuntimeBaselines.get(key); + if (!existing && current) browserPageRuntimeBaselines.set(key, { ...current, capturedAt: new Date().toISOString(), source: "first-page-runtime-sample", valuesRedacted: true }); + const baseline = browserPageRuntimeBaselines.get(key) || null; + result.baseline = baseline ? { + capturedAt: baseline.capturedAt, + source: baseline.source, + heapUsedMb: baseline.heapUsedMb, + jsHeapUsedMb: baseline.jsHeapUsedMb, + domNodes: baseline.domNodes, + valuesRedacted: true, + } : null; + result.effectiveMemory = browserPageEffectiveMemory(current, baseline); +} + +function browserPageRuntimeMemorySnapshot(result) { + const heapUsedBytes = Number(result?.heapUsage?.usedSize); + const metrics = result?.performance?.metrics && typeof result.performance.metrics === "object" ? result.performance.metrics : {}; + const jsHeapUsedBytes = Number(metrics.JSHeapUsedSize); + const domNodes = Number(result?.domCounters?.nodes ?? metrics.Nodes); + if (!Number.isFinite(heapUsedBytes) && !Number.isFinite(jsHeapUsedBytes) && !Number.isFinite(domNodes)) return null; + return { + heapUsedBytes: Number.isFinite(heapUsedBytes) ? heapUsedBytes : null, + heapUsedMb: Number.isFinite(heapUsedBytes) ? bytesToMb(heapUsedBytes) : null, + jsHeapUsedBytes: Number.isFinite(jsHeapUsedBytes) ? jsHeapUsedBytes : null, + jsHeapUsedMb: Number.isFinite(jsHeapUsedBytes) ? bytesToMb(jsHeapUsedBytes) : null, + domNodes: Number.isFinite(domNodes) ? domNodes : null, + valuesRedacted: true, + }; +} + +function browserPageEffectiveMemory(current, baseline) { + if (!current) return { available: false, baselineAvailable: Boolean(baseline), valuesRedacted: true }; + const heapUsedGrowthBytes = numericDelta(current.heapUsedBytes, baseline?.heapUsedBytes); + const jsHeapUsedGrowthBytes = numericDelta(current.jsHeapUsedBytes, baseline?.jsHeapUsedBytes); + const domNodesGrowth = numericDelta(current.domNodes, baseline?.domNodes); + return { + available: true, + baselineAvailable: Boolean(baseline), + heapUsedMb: current.heapUsedMb, + jsHeapUsedMb: current.jsHeapUsedMb, + effectiveHeapUsedMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : current.heapUsedMb, + effectiveJsHeapUsedMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : current.jsHeapUsedMb, + heapUsedGrowthMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : null, + jsHeapUsedGrowthMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : null, + domNodes: current.domNodes, + domNodesGrowth: Number.isFinite(domNodesGrowth) ? domNodesGrowth : null, + valuesRedacted: true, + }; +} + +function numericDelta(current, baseline) { + const value = Number(current); + const base = Number(baseline); + if (!Number.isFinite(value) || !Number.isFinite(base)) return null; + return value - base; +} + +function browserPageRuntimeMetricError(pageRole, targetPageId, pageEpoch, error) { + return { + pageRole, + pageId: targetPageId, + pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, + url: null, + timeoutMs: Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)), + responsiveness: { ok: false, timeout: isTimeoutErrorMessage(error?.message), error: errorSummary(error), valuesRedacted: true }, + cdp: { timeoutCount: isTimeoutErrorMessage(error?.message) ? 1 : 0, errorCount: 1, calls: [] }, + valuesRedacted: true, + }; +} + +async function timedCdpSend(session, method, params, timeoutMs) { + const startedAtMs = Date.now(); + try { + const value = await withHardTimeout(session.send(method, params || {}), timeoutMs, method + " exceeded " + timeoutMs + "ms"); + return { ok: true, timeout: false, latencyMs: Date.now() - startedAtMs, value: compactCdpValue(method, value), valuesRedacted: true }; + } catch (error) { + return { ok: false, timeout: isTimeoutErrorMessage(error?.message), latencyMs: Date.now() - startedAtMs, error: errorSummary(error), valuesRedacted: true }; + } +} + +function compactCdpValue(method, value) { + if (!value || typeof value !== "object") return value ?? null; + if (method === "Performance.getMetrics") { + const wanted = new Set(["Timestamp", "Documents", "Frames", "JSEventListeners", "Nodes", "LayoutCount", "RecalcStyleCount", "LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "TaskDuration", "JSHeapUsedSize", "JSHeapTotalSize"]); + const metrics = {}; + for (const item of Array.isArray(value.metrics) ? value.metrics : []) { + if (wanted.has(item?.name)) metrics[item.name] = Number.isFinite(Number(item?.value)) ? Number(item.value) : null; + } + return { metricCount: Array.isArray(value.metrics) ? value.metrics.length : 0, metrics, valuesRedacted: true }; + } + if (method === "Runtime.getHeapUsage") { + return { + usedSize: Number.isFinite(Number(value.usedSize)) ? Number(value.usedSize) : null, + totalSize: Number.isFinite(Number(value.totalSize)) ? Number(value.totalSize) : null, + embedderHeapUsedSize: Number.isFinite(Number(value.embedderHeapUsedSize)) ? Number(value.embedderHeapUsedSize) : null, + valuesRedacted: true, + }; + } + if (method === "Memory.getDOMCounters") { + return { + documents: Number.isFinite(Number(value.documents)) ? Number(value.documents) : null, + nodes: Number.isFinite(Number(value.nodes)) ? Number(value.nodes) : null, + jsEventListeners: Number.isFinite(Number(value.jsEventListeners)) ? Number(value.jsEventListeners) : null, + valuesRedacted: true, + }; + } + if (method === "Runtime.evaluate") { + return { resultType: value.result?.type ?? null, unserializableValue: value.result?.unserializableValue ?? null, exceptionDetails: value.exceptionDetails ? { text: truncate(value.exceptionDetails.text || "", 200), valuesRedacted: true } : null, valuesRedacted: true }; + } + return { valuesRedacted: true }; +} + +function isTimeoutErrorMessage(value) { + return /timeout|timed\s*out|exceeded\s+\d+\s*ms/iu.test(String(value || "")); +} + +function bytesToMb(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Number((numeric / 1024 / 1024).toFixed(1)); +} + +function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) { + void installPagePerformanceProbe(targetPage, pageRole, targetPageId) + .catch((error) => appendJsonl(files.errors, eventRecord("performance-probe-install-error", { pageRole, pageId: targetPageId, error: errorSummary(error), valuesRedacted: true }))); + targetPage.on("request", (request) => { + void appendJsonl(files.network, eventRecord("request", { + pageRole, + pageId: targetPageId, + observerInitiated: false, + commandId: activeCommandId, + method: request.method(), + url: safeUrl(request.url()), + resourceType: request.resourceType(), + frameUrl: safeFrameUrl(request.frame()), + })); + }); + targetPage.on("response", (response) => { + const request = response.request(); + const base = { + pageRole, + pageId: targetPageId, + sampleSeq, + observerInitiated: false, + commandId: activeCommandId, + method: request.method(), + url: safeUrl(response.url()), + resourceType: request.resourceType(), + status: response.status(), + statusText: response.statusText(), + fromServiceWorker: response.fromServiceWorker(), + }; + void (async () => { + const bodyFields = await summarizeWorkbenchResponseBody(response, request); + await appendJsonl(files.network, eventRecord("response", { ...base, ...bodyFields })); + })().catch((error) => appendJsonl(files.errors, eventRecord("response-body-summary-error", { + pageRole, + pageId: targetPageId, + sampleSeq, + commandId: activeCommandId, + method: request.method(), + url: safeUrl(response.url()), + error: errorSummary(error), + valuesRedacted: true + }))); + }); + targetPage.on("requestfailed", (request) => { + void appendJsonl(files.network, eventRecord("requestfailed", { + pageRole, + pageId: targetPageId, + observerInitiated: false, + commandId: activeCommandId, + method: request.method(), + url: safeUrl(request.url()), + resourceType: request.resourceType(), + failure: request.failure()?.errorText ?? null, + })); + }); + targetPage.on("console", (message) => { + void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() })); + }); + targetPage.on("pageerror", (error) => { + void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) })); + }); + targetPage.on("crash", () => { + void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId })); + }); + targetPage.on("close", () => { + void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" })); + }); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts b/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts new file mode 100644 index 00000000..33e7a130 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts @@ -0,0 +1,1122 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Runner page sampling, screenshots, DOM digest, response body summaries, and command record source fragment. + +export function nodeWebObserveRunnerSamplingSource(): string { + return String.raw`async function preflightSummary() { + return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) }; +} + +async function samplePage(reason, options = {}) { + if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason); + const groupSeq = sampleSeq + 1; + if (page && !page.isClosed()) { + await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }) + .catch((error) => appendJsonl(files.errors, eventRecord("control-sample-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) }))); + await drainPagePerformanceEvents(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }) + .catch((error) => appendJsonl(files.errors, eventRecord("control-performance-drain-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) }))); + } + if (observerPage && !observerPage.isClosed()) { + await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) }))); + await drainPagePerformanceEvents(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }) + .catch((error) => appendJsonl(files.errors, eventRecord("observer-performance-drain-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) }))); + } + if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) { + await captureScreenshot("checkpoint", "jpeg") + .catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) }))); + } + await writeHeartbeat({ status: terminalStatus }); +} + +async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) { + sampleSeq += 1; + const evaluateTimeoutMs = Math.max(3000, Math.min(8000, Number(sampleIntervalMs) || 5000)); + const dom = await withHardTimeout(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"; + }; + const textHashInput = (element) => trim(element.textContent || "", 800); + const loadingTextPattern = /加载中|\bLoading\b/iu; + const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu; + const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]"; + const elementTextForLoading = (element) => [ + element.textContent || "", + element.getAttribute("aria-label") || "", + element.getAttribute("title") || "", + element.getAttribute("data-testid") || "" + ].map((value) => trim(value, 240)).filter(Boolean).join(" "); + const elementLooksLikeLoadingUi = (element) => { + const signal = [ + element.getAttribute("class") || "", + element.getAttribute("data-testid") || "", + element.getAttribute("role") || "", + element.getAttribute("aria-busy") || "", + element.getAttribute("aria-label") || "", + element.getAttribute("title") || "" + ].join(" "); + return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal); + }; + const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element); + const hasLoadingText = (element) => { + const text = elementTextForLoading(element); + if (!loadingTextPattern.test(text)) return false; + if (elementIsCodeLike(element)) return false; + return true; + }; + const elementDescriptor = (element) => { + if (!element) return null; + const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" "); + const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]"); + return { + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid") || null, + role: element.getAttribute("role") || null, + id: element.getAttribute("id") || null, + className: className || null, + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null, + traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null, + ariaLabel: element.getAttribute("aria-label") || null + }; + }; + const ownerKindFor = (element) => { + const value = [ + element.getAttribute("data-testid") || "", + element.getAttribute("class") || "", + element.getAttribute("role") || "", + element.tagName || "" + ].join(" ").toLowerCase(); + if (/message|turn|agent|assistant/.test(value)) return "turn"; + if (/session|rail|sidebar|nav/.test(value)) return "session-nav"; + if (/composer|prompt|input|textarea/.test(value)) return "composer"; + if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic"; + if (/trace|event|terminal|log/.test(value)) return "trace"; + if (/performance|metric|chart|table/.test(value)) return "performance"; + if (/main|workspace|workbench|root/.test(value)) return "workbench"; + return "unknown"; + }; + const ownerLabelFor = (element) => { + const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]"); + return trim( + element.getAttribute("aria-label") + || element.getAttribute("data-testid") + || (heading ? heading.textContent || "" : "") + || element.getAttribute("class") + || element.tagName, + 160 + ); + }; + const ownerKeyFor = (element) => { + const descriptor = elementDescriptor(element) || {}; + return [ + ownerKindFor(element), + descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown", + descriptor.sessionId || descriptor.messageId || descriptor.traceId || "" + ].filter(Boolean).join(":").slice(0, 240); + }; + const collectLoadingNodes = () => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter(visible) + .filter(hasLoadingText) + .filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child))) + .slice(-80); + return candidates.map((element, index) => { + const rect = element.getBoundingClientRect(); + const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]'); + const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]'); + const owner = identityOwner || structuralOwner || element; + const ownerDescriptor = elementDescriptor(owner); + return { + index, + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 180), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + text: trim(elementTextForLoading(element), 240), + ownerKind: ownerKindFor(owner), + ownerKey: ownerKeyFor(owner), + ownerLabel: ownerLabelFor(owner), + owner: ownerDescriptor, + ownerText: trim(owner.textContent || "", 300), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }); + }; + const sessionIdForElement = (element) => { + const direct = element.getAttribute("data-session-id"); + if (direct) return direct; + const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; + if (!href) return null; + try { + const parsed = new URL(href, location.href); + const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); + return match ? decodeURIComponent(match[1] || "") : null; + } catch { + const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); + return match ? decodeURIComponent(match[1] || "") : null; + } + }; + const sessionTitleTextForElement = (element) => { + const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]"); + return trim( + (titleNode ? titleNode.textContent || "" : "") + || element.getAttribute("aria-label") + || element.getAttribute("title") + || element.textContent + || "", + 240 + ); + }; + const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu; + const looksLikeSessionTitleFallback = (title, sessionId) => { + const text = trim(title, 240); + const id = String(sessionId || "").trim(); + if (!text) return true; + if (sessionTitleFallbackPattern.test(text)) return true; + if (!id) return false; + return text === id || text.startsWith(id) || text.startsWith("Session " + id); + }; + const collectSessionRailTitles = () => { + const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']")) + .filter(visible); + const seen = new Set(); + const items = []; + for (const element of candidates) { + const sessionId = sessionIdForElement(element); + const titleText = sessionTitleTextForElement(element); + const key = (sessionId || "") + "|" + titleText; + if (seen.has(key)) continue; + seen.add(key); + const rect = element.getBoundingClientRect(); + const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId); + const dataStatus = element.getAttribute("data-status") || element.getAttribute("data-state") || null; + const dataRunning = element.getAttribute("data-running") || element.getAttribute("data-busy") || null; + const ariaBusy = element.getAttribute("aria-busy") || null; + items.push({ + index: items.length, + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", + status: dataStatus || ariaBusy || null, + dataStatus, + running: dataRunning === "true" || ariaBusy === "true" || element.classList.contains("is-running") || element.classList.contains("running"), + dataRunning, + ariaBusy, + sessionId, + sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null, + titleText, + fallbackTitle, + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }); + } + const visibleItems = items.slice(0, 120); + const fallbackItems = visibleItems.filter((item) => item.fallbackTitle); + const visibleCount = visibleItems.length; + const fallbackTitleCount = fallbackItems.length; + return { + visibleCount, + fallbackTitleCount, + fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0, + activeItem: visibleItems.find((item) => item.active) || null, + items: visibleItems.slice(0, 60), + fallbackItems: fallbackItems.slice(0, 12), + }; + }; + const diagnosticSummaryText = (element) => { + const summarySelectors = [ + ".api-error-diagnostic-summary-text p", + ".api-error-diagnostic-summary-text", + "[class*='diagnostic-summary-text' i] p", + "[class*='diagnostic-summary-text' i]", + "[data-testid*='diagnostic-summary' i]", + "[data-testid*='error-summary' i]", + "[role='alert'] p", + "[role='alert']" + ]; + const parts = []; + for (const selector of summarySelectors) { + for (const candidate of Array.from(element.querySelectorAll(selector))) { + if (!visible(candidate)) continue; + const text = trim(candidate.textContent || "", 800); + if (text && !parts.includes(text)) parts.push(text); + } + } + const ownText = textHashInput(element); + const text = parts.length > 0 ? parts.join(" ") : ownText; + return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim(); + }; + const diagnosticToggleOnly = (element, text) => { + const compact = String(text || "").trim(); + if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false; + const tag = element.tagName.toLowerCase(); + const role = element.getAttribute("role"); + const aria = element.getAttribute("aria-label") || ""; + const title = element.getAttribute("title") || ""; + return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title); + }; + const messageBodyTextDetail = (element) => { + const bodySelectors = [ + ".message-markdown.message-text", + ".message-text", + "[data-message-body]", + "[data-testid='message-body']", + "[data-testid*='message-text' i]", + "[data-testid*='final-response' i]" + ]; + const parts = []; + for (const selector of bodySelectors) { + for (const candidate of Array.from(element.querySelectorAll(selector))) { + if (!visible(candidate)) continue; + const text = trim(candidate.textContent || "", 1200); + if (text && !parts.some((part) => part.text === text)) parts.push({ text, selector }); + } + } + if (parts.length > 0) { + return { + text: parts.map((part) => part.text).join(" "), + source: parts[0]?.selector || "body-selector", + candidateCount: parts.length + }; + } + return { text: "", source: null, candidateCount: 0 }; + }; + const stableMessageText = (element) => { + const body = messageBodyTextDetail(element); + if (body.text) return body.text; + const clone = element.cloneNode(true); + for (const selector of [ + ".message-duration-meta", + ".message-activity-meta", + ".api-error-diagnostic", + "[class*='diagnostic' i]", + "[class*='trace' i]", + "[data-trace-id]", + "[data-testid*='trace' i]", + "[data-testid*='event' i]", + "[role='status']", + "[role='alert']", + "button" + ]) { + for (const child of Array.from(clone.querySelectorAll(selector))) child.remove(); + } + return trim(clone.textContent || "", 1200); + }; + const numericAttr = (element, names) => { + for (const name of names) { + const raw = element.getAttribute(name); + const value = Number(raw); + if (Number.isFinite(value)) return value; + } + return null; + }; + const textAttr = (element, names) => { + for (const name of names) { + const raw = element.getAttribute(name); + if (raw && String(raw).trim()) return String(raw).trim(); + } + return null; + }; + const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => { + const rect = element.getBoundingClientRect(); + const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]'); + const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]"); + return { + index, + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null, + turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null, + projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]), + sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]), + eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]), + eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null), + eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null, + eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null, + text: textHashInput(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }); + const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit); + const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { + const rect = element.getBoundingClientRect(); + const body = messageBodyTextDetail(element); + return { + index, + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + dataRole: element.getAttribute("data-role"), + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: element.getAttribute("data-trace-id") || null, + turnId: element.getAttribute("data-turn-id") || null, + durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120), + activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120), + bodyText: body.text, + bodyTextSource: body.source, + bodyTextCandidateCount: body.candidateCount, + text: stableMessageText(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }); + const resourceTimingSample = (entry) => ({ + name: entry.name.split(/[?#]/u)[0].slice(0, 240), + initiatorType: entry.initiatorType, + startTime: Math.round(entry.startTime), + duration: Math.round(entry.duration), + workerStart: Math.round(entry.workerStart || 0), + redirectStart: Math.round(entry.redirectStart || 0), + redirectEnd: Math.round(entry.redirectEnd || 0), + fetchStart: Math.round(entry.fetchStart || 0), + domainLookupStart: Math.round(entry.domainLookupStart || 0), + domainLookupEnd: Math.round(entry.domainLookupEnd || 0), + connectStart: Math.round(entry.connectStart || 0), + connectEnd: Math.round(entry.connectEnd || 0), + secureConnectionStart: Math.round(entry.secureConnectionStart || 0), + requestStart: Math.round(entry.requestStart || 0), + responseStart: Math.round(entry.responseStart || 0), + responseEnd: Math.round(entry.responseEnd || 0), + transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null, + encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null, + decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null, + nextHopProtocol: entry.nextHopProtocol || null, + responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null, + serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({ + name: String(item.name || "").slice(0, 80), + duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null, + 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 sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); + const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); + const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; + const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; + const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ + value: option.value, + label: trim(option.textContent || option.label || "", 180), + selected: option.selected === true, + })) : []; + const selectedFileOption = fileOptions.find((option) => option.selected) || null; + 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 bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); + const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); + const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); + const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); + 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); + const measurePaneGap = (name, paneSelector, contentSelector) => { + const pane = document.querySelector(paneSelector); + if (!visible(pane)) return { name, visible: false }; + const rect = pane.getBoundingClientRect(); + const contentNodes = Array.from(pane.querySelectorAll(contentSelector)).filter(visible); + const contentBottom = Math.max(rect.top, ...contentNodes.map((element) => element.getBoundingClientRect().bottom)); + const bottomGapPx = Math.max(0, Math.round(rect.bottom - contentBottom)); + const heightPx = Math.max(0, Math.round(rect.height)); + return { + name, + visible: true, + widthPx: Math.max(0, Math.round(rect.width)), + heightPx, + bottomGapPx, + bottomGapRatio: heightPx > 0 ? Number((bottomGapPx / heightPx).toFixed(3)) : 0, + contentNodeCount: contentNodes.length, + }; + }; + const paneGaps = [ + measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', '[data-task-ref], [role="treeitem"], [role="listitem"], li, button, input, select, .task-row-shell, .task-tools'), + measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'), + measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'), + ]; + 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: Math.max(sourceItems.length, sourceOptionCount), + fileCount: Math.max(fileItems.length, fileOptionCount), + taskCount: taskItems.length, + taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length), + selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value), + selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value), + selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null, + fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24), + selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")), + selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, + sourceSelectVisible: visible(sourceSelect), + fileSelectVisible: visible(fileSelect), + sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), + taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), + taskBodyVisible: visible(bodyRendered), + taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "", + newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), + taskStatusCounts: statusCounts, + reportLinkCount: reportLinks.length, + reportPreviewVisible: visible(reportPreview), + reportPreviewText: visible(reportPreview) ? trim(reportPreview.textContent || "", 500) : "", + reportFullscreenVisible: visible(reportFullscreen), + launchButtonVisible: visible(launch), + launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", + launchButtonText: trim(launch?.textContent || "", 120), + blockerCount: blockers.length, + blockers, + paneGaps, + 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]'); + const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null; + const commandInput = document.querySelector("#command-input"); + const commandSubmit = document.querySelector('#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'); + const composerWarning = document.querySelector(".composer-warning"); + const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]'; + const stableTraceSelector = 'li.trace-render-row[data-row-id], li.trace-render-row[data-testid="trace-render-row"], [data-testid="trace-render-row"][data-row-id]'; + const fallbackTraceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; + const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]'; + const messages = summarizeMessages(messageSelector, 80); + const stableTraceElements = Array.from(document.querySelectorAll(stableTraceSelector)); + const fallbackTraceElements = Array.from(document.querySelectorAll(fallbackTraceSelector)).filter((element) => element.matches('li,[role="listitem"],[data-testid*="trace-row" i],[data-testid*="event-row" i]')); + const traceRows = summarizeElements(stableTraceElements.length > 0 ? stableTraceElements : fallbackTraceElements, 30); + const loadings = collectLoadingNodes(); + const sessionRail = collectSessionRailTitles(); + const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => { + const rect = element.getBoundingClientRect(); + const text = diagnosticSummaryText(element); + if (!text || diagnosticToggleOnly(element, text)) return null; + const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu); + const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); + const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu); + const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu); + const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu); + const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic"; + return { + index, + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 240), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + compact: element.getAttribute("data-compact"), + expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"), + title: element.getAttribute("title"), + diagnosticCode, + traceId: traceMatch?.[1] || null, + httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null, + idleSeconds: idleMatch ? Number(idleMatch[1]) : null, + waitingFor: waitingForMatch?.[1] || null, + lastEventLabel: lastEventLabelMatch?.[1] || null, + text, + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }).filter(Boolean); + const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { + const rect = element.getBoundingClientRect(); + const text = textHashInput(element); + const finalResponse = messageBodyTextDetail(element); + const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]"); + const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u); + const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120); + const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120); + const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null; + return { + index, + role: element.getAttribute("data-role") || "agent", + status: element.getAttribute("data-status") || null, + sessionId: element.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: directTraceId, + turnId: element.getAttribute("data-turn-id") || directTraceId, + durationText, + activityText, + finalResponseText: finalResponse.text, + finalResponseTextSource: finalResponse.source, + finalResponseCandidateCount: finalResponse.candidateCount, + finalResponsePresent: Boolean(finalResponse.text && finalResponse.text.trim()), + text, + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }).slice(-80); + const active = document.activeElement; + return { + url, + path: location.pathname, + routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null, + activeSessionId, + title: document.title, + focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null, + viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }, + scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) }, + messages, + traceRows, + loadings, + sessionRail, + diagnostics, + turns, + composer: { + inputPresent: visible(commandInput), + inputDisabled: Boolean(commandInput?.disabled) || commandInput?.getAttribute("aria-disabled") === "true", + warningPresent: visible(composerWarning), + warningText: trim(composerWarning?.textContent || "", 160), + submitPresent: visible(commandSubmit), + submitDisabled: Boolean(commandSubmit?.disabled) || commandSubmit?.getAttribute("aria-disabled") === "true", + submitAction: commandSubmit?.getAttribute("data-action") || null, + submitText: trim(commandSubmit?.textContent || "", 80), + submitTestId: commandSubmit?.getAttribute("data-testid") || null, + activeStatus: activeSession?.getAttribute("data-status") || null, + valuesRedacted: true + }, + projectManagement: collectProjectManagement(), + pageProvenance: { + url: location.href, + path: location.pathname, + title: document.title, + readyState: document.readyState, + timeOrigin: Math.round(performance.timeOrigin || 0), + navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null, + scripts: Array.from(document.scripts).map((element) => { + if (!element.src) return null; + try { + const url = new URL(element.src, location.href); + const keys = Array.from(url.searchParams.keys()).sort(); + return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); + } catch { + return null; + } + }).filter(Boolean).sort(), + stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => { + try { + const url = new URL(element.href, location.href); + const keys = Array.from(url.searchParams.keys()).sort(); + return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); + } catch { + return null; + } + }).filter(Boolean).sort(), + meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ + key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), + content: String(element.getAttribute("content") || "").slice(0, 200), + })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)), + }, + performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample), + }; + }, { projectManagement }), evaluateTimeoutMs, "sampleOnePage DOM evaluate exceeded " + evaluateTimeoutMs + "ms").catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) })); + const sample = { + seq: sampleSeq, + sampleGroupSeq: groupSeq, + ts: new Date().toISOString(), + reason, + pageRole, + pageId: targetPageId, + pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, + commandId: activeCommandId, + observerInitiated: false, + ...digestDom(dom, pageRole), + }; + await appendJsonl(files.samples, sample); +} + +function digestDom(dom, pageRole = "control") { + if (dom && dom.error) return dom; + const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), bodyTextHash: sha256Text(item.bodyText || ""), bodyTextPreview: truncate(item.bodyText || "", 160), bodyTextBytes: Buffer.byteLength(item.bodyText || ""), bodyTextPresent: Boolean(String(item.bodyText || "").trim()) })) : []; + const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; + const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : []; + 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 || ""), finalResponseTextHash: sha256Text(item.finalResponseText || ""), finalResponseTextPreview: truncate(item.finalResponseText || "", 200), finalResponseTextBytes: Buffer.byteLength(item.finalResponseText || ""), finalResponsePresent: Boolean(String(item.finalResponseText || "").trim()) })) : []; + 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, 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 }; + }; + const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); + const suspiciousFileLabel = (label) => { + const text = String(label || "").trim(); + return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); + }; + const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; + 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), + selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, + selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, + fileOptionLabelSamples: fileLabels.slice(0, 10).map((item) => textDigest(item, 120)), + fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, + selectedTaskRef: opaque(value.selectedTaskRef), + selectedTaskStatus: value.selectedTaskStatus ?? null, + sourceSelectVisible: value.sourceSelectVisible === true, + fileSelectVisible: value.fileSelectVisible === true, + sourceConfigVisible: value.sourceConfigVisible === true, + taskEditorVisible: value.taskEditorVisible === true, + taskBodyVisible: value.taskBodyVisible === true, + taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, + newTaskDraftVisible: value.newTaskDraftVisible === true, + taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {}, + reportLinkCount: Number.isFinite(Number(value.reportLinkCount)) ? Number(value.reportLinkCount) : 0, + reportPreviewVisible: value.reportPreviewVisible === true, + reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, + reportFullscreenVisible: value.reportFullscreenVisible === true, + 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), + })) : [], + paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({ + name: item?.name ?? null, + visible: item?.visible === true, + widthPx: Number.isFinite(Number(item?.widthPx)) ? Number(item.widthPx) : null, + heightPx: Number.isFinite(Number(item?.heightPx)) ? Number(item.heightPx) : null, + bottomGapPx: Number.isFinite(Number(item?.bottomGapPx)) ? Number(item.bottomGapPx) : null, + bottomGapRatio: Number.isFinite(Number(item?.bottomGapRatio)) ? Number(item.bottomGapRatio) : null, + contentNodeCount: Number.isFinite(Number(item?.contentNodeCount)) ? Number(item.contentNodeCount) : null, + valuesRedacted: true, + })) : [], + workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0, + valuesRedacted: true + }; +} + +function digestSessionRail(value) { + if (!value || typeof value !== "object") return null; + const items = Array.isArray(value.items) ? value.items.map((item) => { + const titleText = String(item?.titleText || item?.titlePreview || ""); + return { + index: item?.index ?? null, + tag: item?.tag ?? null, + testId: item?.testId ?? null, + role: item?.role ?? null, + active: item?.active === true, + status: item?.status ?? null, + dataStatus: item?.dataStatus ?? null, + running: item?.running === true, + dataRunning: item?.dataRunning ?? null, + ariaBusy: item?.ariaBusy ?? null, + sessionId: item?.sessionId ?? null, + sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), + fallbackTitle: item?.fallbackTitle === true, + titleHash: sha256Text(titleText), + titlePreview: truncate(titleText, 160), + titleBytes: Buffer.byteLength(titleText), + rect: item?.rect ?? null, + }; + }) : []; + const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12); + const visibleCount = Number(value.visibleCount ?? items.length); + const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length); + return { + visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length, + fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length, + fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0), + activeItem: items.find((item) => item.active) || null, + items, + fallbackItems, + valuesRedacted: true, + }; +} + +async function captureScreenshot(reason, imageType = "png") { + if (!page || page.isClosed()) throw new Error("page is not available for screenshot"); + if (screenshotCaptureState && screenshotCaptureState.settled !== true) { + const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now()); + const error = new Error("screenshot capture already in progress"); + error.details = { + reason, + currentUrl: currentPageUrl(), + pageId, + activeReason: screenshotCaptureState.reason, + activeStartedAt: screenshotCaptureState.startedAt, + activeAgeMs: ageMs, + activeTimedOut: screenshotCaptureState.timedOut === true, + timeoutMs: screenshotCaptureTimeoutMs, + valuesRedacted: true, + }; + lastScreenshotAtMs = Date.now(); + throw error; + } + artifactSeq += 1; + const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual"; + const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png"; + const ext = type === "jpeg" ? "jpg" : "png"; + const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext); + const timeoutMs = screenshotCaptureTimeoutMs; + const options = type === "jpeg" + ? { path: file, type: "jpeg", quality: 70, fullPage: false, animations: "disabled", timeout: timeoutMs } + : { path: file, type: "png", fullPage: false, animations: "disabled", timeout: timeoutMs }; + const state = { reason, startedAtMs: Date.now(), startedAt: new Date().toISOString(), timeoutMs, settled: false, timedOut: false }; + screenshotCaptureState = state; + const screenshotPromise = page.screenshot(options) + .then((value) => { + state.settled = true; + return value; + }) + .catch((error) => { + state.settled = true; + throw error; + }) + .finally(() => { + if (screenshotCaptureState === state) screenshotCaptureState = null; + }); + try { + await withHardTimeout(screenshotPromise, timeoutMs + 1000, "captureScreenshot " + safeReason + " exceeded " + timeoutMs + "ms"); + const meta = await fileMeta(file); + const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl(), timeoutMs }; + await appendJsonl(files.artifacts, artifact); + lastScreenshotAtMs = Date.now(); + return artifact; + } catch (error) { + if (String(error?.message || "").includes("exceeded " + timeoutMs + "ms")) state.timedOut = true; + lastScreenshotAtMs = Date.now(); + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { + ...(wrapped.details || {}), + reason, + currentUrl: currentPageUrl(), + pageId, + timeoutMs, + file, + valuesRedacted: true, + }; + throw wrapped; + } +} + +async function captureCommandScreenshot(command) { + const shouldWaitProject = command.waitProjectManagementReady === true; + const readiness = shouldWaitProject ? await waitForProjectManagementCommandReady({ timeoutMs: 15000 }) : null; + if (readiness && readiness.ok !== true) { + const error = new Error("screenshot project-management readiness wait failed: " + (readiness.reason || "not-ready")); + error.details = { readiness, currentUrl: currentPageUrl(), pageId, valuesRedacted: true }; + throw error; + } + const artifact = await captureScreenshot(command.reason || command.label || "manual", command.imageType || "png"); + return { ...artifact, readiness, valuesRedacted: true }; +} + +function eventRecord(type, data) { + const clean = sanitize(data) || {}; + return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean }; +} + +async function summarizeWorkbenchResponseBody(response, request) { + const method = String(request.method() || "GET").toUpperCase(); + const path = safeUrlPath(response.url()) || ""; + const resourceType = String(request.resourceType() || ""); + const status = Number(response.status()); + if (!shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status })) return { bodyRead: false }; + const headers = response.headers(); + const contentType = String(headers["content-type"] || headers["Content-Type"] || ""); + if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true }; + const contentLength = Number(headers["content-length"] || headers["Content-Length"]); + const maxBytes = 512 * 1024; + if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true }; + const text = await response.text(); + const byteCount = Buffer.byteLength(text); + if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true }; + let parsed = null; + try { + parsed = JSON.parse(text); + } catch (error) { + return { bodyRead: true, bodyReadSkipped: "json-parse-error", bodyByteCount: byteCount, bodyHash: sha256Text(text), bodyParseError: errorSummary(error), valuesRedacted: true }; + } + return { + bodyRead: true, + bodyByteCount: byteCount, + bodyHash: sha256Text(text), + bodySummary: summarizeWorkbenchJsonBody(parsed, path), + valuesRedacted: true + }; +} + +function shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status }) { + if (method !== "GET" && method !== "POST") return false; + if (!Number.isFinite(status) || status < 200 || status >= 300) return false; + if (resourceType === "eventsource" || path === "/v1/workbench/events") return false; + return path === "/v1/agent/chat" + || path === "/v1/agent/chat/steer" + || path === "/v1/workbench/sessions" + || /^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path) + || /^\/v1\/workbench\/turns\/[^/]+$/u.test(path) + || /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path); +} + +function summarizeWorkbenchJsonBody(value, path) { + const traceIds = new Set(); + const sessionIds = new Set(); + const terminalTraceIds = new Set(); + const statusCounts = {}; + const counters = { + objectCount: 0, + arrayCount: 0, + traceEventLikeCount: 0, + messageLikeCount: 0, + turnLikeCount: 0, + terminalStatusCount: 0, + runningStatusCount: 0, + terminalTextCount: 0, + finalTextFieldCount: 0, + finalTextByteCount: 0 + }; + + const visit = (node, key = "", parent = null, depth = 0) => { + if (depth > 32 || node === null || node === undefined) return; + if (typeof node === "string") { + collectWorkbenchIdsFromText(node, traceIds, sessionIds); + const normalizedStatus = normalizeWorkbenchStatus(key, node); + if (normalizedStatus) { + statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1; + if (isWorkbenchTerminalStatus(normalizedStatus)) counters.terminalStatusCount += 1; + if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1; + } + if (isWorkbenchTerminalText(node)) { + counters.terminalTextCount += 1; + for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId); + } + if (isLikelyWorkbenchFinalTextField(key, parent, node)) { + counters.finalTextFieldCount += 1; + counters.finalTextByteCount += Buffer.byteLength(node); + for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId); + } + return; + } + if (typeof node !== "object") return; + if (Array.isArray(node)) { + counters.arrayCount += 1; + for (const item of node) visit(item, key, parent, depth + 1); + return; + } + counters.objectCount += 1; + const record = node; + const recordTraceIds = workbenchTraceIdsFromRecord(record); + for (const raw of [record.traceId, record.trace_id, record.id, record.turnId, record.messageId]) { + if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds); + } + for (const raw of [record.sessionId, record.session_id]) { + if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds); + } + const statusValue = record.status ?? record.state ?? record.phase ?? record.result ?? record.lifecycle; + if (typeof statusValue === "string") { + const normalizedStatus = normalizeWorkbenchStatus("status", statusValue); + if (normalizedStatus) { + statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1; + if (isWorkbenchTerminalStatus(normalizedStatus)) { + counters.terminalStatusCount += 1; + for (const traceId of recordTraceIds) terminalTraceIds.add(traceId); + } + if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1; + } + } + if (record.traceId || record.trace_id || record.turnId || record.turn_id) counters.turnLikeCount += 1; + if (record.messageId || record.message_id || record.role || record.author || record.content || record.text || record.finalResponse) counters.messageLikeCount += 1; + if (record.projectedSeq !== undefined || record.sourceSeq !== undefined || record.eventSeq !== undefined || record.eventKind !== undefined || record.eventTimestamp !== undefined) counters.traceEventLikeCount += 1; + for (const [childKey, childValue] of Object.entries(record)) visit(childValue, childKey, record, depth + 1); + }; + visit(value); + return { + pathKind: workbenchBodyPathKind(path), + traceIds: Array.from(traceIds).sort().slice(0, 12), + terminalTraceIds: Array.from(terminalTraceIds).sort().slice(0, 12), + sessionIds: Array.from(sessionIds).sort().slice(0, 12), + statusCounts, + ...counters, + terminalEvidenceCount: counters.terminalStatusCount + counters.terminalTextCount, + valuesRedacted: true + }; +} + +function collectWorkbenchIdsFromText(value, traceIds, sessionIds) { + const text = String(value || ""); + for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) traceIds.add(match[0]); + for (const match of text.matchAll(/\bses_[A-Za-z0-9_-]+\b/gu)) sessionIds.add(match[0]); +} + +function workbenchTraceIdsFromRecord(record) { + if (!record || typeof record !== "object") return []; + const values = [record.traceId, record.trace_id, record.turnId, record.turn_id, record.messageId, record.message_id, record.id]; + const ids = new Set(); + for (const raw of values) { + if (typeof raw !== "string") continue; + for (const match of raw.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]); + } + return Array.from(ids).sort(); +} + +function normalizeWorkbenchStatus(key, value) { + if (!/status|state|phase|result|lifecycle/iu.test(String(key || ""))) return null; + const text = String(value || "").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").replace(/^-+|-+$/gu, ""); + if (!text) return null; + if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(text)) return "completed"; + if (/^(failed|failure|error|errored)$/u.test(text)) return "failed"; + if (/^(canceled|cancelled|aborted|cancel)$/u.test(text)) return "canceled"; + if (/^(running|active|in-progress|in_progress|processing|streaming|executing)$/u.test(text)) return "running"; + if (/^(queued|pending|admitted|created|waiting)$/u.test(text)) return "pending"; + return text.slice(0, 80); +} + +function isWorkbenchTerminalStatus(value) { + return value === "completed" || value === "failed" || value === "canceled"; +} + +function isWorkbenchRunningStatus(value) { + return value === "running" || value === "pending"; +} + +function isWorkbenchTerminalText(value) { + return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(value || "")); +} + +function isLikelyWorkbenchFinalTextField(key, parent, value) { + const text = String(value || "").trim(); + if (!text) return false; + const field = String(key || ""); + if (!/final|assistant|response|content|markdown|text|message|output|result/iu.test(field)) return false; + const parentStatus = normalizeWorkbenchStatus("status", parent?.status ?? parent?.state ?? parent?.phase ?? parent?.result ?? ""); + const parentRole = String(parent?.role ?? parent?.author ?? parent?.dataRole ?? "").toLowerCase(); + return isWorkbenchTerminalStatus(parentStatus) || /assistant|agent|code/iu.test(parentRole) || /final|response|result/iu.test(field); +} + +function workbenchBodyPathKind(path) { + if (path === "/v1/agent/chat" || path === "/v1/agent/chat/steer") return "agent-chat-submit"; + if (path === "/v1/workbench/sessions") return "workbench-sessions"; + if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "workbench-session-messages"; + if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "workbench-turn"; + if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "workbench-trace-events"; + return "workbench"; +} + +function controlRecord(command, phase, detail) { + return { + ts: new Date().toISOString(), + seq: commandSeq, + phase, + commandId: command.id, + type: command.type, + source: command.source || "file", + input: commandInputSummary(command), + beforeUrl: command.beforeUrl || null, + afterUrl: currentPageUrl(), + pageId, + detail: sanitize(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, + afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null, + severity: command.severity || null, + alternateSessionStrategy: command.alternateSessionStrategy || null, + expectedSentinelRange: command.expectedSentinelRange || null, + expectedActionWaitMs: command.expectedActionWaitMs === null || command.expectedActionWaitMs === undefined || command.expectedActionWaitMs === "" ? null : Number(command.expectedActionWaitMs), + durationMs: command.durationMs === null || command.durationMs === undefined || command.durationMs === "" ? null : Number(command.durationMs), + requireComposerReady: command.requireComposerReady === true, + waitProjectManagementReady: command.waitProjectManagementReady === true, + findingId: command.findingId || null, + blocking: command.blocking === true ? true : command.blocking === false ? false : null, + sourceId: opaque(command.sourceId), + fileRef: opaque(command.fileRef), + filename: command.filename ? truncate(command.filename, 200) : null, + taskRef: opaque(command.taskRef), + taskId: command.taskId || null, + field: command.field || null, + link: command.link ? truncate(command.link, 200) : null, + titleHash: command.title ? sha256Text(command.title) : null, + titleBytes: command.title ? Buffer.byteLength(command.title) : null, + bodyHash: command.body ? sha256Text(command.body) : null, + bodyBytes: command.body ? Buffer.byteLength(command.body) : null, + status: command.status || null, + hwpodId: opaque(command.hwpodId), + nodeId: opaque(command.nodeId), + workspaceRoot: opaque(command.workspaceRoot), + root: opaque(command.root), + label: command.label ? truncate(command.label, 200) : null, + textHash: text === null ? null : sha256Text(text), + textBytes: text === null ? null : Buffer.byteLength(text), + textPreview: null, + valuesRedacted: true, + }; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index bd47c1f3..6cce66f5 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -2,6 +2,12 @@ // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // Responsibility: Source string for the pure-client HWLAB web-probe observer runner. import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source"; +import { nodeWebObserveRunnerPerformanceSource } from "./hwlab-node-web-observe-runner-performance-source"; +import { nodeWebObserveRunnerRuntimeSource } from "./hwlab-node-web-observe-runner-runtime-source"; +import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runner-control-source"; +import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source"; +import { nodeWebObserveRunnerSamplingSource } from "./hwlab-node-web-observe-runner-sampling-source"; +import { nodeWebObserveRunnerUtilitySource } from "./hwlab-node-web-observe-runner-utility-source"; export function nodeWebObserveRunnerSource(): string { return String.raw`#!/usr/bin/env node @@ -45,6 +51,8 @@ const dirs = { commandsDone: path.join(stateDir, "commands", "done"), commandsFailed: path.join(stateDir, "commands", "failed"), screenshots: path.join(stateDir, "screenshots"), + performance: path.join(stateDir, "performance"), + performanceCaptures: path.join(stateDir, "performance", "captures"), analysis: path.join(stateDir, "analysis"), archive: path.join(stateDir, "archive"), }; @@ -58,6 +66,7 @@ const files = { errors: path.join(stateDir, "errors.jsonl"), artifacts: path.join(stateDir, "artifacts.jsonl"), browserProcess: path.join(stateDir, "browser-process.jsonl"), + performanceEvents: path.join(stateDir, "performance-events.jsonl"), }; let browser; @@ -158,5663 +167,20 @@ try { if (browser) await browser.close().catch(() => {}); } -async function prepareDirs() { - await mkdir(stateDir, { recursive: true, mode: 0o700 }); - await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 }))); -} +${nodeWebObserveRunnerRuntimeSource()} -async function rotateExistingJsonlArtifacts() { - for (const [key, file] of Object.entries(files)) { - if (!file.endsWith(".jsonl")) continue; - let meta = null; - try { - meta = await stat(file); - } catch (error) { - if (error?.code === "ENOENT") continue; - throw error; - } - if (!meta?.isFile()) continue; - const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file)); - await rename(file, archiveFile); - jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size }); - } -} +${nodeWebObserveRunnerPerformanceSource()} -async function uniqueArchiveFile(name) { - let candidate = path.join(dirs.archive, name); - const parsed = path.parse(name); - for (let index = 1; ; index += 1) { - try { - await stat(candidate); - candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext); - } catch (error) { - if (error?.code === "ENOENT") return candidate; - throw error; - } - } -} - -function compactFileTimestamp(value) { - return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z"); -} - -async function writeManifest(extra = {}) { - const manifest = { - ok: extra.status !== "failed", - command: "web-probe-observe", - specRef, - jobId, - pid: process.pid, - stateDir, - baseUrl, - targetPath, - network: publicNetwork(playwrightProxy), - navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true }, - pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true }, - pageProvenance: compactPageProvenance(currentPageProvenance), - sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false }, - alertThresholds, - browserFreezePolicy, - projectManagement, - jsonlRotation, - commandDirs: dirs, - artifacts: files, - safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false }, - startedAt, - ...extra, - }; - await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); -} - -async function writeHeartbeat(extra = {}) { - const heartbeat = { - ok: terminalStatus !== "failed", - jobId, - pid: process.pid, - stateDir, - status: terminalStatus, - pageId, - observerPageId, - baseUrl, - currentUrl: currentPageUrl(), - observerUrl: pageUrl(observerPage), - observerRefreshIntervalMs, - lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null, - pageProvenance: compactPageProvenance(currentPageProvenance), - sampleSeq, - commandSeq, - activeCommandId, - updatedAt: new Date().toISOString(), - uptimeMs: Date.now() - startedAtMs, - ...extra, - }; - await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 }); -} - -function startHeartbeatPulse() { - const timer = setInterval(() => { - if (stopping) return; - void writeHeartbeat({ status: terminalStatus, heartbeatPulse: true }) - .catch((error) => appendJsonl(files.errors, eventRecord("heartbeat-pulse-error", { error: errorSummary(error) }))); - }, 5000); - if (timer && typeof timer.unref === "function") timer.unref(); - return timer; -} - -function startBrowserProcessMonitor() { - const intervalMs = Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000)); - let stopped = false; - let running = false; - const tick = async (reason) => { - if (stopped || running || stopping || browserFreezeBlocker) return; - running = true; - try { - await collectBrowserProcessSample(reason || "interval"); - } catch (error) { - await appendJsonl(files.errors, eventRecord("browser-process-monitor-error", { error: errorSummary(error), valuesRedacted: true })).catch(() => {}); - } finally { - running = false; - } - }; - void tick("startup"); - const timer = setInterval(() => { - void tick("interval"); - }, intervalMs); - if (timer && typeof timer.unref === "function") timer.unref(); - return () => { - stopped = true; - clearInterval(timer); - }; -} - -async function collectBrowserProcessSample(reason) { - browserProcessMonitorSeq += 1; - const tsMs = Date.now(); - const browserPid = browserProcessPid(); - const processSummary = await collectChromiumProcessSummary(browserPid); - const growth = updateBrowserProcessHistory(tsMs, processSummary); - const pages = []; - if (page && !page.isClosed()) { - pages.push(await collectBrowserPageRuntimeMetrics(page, { pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }).catch((error) => browserPageRuntimeMetricError("control", pageId, controlPageEpoch, error))); - } - if (observerPage && !observerPage.isClosed()) { - pages.push(await collectBrowserPageRuntimeMetrics(observerPage, { pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => browserPageRuntimeMetricError("observer", observerPageId, observerPageEpoch, error))); - } - const sample = eventRecord("browser-process-sample", { - seq: browserProcessMonitorSeq, - reason, - monitorIntervalMs: Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000)), - browserPid, - process: processSummary, - growth, - pages, - valuesRedacted: true, - }); - await appendJsonl(files.browserProcess, sample); - await enforceBrowserFreezePolicy(sample); -} - -async function enforceBrowserFreezePolicy(sample) { - if (browserFreezePolicy.enabled !== true || browserFreezeBlocker) return; - const processSummary = sample && typeof sample.process === "object" ? sample.process : {}; - const growth = sample && typeof sample.growth === "object" ? sample.growth : {}; - const totalRssMb = Number(processSummary.totalRssMb); - const processRssMb = Number(processSummary.maxProcessRssMb); - const totalGrowthMb = Number(growth.totalRssGrowthMb); - const processGrowthMb = Number(growth.maxProcessRssGrowthMb); - - for (const pageMetric of Array.isArray(sample.pages) ? sample.pages : []) { - const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {}; - const effectiveHeapUsedMb = Number(effectiveMemory.effectiveHeapUsedMb); - const effectiveJsHeapUsedMb = Number(effectiveMemory.effectiveJsHeapUsedMb); - const heapGrowthMb = Number(effectiveMemory.heapUsedGrowthMb); - const jsHeapGrowthMb = Number(effectiveMemory.jsHeapUsedGrowthMb); - if ( - (Number.isFinite(effectiveHeapUsedMb) && effectiveHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb) - || (Number.isFinite(effectiveJsHeapUsedMb) && effectiveJsHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb) - ) { - await triggerBrowserFreezeBlocker({ - kind: "memory-page-effective", - rootCause: "frontend_browser_page_effective_memory_pressure", - observed: { - pageRole: pageMetric?.pageRole ?? null, - pageId: pageMetric?.pageId ?? null, - pageEpoch: pageMetric?.pageEpoch ?? null, - totalRssMb, - processRssMb, - totalGrowthMb, - processGrowthMb, - effectiveHeapUsedMb: Number.isFinite(effectiveHeapUsedMb) ? effectiveHeapUsedMb : null, - effectiveJsHeapUsedMb: Number.isFinite(effectiveJsHeapUsedMb) ? effectiveJsHeapUsedMb : null, - baseline: pageMetric?.baseline ?? null, - valuesRedacted: true, - }, - threshold: { processRssBlockerMb: browserFreezePolicy.memory.processRssBlockerMb, policyScope: "per-page-effective-memory", valuesRedacted: true }, - sample: browserProcessSampleRef(sample), - page: browserPageMetricRef(pageMetric), - }); - return; - } - if ( - (Number.isFinite(heapGrowthMb) && heapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb) - || (Number.isFinite(jsHeapGrowthMb) && jsHeapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb) - ) { - await triggerBrowserFreezeBlocker({ - kind: "memory-page-effective-growth", - rootCause: "frontend_browser_page_memory_leak_or_unbounded_render_growth", - observed: { - pageRole: pageMetric?.pageRole ?? null, - pageId: pageMetric?.pageId ?? null, - pageEpoch: pageMetric?.pageEpoch ?? null, - totalRssMb, - processRssMb, - totalGrowthMb, - processGrowthMb, - heapGrowthMb: Number.isFinite(heapGrowthMb) ? heapGrowthMb : null, - jsHeapGrowthMb: Number.isFinite(jsHeapGrowthMb) ? jsHeapGrowthMb : null, - baseline: pageMetric?.baseline ?? null, - valuesRedacted: true, - }, - threshold: { growthBlockerMb: browserFreezePolicy.memory.growthBlockerMb, windowMs: browserFreezePolicy.blockerWindowMs, policyScope: "per-page-effective-memory", valuesRedacted: true }, - sample: browserProcessSampleRef(sample), - page: browserPageMetricRef(pageMetric), - }); - return; - } - const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {}; - const responsivenessLatencyMs = Number(responsiveness.latencyMs); - if (responsiveness.timeout === true || (Number.isFinite(responsivenessLatencyMs) && responsivenessLatencyMs >= browserFreezePolicy.responsiveness.latencyBlockerMs)) { - const signal = recordBrowserFreezeSignal("playwright-responsiveness", sample, pageMetric, { - rootCause: "frontend_browser_page_unresponsive_to_playwright", - observed: { - responsivenessLatencyMs: Number.isFinite(responsivenessLatencyMs) ? responsivenessLatencyMs : null, - responsivenessTimeout: responsiveness.timeout === true, - valuesRedacted: true, - }, - threshold: { - latencyBlockerMs: browserFreezePolicy.responsiveness.latencyBlockerMs, - eventBlockerCount: browserFreezePolicy.responsiveness.eventBlockerCount, - windowMs: browserFreezePolicy.blockerWindowMs, - valuesRedacted: true, - }, - }); - if (signal.burst.length >= browserFreezePolicy.responsiveness.eventBlockerCount) { - await triggerBrowserFreezeBlocker(signal); - return; - } - } - const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {}; - const calls = Array.isArray(cdp.calls) ? cdp.calls : []; - const metricTimeoutCalls = calls.filter((call) => call?.timeout === true && call?.method !== "Runtime.evaluate"); - const sessionTimeoutCount = calls.length === 0 ? Number(cdp.timeoutCount || 0) : 0; - const metricTimeoutCount = metricTimeoutCalls.length + (Number.isFinite(sessionTimeoutCount) ? sessionTimeoutCount : 0); - if (metricTimeoutCount > 0) { - const signal = recordBrowserFreezeSignal("cdp-metrics-timeout", sample, pageMetric, { - rootCause: "frontend_browser_cdp_metrics_unresponsive", - observed: { - cdpMetricsTimeoutCount: metricTimeoutCount, - methods: metricTimeoutCalls.map((call) => call.method || "unknown").slice(0, 8), - valuesRedacted: true, - }, - threshold: { - metricsTimeoutBlockerCount: browserFreezePolicy.cdp.metricsTimeoutBlockerCount, - windowMs: browserFreezePolicy.blockerWindowMs, - valuesRedacted: true, - }, - }); - if (signal.burst.length >= browserFreezePolicy.cdp.metricsTimeoutBlockerCount) { - await triggerBrowserFreezeBlocker(signal); - return; - } - } - } -} - -function recordBrowserFreezeSignal(kind, sample, pageMetric, detail) { - const tsMs = Date.parse(String(sample?.ts || "")); - const signal = { - kind, - ts: sample?.ts ?? new Date().toISOString(), - tsMs: Number.isFinite(tsMs) ? tsMs : Date.now(), - pageRole: pageMetric?.pageRole ?? null, - pageId: pageMetric?.pageId ?? null, - pageEpoch: pageMetric?.pageEpoch ?? null, - sample: browserProcessSampleRef(sample), - page: browserPageMetricRef(pageMetric), - ...detail, - valuesRedacted: true, - }; - browserFreezeSignalHistory.push(signal); - const windowMs = browserFreezePolicy.blockerWindowMs; - const cutoff = signal.tsMs - windowMs; - while (browserFreezeSignalHistory.length > 0 && Number(browserFreezeSignalHistory[0].tsMs || 0) < cutoff) browserFreezeSignalHistory.shift(); - return { - kind, - rootCause: detail.rootCause, - observed: detail.observed, - threshold: detail.threshold, - sample: browserProcessSampleRef(sample), - page: browserPageMetricRef(pageMetric), - burst: browserFreezeSignalHistory.filter((item) => item.kind === kind && item.tsMs >= cutoff).slice(-20), - valuesRedacted: true, - }; -} - -async function triggerBrowserFreezeBlocker(trigger) { - if (browserFreezeBlocker) return; - stopping = true; - terminalStatus = "failed"; - const base = { - id: "browser-freeze-policy-blocker", - severity: "red", - blocking: true, - kind: trigger.kind, - summary: "web-probe runner matched YAML browserFreezePolicy and stopped the browser; this run must stay red instead of refreshing or falling back", - rootCause: trigger.rootCause, - rootCauseStatus: "confirmed-from-runner-browser-freeze-policy", - rootCauseConfidence: "high", - fallbackAllowed: false, - observerRefreshAllowed: false, - policySource: "config/hwlab-node-lanes.yaml#webProbe.browserFreezePolicy via UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON", - observed: trigger.observed || null, - threshold: trigger.threshold || null, - sample: trigger.sample || null, - page: trigger.page || null, - burst: Array.isArray(trigger.burst) ? trigger.burst.slice(0, 20) : [], - valuesRedacted: true, - }; - browserFreezeBlocker = { ...base, browserKill: { ok: false, pending: true, valuesRedacted: true }, valuesRedacted: true }; - const browserKill = browserFreezePolicy.kill.enabled === true - ? await terminateBrowserForFreezeBlocker(base).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true })) - : { ok: true, skipped: true, reason: "kill-disabled-by-yaml-policy", valuesRedacted: true }; - browserFreezeBlocker = { ...base, browserKill, valuesRedacted: true }; - await appendJsonl(files.browserProcess, eventRecord("browser-freeze-blocker", browserFreezeBlocker)).catch(() => {}); - await appendJsonl(files.errors, eventRecord("browser-freeze-blocker", { - ...browserFreezeBlocker, - error: { - name: "BrowserFreezeBlocker", - message: "YAML browserFreezePolicy matched: " + String(trigger.kind || "unknown"), - details: browserFreezeBlocker, - valuesRedacted: true, - }, - })).catch(() => {}); - await writeHeartbeat({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {}); - await writeManifest({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {}); -} - -async function terminateBrowserForFreezeBlocker(blocker) { - const childProcess = browser && typeof browser.process === "function" ? browser.process() : null; - const pid = Number(childProcess?.pid); - if (!Number.isFinite(pid) || pid <= 0) { - return { ok: false, reason: "browser-process-unavailable", blockerKind: blocker.kind, valuesRedacted: true }; - } - const result = { - ok: false, - pid: Math.floor(pid), - gracefulSignal: browserFreezePolicy.kill.gracefulSignal, - forceSignal: browserFreezePolicy.kill.forceSignal, - graceMs: browserFreezePolicy.kill.graceMs, - pollIntervalMs: browserFreezePolicy.kill.pollIntervalMs, - gracefulSent: false, - forceSent: false, - exitedAfterGrace: false, - exitedAfterForce: false, - valuesRedacted: true, - }; - try { - childProcess.kill(browserFreezePolicy.kill.gracefulSignal); - result.gracefulSent = true; - } catch (error) { - result.gracefulError = errorSummary(error); - } - result.exitedAfterGrace = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs); - if (!result.exitedAfterGrace) { - try { - childProcess.kill(browserFreezePolicy.kill.forceSignal); - result.forceSent = true; - } catch (error) { - result.forceError = errorSummary(error); - } - result.exitedAfterForce = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs); - } - result.ok = result.exitedAfterGrace || result.exitedAfterForce; - return result; -} - -async function waitForPidExit(pid, timeoutMs, pollIntervalMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (!pidAlive(pid)) return true; - await sleep(pollIntervalMs); - } - return !pidAlive(pid); -} - -function pidAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function browserProcessSampleRef(sample) { - return { - ts: sample?.ts ?? null, - seq: sample?.seq ?? null, - sampleSeq: sample?.sampleSeq ?? null, - browserPid: sample?.browserPid ?? null, - totalRssMb: sample?.process?.totalRssMb ?? null, - maxProcessRssMb: sample?.process?.maxProcessRssMb ?? null, - totalRssGrowthMb: sample?.growth?.totalRssGrowthMb ?? null, - maxProcessRssGrowthMb: sample?.growth?.maxProcessRssGrowthMb ?? null, - valuesRedacted: true, - }; -} - -function browserPageMetricRef(pageMetric) { - const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {}; - const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {}; - const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {}; - return { - pageRole: pageMetric?.pageRole ?? null, - pageId: pageMetric?.pageId ?? null, - pageEpoch: pageMetric?.pageEpoch ?? null, - timeoutMs: pageMetric?.timeoutMs ?? null, - responsivenessLatencyMs: responsiveness.latencyMs ?? null, - responsivenessTimeout: responsiveness.timeout === true, - cdpTimeoutCount: cdp.timeoutCount ?? null, - cdpErrorCount: cdp.errorCount ?? null, - effectiveHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveHeapUsedMb)) ? Number(effectiveMemory.effectiveHeapUsedMb) : null, - effectiveJsHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveJsHeapUsedMb)) ? Number(effectiveMemory.effectiveJsHeapUsedMb) : null, - heapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.heapUsedGrowthMb)) ? Number(effectiveMemory.heapUsedGrowthMb) : null, - jsHeapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.jsHeapUsedGrowthMb)) ? Number(effectiveMemory.jsHeapUsedGrowthMb) : null, - baselineCapturedAt: pageMetric?.baseline?.capturedAt ?? null, - valuesRedacted: true, - }; -} - -function browserProcessPid() { - try { - const childProcess = browser && typeof browser.process === "function" ? browser.process() : null; - const pid = Number(childProcess?.pid); - return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : null; - } catch { - return null; - } -} - -async function collectChromiumProcessSummary(browserPid) { - const all = await readProcProcessTable(); - const childrenByPpid = new Map(); - for (const item of all) { - if (!Number.isFinite(item.ppid)) continue; - const list = childrenByPpid.get(item.ppid) || []; - list.push(item.pid); - childrenByPpid.set(item.ppid, list); - } - const roots = [process.pid, browserPid].filter((pid, index, items) => Number.isFinite(Number(pid)) && Number(pid) > 0 && items.indexOf(pid) === index).map((pid) => Number(pid)); - const descendants = new Set(roots); - const queue = roots.slice(); - while (queue.length > 0) { - const pid = queue.shift(); - for (const child of childrenByPpid.get(pid) || []) { - if (descendants.has(child)) continue; - descendants.add(child); - queue.push(child); - } - } - const processes = all - .filter((item) => descendants.has(item.pid)) - .map((item) => ({ ...item, role: classifyChromiumProcess(item) })) - .filter((item) => item.role) - .map((item) => ({ - pid: item.pid, - ppid: item.ppid, - name: item.name || null, - role: item.role, - rssBytes: item.rssBytes, - vmSizeBytes: item.vmSizeBytes, - commandHash: item.cmdline ? sha256Text(item.cmdline) : null, - commandPreview: redactProcessCommandPreview(item.cmdline), - valuesRedacted: true, - })) - .sort((a, b) => Number(b.rssBytes || 0) - Number(a.rssBytes || 0)); - const roles = {}; - for (const item of processes) { - const role = item.role || "unknown"; - const summary = roles[role] || { count: 0, rssBytes: 0, maxRssBytes: 0 }; - summary.count += 1; - summary.rssBytes += Number(item.rssBytes || 0); - summary.maxRssBytes = Math.max(summary.maxRssBytes, Number(item.rssBytes || 0)); - roles[role] = summary; - } - const totalRssBytes = processes.reduce((sum, item) => sum + Number(item.rssBytes || 0), 0); - const maxProcess = processes[0] || null; - return { - sampledRootPids: roots, - chromiumProcessCount: processes.length, - totalRssBytes, - totalRssMb: bytesToMb(totalRssBytes), - maxProcessRssBytes: maxProcess ? Number(maxProcess.rssBytes || 0) : 0, - maxProcessRssMb: maxProcess ? bytesToMb(maxProcess.rssBytes) : 0, - maxProcess, - roles, - processes: processes.slice(0, 20), - valuesRedacted: true, - }; -} - -async function readProcProcessTable() { - let entries = []; - try { - entries = await readdir("/proc"); - } catch { - return []; - } - const numeric = entries.map((name) => Number(name)).filter((pid) => Number.isInteger(pid) && pid > 0); - const rows = await Promise.all(numeric.map((pid) => readOneProcProcess(pid).catch(() => null))); - return rows.filter(Boolean); -} - -async function readOneProcProcess(pid) { - const [statText, statusText, cmdlineText] = await Promise.all([ - readFile(path.join("/proc", String(pid), "stat"), "utf8").catch(() => ""), - readFile(path.join("/proc", String(pid), "status"), "utf8").catch(() => ""), - readFile(path.join("/proc", String(pid), "cmdline"), "utf8").catch(() => ""), - ]); - if (!statText && !statusText) return null; - const ppid = procPpidFromStat(statText); - const name = procStatusField(statusText, "Name") || null; - const rssKb = procStatusKb(statusText, "VmRSS"); - const vmSizeKb = procStatusKb(statusText, "VmSize"); - return { - pid, - ppid, - name, - cmdline: String(cmdlineText || "").replace(/\0/gu, " ").replace(/\s+/gu, " ").trim(), - rssBytes: Number.isFinite(rssKb) ? rssKb * 1024 : 0, - vmSizeBytes: Number.isFinite(vmSizeKb) ? vmSizeKb * 1024 : 0, - }; -} - -function procPpidFromStat(value) { - const text = String(value || ""); - const end = text.lastIndexOf(")"); - if (end < 0) return null; - const parts = text.slice(end + 1).trim().split(/\s+/u); - const ppid = Number(parts[1]); - return Number.isFinite(ppid) ? ppid : null; -} - -function procStatusField(value, key) { - const prefix = String(key || "") + ":"; - for (const line of String(value || "").split(/\n/u)) { - if (line.startsWith(prefix)) return line.slice(prefix.length).trim(); - } - return null; -} - -function procStatusKb(value, key) { - const raw = procStatusField(value, key); - const match = String(raw || "").match(/^(\d+)\s+kB$/u); - return match ? Number(match[1]) : null; -} - -function classifyChromiumProcess(item) { - const cmdline = String(item?.cmdline || ""); - const combined = (cmdline + " " + String(item?.name || "")).toLowerCase(); - if (!/(?:chromium|chrome|headless_shell)/u.test(combined)) return null; - if (/--type=renderer\b/u.test(cmdline)) return "renderer"; - if (/--type=gpu-process\b/u.test(cmdline)) return "gpu"; - if (/--type=utility\b/u.test(cmdline)) return "utility"; - if (/--type=zygote\b/u.test(cmdline)) return "zygote"; - if (/--type=broker\b/u.test(cmdline)) return "broker"; - if (/--type=/u.test(cmdline)) return "other"; - return "browser"; -} - -function redactProcessCommandPreview(value) { - const text = String(value || ""); - if (!text) return null; - const parts = text.split(/\s+/u).slice(0, 18).map((part) => { - if (/--(?:proxy|token|secret|password|cookie|auth|key)[^=]*=/iu.test(part)) return part.replace(/=.*/u, "=[redacted]"); - return part.replace(/:\/\/[^/@\s]+@/u, "://[redacted]@"); - }); - return truncate(parts.join(" "), 260); -} - -function updateBrowserProcessHistory(tsMs, processSummary) { - const windowMs = Math.max(1000, Number(alertThresholds.browserRssGrowthWindowMs) || 30000); - const totalRssBytes = Number(processSummary?.totalRssBytes || 0); - const maxProcessRssBytes = Number(processSummary?.maxProcessRssBytes || 0); - browserProcessHistory.push({ tsMs, totalRssBytes, maxProcessRssBytes }); - const retentionMs = Math.max(windowMs * 3, 180000); - while (browserProcessHistory.length > 0 && tsMs - browserProcessHistory[0].tsMs > retentionMs) browserProcessHistory.shift(); - const windowStartMs = tsMs - windowMs; - const candidates = browserProcessHistory.filter((item) => item.tsMs >= windowStartMs && item.tsMs <= tsMs); - const baseline = candidates[0] || browserProcessHistory[0] || null; - const totalRssGrowthBytes = baseline ? totalRssBytes - Number(baseline.totalRssBytes || 0) : 0; - const maxProcessRssGrowthBytes = baseline ? maxProcessRssBytes - Number(baseline.maxProcessRssBytes || 0) : 0; - return { - windowMs, - baselineAt: baseline ? new Date(baseline.tsMs).toISOString() : null, - baselineTotalRssBytes: baseline ? baseline.totalRssBytes : null, - baselineMaxProcessRssBytes: baseline ? baseline.maxProcessRssBytes : null, - totalRssGrowthBytes, - totalRssGrowthMb: bytesToMb(totalRssGrowthBytes), - maxProcessRssGrowthBytes, - maxProcessRssGrowthMb: bytesToMb(maxProcessRssGrowthBytes), - valuesRedacted: true, - }; -} - -async function collectBrowserPageRuntimeMetrics(targetPage, { pageRole, targetPageId, pageEpoch }) { - const timeoutMs = Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)); - const startedAtMs = Date.now(); - let session = null; - const result = { - pageRole, - pageId: targetPageId, - pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, - url: pageUrl(targetPage), - timeoutMs, - responsiveness: null, - cdp: { timeoutCount: 0, errorCount: 0, calls: [] }, - valuesRedacted: true, - }; - try { - session = await withHardTimeout(targetPage.context().newCDPSession(targetPage), timeoutMs, "newCDPSession exceeded " + timeoutMs + "ms"); - const enable = await timedCdpSend(session, "Performance.enable", {}, timeoutMs); - if (enable.ok !== true) result.cdp.calls.push({ method: "Performance.enable", ...enable }); - result.responsiveness = await timedCdpSend(session, "Runtime.evaluate", { expression: "1", returnByValue: true }, timeoutMs); - result.cdp.calls.push({ method: "Runtime.evaluate", ...result.responsiveness }); - const performance = await timedCdpSend(session, "Performance.getMetrics", {}, timeoutMs); - result.cdp.calls.push({ method: "Performance.getMetrics", ...performance }); - result.performance = performance.value; - const heap = await timedCdpSend(session, "Runtime.getHeapUsage", {}, timeoutMs); - result.cdp.calls.push({ method: "Runtime.getHeapUsage", ...heap }); - result.heapUsage = heap.value; - const domCounters = await timedCdpSend(session, "Memory.getDOMCounters", {}, timeoutMs); - result.cdp.calls.push({ method: "Memory.getDOMCounters", ...domCounters }); - result.domCounters = domCounters.value; - result.cdp.timeoutCount = result.cdp.calls.filter((item) => item.timeout === true).length; - result.cdp.errorCount = result.cdp.calls.filter((item) => item.ok !== true).length; - } catch (error) { - result.sessionError = errorSummary(error); - result.cdp.timeoutCount = isTimeoutErrorMessage(error?.message) ? 1 : 0; - result.cdp.errorCount = 1; - } finally { - result.latencyMs = Date.now() - startedAtMs; - applyBrowserPageRuntimeBaseline(result); - if (session) await withHardTimeout(session.detach(), 1000, "CDP session detach exceeded 1000ms").catch(() => {}); - } - return result; -} - -function applyBrowserPageRuntimeBaseline(result) { - const key = [result.pageRole || "unknown", result.pageId || "unknown", Number.isFinite(Number(result.pageEpoch)) ? Number(result.pageEpoch) : 0].join(":"); - const current = browserPageRuntimeMemorySnapshot(result); - const existing = browserPageRuntimeBaselines.get(key); - if (!existing && current) browserPageRuntimeBaselines.set(key, { ...current, capturedAt: new Date().toISOString(), source: "first-page-runtime-sample", valuesRedacted: true }); - const baseline = browserPageRuntimeBaselines.get(key) || null; - result.baseline = baseline ? { - capturedAt: baseline.capturedAt, - source: baseline.source, - heapUsedMb: baseline.heapUsedMb, - jsHeapUsedMb: baseline.jsHeapUsedMb, - domNodes: baseline.domNodes, - valuesRedacted: true, - } : null; - result.effectiveMemory = browserPageEffectiveMemory(current, baseline); -} - -function browserPageRuntimeMemorySnapshot(result) { - const heapUsedBytes = Number(result?.heapUsage?.usedSize); - const metrics = result?.performance?.metrics && typeof result.performance.metrics === "object" ? result.performance.metrics : {}; - const jsHeapUsedBytes = Number(metrics.JSHeapUsedSize); - const domNodes = Number(result?.domCounters?.nodes ?? metrics.Nodes); - if (!Number.isFinite(heapUsedBytes) && !Number.isFinite(jsHeapUsedBytes) && !Number.isFinite(domNodes)) return null; - return { - heapUsedBytes: Number.isFinite(heapUsedBytes) ? heapUsedBytes : null, - heapUsedMb: Number.isFinite(heapUsedBytes) ? bytesToMb(heapUsedBytes) : null, - jsHeapUsedBytes: Number.isFinite(jsHeapUsedBytes) ? jsHeapUsedBytes : null, - jsHeapUsedMb: Number.isFinite(jsHeapUsedBytes) ? bytesToMb(jsHeapUsedBytes) : null, - domNodes: Number.isFinite(domNodes) ? domNodes : null, - valuesRedacted: true, - }; -} - -function browserPageEffectiveMemory(current, baseline) { - if (!current) return { available: false, baselineAvailable: Boolean(baseline), valuesRedacted: true }; - const heapUsedGrowthBytes = numericDelta(current.heapUsedBytes, baseline?.heapUsedBytes); - const jsHeapUsedGrowthBytes = numericDelta(current.jsHeapUsedBytes, baseline?.jsHeapUsedBytes); - const domNodesGrowth = numericDelta(current.domNodes, baseline?.domNodes); - return { - available: true, - baselineAvailable: Boolean(baseline), - heapUsedMb: current.heapUsedMb, - jsHeapUsedMb: current.jsHeapUsedMb, - effectiveHeapUsedMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : current.heapUsedMb, - effectiveJsHeapUsedMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : current.jsHeapUsedMb, - heapUsedGrowthMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : null, - jsHeapUsedGrowthMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : null, - domNodes: current.domNodes, - domNodesGrowth: Number.isFinite(domNodesGrowth) ? domNodesGrowth : null, - valuesRedacted: true, - }; -} - -function numericDelta(current, baseline) { - const value = Number(current); - const base = Number(baseline); - if (!Number.isFinite(value) || !Number.isFinite(base)) return null; - return value - base; -} - -function browserPageRuntimeMetricError(pageRole, targetPageId, pageEpoch, error) { - return { - pageRole, - pageId: targetPageId, - pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, - url: null, - timeoutMs: Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)), - responsiveness: { ok: false, timeout: isTimeoutErrorMessage(error?.message), error: errorSummary(error), valuesRedacted: true }, - cdp: { timeoutCount: isTimeoutErrorMessage(error?.message) ? 1 : 0, errorCount: 1, calls: [] }, - valuesRedacted: true, - }; -} - -async function timedCdpSend(session, method, params, timeoutMs) { - const startedAtMs = Date.now(); - try { - const value = await withHardTimeout(session.send(method, params || {}), timeoutMs, method + " exceeded " + timeoutMs + "ms"); - return { ok: true, timeout: false, latencyMs: Date.now() - startedAtMs, value: compactCdpValue(method, value), valuesRedacted: true }; - } catch (error) { - return { ok: false, timeout: isTimeoutErrorMessage(error?.message), latencyMs: Date.now() - startedAtMs, error: errorSummary(error), valuesRedacted: true }; - } -} - -function compactCdpValue(method, value) { - if (!value || typeof value !== "object") return value ?? null; - if (method === "Performance.getMetrics") { - const wanted = new Set(["Timestamp", "Documents", "Frames", "JSEventListeners", "Nodes", "LayoutCount", "RecalcStyleCount", "LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "TaskDuration", "JSHeapUsedSize", "JSHeapTotalSize"]); - const metrics = {}; - for (const item of Array.isArray(value.metrics) ? value.metrics : []) { - if (wanted.has(item?.name)) metrics[item.name] = Number.isFinite(Number(item?.value)) ? Number(item.value) : null; - } - return { metricCount: Array.isArray(value.metrics) ? value.metrics.length : 0, metrics, valuesRedacted: true }; - } - if (method === "Runtime.getHeapUsage") { - return { - usedSize: Number.isFinite(Number(value.usedSize)) ? Number(value.usedSize) : null, - totalSize: Number.isFinite(Number(value.totalSize)) ? Number(value.totalSize) : null, - embedderHeapUsedSize: Number.isFinite(Number(value.embedderHeapUsedSize)) ? Number(value.embedderHeapUsedSize) : null, - valuesRedacted: true, - }; - } - if (method === "Memory.getDOMCounters") { - return { - documents: Number.isFinite(Number(value.documents)) ? Number(value.documents) : null, - nodes: Number.isFinite(Number(value.nodes)) ? Number(value.nodes) : null, - jsEventListeners: Number.isFinite(Number(value.jsEventListeners)) ? Number(value.jsEventListeners) : null, - valuesRedacted: true, - }; - } - if (method === "Runtime.evaluate") { - return { resultType: value.result?.type ?? null, unserializableValue: value.result?.unserializableValue ?? null, exceptionDetails: value.exceptionDetails ? { text: truncate(value.exceptionDetails.text || "", 200), valuesRedacted: true } : null, valuesRedacted: true }; - } - return { valuesRedacted: true }; -} - -function isTimeoutErrorMessage(value) { - return /timeout|timed\s*out|exceeded\s+\d+\s*ms/iu.test(String(value || "")); -} - -function bytesToMb(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - return Number((numeric / 1024 / 1024).toFixed(1)); -} - -function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) { - targetPage.on("request", (request) => { - void appendJsonl(files.network, eventRecord("request", { - pageRole, - pageId: targetPageId, - observerInitiated: false, - commandId: activeCommandId, - method: request.method(), - url: safeUrl(request.url()), - resourceType: request.resourceType(), - frameUrl: safeFrameUrl(request.frame()), - })); - }); - targetPage.on("response", (response) => { - const request = response.request(); - const base = { - pageRole, - pageId: targetPageId, - sampleSeq, - observerInitiated: false, - commandId: activeCommandId, - method: request.method(), - url: safeUrl(response.url()), - resourceType: request.resourceType(), - status: response.status(), - statusText: response.statusText(), - fromServiceWorker: response.fromServiceWorker(), - }; - void (async () => { - const bodyFields = await summarizeWorkbenchResponseBody(response, request); - await appendJsonl(files.network, eventRecord("response", { ...base, ...bodyFields })); - })().catch((error) => appendJsonl(files.errors, eventRecord("response-body-summary-error", { - pageRole, - pageId: targetPageId, - sampleSeq, - commandId: activeCommandId, - method: request.method(), - url: safeUrl(response.url()), - error: errorSummary(error), - valuesRedacted: true - }))); - }); - targetPage.on("requestfailed", (request) => { - void appendJsonl(files.network, eventRecord("requestfailed", { - pageRole, - pageId: targetPageId, - observerInitiated: false, - commandId: activeCommandId, - method: request.method(), - url: safeUrl(request.url()), - resourceType: request.resourceType(), - failure: request.failure()?.errorText ?? null, - })); - }); - targetPage.on("console", (message) => { - void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() })); - }); - targetPage.on("pageerror", (error) => { - void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) })); - }); - targetPage.on("crash", () => { - void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId })); - }); - targetPage.on("close", () => { - void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" })); - }); -} - -async function drainOneCommand() { - const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort(); - const name = entries[0]; - if (!name) return; - const pending = path.join(dirs.commandsPending, name); - const processing = path.join(dirs.commandsProcessing, name); - await rename(pending, processing).catch(() => null); - const raw = await readFile(processing, "utf8"); - const command = JSON.parse(raw); - const id = safeId(command.id || name.replace(/[.]json$/u, "")); - command.id = id; - const stopCommandSampler = startCommandActiveSampler(command); - try { - const result = await processCommand(command); - const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) }; - await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 }); - await appendJsonl(files.control, controlRecord(command, "completed", done.result)); - await unlink(processing).catch(() => {}); - } catch (error) { - const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false }) - .then(() => ({ ok: true, sampleSeq, valuesRedacted: true })) - .catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true })); - const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample }; - await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 }); - await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample })); - await unlink(processing).catch(() => {}); - } finally { - stopCommandSampler(); - activeCommandId = null; - await writeHeartbeat({ status: terminalStatus }); - } -} - -function startCommandActiveSampler(command) { - const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000); - const heartbeatIntervalMs = Math.min(5000, intervalMs); - let stopped = false; - let timer = null; - let heartbeatTimer = null; - let inFlight = false; - const heartbeat = () => { - if (stopped) return; - void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true }) - .catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))); - }; - const schedule = () => { - if (stopped) return; - timer = setTimeout(tick, intervalMs); - if (timer && typeof timer.unref === "function") timer.unref(); - }; - const tick = () => { - if (stopped) return; - if (inFlight) { - schedule(); - return; - } - inFlight = true; - samplePage("command-active", { refreshObserver: false, screenshot: false }) - .catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))) - .finally(() => { - inFlight = false; - schedule(); - }); - }; - heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs); - if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref(); - schedule(); - return () => { - stopped = true; - if (timer) clearTimeout(timer); - if (heartbeatTimer) clearInterval(heartbeatTimer); - }; -} - -async function processCommand(command) { - commandSeq += 1; - activeCommandId = command.id; - await writeHeartbeat({ status: "running", activeCommandId }); - await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command))); - switch (command.type) { - case "login": return authenticate(context); - case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount"); - case "logout": return withObserverSync(await logoutAccount(command), "logout"); - case "listSessions": return withObserverSync(await listSessions(command), "listSessions"); - case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions"); - case "preflight": return preflightSummary(); - case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); - case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); - case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), { - expectedAction: "turn", - responsePath: "/v1/agent/chat", - alternateResponsePaths: ["/v1/agent/chat/steer"], - noActiveReason: "send-no-turn-composer", - throwOnActionMismatch: true, - expectedActionWaitMs: command.expectedActionWaitMs, - }), "sendPrompt"); - case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer"); - 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 "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession"); - case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack"); - case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant"); - case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo"); - case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command); - case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command); - case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command); - case "probeMdtodoSource": return probeMdtodoSource(command); - case "reindexMdtodoSource": return reindexMdtodoSource(command); - case "selectProjectSource": return selectProjectSource(command); - case "selectMdtodoSource": return selectMdtodoSource(command); - case "selectMdtodoFile": return selectMdtodoFile(command); - case "selectMdtodoTask": return selectMdtodoTask(command); - case "expandMdtodoTask": return expandMdtodoTask(command); - case "openMdtodoReportPreview": return openMdtodoReportPreview(command); - case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command); - case "editMdtodoTaskInline": return editMdtodoTaskInline(command); - case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command); - case "editMdtodoTaskBody": return editMdtodoTaskBody(command); - case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command); - case "addMdtodoRootTask": return addMdtodoRootTask(command); - case "addMdtodoSubTask": return addMdtodoSubTask(command); - case "continueMdtodoTask": return continueMdtodoTask(command); - case "deleteMdtodoTask": return deleteMdtodoTask(command); - case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask"); - case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo"); - case "screenshot": return captureCommandScreenshot(command); - case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId }; - case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId }; - default: throw new Error("unsupported observer command type: " + command.type); - } -} - -async function withObserverSync(result, reason) { - return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) }; -} - -async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) { - if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" }; - const forceRefresh = options?.forceRefresh === true; - const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000; - const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000; - const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 15000; - const shortCircuitReadinessTimeoutMs = Number.isFinite(Number(options.shortCircuitReadinessTimeoutMs)) ? Math.max(1, Number(options.shortCircuitReadinessTimeoutMs)) : 1000; - const shortCircuitHydrationTimeoutMs = Number.isFinite(Number(options.shortCircuitHydrationTimeoutMs)) ? Math.max(1, Number(options.shortCircuitHydrationTimeoutMs)) : 1000; - const snapshot = await workbenchSessionSnapshot(); - const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl()); - const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath; - const targetUrl = new URL(target, baseUrl).toString(); - const beforeUrl = pageUrl(observerPage); - const beforeSessionId = routeSessionIdFromUrl(beforeUrl); - const attempts = []; - if (sessionId && beforeSessionId === sessionId && !forceRefresh) { - const current = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: shortCircuitReadinessTimeoutMs, hydrationTimeoutMs: shortCircuitHydrationTimeoutMs }); - if (current.ok) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, readiness: current.readiness, hydration: current.hydration, valuesRedacted: true }; - attempts.push({ attempt: 0, ok: false, shortCircuitRejected: true, failureKind: current.failureKind, readiness: current.readiness, hydration: current.hydration, beforeUrl, afterUrl: pageUrl(observerPage), valuesRedacted: true }); - } - const maxAttempts = Number.isFinite(Number(options?.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const attemptBeforeUrl = pageUrl(observerPage); - observerPageEpoch += 1; - let status = null; - let statusText = null; - const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }).catch((error) => ({ observerGotoError: errorSummary(error) })); - if (response?.observerGotoError) { - attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(response.observerGotoError?.message || response.observerGotoError?.name || "observer-navigation-error"), beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), error: response.observerGotoError, valuesRedacted: true }); - if (attempt < maxAttempts && isRetryableNavigationError(response.observerGotoError?.message || response.observerGotoError?.name || "")) { - await recreateObserverPageForNavigation("observer-goto-error", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); - continue; - } - lastObserverRefreshAtMs = Date.now(); - return { ok: false, reason, changed: false, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, attempts, valuesRedacted: true }; - } - status = typeof response?.status === "function" ? response.status() : null; - statusText = typeof response?.statusText === "function" ? response.statusText() : null; - const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: readinessTimeoutMs }); - if (!readiness.ok) { - attempts.push({ attempt, ok: false, failureKind: readiness.reason || "observer-target-not-ready", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true }); - if (attempt < maxAttempts && observerReadinessRetryable(readiness)) { - await recreateObserverPageForNavigation(readiness.reason || "observer-target-not-ready", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); - continue; - } - 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, attempts, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true }; - } - if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) { - lastObserverRefreshAtMs = Date.now(); - attempts.push({ attempt, ok: true, beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true }); - 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, attempts, valuesRedacted: true }; - } - const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: hydrationTimeoutMs }); - attempts.push({ attempt, ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, hydration, valuesRedacted: true }); - if (hydration.ok === true) { - lastObserverRefreshAtMs = Date.now(); - return { ok: true, 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, attempts, failureKind: null, valuesRedacted: true }; - } - if (attempt < maxAttempts && observerHydrationRetryable(hydration)) { - await recreateObserverPageForNavigation(hydration.reason || "observer-session-hydration-failed", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); - continue; - } - 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, attempts, failureKind: hydration.reason || "observer-session-hydration-failed", valuesRedacted: true }; - } - 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, attempts, failureKind: "observer-sync-retry-exhausted", valuesRedacted: true }; -} - -async function observerSessionReadiness(targetUrl, sessionId, options = {}) { - const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: options.readinessTimeoutMs ?? 1000 }); - if (!readiness.ok) return { ok: false, failureKind: readiness.reason || "observer-target-not-ready", readiness, hydration: null, valuesRedacted: true }; - if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) return { ok: true, readiness, hydration: null, valuesRedacted: true }; - const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: options.hydrationTimeoutMs ?? 1000 }); - return { ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", readiness, hydration, valuesRedacted: true }; -} - -function observerReadinessRetryable(readiness) { - const reason = String(readiness?.reason || ""); - const snapshot = readiness?.snapshot || {}; - return /workbench-app-not-ready|observer-target-not-ready/iu.test(reason) - || snapshot.workbenchShellVisible === false - || snapshot.sessionRailPresent === false && snapshot.commandInputPresent === false; -} - -function observerHydrationRetryable(hydration) { - const reason = String(hydration?.reason || ""); - return /observer-session-hydration-timeout|observer-session-hydration-failed|active-session-not-hydrated|route-session-not-hydrated/iu.test(reason); -} - -async function recreateObserverPageForNavigation(reason, attempt) { - const before = pageUrl(observerPage); - if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {}); - observerPage = await context.newPage(); - attachPassiveListeners(observerPage, "observer", observerPageId); - await appendJsonl(files.control, eventRecord("observer-page-recreated", { reason, attempt, beforeUrl: before, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, valuesRedacted: true })); -} - -async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) { - const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000; - const started = Date.now(); - const deadline = started + Math.max(1, timeoutMs); - let last = null; - while (Date.now() <= deadline) { - last = await workbenchSessionSnapshot(targetPage); - const expected = String(sessionId || "").trim(); - const observedPath = safeUrlPath(last?.url || pageUrl(targetPage)); - const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || ""); - const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || ""); - if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; - await targetPage.waitForTimeout(250).catch(() => {}); - } - return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true }; -} - -async function maybeRefreshObserverPage(reason) { - if (!observerPage || observerPage.isClosed()) return null; - if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null; - if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null; - const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true }); - await appendJsonl(files.control, eventRecord("observer-periodic-refresh", { - pageRole: "observer", - pageId: observerPageId, - reason, - intervalMs: observerRefreshIntervalMs, - result, - valuesRedacted: true - })); - return result; -} - -async function runControlCommand(command, fn) { - activeCommandId = command.id; - commandSeq += 1; - const beforeUrl = currentPageUrl(); - const started = Date.now(); - await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) })); - try { - const result = await fn(); - await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) })); - return result; - } catch (error) { - let failurePageProvenance = null; - let failureScreenshot = null; - try { - failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null)); - } catch (captureError) { - await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); - } - try { - failureScreenshot = await captureScreenshot("command-failed", "jpeg"); - } catch (captureError) { - await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); - } - await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot })); - throw error; - } finally { - activeCommandId = null; - } -} - -async function authenticate(browserContext) { - const loginUrl = new URL("/auth/login", baseUrl).toString(); - const attempts = []; - const maxAttempts = authLoginMaxAttempts; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const retryDelayMs = authRetryDelayMs(attempt, maxAttempts); - const retryLabel = attempt + "/" + maxAttempts; - await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); - try { - const response = await pageAuthLogin(browserContext, loginUrl); - const cookieState = await readAuthCookieState(browserContext); - const retryable = isRetryableAuthStatus(response.status); - const item = { - attempt, - retryAttempt: attempt, - retryMaxAttempts: maxAttempts, - retryLabel, - retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, - requestTimeoutMs: authLoginRequestTimeoutMs, - method: "api", - status: response.status, - statusText: response.statusText, - retryable, - cookiePresent: cookieState.cookiePresent, - cookieNames: cookieState.cookieNames, - valuesRedacted: true, - }; - attempts.push(item); - await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); - if (response.ok && cookieState.cookiePresent) { - return { - ok: true, - method: "api", - loginPath: new URL(loginUrl).pathname, - status: response.status, - statusText: response.statusText, - cookiePresent: true, - cookieNames: cookieState.cookieNames, - attempts, - retryCount: attempt - 1, - retryMaxAttempts: maxAttempts, - lastRetryLabel: attempt + "/" + maxAttempts, - retryExhausted: false, - retryable: false, - valuesRedacted: true, - }; - } - if (!retryable) break; - } catch (error) { - const retryable = isRetryableAuthError(error); - attempts.push({ - attempt, - retryAttempt: attempt, - retryMaxAttempts: maxAttempts, - retryLabel, - retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, - requestTimeoutMs: authLoginRequestTimeoutMs, - method: "api", - status: 0, - statusText: "request-error", - retryable, - error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500), - cookiePresent: false, - cookieNames: [], - valuesRedacted: true, - }); - const item = attempts[attempts.length - 1] || null; - await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {}); - if (!retryable) break; - } - if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs); - } - const cookieState = await readAuthCookieState(browserContext); - const last = attempts[attempts.length - 1] || null; - const retryable = attempts.some((attempt) => attempt && attempt.retryable === true); - const failure = { - ok: false, - method: "api", - loginPath: new URL(loginUrl).pathname, - status: typeof last?.status === "number" ? last.status : 0, - statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed", - cookiePresent: cookieState.cookiePresent, - cookieNames: cookieState.cookieNames, - attempts, - retryCount: Math.max(0, attempts.length - 1), - retryMaxAttempts: maxAttempts, - lastRetryLabel: last?.retryLabel || null, - retryExhausted: retryable && attempts.length >= maxAttempts, - retryable, - lastError: last?.error || null, - valuesRedacted: true, - }; - await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {}); - const error = new Error(authFailureMessage(failure)); - error.webProbeAuth = failure; - throw error; -} - -async function pageAuthLogin(browserContext, loginUrl, credential = { username, password }) { - if (!browserContext?.request) throw new Error("auth browser context request is not ready"); - const response = await browserContext.request.post(loginUrl, { - data: { username: credential.username, password: credential.password }, - headers: { accept: "application/json", "content-type": "application/json" }, - timeout: authLoginRequestTimeoutMs, - }); - await response.text().catch(() => ""); - return { - ok: response.ok(), - status: response.status(), - statusText: response.statusText() || "", - }; -} - -async function loginAccount(command) { - const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]); - const credential = credentialForAccount(accountId); - const loginUrl = new URL("/auth/login", baseUrl).toString(); - const before = await accountSessionSnapshot(); - const attempts = []; - let response = null; - let cookieState = null; - const maxAttempts = authLoginMaxAttempts; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const retryDelayMs = authRetryDelayMs(attempt, maxAttempts); - const retryLabel = attempt + "/" + maxAttempts; - try { - response = await pageAuthLogin(context, loginUrl, credential); - cookieState = await readAuthCookieState(context); - const retryable = isRetryableAuthStatus(response.status); - attempts.push({ - attempt, - retryAttempt: attempt, - retryMaxAttempts: maxAttempts, - retryLabel, - retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, - requestTimeoutMs: authLoginRequestTimeoutMs, - method: "api", - status: response.status, - statusText: response.statusText, - retryable, - cookiePresent: cookieState.cookiePresent, - cookieNames: cookieState.cookieNames, - credentialSource: credential.source, - valuesRedacted: true, - }); - if (response.ok && cookieState.cookiePresent) break; - if (!retryable) break; - } catch (error) { - const retryable = isRetryableAuthError(error); - attempts.push({ - attempt, - retryAttempt: attempt, - retryMaxAttempts: maxAttempts, - retryLabel, - retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, - requestTimeoutMs: authLoginRequestTimeoutMs, - method: "api", - status: 0, - statusText: "request-error", - retryable, - error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500), - cookiePresent: false, - cookieNames: [], - credentialSource: credential.source, - valuesRedacted: true, - }); - response = { ok: false, status: 0, statusText: "request-error" }; - cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] })); - if (!retryable) break; - } - if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs); - } - response = response ?? { ok: false, status: 0, statusText: "api-login-failed" }; - cookieState = cookieState ?? await readAuthCookieState(context); - if (!response.ok || !cookieState.cookiePresent) { - const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || "")); - const retryable = attempts.some((item) => item && item.retryable === true); - error.details = { - accountId, - status: response.status, - statusText: response.statusText, - cookiePresent: cookieState.cookiePresent, - credentialSource: credential.source, - attempts, - retryCount: Math.max(0, attempts.length - 1), - retryMaxAttempts: maxAttempts, - lastRetryLabel: attempts[attempts.length - 1]?.retryLabel || null, - retryExhausted: retryable && attempts.length >= maxAttempts, - retryable, - valuesRedacted: true, - }; - throw error; - } - const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath; - const navigation = await gotoTarget(target || targetPath); - const after = await accountSessionSnapshot(); - return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }; -} - -async function logoutAccount(command = {}) { - const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; - const before = await accountSessionSnapshot(); - const logoutUrl = new URL("/logout", baseUrl).toString(); - const response = await page.evaluate(async (input) => { - const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" }); - await res.text().catch(() => ""); - return { ok: res.ok, status: res.status, statusText: res.statusText || "" }; - }, { logoutUrl }); - await context.clearCookies().catch(() => {}); - const cookieState = await readAuthCookieState(context); - const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl()); - const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true }; - if (!result.ok) { - const error = new Error("logout failed status=" + response.status + " " + (response.statusText || "")); - error.details = result; - throw error; - } - return result; -} - -async function listSessions(command = {}) { - const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; - if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath); - const snapshot = await workbenchSessionSnapshot(); - const sessions = await page.evaluate(() => { - const seen = new Set(); - const rows = []; - for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) { - const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || ""; - if (!sessionId || seen.has(sessionId)) continue; - seen.add(sessionId); - rows.push({ - sessionId, - active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", - status: element.getAttribute("data-status") || null, - conversationId: element.getAttribute("data-conversation-id") || null, - }); - } - return rows.slice(0, 50); - }).catch(() => []); - return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true }; -} - -async function switchSessions(command) { - const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]); - const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]); - const before = await accountSessionSnapshot(); - if (fromAccountId) { - const beforeAccount = before.accountId || null; - await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true })); - } - const logout = await logoutAccount({ ...command, accountId: fromAccountId || null }); - const login = await loginAccount({ ...command, accountId: toAccountId }); - const sessions = await listSessions({ ...command, accountId: toAccountId }); - return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true }; -} - -async function accountSessionSnapshot() { - const cookieState = await readAuthCookieState(context); - const workbench = await workbenchSessionSnapshot().catch(() => null); - return { - url: currentPageUrl(), - path: safeUrlPath(currentPageUrl()), - cookiePresent: cookieState.cookiePresent, - cookieNames: cookieState.cookieNames, - activeSessionId: workbench?.activeSessionId || null, - routeSessionId: workbench?.routeSessionId || null, - tabCount: workbench?.tabCount ?? null, - messageCount: workbench?.messageCount ?? null, - valuesRedacted: true, - }; -} - -function requiredAccountId(command, keys) { - const accountId = commandValue(command, keys); - if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id"); - return accountId; -} - -function credentialForAccount(accountId) { - if (accountId === "bootstrap-admin" || accountId === "admin") { - if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS"); - return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true }; - } - const env = accountCredentialEnvCandidates(accountId); - for (const jsonKey of env.jsonKeys) { - const raw = process.env[jsonKey]; - if (!raw) continue; - const parsed = parseCredentialJson(raw); - if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true }; - } - for (const pair of env.pairs) { - const user = process.env[pair.userKey]; - const pass = process.env[pair.passKey]; - if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true }; - } - throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(",")); -} - -function accountCredentialEnvCandidates(accountId) { - const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); - return { - jsonKeys: [ - "HWLAB_WEB_" + segment + "_JSON", - "HWLAB_WEB_ACCOUNT_" + segment + "_JSON", - ], - pairs: [ - { userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" }, - { userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" }, - ], - }; -} - -function parseCredentialJson(raw) { - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : ""; - const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : ""; - if (!user || !pass) return null; - return { username: user, password: pass, valuesRedacted: true }; - } catch { - return null; - } -} - -function isSafeAccountId(value) { - return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || "")); -} - -function publicAuth(value) { - if (!value) return null; - return { - ok: value.ok === true, - method: value.method, - status: value.status, - cookiePresent: value.cookiePresent === true, - cookieNames: value.cookieNames || [], - retryCount: value.retryCount ?? null, - retryMaxAttempts: value.retryMaxAttempts ?? null, - lastRetryLabel: value.lastRetryLabel ?? null, - retryExhausted: value.retryExhausted === true, - valuesRedacted: true - }; -} - -async function readAuthCookieState(browserContext) { - const cookies = await browserContext.cookies(baseUrl); - const cookieNames = cookies.map((cookie) => cookie.name).sort(); - return { - cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)), - cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)), - }; -} - -function isRetryableAuthStatus(status) { - return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500; -} - -function isRetryableAuthError(error) { - const message = error && error.message ? String(error.message) : String(error || ""); - return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message); -} - -function authRetryDelayMs(attempt, maxAttempts) { - return attempt < maxAttempts ? Math.min(authLoginMaxDelayMs, authLoginInitialDelayMs * (2 ** (attempt - 1))) : 0; -} - -function authFailureMessage(failure) { - const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null; - const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : ""; - const exhausted = failure.retryExhausted ? " exhausted=true" : ""; - const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : ""; - const error = last?.error ? " error=" + truncate(last.error, 160) : ""; - return ("auth login failed:" + retry + exhausted + status + error).trim(); -} - -function proxyConfigFromEnv(targetBaseUrl) { - if (browserProxyMode === "direct") return null; - let target; - try { - target = new URL(targetBaseUrl); - } catch { - return null; - } - const noProxy = process.env.NO_PROXY || process.env.no_proxy || ""; - if (noProxyMatches(target.hostname, noProxy)) return null; - const raw = target.protocol === "https:" - ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "" - : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || ""; - if (!raw) return null; - return { server: raw }; -} - -function chromiumLaunchOptionsForProxy(proxy) { - const baseArgs = chromiumLowResourceArgs(); - const base = { env: browserProcessEnvWithoutProxy() }; - if (proxy === null) return { ...base, args: [...baseArgs, "--no-proxy-server"] }; - return { ...base, proxy, args: baseArgs }; -} - -function chromiumLowResourceArgs() { - return []; -} - -function browserProcessEnvWithoutProxy() { - const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]); - const env = {}; - for (const [key, value] of Object.entries(process.env)) { - if (!blocked.has(key) && value !== undefined) env[key] = value; - } - return env; -} - -function noProxyMatches(hostname, rawList) { - const host = String(hostname || "").toLowerCase(); - if (!host) return false; - return String(rawList || "").split(",").some((raw) => { - let item = raw.trim().toLowerCase(); - if (!item) return false; - if (item === "*") return true; - item = item.replace(/^\*\./u, "."); - const portIndex = item.lastIndexOf(":"); - if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex); - if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item); - return host === item; - }); -} - -function publicNetwork(proxy) { - return { - proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : { - enabled: true, - source: "env", - server: publicProxyServer(proxy.server), - valuesRedacted: true, - }, - browser: { - proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy", - requestedProxyMode: browserProxyMode, - proxyEnvCleared: true, - valuesRedacted: true, - }, - valuesRedacted: true, - }; -} - -function parseBrowserProxyMode(raw) { - if (raw === "auto" || raw === "direct") return raw; - return "auto"; -} - -function publicProxyServer(raw) { - try { - const parsed = new URL(String(raw || "")); - parsed.username = ""; - parsed.password = ""; - const value = parsed.toString(); - if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, ""); - return value; - } catch { - return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@"); - } -} - -async function gotoTarget(rawTarget, options = {}) { - const target = new URL(String(rawTarget || targetPath), baseUrl).toString(); - const beforeUrl = currentPageUrl(); - const attempts = []; - const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : navigationMaxAttempts; - const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000; - const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000; - const settleMs = Number.isFinite(Number(options.settleMs)) ? Math.max(0, Number(options.settleMs)) : 1000; - const lateReadinessTimeoutMs = Number.isFinite(Number(options.lateReadinessTimeoutMs)) ? Math.max(0, Number(options.lateReadinessTimeoutMs)) : 5000; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }); - if (settleMs > 0) await page.waitForTimeout(settleMs).catch(() => {}); - const httpStatus = response ? response.status() : null; - const readiness = await waitForTargetPageReady(page, target, { timeoutMs: readinessTimeoutMs }); - if (!readiness.ok) { - const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null); - attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" }); - return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; - } - const pageProvenance = await refreshPageProvenance("goto", httpStatus); - attempts.push({ attempt, ok: true, httpStatus, readiness }); - return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null }); - if (lateReadinessTimeoutMs > 0 && /workbench-app-not-ready|navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(message)) { - const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: lateReadinessTimeoutMs }).catch(() => null); - if (lateReadiness?.ok) { - const pageProvenance = await refreshPageProvenance("goto-late-ready", null); - attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness }); - return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts }; - } - } - if (attempt >= maxAttempts || !isRetryableNavigationError(message)) { - throw Object.assign(new Error(message), { attempts, target }); - } - if (!observerPage) { - await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); - } else { - await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); - } - await page.waitForTimeout(1500 * attempt).catch(() => {}); - } - } - return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts }; -} - -async function recreateControlPageForNavigation(reason, attempt) { - const before = currentPageUrl(); - if (page && !page.isClosed()) await withHardTimeout(page.close(), 3000, "control page close exceeded 3000ms").catch((error) => appendJsonl(files.errors, eventRecord("control-page-close-timeout", { reason, attempt, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch }))); - controlPageEpoch += 1; - page = await context.newPage(); - attachPassiveListeners(page, "control", pageId); - currentPageProvenance = null; - await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true })); -} - -async function recreateAuthenticatedContextForNavigation(reason, attempt) { - const before = currentPageUrl(); - if (page && !page.isClosed()) await page.close().catch(() => {}); - if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {}); - observerPage = null; - if (context) await context.close().catch(() => {}); - context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); - auth = await authenticate(context); - page = await context.newPage(); - attachPassiveListeners(page, "control", pageId); - currentPageProvenance = null; - await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true })); -} - -async function refreshPageProvenance(reason, httpStatus = null) { - if (!page || page.isClosed()) return currentPageProvenance; - const observed = await page.evaluate(() => { - const assetPath = (raw) => { - if (!raw) return null; - try { - const url = new URL(raw, location.href); - const keys = Array.from(url.searchParams.keys()).sort(); - return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); - } catch { - return null; - } - }; - const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ - key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), - content: String(element.getAttribute("content") || "").slice(0, 200), - })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)); - const navigation = performance.getEntriesByType("navigation")[0] || null; - return { - url: location.href, - path: location.pathname, - title: document.title, - readyState: document.readyState, - timeOrigin: Math.round(performance.timeOrigin || 0), - navigationStartTime: navigation ? Math.round(navigation.startTime) : null, - scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(), - stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(), - meta, - }; - }).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] })); - pageLoadSeq += 1; - currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq }); - await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) })); - return currentPageProvenance; -} - -function normalizePageProvenance(value, options = {}) { - const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : []; - const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : []; - const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({ - key: String(item?.key || "").slice(0, 120), - contentHash: sha256Text(String(item?.content || "")), - })).filter((item) => item.key) : []; - const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta }); - return { - pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq, - reason: options.reason || "sample", - observedAt: new Date().toISOString(), - urlPath: safeUrlPath(value?.url || currentPageUrl()), - documentPath: value?.path || null, - titleHash: sha256Text(String(value?.title || "")), - documentReadyState: value?.readyState || null, - timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null, - navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null, - httpStatus: options.httpStatus ?? null, - assetFingerprint: sha256Text(fingerprintInput), - scriptCount: scripts.length, - stylesheetCount: stylesheets.length, - metaCount: meta.length, - scripts: scripts.slice(0, 30), - stylesheets: stylesheets.slice(0, 30), - meta: meta.slice(0, 30), - error: value?.error || null, - valuesRedacted: true, - }; -} - -function compactPageProvenance(value) { - if (!value) return null; - return { - pageLoadSeq: value.pageLoadSeq ?? null, - reason: value.reason || null, - observedAt: value.observedAt || null, - urlPath: value.urlPath || null, - documentReadyState: value.documentReadyState || null, - timeOrigin: value.timeOrigin ?? null, - httpStatus: value.httpStatus ?? null, - assetFingerprint: value.assetFingerprint || null, - scriptCount: value.scriptCount ?? 0, - stylesheetCount: value.stylesheetCount ?? 0, - metaCount: value.metaCount ?? 0, - scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [], - stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [], - error: value.error || null, - valuesRedacted: true, - }; -} - -function isRetryableNavigationError(message) { - return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|net::ERR_NAME_NOT_RESOLVED|Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded|workbench-app-not-ready/iu.test(String(message || "")); -} - -function navigationFailureKind(message) { - const text = String(message || ""); - if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED"; - if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED"; - if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET"; - if (/net::ERR_NAME_NOT_RESOLVED/iu.test(text)) return "net::ERR_NAME_NOT_RESOLVED"; - if (/Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(text)) return "navigation-timeout"; - if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready"; - return "navigation-error"; -} - -function redactErrorMessage(message) { - return String(message || "") - .replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]") - .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]"); -} - -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(() => { - 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 workspace = document.querySelector("#workspace, .workbench-route"); - const login = document.querySelector("form.login-card, .login-card, [data-testid='login']"); - return Boolean(visible(workspace) || visible(login)); - }, null, { timeout: timeoutMs }).catch(() => null); - const snapshot = await workbenchReadinessSnapshot(targetPage); - const ok = snapshot.workbenchShellVisible === true; - return { - ok, - reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready", - durationMs: Date.now() - started, - snapshot, - valuesRedacted: true - }; -} - -async function workbenchReadinessSnapshot(targetPage) { - const snapshot = await targetPage.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 sessionCreate = document.querySelector("#session-create"); - const sessionRail = document.querySelector("#session-sidebar"); - const sessionCollapseToggle = document.querySelector("#session-collapse-toggle"); - return { - url: window.location.href, - path: window.location.pathname, - readyState: document.readyState, - workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")), - sessionCreatePresent: Boolean(sessionCreate), - sessionCreateVisible: visible(sessionCreate), - sessionRailPresent: Boolean(sessionRail), - sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null, - sessionCollapseTogglePresent: Boolean(sessionCollapseToggle), - sessionCollapseToggleVisible: visible(sessionCollapseToggle), - sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null, - commandInputPresent: visible(document.querySelector("#command-input")), - activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")), - warningPresent: visible(document.querySelector(".composer-warning")), - loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")), - bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000), - valuesRedacted: true - }; - }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); - if (snapshot && typeof snapshot.bodyTextPreview === "string") { - snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview); - delete snapshot.bodyTextPreview; - } - 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 })); -} - -async function waitForProjectManagementCommandReady(options = {}) { - const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000; - const started = Date.now(); - const deadline = started + timeoutMs; - let last = null; - while (Date.now() <= deadline) { - last = await projectManagementCommandSnapshot(); - const path = String(last?.path || safeUrlPath(currentPageUrl()) || ""); - const baseReady = last?.pageKind === "project-management-mdtodo" - && Number(last?.sourceCount || 0) > 0 - && Number(last?.fileCount || 0) > 0 - && Number(last?.taskCount || 0) > 0; - const needsTask = /\/tasks\//u.test(path); - const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true); - const needsReport = /\/reports\//u.test(path); - const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true; - if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; - await page.waitForTimeout(250).catch(() => {}); - } - return { ok: false, reason: "project-management-command-not-ready", durationMs: Date.now() - started, snapshot: last, 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 + "/")); -} - -function isAgentSessionCreateRequest(requestOrUrl) { - const method = typeof requestOrUrl?.method === "function" ? requestOrUrl.method().toUpperCase() : ""; - if (method && method !== "POST") return false; - const url = typeof requestOrUrl === "string" ? requestOrUrl : typeof requestOrUrl?.url === "function" ? requestOrUrl.url() : ""; - try { - return new URL(url).pathname === "/v1/agent/sessions"; - } catch { - return false; - } -} - -function requestFailureSummary(request) { - let failure = null; - try { - failure = request.failure(); - } catch {} - let urlPath = null; - try { - urlPath = new URL(request.url()).pathname; - } catch {} - return { - method: typeof request.method === "function" ? request.method().toUpperCase() : null, - urlPath, - failureText: failure?.errorText || null, - valuesRedacted: true - }; -} - -async function ensureSessionRailExpanded() { - const before = await workbenchReadinessSnapshot(page); - if (before?.sessionCreateVisible === true) { - return { ok: true, action: "already-visible", before, after: before, valuesRedacted: true }; - } - const toggle = page.locator("#session-collapse-toggle").first(); - const toggleVisible = await toggle.isVisible({ timeout: 2000 }).catch(() => false); - if (before?.sessionRailCollapsed !== true || !toggleVisible) { - return { ok: false, action: "not-expanded", reason: before?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before, after: before, valuesRedacted: true }; - } - await toggle.click(); - await page.waitForFunction(() => { - 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 rail = document.querySelector("#session-sidebar"); - return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false")); - }, null, { timeout: 5000 }).catch(() => null); - const after = await workbenchReadinessSnapshot(page); - return { - ok: after?.sessionCreateVisible === true, - action: "expanded-session-rail", - before, - after, - valuesRedacted: true - }; -} - -async function clickAndWaitForAgentSessionCreate(create) { - let removeRequestFailedListener = () => {}; - const requestFailedPromise = new Promise((resolve) => { - let timeout = null; - const handler = (request) => { - if (!isAgentSessionCreateRequest(request)) return; - removeRequestFailedListener(); - resolve({ kind: "requestfailed", requestFailure: requestFailureSummary(request) }); - }; - removeRequestFailedListener = () => { - page.off("requestfailed", handler); - if (timeout !== null) clearTimeout(timeout); - timeout = null; - }; - page.on("requestfailed", handler); - timeout = setTimeout(() => { - removeRequestFailedListener(); - resolve(null); - }, 45000); - }); - const createResponsePromise = page.waitForResponse((response) => { - const request = response.request(); - return isAgentSessionCreateRequest(request) || isAgentSessionCreateRequest(response.url()); - }, { timeout: 45000 }).then((response) => ({ kind: "response", response })).catch((error) => ({ kind: "wait-error", waitError: errorSummary(error) })); - await create.click(); - const outcome = await Promise.race([createResponsePromise, requestFailedPromise]); - removeRequestFailedListener(); - return outcome ?? await createResponsePromise; -} - -async function createSessionFromUi() { - const beforeUrl = currentPageUrl(); - const before = await workbenchSessionSnapshot(); - const attempts = []; - let createResponse = null; - for (let attempt = 1; attempt <= 2; attempt += 1) { - const railExpansion = await ensureSessionRailExpanded(); - const readinessBeforeClick = railExpansion.after || await workbenchReadinessSnapshot(page); - const create = page.locator("#session-create").first(); - try { - await create.waitFor({ state: "visible", timeout: 15000 }); - } catch (error) { - const readinessAfterWait = await workbenchReadinessSnapshot(page); - const createError = new Error("newSession session create button is not visible"); - createError.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, attempt, readinessBeforeClick, readinessAfterWait, railExpansion, waitError: errorSummary(error), pageId, valuesRedacted: true }; - throw createError; - } - const createButtonState = await create.evaluate((element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return { - tag: element.tagName.toLowerCase(), - id: element.id || null, - disabled: Boolean(element.disabled), - ariaDisabled: element.getAttribute("aria-disabled") || null, - visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none", - rect: { width: Math.round(rect.width), height: Math.round(rect.height) }, - valuesRedacted: true - }; - }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); - const outcome = await clickAndWaitForAgentSessionCreate(create); - if (outcome?.kind === "response") { - createResponse = outcome.response; - attempts.push({ attempt, outcome: "response", readinessBeforeClick, railExpansion, createButtonState, valuesRedacted: true }); - break; - } - const afterAttempt = await workbenchSessionSnapshot(); - attempts.push({ - attempt, - outcome: outcome?.kind || "unknown", - readinessBeforeClick, - railExpansion, - createButtonState, - waitError: outcome?.waitError || null, - requestFailure: outcome?.requestFailure || null, - after: afterAttempt, - valuesRedacted: true - }); - if (attempt < 2 && outcome?.kind === "requestfailed") { - await page.waitForTimeout(1500); - continue; - } - const waitError = outcome?.waitError || outcome?.requestFailure || { name: outcome?.kind || "unknown", valuesRedacted: true }; - const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (waitError.message || waitError.failureText || waitError.name || "timeout")); - error.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, pageId, valuesRedacted: true }; - throw error; - } - if (createResponse === null) throw new Error("newSession did not produce an authoritative session create response"); - const createStatus = createResponse.status(); - let createPayload = null; - let createPayloadError = null; - try { - createPayload = await createResponse.json(); - } catch (error) { - createPayloadError = errorSummary(error); - } - const createdSessionId = sessionIdFromAgentSessionPayload(createPayload); - if (createStatus < 200 || createStatus >= 300 || !createdSessionId) { - const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions"); - error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }; - throw error; - } - await page.waitForFunction((expectedSessionId) => { - const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); - const sessionId = activeTab?.getAttribute("data-session-id") || ""; - const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); - const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : ""; - const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; - const input = document.querySelector("#command-input"); - return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning); - }, createdSessionId, { timeout: 45000 }).catch(() => null); - const after = await workbenchSessionSnapshot(); - const afterSessionId = after?.activeSessionId || after?.routeSessionId || ""; - const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady); - if (!ok) { - const error = new Error("newSession did not select the authoritative newly created workbench session"); - error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, attempts, pageId, valuesRedacted: true }; - throw error; - } - return { - beforeUrl, - afterUrl: currentPageUrl(), - ok, - before, - after, - attempts, - sessionId: createdSessionId, - createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }, - pageId - }; -} - -function sessionIdFromAgentSessionPayload(payload) { - const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id; - const directText = String(direct || "").trim(); - if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText; - const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u); - return match ? match[0] : null; -} - -function traceIdFromAgentChatPayload(payload) { - const direct = payload?.traceId ?? payload?.turn?.traceId ?? payload?.message?.traceId ?? payload?.data?.traceId ?? payload?.data?.turn?.traceId ?? payload?.data?.message?.traceId; - const directText = String(direct || "").trim(); - if (/^(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})$/u.test(directText)) return directText; - const match = JSON.stringify(payload ?? "").match(/\b(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/u); - return match ? match[0] : null; -} - -async function workbenchSessionSnapshot(targetPage = page) { - return targetPage.evaluate(() => { - const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); - const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); - const input = document.querySelector("#command-input"); - const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null; - const visible = (element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - return { - url: window.location.href, - routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null, - activeSessionId: activeTab?.getAttribute("data-session-id") || null, - activeConversationId: activeTab?.getAttribute("data-conversation-id") || null, - activeStatus: activeTab?.getAttribute("data-status") || null, - tabCount: document.querySelectorAll(".session-tab").length, - messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, - traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length, - loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length, - composerReady: Boolean(activeTab && input && !input.disabled && !warning), - warning - }; - }).catch(() => null); -} - -function controlPageRecoveryTarget(snapshot, beforeUrl) { - const sessionId = snapshot?.routeSessionId || snapshot?.activeSessionId || routeSessionIdFromUrl(beforeUrl); - if (sessionId) return { sessionId, targetPath: "/workbench/sessions/" + encodeURIComponent(sessionId), valuesRedacted: true }; - const path = safeUrlPath(beforeUrl); - if (isWorkbenchPathname(path || "")) return { sessionId: null, targetPath: path, valuesRedacted: true }; - return { sessionId: null, targetPath, valuesRedacted: true }; -} - -function controlPageProjectionMissingForCommand(snapshot, beforeUrl) { - const path = safeUrlPath(snapshot?.url || beforeUrl); - if (!isWorkbenchPathname(path || "")) return false; - const routeSessionId = snapshot?.routeSessionId || routeSessionIdFromUrl(snapshot?.url || beforeUrl); - if (!routeSessionId) return false; - return snapshot?.activeSessionId !== routeSessionId - && Number(snapshot?.tabCount || 0) === 0 - && Number(snapshot?.messageCount || 0) === 0 - && Number(snapshot?.traceRowCount || 0) === 0 - && snapshot?.composerReady !== true; -} - -async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) { - const started = Date.now(); - return withHardTimeout(workbenchSessionSnapshot(page), timeoutMs, "control page liveness snapshot exceeded " + timeoutMs + "ms") - .then((snapshot) => ({ - ok: snapshot !== null, - reason, - durationMs: Date.now() - started, - snapshot, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })) - .catch((error) => ({ - ok: false, - reason, - durationMs: Date.now() - started, - error: errorSummary(error), - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })); -} - -async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null, options = {}) { - let navigation = null; - let hydration = null; - let afterLiveness = null; - const attempts = []; - let ok = false; - const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2; - const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 12000; - const hydrationHardTimeoutMs = Number.isFinite(Number(options.hydrationHardTimeoutMs)) ? Math.max(hydrationTimeoutMs, Number(options.hydrationHardTimeoutMs)) : hydrationTimeoutMs + 2000; - const navigationOptions = options.navigation || {}; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt); - try { - navigation = await gotoTarget(target.targetPath, navigationOptions); - } catch (error) { - navigation = { ok: false, targetPath: target.targetPath, error: errorSummary(error), valuesRedacted: true }; - } - if (!navigation?.error && target.sessionId) { - hydration = await withHardTimeout( - waitForWorkbenchSessionHydrated(page, target.sessionId, { timeoutMs: hydrationTimeoutMs }), - hydrationHardTimeoutMs, - "control page recovery hydration exceeded " + hydrationHardTimeoutMs + "ms" - ).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true })); - } else { - hydration = null; - } - afterLiveness = await controlPageLivenessSnapshot(reason + "-post-recovery", 3000); - ok = !navigation?.error && afterLiveness.ok === true && (!target.sessionId || hydration?.ok === true); - attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }); - if (ok) break; - } - return { - ok, - recovered: ok, - reason, - beforeUrl, - afterUrl: currentPageUrl(), - target, - liveness, - navigation, - hydration, - afterLiveness, - attempts, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - }; -} - -async function promoteObserverPageToControlForCommand(reason, target, liveness = null) { - const beforeUrl = currentPageUrl(); - const beforeObserverUrl = pageUrl(observerPage); - if (!observerPage || observerPage.isClosed()) { - return { ok: false, promoted: false, reason, failureKind: "observer-page-unavailable", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true }; - } - const sessionId = target?.sessionId || routeSessionIdFromUrl(beforeUrl); - if (!sessionId) { - return { ok: false, promoted: false, reason, failureKind: "observer-promotion-needs-session", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true }; - } - const observerSessionId = routeSessionIdFromUrl(beforeObserverUrl); - if (observerSessionId !== sessionId) { - return { ok: false, promoted: false, reason, failureKind: "observer-session-mismatch", beforeUrl, observerUrl: beforeObserverUrl, observerSessionId, sessionId, target, liveness, valuesRedacted: true }; - } - const targetUrl = new URL(target?.targetPath || ("/workbench/sessions/" + encodeURIComponent(sessionId)), baseUrl).toString(); - const readiness = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: 1000, hydrationTimeoutMs: 2000 }); - const observerComposerReady = readiness?.hydration?.snapshot?.composerReady === true; - if (readiness.ok !== true || observerComposerReady !== true) { - return { ok: false, promoted: false, reason, failureKind: readiness.failureKind || (observerComposerReady ? "observer-not-ready" : "observer-composer-not-ready"), beforeUrl, observerUrl: beforeObserverUrl, sessionId, target, liveness, readiness, valuesRedacted: true }; - } - const oldControlPage = page; - observerPageEpoch += 1; - controlPageEpoch += 1; - page = observerPage; - observerPage = null; - attachPassiveListeners(page, "control", pageId); - currentPageProvenance = null; - if (oldControlPage && !oldControlPage.isClosed() && oldControlPage !== page) { - await withHardTimeout(oldControlPage.close(), 2000, "old control page close exceeded 2000ms") - .catch((error) => appendJsonl(files.errors, eventRecord("old-control-page-close-timeout", { reason, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch }))); - } - observerPage = await context.newPage(); - attachPassiveListeners(observerPage, "observer", observerPageId); - const observerSync = await syncObserverPageToControlSession(reason + "-observer-recreated-after-promotion", sessionId, { - maxAttempts: 1, - navigationTimeoutMs: 8000, - readinessTimeoutMs: 3000, - hydrationTimeoutMs: 3000, - shortCircuitReadinessTimeoutMs: 1000, - shortCircuitHydrationTimeoutMs: 1000, - }); - return { - ok: true, - promoted: true, - reason, - beforeUrl, - beforeObserverUrl, - afterUrl: currentPageUrl(), - sessionId, - target, - liveness, - readiness, - observerSync, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - }; -} - -async function ensureControlPageResponsiveForCommand(reason) { - const beforeUrl = currentPageUrl(); - const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000); - const projectionMissing = liveness.ok === true && controlPageProjectionMissingForCommand(liveness.snapshot, beforeUrl); - if (liveness.ok && !projectionMissing) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; - const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); - await appendJsonl(files.control, eventRecord(projectionMissing ? "control-page-projection-missing-before-command" : "control-page-unresponsive-before-command", { - reason, - beforeUrl, - target, - liveness, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })); - const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness); - await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-promoted-from-observer-before-command" : "control-page-observer-promotion-skipped-before-command", promotion)); - if (promotion.ok === true) return promotion; - const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, { - maxAttempts: 1, - navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 }, - hydrationTimeoutMs: 4000, - hydrationHardTimeoutMs: 5000, - }); - await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery)); - if (!recovery.ok) { - const error = new Error("control page recovery failed before " + reason); - error.details = recovery; - throw error; - } - return recovery; -} - -async function forceRecoverControlPageForCommand(reason) { - const beforeUrl = currentPageUrl(); - const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000); - const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); - await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", { - reason, - beforeUrl, - target, - liveness, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })); - const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness); - await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-forced-promoted-from-observer-before-command" : "control-page-forced-observer-promotion-skipped-before-command", promotion)); - if (promotion.ok === true) return promotion; - const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, { - maxAttempts: 1, - navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 }, - hydrationTimeoutMs: 4000, - hydrationHardTimeoutMs: 5000, - }); - await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery)); - return recovery; -} - -async function sendPrompt(text, options = {}) { - if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); - const responsePath = options.responsePath || "/v1/agent/chat"; - const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt"); - const beforeUrl = currentPageUrl(); - const beforeEvidence = await promptSideEffectSnapshot(); - let editor = null; - let composerRecovery = null; - let editorWaitError = null; - for (let attempt = 1; attempt <= 2; attempt += 1) { - const primaryEditor = page.locator("#command-input").last(); - const candidate = await primaryEditor.isVisible().catch(() => false) - ? primaryEditor - : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); - try { - await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 8000 }), 10000, "sendPrompt composer editor did not become visible within 10s"); - editor = candidate; - break; - } catch (error) { - editorWaitError = error; - if (attempt >= 2) break; - composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing"); - if (composerRecovery.ok !== true) break; - } - } - if (!editor) { - const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000); - const error = new Error("sendPrompt composer editor did not become visible"); - error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; - throw error; - } - editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery }); - const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'; - const primarySubmit = page.locator(primarySubmitSelector).last(); - const submit = await primarySubmit.isVisible().catch(() => false) - ? primarySubmit - : page.locator([ - 'button[type="submit"]', - 'button:has-text("发送")', - 'button:has-text("Send")', - '[data-testid*="send" i]', - '[aria-label*="send" i]', - '[aria-label*="发送"]' - ].join(", ")).last(); - await withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s"); - if (options.expectedAction) { - const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === "" - ? null - : Number(options.expectedActionWaitMs); - const actionWaitMs = Number.isFinite(configuredActionWaitMs) - ? Math.max(1000, Math.trunc(configuredActionWaitMs)) - : options.expectedAction === "turn" ? 180000 : 1000; - const actionDeadline = Date.now() + actionWaitMs; - let composer = null; - while (Date.now() <= actionDeadline) { - composer = await composerButtonState(submit); - if (composer.action === options.expectedAction && composer.disabled !== true) break; - await page.waitForTimeout(250); - } - composer = composer || await composerButtonState(submit); - if (composer.action !== options.expectedAction || composer.disabled === true) { - await clearComposerEditor(editor).catch(() => {}); - const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot()); - const blocked = { - beforeUrl, - afterUrl: currentPageUrl(), - textHash: sha256Text(text), - textBytes: Buffer.byteLength(text), - submitted: false, - blocked: true, - degradedReason: options.noActiveReason || "composer-action-mismatch", - composer, - chatSubmit: { - status: null, - statusText: null, - urlPath: responsePath, - responseObserved: false, - sideEffectObserved: sideEffectHasAuthoritativePromptSubmission(sideEffect), - sideEffect, - actionWaitMs, - expectedAction: options.expectedAction, - actualAction: composer.action, - valuesRedacted: true - }, - controlRecovery, - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - }; - if (options.throwOnActionMismatch === true) { - const error = new Error("sendPrompt composer action mismatch: expected " + options.expectedAction + " actual " + (composer.action || "null")); - error.details = blocked; - throw error; - } - return blocked; - } - } - const acceptedResponsePaths = [responsePath, ...(Array.isArray(options.alternateResponsePaths) ? options.alternateResponsePaths : [])]; - const chatResponsePromise = page.waitForResponse((response) => { - const request = response.request(); - if (request.method().toUpperCase() !== "POST") return false; - try { - return acceptedResponsePaths.includes(new URL(response.url()).pathname); - } catch { - return false; - } - }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); - await submit.click(); - const chatResponse = await chatResponsePromise; - await page.waitForTimeout(500); - if (chatResponse?.waitError) { - const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000); - if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) { - return { - beforeUrl, - afterUrl: currentPageUrl(), - textHash: sha256Text(text), - textBytes: Buffer.byteLength(text), - chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect }, - controlRecovery, - pageId, - pageEpoch: controlPageEpoch - }; - } - const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout")); - error.details = { - beforeUrl, - afterUrl: currentPageUrl(), - textHash: sha256Text(text), - textBytes: Buffer.byteLength(text), - chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect }, - controlRecovery, - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - }; - throw error; - } - const chatStatus = chatResponse.status(); - let chatUrlPath = responsePath; - try { - chatUrlPath = new URL(chatResponse.url()).pathname; - } catch { - chatUrlPath = responsePath; - } - if (chatStatus < 200 || chatStatus >= 300) { - throw new Error("sendPrompt observed POST " + chatUrlPath + " HTTP " + chatStatus + " " + chatResponse.statusText()); - } - let chatPayload = null; - let chatPayloadError = null; - try { - chatPayload = await chatResponse.json(); - } catch (error) { - chatPayloadError = errorSummary(error); - } - const payloadText = chatPayload ? JSON.stringify(chatPayload) : ""; - const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null; - const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId) - ? chatPayload.otelTrace.traceId - : null; - return { - beforeUrl, - afterUrl: currentPageUrl(), - textHash: sha256Text(text), - textBytes: Buffer.byteLength(text), - chatSubmit: { - status: chatStatus, - statusText: chatResponse.statusText(), - urlPath: chatUrlPath, - traceId, - otelTraceId, - resultUrl: safeUrlPath(chatPayload?.resultUrl), - turnUrl: safeUrlPath(chatPayload?.turnUrl), - streamUrl: safeUrlPath(chatPayload?.streamUrl), - responseParsed: chatPayload !== null, - responseParseError: chatPayloadError, - valuesRedacted: true - }, - controlRecovery, - pageId, - pageEpoch: controlPageEpoch - }; -} - -async function fillComposerEditorWithRetry(initialEditor, text, context = {}) { - let editor = initialEditor; - let lastError = null; - let recovery = null; - for (let attempt = 1; attempt <= 2; attempt += 1) { - try { - if (attempt > 1 || !editor) editor = await resolveComposerEditor(8000); - await fillComposerEditor(editor, text, { timeoutMs: 12000 }); - const retained = await composerEditorRetainsText(editor, text); - if (retained) return editor; - throw new Error("sendPrompt composer editor did not retain filled text"); - } catch (error) { - lastError = error; - await appendJsonl(files.control, eventRecord("sendPrompt-composer-fill-retry", { - attempt, - willRetry: attempt < 2, - error: errorSummary(error), - beforeUrl: context.beforeUrl || null, - afterUrl: currentPageUrl(), - controlRecovery: context.controlRecovery || null, - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })); - if (attempt >= 2) break; - recovery = await forceRecoverControlPageForCommand("sendPrompt-composer-fill-failed"); - if (recovery.ok !== true) await page.waitForTimeout(250); - editor = null; - } - } - const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-fill-failed-final", 3000); - const error = new Error("sendPrompt composer editor fill failed"); - error.details = { - beforeUrl: context.beforeUrl || null, - afterUrl: currentPageUrl(), - controlRecovery: context.controlRecovery || null, - recovery, - snapshot, - fillError: errorSummary(lastError), - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - }; - throw error; -} - -async function resolveComposerEditor(timeoutMs = 8000) { - const primaryEditor = page.locator("#command-input").last(); - const candidate = await primaryEditor.isVisible().catch(() => false) - ? primaryEditor - : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); - await candidate.waitFor({ state: "visible", timeout: timeoutMs }); - return candidate; -} - -async function fillComposerEditor(editor, text, options = {}) { - const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1000, Math.trunc(Number(options.timeoutMs))) : 30000; - const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); - const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); - if (tag === "textarea" || tag === "input") await editor.fill(text, { timeout: timeoutMs }); - else if (editable) { - await editor.click({ timeout: timeoutMs }); - await page.keyboard.insertText(text); - } else { - await editor.click({ timeout: timeoutMs }); - await page.keyboard.insertText(text); - } -} - -async function composerEditorRetainsText(editor, expectedText) { - const expected = String(expectedText || ""); - return editor.evaluate((element, value) => { - if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return element.value === value; - return (element.textContent || "") === value; - }, expected).catch(() => false); -} - -async function clearComposerEditor(editor) { - const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); - const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); - if (tag === "textarea" || tag === "input") { - await editor.fill(""); - return; - } - if (editable) { - await editor.click(); - await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {}); - await page.keyboard.press("Backspace").catch(() => {}); - } -} - -async function composerButtonState(button) { - return button.evaluate((element) => ({ - action: element.getAttribute("data-action") || null, - disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true", - text: (element.textContent || "").trim().slice(0, 80), - title: element.getAttribute("title") || null, - ariaLabel: element.getAttribute("aria-label") || null, - testId: element.getAttribute("data-testid") || null, - })).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true })); -} - -function sideEffectHasAuthoritativePromptSubmission(sideEffect) { - return Boolean( - (Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0) - || (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0) - || Number(sideEffect?.messageCountDelta || 0) > 0 - ); -} - -async function waitForPromptSideEffect(beforeEvidence, timeoutMs) { - await page.waitForFunction((before) => { - const current = (() => { - const text = document.body?.innerText || ""; - const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); - const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); - const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text); - const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text); - const visible = (element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length; - const textBytes = new TextEncoder().encode(text).length; - return { runIds, traceIds, running, executionError, messageCount, textBytes }; - })(); - const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : []; - const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : []; - const newRun = current.runIds.some((id) => !beforeRuns.includes(id)); - const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id)); - const newMessage = current.messageCount > Number(before?.messageCount || 0); - return newRun || newTrace || newMessage; - }, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null); - const after = await promptSideEffectSnapshot(); - const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : []; - const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : []; - const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id)); - const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id)); - const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0)); - return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true }; -} - -async function promptSideEffectSnapshot() { - return withHardTimeout(page.evaluate(() => { - const text = document.body?.innerText || ""; - const visible = (element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - return { - runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), - traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), - running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text), - executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text), - messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, - textBytes: new TextEncoder().encode(text).length, - valuesRedacted: true - }; - }), 3000, "prompt side-effect snapshot exceeded 3000ms") - .catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true })); -} +${nodeWebObserveRunnerControlSource()} ${nodeWebObserveRunnerCommandActionsSource()} -async function selectProvider(provider) { - const target = String(provider || "").trim(); - if (!target) throw new Error("selectProvider requires provider name"); - const beforeUrl = currentPageUrl(); - const beforePath = safeUrlPath(beforeUrl); - if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first"); - const nativeSelect = await page.evaluate((name) => { - const normalized = String(name).toLowerCase(); - const visible = (element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { - const options = Array.from(select.options || []); - const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized)); - if (!option) continue; - select.value = option.value; - select.dispatchEvent(new Event("input", { bubbles: true })); - select.dispatchEvent(new Event("change", { bubbles: true })); - return { kind: "native-select", value: option.value, text: option.textContent || "" }; - } - return null; - }, target).catch(() => null); - if (nativeSelect) { - await page.waitForTimeout(500); - return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId }; - } - const optionVisible = page.getByText(target, { exact: false }).last(); - if (await optionVisible.isVisible().catch(() => false)) { - await optionVisible.click(); - await page.waitForTimeout(500); - return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId }; - } - const control = page.locator([ - '[data-testid*="provider" i]', - '[data-testid*="model" i]', - '[aria-label*="provider" i]', - '[aria-label*="model" i]', - '[aria-label*="模型"]', - '[aria-label*="提供"]', - '[role="combobox"]', - '[aria-haspopup="listbox"]', - ].join(", ")); - const count = Math.min(await control.count().catch(() => 0), 60); - const attempts = []; - for (let index = count - 1; index >= 0; index -= 1) { - const item = control.nth(index); - const visible = await item.isVisible().catch(() => false); - if (!visible) continue; - const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => ""); - if (isProviderNavigationLabel(label)) continue; - if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue; - attempts.push({ index, label }); - await item.click({ timeout: 3000 }).catch(() => null); - await page.waitForTimeout(300); - const option = page.getByText(target, { exact: false }).last(); - if (await option.isVisible().catch(() => false)) { - await option.click(); - await page.waitForTimeout(700); - return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId }; - } - await page.keyboard.press("Escape").catch(() => null); - } - const providerCandidates = await collectProviderCandidates(); - throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10))); -} +${nodeWebObserveRunnerWorkbenchSource()} -async function collectProviderCandidates() { - return page.evaluate(() => { - const rows = []; - const seen = new Set(); - const isNavigationLabel = (value) => { - const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); - if (!label) return false; - if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true; - return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, "")); - }; - const visible = (element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - const push = (kind, element, value, text) => { - const normalizedValue = String(value || "").trim().slice(0, 160); - const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220); - const label = (normalizedValue + " " + normalizedText).trim(); - if (!label) return; - const key = (kind + ":" + label).toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) }); - }; - for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { - for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || ""); - } - const selector = [ - '[role="option"]', - '[role="menuitem"]', - '[data-radix-collection-item]', - '[data-testid*="provider" i]', - '[data-testid*="profile" i]', - '[data-testid*="model" i]', - '[aria-label*="provider" i]', - '[aria-label*="profile" i]', - '[aria-label*="model" i]', - '[aria-label*="模型"]', - '[aria-label*="提供"]', - 'button' - ].join(", "); - for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) { - const text = String(element.textContent || "").replace(/\s+/gu, " ").trim(); - const ariaLabel = String(element.getAttribute("aria-label") || ""); - const testId = String(element.getAttribute("data-testid") || ""); - const haystack = text + " " + ariaLabel + " " + testId; - if (isNavigationLabel(haystack)) continue; - if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue; - push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId); - } - return rows.slice(0, 80); - }).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]); -} +${nodeWebObserveRunnerSamplingSource()} -function isProviderNavigationLabel(value) { - const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); - if (!text) return false; - if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true; - return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, "")); -} +${nodeWebObserveRunnerUtilitySource()} -async function clickSession(sessionId) { - if (!sessionId) throw new Error("clickSession requires --session-id or --value"); - const beforeUrl = currentPageUrl(); - const escaped = cssEscape(sessionId); - const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first(); - const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first(); - await candidate.waitFor({ state: "visible", timeout: 15000 }); - await candidate.click(); - await page.waitForTimeout(1000); - return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId }; -} -async function refreshCurrentSession(command) { - const beforeUrl = currentPageUrl(); - const before = await workbenchSessionSnapshot(); - const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); - if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session"); - const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); - const after = await workbenchSessionSnapshot(); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "refreshCurrentSession", - afterRound: integerOrNull(command.afterRound), - canarySessionId: sessionId, - routeSessionId: after?.routeSessionId ?? null, - activeSessionId: after?.activeSessionId ?? null, - routeOk: after?.routeSessionId === sessionId, - activeOk: after?.activeSessionId === sessionId, - composerReady: after?.composerReady === true, - navigation, - pageId, - valuesRedacted: true, - }; -} - -async function switchAwayAndBack(command) { - const beforeUrl = currentPageUrl(); - const before = await workbenchSessionSnapshot(); - const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); - if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session"); - const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create"); - const beforeSessionIds = await visibleWorkbenchSessionIds(); - let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null; - let alternateSource = alternateSessionId === null ? null : "existing"; - let createResult = null; - if (alternateSessionId === null && strategy === "existing-or-create") { - createResult = await createSessionFromUi(); - alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null; - alternateSource = "created"; - } - if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy); - const switchAway = await clickSession(alternateSessionId); - const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 }); - const away = awaySettle.snapshot ?? await workbenchSessionSnapshot(); - let switchBack; - try { - switchBack = await clickSession(canarySessionId); - } catch (error) { - const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId)); - switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true }; - } - let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 }); - let after = backSettle.snapshot ?? await workbenchSessionSnapshot(); - let switchBackRecovery = null; - let routeOk = after?.routeSessionId === canarySessionId; - let activeOk = after?.activeSessionId === canarySessionId; - if (!routeOk || !activeOk) { - switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle); - if (switchBackRecovery?.ok === true) { - switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true }; - backSettle = switchBackRecovery.settle; - after = switchBackRecovery.snapshot ?? after; - routeOk = after?.routeSessionId === canarySessionId; - activeOk = after?.activeSessionId === canarySessionId; - } - } - if (awaySettle.ok !== true) { - const error = new Error("switchAwayAndBack did not settle on the alternate session"); - error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true }; - throw error; - } - if (!routeOk || !activeOk) { - const error = new Error("switchAwayAndBack did not return to the canary session"); - error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, recovery: switchBackRecovery, pageId, valuesRedacted: true }; - throw error; - } - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "switchAwayAndBack", - afterRound: integerOrNull(command.afterRound), - canarySessionId, - alternateSessionId, - alternateSessionStrategy: strategy, - alternateSource, - beforeSessionCount: beforeSessionIds.length, - createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true }, - switchAway, - awaySettle, - away: { - routeSessionId: away?.routeSessionId ?? null, - activeSessionId: away?.activeSessionId ?? null, - messageCount: away?.messageCount ?? null, - valuesRedacted: true, - }, - switchBack, - backSettle, - switchBackRecovery, - routeSessionId: after?.routeSessionId ?? null, - activeSessionId: after?.activeSessionId ?? null, - routeOk, - activeOk, - composerReady: after?.composerReady === true, - pageId, - valuesRedacted: true, - }; -} - -async function recoverControlPageSessionHydration(sessionId, previousSettle) { - const observerHydration = observerPage && !observerPage.isClosed() - ? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 }) - : { ok: false, reason: "observer-page-unavailable", valuesRedacted: true }; - await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1); - const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); - const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 }); - const snapshot = settle.snapshot ?? await workbenchSessionSnapshot(); - const routeOk = snapshot?.routeSessionId === sessionId; - const activeOk = snapshot?.activeSessionId === sessionId; - return { - ok: settle.ok === true && routeOk && activeOk, - attempted: true, - reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed", - previousSettle, - observerHydrated: observerHydration.ok === true, - observerHydration, - navigation, - settle, - snapshot: { - routeSessionId: snapshot?.routeSessionId ?? null, - activeSessionId: snapshot?.activeSessionId ?? null, - composerReady: snapshot?.composerReady === true, - messageCount: snapshot?.messageCount ?? null, - valuesRedacted: true, - }, - pageId, - valuesRedacted: true, - }; -} - -async function assertSessionInvariant(command) { - const beforeUrl = currentPageUrl(); - const snapshot = await workbenchSessionSnapshot(); - const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim(); - if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session"); - const routeOk = snapshot?.routeSessionId === canarySessionId; - const activeOk = snapshot?.activeSessionId === canarySessionId; - const composerReady = snapshot?.composerReady === true; - if (!routeOk || !activeOk) { - const error = new Error("assertSessionInvariant saw route/active session mismatch"); - error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true }; - throw error; - } - if (command.requireComposerReady === true && !composerReady) { - const error = new Error("assertSessionInvariant requires composer ready for the next round"); - error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true }; - throw error; - } - const messageOrder = await visibleMessageOrderSummary(); - await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true }))); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "assertSessionInvariant", - afterRound: integerOrNull(command.afterRound), - canarySessionId, - routeSessionId: snapshot?.routeSessionId ?? null, - activeSessionId: snapshot?.activeSessionId ?? null, - routeOk, - activeOk, - composerReady, - expectedSentinelRange: command.expectedSentinelRange || null, - findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation", - severity: command.severity || "amber", - blocking: command.blocking === true, - messageOrder, - sampleSeq, - pageRole: "control", - pageId, - valuesRedacted: true, - }; -} - -async function visibleWorkbenchSessionIds() { - return 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 sessionIdForElement = (element) => { - const direct = element.getAttribute("data-session-id"); - if (direct) return direct; - const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; - const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); - return match ? decodeURIComponent(match[1] || "") : null; - }; - const ids = []; - for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) { - if (!visible(element)) continue; - const id = sessionIdForElement(element); - if (id && !ids.includes(id)) ids.push(id); - } - return ids.slice(0, 50); - }).catch(() => []); -} - -async function visibleMessageOrderSummary() { - const rawMessages = await page.evaluate(() => { - const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").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"; - }; - const stableMessageText = (element) => { - const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"]; - const parts = []; - for (const selector of selectors) { - for (const candidate of Array.from(element.querySelectorAll(selector))) { - if (!visible(candidate)) continue; - const text = trim(candidate.textContent || "", 1200); - if (text && !parts.includes(text)) parts.push(text); - } - } - return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200); - }; - return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({ - index, - dataRole: element.getAttribute("data-role") || null, - role: element.getAttribute("role") || null, - testId: element.getAttribute("data-testid") || null, - status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, - sessionId: element.getAttribute("data-session-id") || null, - messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, - traceId: element.getAttribute("data-trace-id") || null, - turnId: element.getAttribute("data-turn-id") || null, - text: stableMessageText(element), - })); - }).catch(() => []); - const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => { - const text = String(item?.text || ""); - const kind = messageKindForOrder(item, text); - const markers = sentinelMarkers(text); - return { - index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index, - kind, - role: item?.dataRole || item?.role || null, - status: item?.status || null, - sessionId: item?.sessionId || null, - messageId: item?.messageId || null, - traceId: item?.traceId || null, - turnId: item?.turnId || null, - markers, - textHash: sha256Text(text), - textBytes: Buffer.byteLength(text), - valuesRedacted: true, - }; - }) : []; - const clusters = userClusters(entries); - const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null; - return { - messageCount: entries.length, - sequence: entries.map((item) => ({ - index: item.index, - kind: item.kind, - marker: item.markers[0] || null, - markerCount: item.markers.length, - traceId: item.traceId, - status: item.status, - textHash: item.textHash, - textBytes: item.textBytes, - valuesRedacted: true, - })).slice(-40), - userClustered: clusters.length > 0, - consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0, - sentinelRange: maxCluster?.sentinelRange ?? null, - traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12), - clusters, - valuesRedacted: true, - }; -} - -function messageKindForOrder(item, text) { - const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" "); - if (/\b(user|human)\b/u.test(signal)) return "user"; - if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent"; - const body = String(text || "").trim(); - if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user"; - if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent"; - return "unknown"; -} - -function sentinelMarkers(text) { - return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase())); -} - -function userClusters(entries) { - const clusters = []; - let current = []; - const flush = () => { - if (current.length >= 2) { - const markers = current.flatMap((item) => item.markers); - clusters.push({ - startIndex: current[0].index, - endIndex: current[current.length - 1].index, - consecutiveUserMessageCount: current.length, - sentinelRange: sentinelRange(markers), - markers: uniqueStrings(markers).slice(0, 12), - messageTextHashes: current.map((item) => item.textHash).slice(0, 12), - traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12), - valuesRedacted: true, - }); - } - current = []; - }; - for (const entry of entries) { - if (entry.kind === "user") { - current.push(entry); - } else { - flush(); - } - } - flush(); - return clusters.slice(0, 20); -} - -function sentinelRange(markers) { - const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b)); - if (unique.length === 0) return null; - return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1]; -} - -function sentinelMarkerNumber(marker) { - const match = String(marker || "").match(/sentinel-(\d+)/u); - return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; -} - -function uniqueStrings(values) { - return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))); -} - -function integerOrNull(value) { - const parsed = Number(value); - return Number.isInteger(parsed) ? parsed : null; -} - -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 gotoProjectMdtodo() { - ensureProjectManagementCommand("gotoProjectMdtodo"); - return gotoTarget("/projects/mdtodo"); -} - -function commandValue(command, keys) { - for (const key of keys) { - const value = command?.[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - } - return ""; -} - -async function visibleLocator(locator) { - return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false); -} - -function normalizedProjectText(value) { - return String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); -} - -function projectSnapshotMatchesCommandSelection(type, raw, selected, targetValue) { - if (!raw || typeof raw !== "object") return false; - const selectedValue = String(selected?.selectedValue || ""); - const normalizedTarget = normalizedProjectText(targetValue); - if (type === "selectMdtodoFile") { - const selectedFileRef = String(raw.selectedFileRefRaw || ""); - const selectedFileLabel = normalizedProjectText(raw.selectedFileLabel); - return Boolean( - (selectedValue && selectedFileRef === selectedValue) - || (normalizedTarget && selectedFileLabel.includes(normalizedTarget)) - ) && Number(raw.fileCount || 0) > 0; - } - if (type === "selectMdtodoSource" || type === "selectProjectSource") { - const selectedSourceId = String(raw.selectedSourceIdRaw || ""); - return Boolean(selectedValue && selectedSourceId === selectedValue) && Number(raw.sourceCount || 0) > 0; - } - return true; -} - -async function waitForProjectCommandSelection({ type, selected, targetValue, timeoutMs = 30000 }) { - const started = Date.now(); - let lastProject = null; - do { - await page.waitForTimeout(300); - lastProject = await projectManagementCommandSnapshot({ includeRaw: true }); - if (projectSnapshotMatchesCommandSelection(type, lastProject, selected, targetValue)) return lastProject; - } while (Date.now() - started < timeoutMs); - return lastProject || await projectManagementCommandSnapshot({ includeRaw: true }); -} - -async function selectHtmlOptionByValueOrLabel(locator, value) { - const select = locator.first(); - const targetValue = typeof value === "string" && value.trim() ? value.trim() : ""; - if (targetValue) { - const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); - if (byValue.ok && byValue.selected.length > 0) return { ok: true, mode: "select-value", selectedValue: byValue.selected[0] || targetValue }; - const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); - if (byLabel.ok && byLabel.selected.length > 0) return { ok: true, mode: "select-label", selectedValue: byLabel.selected[0] || targetValue }; - return await select.evaluate((element, target) => { - const normalize = (raw) => String(raw || "").replace(/\s+/gu, " ").trim().toLowerCase(); - const normalizedTarget = normalize(target); - const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value).map((option) => ({ - value: option.value, - label: String(option.textContent || "").replace(/\s+/gu, " ").trim(), - })); - const matchers = [ - { mode: "select-value-normalized", test: (option) => normalize(option.value) === normalizedTarget }, - { mode: "select-label-normalized", test: (option) => normalize(option.label) === normalizedTarget }, - { mode: "select-label-contains", test: (option) => normalize(option.label).includes(normalizedTarget) }, - { mode: "select-value-contains", test: (option) => normalize(option.value).includes(normalizedTarget) }, - ]; - for (const matcher of matchers) { - const chosen = options.find(matcher.test); - if (!chosen) continue; - element.value = chosen.value; - element.dispatchEvent(new Event("input", { bubbles: true })); - element.dispatchEvent(new Event("change", { bubbles: true })); - return { - ok: true, - mode: matcher.mode, - selectedValue: chosen.value, - selectedLabel: chosen.label, - optionCount: options.length, - optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8), - valuesRedacted: true, - }; - } - return { - ok: false, - mode: "select-target-not-found", - selectedValue: element.value || "", - optionCount: options.length, - optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8), - valuesRedacted: true, - }; - }, targetValue); - } - const selectedValue = await select.evaluate((element) => { - const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value); - const chosen = options[0] || null; - if (!chosen) return ""; - element.value = chosen.value; - element.dispatchEvent(new Event("input", { bubbles: true })); - element.dispatchEvent(new Event("change", { bubbles: true })); - return chosen.value; - }); - return { ok: true, mode: "select-first", selectedValue }; -} - -async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) { - ensureProjectManagementCommand(type); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const targetValue = typeof value === "string" && value.trim() ? value.trim() : null; - if (selectTestId) { - const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]'); - if (await visibleLocator(select)) { - const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || ""); - const afterProjectRaw = await waitForProjectCommandSelection({ type, selected, targetValue: targetValue || "" }); - const selectionMatched = projectSnapshotMatchesCommandSelection(type, afterProjectRaw, selected, targetValue || ""); - const afterProject = sanitizeProjectCommandSnapshot(afterProjectRaw); - if (targetValue && (selected.ok !== true || !selectionMatched)) { - const error = new Error(type + " did not select requested target"); - error.details = { - beforeUrl, - afterUrl: currentPageUrl(), - type, - attr, - selectTestId, - mode: selected.mode, - targetHash: sha256Text(targetValue), - targetPreview: truncate(targetValue, 120), - selected, - beforeProject, - afterProject, - pageId, - valuesRedacted: true - }; - throw error; - } - return { - beforeUrl, - afterUrl: currentPageUrl(), - type, - attr, - mode: selected.mode, - selected: opaqueIdSummary(selected.selectedValue || targetValue), - beforeProject, - afterProject, - pageId, - valuesRedacted: true - }; - } - } - 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]', - selectTestId: "mdtodo-source-select" - }); -} - -async function selectMdtodoSource(command) { - return clickProjectItemByAttr({ - type: "selectMdtodoSource", - attr: "data-source-id", - value: command.sourceId || command.value || command.text || "", - fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', - selectTestId: "mdtodo-source-select" - }); -} - -async function selectMdtodoFile(command) { - return clickProjectItemByAttr({ - type: "selectMdtodoFile", - attr: "data-file-ref", - value: command.fileRef || command.filename || command.value || command.text || "", - fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]', - selectTestId: "mdtodo-file-select" - }); -} - -async function mdtodoTaskLocator(command, options = {}) { - const taskRef = commandValue(command, ["taskRef"]); - const taskId = commandValue(command, ["taskId", "task", "value", "text"]); - const selectors = []; - if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]'); - if (taskId) { - selectors.push('[data-task-id="' + cssEscape(taskId) + '"]'); - selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]'); - } - for (const selector of selectors) { - const locator = page.locator(selector).first(); - if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector }; - } - if (taskId) { - const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first(); - if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId }; - } - if ((taskRef || taskId) && options.allowFallback !== true) { - return { locator: null, taskRef, taskId, selector: "target-task-not-visible", targetMissing: true }; - } - const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first(); - return { locator: fallback, taskRef, taskId, selector: "first-visible-task" }; -} - -async function waitForMdtodoTaskLocator(command, timeoutMs = 30000) { - const started = Date.now(); - let lastProject = null; - let lastTarget = null; - do { - lastTarget = await mdtodoTaskLocator(command, { allowFallback: false }); - if (lastTarget.locator && await visibleLocator(lastTarget.locator)) return { target: lastTarget, project: lastProject }; - lastProject = await projectManagementCommandSnapshot(); - await page.waitForTimeout(500); - } while (Date.now() - started < timeoutMs); - return { target: lastTarget || await mdtodoTaskLocator(command, { allowFallback: false }), project: lastProject || await projectManagementCommandSnapshot() }; -} - -async function selectMdtodoTask(command) { - ensureProjectManagementCommand("selectMdtodoTask"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const hasExplicitTarget = Boolean(commandValue(command, ["taskRef"]) || commandValue(command, ["taskId", "task", "value", "text"])); - const waited = hasExplicitTarget ? await waitForMdtodoTaskLocator(command) : { target: await mdtodoTaskLocator(command, { allowFallback: true }), project: null }; - const target = waited.target; - if (!target.locator) { - const error = new Error("selectMdtodoTask did not find requested task"); - error.details = { - beforeUrl, - afterUrl: currentPageUrl(), - selector: target.selector, - requestedTaskRef: target.taskRef ? opaqueIdSummary(target.taskRef) : null, - requestedTaskId: target.taskId || null, - beforeProject, - afterProject: waited.project, - pageId, - valuesRedacted: true - }; - throw error; - } - await target.locator.waitFor({ state: "visible", timeout: 15000 }); - const clicked = await target.locator.evaluate((element) => ({ - taskRef: element.getAttribute("data-task-ref") || null, - taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null, - status: element.getAttribute("data-task-status") || null, - selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true" - })).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null })); - if (!clicked.selected) { - await target.locator.scrollIntoViewIfNeeded().catch(() => null); - await target.locator.click(); - await page.waitForTimeout(700); - } - const afterProject = await projectManagementCommandSnapshot(); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "selectMdtodoTask", - selector: target.selector, - selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef), - selectedTaskId: clicked.taskId || target.taskId || null, - selectedTaskStatus: clicked.status || null, - alreadySelected: clicked.selected === true, - beforeProject, - afterProject, - pageId, - valuesRedacted: true - }; -} - -async function expandMdtodoTask(command) { - ensureProjectManagementCommand("expandMdtodoTask"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const target = await mdtodoTaskLocator(command); - await target.locator.waitFor({ state: "visible", timeout: 15000 }); - const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first(); - const toggleVisible = await visibleLocator(toggle); - if (toggleVisible) await toggle.click(); - else await target.locator.click(); - await page.waitForTimeout(700); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "expandMdtodoTask", - selector: target.selector, - toggleVisible, - beforeProject, - afterProject: await projectManagementCommandSnapshot(), - pageId, - valuesRedacted: true - }; -} - -async function openMdtodoSourceConfig(command) { - ensureProjectManagementCommand("openMdtodoSourceConfig"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first(); - if (await visibleLocator(existingDialog)) { - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "openMdtodoSourceConfig", - alreadyOpen: true, - beforeProject, - afterProject: await projectManagementCommandSnapshot(), - pageId, - valuesRedacted: true - }; - } - const button = page.locator('[data-testid="mdtodo-source-config-open"]').first(); - await button.waitFor({ state: "visible", timeout: 15000 }); - await button.click(); - await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "openMdtodoSourceConfig", - beforeProject, - afterProject: await projectManagementCommandSnapshot(), - pageId, - valuesRedacted: true - }; -} - -async function ensureMdtodoSourceConfigOpen() { - const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first(); - if (await visibleLocator(form)) return { opened: false }; - await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" }); - return { opened: true }; -} - -async function closeMdtodoSourceConfig(command) { - ensureProjectManagementCommand("closeMdtodoSourceConfig"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const close = await closeMdtodoSourceConfigIfOpen({ required: true }); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "closeMdtodoSourceConfig", - close, - beforeProject, - afterProject: await projectManagementCommandSnapshot(), - pageId, - valuesRedacted: true - }; -} - -async function closeMdtodoSourceConfigIfOpen(options = {}) { - const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({ - has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]') - }).first(); - if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false }; - const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first(); - let closeClick = null; - try { - await closeButton.waitFor({ state: "visible", timeout: 5000 }); - await closeButton.click({ timeout: 5000 }); - closeClick = { attempted: true, ok: true }; - } catch (error) { - closeClick = { attempted: true, ok: false, error: errorSummary(error) }; - await page.keyboard.press("Escape").catch(() => null); - } - await page.waitForTimeout(400); - const stillVisible = await visibleLocator(dialog); - const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true }; - if (options.required && stillVisible) { - const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt"); - error.details = result; - throw error; - } - return result; -} - -async function fillMdtodoField(testId, value) { - if (typeof value !== "string" || !value.trim()) return { testId, filled: false }; - const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); - await locator.waitFor({ state: "visible", timeout: 10000 }); - await locator.fill(value); - return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; -} - -async function clickProjectButtonAndMaybeWait(testId, pathPattern) { - const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); - await button.waitFor({ state: "visible", timeout: 15000 }); - const responsePromise = pathPattern ? page.waitForResponse((response) => { - try { - const pathname = new URL(response.url()).pathname; - return pathPattern.test(pathname); - } catch { - return false; - } - }, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null); - const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) })); - if (buttonState.disabled === true) { - const error = new Error(testId + " button is disabled"); - error.details = { buttonState, valuesRedacted: true }; - throw error; - } - await button.click(); - const response = await responsePromise; - await page.waitForTimeout(900); - return { buttonState, response, valuesRedacted: true }; -} - -async function configureMdtodoHwpodSource(command) { - ensureProjectManagementCommand("configureMdtodoHwpodSource"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const dialog = await ensureMdtodoSourceConfigOpen(); - const fields = [ - await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])), - await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])), - await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])), - await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])), - ]; - const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u); - const close = await closeMdtodoSourceConfigIfOpen(); - return { - beforeUrl, - afterUrl: currentPageUrl(), - type: "configureMdtodoHwpodSource", - dialog, - fields, - save, - close, - beforeProject, - afterProject: await projectManagementCommandSnapshot(), - pageId, - valuesRedacted: true - }; -} - -async function probeMdtodoSource(command) { - ensureProjectManagementCommand("probeMdtodoSource"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - await ensureMdtodoSourceConfigOpen(); - const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u); - const close = await closeMdtodoSourceConfigIfOpen(); - return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function reindexMdtodoSource(command) { - ensureProjectManagementCommand("reindexMdtodoSource"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - await ensureMdtodoSourceConfigOpen(); - const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first(); - const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex"; - const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u); - const close = await closeMdtodoSourceConfigIfOpen(); - return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function selectTaskIfCommandTargetsOne(command) { - if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null; - return selectMdtodoTask(command); -} - -async function selectMdtodoProviderProfileForLaunch(command) { - const provider = commandValue(command, ["provider", "providerProfile"]); - if (!provider) return null; - const select = page.locator('[data-testid="mdtodo-provider-profile"]').first(); - if (!(await visibleLocator(select))) { - return { provider, visible: false, selected: null, valuesRedacted: true }; - } - const selected = await selectHtmlOptionByValueOrLabel(select, provider); - await page.waitForTimeout(300); - return { provider, visible: true, ...selected, valuesRedacted: true }; -} - -async function saveMdtodoTaskWithButton(command, type, testId, fields) { - ensureProjectManagementCommand(type); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const selection = await selectTaskIfCommandTargetsOne(command); - const inlineEditors = []; - for (const field of fields) { - if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId)); - if (field.kind === "fill") await fillMdtodoField(field.testId, field.value); - if (field.kind === "select") { - const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first(); - await locator.waitFor({ state: "visible", timeout: 10000 }); - await selectHtmlOptionByValueOrLabel(locator, field.value); - } - } - const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u); - return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) { - const candidates = (testIds || []).filter(Boolean); - for (const testId of candidates) { - const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); - if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern); - } - if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid"); - return clickProjectButtonAndMaybeWait(candidates[0], pathPattern); -} - -async function ensureMdtodoInlineEditor(editorTestId, readTestId) { - const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first(); - if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false }; - const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first(); - await read.waitFor({ state: "visible", timeout: 10000 }); - await read.dblclick(); - await editor.waitFor({ state: "visible", timeout: 10000 }); - return { editorTestId, readTestId, opened: true }; -} - -async function editMdtodoTaskTitle(command) { - const title = commandValue(command, ["title", "text", "value"]); - if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); -} - -async function editMdtodoTaskBody(command) { - const body = commandValue(command, ["text", "body", "value"]); - if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); -} - -async function toggleMdtodoTaskStatus(command) { - const status = commandValue(command, ["status", "value", "text"]); - if (!status) throw new Error("toggleMdtodoTaskStatus requires --status"); - return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); -} - -async function editMdtodoTaskInline(command) { - const field = commandValue(command, ["field", "value"]).toLowerCase(); - if (field === "title") { - const title = commandValue(command, ["title", "text"]); - if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); - } - if (field === "body" || field === "content") { - const body = commandValue(command, ["body", "text"]); - if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); - } - if (field === "status") { - const status = commandValue(command, ["status", "text"]); - if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); - } - throw new Error("editMdtodoTaskInline requires --field title, body, or status"); -} - -async function openMdtodoReportPreview(command) { - ensureProjectManagementCommand("openMdtodoReportPreview"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const selection = await selectTaskIfCommandTargetsOne(command); - const linkText = commandValue(command, ["link", "value", "text"]); - const links = page.locator('[data-testid="mdtodo-report-link"]'); - await links.first().waitFor({ state: "visible", timeout: 15000 }); - const count = await links.count(); - let index = 0; - if (linkText) { - for (let i = 0; i < count; i += 1) { - const item = links.nth(i); - const text = await item.textContent().catch(() => ""); - if (String(text || "").includes(linkText)) { - index = i; - break; - } - } - } - const link = links.nth(index); - const linkStateRaw = await link.evaluate((element, selectedIndex) => ({ - index: selectedIndex, - disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", - text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240), - valuesRedacted: true - }), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true })); - const linkState = { - ...linkStateRaw, - textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null, - textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null, - text: undefined, - valuesRedacted: true - }; - if (linkState.disabled === true) { - const error = new Error("openMdtodoReportPreview selected report link is disabled"); - error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true }; - throw error; - } - const attempts = []; - let response = null; - let afterProject = null; - for (let attempt = 1; attempt <= 3; attempt += 1) { - const responsePromise = page.waitForResponse((item) => { - try { - const request = item.request(); - return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview"; - } catch { - return false; - } - }, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })); - const clickMode = attempt === 1 ? "normal" : "force"; - let click = { ok: true, mode: clickMode, valuesRedacted: true }; - await link.scrollIntoViewIfNeeded().catch(() => null); - try { - if (attempt === 1) await link.click(); - else await link.click({ force: true, timeout: 5000 }); - } catch (error) { - click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true }; - await link.evaluate((element) => element.click()).catch(() => null); - } - await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null); - afterProject = await projectManagementCommandSnapshot(); - response = afterProject?.reportPreviewVisible === true - ? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true } - : await responsePromise; - attempts.push({ - attempt, - click, - response, - afterUrl: currentPageUrl(), - reportPreviewVisible: afterProject?.reportPreviewVisible === true, - reportErrorVisible: afterProject?.reportErrorVisible === true, - reportFullscreenVisible: afterProject?.reportFullscreenVisible === true, - valuesRedacted: true - }); - if (afterProject?.reportPreviewVisible === true) break; - await page.waitForTimeout(700).catch(() => null); - } - afterProject = afterProject ?? await projectManagementCommandSnapshot(); - if (afterProject?.reportPreviewVisible !== true) { - const error = new Error("openMdtodoReportPreview did not show markdown report preview"); - error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; - throw error; - } - return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; -} - -async function toggleMdtodoReportFullscreen(command) { - ensureProjectManagementCommand("toggleMdtodoReportFullscreen"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const desired = commandValue(command, ["value", "text"]).toLowerCase(); - const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first(); - const initiallyOpen = await visibleLocator(dialog); - let action = "open"; - if (desired === "close" || desired === "off") action = "close"; - else if (desired === "toggle") action = initiallyOpen ? "close" : "open"; - if (action === "close") { - const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first(); - await closeButton.waitFor({ state: "visible", timeout: 10000 }); - await closeButton.click(); - } else if (!initiallyOpen) { - const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first(); - await button.waitFor({ state: "visible", timeout: 10000 }); - await button.click(); - } - await page.waitForTimeout(500); - return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function fillNewTaskDraft(command) { - const title = commandValue(command, ["title", "text", "value"]); - if (!title) throw new Error(command.type + " requires --title or --text"); - const fields = [await fillMdtodoField("mdtodo-new-title", title)]; - const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : ""); - if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body)); - return fields; -} - -async function addMdtodoTaskWithButton(command, type, testId) { - ensureProjectManagementCommand(type); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command); - const fields = await fillNewTaskDraft(command); - const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u); - return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function addMdtodoRootTask(command) { - return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root"); -} - -async function addMdtodoSubTask(command) { - return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask"); -} - -async function continueMdtodoTask(command) { - return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task"); -} - -async function deleteMdtodoTask(command) { - ensureProjectManagementCommand("deleteMdtodoTask"); - const beforeUrl = currentPageUrl(); - const beforeProject = await projectManagementCommandSnapshot(); - const selection = await selectTaskIfCommandTargetsOne(command); - const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null); - let confirmClick = null; - const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()); - if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u); - return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; -} - -async function launchWorkbenchFromTask(command) { - const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask"; - ensureProjectManagementCommand(commandType); - const beforeUrl = currentPageUrl(); - const initialProject = await projectManagementCommandSnapshot({ includeRaw: true }); - const requestedSourceId = commandValue(command, ["sourceId", "mdtodoSourceId"]); - const requestedFileRef = commandValue(command, ["fileRef"]); - const requestedFilename = commandValue(command, ["filename", "fileName"]); - const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null; - const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null; - const needsMdtodoSelection = Boolean(requestedFileRef || requestedFilename || requestedTaskRef || requestedTaskId); - const sourceSelection = requestedSourceId && initialProject.selectedSourceIdRaw !== requestedSourceId - ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: requestedSourceId }) - : !requestedSourceId && needsMdtodoSelection && !initialProject.selectedSourceIdRaw - ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: "" }) - : null; - const fileProject = sourceSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : initialProject; - const fileSelection = (requestedFileRef && fileProject.selectedFileRefRaw !== requestedFileRef) || requestedFilename - ? await selectMdtodoFile({ ...command, type: "selectMdtodoFile", fileRef: requestedFileRef || command.fileRef, filename: requestedFilename || command.filename }) - : null; - const taskProject = fileSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : fileProject; - const taskSelection = (requestedTaskRef && taskProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId - ? await selectMdtodoTask({ ...command, type: "selectMdtodoTask", taskRef: requestedTaskRef || command.taskRef, taskId: requestedTaskId || command.taskId }) - : null; - const providerSelection = await selectMdtodoProviderProfileForLaunch(command); - 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) })); - const chatResponsePromise = page.waitForResponse((response) => { - const request = response.request(); - if (request.method().toUpperCase() !== "POST") return false; - try { - return new URL(response.url()).pathname === "/v1/agent/chat"; - } catch { - return false; - } - }, { timeout: 30000 }).then(async (response) => { - let chatPayload = null; - let chatPayloadError = null; - try { - chatPayload = await response.json(); - } catch (error) { - chatPayloadError = errorSummary(error); - } - const headers = response.headers(); - return { - observed: true, - status: response.status(), - statusText: response.statusText(), - path: new URL(response.url()).pathname, - sessionId: sessionIdFromAgentSessionPayload(chatPayload), - traceId: traceIdFromAgentChatPayload(chatPayload), - otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null, - responseParsed: chatPayload !== null, - responseParseError: chatPayloadError, - valuesRedacted: true - }; - }).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true })); - 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); - } - const chat = await chatResponsePromise; - await page.waitForTimeout(1500); - const workbenchSnapshot = await workbenchSessionSnapshot(); - return { - beforeUrl, - afterUrl: currentPageUrl(), - launchPath, - launchStatus, - statusText: launchResponse.statusText(), - contractVersion, - sessionId, - workbenchUrl, - otelTraceId: launchTraceHeader, - chatObserved: chat?.observed === true, - chatStatus: chat?.status ?? null, - chatSessionId: chat?.sessionId ?? null, - chatTraceId: chat?.traceId ?? null, - chatOtelTraceId: chat?.otelTraceId ?? null, - chat, - workbenchSnapshot, - selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw), - selection: { - source: sourceSelection, - file: fileSelection, - task: taskSelection, - provider: providerSelection, - valuesRedacted: true - }, - initialProject: sanitizeProjectCommandSnapshot(initialProject), - projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick), - buttonState, - responseParsed: payload !== null, - responseParseError, - pageId, - valuesRedacted: true - }; -} - -async function launchWorkbenchFromMdtodo(command) { - return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" }); -} - -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 sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); - const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); - const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; - const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; - const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ - value: option.value, - label: text(option), - selected: option.selected === true - })) : []; - const selectedFileOption = fileOptions.find((option) => option.selected) || null; - const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); - const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); - const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); - const reportError = document.querySelector('[data-testid="mdtodo-report-error"]'); - const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); - const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); - 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: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount), - fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount), - taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length, - selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null, - selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null, - selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null, - fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20), - selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null, - selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null, - selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, - sourceSelectVisible: visible(sourceSelect), - fileSelectVisible: visible(fileSelect), - sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), - taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), - taskBodyVisible: visible(bodyRendered), - taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "", - newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), - reportLinkCount: reportLinks.length, - reportPreviewVisible: visible(reportPreview), - reportPreviewText: visible(reportPreview) ? text(reportPreview) : "", - reportErrorVisible: visible(reportError), - reportErrorText: visible(reportError) ? text(reportError) : "", - reportFullscreenVisible: visible(reportFullscreen), - 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; - const textDigest = (raw, limit = 160) => { - const text = String(raw || ""); - return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true }; - }; - const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); - const suspiciousFileLabel = (label) => { - const text = String(label || "").trim(); - return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); - }; - const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; - return { - ...value, - selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw), - selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw), - selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw), - selectedSourceIdRaw: undefined, - selectedFileRefRaw: undefined, - selectedTaskRefRaw: undefined, - selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, - selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, - fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)), - fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, - fileOptionLabels: undefined, - taskBodyText: undefined, - taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, - reportPreviewText: undefined, - reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, - reportErrorText: undefined, - reportError: value.reportErrorText ? textDigest(value.reportErrorText, 200) : null, - 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) }; -} - -async function samplePage(reason, options = {}) { - if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason); - const groupSeq = sampleSeq + 1; - if (page && !page.isClosed()) { - await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }) - .catch((error) => appendJsonl(files.errors, eventRecord("control-sample-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) }))); - } - if (observerPage && !observerPage.isClosed()) { - await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) }))); - } - if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) { - await captureScreenshot("checkpoint", "jpeg") - .catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) }))); - } - await writeHeartbeat({ status: terminalStatus }); -} - -async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) { - sampleSeq += 1; - const evaluateTimeoutMs = Math.max(3000, Math.min(8000, Number(sampleIntervalMs) || 5000)); - const dom = await withHardTimeout(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"; - }; - const textHashInput = (element) => trim(element.textContent || "", 800); - const loadingTextPattern = /加载中|\bLoading\b/iu; - const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu; - const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]"; - const elementTextForLoading = (element) => [ - element.textContent || "", - element.getAttribute("aria-label") || "", - element.getAttribute("title") || "", - element.getAttribute("data-testid") || "" - ].map((value) => trim(value, 240)).filter(Boolean).join(" "); - const elementLooksLikeLoadingUi = (element) => { - const signal = [ - element.getAttribute("class") || "", - element.getAttribute("data-testid") || "", - element.getAttribute("role") || "", - element.getAttribute("aria-busy") || "", - element.getAttribute("aria-label") || "", - element.getAttribute("title") || "" - ].join(" "); - return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal); - }; - const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element); - const hasLoadingText = (element) => { - const text = elementTextForLoading(element); - if (!loadingTextPattern.test(text)) return false; - if (elementIsCodeLike(element)) return false; - return true; - }; - const elementDescriptor = (element) => { - if (!element) return null; - const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" "); - const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]"); - return { - tag: element.tagName.toLowerCase(), - testId: element.getAttribute("data-testid") || null, - role: element.getAttribute("role") || null, - id: element.getAttribute("id") || null, - className: className || null, - status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, - sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null, - messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null, - traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null, - ariaLabel: element.getAttribute("aria-label") || null - }; - }; - const ownerKindFor = (element) => { - const value = [ - element.getAttribute("data-testid") || "", - element.getAttribute("class") || "", - element.getAttribute("role") || "", - element.tagName || "" - ].join(" ").toLowerCase(); - if (/message|turn|agent|assistant/.test(value)) return "turn"; - if (/session|rail|sidebar|nav/.test(value)) return "session-nav"; - if (/composer|prompt|input|textarea/.test(value)) return "composer"; - if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic"; - if (/trace|event|terminal|log/.test(value)) return "trace"; - if (/performance|metric|chart|table/.test(value)) return "performance"; - if (/main|workspace|workbench|root/.test(value)) return "workbench"; - return "unknown"; - }; - const ownerLabelFor = (element) => { - const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]"); - return trim( - element.getAttribute("aria-label") - || element.getAttribute("data-testid") - || (heading ? heading.textContent || "" : "") - || element.getAttribute("class") - || element.tagName, - 160 - ); - }; - const ownerKeyFor = (element) => { - const descriptor = elementDescriptor(element) || {}; - return [ - ownerKindFor(element), - descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown", - descriptor.sessionId || descriptor.messageId || descriptor.traceId || "" - ].filter(Boolean).join(":").slice(0, 240); - }; - const collectLoadingNodes = () => { - const candidates = Array.from(document.querySelectorAll("body *")) - .filter(visible) - .filter(hasLoadingText) - .filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child))) - .slice(-80); - return candidates.map((element, index) => { - const rect = element.getBoundingClientRect(); - const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]'); - const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]'); - const owner = identityOwner || structuralOwner || element; - const ownerDescriptor = elementDescriptor(owner); - return { - index, - tag: element.tagName.toLowerCase(), - className: String(element.className || "").slice(0, 180), - testId: element.getAttribute("data-testid"), - role: element.getAttribute("role"), - text: trim(elementTextForLoading(element), 240), - ownerKind: ownerKindFor(owner), - ownerKey: ownerKeyFor(owner), - ownerLabel: ownerLabelFor(owner), - owner: ownerDescriptor, - ownerText: trim(owner.textContent || "", 300), - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }; - }); - }; - const sessionIdForElement = (element) => { - const direct = element.getAttribute("data-session-id"); - if (direct) return direct; - const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; - if (!href) return null; - try { - const parsed = new URL(href, location.href); - const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); - return match ? decodeURIComponent(match[1] || "") : null; - } catch { - const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); - return match ? decodeURIComponent(match[1] || "") : null; - } - }; - const sessionTitleTextForElement = (element) => { - const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]"); - return trim( - (titleNode ? titleNode.textContent || "" : "") - || element.getAttribute("aria-label") - || element.getAttribute("title") - || element.textContent - || "", - 240 - ); - }; - const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu; - const looksLikeSessionTitleFallback = (title, sessionId) => { - const text = trim(title, 240); - const id = String(sessionId || "").trim(); - if (!text) return true; - if (sessionTitleFallbackPattern.test(text)) return true; - if (!id) return false; - return text === id || text.startsWith(id) || text.startsWith("Session " + id); - }; - const collectSessionRailTitles = () => { - const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']")) - .filter(visible); - const seen = new Set(); - const items = []; - for (const element of candidates) { - const sessionId = sessionIdForElement(element); - const titleText = sessionTitleTextForElement(element); - const key = (sessionId || "") + "|" + titleText; - if (seen.has(key)) continue; - seen.add(key); - const rect = element.getBoundingClientRect(); - const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId); - const dataStatus = element.getAttribute("data-status") || element.getAttribute("data-state") || null; - const dataRunning = element.getAttribute("data-running") || element.getAttribute("data-busy") || null; - const ariaBusy = element.getAttribute("aria-busy") || null; - items.push({ - index: items.length, - tag: element.tagName.toLowerCase(), - testId: element.getAttribute("data-testid"), - role: element.getAttribute("role"), - active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", - status: dataStatus || ariaBusy || null, - dataStatus, - running: dataRunning === "true" || ariaBusy === "true" || element.classList.contains("is-running") || element.classList.contains("running"), - dataRunning, - ariaBusy, - sessionId, - sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null, - titleText, - fallbackTitle, - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }); - } - const visibleItems = items.slice(0, 120); - const fallbackItems = visibleItems.filter((item) => item.fallbackTitle); - const visibleCount = visibleItems.length; - const fallbackTitleCount = fallbackItems.length; - return { - visibleCount, - fallbackTitleCount, - fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0, - activeItem: visibleItems.find((item) => item.active) || null, - items: visibleItems.slice(0, 60), - fallbackItems: fallbackItems.slice(0, 12), - }; - }; - const diagnosticSummaryText = (element) => { - const summarySelectors = [ - ".api-error-diagnostic-summary-text p", - ".api-error-diagnostic-summary-text", - "[class*='diagnostic-summary-text' i] p", - "[class*='diagnostic-summary-text' i]", - "[data-testid*='diagnostic-summary' i]", - "[data-testid*='error-summary' i]", - "[role='alert'] p", - "[role='alert']" - ]; - const parts = []; - for (const selector of summarySelectors) { - for (const candidate of Array.from(element.querySelectorAll(selector))) { - if (!visible(candidate)) continue; - const text = trim(candidate.textContent || "", 800); - if (text && !parts.includes(text)) parts.push(text); - } - } - const ownText = textHashInput(element); - const text = parts.length > 0 ? parts.join(" ") : ownText; - return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim(); - }; - const diagnosticToggleOnly = (element, text) => { - const compact = String(text || "").trim(); - if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false; - const tag = element.tagName.toLowerCase(); - const role = element.getAttribute("role"); - const aria = element.getAttribute("aria-label") || ""; - const title = element.getAttribute("title") || ""; - return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title); - }; - const messageBodyTextDetail = (element) => { - const bodySelectors = [ - ".message-markdown.message-text", - ".message-text", - "[data-message-body]", - "[data-testid='message-body']", - "[data-testid*='message-text' i]", - "[data-testid*='final-response' i]" - ]; - const parts = []; - for (const selector of bodySelectors) { - for (const candidate of Array.from(element.querySelectorAll(selector))) { - if (!visible(candidate)) continue; - const text = trim(candidate.textContent || "", 1200); - if (text && !parts.some((part) => part.text === text)) parts.push({ text, selector }); - } - } - if (parts.length > 0) { - return { - text: parts.map((part) => part.text).join(" "), - source: parts[0]?.selector || "body-selector", - candidateCount: parts.length - }; - } - return { text: "", source: null, candidateCount: 0 }; - }; - const stableMessageText = (element) => { - const body = messageBodyTextDetail(element); - if (body.text) return body.text; - const clone = element.cloneNode(true); - for (const selector of [ - ".message-duration-meta", - ".message-activity-meta", - ".api-error-diagnostic", - "[class*='diagnostic' i]", - "[class*='trace' i]", - "[data-trace-id]", - "[data-testid*='trace' i]", - "[data-testid*='event' i]", - "[role='status']", - "[role='alert']", - "button" - ]) { - for (const child of Array.from(clone.querySelectorAll(selector))) child.remove(); - } - return trim(clone.textContent || "", 1200); - }; - const numericAttr = (element, names) => { - for (const name of names) { - const raw = element.getAttribute(name); - const value = Number(raw); - if (Number.isFinite(value)) return value; - } - return null; - }; - const textAttr = (element, names) => { - for (const name of names) { - const raw = element.getAttribute(name); - if (raw && String(raw).trim()) return String(raw).trim(); - } - return null; - }; - const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => { - const rect = element.getBoundingClientRect(); - const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]'); - const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]"); - return { - index, - tag: element.tagName.toLowerCase(), - testId: element.getAttribute("data-testid"), - role: element.getAttribute("role"), - status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, - sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null, - messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null, - traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null, - turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null, - projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]), - sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]), - eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]), - eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null), - eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null, - eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null, - text: textHashInput(element), - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }; - }); - const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit); - const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { - const rect = element.getBoundingClientRect(); - const body = messageBodyTextDetail(element); - return { - index, - tag: element.tagName.toLowerCase(), - testId: element.getAttribute("data-testid"), - role: element.getAttribute("role"), - dataRole: element.getAttribute("data-role"), - status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, - sessionId: element.getAttribute("data-session-id") || null, - messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, - traceId: element.getAttribute("data-trace-id") || null, - turnId: element.getAttribute("data-turn-id") || null, - durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120), - activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120), - bodyText: body.text, - bodyTextSource: body.source, - bodyTextCandidateCount: body.candidateCount, - text: stableMessageText(element), - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }; - }); - const resourceTimingSample = (entry) => ({ - name: entry.name.split(/[?#]/u)[0].slice(0, 240), - initiatorType: entry.initiatorType, - startTime: Math.round(entry.startTime), - duration: Math.round(entry.duration), - workerStart: Math.round(entry.workerStart || 0), - redirectStart: Math.round(entry.redirectStart || 0), - redirectEnd: Math.round(entry.redirectEnd || 0), - fetchStart: Math.round(entry.fetchStart || 0), - domainLookupStart: Math.round(entry.domainLookupStart || 0), - domainLookupEnd: Math.round(entry.domainLookupEnd || 0), - connectStart: Math.round(entry.connectStart || 0), - connectEnd: Math.round(entry.connectEnd || 0), - secureConnectionStart: Math.round(entry.secureConnectionStart || 0), - requestStart: Math.round(entry.requestStart || 0), - responseStart: Math.round(entry.responseStart || 0), - responseEnd: Math.round(entry.responseEnd || 0), - transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null, - encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null, - decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null, - nextHopProtocol: entry.nextHopProtocol || null, - responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null, - serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({ - name: String(item.name || "").slice(0, 80), - duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null, - 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 sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); - const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); - const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; - const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; - const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ - value: option.value, - label: trim(option.textContent || option.label || "", 180), - selected: option.selected === true, - })) : []; - const selectedFileOption = fileOptions.find((option) => option.selected) || null; - 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 bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); - const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); - const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); - const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); - 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); - const measurePaneGap = (name, paneSelector, contentSelector) => { - const pane = document.querySelector(paneSelector); - if (!visible(pane)) return { name, visible: false }; - const rect = pane.getBoundingClientRect(); - const contentNodes = Array.from(pane.querySelectorAll(contentSelector)).filter(visible); - const contentBottom = Math.max(rect.top, ...contentNodes.map((element) => element.getBoundingClientRect().bottom)); - const bottomGapPx = Math.max(0, Math.round(rect.bottom - contentBottom)); - const heightPx = Math.max(0, Math.round(rect.height)); - return { - name, - visible: true, - widthPx: Math.max(0, Math.round(rect.width)), - heightPx, - bottomGapPx, - bottomGapRatio: heightPx > 0 ? Number((bottomGapPx / heightPx).toFixed(3)) : 0, - contentNodeCount: contentNodes.length, - }; - }; - const paneGaps = [ - measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', '[data-task-ref], [role="treeitem"], [role="listitem"], li, button, input, select, .task-row-shell, .task-tools'), - measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'), - measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'), - ]; - 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: Math.max(sourceItems.length, sourceOptionCount), - fileCount: Math.max(fileItems.length, fileOptionCount), - taskCount: taskItems.length, - taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length), - selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value), - selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value), - selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null, - fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24), - selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")), - selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, - sourceSelectVisible: visible(sourceSelect), - fileSelectVisible: visible(fileSelect), - sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), - taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), - taskBodyVisible: visible(bodyRendered), - taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "", - newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), - taskStatusCounts: statusCounts, - reportLinkCount: reportLinks.length, - reportPreviewVisible: visible(reportPreview), - reportPreviewText: visible(reportPreview) ? trim(reportPreview.textContent || "", 500) : "", - reportFullscreenVisible: visible(reportFullscreen), - launchButtonVisible: visible(launch), - launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", - launchButtonText: trim(launch?.textContent || "", 120), - blockerCount: blockers.length, - blockers, - paneGaps, - 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]'); - const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null; - const commandInput = document.querySelector("#command-input"); - const commandSubmit = document.querySelector('#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'); - const composerWarning = document.querySelector(".composer-warning"); - const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]'; - const stableTraceSelector = 'li.trace-render-row[data-row-id], li.trace-render-row[data-testid="trace-render-row"], [data-testid="trace-render-row"][data-row-id]'; - const fallbackTraceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; - const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]'; - const messages = summarizeMessages(messageSelector, 80); - const stableTraceElements = Array.from(document.querySelectorAll(stableTraceSelector)); - const fallbackTraceElements = Array.from(document.querySelectorAll(fallbackTraceSelector)).filter((element) => element.matches('li,[role="listitem"],[data-testid*="trace-row" i],[data-testid*="event-row" i]')); - const traceRows = summarizeElements(stableTraceElements.length > 0 ? stableTraceElements : fallbackTraceElements, 30); - const loadings = collectLoadingNodes(); - const sessionRail = collectSessionRailTitles(); - const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => { - const rect = element.getBoundingClientRect(); - const text = diagnosticSummaryText(element); - if (!text || diagnosticToggleOnly(element, text)) return null; - const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu); - const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); - const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu); - const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu); - const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu); - const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic"; - return { - index, - tag: element.tagName.toLowerCase(), - className: String(element.className || "").slice(0, 240), - testId: element.getAttribute("data-testid"), - role: element.getAttribute("role"), - compact: element.getAttribute("data-compact"), - expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"), - title: element.getAttribute("title"), - diagnosticCode, - traceId: traceMatch?.[1] || null, - httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null, - idleSeconds: idleMatch ? Number(idleMatch[1]) : null, - waitingFor: waitingForMatch?.[1] || null, - lastEventLabel: lastEventLabelMatch?.[1] || null, - text, - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }; - }).filter(Boolean); - const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { - const rect = element.getBoundingClientRect(); - const text = textHashInput(element); - const finalResponse = messageBodyTextDetail(element); - const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]"); - const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u); - const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120); - const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120); - const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null; - return { - index, - role: element.getAttribute("data-role") || "agent", - status: element.getAttribute("data-status") || null, - sessionId: element.getAttribute("data-session-id") || null, - messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, - traceId: directTraceId, - turnId: element.getAttribute("data-turn-id") || directTraceId, - durationText, - activityText, - finalResponseText: finalResponse.text, - finalResponseTextSource: finalResponse.source, - finalResponseCandidateCount: finalResponse.candidateCount, - finalResponsePresent: Boolean(finalResponse.text && finalResponse.text.trim()), - text, - rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - }; - }).slice(-80); - const active = document.activeElement; - return { - url, - path: location.pathname, - routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null, - activeSessionId, - title: document.title, - focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null, - viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }, - scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) }, - messages, - traceRows, - loadings, - sessionRail, - diagnostics, - turns, - composer: { - inputPresent: visible(commandInput), - inputDisabled: Boolean(commandInput?.disabled) || commandInput?.getAttribute("aria-disabled") === "true", - warningPresent: visible(composerWarning), - warningText: trim(composerWarning?.textContent || "", 160), - submitPresent: visible(commandSubmit), - submitDisabled: Boolean(commandSubmit?.disabled) || commandSubmit?.getAttribute("aria-disabled") === "true", - submitAction: commandSubmit?.getAttribute("data-action") || null, - submitText: trim(commandSubmit?.textContent || "", 80), - submitTestId: commandSubmit?.getAttribute("data-testid") || null, - activeStatus: activeSession?.getAttribute("data-status") || null, - valuesRedacted: true - }, - projectManagement: collectProjectManagement(), - pageProvenance: { - url: location.href, - path: location.pathname, - title: document.title, - readyState: document.readyState, - timeOrigin: Math.round(performance.timeOrigin || 0), - navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null, - scripts: Array.from(document.scripts).map((element) => { - if (!element.src) return null; - try { - const url = new URL(element.src, location.href); - const keys = Array.from(url.searchParams.keys()).sort(); - return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); - } catch { - return null; - } - }).filter(Boolean).sort(), - stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => { - try { - const url = new URL(element.href, location.href); - const keys = Array.from(url.searchParams.keys()).sort(); - return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); - } catch { - return null; - } - }).filter(Boolean).sort(), - meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ - key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), - content: String(element.getAttribute("content") || "").slice(0, 200), - })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)), - }, - performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample), - }; - }, { projectManagement }), evaluateTimeoutMs, "sampleOnePage DOM evaluate exceeded " + evaluateTimeoutMs + "ms").catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) })); - const sample = { - seq: sampleSeq, - sampleGroupSeq: groupSeq, - ts: new Date().toISOString(), - reason, - pageRole, - pageId: targetPageId, - pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, - commandId: activeCommandId, - observerInitiated: false, - ...digestDom(dom, pageRole), - }; - await appendJsonl(files.samples, sample); -} - -function digestDom(dom, pageRole = "control") { - if (dom && dom.error) return dom; - const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), bodyTextHash: sha256Text(item.bodyText || ""), bodyTextPreview: truncate(item.bodyText || "", 160), bodyTextBytes: Buffer.byteLength(item.bodyText || ""), bodyTextPresent: Boolean(String(item.bodyText || "").trim()) })) : []; - const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; - const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : []; - 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 || ""), finalResponseTextHash: sha256Text(item.finalResponseText || ""), finalResponseTextPreview: truncate(item.finalResponseText || "", 200), finalResponseTextBytes: Buffer.byteLength(item.finalResponseText || ""), finalResponsePresent: Boolean(String(item.finalResponseText || "").trim()) })) : []; - 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, 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 }; - }; - const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); - const suspiciousFileLabel = (label) => { - const text = String(label || "").trim(); - return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); - }; - const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; - 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), - selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, - selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, - fileOptionLabelSamples: fileLabels.slice(0, 10).map((item) => textDigest(item, 120)), - fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, - selectedTaskRef: opaque(value.selectedTaskRef), - selectedTaskStatus: value.selectedTaskStatus ?? null, - sourceSelectVisible: value.sourceSelectVisible === true, - fileSelectVisible: value.fileSelectVisible === true, - sourceConfigVisible: value.sourceConfigVisible === true, - taskEditorVisible: value.taskEditorVisible === true, - taskBodyVisible: value.taskBodyVisible === true, - taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, - newTaskDraftVisible: value.newTaskDraftVisible === true, - taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {}, - reportLinkCount: Number.isFinite(Number(value.reportLinkCount)) ? Number(value.reportLinkCount) : 0, - reportPreviewVisible: value.reportPreviewVisible === true, - reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, - reportFullscreenVisible: value.reportFullscreenVisible === true, - 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), - })) : [], - paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({ - name: item?.name ?? null, - visible: item?.visible === true, - widthPx: Number.isFinite(Number(item?.widthPx)) ? Number(item.widthPx) : null, - heightPx: Number.isFinite(Number(item?.heightPx)) ? Number(item.heightPx) : null, - bottomGapPx: Number.isFinite(Number(item?.bottomGapPx)) ? Number(item.bottomGapPx) : null, - bottomGapRatio: Number.isFinite(Number(item?.bottomGapRatio)) ? Number(item.bottomGapRatio) : null, - contentNodeCount: Number.isFinite(Number(item?.contentNodeCount)) ? Number(item.contentNodeCount) : null, - valuesRedacted: true, - })) : [], - workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0, - valuesRedacted: true - }; -} - -function digestSessionRail(value) { - if (!value || typeof value !== "object") return null; - const items = Array.isArray(value.items) ? value.items.map((item) => { - const titleText = String(item?.titleText || item?.titlePreview || ""); - return { - index: item?.index ?? null, - tag: item?.tag ?? null, - testId: item?.testId ?? null, - role: item?.role ?? null, - active: item?.active === true, - status: item?.status ?? null, - dataStatus: item?.dataStatus ?? null, - running: item?.running === true, - dataRunning: item?.dataRunning ?? null, - ariaBusy: item?.ariaBusy ?? null, - sessionId: item?.sessionId ?? null, - sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), - fallbackTitle: item?.fallbackTitle === true, - titleHash: sha256Text(titleText), - titlePreview: truncate(titleText, 160), - titleBytes: Buffer.byteLength(titleText), - rect: item?.rect ?? null, - }; - }) : []; - const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12); - const visibleCount = Number(value.visibleCount ?? items.length); - const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length); - return { - visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length, - fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length, - fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0), - activeItem: items.find((item) => item.active) || null, - items, - fallbackItems, - valuesRedacted: true, - }; -} - -async function captureScreenshot(reason, imageType = "png") { - if (!page || page.isClosed()) throw new Error("page is not available for screenshot"); - if (screenshotCaptureState && screenshotCaptureState.settled !== true) { - const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now()); - const error = new Error("screenshot capture already in progress"); - error.details = { - reason, - currentUrl: currentPageUrl(), - pageId, - activeReason: screenshotCaptureState.reason, - activeStartedAt: screenshotCaptureState.startedAt, - activeAgeMs: ageMs, - activeTimedOut: screenshotCaptureState.timedOut === true, - timeoutMs: screenshotCaptureTimeoutMs, - valuesRedacted: true, - }; - lastScreenshotAtMs = Date.now(); - throw error; - } - artifactSeq += 1; - const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual"; - const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png"; - const ext = type === "jpeg" ? "jpg" : "png"; - const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext); - const timeoutMs = screenshotCaptureTimeoutMs; - const options = type === "jpeg" - ? { path: file, type: "jpeg", quality: 70, fullPage: false, animations: "disabled", timeout: timeoutMs } - : { path: file, type: "png", fullPage: false, animations: "disabled", timeout: timeoutMs }; - const state = { reason, startedAtMs: Date.now(), startedAt: new Date().toISOString(), timeoutMs, settled: false, timedOut: false }; - screenshotCaptureState = state; - const screenshotPromise = page.screenshot(options) - .then((value) => { - state.settled = true; - return value; - }) - .catch((error) => { - state.settled = true; - throw error; - }) - .finally(() => { - if (screenshotCaptureState === state) screenshotCaptureState = null; - }); - try { - await withHardTimeout(screenshotPromise, timeoutMs + 1000, "captureScreenshot " + safeReason + " exceeded " + timeoutMs + "ms"); - const meta = await fileMeta(file); - const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl(), timeoutMs }; - await appendJsonl(files.artifacts, artifact); - lastScreenshotAtMs = Date.now(); - return artifact; - } catch (error) { - if (String(error?.message || "").includes("exceeded " + timeoutMs + "ms")) state.timedOut = true; - lastScreenshotAtMs = Date.now(); - const wrapped = error instanceof Error ? error : new Error(String(error)); - wrapped.details = { - ...(wrapped.details || {}), - reason, - currentUrl: currentPageUrl(), - pageId, - timeoutMs, - file, - valuesRedacted: true, - }; - throw wrapped; - } -} - -async function captureCommandScreenshot(command) { - const shouldWaitProject = command.waitProjectManagementReady === true; - const readiness = shouldWaitProject ? await waitForProjectManagementCommandReady({ timeoutMs: 15000 }) : null; - if (readiness && readiness.ok !== true) { - const error = new Error("screenshot project-management readiness wait failed: " + (readiness.reason || "not-ready")); - error.details = { readiness, currentUrl: currentPageUrl(), pageId, valuesRedacted: true }; - throw error; - } - const artifact = await captureScreenshot(command.reason || command.label || "manual", command.imageType || "png"); - return { ...artifact, readiness, valuesRedacted: true }; -} - -function eventRecord(type, data) { - const clean = sanitize(data) || {}; - return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean }; -} - -async function summarizeWorkbenchResponseBody(response, request) { - const method = String(request.method() || "GET").toUpperCase(); - const path = safeUrlPath(response.url()) || ""; - const resourceType = String(request.resourceType() || ""); - const status = Number(response.status()); - if (!shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status })) return { bodyRead: false }; - const headers = response.headers(); - const contentType = String(headers["content-type"] || headers["Content-Type"] || ""); - if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true }; - const contentLength = Number(headers["content-length"] || headers["Content-Length"]); - const maxBytes = 512 * 1024; - if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true }; - const text = await response.text(); - const byteCount = Buffer.byteLength(text); - if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true }; - let parsed = null; - try { - parsed = JSON.parse(text); - } catch (error) { - return { bodyRead: true, bodyReadSkipped: "json-parse-error", bodyByteCount: byteCount, bodyHash: sha256Text(text), bodyParseError: errorSummary(error), valuesRedacted: true }; - } - return { - bodyRead: true, - bodyByteCount: byteCount, - bodyHash: sha256Text(text), - bodySummary: summarizeWorkbenchJsonBody(parsed, path), - valuesRedacted: true - }; -} - -function shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status }) { - if (method !== "GET" && method !== "POST") return false; - if (!Number.isFinite(status) || status < 200 || status >= 300) return false; - if (resourceType === "eventsource" || path === "/v1/workbench/events") return false; - return path === "/v1/agent/chat" - || path === "/v1/agent/chat/steer" - || path === "/v1/workbench/sessions" - || /^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path) - || /^\/v1\/workbench\/turns\/[^/]+$/u.test(path) - || /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path); -} - -function summarizeWorkbenchJsonBody(value, path) { - const traceIds = new Set(); - const sessionIds = new Set(); - const terminalTraceIds = new Set(); - const statusCounts = {}; - const counters = { - objectCount: 0, - arrayCount: 0, - traceEventLikeCount: 0, - messageLikeCount: 0, - turnLikeCount: 0, - terminalStatusCount: 0, - runningStatusCount: 0, - terminalTextCount: 0, - finalTextFieldCount: 0, - finalTextByteCount: 0 - }; - - const visit = (node, key = "", parent = null, depth = 0) => { - if (depth > 32 || node === null || node === undefined) return; - if (typeof node === "string") { - collectWorkbenchIdsFromText(node, traceIds, sessionIds); - const normalizedStatus = normalizeWorkbenchStatus(key, node); - if (normalizedStatus) { - statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1; - if (isWorkbenchTerminalStatus(normalizedStatus)) counters.terminalStatusCount += 1; - if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1; - } - if (isWorkbenchTerminalText(node)) { - counters.terminalTextCount += 1; - for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId); - } - if (isLikelyWorkbenchFinalTextField(key, parent, node)) { - counters.finalTextFieldCount += 1; - counters.finalTextByteCount += Buffer.byteLength(node); - for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId); - } - return; - } - if (typeof node !== "object") return; - if (Array.isArray(node)) { - counters.arrayCount += 1; - for (const item of node) visit(item, key, parent, depth + 1); - return; - } - counters.objectCount += 1; - const record = node; - const recordTraceIds = workbenchTraceIdsFromRecord(record); - for (const raw of [record.traceId, record.trace_id, record.id, record.turnId, record.messageId]) { - if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds); - } - for (const raw of [record.sessionId, record.session_id]) { - if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds); - } - const statusValue = record.status ?? record.state ?? record.phase ?? record.result ?? record.lifecycle; - if (typeof statusValue === "string") { - const normalizedStatus = normalizeWorkbenchStatus("status", statusValue); - if (normalizedStatus) { - statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1; - if (isWorkbenchTerminalStatus(normalizedStatus)) { - counters.terminalStatusCount += 1; - for (const traceId of recordTraceIds) terminalTraceIds.add(traceId); - } - if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1; - } - } - if (record.traceId || record.trace_id || record.turnId || record.turn_id) counters.turnLikeCount += 1; - if (record.messageId || record.message_id || record.role || record.author || record.content || record.text || record.finalResponse) counters.messageLikeCount += 1; - if (record.projectedSeq !== undefined || record.sourceSeq !== undefined || record.eventSeq !== undefined || record.eventKind !== undefined || record.eventTimestamp !== undefined) counters.traceEventLikeCount += 1; - for (const [childKey, childValue] of Object.entries(record)) visit(childValue, childKey, record, depth + 1); - }; - visit(value); - return { - pathKind: workbenchBodyPathKind(path), - traceIds: Array.from(traceIds).sort().slice(0, 12), - terminalTraceIds: Array.from(terminalTraceIds).sort().slice(0, 12), - sessionIds: Array.from(sessionIds).sort().slice(0, 12), - statusCounts, - ...counters, - terminalEvidenceCount: counters.terminalStatusCount + counters.terminalTextCount, - valuesRedacted: true - }; -} - -function collectWorkbenchIdsFromText(value, traceIds, sessionIds) { - const text = String(value || ""); - for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) traceIds.add(match[0]); - for (const match of text.matchAll(/\bses_[A-Za-z0-9_-]+\b/gu)) sessionIds.add(match[0]); -} - -function workbenchTraceIdsFromRecord(record) { - if (!record || typeof record !== "object") return []; - const values = [record.traceId, record.trace_id, record.turnId, record.turn_id, record.messageId, record.message_id, record.id]; - const ids = new Set(); - for (const raw of values) { - if (typeof raw !== "string") continue; - for (const match of raw.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]); - } - return Array.from(ids).sort(); -} - -function normalizeWorkbenchStatus(key, value) { - if (!/status|state|phase|result|lifecycle/iu.test(String(key || ""))) return null; - const text = String(value || "").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").replace(/^-+|-+$/gu, ""); - if (!text) return null; - if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(text)) return "completed"; - if (/^(failed|failure|error|errored)$/u.test(text)) return "failed"; - if (/^(canceled|cancelled|aborted|cancel)$/u.test(text)) return "canceled"; - if (/^(running|active|in-progress|in_progress|processing|streaming|executing)$/u.test(text)) return "running"; - if (/^(queued|pending|admitted|created|waiting)$/u.test(text)) return "pending"; - return text.slice(0, 80); -} - -function isWorkbenchTerminalStatus(value) { - return value === "completed" || value === "failed" || value === "canceled"; -} - -function isWorkbenchRunningStatus(value) { - return value === "running" || value === "pending"; -} - -function isWorkbenchTerminalText(value) { - return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(value || "")); -} - -function isLikelyWorkbenchFinalTextField(key, parent, value) { - const text = String(value || "").trim(); - if (!text) return false; - const field = String(key || ""); - if (!/final|assistant|response|content|markdown|text|message|output|result/iu.test(field)) return false; - const parentStatus = normalizeWorkbenchStatus("status", parent?.status ?? parent?.state ?? parent?.phase ?? parent?.result ?? ""); - const parentRole = String(parent?.role ?? parent?.author ?? parent?.dataRole ?? "").toLowerCase(); - return isWorkbenchTerminalStatus(parentStatus) || /assistant|agent|code/iu.test(parentRole) || /final|response|result/iu.test(field); -} - -function workbenchBodyPathKind(path) { - if (path === "/v1/agent/chat" || path === "/v1/agent/chat/steer") return "agent-chat-submit"; - if (path === "/v1/workbench/sessions") return "workbench-sessions"; - if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "workbench-session-messages"; - if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "workbench-turn"; - if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "workbench-trace-events"; - return "workbench"; -} - -function controlRecord(command, phase, detail) { - return { - ts: new Date().toISOString(), - seq: commandSeq, - phase, - commandId: command.id, - type: command.type, - source: command.source || "file", - input: commandInputSummary(command), - beforeUrl: command.beforeUrl || null, - afterUrl: currentPageUrl(), - pageId, - detail: sanitize(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, - afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null, - severity: command.severity || null, - alternateSessionStrategy: command.alternateSessionStrategy || null, - expectedSentinelRange: command.expectedSentinelRange || null, - expectedActionWaitMs: command.expectedActionWaitMs === null || command.expectedActionWaitMs === undefined || command.expectedActionWaitMs === "" ? null : Number(command.expectedActionWaitMs), - requireComposerReady: command.requireComposerReady === true, - waitProjectManagementReady: command.waitProjectManagementReady === true, - findingId: command.findingId || null, - blocking: command.blocking === true ? true : command.blocking === false ? false : null, - sourceId: opaque(command.sourceId), - fileRef: opaque(command.fileRef), - filename: command.filename ? truncate(command.filename, 200) : null, - taskRef: opaque(command.taskRef), - taskId: command.taskId || null, - field: command.field || null, - link: command.link ? truncate(command.link, 200) : null, - titleHash: command.title ? sha256Text(command.title) : null, - titleBytes: command.title ? Buffer.byteLength(command.title) : null, - bodyHash: command.body ? sha256Text(command.body) : null, - bodyBytes: command.body ? Buffer.byteLength(command.body) : null, - status: command.status || null, - hwpodId: opaque(command.hwpodId), - nodeId: opaque(command.nodeId), - workspaceRoot: opaque(command.workspaceRoot), - root: opaque(command.root), - label: command.label ? truncate(command.label, 200) : null, - textHash: text === null ? null : sha256Text(text), - textBytes: text === null ? null : Buffer.byteLength(text), - textPreview: null, - valuesRedacted: true, - }; -} - -async function appendJsonl(file, value) { - await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 }); -} - -async function fileMeta(file) { - const [buffer, stats] = await Promise.all([readFile(file), stat(file)]); - return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") }; -} - -function currentPageUrl() { - return pageUrl(page); -} - -function pageUrl(targetPage) { - try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; } -} - -function routeSessionIdFromUrl(value) { - try { - const pathname = new URL(String(value || ""), baseUrl).pathname; - const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u); - return match ? decodeURIComponent(match[1] || "") : null; - } catch { - return null; - } -} - -function safeFrameUrl(frame) { - try { return frame ? safeUrl(frame.url()) : null; } catch { return null; } -} - -function safeUrl(value) { - try { - const url = new URL(String(value), baseUrl); - for (const key of Array.from(url.searchParams.keys())) { - if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]"); - } - return url.toString(); - } catch { - return truncate(String(value || ""), 300); - } -} - -function safeUrlPath(value) { - try { - return new URL(String(value || ""), baseUrl).pathname; - } catch { - return null; - } -} - -function normalizeBaseUrl(value) { - const raw = value || "http://127.0.0.1:3000"; - const url = new URL(raw); - return url.origin; -} - -function parseViewport(value) { - const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u); - return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 }; -} - -function positiveInteger(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback; -} - -function boundedInteger(value, fallback, min, max) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return fallback; - const integer = Math.floor(parsed); - if (integer < min || integer > max) return fallback; - return integer; -} - -function positiveNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function requiredPositiveThreshold(raw, key) { - const parsed = Number(raw?.[key]); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds"); - } - return parsed; -} - -function parseAlertThresholds(value) { - if (!value) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane"); - } - const raw = (() => { - try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } - })(); - const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio"); - if (sessionRailFallbackRatio > 1) { - throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1"); - } - return { - sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"), - partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"), - longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"), - visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"), - turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"), - turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"), - domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"), - domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"), - screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), - pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), - browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), - requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), - requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), - requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), - requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), - browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), - browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), - browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), - browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"), - playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"), - playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"), - cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"), - uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"), - scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"), - scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), - scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), - sessionRailFallbackRatio, - crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), - source: "yaml-env", - }; -} - -function parseBrowserFreezePolicy(value) { - if (!value) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane"); - } - const raw = (() => { - try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } - })(); - const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy"); - const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy"); - const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy"); - const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy"); - return { - enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"), - blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"), - memory: { - totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"), - processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"), - growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"), - }, - responsiveness: { - latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"), - eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"), - }, - cdp: { - metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"), - }, - kill: { - enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"), - gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"), - forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"), - graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000), - pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000), - exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125), - }, - source: "yaml-env", - valuesRedacted: true, - }; -} - -function requiredPolicyRecord(raw, key, pathLabel) { - const value = raw?.[key]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyBoolean(raw, key, pathLabel) { - const value = raw?.[key]; - if (typeof value !== "boolean") { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyPositiveNumber(raw, key, pathLabel) { - const value = Number(raw?.[key]); - if (!Number.isFinite(value) || value <= 0) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key); - } - return value; -} - -function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) { - const value = Number(raw?.[key]); - if (!Number.isInteger(value) || value < min || value > max) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max); - } - return value; -} - -function requiredPolicySignal(raw, key, pathLabel, expected) { - const value = String(raw?.[key] || ""); - if (value !== expected) { - throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected); - } - return 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"); -} - -function safeId(value) { - return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item"; -} - -function cssEscape(value) { - return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\""); -} - -function truncate(value, limit) { - const text = String(value || ""); - return text.length > limit ? text.slice(0, limit) + "..." : text; -} - -function sanitize(value) { - if (value === null || value === undefined) return value; - if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]"); - if (typeof value === "number" || typeof value === "boolean") return value; - if (Array.isArray(value)) return value.map(sanitize); - if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)])); - return String(value); -} - -function errorSummary(error) { - const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null }; - if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth); - if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5)); - if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness); - if (error && error.details) summary.details = sanitize(error.details); - if (error && error.target) summary.target = sanitize(error.target); - return summary; -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); -} - -function withHardTimeout(promise, timeoutMs, message) { - const guarded = Promise.resolve(promise); - let timer = null; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1)); - if (timer && typeof timer.unref === "function") timer.unref(); - }); - return Promise.race([guarded, timeout]).finally(() => { - if (timer) clearTimeout(timer); - guarded.catch(() => {}); - }); -} `; } diff --git a/scripts/src/hwlab-node-web-observe-runner-utility-source.ts b/scripts/src/hwlab-node-web-observe-runner-utility-source.ts new file mode 100644 index 00000000..8f913902 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-utility-source.ts @@ -0,0 +1,325 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Runner low-level IO, URL, YAML-derived policy parsing, redaction, and timeout utility source fragment. + +export function nodeWebObserveRunnerUtilitySource(): string { + return String.raw`async function appendJsonl(file, value) { + await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 }); +} + +async function fileMeta(file) { + const [buffer, stats] = await Promise.all([readFile(file), stat(file)]); + return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") }; +} + +function currentPageUrl() { + return pageUrl(page); +} + +function pageUrl(targetPage) { + try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; } +} + +function routeSessionIdFromUrl(value) { + try { + const pathname = new URL(String(value || ""), baseUrl).pathname; + const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u); + return match ? decodeURIComponent(match[1] || "") : null; + } catch { + return null; + } +} + +function safeFrameUrl(frame) { + try { return frame ? safeUrl(frame.url()) : null; } catch { return null; } +} + +function safeUrl(value) { + try { + const url = new URL(String(value), baseUrl); + for (const key of Array.from(url.searchParams.keys())) { + if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]"); + } + return url.toString(); + } catch { + return truncate(String(value || ""), 300); + } +} + +function safeUrlPath(value) { + try { + return new URL(String(value || ""), baseUrl).pathname; + } catch { + return null; + } +} + +function normalizeBaseUrl(value) { + const raw = value || "http://127.0.0.1:3000"; + const url = new URL(raw); + return url.origin; +} + +function parseViewport(value) { + const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u); + return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 }; +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback; +} + +function boundedInteger(value, fallback, min, max) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + const integer = Math.floor(parsed); + if (integer < min || integer > max) return fallback; + return integer; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function numberOrNull(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function requiredPositiveThreshold(raw, key) { + const parsed = Number(raw?.[key]); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds"); + } + return parsed; +} + +function parseAlertThresholds(value) { + if (!value) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane"); + } + const raw = (() => { + try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } + })(); + const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio"); + if (sessionRailFallbackRatio > 1) { + throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1"); + } + return { + sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"), + partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"), + longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"), + visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"), + turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"), + turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"), + domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"), + domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"), + screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), + pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), + longTaskRedMs: requiredPositiveThreshold(raw, "longTaskRedMs"), + longAnimationFrameRedMs: requiredPositiveThreshold(raw, "longAnimationFrameRedMs"), + eventLoopGapRedMs: requiredPositiveThreshold(raw, "eventLoopGapRedMs"), + browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), + requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), + requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), + requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), + requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), + browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), + browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), + browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), + browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"), + playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"), + playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"), + cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"), + uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"), + scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"), + scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), + scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), + sessionRailFallbackRatio, + crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), + source: "yaml-env", + }; +} + +function parseBrowserFreezePolicy(value) { + if (!value) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane"); + } + const raw = (() => { + try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } + })(); + const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy"); + const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy"); + const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy"); + const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy"); + return { + enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"), + blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"), + memory: { + totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"), + processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"), + growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"), + }, + responsiveness: { + latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"), + eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"), + }, + cdp: { + metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"), + }, + kill: { + enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"), + gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"), + forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"), + graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000), + pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000), + exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125), + }, + source: "yaml-env", + valuesRedacted: true, + }; +} + +function requiredPolicyRecord(raw, key, pathLabel) { + const value = raw?.[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyBoolean(raw, key, pathLabel) { + const value = raw?.[key]; + if (typeof value !== "boolean") { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyPositiveNumber(raw, key, pathLabel) { + const value = Number(raw?.[key]); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key); + } + return value; +} + +function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) { + const value = Number(raw?.[key]); + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max); + } + return value; +} + +function requiredPolicySignal(raw, key, pathLabel, expected) { + const value = String(raw?.[key] || ""); + if (value !== expected) { + throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected); + } + return 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"); +} + +function safeId(value) { + return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item"; +} + +function cssEscape(value) { + return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\""); +} + +function truncate(value, limit) { + const text = String(value || ""); + return text.length > limit ? text.slice(0, limit) + "..." : text; +} + +function sanitize(value) { + if (value === null || value === undefined) return value; + if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]"); + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.map(sanitize); + if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)])); + return String(value); +} + +function errorSummary(error) { + const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null }; + if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth); + if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5)); + if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness); + if (error && error.details) summary.details = sanitize(error.details); + if (error && error.target) summary.target = sanitize(error.target); + return summary; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); +} + +function withHardTimeout(promise, timeoutMs, message) { + const guarded = Promise.resolve(promise); + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1)); + if (timer && typeof timer.unref === "function") timer.unref(); + }); + return Promise.race([guarded, timeout]).finally(() => { + if (timer) clearTimeout(timer); + guarded.catch(() => {}); + }); +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts b/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts new file mode 100644 index 00000000..c92c5864 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts @@ -0,0 +1,1493 @@ +// SPEC: PJ2026-01040111 long-running Workbench observation. +// Responsibility: Runner Workbench session, provider, and Project/MDTODO command source fragment. + +export function nodeWebObserveRunnerWorkbenchSource(): string { + return String.raw`async function selectProvider(provider) { + const target = String(provider || "").trim(); + if (!target) throw new Error("selectProvider requires provider name"); + const beforeUrl = currentPageUrl(); + const beforePath = safeUrlPath(beforeUrl); + if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first"); + const nativeSelect = await page.evaluate((name) => { + const normalized = String(name).toLowerCase(); + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { + const options = Array.from(select.options || []); + const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized)); + if (!option) continue; + select.value = option.value; + select.dispatchEvent(new Event("input", { bubbles: true })); + select.dispatchEvent(new Event("change", { bubbles: true })); + return { kind: "native-select", value: option.value, text: option.textContent || "" }; + } + return null; + }, target).catch(() => null); + if (nativeSelect) { + await page.waitForTimeout(500); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId }; + } + const optionVisible = page.getByText(target, { exact: false }).last(); + if (await optionVisible.isVisible().catch(() => false)) { + await optionVisible.click(); + await page.waitForTimeout(500); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId }; + } + const control = page.locator([ + '[data-testid*="provider" i]', + '[data-testid*="model" i]', + '[aria-label*="provider" i]', + '[aria-label*="model" i]', + '[aria-label*="模型"]', + '[aria-label*="提供"]', + '[role="combobox"]', + '[aria-haspopup="listbox"]', + ].join(", ")); + const count = Math.min(await control.count().catch(() => 0), 60); + const attempts = []; + for (let index = count - 1; index >= 0; index -= 1) { + const item = control.nth(index); + const visible = await item.isVisible().catch(() => false); + if (!visible) continue; + const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => ""); + if (isProviderNavigationLabel(label)) continue; + if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue; + attempts.push({ index, label }); + await item.click({ timeout: 3000 }).catch(() => null); + await page.waitForTimeout(300); + const option = page.getByText(target, { exact: false }).last(); + if (await option.isVisible().catch(() => false)) { + await option.click(); + await page.waitForTimeout(700); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId }; + } + await page.keyboard.press("Escape").catch(() => null); + } + const providerCandidates = await collectProviderCandidates(); + throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10))); +} + +async function collectProviderCandidates() { + return page.evaluate(() => { + const rows = []; + const seen = new Set(); + const isNavigationLabel = (value) => { + const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); + if (!label) return false; + if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true; + return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, "")); + }; + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + const push = (kind, element, value, text) => { + const normalizedValue = String(value || "").trim().slice(0, 160); + const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220); + const label = (normalizedValue + " " + normalizedText).trim(); + if (!label) return; + const key = (kind + ":" + label).toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) }); + }; + for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { + for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || ""); + } + const selector = [ + '[role="option"]', + '[role="menuitem"]', + '[data-radix-collection-item]', + '[data-testid*="provider" i]', + '[data-testid*="profile" i]', + '[data-testid*="model" i]', + '[aria-label*="provider" i]', + '[aria-label*="profile" i]', + '[aria-label*="model" i]', + '[aria-label*="模型"]', + '[aria-label*="提供"]', + 'button' + ].join(", "); + for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) { + const text = String(element.textContent || "").replace(/\s+/gu, " ").trim(); + const ariaLabel = String(element.getAttribute("aria-label") || ""); + const testId = String(element.getAttribute("data-testid") || ""); + const haystack = text + " " + ariaLabel + " " + testId; + if (isNavigationLabel(haystack)) continue; + if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue; + push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId); + } + return rows.slice(0, 80); + }).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]); +} + +function isProviderNavigationLabel(value) { + const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); + if (!text) return false; + if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true; + return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, "")); +} + +async function clickSession(sessionId) { + if (!sessionId) throw new Error("clickSession requires --session-id or --value"); + const beforeUrl = currentPageUrl(); + const escaped = cssEscape(sessionId); + const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first(); + const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first(); + await candidate.waitFor({ state: "visible", timeout: 15000 }); + await candidate.click(); + await page.waitForTimeout(1000); + return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId }; +} + +async function refreshCurrentSession(command) { + const beforeUrl = currentPageUrl(); + const before = await workbenchSessionSnapshot(); + const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); + if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session"); + const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); + const after = await workbenchSessionSnapshot(); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "refreshCurrentSession", + afterRound: integerOrNull(command.afterRound), + canarySessionId: sessionId, + routeSessionId: after?.routeSessionId ?? null, + activeSessionId: after?.activeSessionId ?? null, + routeOk: after?.routeSessionId === sessionId, + activeOk: after?.activeSessionId === sessionId, + composerReady: after?.composerReady === true, + navigation, + pageId, + valuesRedacted: true, + }; +} + +async function switchAwayAndBack(command) { + const beforeUrl = currentPageUrl(); + const before = await workbenchSessionSnapshot(); + const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); + if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session"); + const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create"); + const beforeSessionIds = await visibleWorkbenchSessionIds(); + let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null; + let alternateSource = alternateSessionId === null ? null : "existing"; + let createResult = null; + if (alternateSessionId === null && strategy === "existing-or-create") { + createResult = await createSessionFromUi(); + alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null; + alternateSource = "created"; + } + if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy); + const switchAway = await clickSession(alternateSessionId); + const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 }); + const away = awaySettle.snapshot ?? await workbenchSessionSnapshot(); + let switchBack; + try { + switchBack = await clickSession(canarySessionId); + } catch (error) { + const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId)); + switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true }; + } + let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 }); + let after = backSettle.snapshot ?? await workbenchSessionSnapshot(); + let switchBackRecovery = null; + let routeOk = after?.routeSessionId === canarySessionId; + let activeOk = after?.activeSessionId === canarySessionId; + if (!routeOk || !activeOk) { + switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle); + if (switchBackRecovery?.ok === true) { + switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true }; + backSettle = switchBackRecovery.settle; + after = switchBackRecovery.snapshot ?? after; + routeOk = after?.routeSessionId === canarySessionId; + activeOk = after?.activeSessionId === canarySessionId; + } + } + if (awaySettle.ok !== true) { + const error = new Error("switchAwayAndBack did not settle on the alternate session"); + error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true }; + throw error; + } + if (!routeOk || !activeOk) { + const error = new Error("switchAwayAndBack did not return to the canary session"); + error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, recovery: switchBackRecovery, pageId, valuesRedacted: true }; + throw error; + } + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "switchAwayAndBack", + afterRound: integerOrNull(command.afterRound), + canarySessionId, + alternateSessionId, + alternateSessionStrategy: strategy, + alternateSource, + beforeSessionCount: beforeSessionIds.length, + createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true }, + switchAway, + awaySettle, + away: { + routeSessionId: away?.routeSessionId ?? null, + activeSessionId: away?.activeSessionId ?? null, + messageCount: away?.messageCount ?? null, + valuesRedacted: true, + }, + switchBack, + backSettle, + switchBackRecovery, + routeSessionId: after?.routeSessionId ?? null, + activeSessionId: after?.activeSessionId ?? null, + routeOk, + activeOk, + composerReady: after?.composerReady === true, + pageId, + valuesRedacted: true, + }; +} + +async function recoverControlPageSessionHydration(sessionId, previousSettle) { + const observerHydration = observerPage && !observerPage.isClosed() + ? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 }) + : { ok: false, reason: "observer-page-unavailable", valuesRedacted: true }; + await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1); + const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); + const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 }); + const snapshot = settle.snapshot ?? await workbenchSessionSnapshot(); + const routeOk = snapshot?.routeSessionId === sessionId; + const activeOk = snapshot?.activeSessionId === sessionId; + return { + ok: settle.ok === true && routeOk && activeOk, + attempted: true, + reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed", + previousSettle, + observerHydrated: observerHydration.ok === true, + observerHydration, + navigation, + settle, + snapshot: { + routeSessionId: snapshot?.routeSessionId ?? null, + activeSessionId: snapshot?.activeSessionId ?? null, + composerReady: snapshot?.composerReady === true, + messageCount: snapshot?.messageCount ?? null, + valuesRedacted: true, + }, + pageId, + valuesRedacted: true, + }; +} + +async function assertSessionInvariant(command) { + const beforeUrl = currentPageUrl(); + const snapshot = await workbenchSessionSnapshot(); + const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim(); + if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session"); + const routeOk = snapshot?.routeSessionId === canarySessionId; + const activeOk = snapshot?.activeSessionId === canarySessionId; + const composerReady = snapshot?.composerReady === true; + if (!routeOk || !activeOk) { + const error = new Error("assertSessionInvariant saw route/active session mismatch"); + error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true }; + throw error; + } + if (command.requireComposerReady === true && !composerReady) { + const error = new Error("assertSessionInvariant requires composer ready for the next round"); + error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true }; + throw error; + } + const messageOrder = await visibleMessageOrderSummary(); + await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true }))); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "assertSessionInvariant", + afterRound: integerOrNull(command.afterRound), + canarySessionId, + routeSessionId: snapshot?.routeSessionId ?? null, + activeSessionId: snapshot?.activeSessionId ?? null, + routeOk, + activeOk, + composerReady, + expectedSentinelRange: command.expectedSentinelRange || null, + findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation", + severity: command.severity || "amber", + blocking: command.blocking === true, + messageOrder, + sampleSeq, + pageRole: "control", + pageId, + valuesRedacted: true, + }; +} + +async function visibleWorkbenchSessionIds() { + return 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 sessionIdForElement = (element) => { + const direct = element.getAttribute("data-session-id"); + if (direct) return direct; + const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; + const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); + return match ? decodeURIComponent(match[1] || "") : null; + }; + const ids = []; + for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) { + if (!visible(element)) continue; + const id = sessionIdForElement(element); + if (id && !ids.includes(id)) ids.push(id); + } + return ids.slice(0, 50); + }).catch(() => []); +} + +async function visibleMessageOrderSummary() { + const rawMessages = await page.evaluate(() => { + const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").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"; + }; + const stableMessageText = (element) => { + const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"]; + const parts = []; + for (const selector of selectors) { + for (const candidate of Array.from(element.querySelectorAll(selector))) { + if (!visible(candidate)) continue; + const text = trim(candidate.textContent || "", 1200); + if (text && !parts.includes(text)) parts.push(text); + } + } + return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200); + }; + return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({ + index, + dataRole: element.getAttribute("data-role") || null, + role: element.getAttribute("role") || null, + testId: element.getAttribute("data-testid") || null, + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: element.getAttribute("data-trace-id") || null, + turnId: element.getAttribute("data-turn-id") || null, + text: stableMessageText(element), + })); + }).catch(() => []); + const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => { + const text = String(item?.text || ""); + const kind = messageKindForOrder(item, text); + const markers = sentinelMarkers(text); + return { + index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index, + kind, + role: item?.dataRole || item?.role || null, + status: item?.status || null, + sessionId: item?.sessionId || null, + messageId: item?.messageId || null, + traceId: item?.traceId || null, + turnId: item?.turnId || null, + markers, + textHash: sha256Text(text), + textBytes: Buffer.byteLength(text), + valuesRedacted: true, + }; + }) : []; + const clusters = userClusters(entries); + const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null; + return { + messageCount: entries.length, + sequence: entries.map((item) => ({ + index: item.index, + kind: item.kind, + marker: item.markers[0] || null, + markerCount: item.markers.length, + traceId: item.traceId, + status: item.status, + textHash: item.textHash, + textBytes: item.textBytes, + valuesRedacted: true, + })).slice(-40), + userClustered: clusters.length > 0, + consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0, + sentinelRange: maxCluster?.sentinelRange ?? null, + traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12), + clusters, + valuesRedacted: true, + }; +} + +function messageKindForOrder(item, text) { + const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" "); + if (/\b(user|human)\b/u.test(signal)) return "user"; + if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent"; + const body = String(text || "").trim(); + if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user"; + if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent"; + return "unknown"; +} + +function sentinelMarkers(text) { + return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase())); +} + +function userClusters(entries) { + const clusters = []; + let current = []; + const flush = () => { + if (current.length >= 2) { + const markers = current.flatMap((item) => item.markers); + clusters.push({ + startIndex: current[0].index, + endIndex: current[current.length - 1].index, + consecutiveUserMessageCount: current.length, + sentinelRange: sentinelRange(markers), + markers: uniqueStrings(markers).slice(0, 12), + messageTextHashes: current.map((item) => item.textHash).slice(0, 12), + traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12), + valuesRedacted: true, + }); + } + current = []; + }; + for (const entry of entries) { + if (entry.kind === "user") { + current.push(entry); + } else { + flush(); + } + } + flush(); + return clusters.slice(0, 20); +} + +function sentinelRange(markers) { + const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b)); + if (unique.length === 0) return null; + return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1]; +} + +function sentinelMarkerNumber(marker) { + const match = String(marker || "").match(/sentinel-(\d+)/u); + return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; +} + +function uniqueStrings(values) { + return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))); +} + +function integerOrNull(value) { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : null; +} + +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 gotoProjectMdtodo() { + ensureProjectManagementCommand("gotoProjectMdtodo"); + return gotoTarget("/projects/mdtodo"); +} + +function commandValue(command, keys) { + for (const key of keys) { + const value = command?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +async function visibleLocator(locator) { + return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false); +} + +function normalizedProjectText(value) { + return String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); +} + +function projectSnapshotMatchesCommandSelection(type, raw, selected, targetValue) { + if (!raw || typeof raw !== "object") return false; + const selectedValue = String(selected?.selectedValue || ""); + const normalizedTarget = normalizedProjectText(targetValue); + if (type === "selectMdtodoFile") { + const selectedFileRef = String(raw.selectedFileRefRaw || ""); + const selectedFileLabel = normalizedProjectText(raw.selectedFileLabel); + return Boolean( + (selectedValue && selectedFileRef === selectedValue) + || (normalizedTarget && selectedFileLabel.includes(normalizedTarget)) + ) && Number(raw.fileCount || 0) > 0; + } + if (type === "selectMdtodoSource" || type === "selectProjectSource") { + const selectedSourceId = String(raw.selectedSourceIdRaw || ""); + return Boolean(selectedValue && selectedSourceId === selectedValue) && Number(raw.sourceCount || 0) > 0; + } + return true; +} + +async function waitForProjectCommandSelection({ type, selected, targetValue, timeoutMs = 30000 }) { + const started = Date.now(); + let lastProject = null; + do { + await page.waitForTimeout(300); + lastProject = await projectManagementCommandSnapshot({ includeRaw: true }); + if (projectSnapshotMatchesCommandSelection(type, lastProject, selected, targetValue)) return lastProject; + } while (Date.now() - started < timeoutMs); + return lastProject || await projectManagementCommandSnapshot({ includeRaw: true }); +} + +async function selectHtmlOptionByValueOrLabel(locator, value) { + const select = locator.first(); + const targetValue = typeof value === "string" && value.trim() ? value.trim() : ""; + if (targetValue) { + const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); + if (byValue.ok && byValue.selected.length > 0) return { ok: true, mode: "select-value", selectedValue: byValue.selected[0] || targetValue }; + const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); + if (byLabel.ok && byLabel.selected.length > 0) return { ok: true, mode: "select-label", selectedValue: byLabel.selected[0] || targetValue }; + return await select.evaluate((element, target) => { + const normalize = (raw) => String(raw || "").replace(/\s+/gu, " ").trim().toLowerCase(); + const normalizedTarget = normalize(target); + const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value).map((option) => ({ + value: option.value, + label: String(option.textContent || "").replace(/\s+/gu, " ").trim(), + })); + const matchers = [ + { mode: "select-value-normalized", test: (option) => normalize(option.value) === normalizedTarget }, + { mode: "select-label-normalized", test: (option) => normalize(option.label) === normalizedTarget }, + { mode: "select-label-contains", test: (option) => normalize(option.label).includes(normalizedTarget) }, + { mode: "select-value-contains", test: (option) => normalize(option.value).includes(normalizedTarget) }, + ]; + for (const matcher of matchers) { + const chosen = options.find(matcher.test); + if (!chosen) continue; + element.value = chosen.value; + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + return { + ok: true, + mode: matcher.mode, + selectedValue: chosen.value, + selectedLabel: chosen.label, + optionCount: options.length, + optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8), + valuesRedacted: true, + }; + } + return { + ok: false, + mode: "select-target-not-found", + selectedValue: element.value || "", + optionCount: options.length, + optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8), + valuesRedacted: true, + }; + }, targetValue); + } + const selectedValue = await select.evaluate((element) => { + const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value); + const chosen = options[0] || null; + if (!chosen) return ""; + element.value = chosen.value; + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + return chosen.value; + }); + return { ok: true, mode: "select-first", selectedValue }; +} + +async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) { + ensureProjectManagementCommand(type); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const targetValue = typeof value === "string" && value.trim() ? value.trim() : null; + if (selectTestId) { + const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]'); + if (await visibleLocator(select)) { + const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || ""); + const afterProjectRaw = await waitForProjectCommandSelection({ type, selected, targetValue: targetValue || "" }); + const selectionMatched = projectSnapshotMatchesCommandSelection(type, afterProjectRaw, selected, targetValue || ""); + const afterProject = sanitizeProjectCommandSnapshot(afterProjectRaw); + if (targetValue && (selected.ok !== true || !selectionMatched)) { + const error = new Error(type + " did not select requested target"); + error.details = { + beforeUrl, + afterUrl: currentPageUrl(), + type, + attr, + selectTestId, + mode: selected.mode, + targetHash: sha256Text(targetValue), + targetPreview: truncate(targetValue, 120), + selected, + beforeProject, + afterProject, + pageId, + valuesRedacted: true + }; + throw error; + } + return { + beforeUrl, + afterUrl: currentPageUrl(), + type, + attr, + mode: selected.mode, + selected: opaqueIdSummary(selected.selectedValue || targetValue), + beforeProject, + afterProject, + pageId, + valuesRedacted: true + }; + } + } + 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]', + selectTestId: "mdtodo-source-select" + }); +} + +async function selectMdtodoSource(command) { + return clickProjectItemByAttr({ + type: "selectMdtodoSource", + attr: "data-source-id", + value: command.sourceId || command.value || command.text || "", + fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', + selectTestId: "mdtodo-source-select" + }); +} + +async function selectMdtodoFile(command) { + return clickProjectItemByAttr({ + type: "selectMdtodoFile", + attr: "data-file-ref", + value: command.fileRef || command.filename || command.value || command.text || "", + fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]', + selectTestId: "mdtodo-file-select" + }); +} + +async function mdtodoTaskLocator(command, options = {}) { + const taskRef = commandValue(command, ["taskRef"]); + const taskId = commandValue(command, ["taskId", "task", "value", "text"]); + const selectors = []; + if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]'); + if (taskId) { + selectors.push('[data-task-id="' + cssEscape(taskId) + '"]'); + selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]'); + } + for (const selector of selectors) { + const locator = page.locator(selector).first(); + if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector }; + } + if (taskId) { + const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first(); + if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId }; + } + if ((taskRef || taskId) && options.allowFallback !== true) { + return { locator: null, taskRef, taskId, selector: "target-task-not-visible", targetMissing: true }; + } + const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first(); + return { locator: fallback, taskRef, taskId, selector: "first-visible-task" }; +} + +async function waitForMdtodoTaskLocator(command, timeoutMs = 30000) { + const started = Date.now(); + let lastProject = null; + let lastTarget = null; + do { + lastTarget = await mdtodoTaskLocator(command, { allowFallback: false }); + if (lastTarget.locator && await visibleLocator(lastTarget.locator)) return { target: lastTarget, project: lastProject }; + lastProject = await projectManagementCommandSnapshot(); + await page.waitForTimeout(500); + } while (Date.now() - started < timeoutMs); + return { target: lastTarget || await mdtodoTaskLocator(command, { allowFallback: false }), project: lastProject || await projectManagementCommandSnapshot() }; +} + +async function selectMdtodoTask(command) { + ensureProjectManagementCommand("selectMdtodoTask"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const hasExplicitTarget = Boolean(commandValue(command, ["taskRef"]) || commandValue(command, ["taskId", "task", "value", "text"])); + const waited = hasExplicitTarget ? await waitForMdtodoTaskLocator(command) : { target: await mdtodoTaskLocator(command, { allowFallback: true }), project: null }; + const target = waited.target; + if (!target.locator) { + const error = new Error("selectMdtodoTask did not find requested task"); + error.details = { + beforeUrl, + afterUrl: currentPageUrl(), + selector: target.selector, + requestedTaskRef: target.taskRef ? opaqueIdSummary(target.taskRef) : null, + requestedTaskId: target.taskId || null, + beforeProject, + afterProject: waited.project, + pageId, + valuesRedacted: true + }; + throw error; + } + await target.locator.waitFor({ state: "visible", timeout: 15000 }); + const clicked = await target.locator.evaluate((element) => ({ + taskRef: element.getAttribute("data-task-ref") || null, + taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null, + status: element.getAttribute("data-task-status") || null, + selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true" + })).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null })); + if (!clicked.selected) { + await target.locator.scrollIntoViewIfNeeded().catch(() => null); + await target.locator.click(); + await page.waitForTimeout(700); + } + const afterProject = await projectManagementCommandSnapshot(); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "selectMdtodoTask", + selector: target.selector, + selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef), + selectedTaskId: clicked.taskId || target.taskId || null, + selectedTaskStatus: clicked.status || null, + alreadySelected: clicked.selected === true, + beforeProject, + afterProject, + pageId, + valuesRedacted: true + }; +} + +async function expandMdtodoTask(command) { + ensureProjectManagementCommand("expandMdtodoTask"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const target = await mdtodoTaskLocator(command); + await target.locator.waitFor({ state: "visible", timeout: 15000 }); + const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first(); + const toggleVisible = await visibleLocator(toggle); + if (toggleVisible) await toggle.click(); + else await target.locator.click(); + await page.waitForTimeout(700); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "expandMdtodoTask", + selector: target.selector, + toggleVisible, + beforeProject, + afterProject: await projectManagementCommandSnapshot(), + pageId, + valuesRedacted: true + }; +} + +async function openMdtodoSourceConfig(command) { + ensureProjectManagementCommand("openMdtodoSourceConfig"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first(); + if (await visibleLocator(existingDialog)) { + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "openMdtodoSourceConfig", + alreadyOpen: true, + beforeProject, + afterProject: await projectManagementCommandSnapshot(), + pageId, + valuesRedacted: true + }; + } + const button = page.locator('[data-testid="mdtodo-source-config-open"]').first(); + await button.waitFor({ state: "visible", timeout: 15000 }); + await button.click(); + await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "openMdtodoSourceConfig", + beforeProject, + afterProject: await projectManagementCommandSnapshot(), + pageId, + valuesRedacted: true + }; +} + +async function ensureMdtodoSourceConfigOpen() { + const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first(); + if (await visibleLocator(form)) return { opened: false }; + await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" }); + return { opened: true }; +} + +async function closeMdtodoSourceConfig(command) { + ensureProjectManagementCommand("closeMdtodoSourceConfig"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const close = await closeMdtodoSourceConfigIfOpen({ required: true }); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "closeMdtodoSourceConfig", + close, + beforeProject, + afterProject: await projectManagementCommandSnapshot(), + pageId, + valuesRedacted: true + }; +} + +async function closeMdtodoSourceConfigIfOpen(options = {}) { + const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({ + has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]') + }).first(); + if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false }; + const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first(); + let closeClick = null; + try { + await closeButton.waitFor({ state: "visible", timeout: 5000 }); + await closeButton.click({ timeout: 5000 }); + closeClick = { attempted: true, ok: true }; + } catch (error) { + closeClick = { attempted: true, ok: false, error: errorSummary(error) }; + await page.keyboard.press("Escape").catch(() => null); + } + await page.waitForTimeout(400); + const stillVisible = await visibleLocator(dialog); + const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true }; + if (options.required && stillVisible) { + const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt"); + error.details = result; + throw error; + } + return result; +} + +async function fillMdtodoField(testId, value) { + if (typeof value !== "string" || !value.trim()) return { testId, filled: false }; + const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); + await locator.waitFor({ state: "visible", timeout: 10000 }); + await locator.fill(value); + return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; +} + +async function clickProjectButtonAndMaybeWait(testId, pathPattern) { + const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); + await button.waitFor({ state: "visible", timeout: 15000 }); + const responsePromise = pathPattern ? page.waitForResponse((response) => { + try { + const pathname = new URL(response.url()).pathname; + return pathPattern.test(pathname); + } catch { + return false; + } + }, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null); + const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) })); + if (buttonState.disabled === true) { + const error = new Error(testId + " button is disabled"); + error.details = { buttonState, valuesRedacted: true }; + throw error; + } + await button.click(); + const response = await responsePromise; + await page.waitForTimeout(900); + return { buttonState, response, valuesRedacted: true }; +} + +async function configureMdtodoHwpodSource(command) { + ensureProjectManagementCommand("configureMdtodoHwpodSource"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const dialog = await ensureMdtodoSourceConfigOpen(); + const fields = [ + await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])), + await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])), + await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])), + await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])), + ]; + const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u); + const close = await closeMdtodoSourceConfigIfOpen(); + return { + beforeUrl, + afterUrl: currentPageUrl(), + type: "configureMdtodoHwpodSource", + dialog, + fields, + save, + close, + beforeProject, + afterProject: await projectManagementCommandSnapshot(), + pageId, + valuesRedacted: true + }; +} + +async function probeMdtodoSource(command) { + ensureProjectManagementCommand("probeMdtodoSource"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + await ensureMdtodoSourceConfigOpen(); + const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u); + const close = await closeMdtodoSourceConfigIfOpen(); + return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function reindexMdtodoSource(command) { + ensureProjectManagementCommand("reindexMdtodoSource"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + await ensureMdtodoSourceConfigOpen(); + const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first(); + const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex"; + const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u); + const close = await closeMdtodoSourceConfigIfOpen(); + return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function selectTaskIfCommandTargetsOne(command) { + if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null; + return selectMdtodoTask(command); +} + +async function selectMdtodoProviderProfileForLaunch(command) { + const provider = commandValue(command, ["provider", "providerProfile"]); + if (!provider) return null; + const select = page.locator('[data-testid="mdtodo-provider-profile"]').first(); + if (!(await visibleLocator(select))) { + return { provider, visible: false, selected: null, valuesRedacted: true }; + } + const selected = await selectHtmlOptionByValueOrLabel(select, provider); + await page.waitForTimeout(300); + return { provider, visible: true, ...selected, valuesRedacted: true }; +} + +async function saveMdtodoTaskWithButton(command, type, testId, fields) { + ensureProjectManagementCommand(type); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const selection = await selectTaskIfCommandTargetsOne(command); + const inlineEditors = []; + for (const field of fields) { + if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId)); + if (field.kind === "fill") await fillMdtodoField(field.testId, field.value); + if (field.kind === "select") { + const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first(); + await locator.waitFor({ state: "visible", timeout: 10000 }); + await selectHtmlOptionByValueOrLabel(locator, field.value); + } + } + const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u); + return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) { + const candidates = (testIds || []).filter(Boolean); + for (const testId of candidates) { + const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); + if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern); + } + if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid"); + return clickProjectButtonAndMaybeWait(candidates[0], pathPattern); +} + +async function ensureMdtodoInlineEditor(editorTestId, readTestId) { + const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first(); + if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false }; + const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first(); + await read.waitFor({ state: "visible", timeout: 10000 }); + await read.dblclick(); + await editor.waitFor({ state: "visible", timeout: 10000 }); + return { editorTestId, readTestId, opened: true }; +} + +async function editMdtodoTaskTitle(command) { + const title = commandValue(command, ["title", "text", "value"]); + if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text"); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); +} + +async function editMdtodoTaskBody(command) { + const body = commandValue(command, ["text", "body", "value"]); + if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin"); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); +} + +async function toggleMdtodoTaskStatus(command) { + const status = commandValue(command, ["status", "value", "text"]); + if (!status) throw new Error("toggleMdtodoTaskStatus requires --status"); + return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); +} + +async function editMdtodoTaskInline(command) { + const field = commandValue(command, ["field", "value"]).toLowerCase(); + if (field === "title") { + const title = commandValue(command, ["title", "text"]); + if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text"); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); + } + if (field === "body" || field === "content") { + const body = commandValue(command, ["body", "text"]); + if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin"); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); + } + if (field === "status") { + const status = commandValue(command, ["status", "text"]); + if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text"); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); + } + throw new Error("editMdtodoTaskInline requires --field title, body, or status"); +} + +async function openMdtodoReportPreview(command) { + ensureProjectManagementCommand("openMdtodoReportPreview"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const selection = await selectTaskIfCommandTargetsOne(command); + const linkText = commandValue(command, ["link", "value", "text"]); + const links = page.locator('[data-testid="mdtodo-report-link"]'); + await links.first().waitFor({ state: "visible", timeout: 15000 }); + const count = await links.count(); + let index = 0; + if (linkText) { + for (let i = 0; i < count; i += 1) { + const item = links.nth(i); + const text = await item.textContent().catch(() => ""); + if (String(text || "").includes(linkText)) { + index = i; + break; + } + } + } + const link = links.nth(index); + const linkStateRaw = await link.evaluate((element, selectedIndex) => ({ + index: selectedIndex, + disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", + text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240), + valuesRedacted: true + }), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true })); + const linkState = { + ...linkStateRaw, + textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null, + textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null, + text: undefined, + valuesRedacted: true + }; + if (linkState.disabled === true) { + const error = new Error("openMdtodoReportPreview selected report link is disabled"); + error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true }; + throw error; + } + const attempts = []; + let response = null; + let afterProject = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const responsePromise = page.waitForResponse((item) => { + try { + const request = item.request(); + return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview"; + } catch { + return false; + } + }, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })); + const clickMode = attempt === 1 ? "normal" : "force"; + let click = { ok: true, mode: clickMode, valuesRedacted: true }; + await link.scrollIntoViewIfNeeded().catch(() => null); + try { + if (attempt === 1) await link.click(); + else await link.click({ force: true, timeout: 5000 }); + } catch (error) { + click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true }; + await link.evaluate((element) => element.click()).catch(() => null); + } + await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null); + afterProject = await projectManagementCommandSnapshot(); + response = afterProject?.reportPreviewVisible === true + ? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true } + : await responsePromise; + attempts.push({ + attempt, + click, + response, + afterUrl: currentPageUrl(), + reportPreviewVisible: afterProject?.reportPreviewVisible === true, + reportErrorVisible: afterProject?.reportErrorVisible === true, + reportFullscreenVisible: afterProject?.reportFullscreenVisible === true, + valuesRedacted: true + }); + if (afterProject?.reportPreviewVisible === true) break; + await page.waitForTimeout(700).catch(() => null); + } + afterProject = afterProject ?? await projectManagementCommandSnapshot(); + if (afterProject?.reportPreviewVisible !== true) { + const error = new Error("openMdtodoReportPreview did not show markdown report preview"); + error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; + throw error; + } + return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; +} + +async function toggleMdtodoReportFullscreen(command) { + ensureProjectManagementCommand("toggleMdtodoReportFullscreen"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const desired = commandValue(command, ["value", "text"]).toLowerCase(); + const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first(); + const initiallyOpen = await visibleLocator(dialog); + let action = "open"; + if (desired === "close" || desired === "off") action = "close"; + else if (desired === "toggle") action = initiallyOpen ? "close" : "open"; + if (action === "close") { + const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first(); + await closeButton.waitFor({ state: "visible", timeout: 10000 }); + await closeButton.click(); + } else if (!initiallyOpen) { + const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first(); + await button.waitFor({ state: "visible", timeout: 10000 }); + await button.click(); + } + await page.waitForTimeout(500); + return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function fillNewTaskDraft(command) { + const title = commandValue(command, ["title", "text", "value"]); + if (!title) throw new Error(command.type + " requires --title or --text"); + const fields = [await fillMdtodoField("mdtodo-new-title", title)]; + const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : ""); + if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body)); + return fields; +} + +async function addMdtodoTaskWithButton(command, type, testId) { + ensureProjectManagementCommand(type); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command); + const fields = await fillNewTaskDraft(command); + const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u); + return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function addMdtodoRootTask(command) { + return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root"); +} + +async function addMdtodoSubTask(command) { + return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask"); +} + +async function continueMdtodoTask(command) { + return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task"); +} + +async function deleteMdtodoTask(command) { + ensureProjectManagementCommand("deleteMdtodoTask"); + const beforeUrl = currentPageUrl(); + const beforeProject = await projectManagementCommandSnapshot(); + const selection = await selectTaskIfCommandTargetsOne(command); + const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null); + let confirmClick = null; + const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()); + if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u); + return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; +} + +async function launchWorkbenchFromTask(command) { + const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask"; + ensureProjectManagementCommand(commandType); + const beforeUrl = currentPageUrl(); + const initialProject = await projectManagementCommandSnapshot({ includeRaw: true }); + const requestedSourceId = commandValue(command, ["sourceId", "mdtodoSourceId"]); + const requestedFileRef = commandValue(command, ["fileRef"]); + const requestedFilename = commandValue(command, ["filename", "fileName"]); + const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null; + const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null; + const needsMdtodoSelection = Boolean(requestedFileRef || requestedFilename || requestedTaskRef || requestedTaskId); + const sourceSelection = requestedSourceId && initialProject.selectedSourceIdRaw !== requestedSourceId + ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: requestedSourceId }) + : !requestedSourceId && needsMdtodoSelection && !initialProject.selectedSourceIdRaw + ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: "" }) + : null; + const fileProject = sourceSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : initialProject; + const fileSelection = (requestedFileRef && fileProject.selectedFileRefRaw !== requestedFileRef) || requestedFilename + ? await selectMdtodoFile({ ...command, type: "selectMdtodoFile", fileRef: requestedFileRef || command.fileRef, filename: requestedFilename || command.filename }) + : null; + const taskProject = fileSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : fileProject; + const taskSelection = (requestedTaskRef && taskProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId + ? await selectMdtodoTask({ ...command, type: "selectMdtodoTask", taskRef: requestedTaskRef || command.taskRef, taskId: requestedTaskId || command.taskId }) + : null; + const providerSelection = await selectMdtodoProviderProfileForLaunch(command); + 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) })); + const chatResponsePromise = page.waitForResponse((response) => { + const request = response.request(); + if (request.method().toUpperCase() !== "POST") return false; + try { + return new URL(response.url()).pathname === "/v1/agent/chat"; + } catch { + return false; + } + }, { timeout: 30000 }).then(async (response) => { + let chatPayload = null; + let chatPayloadError = null; + try { + chatPayload = await response.json(); + } catch (error) { + chatPayloadError = errorSummary(error); + } + const headers = response.headers(); + return { + observed: true, + status: response.status(), + statusText: response.statusText(), + path: new URL(response.url()).pathname, + sessionId: sessionIdFromAgentSessionPayload(chatPayload), + traceId: traceIdFromAgentChatPayload(chatPayload), + otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null, + responseParsed: chatPayload !== null, + responseParseError: chatPayloadError, + valuesRedacted: true + }; + }).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true })); + 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); + } + const chat = await chatResponsePromise; + await page.waitForTimeout(1500); + const workbenchSnapshot = await workbenchSessionSnapshot(); + return { + beforeUrl, + afterUrl: currentPageUrl(), + launchPath, + launchStatus, + statusText: launchResponse.statusText(), + contractVersion, + sessionId, + workbenchUrl, + otelTraceId: launchTraceHeader, + chatObserved: chat?.observed === true, + chatStatus: chat?.status ?? null, + chatSessionId: chat?.sessionId ?? null, + chatTraceId: chat?.traceId ?? null, + chatOtelTraceId: chat?.otelTraceId ?? null, + chat, + workbenchSnapshot, + selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw), + selection: { + source: sourceSelection, + file: fileSelection, + task: taskSelection, + provider: providerSelection, + valuesRedacted: true + }, + initialProject: sanitizeProjectCommandSnapshot(initialProject), + projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick), + buttonState, + responseParsed: payload !== null, + responseParseError, + pageId, + valuesRedacted: true + }; +} + +async function launchWorkbenchFromMdtodo(command) { + return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" }); +} + +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 sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); + const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); + const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; + const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; + const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ + value: option.value, + label: text(option), + selected: option.selected === true + })) : []; + const selectedFileOption = fileOptions.find((option) => option.selected) || null; + const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); + const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); + const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); + const reportError = document.querySelector('[data-testid="mdtodo-report-error"]'); + const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); + const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); + 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: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount), + fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount), + taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length, + selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null, + selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null, + selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null, + fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20), + selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null, + selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null, + selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, + sourceSelectVisible: visible(sourceSelect), + fileSelectVisible: visible(fileSelect), + sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), + taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), + taskBodyVisible: visible(bodyRendered), + taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "", + newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), + reportLinkCount: reportLinks.length, + reportPreviewVisible: visible(reportPreview), + reportPreviewText: visible(reportPreview) ? text(reportPreview) : "", + reportErrorVisible: visible(reportError), + reportErrorText: visible(reportError) ? text(reportError) : "", + reportFullscreenVisible: visible(reportFullscreen), + 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; + const textDigest = (raw, limit = 160) => { + const text = String(raw || ""); + return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true }; + }; + const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); + const suspiciousFileLabel = (label) => { + const text = String(label || "").trim(); + return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); + }; + const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; + return { + ...value, + selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw), + selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw), + selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw), + selectedSourceIdRaw: undefined, + selectedFileRefRaw: undefined, + selectedTaskRefRaw: undefined, + selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, + selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, + fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)), + fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, + fileOptionLabels: undefined, + taskBodyText: undefined, + taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, + reportPreviewText: undefined, + reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, + reportErrorText: undefined, + reportError: value.reportErrorText ? textDigest(value.reportErrorText, 200) : null, + 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 + }; +} +`; +} diff --git a/scripts/src/hwlab-node-web-observe-wrapper.ts b/scripts/src/hwlab-node-web-observe-wrapper.ts index 5adf7ff9..96039ced 100644 --- a/scripts/src/hwlab-node-web-observe-wrapper.ts +++ b/scripts/src/hwlab-node-web-observe-wrapper.ts @@ -64,6 +64,8 @@ const WEB_OBSERVE_ARTIFACT_CONTRACT = [ { path: "network.jsonl", producer: "existing-observe-runner", purpose: "request, response, requestfailed, and timing evidence" }, { path: "console.jsonl", producer: "existing-observe-runner", purpose: "browser console/runtime evidence" }, { path: "artifacts.jsonl", producer: "existing-observe-runner", purpose: "screenshots and auxiliary artifact index" }, + { path: "performance-events.jsonl", producer: "existing-observe-runner", purpose: "LongTask, LoAF, event-loop gap, and performance capture events" }, + { path: "performance/captures/*/{profile.cpuprofile,summary.json}", producer: "observe-command-cli", purpose: "explicit CPU profile captures for frontend hotspot attribution" }, { path: "commands/{pending,processing,done,failed}/*.json", producer: "observe-command-cli", purpose: "durable command queue handoff" }, { path: "analysis/report.json", producer: "existing-observe-analyzer", purpose: "offline machine-readable findings" }, { path: "analysis/report.md", producer: "existing-observe-analyzer", purpose: "offline human-readable report" }, diff --git a/scripts/src/hwlab-node-web-sentinel-config.ts b/scripts/src/hwlab-node-web-sentinel-config.ts index 119f35f5..ca189b7a 100644 --- a/scripts/src/hwlab-node-web-sentinel-config.ts +++ b/scripts/src/hwlab-node-web-sentinel-config.ts @@ -427,6 +427,17 @@ function knownWebProbeFindingIds(): string[] { const ids = new Set(); for (const file of [ "scripts/src/hwlab-node-web-observe-analyzer-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-api-dom-lag-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-browser-process-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-io-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-project-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-sample-metrics-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-session-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts", + "scripts/src/hwlab-node-web-observe-analyzer-workbench-triad-source.ts", "scripts/src/hwlab-node-web-sentinel-p5-observe.ts", ]) { let source = ""; diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index e117eb3f..46c80df4 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -158,6 +158,7 @@ export type NodeWebProbeObserveCommandType = | "deleteMdtodoTask" | "launchWorkbenchFromTask" | "launchWorkbenchFromMdtodo" + | "performanceCapture" | "screenshot" | "mark" | "stop"; @@ -214,6 +215,7 @@ export interface NodeWebProbeObserveOptions { commandAlternateSessionStrategy: string | null; commandExpectedSentinelRange: string | null; commandExpectedActionWaitMs: number | null; + commandDurationMs: number | null; commandRequireComposerReady: boolean; commandWaitProjectManagementReady: boolean; commandFindingId: string | null; diff --git a/scripts/src/hwlab-node/web-observe-analyzer-triad.test.ts b/scripts/src/hwlab-node/web-observe-analyzer-triad.test.ts index 75157480..756847e2 100644 --- a/scripts/src/hwlab-node/web-observe-analyzer-triad.test.ts +++ b/scripts/src/hwlab-node/web-observe-analyzer-triad.test.ts @@ -18,7 +18,14 @@ const alertThresholds = { domEvaluateTimeoutRedWindowMs: 60000, screenshotTimeoutRedCount: 99, pageErrorRedCount: 99, + longTaskRedMs: 60000, + longAnimationFrameRedMs: 60000, + eventLoopGapRedMs: 60000, browserProcessSampleIntervalMs: 1000, + requestRateBucketMs: 10000, + requestRateTotalRedPerMinute: 999999, + requestRatePageRedPerMinute: 999999, + requestRateApiPathRedPerMinute: 999999, browserTotalRssRedMb: 999999, browserProcessRssRedMb: 999999, browserRssGrowthRedMb: 999999, diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 6977eb0e..a92ed96a 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -394,6 +394,7 @@ export function commandSummaryForOutput(payload: Record): Recor label: payload.label ?? null, sessionId: payload.sessionId ?? null, provider: payload.provider ?? null, + durationMs: payload.durationMs ?? null, sourceId: opaque(payload.sourceId), fileRef: opaque(payload.fileRef), taskRef: opaque(payload.taskRef), diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index f2b7bb19..3d32c7db 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -271,6 +271,7 @@ export function parseNodeWebProbeObserveOptions( "--alternate-session-strategy", "--expected-sentinel-range", "--expected-action-wait-ms", + "--duration-ms", "--finding-id", "--source-id", "--file-ref", @@ -369,6 +370,11 @@ export function parseNodeWebProbeObserveOptions( if (commandExpectedActionWaitMs !== null && (!Number.isInteger(commandExpectedActionWaitMs) || commandExpectedActionWaitMs < 1000 || commandExpectedActionWaitMs > 600000)) { throw new Error("unsafe web-probe observe --expected-action-wait-ms: expected integer 1000-600000"); } + const commandDurationMsRaw = optionValue(args, "--duration-ms") ?? null; + const commandDurationMs = commandDurationMsRaw === null ? null : Number(commandDurationMsRaw); + if (commandDurationMs !== null && (!Number.isInteger(commandDurationMs) || commandDurationMs < 100 || commandDurationMs > 600000)) { + throw new Error("unsafe web-probe observe --duration-ms: expected integer 100-600000"); + } const commandFindingId = optionValue(args, "--finding-id") ?? null; const commandBlocking = args.includes("--blocking") ? true : args.includes("--non-blocking") ? false : null; for (const [label, value] of [ @@ -448,6 +454,7 @@ export function parseNodeWebProbeObserveOptions( commandAlternateSessionStrategy, commandExpectedSentinelRange, commandExpectedActionWaitMs, + commandDurationMs, commandRequireComposerReady: args.includes("--require-composer-ready"), commandWaitProjectManagementReady: args.includes("--wait-project-management-ready"), commandFindingId, @@ -513,11 +520,12 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe || value === "deleteMdtodoTask" || value === "launchWorkbenchFromTask" || value === "launchWorkbenchFromMdtodo" + || value === "performanceCapture" || value === "screenshot" || value === "mark" || value === "stop" ) return value; - throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`); + throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`); } export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode { @@ -1440,7 +1448,7 @@ const nodeId = process.argv[6]; const lane = process.argv[7]; const keepMs = keepHours * 60 * 60 * 1000; const nowMs = Date.now(); -const rawNames = ["samples.jsonl", "browser-process.jsonl", "network.jsonl", "console.jsonl", "artifacts.jsonl", "screenshots"]; +const rawNames = ["samples.jsonl", "browser-process.jsonl", "network.jsonl", "console.jsonl", "artifacts.jsonl", "performance-events.jsonl", "screenshots", "performance"]; function jsonRead(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } @@ -1895,6 +1903,7 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption alternateSessionStrategy: options.commandAlternateSessionStrategy, expectedSentinelRange: options.commandExpectedSentinelRange, expectedActionWaitMs: options.commandExpectedActionWaitMs, + durationMs: options.commandDurationMs, requireComposerReady: options.commandRequireComposerReady, waitProjectManagementReady: options.commandWaitProjectManagementReady, findingId: options.commandFindingId, @@ -2167,6 +2176,7 @@ function compactObserveCollectForRaw(collect: Record | null): R anchor: observeRecord(collect.anchor), window: observeRecord(collect.window), counts: observeRecord(collect.counts), + summary: observeRecord(collect.summary), ...(rows === undefined ? {} : { rows }), ...(timelineRows === undefined ? {} : { timelineRows }), renderedText: collectView === "timeline" || collectView === "workbench-triad" ? undefined : typeof collect.renderedText === "string" ? collect.renderedText : undefined, @@ -2179,6 +2189,13 @@ function compactObserveCollectForRaw(collect: Record | null): R dom: observeRecord(collect.dom), networkEvidence: collectView === "workbench-triad" ? slimNetworkEvidence(collect.networkEvidence) : observeRecord(collect.networkEvidence), freeze: collectView === "workbench-triad" ? slimFreeze(collect.freeze) : observeRecord(collect.freeze), + profileHotspots: Array.isArray(collect.profileHotspots) ? collect.profileHotspots.slice(0, 8) : undefined, + profileStacks: Array.isArray(collect.profileStacks) ? collect.profileStacks.slice(0, 5) : undefined, + scriptHotspots: Array.isArray(collect.scriptHotspots) ? collect.scriptHotspots.slice(0, 8) : undefined, + longTasks: Array.isArray(collect.longTasks) ? collect.longTasks.slice(0, 8) : undefined, + longAnimationFrames: Array.isArray(collect.longAnimationFrames) ? collect.longAnimationFrames.slice(0, 8) : undefined, + eventLoopGaps: Array.isArray(collect.eventLoopGaps) ? collect.eventLoopGaps.slice(0, 8) : undefined, + captures: Array.isArray(collect.captures) ? collect.captures.slice(0, 8) : undefined, findings: collectView === "workbench-triad" ? slimFindings : Array.isArray(collect.findings) ? collect.findings.slice(0, 8) : undefined, valuesRedacted: true, }; @@ -2327,6 +2344,9 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption "const requestRateCurve = slimRequestRateCurve(source?.requestRateCurve) || slimRequestRateCurve(sourceRequestRate) || slimRequestRateCurve(fullRecentWindow?.requestRate) || slimRequestRateCurve(fullSource?.requestRate);", "const requestRateSummary = objectOrNull(requestRateCurve?.summary) || objectOrNull(sourceRequestRate?.summary) || sourceRequestRate || null;", "const requestRatePeaks = takeHead(firstNonEmptyArray(source?.requestRatePeaks, requestRateCurve?.peaks, sourceRequestRate?.peaks, fullRecentWindow?.requestRate?.peaks, fullSource?.requestRate?.peaks), 12).map(slimRequestPeak);", + "const frontendPerformance = objectOrNull(source?.frontendPerformance) || objectOrNull(fullRecentWindow?.frontendPerformance) || objectOrNull(fullSource?.frontendPerformance) || null;", + "const frontendPerformanceSummary = objectOrNull(frontendPerformance?.summary) || frontendPerformance;", + "const frontendPerformanceHotspots = { scripts: takeHead(firstNonEmptyArray(source?.frontendPerformanceHotspots?.scripts, frontendPerformance?.scriptHotspots, fullRecentWindow?.frontendPerformance?.scriptHotspots, fullSource?.frontendPerformance?.scriptHotspots), 8), profileFunctions: takeHead(firstNonEmptyArray(source?.frontendPerformanceHotspots?.profileFunctions, frontendPerformance?.profileHotspots, fullRecentWindow?.frontendPerformance?.profileHotspots, fullSource?.frontendPerformance?.profileHotspots), 8), profileStacks: takeHead(firstNonEmptyArray(source?.frontendPerformanceHotspots?.profileStacks, frontendPerformance?.profileStacks, fullRecentWindow?.frontendPerformance?.profileStacks, fullSource?.frontendPerformance?.profileStacks), 5), valuesRedacted: true };", "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,", @@ -2342,6 +2362,8 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " requestRate: requestRateSummary,", " requestRateCurve,", " requestRatePeaks,", + " frontendPerformance: frontendPerformanceSummary,", + " frontendPerformanceHotspots,", " pagePerformanceSlowApi: takeHead(sourceSlowApi, 4).map(slimSlowApi),", " archivePagePerformanceSlowApi: takeHead(archiveSlowApi, 8).map(slimSlowApi),", " pagePerformanceSseStreams: takeHead(sourceSseStreams, 4).map((item) => ({ path: item?.path ?? item?.route ?? null, route: item?.route ?? null, sampleCount: item?.sampleCount ?? null, streamOpenSampleCount: item?.streamOpenSampleCount ?? null, streamOpenP95Ms: item?.streamOpenP95Ms ?? null, streamOpenMaxMs: item?.streamOpenMaxMs ?? null, streamOpenBudgetMs: item?.streamOpenBudgetMs ?? null, streamOpenOverBudgetCount: item?.streamOpenOverBudgetCount ?? null, streamOpenOverFiveSecondCount: item?.streamOpenOverFiveSecondCount ?? null, streamLifetimeOverFiveSecondCount: item?.streamLifetimeOverFiveSecondCount ?? null })),", @@ -2432,6 +2454,8 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " requestRate: compact.requestRate ?? null,", " requestRateCurve: compact.requestRateCurve ? { summary: compact.requestRateCurve.summary ?? null, buckets: Array.isArray(compact.requestRateCurve.buckets) ? compact.requestRateCurve.buckets.slice(-6) : [], pageCurves: Array.isArray(compact.requestRateCurve.pageCurves) ? compact.requestRateCurve.pageCurves.slice(0, 4).map((item) => ({ pageKey: item.pageKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null, buckets: Array.isArray(item.buckets) ? item.buckets.slice(-6) : [] })) : [], apiPathCurves: Array.isArray(compact.requestRateCurve.apiPathCurves) ? compact.requestRateCurve.apiPathCurves.slice(0, 6).map((item) => ({ apiKey: item.apiKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null, buckets: Array.isArray(item.buckets) ? item.buckets.slice(-6) : [] })) : [], valuesRedacted: true } : null,", " requestRatePeaks: Array.isArray(compact.requestRatePeaks) ? compact.requestRatePeaks.slice(0, 6) : [],", + " frontendPerformance: compact.frontendPerformance ?? null,", + " frontendPerformanceHotspots: compact.frontendPerformanceHotspots ? { scripts: Array.isArray(compact.frontendPerformanceHotspots.scripts) ? compact.frontendPerformanceHotspots.scripts.slice(0, 6) : [], profileFunctions: Array.isArray(compact.frontendPerformanceHotspots.profileFunctions) ? compact.frontendPerformanceHotspots.profileFunctions.slice(0, 6) : [], profileStacks: Array.isArray(compact.frontendPerformanceHotspots.profileStacks) ? compact.frontendPerformanceHotspots.profileStacks.slice(0, 4) : [], valuesRedacted: true } : null,", " projectManagement: compact.projectManagement ?? null,", " promptNetwork: compact.promptNetwork ?? null,", " toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],", @@ -2916,10 +2940,13 @@ export function recoverWebObserveAnalyzeFromArtifacts(options: NodeWebProbeObser "const requestRateCurve = slimRequestRateCurve(source.requestRateCurve) || slimRequestRateCurve(source.requestRate) || slimRequestRateCurve(recent.requestRate);", "const requestRateSummary = objectOrNull(requestRateCurve?.summary) || objectOrNull(source.requestRate?.summary) || objectOrNull(source.requestRate) || null;", "const requestRatePeaks = arr(source.requestRatePeaks ?? requestRateCurve?.peaks ?? source.requestRate?.peaks ?? recent.requestRate?.peaks).slice(0, 12).map(slimRequestPeak);", + "const frontendPerformance = objectOrNull(source.frontendPerformance) || objectOrNull(recent.frontendPerformance) || null;", + "const frontendPerformanceSummary = objectOrNull(frontendPerformance?.summary) || frontendPerformance;", + "const frontendPerformanceHotspots = { scripts: arr(source.frontendPerformanceHotspots?.scripts ?? frontendPerformance?.scriptHotspots ?? recent.frontendPerformance?.scriptHotspots).slice(0, 8), profileFunctions: arr(source.frontendPerformanceHotspots?.profileFunctions ?? frontendPerformance?.profileHotspots ?? recent.frontendPerformance?.profileHotspots).slice(0, 8), profileStacks: arr(source.frontendPerformanceHotspots?.profileStacks ?? frontendPerformance?.profileStacks ?? recent.frontendPerformance?.profileStacks).slice(0, 5), valuesRedacted: true };", "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) || {}, browserProcess: objectOrNull(archiveSummary.browserProcess) || objectOrNull(browserProcess.summary) || {}, requestRate: objectOrNull(archiveSummary.requestRate) || requestRateSummary || {}, 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, requestRate: requestRateSummary, requestRateCurve, requestRatePeaks, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, browserProcess: objectOrNull(browserProcess.summary) || browserProcess, 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) || {}, browserProcess: objectOrNull(archiveSummary.browserProcess) || objectOrNull(browserProcess.summary) || {}, requestRate: objectOrNull(archiveSummary.requestRate) || requestRateSummary || {}, frontendPerformance: objectOrNull(archiveSummary.frontendPerformance) || frontendPerformanceSummary || {}, 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, requestRate: requestRateSummary, requestRateCurve, requestRatePeaks, frontendPerformance: frontendPerformanceSummary, frontendPerformanceHotspots, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, browserProcess: objectOrNull(browserProcess.summary) || browserProcess, 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");