Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-runner-source.ts
T
2026-06-21 10:11:49 +08:00

2110 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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;
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 });
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,
pageAuthority: { browser: "chromium", context: "single", pageId, continuityBreaksRecorded: true },
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(),
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 };
}
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(() => {});
attempts.push({ attempt, ok: true, httpStatus: response ? response.status() : null });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: response ? response.status() : null, pageId, 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 };
}
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();
return {
beforeUrl,
afterUrl: currentPageUrl(),
ok: Boolean(after?.activeSessionId && after.activeSessionId !== before?.activeSessionId),
before,
after,
sessionId: after?.activeSessionId || 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: 20000 }).catch((error) => ({ waitError: errorSummary(error) }));
await submit.click();
const chatResponse = await chatResponsePromise;
await page.waitForTimeout(500);
if (chatResponse?.waitError) {
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 12000);
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 chatUrlPath = "/v1/agent/chat";
try {
chatUrlPath = new URL(chatResponse.url()).pathname;
} catch {
chatUrlPath = "/v1/agent/chat";
}
return { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), chatSubmit: { status: chatStatus, statusText: chatResponse.statusText(), urlPath: chatUrlPath }, 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,
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, .api-error-diagnostic.message-diagnostic, .api-error-diagnostic.projection-diagnostic, [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [role="alert"], .alert, .warning, .error';
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.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);
return {
index,
role: element.getAttribute("data-role") || "agent",
status: element.getAttribute("data-status") || null,
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
traceId: traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null,
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,
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 || "") })) : [];
return { ...dom, messages, traceRows, diagnostics, turns };
}
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 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 { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
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 samples = await readJsonl(path.join(stateDir, "samples.jsonl"));
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 promptNetwork = buildPromptNetworkReport(control, network);
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts);
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,
promptNetwork,
runtimeAlerts,
findings,
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, sampleMetrics: sampleMetrics.summary, promptNetwork: promptNetwork.summary, runtimeAlerts: runtimeAlerts.summary, turnTimingRecentUpdateSawtoothJumps: sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 20), turnTimingRecentUpdateLargestSteps: sampleMetrics.turnTimingRecentUpdateLargestSteps.slice(0, 20), findings: findings.slice(0, 20), valuesRedacted: true }, null, 2));
async function readJson(file) {
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
}
async function readJsonl(file) {
try {
const text = await readFile(file, "utf8");
return text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
try { return JSON.parse(line); } catch { return { parseError: true, rawHash: sha256(line) }; }
});
} catch { return []; }
}
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts) {
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 = [];
for (let i = 1; i < samples.length; i += 1) {
const prev = digestSample(samples[i - 1]);
const next = digestSample(samples[i]);
if (prev !== next && !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) => /completed|failed|canceled|terminal|done/iu.test((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, 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 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 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 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(isDiagnosticText);
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,
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(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 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}|trace_id=|workbench turn\s*超过|无新活动|idle\s+\d+s|waitingFor=|lastEventLabel=|无法连接上游|(?:报警|告警|警告|错误|异常|超时)[:]|timeout[:=]|error[:=]|failed[:=]|warning[:=]|provider-unavailable|agentrun:error|AgentRun error|projection-resume|durable projection store|realtime-gap|\b50[234]\b/iu.test(value);
}
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((text) => /Failed to fetch|realtime-gap|durable projection store|turn 超过|无法连接|projection-resume|503|timeout|error/iu.test(text)).slice(0, 5);
const terminalTexts = texts.filter((text) => /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|turn completed|turn failed|turn canceled|terminal result/iu.test(text)).slice(0, 5);
const finalResultTexts = texts.filter((text) => /已完成第\d+轮|final response|sealed final response|最终结果|已完成[:]/iu.test(text)).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 turnTimingRecentUpdateResets = Array.isArray(turnTiming.recentUpdateResets) ? turnTiming.recentUpdateResets : [];
const turnTimingRecentUpdateSteps = Array.isArray(turnTiming.recentUpdateSteps) ? turnTiming.recentUpdateSteps : [];
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,
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,
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,
roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length
},
rounds,
turnColumns: turnTiming.columns,
turnTimingTable: turnTiming.rows,
turnTimingNonMonotonic,
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,
recentUpdateResets: timingEvents.recentUpdateResets,
recentUpdateSteps: timingEvents.recentUpdateSteps
};
}
function detectTurnTimingNonMonotonic(columns, rows) {
const anomalies = [];
const recentUpdateResets = [];
const recentUpdateSteps = [];
for (const column of columns) {
const previousByMetric = new Map();
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 (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, recentUpdateResets, recentUpdateSteps };
}
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 domIndex = Number.isFinite(Number(turn.index)) ? Number(turn.index) : items.length;
const key = "turn:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex);
items.push({
key,
source: "turn",
promptIndex,
traceId,
messageId,
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;
items.push({
key: "message:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex),
source: "message",
promptIndex,
traceId: firstTraceId([text]),
messageId: 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 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 timingResets = recentUpdateResets.filter((item) => item.promptIndex === promptIndex);
const timingSteps = recentUpdateSteps.filter((item) => item.promptIndex === promptIndex);
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,
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, 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, 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 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\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) + " 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 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"
+ "- 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"
+ "### 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)) + "…";
}
`;
}