8e8865e9df
Co-authored-by: Codex <codex@noreply.local>
2110 lines
112 KiB
TypeScript
2110 lines
112 KiB
TypeScript
// 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)) + "…";
|
||
}
|
||
`;
|
||
}
|