// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer. // Responsibility: Source strings for the pure-client HWLAB web-probe observer and offline analyzer. export function nodeWebObserveRunnerSource(): string { return String.raw`#!/usr/bin/env node import { createHash, randomBytes } from "node:crypto"; import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; const specRef = "PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer"; const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL); const username = process.env.HWLAB_WEB_USER || "admin"; const password = process.env.HWLAB_WEB_PASS || ""; const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || ".state/web-observe/manual"); const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex")); const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench"; const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000); const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000); const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); const playwrightProxy = proxyConfigFromEnv(baseUrl); const pageId = "page-" + randomBytes(4).toString("hex"); const dirs = { commandsPending: path.join(stateDir, "commands", "pending"), commandsProcessing: path.join(stateDir, "commands", "processing"), commandsDone: path.join(stateDir, "commands", "done"), commandsFailed: path.join(stateDir, "commands", "failed"), screenshots: path.join(stateDir, "screenshots"), analysis: path.join(stateDir, "analysis"), }; const files = { manifest: path.join(stateDir, "manifest.json"), heartbeat: path.join(stateDir, "heartbeat.json"), control: path.join(stateDir, "control.jsonl"), samples: path.join(stateDir, "samples.jsonl"), network: path.join(stateDir, "network.jsonl"), console: path.join(stateDir, "console.jsonl"), errors: path.join(stateDir, "errors.jsonl"), artifacts: path.join(stateDir, "artifacts.jsonl"), }; let browser; let context; let page; let sampleSeq = 0; let commandSeq = 0; let artifactSeq = 0; let activeCommandId = null; let stopping = false; let terminalStatus = "starting"; let lastScreenshotAtMs = 0; let auth = null; let pageLoadSeq = 0; let currentPageProvenance = null; try { if (!password) throw new Error("missing HWLAB_WEB_PASS"); await prepareDirs(); await writeManifest({ status: "starting" }); await writeHeartbeat({ status: "starting" }); const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href); const { chromium } = await launcher.importPlaywright(); browser = await launcher.launchChromium(chromium); context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context)); page = await context.newPage(); attachPassiveListeners(page); await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath)); terminalStatus = "running"; await writeManifest({ status: "running", auth: publicAuth(auth) }); await writeHeartbeat({ status: "running" }); while (!stopping) { await drainOneCommand(); await samplePage("interval"); if (maxSamples > 0 && sampleSeq >= maxSamples) { await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples })); break; } await sleep(sampleIntervalMs); } terminalStatus = "completed"; await writeHeartbeat({ status: "completed" }); await writeManifest({ status: "completed", completedAt: new Date().toISOString() }); process.exitCode = 0; } catch (error) { terminalStatus = "failed"; await appendJsonl(files.errors, eventRecord("runner-error", { error: errorSummary(error) })).catch(() => {}); await writeHeartbeat({ status: "failed", error: errorSummary(error) }).catch(() => {}); await writeManifest({ status: "failed", error: errorSummary(error) }).catch(() => {}); process.exitCode = 2; } finally { 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 }))); } 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), pageAuthority: { browser: "chromium", context: "single", pageId, continuityBreaksRecorded: true }, pageProvenance: compactPageProvenance(currentPageProvenance), sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerInitiatedDefault: false, responseBodyReadDefault: false }, 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, baseUrl, currentUrl: currentPageUrl(), 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 attachPassiveListeners(targetPage) { targetPage.on("request", (request) => { void appendJsonl(files.network, eventRecord("request", { 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(); void appendJsonl(files.network, eventRecord("response", { observerInitiated: false, commandId: activeCommandId, method: request.method(), url: safeUrl(response.url()), resourceType: request.resourceType(), status: response.status(), statusText: response.statusText(), fromServiceWorker: response.fromServiceWorker(), bodyRead: false, })); }); targetPage.on("requestfailed", (request) => { void appendJsonl(files.network, eventRecord("requestfailed", { 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", { type: message.type(), text: truncate(message.text(), 1000), location: message.location() })); }); targetPage.on("pageerror", (error) => { void appendJsonl(files.errors, eventRecord("pageerror", { error: errorSummary(error) })); }); targetPage.on("crash", () => { void appendJsonl(files.errors, eventRecord("page-crash", { pageId })); }); targetPage.on("close", () => { void appendJsonl(files.control, eventRecord("continuity-break", { pageId, 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; 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 failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error) }; await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 }); await appendJsonl(files.control, controlRecord(command, "failed", failed.error)); await unlink(processing).catch(() => {}); } finally { activeCommandId = null; await writeHeartbeat({ status: terminalStatus }); } } 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 "preflight": return preflightSummary(); case "goto": return gotoTarget(command.path || command.url || targetPath); case "newSession": return createSessionFromUi(); case "sendPrompt": return sendPrompt(String(command.text || "")); case "selectProvider": return selectProvider(String(command.provider || command.value || command.text || "")); case "clickSession": return clickSession(String(command.sessionId || command.value || "")); case "screenshot": return captureScreenshot(command.reason || "manual", command.imageType || "png"); case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId }; case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId }; default: throw new Error("unsupported observer command type: " + command.type); } } 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) { await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error) })); throw error; } finally { activeCommandId = null; } } async function authenticate(browserContext) { const loginUrl = new URL("/auth/login", baseUrl).toString(); const response = await browserContext.request.post(loginUrl, { data: { username, password }, headers: { accept: "application/json", "content-type": "application/json" }, timeout: 15000, }); const cookies = await browserContext.cookies(baseUrl); const cookieNames = cookies.map((cookie) => cookie.name).filter((name) => /session|auth|token/iu.test(name)); return { ok: response.ok() && cookieNames.length > 0, method: "api", loginPath: new URL(loginUrl).pathname, status: response.status(), statusText: response.statusText(), cookiePresent: cookieNames.length > 0, cookieNames, valuesRedacted: true, }; } 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 || [], valuesRedacted: true }; } function proxyConfigFromEnv(targetBaseUrl) { 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 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, }, valuesRedacted: true, }; } 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) { const target = new URL(String(rawTarget || targetPath), baseUrl).toString(); const beforeUrl = currentPageUrl(); const attempts = []; for (let attempt = 1; attempt <= 3; attempt += 1) { try { const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1000).catch(() => {}); const httpStatus = response ? response.status() : null; const pageProvenance = await refreshPageProvenance("goto", httpStatus); attempts.push({ attempt, ok: true, httpStatus }); return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), attempts }; } catch (error) { const message = error instanceof Error ? error.message : String(error); attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message) }); if (attempt >= 3 || !isRetryableNavigationError(message)) { throw Object.assign(new Error(message), { attempts, target }); } await page.waitForTimeout(500 * attempt).catch(() => {}); } } return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts }; } 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|Navigation timeout/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 (/Navigation timeout/iu.test(text)) return "navigation-timeout"; 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 createSessionFromUi() { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const create = page.locator("#session-create").first(); await create.waitFor({ state: "visible", timeout: 15000 }); await create.click(); await page.waitForFunction((initial) => { const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); const sessionId = activeTab?.getAttribute("data-session-id") || ""; const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; const input = document.querySelector("#command-input"); return Boolean(activeTab && sessionId && sessionId !== initial.sessionId && input && !input.disabled && !warning); }, { sessionId: before?.activeSessionId || "" }, { timeout: 30000 }).catch(() => null); const after = await workbenchSessionSnapshot(); const beforeSessionId = before?.activeSessionId || before?.routeSessionId || ""; const afterSessionId = after?.activeSessionId || after?.routeSessionId || ""; const ok = Boolean(afterSessionId && afterSessionId !== beforeSessionId && after?.composerReady); if (!ok) { const error = new Error("newSession did not create or select a different workbench session"); error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, pageId, valuesRedacted: true }; throw error; } return { beforeUrl, afterUrl: currentPageUrl(), ok, before, after, sessionId: afterSessionId || null, pageId }; } async function workbenchSessionSnapshot() { return page.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; 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, composerReady: Boolean(activeTab && input && !input.disabled && !warning), warning }; }).catch(() => null); } async function sendPrompt(text) { if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); const beforeUrl = currentPageUrl(); const beforeEvidence = await promptSideEffectSnapshot(); const primaryEditor = page.locator("#command-input").last(); const editor = await primaryEditor.isVisible().catch(() => false) ? primaryEditor : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); await editor.waitFor({ state: "visible", timeout: 15000 }); 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); else if (editable) { await editor.click(); await page.keyboard.insertText(text); } else { await editor.click(); await page.keyboard.insertText(text); } const primarySubmit = page.locator('#command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]').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 submit.waitFor({ state: "visible", timeout: 15000 }); 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: 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 (sideEffect.submitted) { return { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), chatSubmit: { status: null, statusText: null, urlPath: "/v1/agent/chat", waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect }, pageId }; } throw new Error("sendPrompt did not observe POST /v1/agent/chat response after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout")); } const chatStatus = chatResponse.status(); if (chatStatus < 200 || chatStatus >= 300) { throw new Error("sendPrompt observed POST /v1/agent/chat HTTP " + chatStatus + " " + chatResponse.statusText()); } let chatPayload = null; let chatPayloadError = null; try { chatPayload = await chatResponse.json(); } catch (error) { chatPayloadError = errorSummary(error); } let chatUrlPath = "/v1/agent/chat"; try { chatUrlPath = new URL(chatResponse.url()).pathname; } catch { chatUrlPath = "/v1/agent/chat"; } 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 }, pageId }; } 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 textBytes = new TextEncoder().encode(text).length; return { runIds, traceIds, running, executionError, 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 meaningfulChange = Math.abs(current.textBytes - Number(before?.textBytes || 0)) > 20 && (current.running || current.executionError); return newRun || newTrace || meaningfulChange; }, 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 meaningfulChange = Math.abs(after.textBytes - Number(beforeEvidence?.textBytes || 0)) > 20 && (after.running || after.executionError); return { ...after, newRunIds, newTraceIds, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || meaningfulChange, valuesRedacted: true }; } async function promptSideEffectSnapshot() { return page.evaluate(() => { const text = document.body?.innerText || ""; 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), textBytes: new TextEncoder().encode(text).length, valuesRedacted: true }; }).catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, textBytes: 0, valuesRedacted: true })); } async function selectProvider(provider) { const target = String(provider || "").trim(); if (!target) throw new Error("selectProvider requires provider name"); const beforeUrl = currentPageUrl(); 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*="profile" i]', '[data-testid*="model" i]', '[aria-label*="provider" i]', '[aria-label*="profile" i]', '[aria-label*="model" i]', '[aria-label*="模型"]', '[aria-label*="提供"]', '[role="combobox"]', '[aria-haspopup="listbox"]', 'button' ].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 (!/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); } throw new Error("provider option not found: " + target + "; attempts=" + JSON.stringify(attempts.slice(-10))); } async function clickSession(sessionId) { if (!sessionId) throw new Error("clickSession requires --session-id or --value"); const beforeUrl = currentPageUrl(); const escaped = cssEscape(sessionId); const candidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"], text=" + sessionId).first(); await candidate.waitFor({ state: "visible", timeout: 15000 }); await candidate.click(); await page.waitForTimeout(1000); return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId }; } async function preflightSummary() { return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) }; } async function samplePage(reason) { if (!page || page.isClosed()) return; sampleSeq += 1; const dom = await page.evaluate(() => { const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit); 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 textHashInput = (element) => trim(element.textContent || "", 800); 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 summarize = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { const rect = element.getBoundingClientRect(); 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") || 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: textHashInput(element), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }); 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 messageSelector = '[data-testid*="message" i], [class*="message" i], article, [role="article"]'; const traceSelector = '[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 = summarize(messageSelector, 20); const traceRows = summarize(traceSelector, 30); 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 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, text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }).slice(-20); 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, diagnostics, turns, 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(-30).map((entry) => ({ name: entry.name.split(/[?#]/u)[0].slice(0, 240), initiatorType: entry.initiatorType, startTime: Math.round(entry.startTime), duration: Math.round(entry.duration) })), }; }).catch((error) => ({ error: errorSummary(error), url: currentPageUrl() })); const sample = { seq: sampleSeq, ts: new Date().toISOString(), reason, pageId, commandId: activeCommandId, observerInitiated: false, ...digestDom(dom), }; await appendJsonl(files.samples, sample); if (screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) { await captureScreenshot("checkpoint", "jpeg").catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { error: errorSummary(error) }))); } await writeHeartbeat({ status: terminalStatus }); } function digestDom(dom) { 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 || "") })) : []; 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 diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : []; const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : []; const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq }); currentPageProvenance = pageProvenance; return { ...dom, messages, traceRows, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) }; } async function captureScreenshot(reason, imageType = "png") { if (!page || page.isClosed()) throw new Error("page is not available for screenshot"); 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 options = type === "jpeg" ? { path: file, type: "jpeg", quality: 70, fullPage: false } : { path: file, type: "png", fullPage: false }; await page.screenshot(options); 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() }; await appendJsonl(files.artifacts, artifact); lastScreenshotAtMs = Date.now(); return artifact; } function eventRecord(type, data) { return { ts: new Date().toISOString(), type, jobId, pageId, sampleSeq, commandId: activeCommandId, ...sanitize(data) }; } 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; 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, 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() { try { return page && !page.isClosed() ? page.url() : 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 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) { return { 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 }; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); } `; } export function nodeWebObserveAnalyzerSource(): string { return String.raw`#!/usr/bin/env node import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { createInterface } from "node:readline"; const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || process.argv[2] || ".state/web-observe/manual"); const analysisDir = path.join(stateDir, "analysis"); const reportJsonPath = path.join(analysisDir, "report.json"); const reportMdPath = path.join(analysisDir, "report.md"); const jsonlReadIssues = []; const samples = await readJsonl(path.join(stateDir, "samples.jsonl"), { compact: compactSampleForAnalysis }); const control = await readJsonl(path.join(stateDir, "control.jsonl")); const network = await readJsonl(path.join(stateDir, "network.jsonl")); const consoleEvents = await readJsonl(path.join(stateDir, "console.jsonl")); const errors = await readJsonl(path.join(stateDir, "errors.jsonl")); const artifacts = await readJsonl(path.join(stateDir, "artifacts.jsonl")); const manifest = await readJson(path.join(stateDir, "manifest.json")); const heartbeat = await readJson(path.join(stateDir, "heartbeat.json")); await mkdir(analysisDir, { recursive: true, mode: 0o700 }); const transitions = buildTransitions(samples); const sampleMetrics = buildSampleMetrics(samples, control); const pageProvenance = buildPageProvenanceReport(samples, control, manifest); const pagePerformance = buildPagePerformanceReport(samples, manifest); const promptNetwork = buildPromptNetworkReport(control, network); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance); if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) }); const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest }); const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl })); const report = { ok: findings.filter((item) => item.severity === "red").length === 0, command: "web-probe-observe analyze", generatedAt: new Date().toISOString(), stateDir, 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 }, commandTimeline, transitions, sampleMetrics, pageProvenance, pagePerformance, promptNetwork, runtimeAlerts, findings, windows: { recent: recentWindow }, readIssues: jsonlReadIssues, artifactSummary: await artifactSummary(artifacts), safety: { offlineOnly: true, browserDriven: false, apiFetch: false, valuesRedacted: true }, }; await writeFile(reportJsonPath, JSON.stringify(report, null, 2) + "\n", { mode: 0o600 }); await writeFile(reportMdPath, renderMarkdown(report), { mode: 0o600 }); const [jsonMeta, mdMeta] = await Promise.all([fileMeta(reportJsonPath), fileMeta(reportMdPath)]); console.log(JSON.stringify({ ok: true, command: "web-probe-observe analyze", stateDir, reportJsonPath, reportJsonSha256: jsonMeta.sha256, reportMdPath, reportMdSha256: mdMeta.sha256, counts: report.counts, archiveSummary: { sampleMetrics: sampleMetrics.summary, pagePerformance: pagePerformance.summary, runtimeAlerts: runtimeAlerts.summary, findingCount: findings.length, redFindingCount: findings.filter((item) => item.severity === "red").length, }, analysisWindow: recentWindow.summary, jsonlReadIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })), readIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })), sampleMetrics: { ...recentWindow.sampleMetrics.summary, rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })), turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, promptIndex: item.promptIndex, lastPromptIndex: item.lastPromptIndex, firstSeq: item.firstSeq, lastSeq: item.lastSeq, traceId: item.traceId, messageId: item.messageId })), }, pageProvenance: recentWindow.pageProvenance.summary, pagePerformance: recentWindow.pagePerformance.summary, promptNetwork: recentWindow.promptNetwork.summary, runtimeAlerts: recentWindow.runtimeAlerts.summary, httpErrorGroups: recentWindow.runtimeAlerts.networkHttpErrorsByPath.slice(0, 8).map((item) => ({ method: item.method ?? null, status: item.status ?? null, path: item.urlPath ?? null, count: item.count, firstAt: item.firstAt ?? null, lastAt: item.lastAt ?? null, promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [], failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [], })), requestFailedGroups: recentWindow.runtimeAlerts.networkRequestFailedByPath.slice(0, 8).map((item) => ({ method: item.method ?? null, status: item.status ?? null, path: item.urlPath ?? null, count: item.count, firstAt: item.firstAt ?? null, lastAt: item.lastAt ?? null, promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [], failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [], })), domDiagnosticGroups: recentWindow.runtimeAlerts.domDiagnosticsByFingerprint.slice(0, 5).map((item) => ({ diagnosticCode: item.diagnosticCode ?? null, count: item.count, firstAt: item.firstAt, lastAt: item.lastAt, promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [], traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [], text: String(item.preview ?? item.normalizedPreview ?? item.text ?? "").slice(0, 160), })), domDiagnosticSamples: recentWindow.runtimeAlerts.domDiagnostics.slice(-8).map((item) => ({ seq: item.seq ?? null, ts: item.ts ?? null, promptIndex: item.promptIndex ?? null, source: item.source ?? null, diagnosticCode: item.diagnosticCode ?? null, traceId: item.traceId ?? null, httpStatus: item.httpStatus ?? null, idleSeconds: item.idleSeconds ?? null, waitingFor: item.waitingFor ?? null, lastEventLabel: item.lastEventLabel ?? null, text: String(item.preview ?? item.text ?? "").slice(0, 180), })), consoleAlertGroups: recentWindow.runtimeAlerts.consoleAlertsByPath.slice(0, 8).map((item) => ({ type: item.type ?? null, status: item.status ?? null, path: item.urlPath ?? null, count: item.count, firstAt: item.firstAt ?? null, lastAt: item.lastAt ?? null, promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [], traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [], })), consoleAlertSamples: recentWindow.runtimeAlerts.consoleAlerts.slice(-8).map((item) => ({ ts: item.ts ?? null, promptIndex: item.promptIndex ?? null, type: item.type ?? null, status: item.status ?? null, path: item.urlPath ?? null, traceId: item.traceId ?? null, text: String(item.preview ?? item.text ?? "").slice(0, 180), })), turnTimingRecentUpdateJumps: recentWindow.sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 8).map((item) => ({ columnLabel: item.columnLabel ?? item.columnId ?? null, promptIndex: item.promptIndex ?? null, traceId: item.traceId ?? null, fromSeq: item.fromSeq ?? null, toSeq: item.toSeq ?? null, fromTs: item.fromTs ?? null, toTs: item.toTs ?? null, fromValue: item.fromValue ?? null, toValue: item.toValue ?? null, delta: item.delta ?? null, sampleDeltaSeconds: item.sampleDeltaSeconds ?? null, allowedIncreaseSeconds: item.allowedIncreaseSeconds ?? null, excessiveIncreaseSeconds: item.excessiveIncreaseSeconds ?? null, })), pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overFiveSecondCount: item.overFiveSecondCount })), findings: recentWindow.findings.slice(0, 8).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) })), valuesRedacted: true, })); async function readJson(file) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } } async function readJsonl(file, 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 []; } } function compactSampleForAnalysis(sample) { if (!sample || typeof sample !== "object") return sample; return { seq: sample.seq ?? null, ts: sample.ts ?? null, reason: sample.reason ?? null, pageId: sample.pageId ?? null, commandId: sample.commandId ?? null, observerInitiated: sample.observerInitiated ?? null, url: sample.url ?? null, path: sample.path ?? null, routeSessionId: sample.routeSessionId ?? null, activeSessionId: sample.activeSessionId ?? null, messages: compactDomItems(sample.messages), traceRows: compactDomItems(sample.traceRows), turns: compactDomItems(sample.turns), diagnostics: compactDomItems(sample.diagnostics), pageProvenance: compactSamplePageProvenance(sample.pageProvenance), performance: compactPerformanceItems(sample.performance) }; } 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)); return { index: item.index ?? null, tag: item.tag ?? null, testId: item.testId ?? null, role: item.role ?? null, status: item.status ?? null, sessionId: item.sessionId ?? null, messageId: item.messageId ?? null, traceId: item.traceId ?? null, turnId: item.turnId ?? null, durationText: item.durationText ?? null, activityText: item.activityText ?? 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 })); } 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 buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance) { const findings = []; const commandTimes = control.filter((item) => item.phase === "completed" || item.phase === "started").map((item) => Date.parse(item.ts)).filter(Number.isFinite); const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean)); const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean)); if (routeSessions.size > 1) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed during observation", routeSessionCount: routeSessions.size, samples: sampleRefs(samples, (item) => item.routeSessionId) }); if (activeSessions.size > 1) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed during observation", activeSessionCount: activeSessions.size, samples: sampleRefs(samples, (item) => item.activeSessionId) }); const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId); 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)); for (let i = 1; i < samples.length; i += 1) { const prev = digestSample(samples[i - 1]); const next = digestSample(samples[i]); if (prev !== next && !commandedPromptSeqs.has(samples[i]?.seq) && !nearCommand(samples[i], commandTimes, 10000)) uncommandedChanges.push(ref(samples[i])); } 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 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 > 250 && nextY < 40 && !nearCommand(samples[i], commandTimes, 8000)) 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 promptFailures = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.chatPostOk === false) : []; if (promptFailures.length > 0) findings.push({ id: "prompt-chat-submit-failed", severity: "red", summary: "sendPrompt command had no successful /v1/agent/chat POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) }); 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) }); 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?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.requestFailedCount, groups: 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?.consoleAlertCount ?? 0) > 0) findings.push({ id: "runtime-console-alerts", severity: "amber", summary: "browser console warning/error entries were captured during observation", count: runtimeAlerts.summary.consoleAlertCount, groups: runtimeAlerts.consoleAlertsByPath.slice(0, 12) }); const slowApi = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0) : []; if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded 5s usability budget", count: slowApi.length, groups: slowApi.slice(0, 20) }); const longLivedStreams = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream) : []; 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) }); 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 || ""))); findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for manual lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length }); 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 buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, 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 sampleMetrics = buildSampleMetrics(windowSamples, control); const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest); const pagePerformance = buildPagePerformanceReport(windowSamples, manifest); const promptNetwork = buildPromptNetworkReport(control, windowNetwork); const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance); 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, valuesRedacted: true }, sampleMetrics, pageProvenance, pagePerformance, promptNetwork, runtimeAlerts, 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(); 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 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 dedupeKey = [parsed.path, entry.initiatorType || "", 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, durationsMs: [], streamOpenDurationsMs: [], overFiveSecondCount: 0, streamLifetimeOverFiveSecondCount: 0, streamOpenOverFiveSecondCount: 0, firstAt: sample.ts ?? null, lastAt: sample.ts ?? null, firstSeq: sample.seq ?? null, lastSeq: sample.seq ?? null, initiatorTypes: [], pageAssetFingerprints: [], valuesRedacted: true }; group.sampleCount += 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; } } } else if (durationMs > 5000) { group.overFiveSecondCount += 1; } group.lastAt = sample.ts ?? null; 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, 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, streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount, overFiveSecondCount: group.overFiveSecondCount, overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / 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), valuesRedacted: true }; }).sort((a, b) => (b.overFiveSecondCount - a.overFiveSecondCount) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path)); const slow = sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0); const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream); const budgetP95Values = sameOriginApiByPath .map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0))) .filter((value) => Number.isFinite(value)); return { summary: { budgetMs: 5000, sameOriginApiPathCount: sameOriginApiByPath.length, sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0), longLivedStreamPathCount: longLivedStreams.length, longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0), slowPathCount: slow.length, slowSampleCount: slow.reduce((sum, item) => sum + item.overFiveSecondCount, 0), worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null, valuesRedacted: true }, sameOriginApiByPath, valuesRedacted: true }; } 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 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" && /\/v1\/agent\/chat(?:\?|$)/u.test(String(item?.url || ""))) .map((item) => { const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null; 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: urlPath(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 chatPostOk = responseStatuses.some((status) => status >= 200 && status < 300) && failures.length === 0; 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, 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, 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 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 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 domDiagnostics = []; const executionErrors = []; const baselineExecutionErrors = []; 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) }; if (baseline) baselineExecutionErrors.push(event); 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 pageErrors = errors.map((item) => ({ ts: item.ts ?? null, promptIndex: promptIndexForTs(promptTimes, item.ts), type: item.type ?? null, 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, domDiagnosticSampleCount: domDiagnostics.length, domDiagnosticGroupCount: groupDomDiagnostics(domDiagnostics).length, executionErrorCount: executionErrors.length, baselineExecutionErrorCount: baselineExecutionErrors.length, consoleAlertCount: consoleAlerts.length, pageErrorCount: pageErrors.length, networkErrorGroupCount: groupNetworkAlerts(httpErrors).length, requestFailedGroupCount: groupNetworkAlerts(requestFailed).length, executionErrorGroupCount: groupExecutionErrors(executionErrors).length, baselineExecutionErrorGroupCount: groupExecutionErrors(baselineExecutionErrors).length, consoleAlertGroupCount: groupConsoleAlerts(consoleAlerts).length }, networkHttpErrorsByPath: groupNetworkAlerts(httpErrors), networkRequestFailedByPath: groupNetworkAlerts(requestFailed), 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), 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 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); } 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+轮|final response|sealed final response|最终结果|已完成[::]/iu.test(String(text || "")); } function buildSampleMetrics(samples, control) { const promptCommands = control .filter((item) => item.type === "sendPrompt" && item.phase === "completed") .map((item) => ({ ts: item.ts, tsMs: Date.parse(item.ts), commandId: item.commandId ?? null, textHash: item.input?.textHash ?? null, textBytes: item.input?.textBytes ?? null })) .filter((item) => Number.isFinite(item.tsMs)) .sort((a, b) => a.tsMs - b.tsMs); const promptTimes = promptCommands.map((item) => item.tsMs); const timeline = samples.map((sample) => { const texts = sampleTexts(sample); const tsMs = Date.parse(sample.ts); const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; const totalElapsedValues = texts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite); const recentUpdateValues = texts.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); 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, 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 turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : []; 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 rounds = buildRoundMetricSummaries(timeline, promptCommands, { nonMonotonic: turnTimingNonMonotonic, terminalElapsedGrowth: turnTimingTerminalElapsedGrowth, recentUpdateResets: turnTimingRecentUpdateResets, recentUpdateSteps: turnTimingRecentUpdateSteps }); const recentUpdateJumpCount = turnTimingRecentUpdateSawtoothJumps.length; return { summary: { sampleCount: timeline.length, withTotalElapsed: withTotal, withRecentUpdate: withRecent, diagnostics, 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, 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, roundsWithTurnTimingNonMonotonic: rounds.filter((item) => item.turnTimingNonMonotonicCount > 0).length, roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length, roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length }, rounds, turnColumns: turnTiming.columns, turnTimingTable: turnTiming.rows, turnTimingNonMonotonic, turnTimingTerminalElapsedGrowth, turnTimingRecentUpdateSawtoothJumps, turnTimingRecentUpdateSteps, turnTimingRecentUpdateLargestSteps, turnTimingRecentUpdateResets, timeline }; } function buildTurnTimingTable(samples, timeline) { const columns = []; const registry = new Map(); const rows = []; for (let index = 0; index < samples.length; index += 1) { const sample = samples[index]; const timelineItem = timeline[index] || {}; const cells = {}; for (const metric of turnMetricItems(sample, timelineItem)) { let column = registry.get(metric.key); if (!column) { column = { id: "T" + String(columns.length + 1), label: "T" + String(columns.length + 1), keyHash: sha256(metric.key), source: metric.source, firstSeq: sample.seq ?? null, firstTs: sample.ts ?? null, lastSeq: sample.seq ?? null, lastTs: sample.ts ?? null, promptIndex: metric.promptIndex ?? null, lastPromptIndex: metric.promptIndex ?? null, traceId: metric.traceId ?? null, messageId: metric.messageId ?? null, domIndex: metric.domIndex ?? null }; registry.set(metric.key, column); columns.push(column); } else { column.lastSeq = sample.seq ?? null; column.lastTs = sample.ts ?? null; column.lastPromptIndex = metric.promptIndex ?? column.lastPromptIndex ?? null; if (!column.traceId && metric.traceId) column.traceId = metric.traceId; if (!column.messageId && metric.messageId) column.messageId = metric.messageId; } cells[column.id] = { totalElapsedSeconds: metric.totalElapsedSeconds, recentUpdateSeconds: metric.recentUpdateSeconds, status: metric.status ?? null, promptIndex: metric.promptIndex ?? null, source: metric.source, traceId: metric.traceId ?? null, messageId: metric.messageId ?? null, textHash: metric.textHash ?? null }; } rows.push({ ts: sample.ts ?? null, seq: sample.seq ?? null, promptIndex: timelineItem.promptIndex ?? 0, routeSessionId: sample.routeSessionId ?? null, activeSessionId: sample.activeSessionId ?? null, cells }); } const timingEvents = detectTurnTimingNonMonotonic(columns, rows); return { columns, rows, nonMonotonic: timingEvents.anomalies, terminalElapsedGrowth: timingEvents.terminalElapsedGrowth, recentUpdateResets: timingEvents.recentUpdateResets, recentUpdateSteps: timingEvents.recentUpdateSteps }; } function detectTurnTimingNonMonotonic(columns, rows) { const anomalies = []; const terminalElapsedGrowth = []; const recentUpdateResets = []; const recentUpdateSteps = []; for (const column of columns) { const previousByMetric = new Map(); let previousTerminalTotal = null; for (const row of rows) { const cell = row.cells?.[column.id]; if (!cell) continue; for (const metric of ["totalElapsedSeconds", "recentUpdateSeconds"]) { const value = cell[metric]; if (value === null || value === undefined || !Number.isFinite(Number(value))) continue; const current = Number(value); const previous = previousByMetric.get(metric); if (previous && metric === "totalElapsedSeconds" && current < previous.value) { anomalies.push({ columnId: column.id, columnLabel: column.label, metric, anomaly: "decrease", fromSeq: previous.seq, fromTs: previous.ts, fromValue: previous.value, toSeq: row.seq ?? null, toTs: row.ts ?? null, toValue: current, delta: current - previous.value, traceId: cell.traceId ?? column.traceId ?? null, messageId: cell.messageId ?? column.messageId ?? null, promptIndex: cell.promptIndex ?? row.promptIndex ?? null, source: cell.source ?? column.source ?? null, valuesRedacted: true }); } if (metric === "totalElapsedSeconds" && isTerminalTurnStatus(cell.status)) { if (previousTerminalTotal && current > previousTerminalTotal.value) { terminalElapsedGrowth.push({ columnId: column.id, columnLabel: column.label, metric, anomaly: "terminal-growth", expectedPattern: "terminal-total-elapsed-sealed", fromSeq: previousTerminalTotal.seq, fromTs: previousTerminalTotal.ts, fromValue: previousTerminalTotal.value, fromStatus: previousTerminalTotal.status, toSeq: row.seq ?? null, toTs: row.ts ?? null, toValue: current, toStatus: cell.status ?? null, delta: current - previousTerminalTotal.value, traceId: cell.traceId ?? column.traceId ?? null, messageId: cell.messageId ?? column.messageId ?? null, promptIndex: cell.promptIndex ?? row.promptIndex ?? null, source: cell.source ?? column.source ?? null, valuesRedacted: true }); } previousTerminalTotal = { value: current, seq: row.seq ?? null, ts: row.ts ?? null, status: cell.status ?? null }; } if (previous && metric === "recentUpdateSeconds") { const elapsedMs = Date.parse(String(row.ts ?? "")) - Date.parse(String(previous.ts ?? "")); const elapsedSeconds = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs / 1000 : null; const increase = current - previous.value; const allowedIncrease = elapsedSeconds === null ? 3 : Math.max(3, elapsedSeconds + 2); const excessiveIncrease = increase > allowedIncrease ? increase - allowedIncrease : 0; recentUpdateSteps.push({ columnId: column.id, columnLabel: column.label, metric: "recentUpdateSeconds", event: increase < 0 ? "reset" : excessiveIncrease > 0 ? "jump" : "increase", expectedPattern: "sawtooth-increase-or-reset", fromSeq: previous.seq, fromTs: previous.ts, fromValue: previous.value, toSeq: row.seq ?? null, toTs: row.ts ?? null, toValue: current, delta: increase, sampleDeltaSeconds: elapsedSeconds, allowedIncreaseSeconds: allowedIncrease, excessiveIncreaseSeconds: excessiveIncrease, traceId: cell.traceId ?? column.traceId ?? null, messageId: cell.messageId ?? column.messageId ?? null, promptIndex: cell.promptIndex ?? row.promptIndex ?? null, source: cell.source ?? column.source ?? null, valuesRedacted: true }); if (increase < 0) { recentUpdateResets.push({ columnId: column.id, columnLabel: column.label, metric: "recentUpdateSeconds", event: "reset", fromSeq: previous.seq, fromTs: previous.ts, fromValue: previous.value, toSeq: row.seq ?? null, toTs: row.ts ?? null, toValue: current, delta: increase, sampleDeltaSeconds: elapsedSeconds, traceId: cell.traceId ?? column.traceId ?? null, messageId: cell.messageId ?? column.messageId ?? null, promptIndex: cell.promptIndex ?? row.promptIndex ?? null, source: cell.source ?? column.source ?? null, valuesRedacted: true }); } if (excessiveIncrease > 0) { anomalies.push({ columnId: column.id, columnLabel: column.label, metric: "recentUpdateSeconds", anomaly: "jump", expectedPattern: "sawtooth-increase-or-reset", fromSeq: previous.seq, fromTs: previous.ts, fromValue: previous.value, toSeq: row.seq ?? null, toTs: row.ts ?? null, toValue: current, delta: increase, sampleDeltaSeconds: elapsedSeconds, allowedIncreaseSeconds: allowedIncrease, excessiveIncreaseSeconds: excessiveIncrease, traceId: cell.traceId ?? column.traceId ?? null, messageId: cell.messageId ?? column.messageId ?? null, promptIndex: cell.promptIndex ?? row.promptIndex ?? null, source: cell.source ?? column.source ?? null, valuesRedacted: true }); } } previousByMetric.set(metric, { value: current, seq: row.seq ?? null, ts: row.ts ?? null }); } } } return { anomalies, terminalElapsedGrowth, recentUpdateResets, recentUpdateSteps }; } function isTerminalTurnStatus(value) { const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"); return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(status); } function turnMetricItems(sample, timelineItem) { const promptIndex = timelineItem.promptIndex ?? 0; const sessionKey = sample?.routeSessionId || sample?.activeSessionId || "unknown-session"; const roundKey = String(promptIndex); const items = []; if (Array.isArray(sample?.turns) && sample.turns.length > 0) { for (const turn of sample.turns) { const texts = turnTexts(turn); const totalElapsedValues = texts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite); const recentUpdateValues = texts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite); const traceId = turn.traceId || firstTraceId(texts); const messageId = turn.messageId || null; const turnId = turn.turnId || traceId || null; const stableId = traceId || messageId || turnId || null; const domIndex = Number.isFinite(Number(turn.index)) ? Number(turn.index) : items.length; const key = stableId ? "turn:" + sessionKey + ":id-" + stableId : "turn:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex); items.push({ key, source: "turn", promptIndex, traceId, messageId, turnId, domIndex, status: turn.status ?? null, totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, textHash: turn.textHash || sha256(texts.join("\n")) }); } return items; } if (Array.isArray(sample?.messages) && sample.messages.length > 0) { for (const message of sample.messages) { const text = String(message?.textPreview || ""); const totalElapsedValues = parseTotalElapsedSeconds(text).filter(Number.isFinite); const recentUpdateValues = parseRecentUpdateSeconds(text).filter(Number.isFinite); if (totalElapsedValues.length === 0 && recentUpdateValues.length === 0) continue; const domIndex = Number.isFinite(Number(message.index)) ? Number(message.index) : items.length; const traceId = message.traceId || firstTraceId([text]); const messageId = message.messageId || null; const stableId = traceId || messageId || message.turnId || null; items.push({ key: stableId ? "message:" + sessionKey + ":id-" + stableId : "message:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex), source: "message", promptIndex, traceId, messageId, turnId: message.turnId || traceId || null, domIndex, status: message.status ?? null, totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, textHash: message.textHash || sha256(text) }); } if (items.length > 0) return items; } if (timelineItem.totalElapsedSeconds !== null || timelineItem.recentUpdateSeconds !== null) { return [{ key: "aggregate:" + sessionKey + ":round-" + roundKey, source: "aggregate", promptIndex, traceId: null, messageId: null, domIndex: null, status: null, totalElapsedSeconds: timelineItem.totalElapsedSeconds ?? null, recentUpdateSeconds: timelineItem.recentUpdateSeconds ?? null, textHash: timelineItem.textDigest ?? null }]; } return []; } function turnTexts(turn) { return [ turn?.durationText, turn?.activityText, turn?.textPreview, turn?.text ].map((value) => String(value || "")).filter((value) => value.trim().length > 0); } function firstTraceId(texts) { for (const text of texts) { const match = String(text || "").match(/\btrc_[A-Za-z0-9_-]+\b/u); if (match) return match[0]; } return null; } function buildRoundMetricSummaries(timeline, promptCommands, timing = {}) { const rounds = []; const nonMonotonic = Array.isArray(timing.nonMonotonic) ? timing.nonMonotonic : []; const terminalElapsedGrowth = Array.isArray(timing.terminalElapsedGrowth) ? timing.terminalElapsedGrowth : []; const recentUpdateResets = Array.isArray(timing.recentUpdateResets) ? timing.recentUpdateResets : []; const recentUpdateSteps = Array.isArray(timing.recentUpdateSteps) ? timing.recentUpdateSteps : []; for (let index = 0; index < promptCommands.length; index += 1) { const promptIndex = index + 1; const items = timeline.filter((item) => item.promptIndex === promptIndex); const totalElapsed = items.map((item) => item.totalElapsedSeconds).filter((value) => value !== null); const recentUpdate = items.map((item) => item.recentUpdateSeconds).filter((value) => value !== null); const timingAnomalies = nonMonotonic.filter((item) => item.promptIndex === promptIndex); const timingTerminalGrowth = terminalElapsedGrowth.filter((item) => item.promptIndex === promptIndex); const timingResets = recentUpdateResets.filter((item) => item.promptIndex === promptIndex); const timingSteps = recentUpdateSteps.filter((item) => item.promptIndex === promptIndex); const terminalGrowthDeltas = timingTerminalGrowth.map((item) => Number(item.delta)).filter((value) => Number.isFinite(value) && value > 0); const timingStepDeltas = timingSteps.map((item) => Number(item.delta)).filter((value) => Number.isFinite(value) && value >= 0); const timingStepExcess = timingSteps.map((item) => Number(item.excessiveIncreaseSeconds)).filter((value) => Number.isFinite(value) && value > 0); rounds.push({ promptIndex, promptCommandId: promptCommands[index].commandId, promptTextHash: promptCommands[index].textHash, promptTextBytes: promptCommands[index].textBytes, promptCompletedAt: promptCommands[index].ts, sampleCount: items.length, firstSeq: items[0]?.seq ?? null, lastSeq: items[items.length - 1]?.seq ?? null, firstSampleAt: items[0]?.ts ?? null, lastSampleAt: items[items.length - 1]?.ts ?? null, withTotalElapsed: totalElapsed.length, withRecentUpdate: recentUpdate.length, maxTotalElapsedSeconds: totalElapsed.length > 0 ? Math.max(...totalElapsed) : null, lastTotalElapsedSeconds: lastNonNull(items.map((item) => item.totalElapsedSeconds)), maxRecentUpdateSeconds: recentUpdate.length > 0 ? Math.max(...recentUpdate) : null, lastRecentUpdateSeconds: lastNonNull(items.map((item) => item.recentUpdateSeconds)), diagnosticSamples: items.filter((item) => item.diagnosticSeen).length, terminalSamples: items.filter((item) => item.terminalSeen).length, finalTextSamples: items.filter((item) => item.finalResultTextSeen).length, turnTimingNonMonotonicCount: timingAnomalies.length, turnTimingTotalElapsedDecreaseCount: timingAnomalies.filter((item) => item.metric === "totalElapsedSeconds").length, turnTimingTerminalElapsedGrowthCount: timingTerminalGrowth.length, turnTimingTerminalElapsedGrowthMaxSeconds: terminalGrowthDeltas.length > 0 ? Math.max(...terminalGrowthDeltas) : 0, turnTimingRecentUpdateJumpCount: timingAnomalies.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump").length, turnTimingRecentUpdateSawtoothJumpCount: timingAnomalies.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump").length, turnTimingRecentUpdateStepCount: timingSteps.length, turnTimingRecentUpdateMaxIncreaseSeconds: timingStepDeltas.length > 0 ? Math.max(...timingStepDeltas) : null, turnTimingRecentUpdateMaxExcessSeconds: timingStepExcess.length > 0 ? Math.max(...timingStepExcess) : 0, turnTimingRecentUpdateResetCount: timingResets.length, turnTimingRecentUpdateDecreaseCount: timingResets.length }); } return rounds; } function sampleTexts(sample) { const rows = []; for (const group of [sample?.messages, sample?.traceRows, sample?.diagnostics]) { if (!Array.isArray(group)) continue; for (const item of group) { const text = String(item?.textPreview || ""); if (text.trim()) rows.push(text); } } return rows; } function parseTotalElapsedSeconds(text) { const values = []; for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(\d{1,2}):(\d{2}):(\d{2})/giu)) { values.push(Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3])); } for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(\d{1,2}):(\d{2})(?!:)/giu)) { values.push(Number(match[1]) * 60 + Number(match[2])); } for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(?:(\d+)\s*天)?\s*(?:(\d+)\s*小时)?\s*(?:(\d+)\s*(?:分钟|分))?\s*(?:(\d+)\s*秒)?/giu)) { const days = Number(match[1] || 0); const hours = Number(match[2] || 0); const minutes = Number(match[3] || 0); const seconds = Number(match[4] || 0); if (days || hours || minutes || seconds) values.push(days * 86400 + hours * 3600 + minutes * 60 + seconds); } return values; } function lastNonNull(values) { for (let index = values.length - 1; index >= 0; index -= 1) if (values[index] !== null && values[index] !== undefined) return values[index]; return null; } function parseRecentUpdateSeconds(text) { const values = []; for (const match of String(text || "").matchAll(/最近\s*(?:(\d+)\s*天)?\s*(?:(\d+)\s*小时)?\s*(?:(\d+)\s*(?:分钟|分))?\s*(?:(\d+)\s*秒)?\s*前/giu)) { const days = Number(match[1] || 0); const hours = Number(match[2] || 0); const minutes = Number(match[3] || 0); const seconds = Number(match[4] || 0); values.push(days * 86400 + hours * 3600 + minutes * 60 + seconds); } return values; } function latestPromptIndex(promptTimes, tsMs) { let index = 0; for (let i = 0; i < promptTimes.length; i += 1) { if (promptTimes[i] <= tsMs) index = i + 1; else break; } return index; } function promptIndexForTs(promptTimes, ts) { const tsMs = Date.parse(ts); return Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0; } function buildTransitions(samples) { const rows = []; let last = null; for (const sample of samples) { const digest = digestSample(sample); if (digest !== last) { rows.push({ seq: sample.seq, ts: sample.ts, url: sample.url, routeSessionId: sample.routeSessionId || null, activeSessionId: sample.activeSessionId || null, messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, digest }); last = digest; } } return rows.slice(0, 200); } function detectFinalFlicker(samples) { const flickers = []; let lastNonEmpty = null; for (const sample of samples) { const messageText = Array.isArray(sample.messages) ? sample.messages.map((item) => item.textPreview || "").join("\n") : ""; const nonEmpty = messageText.trim().length > 0; const finalLike = /轮次完成|已记录|已完成第\d+轮|final response|terminal result/iu.test(messageText); if (nonEmpty && finalLike && !/temporarily|timeout|无法连接|暂时|error|failed|超时/iu.test(messageText)) lastNonEmpty = { sample, messageText }; if (lastNonEmpty && nonEmpty && /temporarily|timeout|无法连接|暂时|error|failed|超时/iu.test(messageText)) flickers.push({ from: ref(lastNonEmpty.sample), to: ref(sample) }); if (lastNonEmpty && !nonEmpty) flickers.push({ from: ref(lastNonEmpty.sample), to: ref(sample), reason: "text-disappeared" }); } return flickers; } function digestSample(sample) { const messages = Array.isArray(sample.messages) ? sample.messages.map((item) => item.textHash || item.textPreview || "").join("|") : ""; const trace = Array.isArray(sample.traceRows) ? sample.traceRows.map((item) => (item.status || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; const diagnostics = Array.isArray(sample.diagnostics) ? sample.diagnostics.map((item) => (item.className || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; return sha256((sample.routeSessionId || "") + "|" + (sample.activeSessionId || "") + "|" + messages + "|" + trace + "|" + diagnostics); } function nearCommand(sample, commandTimes, windowMs) { const ts = Date.parse(sample.ts); return Number.isFinite(ts) && commandTimes.some((item) => Math.abs(ts - item) <= windowMs); } function sampleRefs(samples, pick) { const seen = new Set(); const refs = []; for (const sample of samples) { const value = pick(sample); if (!value || seen.has(value)) continue; seen.add(value); refs.push({ ...ref(sample), value }); } return refs.slice(0, 20); } function ref(sample) { if (!sample) return null; return { seq: sample.seq ?? null, ts: sample.ts ?? null, url: sample.url ?? null, routeSessionId: sample.routeSessionId ?? null, activeSessionId: sample.activeSessionId ?? null }; } async function artifactSummary(artifacts) { const items = artifacts.slice(-30).map((item) => ({ kind: item.kind, reason: item.reason, sampleSeq: item.sampleSeq, path: item.path, sha256: item.sha256, byteCount: item.byteCount })); return { count: artifacts.length, latest: items }; } function compactManifest(value) { if (!value) return null; return { jobId: value.jobId, stateDir: value.stateDir, baseUrl: value.baseUrl, targetPath: value.targetPath, startedAt: value.startedAt, status: value.status, sampling: value.sampling, pageProvenance: value.pageProvenance ?? null, safety: value.safety }; } function compactHeartbeat(value) { if (!value) return null; return { jobId: value.jobId, pid: value.pid, status: value.status, sampleSeq: value.sampleSeq, commandSeq: value.commandSeq, currentUrl: value.currentUrl, pageProvenance: value.pageProvenance ?? null, updatedAt: value.updatedAt, uptimeMs: value.uptimeMs }; } function renderTurnTimingTable(sampleMetrics) { const columns = Array.isArray(sampleMetrics?.turnColumns) ? sampleMetrics.turnColumns : []; const rows = Array.isArray(sampleMetrics?.turnTimingTable) ? sampleMetrics.turnTimingTable : []; if (columns.length === 0 || rows.length === 0) return "- 无 turn 时间表。"; const header = ["时间戳"]; for (const column of columns) { header.push(column.label + " 总耗时(s)"); header.push(column.label + " 最近更新(s)"); } const lines = []; lines.push("| " + header.map(escapeMarkdownCell).join(" | ") + " |"); lines.push("| " + header.map(() => "---").join(" | ") + " |"); for (const row of rows) { const cells = [row.ts || "-"]; for (const column of columns) { const cell = row.cells?.[column.id] || {}; cells.push(formatMetricCell(cell.totalElapsedSeconds)); cells.push(formatMetricCell(cell.recentUpdateSeconds)); } lines.push("| " + cells.map(escapeMarkdownCell).join(" | ") + " |"); } const columnLines = columns.map((column) => "- " + column.label + ": source=" + (column.source || "-") + " prompt=" + (column.promptIndex ?? "-") + " lastPrompt=" + (column.lastPromptIndex ?? "-") + " firstSeq=" + (column.firstSeq ?? "-") + " lastSeq=" + (column.lastSeq ?? "-") + " traceId=" + (column.traceId || "-") + " messageId=" + (column.messageId || "-")).join("\n"); const nonMonotonic = Array.isArray(sampleMetrics?.turnTimingNonMonotonic) ? sampleMetrics.turnTimingNonMonotonic : []; const nonMonotonicLines = nonMonotonic.length > 0 ? nonMonotonic.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " " + item.metric + (item.anomaly ? " " + item.anomaly : "") + " " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " allowed=" + (item.allowedIncreaseSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到总耗时下降或最近更新异常跳增。"; const terminalGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : []; const terminalGrowthLines = terminalGrowth.length > 0 ? terminalGrowth.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " terminal totalElapsed growth " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " status " + (item.fromStatus || "-") + " -> " + (item.toStatus || "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到 terminal 后总耗时增长。"; const sawtoothJumps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateSawtoothJumps) ? sampleMetrics.turnTimingRecentUpdateSawtoothJumps : nonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump"); const sawtoothJumpLines = sawtoothJumps.length > 0 ? sawtoothJumps.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " recentUpdate sawtooth-jump " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " allowed=" + (item.allowedIncreaseSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到最近更新三角波异常跳增。"; const recentUpdateSteps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateLargestSteps) ? sampleMetrics.turnTimingRecentUpdateLargestSteps : Array.isArray(sampleMetrics?.turnTimingRecentUpdateSteps) ? sampleMetrics.turnTimingRecentUpdateSteps.filter((item) => Number.isFinite(Number(item.delta))).slice().sort((a, b) => Number(b.delta) - Number(a.delta)).slice(0, 200) : []; const stepLines = recentUpdateSteps.length > 0 ? recentUpdateSteps.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " recentUpdate step " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " allowed=" + (item.allowedIncreaseSeconds ?? "-") + " excess=" + (item.excessiveIncreaseSeconds ?? 0) + " event=" + (item.event || "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到最近更新相邻采样 step。"; const recentUpdateResets = Array.isArray(sampleMetrics?.turnTimingRecentUpdateResets) ? sampleMetrics.turnTimingRecentUpdateResets : []; const resetLines = recentUpdateResets.length > 0 ? recentUpdateResets.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " reset " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到最近更新归零/回落。"; return lines.join("\n") + "\n\n列说明:\n" + columnLines + "\n\n异常事件(仅报表暴露,不做下游 repair;最近更新按三角波模型检测异常跳增):\n" + nonMonotonicLines + "\n\nTerminal 后总耗时增长事件(终态 turn 的总耗时应 sealed,不应继续增长):\n" + terminalGrowthLines + "\n\n最近更新 sawtooth jump 事件(预期每秒增长约 1,遇到新活动归零;例如 1 秒 -> 1 分 4 秒应被列入这里):\n" + sawtoothJumpLines + "\n\n最近更新相邻采样 step(按 delta 降序;用于人工识别一秒跳几十秒/一分钟的瞬态):\n" + stepLines + "\n\n最近更新 reset 事件(预期三角波归零,不计为异常):\n" + resetLines; } function formatMetricCell(value) { if (value === null || value === undefined || !Number.isFinite(Number(value))) return "-"; return String(Number(value)); } function escapeMarkdownCell(value) { return String(value ?? "-").replace(/\|/gu, "\\|"); } function renderMarkdown(report) { const findingLines = report.findings.length === 0 ? "- 无红灯项。" : report.findings.map((item) => "- " + item.severity + ": " + item.id + " - " + item.summary).join("\n"); const commandLines = report.commandTimeline.length === 0 ? "- 无控制命令。" : report.commandTimeline.map((item) => "- " + item.ts + " " + item.phase + " " + item.type + " " + item.commandId + " " + (item.afterUrl || "")).join("\n"); const transitionLines = report.transitions.length === 0 ? "- 无状态变化。" : report.transitions.slice(0, 80).map((item) => "- #" + item.seq + " " + item.ts + " messages=" + item.messageCount + " traceRows=" + item.traceRowCount + " route=" + (item.routeSessionId || "-") + " active=" + (item.activeSessionId || "-")).join("\n"); const metricSummary = report.sampleMetrics?.summary || {}; const alertSummary = report.runtimeAlerts?.summary || {}; const httpAlertLines = Array.isArray(report.runtimeAlerts?.networkHttpErrorsByPath) && report.runtimeAlerts.networkHttpErrorsByPath.length > 0 ? report.runtimeAlerts.networkHttpErrorsByPath.slice(0, 40).map((item) => "- HTTP " + (item.status ?? "-") + " " + item.method + " " + item.urlPath + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n") : "- 无 HTTP 错误。"; const requestFailedLines = Array.isArray(report.runtimeAlerts?.networkRequestFailedByPath) && report.runtimeAlerts.networkRequestFailedByPath.length > 0 ? report.runtimeAlerts.networkRequestFailedByPath.slice(0, 40).map((item) => "- requestfailed " + item.method + " " + item.urlPath + " count=" + item.count + " failure=" + (item.failureKinds?.slice(0, 4).join(",") || "-") + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n") : "- 无 requestfailed。"; const domDiagnosticLines = Array.isArray(report.runtimeAlerts?.domDiagnostics) && report.runtimeAlerts.domDiagnostics.length > 0 ? report.runtimeAlerts.domDiagnostics.slice(0, 40).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " source=" + (item.source || "-") + " code=" + (item.diagnosticCode || "-") + " traceId=" + (item.traceId || "-") + " http=" + (item.httpStatus ?? "-") + " idle=" + (item.idleSeconds ?? "-") + " waitingFor=" + (item.waitingFor || "-") + " lastEventLabel=" + (item.lastEventLabel || "-") + " textHash=" + (item.textHash || "-") + " preview=" + escapeMarkdownCell(item.preview || "")).join("\n") : "- 无 DOM 诊断文本。"; const consoleAlertLines = Array.isArray(report.runtimeAlerts?.consoleAlerts) && report.runtimeAlerts.consoleAlerts.length > 0 ? report.runtimeAlerts.consoleAlerts.slice(0, 40).map((item) => "- " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " type=" + (item.type || "-") + " status=" + (item.status ?? "-") + " path=" + (item.urlPath || "-") + " traceId=" + (item.traceId || "-") + " textHash=" + (item.textHash || "-") + " preview=" + escapeMarkdownCell(item.preview || "")).join("\n") : "- 无 console warning/error。"; const consoleAlertGroupLines = Array.isArray(report.runtimeAlerts?.consoleAlertsByPath) && report.runtimeAlerts.consoleAlertsByPath.length > 0 ? report.runtimeAlerts.consoleAlertsByPath.slice(0, 40).map((item) => "- console " + (item.type || "-") + " status=" + (item.status ?? "-") + " path=" + (item.urlPath || "-") + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " traces=" + (item.traceIds?.slice(0, 6).join(",") || "-")).join("\n") : "- 无 console 分组。"; const promptNetworkLines = Array.isArray(report.promptNetwork?.rounds) && report.promptNetwork.rounds.length > 0 ? report.promptNetwork.rounds.map((item) => "- round " + item.promptIndex + " promptHash=" + (item.promptTextHash || "-") + " chatPostOk=" + String(item.chatPostOk) + " failure=" + (item.failureKind || "-") + " statuses=" + (Array.isArray(item.responseStatuses) && item.responseStatuses.length > 0 ? item.responseStatuses.join(",") : "-") + " firstChat=" + (item.firstChatEventAt || "-") + " lastChat=" + (item.lastChatEventAt || "-")).join("\n") : "- 无 prompt 网络记录。"; const roundLines = Array.isArray(report.sampleMetrics?.rounds) && report.sampleMetrics.rounds.length > 0 ? report.sampleMetrics.rounds.map((item) => "- round " + item.promptIndex + " promptHash=" + (item.promptTextHash || "-") + " samples=" + item.sampleCount + " totalMax=" + (item.maxTotalElapsedSeconds ?? "-") + " totalLast=" + (item.lastTotalElapsedSeconds ?? "-") + " recentMax=" + (item.maxRecentUpdateSeconds ?? "-") + " recentLast=" + (item.lastRecentUpdateSeconds ?? "-") + " totalDecrease=" + (item.turnTimingTotalElapsedDecreaseCount ?? 0) + " terminalGrowth=" + (item.turnTimingTerminalElapsedGrowthCount ?? 0) + " terminalGrowthMax=" + (item.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + " recentJump=" + (item.turnTimingRecentUpdateJumpCount ?? 0) + " recentSawtoothJump=" + (item.turnTimingRecentUpdateSawtoothJumpCount ?? item.turnTimingRecentUpdateJumpCount ?? 0) + " recentStep=" + (item.turnTimingRecentUpdateStepCount ?? 0) + " recentMaxIncrease=" + (item.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + " recentMaxExcess=" + (item.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + " recentReset=" + (item.turnTimingRecentUpdateResetCount ?? 0) + " diagnostics=" + item.diagnosticSamples + " terminal=" + item.terminalSamples + " finalText=" + item.finalTextSamples).join("\n") : "- 无轮次指标。"; const provenanceLines = Array.isArray(report.pageProvenance?.segments) && report.pageProvenance.segments.length > 0 ? report.pageProvenance.segments.slice(0, 40).map((item) => "- fingerprint=" + (item.assetFingerprint || "-") + " samples=" + item.sampleCount + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " scripts=" + (item.scriptCount ?? "-") + " styles=" + (item.stylesheetCount ?? "-") + " urlPaths=" + (Array.isArray(item.urlPaths) ? item.urlPaths.slice(0, 4).join(",") : "-")).join("\n") : "- 无页面 provenance segment。"; const performanceLines = Array.isArray(report.pagePerformance?.sameOriginApiByPath) && report.pagePerformance.sameOriginApiByPath.length > 0 ? report.pagePerformance.sameOriginApiByPath.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api") + " budgetMetric=" + (item.budgetMetric || "durationMs") + " samples=" + item.sampleCount + " p50=" + (item.p50Ms ?? "-") + "ms p75=" + (item.p75Ms ?? "-") + "ms p95=" + (item.p95Ms ?? "-") + "ms max=" + (item.maxMs ?? "-") + "ms >5s=" + (item.overFiveSecondCount ?? 0) + " streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamLifetime>5s=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") : "- 无同源 API Resource Timing 样本。"; const metricLines = Array.isArray(report.sampleMetrics?.timeline) && report.sampleMetrics.timeline.length > 0 ? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " totalElapsedSeconds=" + (item.totalElapsedSeconds ?? "-") + " recentUpdateSeconds=" + (item.recentUpdateSeconds ?? "-") + " terminal=" + item.terminalSeen + " finalText=" + item.finalResultTextSeen + " diagnostic=" + item.diagnosticSeen).join("\n") : "- 无采样指标。"; const turnTimingTable = renderTurnTimingTable(report.sampleMetrics); return "# web-probe observe analysis\n\n" + "- stateDir: " + report.stateDir + "\n" + "- generatedAt: " + report.generatedAt + "\n" + "- samples: " + report.counts.samples + "\n" + "- control: " + report.counts.control + "\n" + "- network: " + report.counts.network + "\n" + "- console: " + (report.counts.console ?? 0) + "\n" + "- errors: " + report.counts.errors + "\n\n" + "## Findings\n\n" + findingLines + "\n\n" + "## Sample metrics\n\n" + "- sampleCount: " + (metricSummary.sampleCount ?? 0) + "\n" + "- withTotalElapsed: " + (metricSummary.withTotalElapsed ?? 0) + "\n" + "- withRecentUpdate: " + (metricSummary.withRecentUpdate ?? 0) + "\n" + "- diagnostics: " + (metricSummary.diagnostics ?? 0) + "\n" + "- promptSegments: " + (metricSummary.promptSegments ?? 0) + "\n\n" + "- turnColumns: " + (metricSummary.turnColumns ?? 0) + "\n" + "- turnTimingRows: " + (metricSummary.turnTimingRows ?? 0) + "\n" + "- turnTimingNonMonotonicCount: " + (metricSummary.turnTimingNonMonotonicCount ?? 0) + "\n" + "- turnTimingTotalElapsedDecreaseCount: " + (metricSummary.turnTimingTotalElapsedDecreaseCount ?? 0) + "\n" + "- turnTimingTerminalElapsedGrowthCount: " + (metricSummary.turnTimingTerminalElapsedGrowthCount ?? 0) + "\n" + "- turnTimingTerminalElapsedGrowthMaxSeconds: " + (metricSummary.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + "\n" + "- turnTimingRecentUpdateJumpCount: " + (metricSummary.turnTimingRecentUpdateJumpCount ?? 0) + "\n" + "- turnTimingRecentUpdateSawtoothJumpCount: " + (metricSummary.turnTimingRecentUpdateSawtoothJumpCount ?? metricSummary.turnTimingRecentUpdateJumpCount ?? 0) + "\n" + "- turnTimingRecentUpdateStepCount: " + (metricSummary.turnTimingRecentUpdateStepCount ?? 0) + "\n" + "- turnTimingRecentUpdateMaxIncreaseSeconds: " + (metricSummary.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + "\n" + "- turnTimingRecentUpdateMaxExcessSeconds: " + (metricSummary.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + "\n" + "- turnTimingRecentUpdateResetCount: " + (metricSummary.turnTimingRecentUpdateResetCount ?? 0) + "\n\n" + "### Rounds\n\n" + roundLines + "\n\n" + "### Page provenance\n\n" + "- segmentCount: " + (report.pageProvenance?.summary?.segmentCount ?? 0) + "\n" + "- controlSegmentCount: " + (report.pageProvenance?.summary?.controlSegmentCount ?? 0) + "\n\n" + provenanceLines + "\n\n" + "### Page performance: same-origin API Resource Timing\n\n" + "- budgetMs: " + (report.pagePerformance?.summary?.budgetMs ?? 5000) + "\n" + "- sameOriginApiPathCount: " + (report.pagePerformance?.summary?.sameOriginApiPathCount ?? 0) + "\n" + "- sameOriginApiSampleCount: " + (report.pagePerformance?.summary?.sameOriginApiSampleCount ?? 0) + "\n" + "- longLivedStreamPathCount: " + (report.pagePerformance?.summary?.longLivedStreamPathCount ?? 0) + "\n" + "- longLivedStreamSampleCount: " + (report.pagePerformance?.summary?.longLivedStreamSampleCount ?? 0) + "\n" + "- slowPathCount: " + (report.pagePerformance?.summary?.slowPathCount ?? 0) + "\n" + "- slowSampleCount: " + (report.pagePerformance?.summary?.slowSampleCount ?? 0) + "\n" + "- worstP95Ms: " + (report.pagePerformance?.summary?.worstP95Ms ?? "-") + "\n\n" + performanceLines + "\n\n" + "### Prompt network\n\n" + promptNetworkLines + "\n\n" + "### Runtime alerts\n\n" + "- httpErrorCount: " + (alertSummary.httpErrorCount ?? 0) + "\n" + "- requestFailedCount: " + (alertSummary.requestFailedCount ?? 0) + "\n" + "- domDiagnosticSampleCount: " + (alertSummary.domDiagnosticSampleCount ?? 0) + "\n" + "- consoleAlertCount: " + (alertSummary.consoleAlertCount ?? 0) + "\n" + "- pageErrorCount: " + (alertSummary.pageErrorCount ?? 0) + "\n\n" + "#### HTTP errors\n\n" + httpAlertLines + "\n\n" + "#### Request failed\n\n" + requestFailedLines + "\n\n" + "#### DOM diagnostics\n\n" + domDiagnosticLines + "\n\n" + "#### Console alerts\n\n" + consoleAlertLines + "\n\n" + "#### Console alert groups\n\n" + consoleAlertGroupLines + "\n\n" + "### Turn timing table\n\n" + turnTimingTable + "\n\n" + "### Aggregate timeline\n\n" + metricLines + "\n\n" + "## Command timeline\n\n" + commandLines + "\n\n" + "## State transitions\n\n" + transitionLines + "\n"; } 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 sha256(value) { return "sha256:" + createHash("sha256").update(String(value)).digest("hex"); } function urlPath(value) { try { const url = new URL(String(value || "http://invalid.local/")); return url.pathname; } catch { return "-"; } } function compactLocation(value) { if (!value || typeof value !== "object") return null; return { urlPath: urlPath(value.url), lineNumber: value.lineNumber ?? null, columnNumber: value.columnNumber ?? null }; } function limitText(value, limit) { const text = String(value ?? ""); if (text.length <= limit) return text; return text.slice(0, Math.max(0, limit - 1)) + "…"; } `; }