668c73b7e0
Co-authored-by: Codex <codex@noreply.local>
7095 lines
388 KiB
TypeScript
7095 lines
388 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 observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000);
|
||
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
|
||
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
|
||
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
||
const playwrightProxy = proxyConfigFromEnv(baseUrl);
|
||
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
|
||
const pageId = "control-" + randomBytes(4).toString("hex");
|
||
const observerPageId = "observer-" + 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"),
|
||
archive: path.join(stateDir, "archive"),
|
||
};
|
||
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 observerPage;
|
||
let lastObserverRefreshAtMs = Date.now();
|
||
let sampleSeq = 0;
|
||
let commandSeq = 0;
|
||
let artifactSeq = 0;
|
||
let activeCommandId = null;
|
||
let stopping = false;
|
||
let terminalStatus = "starting";
|
||
let lastScreenshotAtMs = 0;
|
||
let auth = null;
|
||
let pageLoadSeq = 0;
|
||
let controlPageEpoch = 0;
|
||
let observerPageEpoch = 0;
|
||
let currentPageProvenance = null;
|
||
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
|
||
|
||
try {
|
||
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
||
await prepareDirs();
|
||
await rotateExistingJsonlArtifacts();
|
||
await writeManifest({ status: "starting" });
|
||
await writeHeartbeat({ status: "starting" });
|
||
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
|
||
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||
const { chromium } = await launcher.importPlaywright();
|
||
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context, page));
|
||
await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath));
|
||
observerPage = await context.newPage();
|
||
attachPassiveListeners(observerPage, "observer", observerPageId);
|
||
await runControlCommand({ id: "startup-observer-goto", type: "observerGoto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => {
|
||
const result = await syncObserverPageToControlSession("startup");
|
||
if (!result?.ok) {
|
||
await appendJsonl(files.control, eventRecord("observer-startup-degraded", {
|
||
reason: result?.failureKind || result?.reason || "observer-not-ready",
|
||
result: sanitize(result),
|
||
valuesRedacted: true,
|
||
}));
|
||
}
|
||
return result ?? { ok: false, reason: "observer-not-ready", pageRole: "observer", pageId: observerPageId, valuesRedacted: true };
|
||
});
|
||
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 rotateExistingJsonlArtifacts() {
|
||
for (const [key, file] of Object.entries(files)) {
|
||
if (!file.endsWith(".jsonl")) continue;
|
||
let meta = null;
|
||
try {
|
||
meta = await stat(file);
|
||
} catch (error) {
|
||
if (error?.code === "ENOENT") continue;
|
||
throw error;
|
||
}
|
||
if (!meta?.isFile()) continue;
|
||
const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file));
|
||
await rename(file, archiveFile);
|
||
jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size });
|
||
}
|
||
}
|
||
|
||
async function uniqueArchiveFile(name) {
|
||
let candidate = path.join(dirs.archive, name);
|
||
const parsed = path.parse(name);
|
||
for (let index = 1; ; index += 1) {
|
||
try {
|
||
await stat(candidate);
|
||
candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext);
|
||
} catch (error) {
|
||
if (error?.code === "ENOENT") return candidate;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
function compactFileTimestamp(value) {
|
||
return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
|
||
}
|
||
|
||
async function writeManifest(extra = {}) {
|
||
const manifest = {
|
||
ok: extra.status !== "failed",
|
||
command: "web-probe-observe",
|
||
specRef,
|
||
jobId,
|
||
pid: process.pid,
|
||
stateDir,
|
||
baseUrl,
|
||
targetPath,
|
||
network: publicNetwork(playwrightProxy),
|
||
pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true },
|
||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
|
||
alertThresholds,
|
||
jsonlRotation,
|
||
commandDirs: dirs,
|
||
artifacts: files,
|
||
safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false },
|
||
startedAt,
|
||
...extra,
|
||
};
|
||
await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
||
}
|
||
|
||
async function writeHeartbeat(extra = {}) {
|
||
const heartbeat = {
|
||
ok: terminalStatus !== "failed",
|
||
jobId,
|
||
pid: process.pid,
|
||
stateDir,
|
||
status: terminalStatus,
|
||
pageId,
|
||
observerPageId,
|
||
baseUrl,
|
||
currentUrl: currentPageUrl(),
|
||
observerUrl: pageUrl(observerPage),
|
||
observerRefreshIntervalMs,
|
||
lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null,
|
||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||
sampleSeq,
|
||
commandSeq,
|
||
activeCommandId,
|
||
updatedAt: new Date().toISOString(),
|
||
uptimeMs: Date.now() - startedAtMs,
|
||
...extra,
|
||
};
|
||
await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 });
|
||
}
|
||
|
||
function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) {
|
||
targetPage.on("request", (request) => {
|
||
void appendJsonl(files.network, eventRecord("request", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
observerInitiated: false,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(request.url()),
|
||
resourceType: request.resourceType(),
|
||
frameUrl: safeFrameUrl(request.frame()),
|
||
}));
|
||
});
|
||
targetPage.on("response", (response) => {
|
||
const request = response.request();
|
||
void appendJsonl(files.network, eventRecord("response", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
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", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
observerInitiated: false,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(request.url()),
|
||
resourceType: request.resourceType(),
|
||
failure: request.failure()?.errorText ?? null,
|
||
}));
|
||
});
|
||
targetPage.on("console", (message) => {
|
||
void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() }));
|
||
});
|
||
targetPage.on("pageerror", (error) => {
|
||
void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) }));
|
||
});
|
||
targetPage.on("crash", () => {
|
||
void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId }));
|
||
});
|
||
targetPage.on("close", () => {
|
||
void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" }));
|
||
});
|
||
}
|
||
|
||
async function drainOneCommand() {
|
||
const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
|
||
const name = entries[0];
|
||
if (!name) return;
|
||
const pending = path.join(dirs.commandsPending, name);
|
||
const processing = path.join(dirs.commandsProcessing, name);
|
||
await rename(pending, processing).catch(() => null);
|
||
const raw = await readFile(processing, "utf8");
|
||
const command = JSON.parse(raw);
|
||
const id = safeId(command.id || name.replace(/[.]json$/u, ""));
|
||
command.id = id;
|
||
const stopCommandSampler = startCommandActiveSampler(command);
|
||
try {
|
||
const result = await processCommand(command);
|
||
const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) };
|
||
await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 });
|
||
await appendJsonl(files.control, controlRecord(command, "completed", done.result));
|
||
await unlink(processing).catch(() => {});
|
||
} catch (error) {
|
||
const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false })
|
||
.then(() => ({ ok: true, sampleSeq, valuesRedacted: true }))
|
||
.catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true }));
|
||
const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample };
|
||
await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 });
|
||
await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample }));
|
||
await unlink(processing).catch(() => {});
|
||
} finally {
|
||
stopCommandSampler();
|
||
activeCommandId = null;
|
||
await writeHeartbeat({ status: terminalStatus });
|
||
}
|
||
}
|
||
|
||
function startCommandActiveSampler(command) {
|
||
const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000);
|
||
let stopped = false;
|
||
let timer = null;
|
||
let inFlight = false;
|
||
const schedule = () => {
|
||
if (stopped) return;
|
||
timer = setTimeout(tick, intervalMs);
|
||
if (timer && typeof timer.unref === "function") timer.unref();
|
||
};
|
||
const tick = () => {
|
||
if (stopped) return;
|
||
if (inFlight) {
|
||
schedule();
|
||
return;
|
||
}
|
||
inFlight = true;
|
||
samplePage("command-active", { refreshObserver: false, screenshot: false })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })))
|
||
.finally(() => {
|
||
inFlight = false;
|
||
schedule();
|
||
});
|
||
};
|
||
schedule();
|
||
return () => {
|
||
stopped = true;
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}
|
||
|
||
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, page);
|
||
case "preflight": return preflightSummary();
|
||
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
|
||
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
|
||
case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || "")), "sendPrompt");
|
||
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
|
||
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
|
||
case "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 withObserverSync(result, reason) {
|
||
return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) };
|
||
}
|
||
|
||
async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) {
|
||
if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" };
|
||
const forceRefresh = options?.forceRefresh === true;
|
||
const snapshot = await workbenchSessionSnapshot();
|
||
const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
|
||
const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath;
|
||
const targetUrl = new URL(target, baseUrl).toString();
|
||
const beforeUrl = pageUrl(observerPage);
|
||
const beforeSessionId = routeSessionIdFromUrl(beforeUrl);
|
||
if (sessionId && beforeSessionId === sessionId && !forceRefresh) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch };
|
||
observerPageEpoch += 1;
|
||
let status = null;
|
||
let statusText = null;
|
||
const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 45000 }).catch((error) => ({ observerGotoError: errorSummary(error) }));
|
||
if (response?.observerGotoError) return { ok: false, reason, changed: false, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, valuesRedacted: true };
|
||
status = typeof response?.status === "function" ? response.status() : null;
|
||
statusText = typeof response?.statusText === "function" ? response.statusText() : null;
|
||
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: 15000 });
|
||
if (!readiness.ok) {
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
|
||
}
|
||
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 15000 });
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: hydration.ok === true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", valuesRedacted: true };
|
||
}
|
||
|
||
async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000;
|
||
const started = Date.now();
|
||
const deadline = started + Math.max(1, timeoutMs);
|
||
let last = null;
|
||
while (Date.now() <= deadline) {
|
||
last = await workbenchSessionSnapshot(targetPage);
|
||
const expected = String(sessionId || "").trim();
|
||
const observedPath = safeUrlPath(last?.url || pageUrl(targetPage));
|
||
const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || "");
|
||
const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || "");
|
||
if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
|
||
await targetPage.waitForTimeout(250).catch(() => {});
|
||
}
|
||
return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true };
|
||
}
|
||
|
||
async function maybeRefreshObserverPage(reason) {
|
||
if (!observerPage || observerPage.isClosed()) return null;
|
||
if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null;
|
||
if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null;
|
||
const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true });
|
||
await appendJsonl(files.control, eventRecord("observer-periodic-refresh", {
|
||
pageRole: "observer",
|
||
pageId: observerPageId,
|
||
reason,
|
||
intervalMs: observerRefreshIntervalMs,
|
||
result,
|
||
valuesRedacted: true
|
||
}));
|
||
return result;
|
||
}
|
||
|
||
async function runControlCommand(command, fn) {
|
||
activeCommandId = command.id;
|
||
commandSeq += 1;
|
||
const beforeUrl = currentPageUrl();
|
||
const started = Date.now();
|
||
await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) }));
|
||
try {
|
||
const result = await fn();
|
||
await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) }));
|
||
return result;
|
||
} catch (error) {
|
||
let failurePageProvenance = null;
|
||
let failureScreenshot = null;
|
||
try {
|
||
failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null));
|
||
} catch (captureError) {
|
||
await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||
}
|
||
try {
|
||
failureScreenshot = await captureScreenshot("command-failed", "jpeg");
|
||
} catch (captureError) {
|
||
await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||
}
|
||
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot }));
|
||
throw error;
|
||
} finally {
|
||
activeCommandId = null;
|
||
}
|
||
}
|
||
|
||
async function authenticate(browserContext, authPage) {
|
||
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
||
let activeAuthPage = authPage && !authPage.isClosed() ? authPage : null;
|
||
let closeAuthPage = false;
|
||
if (!activeAuthPage) {
|
||
activeAuthPage = await browserContext.newPage();
|
||
closeAuthPage = true;
|
||
}
|
||
const attempts = [];
|
||
const maxAttempts = 5;
|
||
const initialDelayMs = 250;
|
||
const maxDelayMs = 5000;
|
||
try {
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
const retryDelayMs = attempt < maxAttempts ? Math.min(maxDelayMs, initialDelayMs * (2 ** (attempt - 1))) : 0;
|
||
const retryLabel = attempt + "/" + maxAttempts;
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
|
||
try {
|
||
const response = await pageAuthLogin(activeAuthPage, loginUrl);
|
||
const cookieState = await readAuthCookieState(browserContext);
|
||
const retryable = isRetryableAuthStatus(response.status);
|
||
const item = {
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
method: "api",
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
retryable,
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
valuesRedacted: true,
|
||
};
|
||
attempts.push(item);
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
|
||
if (response.ok && cookieState.cookiePresent) {
|
||
return {
|
||
ok: true,
|
||
method: "api",
|
||
loginPath: new URL(loginUrl).pathname,
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
cookiePresent: true,
|
||
cookieNames: cookieState.cookieNames,
|
||
attempts,
|
||
retryCount: attempt - 1,
|
||
retryMaxAttempts: maxAttempts,
|
||
lastRetryLabel: attempt + "/" + maxAttempts,
|
||
retryExhausted: false,
|
||
retryable: false,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
if (!retryable) break;
|
||
} catch (error) {
|
||
const retryable = isRetryableAuthError(error);
|
||
attempts.push({
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
method: "api",
|
||
status: 0,
|
||
statusText: "request-error",
|
||
retryable,
|
||
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
|
||
cookiePresent: false,
|
||
cookieNames: [],
|
||
valuesRedacted: true,
|
||
});
|
||
const item = attempts[attempts.length - 1] || null;
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {});
|
||
if (!retryable) break;
|
||
}
|
||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
||
}
|
||
} finally {
|
||
if (closeAuthPage && activeAuthPage && !activeAuthPage.isClosed()) await activeAuthPage.close().catch(() => {});
|
||
}
|
||
const cookieState = await readAuthCookieState(browserContext);
|
||
const last = attempts[attempts.length - 1] || null;
|
||
const retryable = attempts.some((attempt) => attempt && attempt.retryable === true);
|
||
const failure = {
|
||
ok: false,
|
||
method: "api",
|
||
loginPath: new URL(loginUrl).pathname,
|
||
status: typeof last?.status === "number" ? last.status : 0,
|
||
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
attempts,
|
||
retryCount: Math.max(0, attempts.length - 1),
|
||
retryMaxAttempts: maxAttempts,
|
||
lastRetryLabel: last?.retryLabel || null,
|
||
retryExhausted: retryable && attempts.length >= maxAttempts,
|
||
retryable,
|
||
lastError: last?.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {});
|
||
const error = new Error(authFailureMessage(failure));
|
||
error.webProbeAuth = failure;
|
||
throw error;
|
||
}
|
||
|
||
async function pageAuthLogin(authPage, loginUrl) {
|
||
if (!authPage) throw new Error("auth page is not ready");
|
||
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
|
||
return authPage.evaluate(async (input) => {
|
||
const response = await fetch(input.loginUrl, {
|
||
method: "POST",
|
||
headers: { accept: "application/json", "content-type": "application/json" },
|
||
body: JSON.stringify({ username: input.username, password: input.password }),
|
||
credentials: "include",
|
||
});
|
||
await response.text().catch(() => "");
|
||
return {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
statusText: response.statusText || "",
|
||
};
|
||
}, { username, password, loginUrl });
|
||
}
|
||
|
||
function publicAuth(value) {
|
||
if (!value) return null;
|
||
return {
|
||
ok: value.ok === true,
|
||
method: value.method,
|
||
status: value.status,
|
||
cookiePresent: value.cookiePresent === true,
|
||
cookieNames: value.cookieNames || [],
|
||
retryCount: value.retryCount ?? null,
|
||
retryMaxAttempts: value.retryMaxAttempts ?? null,
|
||
lastRetryLabel: value.lastRetryLabel ?? null,
|
||
retryExhausted: value.retryExhausted === true,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function readAuthCookieState(browserContext) {
|
||
const cookies = await browserContext.cookies(baseUrl);
|
||
const cookieNames = cookies.map((cookie) => cookie.name).sort();
|
||
return {
|
||
cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)),
|
||
cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)),
|
||
};
|
||
}
|
||
|
||
function isRetryableAuthStatus(status) {
|
||
return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
||
}
|
||
|
||
function isRetryableAuthError(error) {
|
||
const message = error && error.message ? String(error.message) : String(error || "");
|
||
return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message);
|
||
}
|
||
|
||
function authFailureMessage(failure) {
|
||
const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null;
|
||
const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : "";
|
||
const exhausted = failure.retryExhausted ? " exhausted=true" : "";
|
||
const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : "";
|
||
const error = last?.error ? " error=" + truncate(last.error, 160) : "";
|
||
return ("auth login failed:" + retry + exhausted + status + error).trim();
|
||
}
|
||
|
||
function proxyConfigFromEnv(targetBaseUrl) {
|
||
if (browserProxyMode === "direct") return null;
|
||
let target;
|
||
try {
|
||
target = new URL(targetBaseUrl);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
|
||
if (noProxyMatches(target.hostname, noProxy)) return null;
|
||
const raw = target.protocol === "https:"
|
||
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""
|
||
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || "";
|
||
if (!raw) return null;
|
||
return { server: raw };
|
||
}
|
||
|
||
function chromiumLaunchOptionsForProxy(proxy) {
|
||
const base = { env: browserProcessEnvWithoutProxy() };
|
||
if (proxy === null) return { ...base, args: ["--no-proxy-server"] };
|
||
return { ...base, proxy };
|
||
}
|
||
|
||
function browserProcessEnvWithoutProxy() {
|
||
const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
|
||
const env = {};
|
||
for (const [key, value] of Object.entries(process.env)) {
|
||
if (!blocked.has(key) && value !== undefined) env[key] = value;
|
||
}
|
||
return env;
|
||
}
|
||
|
||
function noProxyMatches(hostname, rawList) {
|
||
const host = String(hostname || "").toLowerCase();
|
||
if (!host) return false;
|
||
return String(rawList || "").split(",").some((raw) => {
|
||
let item = raw.trim().toLowerCase();
|
||
if (!item) return false;
|
||
if (item === "*") return true;
|
||
item = item.replace(/^\*\./u, ".");
|
||
const portIndex = item.lastIndexOf(":");
|
||
if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex);
|
||
if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item);
|
||
return host === item;
|
||
});
|
||
}
|
||
|
||
function publicNetwork(proxy) {
|
||
return {
|
||
proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : {
|
||
enabled: true,
|
||
source: "env",
|
||
server: publicProxyServer(proxy.server),
|
||
valuesRedacted: true,
|
||
},
|
||
browser: {
|
||
proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy",
|
||
requestedProxyMode: browserProxyMode,
|
||
proxyEnvCleared: true,
|
||
valuesRedacted: true,
|
||
},
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function parseBrowserProxyMode(raw) {
|
||
if (raw === "auto" || raw === "direct") return raw;
|
||
return "auto";
|
||
}
|
||
|
||
function publicProxyServer(raw) {
|
||
try {
|
||
const parsed = new URL(String(raw || ""));
|
||
parsed.username = "";
|
||
parsed.password = "";
|
||
const value = parsed.toString();
|
||
if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, "");
|
||
return value;
|
||
} catch {
|
||
return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@");
|
||
}
|
||
}
|
||
|
||
async function gotoTarget(rawTarget) {
|
||
const target = new URL(String(rawTarget || targetPath), baseUrl).toString();
|
||
const beforeUrl = currentPageUrl();
|
||
const attempts = [];
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
try {
|
||
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||
await page.waitForTimeout(1000).catch(() => {});
|
||
const httpStatus = response ? response.status() : null;
|
||
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 15000 });
|
||
if (!readiness.ok) {
|
||
const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null);
|
||
attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
|
||
}
|
||
const pageProvenance = await refreshPageProvenance("goto", httpStatus);
|
||
attempts.push({ attempt, ok: true, httpStatus, readiness });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null });
|
||
if (/workbench-app-not-ready/iu.test(message)) {
|
||
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: 5000 }).catch(() => null);
|
||
if (lateReadiness?.ok) {
|
||
const pageProvenance = await refreshPageProvenance("goto-late-ready", null);
|
||
attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts };
|
||
}
|
||
}
|
||
if (attempt >= 2 || !isRetryableNavigationError(message)) {
|
||
throw Object.assign(new Error(message), { attempts, target });
|
||
}
|
||
if (!observerPage) {
|
||
await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
} else {
|
||
await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
}
|
||
await page.waitForTimeout(1500 * attempt).catch(() => {});
|
||
}
|
||
}
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts };
|
||
}
|
||
|
||
async function recreateControlPageForNavigation(reason, attempt) {
|
||
const before = currentPageUrl();
|
||
if (page && !page.isClosed()) await page.close().catch(() => {});
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
currentPageProvenance = null;
|
||
await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, valuesRedacted: true }));
|
||
}
|
||
|
||
async function recreateAuthenticatedContextForNavigation(reason, attempt) {
|
||
const before = currentPageUrl();
|
||
if (page && !page.isClosed()) await page.close().catch(() => {});
|
||
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
|
||
observerPage = null;
|
||
if (context) await context.close().catch(() => {});
|
||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||
auth = await authenticate(context);
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
currentPageProvenance = null;
|
||
await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true }));
|
||
}
|
||
|
||
async function refreshPageProvenance(reason, httpStatus = null) {
|
||
if (!page || page.isClosed()) return currentPageProvenance;
|
||
const observed = await page.evaluate(() => {
|
||
const assetPath = (raw) => {
|
||
if (!raw) return null;
|
||
try {
|
||
const url = new URL(raw, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
|
||
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
|
||
content: String(element.getAttribute("content") || "").slice(0, 200),
|
||
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key));
|
||
const navigation = performance.getEntriesByType("navigation")[0] || null;
|
||
return {
|
||
url: location.href,
|
||
path: location.pathname,
|
||
title: document.title,
|
||
readyState: document.readyState,
|
||
timeOrigin: Math.round(performance.timeOrigin || 0),
|
||
navigationStartTime: navigation ? Math.round(navigation.startTime) : null,
|
||
scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(),
|
||
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(),
|
||
meta,
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] }));
|
||
pageLoadSeq += 1;
|
||
currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq });
|
||
await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) }));
|
||
return currentPageProvenance;
|
||
}
|
||
|
||
function normalizePageProvenance(value, options = {}) {
|
||
const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : [];
|
||
const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : [];
|
||
const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({
|
||
key: String(item?.key || "").slice(0, 120),
|
||
contentHash: sha256Text(String(item?.content || "")),
|
||
})).filter((item) => item.key) : [];
|
||
const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta });
|
||
return {
|
||
pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq,
|
||
reason: options.reason || "sample",
|
||
observedAt: new Date().toISOString(),
|
||
urlPath: safeUrlPath(value?.url || currentPageUrl()),
|
||
documentPath: value?.path || null,
|
||
titleHash: sha256Text(String(value?.title || "")),
|
||
documentReadyState: value?.readyState || null,
|
||
timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null,
|
||
navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null,
|
||
httpStatus: options.httpStatus ?? null,
|
||
assetFingerprint: sha256Text(fingerprintInput),
|
||
scriptCount: scripts.length,
|
||
stylesheetCount: stylesheets.length,
|
||
metaCount: meta.length,
|
||
scripts: scripts.slice(0, 30),
|
||
stylesheets: stylesheets.slice(0, 30),
|
||
meta: meta.slice(0, 30),
|
||
error: value?.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function compactPageProvenance(value) {
|
||
if (!value) return null;
|
||
return {
|
||
pageLoadSeq: value.pageLoadSeq ?? null,
|
||
reason: value.reason || null,
|
||
observedAt: value.observedAt || null,
|
||
urlPath: value.urlPath || null,
|
||
documentReadyState: value.documentReadyState || null,
|
||
timeOrigin: value.timeOrigin ?? null,
|
||
httpStatus: value.httpStatus ?? null,
|
||
assetFingerprint: value.assetFingerprint || null,
|
||
scriptCount: value.scriptCount ?? 0,
|
||
stylesheetCount: value.stylesheetCount ?? 0,
|
||
metaCount: value.metaCount ?? 0,
|
||
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [],
|
||
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [],
|
||
error: value.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function isRetryableNavigationError(message) {
|
||
return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|Navigation timeout|workbench-app-not-ready/iu.test(String(message || ""));
|
||
}
|
||
|
||
function navigationFailureKind(message) {
|
||
const text = String(message || "");
|
||
if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED";
|
||
if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED";
|
||
if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET";
|
||
if (/Navigation timeout/iu.test(text)) return "navigation-timeout";
|
||
if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready";
|
||
return "navigation-error";
|
||
}
|
||
|
||
function redactErrorMessage(message) {
|
||
return String(message || "")
|
||
.replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]")
|
||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]");
|
||
}
|
||
|
||
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
|
||
const targetPathname = safeUrlPath(targetUrl) || "";
|
||
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
|
||
const started = Date.now();
|
||
await targetPage.waitForFunction(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const workspace = document.querySelector("#workspace, .workbench-route");
|
||
const login = document.querySelector("form.login-card, .login-card, [data-testid='login']");
|
||
return Boolean(visible(workspace) || visible(login));
|
||
}, null, { timeout: timeoutMs }).catch(() => null);
|
||
const snapshot = await workbenchReadinessSnapshot(targetPage);
|
||
const ok = snapshot.workbenchShellVisible === true;
|
||
return {
|
||
ok,
|
||
reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready",
|
||
durationMs: Date.now() - started,
|
||
snapshot,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function workbenchReadinessSnapshot(targetPage) {
|
||
const snapshot = await targetPage.evaluate(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return {
|
||
url: window.location.href,
|
||
path: window.location.pathname,
|
||
readyState: document.readyState,
|
||
workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")),
|
||
sessionCreateVisible: visible(document.querySelector("#session-create")),
|
||
commandInputPresent: visible(document.querySelector("#command-input")),
|
||
activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")),
|
||
warningPresent: visible(document.querySelector(".composer-warning")),
|
||
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
|
||
bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000),
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
if (snapshot && typeof snapshot.bodyTextPreview === "string") {
|
||
snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview);
|
||
delete snapshot.bodyTextPreview;
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
function isWorkbenchPathname(value) {
|
||
const pathname = String(value || "");
|
||
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
|
||
}
|
||
|
||
async function createSessionFromUi() {
|
||
const beforeUrl = currentPageUrl();
|
||
const before = await workbenchSessionSnapshot();
|
||
const readinessBeforeClick = await workbenchReadinessSnapshot(page);
|
||
const create = page.locator("#session-create").first();
|
||
await create.waitFor({ state: "visible", timeout: 15000 });
|
||
const createButtonState = await create.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return {
|
||
tag: element.tagName.toLowerCase(),
|
||
id: element.id || null,
|
||
disabled: Boolean(element.disabled),
|
||
ariaDisabled: element.getAttribute("aria-disabled") || null,
|
||
visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none",
|
||
rect: { width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
const createResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
if (request.method().toUpperCase() !== "POST") return false;
|
||
try {
|
||
return new URL(response.url()).pathname === "/v1/agent/sessions";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||
await create.click();
|
||
const createResponse = await createResponsePromise;
|
||
if (createResponse?.waitError) {
|
||
const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (createResponse.waitError.message || createResponse.waitError.name || "timeout"));
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, readinessBeforeClick, createButtonState, waitError: createResponse.waitError, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const createStatus = createResponse.status();
|
||
let createPayload = null;
|
||
let createPayloadError = null;
|
||
try {
|
||
createPayload = await createResponse.json();
|
||
} catch (error) {
|
||
createPayloadError = errorSummary(error);
|
||
}
|
||
const createdSessionId = sessionIdFromAgentSessionPayload(createPayload);
|
||
if (createStatus < 200 || createStatus >= 300 || !createdSessionId) {
|
||
const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions");
|
||
error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
await page.waitForFunction((expectedSessionId) => {
|
||
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
|
||
const sessionId = activeTab?.getAttribute("data-session-id") || "";
|
||
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
|
||
const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : "";
|
||
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || "";
|
||
const input = document.querySelector("#command-input");
|
||
return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning);
|
||
}, createdSessionId, { timeout: 45000 }).catch(() => null);
|
||
const after = await workbenchSessionSnapshot();
|
||
const afterSessionId = after?.activeSessionId || after?.routeSessionId || "";
|
||
const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady);
|
||
if (!ok) {
|
||
const error = new Error("newSession did not select the authoritative newly created workbench session");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
ok,
|
||
before,
|
||
after,
|
||
sessionId: createdSessionId,
|
||
createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true },
|
||
pageId
|
||
};
|
||
}
|
||
|
||
function sessionIdFromAgentSessionPayload(payload) {
|
||
const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id;
|
||
const directText = String(direct || "").trim();
|
||
if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText;
|
||
const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u);
|
||
return match ? match[0] : null;
|
||
}
|
||
|
||
async function workbenchSessionSnapshot(targetPage = page) {
|
||
return targetPage.evaluate(() => {
|
||
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
|
||
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
|
||
const input = document.querySelector("#command-input");
|
||
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null;
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return {
|
||
url: window.location.href,
|
||
routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null,
|
||
activeSessionId: activeTab?.getAttribute("data-session-id") || null,
|
||
activeConversationId: activeTab?.getAttribute("data-conversation-id") || null,
|
||
activeStatus: activeTab?.getAttribute("data-status") || null,
|
||
tabCount: document.querySelectorAll(".session-tab").length,
|
||
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
|
||
traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length,
|
||
loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length,
|
||
composerReady: Boolean(activeTab && input && !input.disabled && !warning),
|
||
warning
|
||
};
|
||
}).catch(() => null);
|
||
}
|
||
|
||
async function sendPrompt(text) {
|
||
if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeEvidence = await promptSideEffectSnapshot();
|
||
const primaryEditor = page.locator("#command-input").last();
|
||
const editor = await primaryEditor.isVisible().catch(() => false)
|
||
? primaryEditor
|
||
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
|
||
await editor.waitFor({ state: "visible", timeout: 15000 });
|
||
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
|
||
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
|
||
if (tag === "textarea" || tag === "input") await editor.fill(text);
|
||
else if (editable) {
|
||
await editor.click();
|
||
await page.keyboard.insertText(text);
|
||
} else {
|
||
await editor.click();
|
||
await page.keyboard.insertText(text);
|
||
}
|
||
const primarySubmit = page.locator('#command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]').last();
|
||
const submit = await primarySubmit.isVisible().catch(() => false)
|
||
? primarySubmit
|
||
: page.locator([
|
||
'button[type="submit"]',
|
||
'button:has-text("发送")',
|
||
'button:has-text("Send")',
|
||
'[data-testid*="send" i]',
|
||
'[aria-label*="send" i]',
|
||
'[aria-label*="发送"]'
|
||
].join(", ")).last();
|
||
await submit.waitFor({ state: "visible", timeout: 15000 });
|
||
const chatResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
if (request.method().toUpperCase() !== "POST") return false;
|
||
try {
|
||
return new URL(response.url()).pathname === "/v1/agent/chat";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||
await submit.click();
|
||
const chatResponse = await chatResponsePromise;
|
||
await page.waitForTimeout(500);
|
||
if (chatResponse?.waitError) {
|
||
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000);
|
||
if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) {
|
||
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
|
||
};
|
||
}
|
||
const error = new Error("sendPrompt did not observe POST /v1/agent/chat response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout"));
|
||
error.details = {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
chatSubmit: { status: null, statusText: null, urlPath: "/v1/agent/chat", waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect },
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
throw error;
|
||
}
|
||
const chatStatus = chatResponse.status();
|
||
if (chatStatus < 200 || chatStatus >= 300) {
|
||
throw new Error("sendPrompt observed POST /v1/agent/chat HTTP " + chatStatus + " " + chatResponse.statusText());
|
||
}
|
||
let chatPayload = null;
|
||
let chatPayloadError = null;
|
||
try {
|
||
chatPayload = await chatResponse.json();
|
||
} catch (error) {
|
||
chatPayloadError = errorSummary(error);
|
||
}
|
||
let chatUrlPath = "/v1/agent/chat";
|
||
try {
|
||
chatUrlPath = new URL(chatResponse.url()).pathname;
|
||
} catch {
|
||
chatUrlPath = "/v1/agent/chat";
|
||
}
|
||
const payloadText = chatPayload ? JSON.stringify(chatPayload) : "";
|
||
const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null;
|
||
const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId)
|
||
? chatPayload.otelTrace.traceId
|
||
: null;
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
chatSubmit: {
|
||
status: chatStatus,
|
||
statusText: chatResponse.statusText(),
|
||
urlPath: chatUrlPath,
|
||
traceId,
|
||
otelTraceId,
|
||
resultUrl: safeUrlPath(chatPayload?.resultUrl),
|
||
turnUrl: safeUrlPath(chatPayload?.turnUrl),
|
||
streamUrl: safeUrlPath(chatPayload?.streamUrl),
|
||
responseParsed: chatPayload !== null,
|
||
responseParseError: chatPayloadError,
|
||
valuesRedacted: true
|
||
},
|
||
pageId
|
||
};
|
||
}
|
||
|
||
function sideEffectHasAuthoritativePromptSubmission(sideEffect) {
|
||
return Boolean(
|
||
(Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0)
|
||
|| (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0)
|
||
|| Number(sideEffect?.messageCountDelta || 0) > 0
|
||
);
|
||
}
|
||
|
||
async function waitForPromptSideEffect(beforeEvidence, timeoutMs) {
|
||
await page.waitForFunction((before) => {
|
||
const current = (() => {
|
||
const text = document.body?.innerText || "";
|
||
const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
|
||
const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
|
||
const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text);
|
||
const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text);
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length;
|
||
const textBytes = new TextEncoder().encode(text).length;
|
||
return { runIds, traceIds, running, executionError, messageCount, textBytes };
|
||
})();
|
||
const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : [];
|
||
const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : [];
|
||
const newRun = current.runIds.some((id) => !beforeRuns.includes(id));
|
||
const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id));
|
||
const newMessage = current.messageCount > Number(before?.messageCount || 0);
|
||
return newRun || newTrace || newMessage;
|
||
}, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null);
|
||
const after = await promptSideEffectSnapshot();
|
||
const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : [];
|
||
const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : [];
|
||
const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id));
|
||
const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id));
|
||
const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0));
|
||
return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true };
|
||
}
|
||
|
||
async function promptSideEffectSnapshot() {
|
||
return page.evaluate(() => {
|
||
const text = document.body?.innerText || "";
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return {
|
||
runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
|
||
traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
|
||
running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text),
|
||
executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text),
|
||
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
|
||
textBytes: new TextEncoder().encode(text).length,
|
||
valuesRedacted: true
|
||
};
|
||
}).catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, 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 beforePath = safeUrlPath(beforeUrl);
|
||
if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first");
|
||
const nativeSelect = await page.evaluate((name) => {
|
||
const normalized = String(name).toLowerCase();
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
||
const options = Array.from(select.options || []);
|
||
const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized));
|
||
if (!option) continue;
|
||
select.value = option.value;
|
||
select.dispatchEvent(new Event("input", { bubbles: true }));
|
||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||
return { kind: "native-select", value: option.value, text: option.textContent || "" };
|
||
}
|
||
return null;
|
||
}, target).catch(() => null);
|
||
if (nativeSelect) {
|
||
await page.waitForTimeout(500);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId };
|
||
}
|
||
const optionVisible = page.getByText(target, { exact: false }).last();
|
||
if (await optionVisible.isVisible().catch(() => false)) {
|
||
await optionVisible.click();
|
||
await page.waitForTimeout(500);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId };
|
||
}
|
||
const control = page.locator([
|
||
'[data-testid*="provider" i]',
|
||
'[data-testid*="model" i]',
|
||
'[aria-label*="provider" i]',
|
||
'[aria-label*="model" i]',
|
||
'[aria-label*="模型"]',
|
||
'[aria-label*="提供"]',
|
||
'[role="combobox"]',
|
||
'[aria-haspopup="listbox"]',
|
||
].join(", "));
|
||
const count = Math.min(await control.count().catch(() => 0), 60);
|
||
const attempts = [];
|
||
for (let index = count - 1; index >= 0; index -= 1) {
|
||
const item = control.nth(index);
|
||
const visible = await item.isVisible().catch(() => false);
|
||
if (!visible) continue;
|
||
const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => "");
|
||
if (isProviderNavigationLabel(label)) continue;
|
||
if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue;
|
||
attempts.push({ index, label });
|
||
await item.click({ timeout: 3000 }).catch(() => null);
|
||
await page.waitForTimeout(300);
|
||
const option = page.getByText(target, { exact: false }).last();
|
||
if (await option.isVisible().catch(() => false)) {
|
||
await option.click();
|
||
await page.waitForTimeout(700);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId };
|
||
}
|
||
await page.keyboard.press("Escape").catch(() => null);
|
||
}
|
||
const providerCandidates = await collectProviderCandidates();
|
||
throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10)));
|
||
}
|
||
|
||
async function collectProviderCandidates() {
|
||
return page.evaluate(() => {
|
||
const rows = [];
|
||
const seen = new Set();
|
||
const isNavigationLabel = (value) => {
|
||
const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||
if (!label) return false;
|
||
if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true;
|
||
return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, ""));
|
||
};
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const push = (kind, element, value, text) => {
|
||
const normalizedValue = String(value || "").trim().slice(0, 160);
|
||
const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220);
|
||
const label = (normalizedValue + " " + normalizedText).trim();
|
||
if (!label) return;
|
||
const key = (kind + ":" + label).toLowerCase();
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) });
|
||
};
|
||
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
||
for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || "");
|
||
}
|
||
const selector = [
|
||
'[role="option"]',
|
||
'[role="menuitem"]',
|
||
'[data-radix-collection-item]',
|
||
'[data-testid*="provider" i]',
|
||
'[data-testid*="profile" i]',
|
||
'[data-testid*="model" i]',
|
||
'[aria-label*="provider" i]',
|
||
'[aria-label*="profile" i]',
|
||
'[aria-label*="model" i]',
|
||
'[aria-label*="模型"]',
|
||
'[aria-label*="提供"]',
|
||
'button'
|
||
].join(", ");
|
||
for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) {
|
||
const text = String(element.textContent || "").replace(/\s+/gu, " ").trim();
|
||
const ariaLabel = String(element.getAttribute("aria-label") || "");
|
||
const testId = String(element.getAttribute("data-testid") || "");
|
||
const haystack = text + " " + ariaLabel + " " + testId;
|
||
if (isNavigationLabel(haystack)) continue;
|
||
if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue;
|
||
push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId);
|
||
}
|
||
return rows.slice(0, 80);
|
||
}).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]);
|
||
}
|
||
|
||
function isProviderNavigationLabel(value) {
|
||
const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||
if (!text) return false;
|
||
if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true;
|
||
return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, ""));
|
||
}
|
||
|
||
async function clickSession(sessionId) {
|
||
if (!sessionId) throw new Error("clickSession requires --session-id or --value");
|
||
const beforeUrl = currentPageUrl();
|
||
const escaped = cssEscape(sessionId);
|
||
const 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, options = {}) {
|
||
if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason);
|
||
const groupSeq = sampleSeq + 1;
|
||
if (page && !page.isClosed()) await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch });
|
||
if (observerPage && !observerPage.isClosed()) {
|
||
await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) })));
|
||
}
|
||
if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) {
|
||
await captureScreenshot("checkpoint", "jpeg").catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) })));
|
||
}
|
||
await writeHeartbeat({ status: terminalStatus });
|
||
}
|
||
|
||
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
|
||
sampleSeq += 1;
|
||
const dom = await targetPage.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 loadingTextPattern = /加载中|\bLoading\b/iu;
|
||
const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu;
|
||
const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]";
|
||
const elementTextForLoading = (element) => [
|
||
element.textContent || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || "",
|
||
element.getAttribute("data-testid") || ""
|
||
].map((value) => trim(value, 240)).filter(Boolean).join(" ");
|
||
const elementLooksLikeLoadingUi = (element) => {
|
||
const signal = [
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("role") || "",
|
||
element.getAttribute("aria-busy") || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || ""
|
||
].join(" ");
|
||
return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal);
|
||
};
|
||
const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element);
|
||
const hasLoadingText = (element) => {
|
||
const text = elementTextForLoading(element);
|
||
if (!loadingTextPattern.test(text)) return false;
|
||
if (elementIsCodeLike(element)) return false;
|
||
return true;
|
||
};
|
||
const elementDescriptor = (element) => {
|
||
if (!element) return null;
|
||
const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
|
||
const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]");
|
||
return {
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid") || null,
|
||
role: element.getAttribute("role") || null,
|
||
id: element.getAttribute("id") || null,
|
||
className: className || null,
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null,
|
||
ariaLabel: element.getAttribute("aria-label") || null
|
||
};
|
||
};
|
||
const ownerKindFor = (element) => {
|
||
const value = [
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("role") || "",
|
||
element.tagName || ""
|
||
].join(" ").toLowerCase();
|
||
if (/message|turn|agent|assistant/.test(value)) return "turn";
|
||
if (/session|rail|sidebar|nav/.test(value)) return "session-nav";
|
||
if (/composer|prompt|input|textarea/.test(value)) return "composer";
|
||
if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic";
|
||
if (/trace|event|terminal|log/.test(value)) return "trace";
|
||
if (/performance|metric|chart|table/.test(value)) return "performance";
|
||
if (/main|workspace|workbench|root/.test(value)) return "workbench";
|
||
return "unknown";
|
||
};
|
||
const ownerLabelFor = (element) => {
|
||
const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]");
|
||
return trim(
|
||
element.getAttribute("aria-label")
|
||
|| element.getAttribute("data-testid")
|
||
|| (heading ? heading.textContent || "" : "")
|
||
|| element.getAttribute("class")
|
||
|| element.tagName,
|
||
160
|
||
);
|
||
};
|
||
const ownerKeyFor = (element) => {
|
||
const descriptor = elementDescriptor(element) || {};
|
||
return [
|
||
ownerKindFor(element),
|
||
descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown",
|
||
descriptor.sessionId || descriptor.messageId || descriptor.traceId || ""
|
||
].filter(Boolean).join(":").slice(0, 240);
|
||
};
|
||
const collectLoadingNodes = () => {
|
||
const candidates = Array.from(document.querySelectorAll("body *"))
|
||
.filter(visible)
|
||
.filter(hasLoadingText)
|
||
.filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child)))
|
||
.slice(-80);
|
||
return candidates.map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]');
|
||
const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]');
|
||
const owner = identityOwner || structuralOwner || element;
|
||
const ownerDescriptor = elementDescriptor(owner);
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 180),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
text: trim(elementTextForLoading(element), 240),
|
||
ownerKind: ownerKindFor(owner),
|
||
ownerKey: ownerKeyFor(owner),
|
||
ownerLabel: ownerLabelFor(owner),
|
||
owner: ownerDescriptor,
|
||
ownerText: trim(owner.textContent || "", 300),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
};
|
||
const sessionIdForElement = (element) => {
|
||
const direct = element.getAttribute("data-session-id");
|
||
if (direct) return direct;
|
||
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
||
if (!href) return null;
|
||
try {
|
||
const parsed = new URL(href, location.href);
|
||
const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
} catch {
|
||
const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
}
|
||
};
|
||
const sessionTitleTextForElement = (element) => {
|
||
const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]");
|
||
return trim(
|
||
(titleNode ? titleNode.textContent || "" : "")
|
||
|| element.getAttribute("aria-label")
|
||
|| element.getAttribute("title")
|
||
|| element.textContent
|
||
|| "",
|
||
240
|
||
);
|
||
};
|
||
const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu;
|
||
const looksLikeSessionTitleFallback = (title, sessionId) => {
|
||
const text = trim(title, 240);
|
||
const id = String(sessionId || "").trim();
|
||
if (!text) return true;
|
||
if (sessionTitleFallbackPattern.test(text)) return true;
|
||
if (!id) return false;
|
||
return text === id || text.startsWith(id) || text.startsWith("Session " + id);
|
||
};
|
||
const collectSessionRailTitles = () => {
|
||
const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))
|
||
.filter(visible);
|
||
const seen = new Set();
|
||
const items = [];
|
||
for (const element of candidates) {
|
||
const sessionId = sessionIdForElement(element);
|
||
const titleText = sessionTitleTextForElement(element);
|
||
const key = (sessionId || "") + "|" + titleText;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const rect = element.getBoundingClientRect();
|
||
const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId);
|
||
items.push({
|
||
index: items.length,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
|
||
sessionId,
|
||
sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null,
|
||
titleText,
|
||
fallbackTitle,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
});
|
||
}
|
||
const visibleItems = items.slice(0, 120);
|
||
const fallbackItems = visibleItems.filter((item) => item.fallbackTitle);
|
||
const visibleCount = visibleItems.length;
|
||
const fallbackTitleCount = fallbackItems.length;
|
||
return {
|
||
visibleCount,
|
||
fallbackTitleCount,
|
||
fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0,
|
||
items: visibleItems.slice(0, 60),
|
||
fallbackItems: fallbackItems.slice(0, 12),
|
||
};
|
||
};
|
||
const diagnosticSummaryText = (element) => {
|
||
const summarySelectors = [
|
||
".api-error-diagnostic-summary-text p",
|
||
".api-error-diagnostic-summary-text",
|
||
"[class*='diagnostic-summary-text' i] p",
|
||
"[class*='diagnostic-summary-text' i]",
|
||
"[data-testid*='diagnostic-summary' i]",
|
||
"[data-testid*='error-summary' i]",
|
||
"[role='alert'] p",
|
||
"[role='alert']"
|
||
];
|
||
const parts = [];
|
||
for (const selector of summarySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 800);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
const ownText = textHashInput(element);
|
||
const text = parts.length > 0 ? parts.join(" ") : ownText;
|
||
return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim();
|
||
};
|
||
const diagnosticToggleOnly = (element, text) => {
|
||
const compact = String(text || "").trim();
|
||
if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false;
|
||
const tag = element.tagName.toLowerCase();
|
||
const role = element.getAttribute("role");
|
||
const aria = element.getAttribute("aria-label") || "";
|
||
const title = element.getAttribute("title") || "";
|
||
return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title);
|
||
};
|
||
const stableMessageText = (element) => {
|
||
const bodySelectors = [
|
||
".message-markdown.message-text",
|
||
".message-text",
|
||
"[data-message-body]",
|
||
"[data-testid='message-body']",
|
||
"[data-testid*='message-text' i]",
|
||
"[data-testid*='final-response' i]"
|
||
];
|
||
const parts = [];
|
||
for (const selector of bodySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 1200);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
if (parts.length > 0) return parts.join(" ");
|
||
const clone = element.cloneNode(true);
|
||
for (const selector of [
|
||
".message-duration-meta",
|
||
".message-activity-meta",
|
||
".api-error-diagnostic",
|
||
"[class*='diagnostic' i]",
|
||
"[class*='trace' i]",
|
||
"[data-trace-id]",
|
||
"[data-testid*='trace' i]",
|
||
"[data-testid*='event' i]",
|
||
"[role='status']",
|
||
"[role='alert']",
|
||
"button"
|
||
]) {
|
||
for (const child of Array.from(clone.querySelectorAll(selector))) child.remove();
|
||
}
|
||
return trim(clone.textContent || "", 1200);
|
||
};
|
||
const numericAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
const value = Number(raw);
|
||
if (Number.isFinite(value)) return value;
|
||
}
|
||
return null;
|
||
};
|
||
const textAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
if (raw && String(raw).trim()) return String(raw).trim();
|
||
}
|
||
return null;
|
||
};
|
||
const summarize = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]');
|
||
const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]");
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null,
|
||
projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]),
|
||
sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]),
|
||
eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]),
|
||
eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null),
|
||
eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null,
|
||
eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null,
|
||
text: textHashInput(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const summarizeMessages = (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"),
|
||
dataRole: element.getAttribute("data-role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || null,
|
||
durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120),
|
||
activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120),
|
||
text: stableMessageText(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const resourceTimingSample = (entry) => ({
|
||
name: entry.name.split(/[?#]/u)[0].slice(0, 240),
|
||
initiatorType: entry.initiatorType,
|
||
startTime: Math.round(entry.startTime),
|
||
duration: Math.round(entry.duration),
|
||
workerStart: Math.round(entry.workerStart || 0),
|
||
redirectStart: Math.round(entry.redirectStart || 0),
|
||
redirectEnd: Math.round(entry.redirectEnd || 0),
|
||
fetchStart: Math.round(entry.fetchStart || 0),
|
||
domainLookupStart: Math.round(entry.domainLookupStart || 0),
|
||
domainLookupEnd: Math.round(entry.domainLookupEnd || 0),
|
||
connectStart: Math.round(entry.connectStart || 0),
|
||
connectEnd: Math.round(entry.connectEnd || 0),
|
||
secureConnectionStart: Math.round(entry.secureConnectionStart || 0),
|
||
requestStart: Math.round(entry.requestStart || 0),
|
||
responseStart: Math.round(entry.responseStart || 0),
|
||
responseEnd: Math.round(entry.responseEnd || 0),
|
||
transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null,
|
||
encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
||
decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
||
nextHopProtocol: entry.nextHopProtocol || null,
|
||
responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null,
|
||
serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({
|
||
name: String(item.name || "").slice(0, 80),
|
||
duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null,
|
||
description: String(item.description || "").slice(0, 120)
|
||
})),
|
||
});
|
||
const 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 = 'article.message-card, .message-card[data-message-id], article[data-message-id]';
|
||
const traceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]';
|
||
const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]';
|
||
const messages = summarizeMessages(messageSelector, 80);
|
||
const traceRows = summarize(traceSelector, 30);
|
||
const loadings = collectLoadingNodes();
|
||
const sessionRail = collectSessionRailTitles();
|
||
const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = diagnosticSummaryText(element);
|
||
if (!text || diagnosticToggleOnly(element, text)) return null;
|
||
const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu);
|
||
const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu);
|
||
const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu);
|
||
const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu);
|
||
const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic";
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 240),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
compact: element.getAttribute("data-compact"),
|
||
expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"),
|
||
title: element.getAttribute("title"),
|
||
diagnosticCode,
|
||
traceId: traceMatch?.[1] || null,
|
||
httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null,
|
||
idleSeconds: idleMatch ? Number(idleMatch[1]) : null,
|
||
waitingFor: waitingForMatch?.[1] || null,
|
||
lastEventLabel: lastEventLabelMatch?.[1] || null,
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).filter(Boolean);
|
||
const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = textHashInput(element);
|
||
const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]");
|
||
const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120);
|
||
const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120);
|
||
const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null;
|
||
return {
|
||
index,
|
||
role: element.getAttribute("data-role") || "agent",
|
||
status: element.getAttribute("data-status") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: directTraceId,
|
||
turnId: element.getAttribute("data-turn-id") || directTraceId,
|
||
durationText,
|
||
activityText,
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).slice(-80);
|
||
const active = document.activeElement;
|
||
return {
|
||
url,
|
||
path: location.pathname,
|
||
routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null,
|
||
activeSessionId,
|
||
title: document.title,
|
||
focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null,
|
||
viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
|
||
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) },
|
||
messages,
|
||
traceRows,
|
||
loadings,
|
||
sessionRail,
|
||
diagnostics,
|
||
turns,
|
||
pageProvenance: {
|
||
url: location.href,
|
||
path: location.pathname,
|
||
title: document.title,
|
||
readyState: document.readyState,
|
||
timeOrigin: Math.round(performance.timeOrigin || 0),
|
||
navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null,
|
||
scripts: Array.from(document.scripts).map((element) => {
|
||
if (!element.src) return null;
|
||
try {
|
||
const url = new URL(element.src, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => {
|
||
try {
|
||
const url = new URL(element.href, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
|
||
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
|
||
content: String(element.getAttribute("content") || "").slice(0, 200),
|
||
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)),
|
||
},
|
||
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
|
||
const sample = {
|
||
seq: sampleSeq,
|
||
sampleGroupSeq: groupSeq,
|
||
ts: new Date().toISOString(),
|
||
reason,
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
|
||
commandId: activeCommandId,
|
||
observerInitiated: false,
|
||
...digestDom(dom, pageRole),
|
||
};
|
||
await appendJsonl(files.samples, sample);
|
||
}
|
||
|
||
function digestDom(dom, pageRole = "control") {
|
||
if (dom && dom.error) return dom;
|
||
const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : [];
|
||
const sessionRail = digestSessionRail(dom.sessionRail);
|
||
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
|
||
if (pageRole === "control") currentPageProvenance = pageProvenance;
|
||
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) };
|
||
}
|
||
|
||
function digestSessionRail(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const items = Array.isArray(value.items) ? value.items.map((item) => {
|
||
const titleText = String(item?.titleText || item?.titlePreview || "");
|
||
return {
|
||
index: item?.index ?? null,
|
||
tag: item?.tag ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
active: item?.active === true,
|
||
sessionId: item?.sessionId ?? null,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
fallbackTitle: item?.fallbackTitle === true,
|
||
titleHash: sha256Text(titleText),
|
||
titlePreview: truncate(titleText, 160),
|
||
titleBytes: Buffer.byteLength(titleText),
|
||
rect: item?.rect ?? null,
|
||
};
|
||
}) : [];
|
||
const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12);
|
||
const visibleCount = Number(value.visibleCount ?? items.length);
|
||
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
|
||
return {
|
||
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
|
||
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
|
||
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
|
||
items,
|
||
fallbackItems,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
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) {
|
||
const clean = sanitize(data) || {};
|
||
return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean };
|
||
}
|
||
|
||
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() {
|
||
return pageUrl(page);
|
||
}
|
||
|
||
function pageUrl(targetPage) {
|
||
try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; }
|
||
}
|
||
|
||
function routeSessionIdFromUrl(value) {
|
||
try {
|
||
const pathname = new URL(String(value || ""), baseUrl).pathname;
|
||
const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function safeFrameUrl(frame) {
|
||
try { return frame ? safeUrl(frame.url()) : null; } catch { return null; }
|
||
}
|
||
|
||
function safeUrl(value) {
|
||
try {
|
||
const url = new URL(String(value), baseUrl);
|
||
for (const key of Array.from(url.searchParams.keys())) {
|
||
if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]");
|
||
}
|
||
return url.toString();
|
||
} catch {
|
||
return truncate(String(value || ""), 300);
|
||
}
|
||
}
|
||
|
||
function safeUrlPath(value) {
|
||
try {
|
||
return new URL(String(value || ""), baseUrl).pathname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeBaseUrl(value) {
|
||
const raw = value || "http://127.0.0.1:3000";
|
||
const url = new URL(raw);
|
||
return url.origin;
|
||
}
|
||
|
||
function parseViewport(value) {
|
||
const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u);
|
||
return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 };
|
||
}
|
||
|
||
function positiveInteger(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
|
||
}
|
||
|
||
function positiveNumber(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function requiredPositiveThreshold(raw, key) {
|
||
const parsed = Number(raw?.[key]);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseAlertThresholds(value) {
|
||
if (!value) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
|
||
if (sessionRailFallbackRatio > 1) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
|
||
}
|
||
return {
|
||
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
|
||
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
|
||
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
|
||
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
|
||
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
|
||
uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"),
|
||
scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"),
|
||
scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"),
|
||
scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"),
|
||
sessionRailFallbackRatio,
|
||
crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")),
|
||
source: "yaml-env",
|
||
};
|
||
}
|
||
|
||
function sha256Text(value) {
|
||
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
|
||
}
|
||
|
||
function safeId(value) {
|
||
return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item";
|
||
}
|
||
|
||
function cssEscape(value) {
|
||
return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
|
||
}
|
||
|
||
function truncate(value, limit) {
|
||
const text = String(value || "");
|
||
return text.length > limit ? text.slice(0, limit) + "..." : text;
|
||
}
|
||
|
||
function sanitize(value) {
|
||
if (value === null || value === undefined) return value;
|
||
if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]");
|
||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||
if (Array.isArray(value)) return value.map(sanitize);
|
||
if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)]));
|
||
return String(value);
|
||
}
|
||
|
||
function errorSummary(error) {
|
||
const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null };
|
||
if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth);
|
||
if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5));
|
||
if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness);
|
||
if (error && error.details) summary.details = sanitize(error.details);
|
||
if (error && error.target) summary.target = sanitize(error.target);
|
||
return summary;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||
}
|
||
`;
|
||
}
|
||
|
||
export function nodeWebObserveAnalyzerSource(): string {
|
||
return String.raw`#!/usr/bin/env node
|
||
import { createHash } from "node:crypto";
|
||
import { createReadStream } from "node:fs";
|
||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { createInterface } from "node:readline";
|
||
|
||
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || process.argv[2] || ".state/web-observe/manual");
|
||
const archivePrefix = safeArchivePrefix(process.env.UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX || "");
|
||
const analyzeTailSamples = (() => {
|
||
const raw = process.env.UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES;
|
||
if (raw === undefined || raw === null || String(raw).trim() === "") return 360;
|
||
const parsed = Number(raw);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 360;
|
||
})();
|
||
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
||
const dataDir = archivePrefix ? path.join(stateDir, "archive") : stateDir;
|
||
const dataFile = (name) => path.join(dataDir, archivePrefix ? archivePrefix + "-" + name : name);
|
||
const analysisDir = path.join(stateDir, "analysis");
|
||
const reportJsonPath = path.join(analysisDir, "report.json");
|
||
const reportMdPath = path.join(analysisDir, "report.md");
|
||
const jsonlReadIssues = [];
|
||
const sourceSamples = await readJsonl(dataFile("samples.jsonl"), { compact: compactSampleForAnalysis, tail: analyzeTailSamples });
|
||
const relatedJsonlTailLimit = analyzeTailSamples > 0 ? Math.max(1200, analyzeTailSamples * 50) : 0;
|
||
const smallJsonlTailLimit = analyzeTailSamples > 0 ? Math.max(500, analyzeTailSamples * 8) : 0;
|
||
const sourceSampleWindow = sampleTimeWindow(sourceSamples, 60_000);
|
||
const sourceControlAll = await readJsonl(dataFile("control.jsonl"), { tail: relatedJsonlTailLimit });
|
||
const analysisFocus = analysisFocusFromControl(sourceControlAll);
|
||
const sourceControl = filterRowsByTimeWindow(sourceControlAll, sourceSampleWindow);
|
||
const samples = applyAnalysisFocus(sourceSamples, analysisFocus);
|
||
const sampleWindow = sampleTimeWindow(samples, 60_000);
|
||
const controlWindow = analysisControlWindow(sampleWindow, analysisFocus, 1000);
|
||
const control = applyAnalysisFocus(filterRowsByTimeWindow(sourceControlAll, controlWindow), analysisFocus, 1000);
|
||
const sourceNetworkAll = await readJsonl(dataFile("network.jsonl"), { tail: relatedJsonlTailLimit });
|
||
const network = applyAnalysisFocus(filterRowsByTimeWindow(sourceNetworkAll, sampleWindow), analysisFocus);
|
||
const promptNetworkRows = applyAnalysisFocus(filterRowsByTimeWindow(sourceNetworkAll, controlWindow), analysisFocus);
|
||
const consoleEvents = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("console.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const errors = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("errors.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const artifacts = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("artifacts.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const manifest = await readJson(path.join(stateDir, "manifest.json"));
|
||
const heartbeat = await readJson(path.join(stateDir, "heartbeat.json"));
|
||
|
||
await mkdir(analysisDir, { recursive: true, mode: 0o700 });
|
||
const transitions = buildTransitions(samples);
|
||
const sampleMetrics = buildSampleMetrics(samples, control);
|
||
const pageProvenance = buildPageProvenanceReport(samples, control, manifest);
|
||
const pagePerformance = buildPagePerformanceReport(samples, manifest);
|
||
const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows);
|
||
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
|
||
const runnerErrors = errors.slice(-8).map((item) => {
|
||
const attempts = Array.isArray(item.error?.attempts) ? item.error.attempts : [];
|
||
const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
|
||
const readiness = lastAttempt?.readiness || item.error?.navigationReadiness || null;
|
||
const readinessSnapshot = readiness?.snapshot || readiness;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
type: item.type ?? null,
|
||
commandId: item.commandId ?? null,
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
message: limitText(item.error?.message ?? item.message ?? "", 240),
|
||
retry: item.error?.auth?.lastRetryLabel ?? null,
|
||
retryExhausted: item.error?.auth?.retryExhausted === true,
|
||
lastError: limitText(item.error?.auth?.lastError ?? "", 160),
|
||
attemptCount: attempts.length,
|
||
lastFailureKind: lastAttempt?.failureKind ?? null,
|
||
lastReadinessReason: readiness?.reason ?? null,
|
||
lastReadiness: readinessSnapshot ? {
|
||
reason: readiness?.reason ?? readinessSnapshot.reason ?? null,
|
||
path: readinessSnapshot.path ?? null,
|
||
readyState: readinessSnapshot.readyState ?? null,
|
||
workbenchShellVisible: readinessSnapshot.workbenchShellVisible === true,
|
||
sessionCreateVisible: readinessSnapshot.sessionCreateVisible === true,
|
||
commandInputPresent: readinessSnapshot.commandInputPresent === true,
|
||
activeTabPresent: readinessSnapshot.activeTabPresent === true,
|
||
warningPresent: readinessSnapshot.warningPresent === true,
|
||
loginVisible: readinessSnapshot.loginVisible === true,
|
||
bodyTextHash: readinessSnapshot.bodyTextHash ?? null,
|
||
valuesRedacted: true
|
||
} : null,
|
||
navigationAttempts: attempts.slice(-5).map((attempt) => ({
|
||
attempt: attempt?.attempt ?? null,
|
||
ok: attempt?.ok === true,
|
||
failureKind: attempt?.failureKind ?? null,
|
||
message: limitText(attempt?.message ?? "", 160),
|
||
readinessReason: attempt?.readiness?.reason ?? null,
|
||
valuesRedacted: true
|
||
})),
|
||
};
|
||
});
|
||
const commandFailures = summarizeCommandFailures(control);
|
||
const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures);
|
||
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
|
||
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest });
|
||
const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl }));
|
||
const report = {
|
||
ok: findings.filter((item) => item.severity === "red").length === 0,
|
||
command: "web-probe-observe analyze",
|
||
generatedAt: new Date().toISOString(),
|
||
stateDir,
|
||
jsonlScope: { mode: archivePrefix ? "archive" : "current", archivePrefix: archivePrefix || null, dataDir, analyzeTailSamples, sourceSampleCount: sourceSamples.length, effectiveSampleCount: samples.length, sourceControlCount: sourceControlAll.length, sampleWindow, focus: analysisFocus, valuesRedacted: true },
|
||
alertThresholds,
|
||
manifest: compactManifest(manifest),
|
||
heartbeat: compactHeartbeat(heartbeat),
|
||
counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length },
|
||
commandTimeline,
|
||
transitions,
|
||
sampleMetrics,
|
||
pageProvenance,
|
||
pagePerformance,
|
||
promptNetwork,
|
||
runtimeAlerts,
|
||
runnerErrors,
|
||
commandFailures,
|
||
findings,
|
||
windows: { recent: recentWindow },
|
||
readIssues: jsonlReadIssues,
|
||
artifactSummary: await artifactSummary(artifacts),
|
||
safety: { offlineOnly: true, browserDriven: false, apiFetch: false, valuesRedacted: true },
|
||
};
|
||
await writeFile(reportJsonPath, JSON.stringify(report, null, 2) + "\n", { mode: 0o600 });
|
||
await writeFile(reportMdPath, renderMarkdown(report), { mode: 0o600 });
|
||
const [jsonMeta, mdMeta] = await Promise.all([fileMeta(reportJsonPath), fileMeta(reportMdPath)]);
|
||
console.log(JSON.stringify({
|
||
ok: true,
|
||
command: "web-probe-observe analyze",
|
||
stateDir,
|
||
jsonlScope: report.jsonlScope,
|
||
reportJsonPath,
|
||
reportJsonSha256: jsonMeta.sha256,
|
||
reportMdPath,
|
||
reportMdSha256: mdMeta.sha256,
|
||
counts: report.counts,
|
||
archiveSummary: {
|
||
sampleMetrics: sampleMetrics.summary,
|
||
pagePerformance: pagePerformance.summary,
|
||
runtimeAlerts: runtimeAlerts.summary,
|
||
findingCount: findings.length,
|
||
redFindingCount: findings.filter((item) => item.severity === "red").length,
|
||
redFindings: prioritizeFindings(findings.filter((item) => item.severity === "red")).slice(0, 12).map((item) => ({ kind: item.id ?? item.kind ?? item.code, severity: item.severity, count: item.count ?? item.sampleCount ?? null, summary: String(item.summary ?? item.message ?? "").slice(0, 180) })),
|
||
},
|
||
analysisWindow: recentWindow.summary,
|
||
jsonlReadIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })),
|
||
readIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })),
|
||
sampleMetrics: {
|
||
...recentWindow.sampleMetrics.summary,
|
||
rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, loadingSamples: item.loadingSamples, maxLoadingCount: item.maxLoadingCount, loadingOwnerCount: item.loadingOwnerCount, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingTotalElapsedZeroResetCount: item.turnTimingTotalElapsedZeroResetCount, turnTimingTotalElapsedForwardJumpCount: item.turnTimingTotalElapsedForwardJumpCount, turnTimingTotalElapsedForwardJumpMaxSeconds: item.turnTimingTotalElapsedForwardJumpMaxSeconds, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })),
|
||
turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, pageRole: item.pageRole ?? null, pageId: item.pageId ?? null, pageEpoch: item.pageEpoch ?? null, promptIndex: item.promptIndex, lastPromptIndex: item.lastPromptIndex, firstSeq: item.firstSeq, lastSeq: item.lastSeq, traceId: item.traceId, messageId: item.messageId })),
|
||
loading: compactLoadingMetricsForOutput(recentWindow.sampleMetrics.loading),
|
||
sessionRailTitles: compactSessionRailTitleMetricsForOutput(recentWindow.sampleMetrics.sessionRailTitles),
|
||
},
|
||
pageProvenance: recentWindow.pageProvenance.summary,
|
||
pagePerformance: recentWindow.pagePerformance.summary,
|
||
promptNetwork: recentWindow.promptNetwork.summary,
|
||
runtimeAlerts: recentWindow.runtimeAlerts.summary,
|
||
runnerErrors,
|
||
commandFailures: commandFailures.slice(-8),
|
||
httpErrorGroups: recentWindow.runtimeAlerts.networkHttpErrorsByPath.slice(0, 8).map((item) => ({
|
||
method: item.method ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [],
|
||
})),
|
||
requestFailedGroups: recentWindow.runtimeAlerts.networkRequestFailedByPath.slice(0, 8).map((item) => ({
|
||
method: item.method ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [],
|
||
})),
|
||
domDiagnosticGroups: recentWindow.runtimeAlerts.domDiagnosticsByFingerprint.slice(0, 5).map((item) => ({
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt,
|
||
lastAt: item.lastAt,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [],
|
||
text: String(item.preview ?? item.normalizedPreview ?? item.text ?? "").slice(0, 160),
|
||
})),
|
||
domDiagnosticSamples: recentWindow.runtimeAlerts.domDiagnostics.slice(-8).map((item) => ({
|
||
seq: item.seq ?? null,
|
||
ts: item.ts ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
source: item.source ?? null,
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
traceId: item.traceId ?? null,
|
||
httpStatus: item.httpStatus ?? null,
|
||
idleSeconds: item.idleSeconds ?? null,
|
||
waitingFor: item.waitingFor ?? null,
|
||
lastEventLabel: item.lastEventLabel ?? null,
|
||
text: String(item.preview ?? item.text ?? "").slice(0, 180),
|
||
})),
|
||
consoleAlertGroups: recentWindow.runtimeAlerts.consoleAlertsByPath.slice(0, 8).map((item) => ({
|
||
type: item.type ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [],
|
||
})),
|
||
consoleAlertSamples: recentWindow.runtimeAlerts.consoleAlerts.slice(-8).map((item) => ({
|
||
ts: item.ts ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
type: item.type ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
traceId: item.traceId ?? null,
|
||
text: String(item.preview ?? item.text ?? "").slice(0, 180),
|
||
})),
|
||
turnTimingRecentUpdateJumps: recentWindow.sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
sampleDeltaSeconds: item.sampleDeltaSeconds ?? null,
|
||
allowedIncreaseSeconds: item.allowedIncreaseSeconds ?? null,
|
||
excessiveIncreaseSeconds: item.excessiveIncreaseSeconds ?? null,
|
||
})),
|
||
turnTimingElapsedZeroResets: recentWindow.sampleMetrics.turnTimingElapsedZeroResets.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
anomaly: item.anomaly ?? null,
|
||
})),
|
||
turnTimingTotalElapsedForwardJumps: recentWindow.sampleMetrics.turnTimingTotalElapsedForwardJumps.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
sampleDeltaSeconds: item.sampleDeltaSeconds ?? null,
|
||
allowedIncreaseSeconds: item.allowedIncreaseSeconds ?? null,
|
||
excessiveIncreaseSeconds: item.excessiveIncreaseSeconds ?? null,
|
||
anomaly: item.anomaly ?? null,
|
||
})),
|
||
pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
|
||
archivePagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 8).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
|
||
pagePerformancePartialApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, completeTimingSampleCount: item.completeTimingSampleCount, partialTimingSampleCount: item.partialTimingSampleCount, partialOverBudgetCount: item.partialOverBudgetCount, budgetMs: item.partialBudgetMs, partialOverFiveSecondCount: item.partialOverFiveSecondCount, partialSamples: item.partialSamples })),
|
||
pagePerformanceSseStreams: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream === true).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, streamOpenSampleCount: item.streamOpenSampleCount, streamOpenP95Ms: item.streamOpenP95Ms, streamOpenMaxMs: item.streamOpenMaxMs, streamOpenOverBudgetCount: item.streamOpenOverBudgetCount, streamOpenBudgetMs: item.streamOpenBudgetMs, streamOpenOverFiveSecondCount: item.streamOpenOverFiveSecondCount, streamLifetimeOverFiveSecondCount: item.streamLifetimeOverFiveSecondCount, slowSamples: item.slowSamples })),
|
||
findings: prioritizeFindings(recentWindow.findings).slice(0, 8).map((item) => ({ kind: item.id ?? item.kind ?? item.code, severity: item.severity, count: item.count ?? item.sampleCount ?? null, summary: String(item.summary ?? item.message ?? "").slice(0, 180) })),
|
||
traceOrderAnomalies: (recentWindow.sampleMetrics?.traceOrder?.orderAnomalies || []).slice(0, 8).map((item) => ({
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
reasons: item.reasons ?? [],
|
||
previousRowIndex: item.previousRowIndex ?? null,
|
||
currentRowIndex: item.currentRowIndex ?? null,
|
||
previousTotalSeconds: item.previousTotalSeconds ?? null,
|
||
currentTotalSeconds: item.currentTotalSeconds ?? null,
|
||
previousProjectedSeq: item.previousProjectedSeq ?? null,
|
||
currentProjectedSeq: item.currentProjectedSeq ?? null,
|
||
previousSourceSeq: item.previousSourceSeq ?? null,
|
||
currentSourceSeq: item.currentSourceSeq ?? null,
|
||
previousEventSeq: item.previousEventSeq ?? null,
|
||
currentEventSeq: item.currentEventSeq ?? null,
|
||
previousPreview: String(item.previousPreview || "").slice(0, 120),
|
||
currentPreview: String(item.currentPreview || "").slice(0, 120),
|
||
})),
|
||
traceCompletionNotLast: (recentWindow.sampleMetrics?.traceOrder?.completionNotLast || []).slice(0, 8).map((item) => ({
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
completionRowIndex: item.completionRowIndex ?? null,
|
||
laterRowIndex: item.laterRowIndex ?? null,
|
||
completionTotalSeconds: item.completionTotalSeconds ?? null,
|
||
laterTotalSeconds: item.laterTotalSeconds ?? null,
|
||
completionProjectedSeq: item.completionProjectedSeq ?? null,
|
||
laterProjectedSeq: item.laterProjectedSeq ?? null,
|
||
completionPreview: String(item.completionPreview || "").slice(0, 120),
|
||
laterPreview: String(item.laterPreview || "").slice(0, 120),
|
||
})),
|
||
roundCompletionElapsedMismatches: (recentWindow.sampleMetrics?.codeAgentCardTiming?.roundCompletion?.elapsedMismatches || []).slice(0, 8).map((item) => ({
|
||
seq: item.seq ?? null,
|
||
ts: item.ts ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
completionElapsedSeconds: item.completionElapsedSeconds ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
toleranceSeconds: item.toleranceSeconds ?? null,
|
||
})),
|
||
codeAgentCardDurationUnderreported: (recentWindow.sampleMetrics?.codeAgentCardTiming?.durationUnderreported || []).slice(0, 8).map((item) => ({
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
expectedElapsedSeconds: item.expectedElapsedSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
evidenceKind: item.evidenceKind ?? null,
|
||
evidencePreview: String(item.evidencePreview || "").slice(0, 120),
|
||
})),
|
||
codeAgentCardDurationMismatches: (recentWindow.sampleMetrics?.codeAgentCardTiming?.durationMismatches || []).slice(0, 8).map((item) => ({
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
direction: item.direction ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
expectedElapsedSeconds: item.expectedElapsedSeconds ?? null,
|
||
signedDeltaSeconds: item.signedDeltaSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
evidenceKind: item.evidenceKind ?? null,
|
||
exactEvidence: item.exactEvidence === true,
|
||
evidencePreview: String(item.evidencePreview || "").slice(0, 120),
|
||
})),
|
||
valuesRedacted: true,
|
||
}));
|
||
|
||
async function readJson(file) {
|
||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||
}
|
||
|
||
function safeArchivePrefix(value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return "";
|
||
if (!/^[A-Za-z0-9_.-]+$/u.test(text) || text.includes("..")) throw new Error("unsafe archive prefix: " + text);
|
||
return text;
|
||
}
|
||
|
||
function positiveNumber(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function requiredPositiveThreshold(raw, key) {
|
||
const parsed = Number(raw?.[key]);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseAlertThresholds(value) {
|
||
if (!value) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
|
||
if (sessionRailFallbackRatio > 1) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
|
||
}
|
||
return {
|
||
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
|
||
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
|
||
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
|
||
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
|
||
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
|
||
uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"),
|
||
scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"),
|
||
scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"),
|
||
scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"),
|
||
sessionRailFallbackRatio,
|
||
crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")),
|
||
source: "yaml-env",
|
||
};
|
||
}
|
||
|
||
async function readJsonl(file, options = {}) {
|
||
const tailLimit = Number.isFinite(Number(options.tail)) && Number(options.tail) > 0 ? Math.floor(Number(options.tail)) : 0;
|
||
if (tailLimit > 0) return readJsonlTail(file, tailLimit, options);
|
||
const rows = [];
|
||
try {
|
||
const input = createReadStream(file, { encoding: "utf8" });
|
||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||
let lineNo = 0;
|
||
for await (const rawLine of lines) {
|
||
lineNo += 1;
|
||
const line = String(rawLine || "").trim();
|
||
if (!line) continue;
|
||
try {
|
||
const parsed = JSON.parse(line);
|
||
rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed);
|
||
} catch (error) {
|
||
const item = { parseError: true, lineNo, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) };
|
||
rows.push(item);
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, rawHash: item.rawHash, errorMessage: item.errorMessage });
|
||
}
|
||
}
|
||
return rows;
|
||
} catch (error) {
|
||
if (error && error.code === "ENOENT") return [];
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function readJsonlTail(file, limit, options = {}) {
|
||
try {
|
||
const lines = await readTailLines(file, limit);
|
||
const rows = [];
|
||
let lineNo = 0;
|
||
for (const rawLine of lines) {
|
||
lineNo += 1;
|
||
const line = String(rawLine || "").trim();
|
||
if (!line) continue;
|
||
try {
|
||
const parsed = JSON.parse(line);
|
||
rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed);
|
||
} catch (error) {
|
||
const item = { parseError: true, lineNo, tail: true, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) };
|
||
rows.push(item);
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, tail: true, rawHash: item.rawHash, errorMessage: item.errorMessage });
|
||
}
|
||
}
|
||
return rows;
|
||
} catch (error) {
|
||
if (error && error.code === "ENOENT") return [];
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", tail: true, code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function readTailLines(file, limit) {
|
||
const info = await stat(file);
|
||
if (!info.size || limit <= 0) return [];
|
||
const chunkSize = 64 * 1024;
|
||
const maxBytes = Math.max(32 * 1024 * 1024, Math.min(256 * 1024 * 1024, limit * 512 * 1024));
|
||
const chunks = [];
|
||
let position = info.size;
|
||
let readBytes = 0;
|
||
let newlineCount = 0;
|
||
while (position > 0 && newlineCount <= limit && readBytes < maxBytes) {
|
||
const readSize = Math.min(chunkSize, position, maxBytes - readBytes);
|
||
position -= readSize;
|
||
const chunk = await readFileSlice(file, position, readSize);
|
||
chunks.unshift(chunk);
|
||
readBytes += chunk.length;
|
||
for (let index = 0; index < chunk.length; index += 1) {
|
||
if (chunk[index] === 10) newlineCount += 1;
|
||
}
|
||
}
|
||
if (position > 0 && newlineCount <= limit && jsonlReadIssues.length < 50) {
|
||
jsonlReadIssues.push({ file: path.basename(file), kind: "tail-scan-truncated", limit, readBytes, fileBytes: info.size });
|
||
}
|
||
const text = Buffer.concat(chunks).toString("utf8");
|
||
let lines = text.split(/\r?\n/u);
|
||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||
if (position > 0 && lines.length > 0) lines.shift();
|
||
return lines.slice(-limit);
|
||
}
|
||
|
||
async function readFileSlice(file, start, length) {
|
||
const chunks = [];
|
||
await new Promise((resolve, reject) => {
|
||
const stream = createReadStream(file, { start, end: start + length - 1 });
|
||
stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||
stream.on("error", reject);
|
||
stream.on("end", resolve);
|
||
});
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
function sampleTimeWindow(samples, paddingMs) {
|
||
const times = samples
|
||
.map((item) => Date.parse(String(item && item.ts || "")))
|
||
.filter((value) => Number.isFinite(value));
|
||
if (times.length === 0) return { startMs: null, endMs: null, startAt: null, endAt: null, paddingMs };
|
||
const startMs = Math.max(0, Math.min(...times) - Math.max(0, paddingMs || 0));
|
||
const endMs = Math.max(...times) + Math.max(0, paddingMs || 0);
|
||
return { startMs, endMs, startAt: new Date(startMs).toISOString(), endAt: new Date(endMs).toISOString(), paddingMs };
|
||
}
|
||
|
||
function filterRowsByTimeWindow(rows, window) {
|
||
if (!window || !Number.isFinite(window.startMs) || !Number.isFinite(window.endMs)) return rows;
|
||
return rows.filter((item) => {
|
||
const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt);
|
||
const ms = Date.parse(String(value || ""));
|
||
if (!Number.isFinite(ms)) return true;
|
||
return ms >= window.startMs && ms <= window.endMs;
|
||
});
|
||
}
|
||
|
||
function analysisFocusFromControl(control) {
|
||
const completedNewSessions = (Array.isArray(control) ? control : [])
|
||
.filter((item) => item?.type === "newSession" && item?.phase === "completed" && Number.isFinite(Date.parse(String(item.ts || ""))))
|
||
.sort((a, b) => Date.parse(String(a.ts || "")) - Date.parse(String(b.ts || "")));
|
||
const latest = completedNewSessions.at(-1) ?? null;
|
||
if (!latest) return { mode: "all", reason: "no-new-session-command", startAt: null, startMs: null, commandId: null, valuesRedacted: true };
|
||
const startMs = Date.parse(String(latest.ts));
|
||
return {
|
||
mode: "after-new-session",
|
||
reason: "latest-completed-new-session",
|
||
startAt: new Date(startMs).toISOString(),
|
||
startMs,
|
||
commandId: latest.commandId ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function applyAnalysisFocus(rows, focus, graceMs = 0) {
|
||
if (!focus || !Number.isFinite(focus.startMs)) return rows;
|
||
const minMs = focus.startMs - Math.max(0, Number(graceMs) || 0);
|
||
return (Array.isArray(rows) ? rows : []).filter((item) => {
|
||
const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt);
|
||
const ms = Date.parse(String(value || ""));
|
||
return Number.isFinite(ms) ? ms >= minMs : true;
|
||
});
|
||
}
|
||
|
||
function analysisControlWindow(sampleWindow, focus, graceMs = 0) {
|
||
if (!sampleWindow || !Number.isFinite(sampleWindow.endMs)) return sampleWindow;
|
||
if (!focus || !Number.isFinite(focus.startMs)) return sampleWindow;
|
||
const startMs = Math.max(0, Math.min(Number(sampleWindow.startMs ?? focus.startMs), focus.startMs - Math.max(0, Number(graceMs) || 0)));
|
||
return {
|
||
...sampleWindow,
|
||
startMs,
|
||
startAt: new Date(startMs).toISOString()
|
||
};
|
||
}
|
||
|
||
function compactSampleForAnalysis(sample) {
|
||
if (!sample || typeof sample !== "object") return sample;
|
||
return {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
reason: sample.reason ?? null,
|
||
sampleGroupSeq: sample.sampleGroupSeq ?? null,
|
||
pageId: sample.pageId ?? null,
|
||
pageRole: sample.pageRole ?? null,
|
||
commandId: sample.commandId ?? null,
|
||
observerInitiated: sample.observerInitiated ?? null,
|
||
url: sample.url ?? null,
|
||
path: sample.path ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
messages: compactDomItems(sample.messages),
|
||
traceRows: compactDomItems(sample.traceRows),
|
||
loadings: compactLoadingItems(sample.loadings),
|
||
sessionRail: compactSessionRail(sample.sessionRail),
|
||
turns: compactDomItems(sample.turns),
|
||
diagnostics: compactDomItems(sample.diagnostics),
|
||
pageProvenance: compactSamplePageProvenance(sample.pageProvenance),
|
||
performance: compactPerformanceItems(sample.performance)
|
||
};
|
||
}
|
||
|
||
function compactSessionRail(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const items = Array.isArray(value.items) ? value.items.slice(0, 80).map((item) => ({
|
||
index: item?.index ?? null,
|
||
tag: item?.tag ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
active: item?.active === true,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
fallbackTitle: item?.fallbackTitle === true,
|
||
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
|
||
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
|
||
titleBytes: item?.titleBytes ?? null,
|
||
})) : [];
|
||
const fallbackItems = Array.isArray(value.fallbackItems)
|
||
? value.fallbackItems.slice(0, 20).map((item) => ({
|
||
index: item?.index ?? null,
|
||
active: item?.active === true,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
|
||
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
|
||
}))
|
||
: items.filter((item) => item.fallbackTitle).slice(0, 20);
|
||
const visibleCount = Number(value.visibleCount ?? items.length);
|
||
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
|
||
return {
|
||
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
|
||
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
|
||
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
|
||
items,
|
||
fallbackItems,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactLoadingItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map((item) => {
|
||
if (!item || typeof item !== "object") return item;
|
||
const rawText = String(item.text ?? item.textPreview ?? "");
|
||
return {
|
||
index: item.index ?? null,
|
||
tag: item.tag ?? null,
|
||
testId: item.testId ?? null,
|
||
role: item.role ?? null,
|
||
ownerKind: item.ownerKind ?? null,
|
||
ownerKey: item.ownerKey ?? null,
|
||
ownerLabel: item.ownerLabel ?? null,
|
||
owner: item.owner && typeof item.owner === "object" ? {
|
||
tag: item.owner.tag ?? null,
|
||
testId: item.owner.testId ?? null,
|
||
role: item.owner.role ?? null,
|
||
id: item.owner.id ?? null,
|
||
className: item.owner.className ?? null,
|
||
status: item.owner.status ?? null,
|
||
sessionId: item.owner.sessionId ?? null,
|
||
messageId: item.owner.messageId ?? null,
|
||
traceId: item.owner.traceId ?? null,
|
||
ariaLabel: item.owner.ariaLabel ?? null,
|
||
} : null,
|
||
text: limitText(rawText, 400),
|
||
textPreview: limitText(String(item.textPreview ?? rawText), 240),
|
||
textHash: item.textHash ?? sha256(rawText),
|
||
textBytes: item.textBytes ?? Buffer.byteLength(rawText),
|
||
ownerTextHash: item.ownerTextHash ?? null,
|
||
ownerTextPreview: item.ownerTextPreview ? limitText(item.ownerTextPreview, 240) : null,
|
||
};
|
||
});
|
||
}
|
||
|
||
function compactDomItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map(compactDomItem);
|
||
}
|
||
|
||
function compactDomItem(item) {
|
||
if (!item || typeof item !== "object") return item;
|
||
const rawText = String(item.text ?? item.textPreview ?? "");
|
||
const preview = String(item.textPreview ?? limitText(rawText, 240));
|
||
return {
|
||
index: item.index ?? null,
|
||
tag: item.tag ?? null,
|
||
testId: item.testId ?? null,
|
||
role: item.role ?? null,
|
||
dataRole: item.dataRole ?? null,
|
||
status: item.status ?? null,
|
||
sessionId: item.sessionId ?? null,
|
||
messageId: item.messageId ?? null,
|
||
traceId: item.traceId ?? null,
|
||
turnId: item.turnId ?? null,
|
||
projectedSeq: Number.isFinite(Number(item.projectedSeq)) ? Number(item.projectedSeq) : null,
|
||
sourceSeq: Number.isFinite(Number(item.sourceSeq)) ? Number(item.sourceSeq) : null,
|
||
eventSeq: Number.isFinite(Number(item.eventSeq)) ? Number(item.eventSeq) : null,
|
||
eventTimestamp: item.eventTimestamp ?? null,
|
||
eventTimeText: item.eventTimeText ?? null,
|
||
eventKind: item.eventKind ?? null,
|
||
durationText: item.durationText ?? null,
|
||
activityText: item.activityText ?? null,
|
||
className: item.className ?? null,
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
source: item.source ?? null,
|
||
sources: Array.isArray(item.sources) ? item.sources.slice(0, 8) : undefined,
|
||
text: limitText(rawText, 4000),
|
||
textPreview: limitText(preview, 600),
|
||
textHash: item.textHash ?? sha256(rawText),
|
||
textBytes: item.textBytes ?? Buffer.byteLength(rawText)
|
||
};
|
||
}
|
||
|
||
function compactPerformanceItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map((item) => ({
|
||
name: item?.name ?? null,
|
||
initiatorType: item?.initiatorType ?? null,
|
||
startTime: item?.startTime ?? null,
|
||
duration: item?.duration ?? null,
|
||
workerStart: item?.workerStart ?? null,
|
||
redirectStart: item?.redirectStart ?? null,
|
||
redirectEnd: item?.redirectEnd ?? null,
|
||
fetchStart: item?.fetchStart ?? null,
|
||
domainLookupStart: item?.domainLookupStart ?? null,
|
||
domainLookupEnd: item?.domainLookupEnd ?? null,
|
||
connectStart: item?.connectStart ?? null,
|
||
connectEnd: item?.connectEnd ?? null,
|
||
secureConnectionStart: item?.secureConnectionStart ?? null,
|
||
requestStart: item?.requestStart ?? null,
|
||
responseStart: item?.responseStart ?? null,
|
||
responseEnd: item?.responseEnd ?? null,
|
||
transferSize: item?.transferSize ?? null,
|
||
encodedBodySize: item?.encodedBodySize ?? null,
|
||
decodedBodySize: item?.decodedBodySize ?? null,
|
||
nextHopProtocol: item?.nextHopProtocol ?? null
|
||
}));
|
||
}
|
||
|
||
function compactLoadingMetricsForOutput(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
summary: value.summary ?? null,
|
||
longestSegments: Array.isArray(value.segments) ? value.segments.slice(0, 8) : [],
|
||
owners: Array.isArray(value.owners) ? value.owners.slice(0, 8) : [],
|
||
timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactSessionRailTitleMetricsForOutput(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
summary: value.summary ?? null,
|
||
samples: Array.isArray(value.samples) ? value.samples.slice(0, 12) : [],
|
||
examples: Array.isArray(value.examples) ? value.examples.slice(0, 12) : [],
|
||
timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactSamplePageProvenance(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
pageLoadSeq: value.pageLoadSeq ?? null,
|
||
reason: value.reason ?? null,
|
||
observedAt: value.observedAt ?? null,
|
||
urlPath: value.urlPath ?? null,
|
||
documentReadyState: value.documentReadyState ?? null,
|
||
timeOrigin: value.timeOrigin ?? null,
|
||
httpStatus: value.httpStatus ?? null,
|
||
assetFingerprint: value.assetFingerprint ?? null,
|
||
scriptCount: value.scriptCount ?? null,
|
||
stylesheetCount: value.stylesheetCount ?? null,
|
||
metaCount: value.metaCount ?? null,
|
||
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 20) : [],
|
||
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 20) : [],
|
||
error: value.error ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function summarizeCommandFailures(control) {
|
||
return control.filter((item) => item?.phase === "failed").map((item) => {
|
||
const detail = item?.detail && typeof item.detail === "object" ? item.detail : {};
|
||
const error = detail?.error && typeof detail.error === "object" ? detail.error : detail;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
commandId: item.commandId ?? null,
|
||
type: item.type ?? item.input?.type ?? null,
|
||
source: item.source ?? null,
|
||
durationMs: detail.durationMs ?? null,
|
||
beforePath: urlPath(detail.beforeUrl || item.beforeUrl),
|
||
afterPath: urlPath(detail.afterUrl || item.afterUrl),
|
||
name: error?.name ?? null,
|
||
message: limitText(error?.message ?? detail?.message ?? "", 240),
|
||
failureKind: error?.failureKind ?? detail?.failureKind ?? null,
|
||
failureSampleOk: detail?.failureSample?.ok === true,
|
||
sampleSeq: detail?.failureSample?.sampleSeq ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = []) {
|
||
const findings = [];
|
||
if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) });
|
||
const commandTimes = control
|
||
.filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite);
|
||
const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean));
|
||
const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean));
|
||
if (routeSessions.size > 1) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed during observation", routeSessionCount: routeSessions.size, samples: sampleRefs(samples, (item) => item.routeSessionId) });
|
||
if (activeSessions.size > 1) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed during observation", activeSessionCount: activeSessions.size, samples: sampleRefs(samples, (item) => item.activeSessionId) });
|
||
const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId);
|
||
if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) });
|
||
const uncommandedChanges = [];
|
||
const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined));
|
||
const previousDigestByPage = new Map();
|
||
for (const sample of samples) {
|
||
const pageKey = samplePageKey(sample);
|
||
const previous = previousDigestByPage.get(pageKey);
|
||
const next = digestSample(sample);
|
||
if (previous && previous.digest !== next && !commandedPromptSeqs.has(sample?.seq) && !nearCommand(sample, commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) {
|
||
uncommandedChanges.push({
|
||
...ref(sample),
|
||
previousSeq: previous.sample?.seq ?? null,
|
||
previousTs: previous.sample?.timestamp ?? previous.sample?.ts ?? null,
|
||
fromDigest: previous.digest,
|
||
toDigest: next,
|
||
fromMessageCount: previous.sample?.messageCount ?? previous.sample?.messages?.length ?? null,
|
||
toMessageCount: sample?.messageCount ?? sample?.messages?.length ?? null,
|
||
fromTraceRowCount: Array.isArray(previous.sample?.traceRows) ? previous.sample.traceRows.length : previous.sample?.traceRowCount ?? null,
|
||
toTraceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : sample?.traceRowCount ?? null,
|
||
fromPath: previous.sample?.path ?? previous.sample?.urlPath ?? null,
|
||
toPath: sample?.path ?? sample?.urlPath ?? null,
|
||
detail: "visible digest changed without nearby command",
|
||
});
|
||
}
|
||
previousDigestByPage.set(pageKey, { digest: next, sample });
|
||
}
|
||
if (uncommandedChanges.length > 0) findings.push({ id: "uncommanded-visible-state-change", severity: "amber", summary: "visible message/trace digest changed without a nearby command", count: uncommandedChanges.length, samples: uncommandedChanges.slice(0, 20) });
|
||
const finalFlicker = detectFinalFlicker(samples);
|
||
if (finalFlicker.length > 0) findings.push({ id: "final-response-flicker", severity: "red", summary: "message text digest disappeared or switched to diagnostic-like text after non-empty final text", count: finalFlicker.length, samples: finalFlicker.slice(0, 20) });
|
||
const terminalZeroElapsed = detectTerminalZeroElapsed(samples);
|
||
if (terminalZeroElapsed.length > 0) findings.push({ id: "turn-terminal-zero-elapsed", severity: "red", summary: "terminal Code Agent card displayed 耗时 0 秒; terminal duration must come from durable timing projection, not a missing/zero display fallback", count: terminalZeroElapsed.length, samples: terminalZeroElapsed.slice(0, 20) });
|
||
const cardTiming = sampleMetrics?.codeAgentCardTiming || {};
|
||
const cardTimingSummary = cardTiming.summary || {};
|
||
if (Number(cardTimingSummary.missingElapsedCount ?? 0) > 0) findings.push({ id: "code-agent-card-elapsed-missing", severity: "red", summary: "visible Code Agent card did not display total elapsed time; elapsed must be visible for running and terminal cards", count: cardTimingSummary.missingElapsedCount, samples: (cardTiming.missingElapsed || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.missingRecentUpdateCount ?? 0) > 0) findings.push({ id: "code-agent-card-running-recent-update-missing", severity: "red", summary: "non-terminal Code Agent card did not display 最近更新", count: cardTimingSummary.missingRecentUpdateCount, samples: (cardTiming.missingRecentUpdate || []).slice(0, 20) });
|
||
const roundCompletion = cardTiming.roundCompletion || {};
|
||
if (Number(cardTimingSummary.durationUnderreportedCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "code-agent-card-duration-underreported",
|
||
severity: "red",
|
||
summary: "completed Code Agent card total elapsed is shorter than trace/final-response duration evidence",
|
||
count: cardTimingSummary.durationUnderreportedCount,
|
||
samples: (cardTiming.durationUnderreported || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(cardTimingSummary.durationMismatchCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "code-agent-card-duration-mismatch",
|
||
severity: "red",
|
||
summary: "completed Code Agent card total elapsed does not match sealed completion/final-response timing evidence",
|
||
count: cardTimingSummary.durationMismatchCount,
|
||
samples: (cardTiming.durationMismatches || []).slice(0, 20),
|
||
});
|
||
}
|
||
const traceOrder = sampleMetrics?.traceOrder || {};
|
||
const traceOrderSummary = traceOrder.summary || {};
|
||
if (Number(traceOrderSummary.orderAnomalyCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "trace-row-order-nonmonotonic",
|
||
severity: "red",
|
||
summary: "visible trace rows are not monotonic by total time, clock time, or projected sequence in DOM order",
|
||
count: traceOrderSummary.orderAnomalyCount,
|
||
samples: (traceOrder.orderAnomalies || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(traceOrderSummary.completionNotLastCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "trace-completion-row-not-last",
|
||
severity: "red",
|
||
summary: "visible trace shows a completion row before later trace rows for the same trace",
|
||
count: traceOrderSummary.completionNotLastCount,
|
||
samples: (traceOrder.completionNotLast || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(cardTimingSummary.roundCompletionElapsedMismatchCount ?? 0) > 0) findings.push({ id: "round-completion-elapsed-mismatch", severity: "red", summary: "Trace row 轮次完成(总耗时 ...) does not match the visible Code Agent card total elapsed time within YAML timing slack", count: cardTimingSummary.roundCompletionElapsedMismatchCount, toleranceSeconds: cardTimingSummary.elapsedMismatchToleranceSeconds, samples: (roundCompletion.elapsedMismatches || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionFinalResponseMissingCount ?? 0) > 0) findings.push({ id: "round-completion-final-response-missing", severity: "red", summary: "Trace row showed 轮次完成, but no final response was visible in the Code Agent card afterward", count: cardTimingSummary.roundCompletionFinalResponseMissingCount, samples: (roundCompletion.finalResponseMissing || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionPostTimingChangeCount ?? 0) > 0) findings.push({ id: "round-completion-post-timing-change", severity: "red", summary: "After 轮次完成, card total elapsed or 最近更新 continued changing; terminal timing should be sealed", count: cardTimingSummary.roundCompletionPostTimingChangeCount, samples: (roundCompletion.postCompletionTimingChanges || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount ?? 0) > 0) findings.push({ id: "round-completion-recent-update-still-visible", severity: "info", summary: "最近更新 was still visible after 轮次完成; inspect whether terminal cards should hide activity age or keep it sealed", count: cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount, samples: (roundCompletion.postCompletionRecentUpdateVisible || []).slice(0, 20) });
|
||
const scrollJumps = [];
|
||
for (let i = 1; i < samples.length; i += 1) {
|
||
const prevY = Number(samples[i - 1]?.scroll?.y ?? 0);
|
||
const nextY = Number(samples[i]?.scroll?.y ?? 0);
|
||
if (prevY > alertThresholds.scrollJumpFromY && nextY < alertThresholds.scrollJumpToY && !nearCommand(samples[i], commandTimes, alertThresholds.scrollJumpCommandWindowMs)) scrollJumps.push({ from: ref(samples[i - 1]), to: ref(samples[i]) });
|
||
}
|
||
if (scrollJumps.length > 0) findings.push({ id: "scroll-jump-top", severity: "amber", summary: "scroll position jumped near top without nearby command", count: scrollJumps.length, samples: scrollJumps.slice(0, 10) });
|
||
const traceTerminal = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.some((row) => isTerminalTraceText((row.status || "") + " " + (row.textPreview || ""))));
|
||
const traceSeen = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0);
|
||
if (traceSeen && !traceTerminal) findings.push({ id: "trace-without-terminal", severity: "amber", summary: "trace rows were visible but no terminal status was sampled", firstTraceSample: ref(samples.find((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0)) });
|
||
const 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 or /v1/agent/chat/steer POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) });
|
||
const promptSteerRounds = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.steerUsed === true) : [];
|
||
if (promptSteerRounds.length > 0) findings.push({ id: "prompt-routed-to-steer", severity: "amber", summary: "sendPrompt was submitted through /v1/agent/chat/steer; verify the previous turn was truly in-flight and not an unsealed terminal failure", count: promptSteerRounds.length, rounds: promptSteerRounds.slice(0, 10) });
|
||
const elapsedZeroResets = Array.isArray(sampleMetrics?.turnTimingElapsedZeroResets) ? sampleMetrics.turnTimingElapsedZeroResets : [];
|
||
if (elapsedZeroResets.length > 0) findings.push({ id: "turn-timing-total-elapsed-zero-reset", severity: "red", summary: "Code Agent total elapsed jumped from a non-zero value back to 0 seconds", count: elapsedZeroResets.length, samples: elapsedZeroResets.slice(0, 20) });
|
||
const elapsedDecreases = Array.isArray(sampleMetrics?.turnTimingNonMonotonic)
|
||
? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds" && item.anomaly !== "zero-reset")
|
||
: [];
|
||
if (elapsedDecreases.length > 0) findings.push({ id: "turn-timing-total-elapsed-decrease", severity: "red", summary: "Code Agent total elapsed decreased between adjacent samples; total elapsed must be monotonic per turn", count: elapsedDecreases.length, samples: elapsedDecreases.slice(0, 20) });
|
||
const elapsedForwardJumps = Array.isArray(sampleMetrics?.turnTimingTotalElapsedForwardJumps) ? sampleMetrics.turnTimingTotalElapsedForwardJumps : [];
|
||
if (elapsedForwardJumps.length > 0) findings.push({ id: "turn-timing-total-elapsed-forward-jump", severity: "red", summary: "Code Agent total elapsed jumped forward faster than browser sample interval", count: elapsedForwardJumps.length, samples: elapsedForwardJumps.slice(0, 20) });
|
||
const terminalElapsedGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : [];
|
||
if (terminalElapsedGrowth.length > 0) findings.push({ id: "turn-timing-terminal-elapsed-growth", severity: "red", summary: "terminal Code Agent card total elapsed changed after terminal status; completed/failed/canceled timing must be sealed", count: terminalElapsedGrowth.length, samples: terminalElapsedGrowth.slice(0, 20) });
|
||
const recentUpdateSawtoothJumps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateSawtoothJumps)
|
||
? sampleMetrics.turnTimingRecentUpdateSawtoothJumps
|
||
: Array.isArray(sampleMetrics?.turnTimingNonMonotonic)
|
||
? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump")
|
||
: [];
|
||
if (recentUpdateSawtoothJumps.length > 0) findings.push({ id: "turn-timing-recent-update-sawtooth-jump", severity: "amber", summary: "最近更新 value jumped faster than sample interval; expected sawtooth increase-or-reset", count: recentUpdateSawtoothJumps.length, samples: recentUpdateSawtoothJumps.slice(0, 20) });
|
||
const loadingSummary = sampleMetrics?.loading?.summary || {};
|
||
const visibleLoadingSlowSeconds = alertThresholds.visibleLoadingSlowMs / 1000;
|
||
if (Number(loadingSummary.longestContinuousSeconds ?? 0) > visibleLoadingSlowSeconds) findings.push({ id: "page-loading-visible-over-budget", severity: "red", summary: "visible 加载中 stayed on screen longer than configured YAML budget; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overBudgetSegmentCount ?? loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, budgetSeconds: visibleLoadingSlowSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) });
|
||
if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) });
|
||
const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {};
|
||
if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ id: "session-rail-title-fallback-over-threshold", severity: "red", summary: "visible session list rows exceeded configured YAML fallback-title ratio", count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, thresholdRatio: alertThresholds.sessionRailFallbackRatio, maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), examples: sampleMetrics.sessionRailTitles.examples.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?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) });
|
||
if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) });
|
||
if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) });
|
||
if ((runtimeAlerts?.summary?.significantConsoleAlertCount ?? runtimeAlerts?.summary?.consoleAlertCount ?? 0) > 0) findings.push({ id: "runtime-console-alerts", severity: "amber", summary: "browser console warning/error entries were captured during observation", count: runtimeAlerts.summary.significantConsoleAlertCount ?? runtimeAlerts.summary.consoleAlertCount, groups: (runtimeAlerts.significantConsoleAlertsByPath ?? runtimeAlerts.consoleAlertsByPath).slice(0, 12) });
|
||
const crossPageDiffs = mergeCrossPageDiffRows(
|
||
detectCrossPageProjectionDiffs(samples),
|
||
detectAdjacentCrossPageProjectionDiffs(samples)
|
||
);
|
||
const crossPageProjectionDiffs = crossPageDiffs.filter((item) => item.diffKind !== "trace-visibility");
|
||
const crossPageTraceVisibilityDiffs = crossPageDiffs.filter((item) => item.diffKind === "trace-visibility");
|
||
const crossPageProjectionBudgetMs = alertThresholds.crossPageProjectionDivergenceRedMs;
|
||
const timedCrossPageProjectionDiffs = annotateCrossPageDiffTiming(crossPageProjectionDiffs);
|
||
const persistentCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs);
|
||
const transientCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs);
|
||
if (persistentCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-divergence", severity: "red", summary: "control and observer pages saw different projection state for the same sampled session beyond the configured budget", count: persistentCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: persistentCrossPageProjectionDiffs.slice(0, 20) });
|
||
if (transientCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-transient-divergence", severity: "info", summary: "control and observer pages briefly differed near a sampled transition; retained as transient evidence but not treated as persistent projection failure", count: transientCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: transientCrossPageProjectionDiffs.slice(0, 20) });
|
||
if (crossPageTraceVisibilityDiffs.length > 0) findings.push({ id: "cross-page-trace-visibility-divergence", severity: "info", summary: "control and observer pages differed only in visible trace row count; this is local disclosure/hydration visibility, not session/message projection divergence", count: crossPageTraceVisibilityDiffs.length, samples: crossPageTraceVisibilityDiffs.slice(0, 20) });
|
||
const traceMessageDuplicates = detectTraceMessageDuplication(samples);
|
||
if (traceMessageDuplicates.length > 0) findings.push({ id: "trace-assistant-message-duplicates-final-response", severity: "amber", summary: "trace rendered assistant message rows that duplicate the sealed final response", count: traceMessageDuplicates.length, samples: traceMessageDuplicates.slice(0, 20) });
|
||
const turnTraceMissing = detectTurnTraceIdMissing(samples);
|
||
if (turnTraceMissing.length > 0) findings.push({ id: "turn-trace-id-missing", severity: "red", summary: "Code Agent turn/card was visible without a trace id, so historical trace hydration cannot be reliable", count: turnTraceMissing.length, samples: turnTraceMissing.slice(0, 20) });
|
||
const pagePerformanceItems = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath : [];
|
||
const slowApi = pagePerformanceItems.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
|
||
if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded configured YAML usability budget", count: slowApi.length, budgetMs: alertThresholds.sameOriginApiSlowMs, groups: slowApi.slice(0, 20) });
|
||
const longLivedStreams = pagePerformanceItems.filter((item) => item.isLongLivedStream);
|
||
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) });
|
||
if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) });
|
||
if ((pageProvenance?.summary?.segmentCount ?? 0) > 1) findings.push({ id: "page-provenance-segments", severity: "info", summary: "observer crossed page asset provenance segments; interpret runtime findings by segment", segmentCount: pageProvenance.summary.segmentCount, segments: pageProvenance.segments.slice(0, 20) });
|
||
const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || "")));
|
||
findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for manual lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length });
|
||
if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) });
|
||
if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" });
|
||
return findings;
|
||
}
|
||
|
||
function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest }) {
|
||
const latestSampleMs = latestTimestampMs(samples);
|
||
const windowMs = 5 * 60 * 1000;
|
||
const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY;
|
||
const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY;
|
||
const inWindow = (item) => {
|
||
const tsMs = Date.parse(item?.ts);
|
||
return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs;
|
||
};
|
||
const windowSamples = samples.filter(inWindow);
|
||
const windowControl = control.filter(inWindow);
|
||
const windowNetwork = network.filter(inWindow);
|
||
const windowConsole = consoleEvents.filter(inWindow);
|
||
const windowErrors = errors.filter(inWindow);
|
||
const sampleMetrics = buildSampleMetrics(windowSamples, control);
|
||
const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest);
|
||
const pagePerformance = buildPagePerformanceReport(windowSamples, manifest);
|
||
const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork);
|
||
const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors);
|
||
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance);
|
||
return {
|
||
summary: {
|
||
name: "recent-5m",
|
||
windowMs,
|
||
fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null,
|
||
toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null,
|
||
samples: windowSamples.length,
|
||
control: windowControl.length,
|
||
network: windowNetwork.length,
|
||
console: windowConsole.length,
|
||
errors: windowErrors.length,
|
||
valuesRedacted: true
|
||
},
|
||
sampleMetrics,
|
||
pageProvenance,
|
||
pagePerformance,
|
||
promptNetwork,
|
||
runtimeAlerts,
|
||
findings,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function latestTimestampMs(items) {
|
||
let latest = Number.NEGATIVE_INFINITY;
|
||
for (const item of items || []) {
|
||
const tsMs = Date.parse(item?.ts);
|
||
if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs;
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
function buildPageProvenanceReport(samples, control, manifest) {
|
||
const groups = new Map();
|
||
for (const sample of samples) {
|
||
const provenance = sample?.pageProvenance;
|
||
if (!provenance) continue;
|
||
const key = provenance.assetFingerprint || "unknown";
|
||
const group = groups.get(key) || {
|
||
assetFingerprint: provenance.assetFingerprint || null,
|
||
pageLoadSeqs: [],
|
||
sampleCount: 0,
|
||
firstSeq: sample.seq ?? null,
|
||
lastSeq: sample.seq ?? null,
|
||
firstAt: sample.ts ?? null,
|
||
lastAt: sample.ts ?? null,
|
||
urlPaths: [],
|
||
scriptCount: provenance.scriptCount ?? null,
|
||
stylesheetCount: provenance.stylesheetCount ?? null,
|
||
metaCount: provenance.metaCount ?? null,
|
||
scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [],
|
||
stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
group.sampleCount += 1;
|
||
group.lastSeq = sample.seq ?? null;
|
||
group.lastAt = sample.ts ?? null;
|
||
if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq);
|
||
if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath);
|
||
groups.set(key, group);
|
||
}
|
||
const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0));
|
||
const controlSegments = control
|
||
.filter((item) => item.type === "page-provenance" || item?.pageProvenance)
|
||
.map((item) => ({
|
||
ts: item.ts ?? null,
|
||
reason: item.reason ?? item.detail?.reason ?? null,
|
||
httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null,
|
||
pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null,
|
||
}))
|
||
.slice(0, 80);
|
||
return {
|
||
summary: {
|
||
segmentCount: segments.length,
|
||
sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null,
|
||
controlSegmentCount: controlSegments.length
|
||
},
|
||
segments,
|
||
controlSegments,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function buildPagePerformanceReport(samples, manifest) {
|
||
const base = manifest?.baseUrl || "http://invalid.local";
|
||
const seen = new Set();
|
||
const groups = new Map();
|
||
const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite);
|
||
const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null;
|
||
const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null;
|
||
for (const sample of samples) {
|
||
const entries = Array.isArray(sample?.performance) ? sample.performance : [];
|
||
for (const entry of entries) {
|
||
const durationMs = Number(entry?.duration);
|
||
if (!Number.isFinite(durationMs) || durationMs < 0) continue;
|
||
const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry);
|
||
if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue;
|
||
if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue;
|
||
const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString();
|
||
const parsed = parsePerformanceUrl(entry?.name, base);
|
||
if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue;
|
||
const normalizedPath = normalizeApiPath(parsed.path);
|
||
const routeKind = classifyApiPerformanceRoute(normalizedPath, entry);
|
||
const isLongLivedStream = routeKind === "same-origin-api-stream";
|
||
const streamOpenMs = streamOpenLatencyMs(entry);
|
||
const timingStatus = resourceTimingPhaseStatus(entry);
|
||
const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|");
|
||
if (seen.has(dedupeKey)) continue;
|
||
seen.add(dedupeKey);
|
||
const group = groups.get(normalizedPath) || {
|
||
routeKind,
|
||
path: normalizedPath,
|
||
isLongLivedStream,
|
||
budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs",
|
||
rawPathSamples: [],
|
||
sampleCount: 0,
|
||
completeTimingSampleCount: 0,
|
||
partialTimingSampleCount: 0,
|
||
durationsMs: [],
|
||
streamOpenDurationsMs: [],
|
||
overFiveSecondCount: 0,
|
||
overBudgetCount: 0,
|
||
partialOverFiveSecondCount: 0,
|
||
partialOverBudgetCount: 0,
|
||
streamLifetimeOverFiveSecondCount: 0,
|
||
streamOpenOverFiveSecondCount: 0,
|
||
streamOpenOverBudgetCount: 0,
|
||
firstAt: entryTs,
|
||
lastAt: entryTs,
|
||
firstSeq: sample.seq ?? null,
|
||
lastSeq: sample.seq ?? null,
|
||
initiatorTypes: [],
|
||
pageAssetFingerprints: [],
|
||
slowSamples: [],
|
||
partialSamples: [],
|
||
valuesRedacted: true
|
||
};
|
||
group.sampleCount += 1;
|
||
const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete";
|
||
let overBudget = false;
|
||
if (partialOrdinaryTiming) {
|
||
group.partialTimingSampleCount += 1;
|
||
if (durationMs > 5000) group.partialOverFiveSecondCount += 1;
|
||
if (durationMs > alertThresholds.partialApiSlowMs) {
|
||
group.partialOverBudgetCount += 1;
|
||
if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
||
}
|
||
} else {
|
||
group.completeTimingSampleCount += 1;
|
||
group.durationsMs.push(durationMs);
|
||
if (isLongLivedStream) {
|
||
if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1;
|
||
if (streamOpenMs !== null) {
|
||
group.streamOpenDurationsMs.push(streamOpenMs);
|
||
if (streamOpenMs > 5000) {
|
||
group.streamOpenOverFiveSecondCount += 1;
|
||
group.overFiveSecondCount += 1;
|
||
}
|
||
if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) {
|
||
group.streamOpenOverBudgetCount += 1;
|
||
group.overBudgetCount += 1;
|
||
overBudget = true;
|
||
}
|
||
}
|
||
} else {
|
||
if (durationMs > 5000) group.overFiveSecondCount += 1;
|
||
if (durationMs > alertThresholds.sameOriginApiSlowMs) {
|
||
group.overBudgetCount += 1;
|
||
overBudget = true;
|
||
}
|
||
}
|
||
}
|
||
if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
||
group.lastAt = entryTs;
|
||
group.lastSeq = sample.seq ?? null;
|
||
if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path);
|
||
if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType);
|
||
const assetFingerprint = sample?.pageProvenance?.assetFingerprint;
|
||
if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint);
|
||
groups.set(normalizedPath, group);
|
||
}
|
||
}
|
||
const sameOriginApiByPath = Array.from(groups.values()).map((group) => {
|
||
const durations = group.durationsMs.slice().sort((a, b) => a - b);
|
||
const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b);
|
||
return {
|
||
routeKind: group.routeKind,
|
||
path: group.path,
|
||
isLongLivedStream: group.isLongLivedStream === true,
|
||
budgetMetric: group.budgetMetric,
|
||
sampleCount: group.sampleCount,
|
||
budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs,
|
||
partialBudgetMs: alertThresholds.partialApiSlowMs,
|
||
streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs,
|
||
completeTimingSampleCount: group.completeTimingSampleCount,
|
||
partialTimingSampleCount: group.partialTimingSampleCount,
|
||
p50Ms: percentile(durations, 50),
|
||
p75Ms: percentile(durations, 75),
|
||
p95Ms: percentile(durations, 95),
|
||
maxMs: durations.length > 0 ? durations[durations.length - 1] : null,
|
||
streamOpenSampleCount: streamOpenDurations.length,
|
||
streamOpenP50Ms: percentile(streamOpenDurations, 50),
|
||
streamOpenP75Ms: percentile(streamOpenDurations, 75),
|
||
streamOpenP95Ms: percentile(streamOpenDurations, 95),
|
||
streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null,
|
||
streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount,
|
||
streamOpenOverBudgetCount: group.streamOpenOverBudgetCount,
|
||
streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount,
|
||
overFiveSecondCount: group.overFiveSecondCount,
|
||
overBudgetCount: group.overBudgetCount,
|
||
partialOverFiveSecondCount: group.partialOverFiveSecondCount,
|
||
partialOverBudgetCount: group.partialOverBudgetCount,
|
||
overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0,
|
||
overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0,
|
||
firstAt: group.firstAt,
|
||
lastAt: group.lastAt,
|
||
firstSeq: group.firstSeq,
|
||
lastSeq: group.lastSeq,
|
||
initiatorTypes: group.initiatorTypes,
|
||
rawPathSamples: group.rawPathSamples.slice(0, 8),
|
||
pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8),
|
||
slowSamples: group.slowSamples
|
||
.slice()
|
||
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
||
.slice(0, 12),
|
||
partialSamples: group.partialSamples
|
||
.slice()
|
||
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
||
.slice(0, 12),
|
||
valuesRedacted: true
|
||
};
|
||
}).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path));
|
||
const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream);
|
||
const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true);
|
||
const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
|
||
const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0);
|
||
const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0);
|
||
const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0);
|
||
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
const budgetP95Values = sameOriginApiByPath
|
||
.map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0)))
|
||
.filter((value) => Number.isFinite(value));
|
||
return {
|
||
summary: {
|
||
budgetMs: alertThresholds.sameOriginApiSlowMs,
|
||
alertThresholds,
|
||
sameOriginApiPathCount: sameOriginApiByPath.length,
|
||
sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
longLivedStreamPathCount: longLivedStreams.length,
|
||
longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length,
|
||
longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0),
|
||
longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length,
|
||
longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0),
|
||
longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0),
|
||
slowPathCount: slow.length,
|
||
slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0),
|
||
overFiveSecondPathCount: slowFiveSecond.length,
|
||
overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0),
|
||
partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0),
|
||
partialOverFiveSecondPathCount: partialFiveSecond.length,
|
||
partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0),
|
||
partialOverBudgetPathCount: partialSlow.length,
|
||
partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0),
|
||
worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null,
|
||
valuesRedacted: true
|
||
},
|
||
sameOriginApiByPath,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function performanceEntryCompletedEpochMs(sample, entry) {
|
||
const origin = Number(sample?.pageProvenance?.timeOrigin);
|
||
const responseEnd = Number(entry?.responseEnd);
|
||
const startTime = Number(entry?.startTime);
|
||
const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime;
|
||
if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset);
|
||
const sampleTs = Date.parse(sample?.ts || "");
|
||
return Number.isFinite(sampleTs) ? sampleTs : null;
|
||
}
|
||
|
||
function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) {
|
||
const timingStatus = resourceTimingPhaseStatus(entry);
|
||
const serverTiming = compactServerTiming(entry?.serverTiming);
|
||
return {
|
||
ts: entryTs ?? sample?.ts ?? null,
|
||
sampleTs: sample?.ts ?? null,
|
||
seq: sample?.seq ?? null,
|
||
path: normalizedPath ?? null,
|
||
rawPath: rawPath ?? null,
|
||
initiatorType: entry?.initiatorType ?? null,
|
||
durationMs: roundFinite(durationMs),
|
||
startTimeMs: roundFinite(entry?.startTime),
|
||
fetchStartMs: roundFinite(entry?.fetchStart),
|
||
requestStartMs: roundFinite(entry?.requestStart),
|
||
responseStartMs: roundFinite(entry?.responseStart),
|
||
responseEndMs: roundFinite(entry?.responseEnd),
|
||
streamOpenMs: roundFinite(streamOpenMs),
|
||
dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"),
|
||
tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"),
|
||
tlsStartMs: roundFinite(entry?.secureConnectionStart),
|
||
requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"),
|
||
responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"),
|
||
timingStatus: timingStatus.status,
|
||
invalidTimingPhases: timingStatus.invalidPhases,
|
||
partialTimingPhases: timingStatus.partialPhases,
|
||
transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null,
|
||
encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
||
decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
||
nextHopProtocol: entry?.nextHopProtocol ?? null,
|
||
serverTiming,
|
||
serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8),
|
||
otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function phaseDeltaMs(entry, endKey, startKey) {
|
||
const end = Number(entry?.[endKey]);
|
||
const start = Number(entry?.[startKey]);
|
||
if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null;
|
||
return Math.round(end - start);
|
||
}
|
||
|
||
function resourceTimingPhaseStatus(entry) {
|
||
const pairs = [
|
||
["requestToResponseStart", "requestStart", "responseStart"],
|
||
["responseTransfer", "responseStart", "responseEnd"],
|
||
];
|
||
const invalidPhases = [];
|
||
const partialPhases = [];
|
||
for (const [label, startKey, endKey] of pairs) {
|
||
const start = Number(entry?.[startKey]);
|
||
const end = Number(entry?.[endKey]);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
|
||
partialPhases.push(label);
|
||
} else if (end < start) {
|
||
invalidPhases.push(label);
|
||
}
|
||
}
|
||
return {
|
||
status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"),
|
||
invalidPhases,
|
||
partialPhases,
|
||
};
|
||
}
|
||
|
||
function compactServerTiming(value) {
|
||
const items = Array.isArray(value) ? value : [];
|
||
return items.slice(0, 8).map((item) => ({
|
||
name: truncate(String(item?.name || ""), 80),
|
||
duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null,
|
||
description: truncate(String(item?.description || ""), 120),
|
||
})).filter((item) => item.name || item.description || item.duration !== null);
|
||
}
|
||
|
||
function extractOtelTraceIdFromServerTiming(items) {
|
||
const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" ");
|
||
const match = text.match(/\b[0-9a-f]{32}\b/iu);
|
||
return match ? match[0].toLowerCase() : null;
|
||
}
|
||
|
||
function roundFinite(value) {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? Math.round(numeric) : null;
|
||
}
|
||
|
||
function classifyApiPerformanceRoute(normalizedPath, entry = {}) {
|
||
if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream";
|
||
if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream";
|
||
return "same-origin-api";
|
||
}
|
||
|
||
function streamOpenLatencyMs(entry = {}) {
|
||
const responseStart = Number(entry?.responseStart);
|
||
const startTime = Number(entry?.startTime);
|
||
if (!Number.isFinite(responseStart) || responseStart <= 0) return null;
|
||
if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart);
|
||
if (responseStart < startTime) return null;
|
||
return Math.max(0, responseStart - startTime);
|
||
}
|
||
|
||
function parsePerformanceUrl(value, base) {
|
||
try {
|
||
const url = new URL(String(value || ""), base);
|
||
const origin = new URL(String(base || "http://invalid.local")).origin;
|
||
return { sameOrigin: url.origin === origin, path: url.pathname };
|
||
} catch {
|
||
return { sameOrigin: false, path: "-" };
|
||
}
|
||
}
|
||
|
||
function isApiLikePath(path) {
|
||
return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || ""));
|
||
}
|
||
|
||
function normalizeApiPath(path) {
|
||
return String(path || "-")
|
||
.replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id")
|
||
.replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId")
|
||
.replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId")
|
||
.replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id")
|
||
.replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id"));
|
||
}
|
||
|
||
function percentile(sortedValues, percentileValue) {
|
||
if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null;
|
||
if (sortedValues.length === 1) return Math.round(sortedValues[0]);
|
||
const rank = (percentileValue / 100) * (sortedValues.length - 1);
|
||
const lower = Math.floor(rank);
|
||
const upper = Math.ceil(rank);
|
||
if (lower === upper) return Math.round(sortedValues[lower]);
|
||
const weight = rank - lower;
|
||
return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight);
|
||
}
|
||
|
||
function buildPromptNetworkReport(control, network) {
|
||
const promptsById = new Map();
|
||
for (const item of control) {
|
||
if (item?.type !== "sendPrompt" || !item.commandId) continue;
|
||
const existing = promptsById.get(item.commandId) || {
|
||
commandId: item.commandId,
|
||
promptIndex: promptsById.size + 1,
|
||
promptTextHash: item.input?.textHash ?? null,
|
||
promptTextBytes: item.input?.textBytes ?? null,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
failedAt: null,
|
||
phase: null
|
||
};
|
||
if (!existing.promptTextHash && item.input?.textHash) existing.promptTextHash = item.input.textHash;
|
||
if (!existing.promptTextBytes && item.input?.textBytes) existing.promptTextBytes = item.input.textBytes;
|
||
if (item.phase === "started") existing.startedAt = item.ts ?? existing.startedAt;
|
||
if (item.phase === "completed") existing.completedAt = item.ts ?? existing.completedAt;
|
||
if (item.phase === "failed") existing.failedAt = item.ts ?? existing.failedAt;
|
||
existing.phase = item.phase ?? existing.phase;
|
||
promptsById.set(item.commandId, existing);
|
||
}
|
||
const prompts = Array.from(promptsById.values()).sort((a, b) => Date.parse(a.startedAt || a.completedAt || a.failedAt || "") - Date.parse(b.startedAt || b.completedAt || b.failedAt || ""));
|
||
prompts.forEach((item, index) => { item.promptIndex = index + 1; });
|
||
const chatEvents = network
|
||
.filter((item) => String(item?.method || "").toUpperCase() === "POST" && promptSubmitModeForUrl(item?.url) !== null)
|
||
.map((item) => {
|
||
const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null;
|
||
const urlPathValue = urlPath(item.url);
|
||
return {
|
||
ts: item.ts ?? null,
|
||
tsMs: Date.parse(item.ts),
|
||
type: item.type ?? null,
|
||
status: Number.isFinite(Number(item.status)) ? Number(item.status) : null,
|
||
commandId: item.commandId ?? null,
|
||
urlPath: urlPathValue,
|
||
submitMode: promptSubmitModeForUrl(item.url),
|
||
failureKind: failureText ? String(failureText) : null,
|
||
errorTextHash: failureText ? sha256(failureText) : null
|
||
};
|
||
})
|
||
.filter((item) => Number.isFinite(item.tsMs))
|
||
.sort((a, b) => a.tsMs - b.tsMs);
|
||
const rounds = prompts.map((prompt) => {
|
||
const startMs = Date.parse(prompt.startedAt || prompt.completedAt || prompt.failedAt || "");
|
||
const endAnchorMs = Date.parse(prompt.completedAt || prompt.failedAt || prompt.startedAt || "");
|
||
const fromMs = Number.isFinite(startMs) ? startMs - 3000 : Number.NEGATIVE_INFINITY;
|
||
const toMs = Number.isFinite(endAnchorMs) ? endAnchorMs + 30000 : Number.POSITIVE_INFINITY;
|
||
const events = chatEvents.filter((event) => {
|
||
if (event.commandId && prompt.commandId && event.commandId === prompt.commandId) return true;
|
||
return event.tsMs >= fromMs && event.tsMs <= toMs;
|
||
});
|
||
const responses = events.filter((event) => event.type === "response");
|
||
const failures = events.filter((event) => event.type === "requestfailed");
|
||
const responseStatuses = responses.map((event) => event.status).filter((status) => status !== null);
|
||
const submitModes = Array.from(new Set(events.map((event) => event.submitMode).filter(Boolean))).sort();
|
||
const chatPostOk = responseStatuses.some((status) => status >= 200 && status < 300) && 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,
|
||
submitModes,
|
||
steerUsed: submitModes.includes("steer"),
|
||
firstChatEventAt: events[0]?.ts ?? null,
|
||
lastChatEventAt: events[events.length - 1]?.ts ?? null,
|
||
events: events.slice(0, 12).map((event) => ({ ts: event.ts, type: event.type, status: event.status, urlPath: event.urlPath, submitMode: event.submitMode, failureKind: event.failureKind, errorTextHash: event.errorTextHash }))
|
||
};
|
||
});
|
||
return {
|
||
summary: {
|
||
promptCount: rounds.length,
|
||
chatPostOk: rounds.filter((item) => item.chatPostOk === true).length,
|
||
chatPostFailed: rounds.filter((item) => item.chatPostOk === false).length,
|
||
chatPostMissing: rounds.filter((item) => item.failureKind === "missing-response").length
|
||
},
|
||
rounds
|
||
};
|
||
}
|
||
|
||
function promptSubmitModeForUrl(value) {
|
||
const pathValue = urlPath(value);
|
||
if (pathValue === "/v1/agent/chat") return "chat";
|
||
if (pathValue === "/v1/agent/chat/steer") return "steer";
|
||
return null;
|
||
}
|
||
|
||
function parseDomDiagnosticSummary(text) {
|
||
const value = String(text || "");
|
||
const traceMatch = value.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu);
|
||
const httpStatusMatch = value.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const idleMatch = value.match(/\bidle\s+(\d+)s\b/iu);
|
||
const waitingForMatch = value.match(/\bwaitingFor=([^\s;;,,)]+)/iu);
|
||
const lastEventLabelMatch = value.match(/\blastEventLabel=([^\s;;,,)]+)/iu);
|
||
const diagnosticCode = httpStatusMatch
|
||
? "http-" + httpStatusMatch[1]
|
||
: /turn\s*超过|无新活动/iu.test(value)
|
||
? "turn-idle-no-activity"
|
||
: /Failed to fetch/iu.test(value)
|
||
? "failed-to-fetch"
|
||
: "diagnostic";
|
||
return {
|
||
diagnosticCode,
|
||
traceId: traceMatch?.[1] || null,
|
||
httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null,
|
||
idleSeconds: idleMatch ? Number(idleMatch[1]) : null,
|
||
waitingFor: waitingForMatch?.[1] || null,
|
||
lastEventLabel: lastEventLabelMatch?.[1] || null
|
||
};
|
||
}
|
||
|
||
function isDomDiagnosticSampleText(text) {
|
||
const value = String(text || "").replace(/\s+/g, " ").trim();
|
||
if (!value) return false;
|
||
const strongDiagnostic = [
|
||
/\bHTTP\s+[45][0-9]{2}\b(?:[\s\S]{0,120}\btrace_id=|\b)/iu,
|
||
/\btrace_id=(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu,
|
||
/workbench\s+turn\s*超过\s*\d+ms\s*无新活动/iu,
|
||
/\bturn\s*超过\b[\s\S]{0,120}\b无新活动\b/iu,
|
||
/\bprojection-resume:sync-failed\b/iu,
|
||
/\bAgentRun\s+GET\b[\s\S]*\/result\b[\s\S]*timed out after\s+\d+ms\b/iu,
|
||
/\bFailed to fetch\b/iu,
|
||
/\bFailed to load resource\b[\s\S]{0,180}\bstatus of\s+[45][0-9]{2}\b/iu,
|
||
/\bserver responded with a status of\s+[45][0-9]{2}\b/iu,
|
||
/Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束)/iu
|
||
].some((pattern) => pattern.test(value));
|
||
if (!strongDiagnostic) return false;
|
||
const looksLikeToolStdout = /\b(?:stdout|stderr):/iu.test(value)
|
||
&& /(?:\becho\s+["']?===|\bnode\s+|\.tspy\b|tspy\/|===\s*[A-Za-z0-9_.-]+\s*===)/iu.test(value);
|
||
if (!looksLikeToolStdout) return true;
|
||
return /\b(?:trace_id=|HTTP\s+[45][0-9]{2}|workbench\s+turn\s*超过|projection-resume:sync-failed|Failed to fetch|Failed to load resource|server responded with a status of\s+[45][0-9]{2}|Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束))\b/iu.test(value);
|
||
}
|
||
|
||
function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
|
||
const promptTimes = control
|
||
.filter((item) => item.type === "sendPrompt" && item.phase === "completed")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite)
|
||
.sort((a, b) => a - b);
|
||
const observerRefreshTimes = control
|
||
.filter((item) => item.type === "observer-periodic-refresh")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite)
|
||
.sort((a, b) => a - b);
|
||
const naturalNetwork = network.filter((item) => item?.observerInitiated !== true);
|
||
const httpErrors = naturalNetwork
|
||
.filter((item) => item?.type === "response" && Number(item.status) >= 400)
|
||
.map((item) => networkAlertEvent(item, promptTimes));
|
||
const requestFailed = naturalNetwork
|
||
.filter((item) => item?.type === "requestfailed")
|
||
.map((item) => networkAlertEvent(item, promptTimes));
|
||
const significantRequestFailed = requestFailed.filter(
|
||
(item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes),
|
||
);
|
||
const domDiagnostics = [];
|
||
const executionErrors = [];
|
||
const baselineExecutionErrors = [];
|
||
const firstPromptMs = promptTimes.length > 0 ? promptTimes[0] : Infinity;
|
||
const firstSeenExecutionErrorMs = new Map();
|
||
for (const sample of samples) {
|
||
const tsMs = Date.parse(sample?.ts);
|
||
const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0;
|
||
if (Array.isArray(sample?.diagnostics)) {
|
||
for (const diagnostic of sample.diagnostics.slice(0, 12)) {
|
||
const text = diagnostic?.textPreview || diagnostic?.text || "";
|
||
if (!String(text).trim()) continue;
|
||
const parsedDiagnostic = parseDomDiagnosticSummary(text);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "diagnostic-node",
|
||
className: diagnostic.className ?? null,
|
||
diagnosticCode: diagnostic.diagnosticCode ?? parsedDiagnostic.diagnosticCode,
|
||
traceId: diagnostic.traceId ?? parsedDiagnostic.traceId,
|
||
httpStatus: diagnostic.httpStatus ?? parsedDiagnostic.httpStatus,
|
||
idleSeconds: diagnostic.idleSeconds ?? parsedDiagnostic.idleSeconds,
|
||
waitingFor: diagnostic.waitingFor ?? parsedDiagnostic.waitingFor,
|
||
lastEventLabel: diagnostic.lastEventLabel ?? parsedDiagnostic.lastEventLabel,
|
||
compact: diagnostic.compact ?? null,
|
||
expanded: diagnostic.expanded ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash: diagnostic.textHash || sha256(text),
|
||
preview: limitText(text, 260)
|
||
});
|
||
}
|
||
}
|
||
const texts = sampleTexts(sample).filter(isDomDiagnosticSampleText);
|
||
for (const text of texts.slice(0, 4)) {
|
||
const parsedDiagnostic = parseDomDiagnosticSummary(text);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "sample-text",
|
||
diagnosticCode: parsedDiagnostic.diagnosticCode,
|
||
traceId: parsedDiagnostic.traceId,
|
||
httpStatus: parsedDiagnostic.httpStatus,
|
||
idleSeconds: parsedDiagnostic.idleSeconds,
|
||
waitingFor: parsedDiagnostic.waitingFor,
|
||
lastEventLabel: parsedDiagnostic.lastEventLabel,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash: sha256(text),
|
||
preview: limitText(text, 220)
|
||
});
|
||
}
|
||
const seenExecutionErrors = new Set();
|
||
for (const candidate of sampleExecutionErrorCandidates(sample)) {
|
||
const parsed = parseExecutionErrorText(candidate.text);
|
||
if (!parsed) continue;
|
||
const textHash = sha256(candidate.text);
|
||
const dedupeKey = [candidate.source, candidate.traceId || "-", parsed.backend || "-", parsed.code || "-", parsed.status || "-", textHash].join("|");
|
||
if (seenExecutionErrors.has(dedupeKey)) continue;
|
||
seenExecutionErrors.add(dedupeKey);
|
||
const firstSeenMs = firstSeenExecutionErrorMs.has(dedupeKey) ? firstSeenExecutionErrorMs.get(dedupeKey) : tsMs;
|
||
if (!firstSeenExecutionErrorMs.has(dedupeKey) && Number.isFinite(tsMs)) firstSeenExecutionErrorMs.set(dedupeKey, tsMs);
|
||
const baseline = Number.isFinite(firstSeenMs) && firstSeenMs < firstPromptMs;
|
||
const event = {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
baseline,
|
||
firstSeenAt: Number.isFinite(firstSeenMs) ? new Date(firstSeenMs).toISOString() : null,
|
||
source: candidate.source,
|
||
backend: parsed.backend,
|
||
status: parsed.status,
|
||
code: parsed.code,
|
||
rawCode: parsed.rawCode,
|
||
totalSeconds: parsed.totalSeconds,
|
||
traceId: candidate.traceId || parsed.traceId || null,
|
||
messageId: candidate.messageId || null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash,
|
||
preview: limitText(candidate.text, 260)
|
||
};
|
||
if (baseline) baselineExecutionErrors.push(event);
|
||
else executionErrors.push(event);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "execution-row",
|
||
diagnosticCode: parsed.rawCode || parsed.code || "execution-error",
|
||
traceId: candidate.traceId || parsed.traceId || null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash,
|
||
preview: limitText(candidate.text, 220)
|
||
});
|
||
}
|
||
}
|
||
const consoleAlerts = consoleEvents
|
||
.filter((item) => /error|warning|warn|assert/iu.test(String(item?.type || "")) || isDiagnosticText(item?.text))
|
||
.map((item) => consoleAlertEvent(item, promptTimes));
|
||
const significantConsoleAlerts = consoleAlerts.filter((item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes));
|
||
const pageErrors = errors.map((item) => ({
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
type: item.type ?? null,
|
||
errorName: item.error?.name ?? item.name ?? null,
|
||
messageHash: item.error?.message ? sha256(item.error.message) : item.message ? sha256(item.message) : null,
|
||
preview: limitText(item.error?.message || item.message || item.error || "", 220)
|
||
}));
|
||
return {
|
||
summary: {
|
||
httpErrorCount: httpErrors.length,
|
||
requestFailedCount: requestFailed.length,
|
||
significantRequestFailedCount: significantRequestFailed.length,
|
||
benignLongLivedStreamClosureCount: requestFailed.length - significantRequestFailed.length,
|
||
domDiagnosticSampleCount: domDiagnostics.length,
|
||
domDiagnosticGroupCount: groupDomDiagnostics(domDiagnostics).length,
|
||
executionErrorCount: executionErrors.length,
|
||
baselineExecutionErrorCount: baselineExecutionErrors.length,
|
||
consoleAlertCount: consoleAlerts.length,
|
||
significantConsoleAlertCount: significantConsoleAlerts.length,
|
||
pageErrorCount: pageErrors.length,
|
||
networkErrorGroupCount: groupNetworkAlerts(httpErrors).length,
|
||
requestFailedGroupCount: groupNetworkAlerts(requestFailed).length,
|
||
significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length,
|
||
executionErrorGroupCount: groupExecutionErrors(executionErrors).length,
|
||
baselineExecutionErrorGroupCount: groupExecutionErrors(baselineExecutionErrors).length,
|
||
consoleAlertGroupCount: groupConsoleAlerts(consoleAlerts).length,
|
||
significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length
|
||
},
|
||
networkHttpErrorsByPath: groupNetworkAlerts(httpErrors),
|
||
networkRequestFailedByPath: groupNetworkAlerts(requestFailed),
|
||
networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed),
|
||
domDiagnostics: domDiagnostics.slice(-80),
|
||
domDiagnosticsByText: groupDomDiagnostics(domDiagnostics),
|
||
domDiagnosticsByFingerprint: groupDomDiagnostics(domDiagnostics).slice(0, 80),
|
||
runtimeExecutionErrors: executionErrors.slice(0, 120),
|
||
runtimeExecutionErrorsByCode: groupExecutionErrors(executionErrors),
|
||
baselineRuntimeExecutionErrors: baselineExecutionErrors.slice(0, 80),
|
||
baselineRuntimeExecutionErrorsByCode: groupExecutionErrors(baselineExecutionErrors),
|
||
consoleAlerts: consoleAlerts.slice(0, 80),
|
||
consoleAlertsByPath: groupConsoleAlerts(consoleAlerts),
|
||
significantConsoleAlerts: significantConsoleAlerts.slice(0, 80),
|
||
significantConsoleAlertsByPath: groupConsoleAlerts(significantConsoleAlerts),
|
||
pageErrors: pageErrors.slice(0, 40)
|
||
};
|
||
}
|
||
|
||
function groupDomDiagnostics(events) {
|
||
const groups = new Map();
|
||
for (const item of events || []) {
|
||
const preview = String(item?.preview || "").trim();
|
||
if (!isReportableDomDiagnostic(item, preview)) continue;
|
||
const normalizedPreview = normalizeDiagnosticPreview(preview);
|
||
const key = [
|
||
item?.diagnosticCode || "",
|
||
normalizedPreview
|
||
].join("|");
|
||
const existing = groups.get(key) || {
|
||
source: item?.source || null,
|
||
sources: new Set(),
|
||
diagnosticCode: item?.diagnosticCode || null,
|
||
textHash: item?.textHash || null,
|
||
normalizedPreview,
|
||
preview,
|
||
count: 0,
|
||
firstAt: item?.ts || null,
|
||
lastAt: item?.ts || null,
|
||
promptIndexes: new Set(),
|
||
traceIds: new Set(),
|
||
sampleSeqs: []
|
||
};
|
||
if (item?.source) existing.sources.add(String(item.source));
|
||
existing.count += 1;
|
||
existing.firstAt = minIso(existing.firstAt, item?.ts || null);
|
||
existing.lastAt = maxIso(existing.lastAt, item?.ts || null);
|
||
if (Number.isFinite(Number(item?.promptIndex))) existing.promptIndexes.add(Number(item.promptIndex));
|
||
for (const traceId of extractDiagnosticTraceIds(item, preview)) existing.traceIds.add(traceId);
|
||
if (existing.sampleSeqs.length < 12 && item?.seq !== undefined && item?.seq !== null) existing.sampleSeqs.push(item.seq);
|
||
groups.set(key, existing);
|
||
}
|
||
return Array.from(groups.values())
|
||
.map((item) => ({
|
||
source: item.source,
|
||
sources: Array.from(item.sources).sort(),
|
||
diagnosticCode: item.diagnosticCode,
|
||
textHash: item.textHash,
|
||
normalizedPreview: item.normalizedPreview,
|
||
preview: item.preview,
|
||
count: item.count,
|
||
firstAt: item.firstAt,
|
||
lastAt: item.lastAt,
|
||
promptIndexes: Array.from(item.promptIndexes).sort((a, b) => a - b),
|
||
traceIds: Array.from(item.traceIds).sort(),
|
||
sampleSeqs: item.sampleSeqs
|
||
}))
|
||
.sort((a, b) => (b.count - a.count) || String(a.firstAt || "").localeCompare(String(b.firstAt || "")));
|
||
}
|
||
|
||
function isReportableDomDiagnostic(item, preview) {
|
||
if (item?.source === "diagnostic-node" || item?.source === "execution-row") return true;
|
||
return /trace_id=|HTTP\s+\d{3}\b|Failed to load resource|ERR_[A-Z_]+|provider-unavailable|AgentRun error|超过\s*\d+\s*ms\s*无新活动|代理暂时无法连接上游|Trace 更新超时|加载失败/iu.test(String(preview || ""));
|
||
}
|
||
|
||
function normalizeDiagnosticPreview(text) {
|
||
return String(text || "")
|
||
.replace(/trace_id=[A-Za-z0-9_-]+/gu, "trace_id=:traceId")
|
||
.replace(/\btrc_[A-Za-z0-9_-]+\b/gu, "trc_:traceId")
|
||
.replace(/\bses_[A-Za-z0-9_-]+\b/gu, "ses_:sessionId")
|
||
.replace(/\brun_[A-Za-z0-9_-]+\b/gu, "run_:runId")
|
||
.replace(/\bcmd_[A-Za-z0-9_-]+\b/gu, "cmd_:commandId")
|
||
.replace(/[!!]+$/gu, "")
|
||
.replace(/\s+/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
function extractDiagnosticTraceIds(item, preview) {
|
||
const ids = new Set();
|
||
if (item?.traceId) ids.add(String(item.traceId));
|
||
const text = String(preview || "");
|
||
for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]);
|
||
for (const match of text.matchAll(/trace_id=([A-Za-z0-9_-]+)/gu)) ids.add(match[1]);
|
||
return ids;
|
||
}
|
||
|
||
function minIso(a, b) {
|
||
if (!a) return b || null;
|
||
if (!b) return a || null;
|
||
return Date.parse(a) <= Date.parse(b) ? a : b;
|
||
}
|
||
|
||
function maxIso(a, b) {
|
||
if (!a) return b || null;
|
||
if (!b) return a || null;
|
||
return Date.parse(a) >= Date.parse(b) ? a : b;
|
||
}
|
||
|
||
function sampleExecutionErrorCandidates(sample) {
|
||
const candidates = [];
|
||
const add = (source, items) => {
|
||
if (!Array.isArray(items)) return;
|
||
for (const item of items) {
|
||
const text = String(item?.textPreview || item?.text || item?.preview || "").trim();
|
||
if (!text) continue;
|
||
if (!parseExecutionErrorText(text)) continue;
|
||
candidates.push({
|
||
source,
|
||
text,
|
||
traceId: item?.traceId ?? null,
|
||
messageId: item?.messageId ?? null,
|
||
status: item?.status ?? null
|
||
});
|
||
}
|
||
};
|
||
add("diagnostic-node", sample?.diagnostics);
|
||
add("message", sample?.messages);
|
||
add("trace-row", sample?.traceRows);
|
||
add("turn", sample?.turns);
|
||
const specific = candidates.filter((candidate) => {
|
||
const parsed = parseExecutionErrorText(candidate.text);
|
||
return parsed && parsed.code !== "error";
|
||
});
|
||
return specific.length > 0 ? specific : candidates;
|
||
}
|
||
|
||
function parseExecutionErrorText(text) {
|
||
const value = String(text || "");
|
||
const agentRunCodeMatch = value.match(/\bagentrun:error:([A-Za-z0-9_.:-]+)/u);
|
||
const agentRunText = /\bAgentRun\s+error\b|\bagentrun:error:/iu.test(value);
|
||
const providerUnavailable = /\bprovider[-_\s]*unavailable\b/iu.test(value);
|
||
if (!agentRunCodeMatch && !agentRunText && !providerUnavailable) return null;
|
||
const statusMatch = value.match(/\b(fail(?:ed)?|error|blocked|cancel(?:ed)?)\b/iu);
|
||
const traceMatch = value.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
const totalMatch = value.match(/\btotal\s*=\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)\b/iu)
|
||
|| value.match(/总耗时\s*[::]?\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)/iu);
|
||
const agentRunCode = cleanExecutionCode(agentRunCodeMatch?.[1] || "");
|
||
const rawCode = agentRunCode ? "agentrun:error:" + agentRunCode : providerUnavailable ? "provider-unavailable" : "agentrun:error";
|
||
return {
|
||
backend: agentRunText || agentRunCodeMatch ? "agentrun" : "unknown",
|
||
status: normalizeExecutionStatus(statusMatch?.[1] || "error"),
|
||
code: agentRunCode || (providerUnavailable ? "provider-unavailable" : "error"),
|
||
rawCode,
|
||
totalSeconds: totalMatch ? parseClockDurationSeconds(totalMatch[1]) : null,
|
||
traceId: traceMatch?.[0] || null
|
||
};
|
||
}
|
||
|
||
function cleanExecutionCode(code) {
|
||
const value = String(code || "").replace(/(?:AgentRun|Error|Failed).*$/u, "").replace(/[^A-Za-z0-9_.:-].*$/u, "");
|
||
return value || null;
|
||
}
|
||
|
||
function normalizeExecutionStatus(status) {
|
||
const value = String(status || "").toLowerCase();
|
||
if (value === "failed") return "fail";
|
||
if (value === "cancelled" || value === "canceled") return "cancel";
|
||
return value || "error";
|
||
}
|
||
|
||
function parseClockDurationSeconds(value) {
|
||
const parts = String(value || "").split(":").map((part) => Number(part));
|
||
if (parts.length === 2 && parts.every(Number.isFinite)) return parts[0] * 60 + parts[1];
|
||
if (parts.length === 3 && parts.every(Number.isFinite)) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
return null;
|
||
}
|
||
|
||
function groupExecutionErrors(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.backend || "-", event.status || "-", event.code || "-"].join(" ");
|
||
const group = groups.get(key) || {
|
||
backend: event.backend ?? null,
|
||
status: event.status ?? null,
|
||
code: event.code ?? null,
|
||
rawCode: event.rawCode ?? null,
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
traceIds: [],
|
||
sources: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId);
|
||
if (event.source && !group.sources.includes(event.source)) group.sources.push(event.source);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.code).localeCompare(String(b.code)));
|
||
}
|
||
|
||
function consoleAlertEvent(item, promptTimes) {
|
||
const text = String(item?.text || "");
|
||
const statusMatch = text.match(/\bstatus\s+of\s+([1-5][0-9]{2})\b/iu) || text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const location = compactLocation(item.location);
|
||
const traceMatch = (location?.urlPath || text).match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
return {
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
type: item.type ?? null,
|
||
status: statusMatch ? Number(statusMatch[1]) : null,
|
||
urlPath: location?.urlPath || "-",
|
||
traceId: traceMatch?.[0] || null,
|
||
textHash: item.text ? sha256(item.text) : null,
|
||
preview: limitText(text, 220),
|
||
location
|
||
};
|
||
}
|
||
|
||
function groupConsoleAlerts(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.type || "-", event.status ?? "-", event.urlPath || "-"].join(" ");
|
||
const group = groups.get(key) || {
|
||
type: event.type ?? null,
|
||
status: event.status ?? null,
|
||
urlPath: event.urlPath || "-",
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
traceIds: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath)));
|
||
}
|
||
|
||
function isBenignLongLivedStreamClosureAlert(event) {
|
||
if (event?.urlPath !== "/v1/workbench/events") return false;
|
||
if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false;
|
||
const text = String(event.failureKind || event.errorPreview || event.preview || "");
|
||
return /ERR_NETWORK_CHANGED|ERR_ABORTED|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed/iu.test(text);
|
||
}
|
||
|
||
function isObserverRefreshClosureAlert(event, observerRefreshTimes) {
|
||
const urlPath = String(event?.urlPath || "");
|
||
if (!["/v1/workbench/events", "/v1/web-performance"].includes(urlPath) && !/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath)) return false;
|
||
if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false;
|
||
const text = String(event.failureKind || event.errorPreview || event.preview || "");
|
||
if (!/ERR_NETWORK_CHANGED|ERR_ABORTED|ERR_INCOMPLETE_CHUNKED_ENCODING|ERR_INVALID_CHUNKED_ENCODING|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed|incomplete chunked|invalid chunked/iu.test(text)) return false;
|
||
const ts = Date.parse(String(event.ts || ""));
|
||
return Number.isFinite(ts) && observerRefreshTimes.some((refreshTs) => Math.abs(ts - refreshTs) <= 8000);
|
||
}
|
||
|
||
function networkAlertEvent(item, promptTimes) {
|
||
const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
method: String(item.method || "GET").toUpperCase(),
|
||
status: Number.isFinite(Number(item.status)) ? Number(item.status) : null,
|
||
type: item.type ?? null,
|
||
urlPath: urlPath(item.url),
|
||
urlHash: item.url ? sha256(item.url) : null,
|
||
failureKind: failureText ? String(failureText) : null,
|
||
errorTextHash: failureText ? sha256(failureText) : null,
|
||
errorPreview: failureText ? limitText(failureText, 160) : null
|
||
};
|
||
}
|
||
|
||
function groupNetworkAlerts(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.method, event.urlPath, event.status ?? "-", event.type].join(" ");
|
||
const group = groups.get(key) || {
|
||
method: event.method,
|
||
urlPath: event.urlPath,
|
||
status: event.status,
|
||
type: event.type,
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
failureKinds: [],
|
||
errorTextHashes: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.failureKind && !group.failureKinds.includes(event.failureKind)) group.failureKinds.push(event.failureKind);
|
||
if (event.errorTextHash && !group.errorTextHashes.includes(event.errorTextHash)) group.errorTextHashes.push(event.errorTextHash);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath)));
|
||
}
|
||
|
||
function isDiagnosticText(text) {
|
||
const value = String(text || "");
|
||
return /Failed to (?:fetch|load resource)|request failed|net::ERR_[A-Z0-9_:-]+|server responded with a status of [45][0-9]{2}|HTTP\s+[45][0-9]{2}\b|trace_id=|workbench turn\s*超过|turn\s*超过|无新活动|idle\s+\d+s|waitingFor=|lastEventLabel=|无法连接上游|代理暂时无法连接上游|provider-unavailable|agentrun:error|AgentRun error|projection-resume|sync-failed|durable projection store|realtime-gap|Trace 更新超时|加载失败|请求失败|请求已失败/iu.test(value);
|
||
}
|
||
|
||
function prioritizeFindings(findings) {
|
||
const items = Array.isArray(findings) ? findings : [];
|
||
const severityRank = (severity) => {
|
||
const value = String(severity || "").toLowerCase();
|
||
if (value === "red") return 0;
|
||
if (value === "amber" || value === "warning") return 1;
|
||
if (value === "info") return 3;
|
||
return 2;
|
||
};
|
||
const kindRank = (item) => {
|
||
const id = String(item?.id ?? item?.kind ?? item?.code ?? "");
|
||
if (id === "page-performance-slow-same-origin-api") return 0;
|
||
if (id === "session-rail-title-fallback-majority") return 0.5;
|
||
if (id.startsWith("code-agent-card-")) return 0.8;
|
||
if (id.startsWith("round-completion-")) return 0.9;
|
||
if (id.startsWith("turn-timing-total-elapsed")) return 1;
|
||
if (id.startsWith("turn-timing-terminal-elapsed")) return 1.1;
|
||
if (id.startsWith("turn-timing-recent-update")) return 2;
|
||
if (id.includes("runtime-execution") || id.includes("prompt-chat-submit-failed")) return 3;
|
||
return 10;
|
||
};
|
||
return items.slice().sort((left, right) => {
|
||
const kindDelta = kindRank(left) - kindRank(right);
|
||
if (kindDelta !== 0) return kindDelta;
|
||
return severityRank(left?.severity ?? left?.level) - severityRank(right?.severity ?? right?.level);
|
||
});
|
||
}
|
||
|
||
function isTerminalTraceText(text) {
|
||
return /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(text || ""));
|
||
}
|
||
|
||
function isFinalResultText(text) {
|
||
return /已完成第\d+轮|已按第\d+轮完成|final response|sealed final response|最终结果|已完成[::]|smoke\s*测试结果|benchmark|PVC\/workspace|修改文件|Results:/iu.test(String(text || ""));
|
||
}
|
||
|
||
function buildSampleMetrics(samples, control) {
|
||
const promptCommands = buildSendPromptCommandTimeline(control);
|
||
const promptTimes = promptCommands.map((item) => item.tsMs);
|
||
const timeline = samples.map((sample) => {
|
||
const texts = sampleTexts(sample);
|
||
const timingTexts = sampleTurnTimingTexts(sample);
|
||
const tsMs = Date.parse(sample.ts);
|
||
const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0;
|
||
const totalElapsedValues = timingTexts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite);
|
||
const recentUpdateValues = timingTexts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite);
|
||
const diagnosticTexts = texts.filter(isDiagnosticText).slice(0, 5);
|
||
const terminalTexts = texts.filter(isTerminalTraceText).slice(0, 5);
|
||
const finalResultTexts = texts.filter(isFinalResultText).slice(0, 5);
|
||
const loadings = Array.isArray(sample.loadings) ? sample.loadings : [];
|
||
const loadingOwners = uniqueLoadingOwners(loadings);
|
||
return {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
promptIndex,
|
||
messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0,
|
||
traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0,
|
||
loadingCount: loadings.length,
|
||
loadingOwnerCount: loadingOwners.length,
|
||
loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12),
|
||
sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0),
|
||
sessionRailFallbackTitleCount: Number(sample?.sessionRail?.fallbackTitleCount ?? 0),
|
||
sessionRailFallbackTitleRatio: Number(sample?.sessionRail?.fallbackTitleRatio ?? 0),
|
||
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
|
||
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
|
||
terminalSeen: terminalTexts.length > 0,
|
||
finalResultTextSeen: finalResultTexts.length > 0,
|
||
diagnosticSeen: diagnosticTexts.length > 0,
|
||
diagnosticTextHashes: diagnosticTexts.map(sha256).slice(0, 5),
|
||
textDigest: digestSample(sample)
|
||
};
|
||
});
|
||
const turnTiming = buildTurnTimingTable(samples, timeline);
|
||
const traceOrder = buildTraceOrderMetrics(samples, timeline);
|
||
const codeAgentCardTiming = buildCodeAgentCardTimingMetrics(samples, timeline, turnTiming);
|
||
const codeAgentCardDurationUnderreported = buildCodeAgentCardDurationUnderreportedMetrics(samples, timeline);
|
||
const codeAgentCardDurationMismatches = buildCodeAgentCardDurationMismatchMetrics(samples, timeline);
|
||
if (codeAgentCardTiming && codeAgentCardTiming.summary) {
|
||
codeAgentCardTiming.summary.durationUnderreportedCount = codeAgentCardDurationUnderreported.length;
|
||
codeAgentCardTiming.summary.durationMismatchCount = codeAgentCardDurationMismatches.length;
|
||
codeAgentCardTiming.durationUnderreported = codeAgentCardDurationUnderreported;
|
||
codeAgentCardTiming.durationMismatches = codeAgentCardDurationMismatches;
|
||
}
|
||
const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {}));
|
||
const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : [];
|
||
const turnTimingElapsedZeroResets = Array.isArray(turnTiming.elapsedZeroResets) ? turnTiming.elapsedZeroResets : [];
|
||
const turnTimingTotalElapsedForwardJumps = Array.isArray(turnTiming.totalElapsedForwardJumps) ? turnTiming.totalElapsedForwardJumps : [];
|
||
const turnTimingRecentUpdateSawtoothJumps = turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump");
|
||
const turnTimingTerminalElapsedGrowth = Array.isArray(turnTiming.terminalElapsedGrowth) ? turnTiming.terminalElapsedGrowth : [];
|
||
const turnTimingRecentUpdateResets = Array.isArray(turnTiming.recentUpdateResets) ? turnTiming.recentUpdateResets : [];
|
||
const turnTimingRecentUpdateSteps = Array.isArray(turnTiming.recentUpdateSteps) ? turnTiming.recentUpdateSteps : [];
|
||
const turnTimingTerminalElapsedGrowthDeltas = turnTimingTerminalElapsedGrowth
|
||
.map((item) => Number(item.delta))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
const turnTimingRecentUpdateLargestSteps = turnTimingRecentUpdateSteps
|
||
.filter((item) => Number.isFinite(Number(item.delta)))
|
||
.slice()
|
||
.sort((a, b) => Number(b.delta) - Number(a.delta))
|
||
.slice(0, 200);
|
||
const turnTimingRecentUpdatePositiveSteps = turnTimingRecentUpdateSteps
|
||
.map((item) => Number(item.delta))
|
||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||
const turnTimingRecentUpdateExcessSteps = turnTimingRecentUpdateSteps
|
||
.map((item) => Number(item.excessiveIncreaseSeconds))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length;
|
||
const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length;
|
||
const diagnostics = timeline.filter((item) => item.diagnosticSeen).length;
|
||
const loading = buildLoadingMetrics(samples, timeline);
|
||
const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline);
|
||
const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows);
|
||
const reportTimeline = boundedRowsForReport(timeline);
|
||
const rounds = buildRoundMetricSummaries(timeline, promptCommands, {
|
||
columns: turnTiming.columns,
|
||
rows: turnTiming.rows,
|
||
nonMonotonic: turnTimingNonMonotonic,
|
||
elapsedZeroResets: turnTimingElapsedZeroResets,
|
||
totalElapsedForwardJumps: turnTimingTotalElapsedForwardJumps,
|
||
terminalElapsedGrowth: turnTimingTerminalElapsedGrowth,
|
||
recentUpdateResets: turnTimingRecentUpdateResets,
|
||
recentUpdateSteps: turnTimingRecentUpdateSteps
|
||
});
|
||
const recentUpdateJumpCount = turnTimingRecentUpdateSawtoothJumps.length;
|
||
return {
|
||
summary: {
|
||
sampleCount: timeline.length,
|
||
withTotalElapsed: withTotal,
|
||
withRecentUpdate: withRecent,
|
||
diagnostics,
|
||
loadingSampleCount: loading.summary.loadingSampleCount,
|
||
loadingMaxCount: loading.summary.maxSimultaneousCount,
|
||
loadingMaxOwnerCount: loading.summary.maxSimultaneousOwnerCount,
|
||
loadingOwnerCount: loading.summary.ownerCount,
|
||
loadingConcurrentSampleCount: loading.summary.concurrentLoadingSampleCount,
|
||
loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds,
|
||
loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds,
|
||
loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount,
|
||
loadingOverBudgetSegmentCount: loading.summary.overBudgetSegmentCount,
|
||
sessionRailSampleCount: sessionRailTitles.summary.sampleCount,
|
||
sessionRailVisibleSampleCount: sessionRailTitles.summary.visibleSampleCount,
|
||
sessionRailFallbackMajoritySampleCount: sessionRailTitles.summary.majorityFallbackSampleCount,
|
||
sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio,
|
||
sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount,
|
||
sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount,
|
||
promptSegments: Math.max(0, promptTimes.length),
|
||
rounds: rounds.length,
|
||
turnColumns: turnTiming.columns.length,
|
||
turnTimingRows: turnTiming.rows.length,
|
||
turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length,
|
||
turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length,
|
||
turnTimingNonMonotonicCount: turnTimingNonMonotonic.length,
|
||
turnTimingTotalElapsedDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds").length,
|
||
turnTimingTotalElapsedZeroResetCount: turnTimingElapsedZeroResets.length,
|
||
turnTimingTotalElapsedForwardJumpCount: turnTimingTotalElapsedForwardJumps.length,
|
||
turnTimingTotalElapsedForwardJumpMaxSeconds: maxPositiveDelta(turnTimingTotalElapsedForwardJumps),
|
||
turnTimingTerminalElapsedGrowthCount: turnTimingTerminalElapsedGrowth.length,
|
||
turnTimingTerminalElapsedGrowthMaxSeconds: turnTimingTerminalElapsedGrowthDeltas.length > 0 ? Math.max(...turnTimingTerminalElapsedGrowthDeltas) : 0,
|
||
turnTimingRecentUpdateJumpCount: recentUpdateJumpCount,
|
||
turnTimingRecentUpdateSawtoothJumpCount: recentUpdateJumpCount,
|
||
turnTimingRecentUpdateStepCount: turnTimingRecentUpdateSteps.length,
|
||
turnTimingRecentUpdateMaxIncreaseSeconds: turnTimingRecentUpdatePositiveSteps.length > 0 ? Math.max(...turnTimingRecentUpdatePositiveSteps) : null,
|
||
turnTimingRecentUpdateMaxExcessSeconds: turnTimingRecentUpdateExcessSteps.length > 0 ? Math.max(...turnTimingRecentUpdateExcessSteps) : 0,
|
||
turnTimingRecentUpdateResetCount: turnTimingRecentUpdateResets.length,
|
||
turnTimingRecentUpdateDecreaseCount: turnTimingRecentUpdateResets.length,
|
||
codeAgentCardSampleCount: codeAgentCardTiming.summary.cardSampleCount,
|
||
codeAgentCardMissingElapsedCount: codeAgentCardTiming.summary.missingElapsedCount,
|
||
codeAgentCardMissingRecentUpdateCount: codeAgentCardTiming.summary.missingRecentUpdateCount,
|
||
roundCompletionEventCount: codeAgentCardTiming.summary.roundCompletionEventCount,
|
||
roundCompletionElapsedMismatchCount: codeAgentCardTiming.summary.roundCompletionElapsedMismatchCount,
|
||
roundCompletionFinalResponseMissingCount: codeAgentCardTiming.summary.roundCompletionFinalResponseMissingCount,
|
||
roundCompletionPostTimingChangeCount: codeAgentCardTiming.summary.roundCompletionPostTimingChangeCount,
|
||
codeAgentCardDurationUnderreportedCount: codeAgentCardTiming.summary.durationUnderreportedCount,
|
||
codeAgentCardDurationMismatchCount: codeAgentCardTiming.summary.durationMismatchCount,
|
||
traceRowCount: traceOrder.summary.traceRowCount,
|
||
traceRowOrderAnomalyCount: traceOrder.summary.orderAnomalyCount,
|
||
traceRowCompletionNotLastCount: traceOrder.summary.completionNotLastCount,
|
||
roundsWithTurnTimingNonMonotonic: rounds.filter((item) => item.turnTimingNonMonotonicCount > 0).length,
|
||
roundsWithTurnTimingTotalElapsedForwardJumps: rounds.filter((item) => item.turnTimingTotalElapsedForwardJumpCount > 0).length,
|
||
roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length,
|
||
roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length
|
||
},
|
||
loading,
|
||
sessionRailTitles,
|
||
codeAgentCardTiming,
|
||
traceOrder,
|
||
rounds,
|
||
turnColumns: turnTiming.columns,
|
||
turnTimingTable: reportTurnTimingRows.rows,
|
||
turnTimingTableDisclosure: reportTurnTimingRows.disclosure,
|
||
turnTimingNonMonotonic,
|
||
turnTimingElapsedZeroResets,
|
||
turnTimingTotalElapsedForwardJumps,
|
||
turnTimingTerminalElapsedGrowth,
|
||
turnTimingRecentUpdateSawtoothJumps,
|
||
turnTimingRecentUpdateSteps,
|
||
turnTimingRecentUpdateLargestSteps,
|
||
turnTimingRecentUpdateResets,
|
||
timeline: reportTimeline.rows,
|
||
timelineDisclosure: reportTimeline.disclosure
|
||
};
|
||
}
|
||
|
||
function boundedRowsForReport(rows) {
|
||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||
const maxRows = 1200;
|
||
const headRows = 120;
|
||
if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } };
|
||
const tailRows = Math.max(0, maxRows - headRows);
|
||
return {
|
||
rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)],
|
||
disclosure: {
|
||
truncated: true,
|
||
totalRows: sourceRows.length,
|
||
includedRows: maxRows,
|
||
omittedRows: Math.max(0, sourceRows.length - maxRows),
|
||
headRows,
|
||
tailRows,
|
||
policy: "report-bounded-head-tail; summary metrics are computed before truncation",
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildSendPromptCommandTimeline(control) {
|
||
const byCommand = new Map();
|
||
let ordinal = 0;
|
||
for (const item of Array.isArray(control) ? control : []) {
|
||
if (item?.type !== "sendPrompt") continue;
|
||
const commandId = item.commandId ? String(item.commandId) : "sendPrompt-" + String(ordinal++);
|
||
const existing = byCommand.get(commandId) || {
|
||
commandId,
|
||
startedAt: null,
|
||
startedTsMs: null,
|
||
completedAt: null,
|
||
completedTsMs: null,
|
||
failedAt: null,
|
||
failedTsMs: null,
|
||
textHash: null,
|
||
textBytes: null,
|
||
};
|
||
if (!existing.textHash && item.input?.textHash) existing.textHash = item.input.textHash;
|
||
if (!existing.textBytes && item.input?.textBytes) existing.textBytes = item.input.textBytes;
|
||
const tsMs = Date.parse(item.ts);
|
||
if (item.phase === "started" && Number.isFinite(tsMs)) {
|
||
existing.startedAt = item.ts ?? existing.startedAt;
|
||
existing.startedTsMs = tsMs;
|
||
} else if (item.phase === "completed" && Number.isFinite(tsMs)) {
|
||
existing.completedAt = item.ts ?? existing.completedAt;
|
||
existing.completedTsMs = tsMs;
|
||
} else if (item.phase === "failed" && Number.isFinite(tsMs)) {
|
||
existing.failedAt = item.ts ?? existing.failedAt;
|
||
existing.failedTsMs = tsMs;
|
||
}
|
||
byCommand.set(commandId, existing);
|
||
}
|
||
return Array.from(byCommand.values())
|
||
.map((item) => {
|
||
const tsMs = Number.isFinite(item.startedTsMs) ? item.startedTsMs : Number.isFinite(item.completedTsMs) ? item.completedTsMs : item.failedTsMs;
|
||
const ts = Number.isFinite(item.startedTsMs) ? item.startedAt : Number.isFinite(item.completedTsMs) ? item.completedAt : item.failedAt;
|
||
return { ...item, ts, tsMs };
|
||
})
|
||
.filter((item) => Number.isFinite(item.tsMs))
|
||
.sort((a, b) => a.tsMs - b.tsMs)
|
||
.map((item, index) => ({ ...item, promptIndex: index + 1 }));
|
||
}
|
||
|
||
function buildSessionRailTitleMetrics(samples, timeline) {
|
||
const rows = [];
|
||
const examplesByHash = new Map();
|
||
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
|
||
const sample = samples[index];
|
||
const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null;
|
||
if (!rail) continue;
|
||
const visibleCount = Number(rail.visibleCount ?? 0);
|
||
const fallbackTitleCount = Number(rail.fallbackTitleCount ?? 0);
|
||
const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0;
|
||
const safeFallbackTitleCount = Number.isFinite(fallbackTitleCount) && fallbackTitleCount > 0 ? fallbackTitleCount : 0;
|
||
const fallbackTitleRatio = safeVisibleCount > 0 ? Number((safeFallbackTitleCount / safeVisibleCount).toFixed(4)) : 0;
|
||
const fallbackItems = Array.isArray(rail.fallbackItems) ? rail.fallbackItems : [];
|
||
for (const item of fallbackItems) {
|
||
const hash = String(item?.titleHash || item?.titlePreview || item?.sessionIdPrefix || "").trim();
|
||
if (!hash || examplesByHash.has(hash)) continue;
|
||
examplesByHash.set(hash, {
|
||
titleHash: item?.titleHash ?? null,
|
||
titlePreview: limitText(String(item?.titlePreview || ""), 160),
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? null,
|
||
active: item?.active === true,
|
||
firstSeq: sample?.seq ?? null,
|
||
firstAt: sample?.ts ?? null,
|
||
pageRole: sample?.pageRole ?? null,
|
||
});
|
||
}
|
||
rows.push({
|
||
...ref(sample),
|
||
promptIndex: timeline[index]?.promptIndex ?? 0,
|
||
visibleCount: safeVisibleCount,
|
||
fallbackTitleCount: safeFallbackTitleCount,
|
||
fallbackTitleRatio,
|
||
majorityFallback: safeVisibleCount > 0 && safeFallbackTitleCount > safeVisibleCount / 2,
|
||
overThreshold: safeVisibleCount > 0 && fallbackTitleRatio > alertThresholds.sessionRailFallbackRatio,
|
||
examples: fallbackItems.slice(0, 5).map((item) => ({
|
||
titleHash: item?.titleHash ?? null,
|
||
titlePreview: limitText(String(item?.titlePreview || ""), 160),
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? null,
|
||
active: item?.active === true,
|
||
})),
|
||
});
|
||
}
|
||
const visibleRows = rows.filter((item) => item.visibleCount > 0);
|
||
const majorityRows = rows.filter((item) => item.majorityFallback);
|
||
const overThresholdRows = rows.filter((item) => item.overThreshold);
|
||
const fallbackRows = rows.filter((item) => item.fallbackTitleCount > 0);
|
||
const maxFallbackRatio = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleRatio) || 0)) : 0;
|
||
const maxVisibleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.visibleCount) || 0)) : 0;
|
||
const maxFallbackTitleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleCount) || 0)) : 0;
|
||
return {
|
||
summary: {
|
||
sampleCount: rows.length,
|
||
visibleSampleCount: visibleRows.length,
|
||
fallbackSampleCount: fallbackRows.length,
|
||
majorityFallbackSampleCount: majorityRows.length,
|
||
overThresholdSampleCount: overThresholdRows.length,
|
||
thresholdRatio: alertThresholds.sessionRailFallbackRatio,
|
||
maxFallbackRatio,
|
||
maxVisibleCount,
|
||
maxFallbackTitleCount,
|
||
},
|
||
samples: majorityRows.slice(0, 80),
|
||
examples: Array.from(examplesByHash.values()).slice(0, 80),
|
||
timeline: rows.slice(-200),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function uniqueLoadingOwners(loadings) {
|
||
const groups = new Map();
|
||
for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) {
|
||
const item = loadings[index];
|
||
const ownerKey = loadingOwnerKey(item, index);
|
||
const ownerIdentity = loadingOwnerIdentity(item);
|
||
const existing = groups.get(ownerKey) || {
|
||
ownerKey,
|
||
ownerKind: item?.ownerKind ?? "unknown",
|
||
ownerLabel: loadingOwnerLabel(item, ownerKey),
|
||
...ownerIdentity,
|
||
count: 0,
|
||
textHashes: []
|
||
};
|
||
existing.count += 1;
|
||
if (item?.textHash && !existing.textHashes.includes(item.textHash)) existing.textHashes.push(item.textHash);
|
||
for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) {
|
||
if (!existing[key] && ownerIdentity[key]) existing[key] = ownerIdentity[key];
|
||
}
|
||
groups.set(ownerKey, existing);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel)));
|
||
}
|
||
|
||
function loadingOwnerIdentity(item) {
|
||
const owner = item?.owner && typeof item.owner === "object" ? item.owner : {};
|
||
return {
|
||
ownerSessionId: owner.sessionId ?? null,
|
||
ownerMessageId: owner.messageId ?? null,
|
||
ownerTraceId: owner.traceId ?? null,
|
||
};
|
||
}
|
||
|
||
function loadingOwnerKey(item, index = 0) {
|
||
const key = String(item?.ownerKey || "").trim();
|
||
if (key) return key.slice(0, 240);
|
||
const owner = item?.owner && typeof item.owner === "object" ? item.owner : {};
|
||
return [
|
||
item?.ownerKind || "unknown",
|
||
owner.testId || item?.testId || owner.id || owner.role || owner.className || item?.role || item?.tag || "node",
|
||
owner.sessionId || owner.messageId || owner.traceId || item?.textHash || String(index)
|
||
].filter(Boolean).join(":").slice(0, 240);
|
||
}
|
||
|
||
function loadingOwnerLabel(item, fallback) {
|
||
return limitText(String(item?.ownerLabel || item?.owner?.ariaLabel || item?.owner?.testId || item?.owner?.className || fallback || "unknown"), 160);
|
||
}
|
||
|
||
function buildLoadingMetrics(samples, timeline) {
|
||
const events = samples.map((sample, index) => {
|
||
const tsMs = Date.parse(sample?.ts);
|
||
const loadings = Array.isArray(sample?.loadings) ? sample.loadings : [];
|
||
const owners = uniqueLoadingOwners(loadings);
|
||
return {
|
||
seq: sample?.seq ?? null,
|
||
ts: sample?.ts ?? null,
|
||
tsMs,
|
||
promptIndex: timeline[index]?.promptIndex ?? 0,
|
||
routeSessionId: sample?.routeSessionId ?? null,
|
||
activeSessionId: sample?.activeSessionId ?? null,
|
||
loadingCount: loadings.length,
|
||
ownerCount: owners.length,
|
||
owners,
|
||
ownerKeys: owners.map((item) => item.ownerKey),
|
||
ownerLabels: owners.map((item) => item.ownerLabel).slice(0, 8)
|
||
};
|
||
}).filter((item) => Number.isFinite(item.tsMs));
|
||
const continuityThresholdMs = loadingContinuityThresholdMs(events);
|
||
const segments = buildLoadingSegments(events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners)
|
||
.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0) || Number(b.maxCount ?? 0) - Number(a.maxCount ?? 0));
|
||
const ownerMap = new Map();
|
||
for (const event of events) {
|
||
for (const owner of event.owners) {
|
||
const existing = ownerMap.get(owner.ownerKey) || {
|
||
ownerKey: owner.ownerKey,
|
||
ownerKind: owner.ownerKind,
|
||
ownerLabel: owner.ownerLabel,
|
||
ownerSessionId: owner.ownerSessionId ?? null,
|
||
ownerMessageId: owner.ownerMessageId ?? null,
|
||
ownerTraceId: owner.ownerTraceId ?? null,
|
||
sampleCount: 0,
|
||
occurrenceCount: 0,
|
||
maxSimultaneousCount: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
firstSeq: event.seq,
|
||
lastSeq: event.seq,
|
||
promptIndexes: new Set(),
|
||
events: []
|
||
};
|
||
existing.sampleCount += 1;
|
||
existing.occurrenceCount += owner.count;
|
||
existing.maxSimultaneousCount = Math.max(existing.maxSimultaneousCount, owner.count);
|
||
existing.lastAt = event.ts;
|
||
existing.lastSeq = event.seq;
|
||
for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) {
|
||
if (!existing[key] && owner[key]) existing[key] = owner[key];
|
||
}
|
||
if (Number.isFinite(Number(event.promptIndex))) existing.promptIndexes.add(Number(event.promptIndex));
|
||
existing.events.push({ ...event, loadingCount: owner.count, owners: [owner] });
|
||
ownerMap.set(owner.ownerKey, existing);
|
||
}
|
||
}
|
||
const owners = Array.from(ownerMap.values()).map((owner) => {
|
||
const ownerSegments = buildLoadingSegments(owner.events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners);
|
||
const longest = ownerSegments.reduce((max, item) => Math.max(max, Number(item.durationSeconds ?? 0)), 0);
|
||
return {
|
||
ownerKey: owner.ownerKey,
|
||
ownerKind: owner.ownerKind,
|
||
ownerLabel: owner.ownerLabel,
|
||
ownerSessionId: owner.ownerSessionId ?? null,
|
||
ownerMessageId: owner.ownerMessageId ?? null,
|
||
ownerTraceId: owner.ownerTraceId ?? null,
|
||
sampleCount: owner.sampleCount,
|
||
occurrenceCount: owner.occurrenceCount,
|
||
maxSimultaneousCount: owner.maxSimultaneousCount,
|
||
longestContinuousSeconds: longest,
|
||
firstAt: owner.firstAt,
|
||
lastAt: owner.lastAt,
|
||
firstSeq: owner.firstSeq,
|
||
lastSeq: owner.lastSeq,
|
||
promptIndexes: Array.from(owner.promptIndexes).sort((a, b) => a - b),
|
||
segments: ownerSegments.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0)).slice(0, 8),
|
||
valuesRedacted: true
|
||
};
|
||
}).sort((a, b) => Number(b.longestContinuousSeconds ?? 0) - Number(a.longestContinuousSeconds ?? 0) || Number(b.occurrenceCount ?? 0) - Number(a.occurrenceCount ?? 0));
|
||
const latest = events[events.length - 1] || null;
|
||
const currentSegment = latest && latest.loadingCount > 0
|
||
? segments.find((segment) => segment.ongoing === true && segment.lastSeq === latest.seq) || null
|
||
: null;
|
||
const timelineRows = events
|
||
.filter((event, index) => event.loadingCount > 0 || (index > 0 && events[index - 1]?.loadingCount > 0))
|
||
.slice(0, 500)
|
||
.map((event) => ({
|
||
seq: event.seq,
|
||
ts: event.ts,
|
||
promptIndex: event.promptIndex,
|
||
loadingCount: event.loadingCount,
|
||
ownerCount: event.ownerCount,
|
||
owners: event.owners.map((owner) => ({ ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, ownerSessionId: owner.ownerSessionId ?? null, ownerMessageId: owner.ownerMessageId ?? null, ownerTraceId: owner.ownerTraceId ?? null, count: owner.count })).slice(0, 8)
|
||
}));
|
||
return {
|
||
summary: {
|
||
sampleCount: events.length,
|
||
loadingSampleCount: events.filter((event) => event.loadingCount > 0).length,
|
||
maxSimultaneousCount: events.reduce((max, event) => Math.max(max, event.loadingCount), 0),
|
||
maxSimultaneousOwnerCount: events.reduce((max, event) => Math.max(max, event.ownerCount), 0),
|
||
concurrentLoadingSampleCount: events.filter((event) => event.loadingCount > 1).length,
|
||
ownerCount: owners.length,
|
||
segmentCount: segments.length,
|
||
overFiveSecondSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > 5).length,
|
||
overBudgetSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > alertThresholds.visibleLoadingSlowMs / 1000).length,
|
||
budgetSeconds: alertThresholds.visibleLoadingSlowMs / 1000,
|
||
longestContinuousSeconds: segments.length > 0 ? Number(segments[0].durationSeconds ?? 0) : 0,
|
||
currentContinuousSeconds: currentSegment ? Number(currentSegment.durationSeconds ?? 0) : 0,
|
||
continuityThresholdMs,
|
||
latestLoadingCount: latest?.loadingCount ?? 0,
|
||
latestOwnerCount: latest?.ownerCount ?? 0,
|
||
valuesRedacted: true
|
||
},
|
||
segments: segments.slice(0, 80),
|
||
owners: owners.slice(0, 80),
|
||
timeline: timelineRows,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function loadingContinuityThresholdMs(events) {
|
||
const deltas = [];
|
||
for (let index = 1; index < events.length; index += 1) {
|
||
const delta = events[index].tsMs - events[index - 1].tsMs;
|
||
if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
|
||
}
|
||
if (deltas.length === 0) return 5000;
|
||
const sorted = deltas.slice().sort((a, b) => a - b);
|
||
const median = sorted[Math.floor(sorted.length / 2)];
|
||
return Math.min(15000, Math.max(1500, Math.round(median * 2.5)));
|
||
}
|
||
|
||
function buildLoadingSegments(events, continuityThresholdMs, countForEvent, ownersForEvent) {
|
||
const segments = [];
|
||
let segment = null;
|
||
let previousTsMs = null;
|
||
for (const event of events) {
|
||
const count = Number(countForEvent(event) ?? 0);
|
||
const gapOk = previousTsMs === null || !Number.isFinite(event.tsMs) || event.tsMs - previousTsMs <= continuityThresholdMs;
|
||
if (count > 0) {
|
||
if (!segment || !gapOk) {
|
||
if (segment) segments.push(finalizeLoadingSegment(segment, null));
|
||
segment = {
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
firstSeq: event.seq,
|
||
lastSeq: event.seq,
|
||
promptIndexes: new Set(),
|
||
ownerKeys: new Set(),
|
||
ownerLabels: new Map(),
|
||
sampleCount: 0,
|
||
maxCount: 0,
|
||
ongoing: true
|
||
};
|
||
}
|
||
segment.lastAt = event.ts;
|
||
segment.lastSeq = event.seq;
|
||
segment.sampleCount += 1;
|
||
segment.maxCount = Math.max(segment.maxCount, count);
|
||
if (Number.isFinite(Number(event.promptIndex))) segment.promptIndexes.add(Number(event.promptIndex));
|
||
for (const owner of ownersForEvent(event) || []) {
|
||
if (!owner?.ownerKey) continue;
|
||
segment.ownerKeys.add(owner.ownerKey);
|
||
if (!segment.ownerLabels.has(owner.ownerKey)) segment.ownerLabels.set(owner.ownerKey, { ownerKey: owner.ownerKey, ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: 0 });
|
||
const label = segment.ownerLabels.get(owner.ownerKey);
|
||
label.count += owner.count ?? 1;
|
||
}
|
||
} else if (segment) {
|
||
segment.ongoing = false;
|
||
segment.endedAt = event.ts;
|
||
segment.endSeq = event.seq;
|
||
segments.push(finalizeLoadingSegment(segment, event));
|
||
segment = null;
|
||
}
|
||
previousTsMs = event.tsMs;
|
||
}
|
||
if (segment) segments.push(finalizeLoadingSegment(segment, null));
|
||
return segments;
|
||
}
|
||
|
||
function finalizeLoadingSegment(segment, absentEvent) {
|
||
const startMs = Date.parse(segment.firstAt || "");
|
||
const lastMs = Date.parse(segment.lastAt || "");
|
||
const absentMs = Date.parse(absentEvent?.ts || "");
|
||
const durationSeconds = Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs >= startMs ? Number(((lastMs - startMs) / 1000).toFixed(3)) : 0;
|
||
const upperBoundSeconds = Number.isFinite(startMs) && Number.isFinite(absentMs) && absentMs >= startMs ? Number(((absentMs - startMs) / 1000).toFixed(3)) : durationSeconds;
|
||
const endedGapSeconds = Number.isFinite(lastMs) && Number.isFinite(absentMs) && absentMs >= lastMs ? Number(((absentMs - lastMs) / 1000).toFixed(3)) : null;
|
||
return {
|
||
firstAt: segment.firstAt,
|
||
lastAt: segment.lastAt,
|
||
endedAt: absentEvent?.ts ?? null,
|
||
firstSeq: segment.firstSeq,
|
||
lastSeq: segment.lastSeq,
|
||
endSeq: absentEvent?.seq ?? null,
|
||
durationSeconds,
|
||
upperBoundSeconds,
|
||
endedGapSeconds,
|
||
sampleCount: segment.sampleCount,
|
||
maxCount: segment.maxCount,
|
||
ownerCount: segment.ownerKeys.size,
|
||
owners: Array.from(segment.ownerLabels.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))).slice(0, 12),
|
||
promptIndexes: Array.from(segment.promptIndexes).sort((a, b) => a - b),
|
||
ongoing: absentEvent ? false : segment.ongoing === true,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function boundedTurnTimingRowsForReport(rows) {
|
||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||
const maxRows = 1200;
|
||
const headRows = 120;
|
||
if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } };
|
||
const tailRows = Math.max(0, maxRows - headRows);
|
||
return {
|
||
rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)],
|
||
disclosure: {
|
||
truncated: true,
|
||
totalRows: sourceRows.length,
|
||
includedRows: maxRows,
|
||
omittedRows: Math.max(0, sourceRows.length - maxRows),
|
||
headRows,
|
||
tailRows,
|
||
policy: "report-bounded-head-tail; full anomaly counters are computed before truncation",
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildTurnTimingTable(samples, timeline) {
|
||
const columns = [];
|
||
const registry = new Map();
|
||
const promptAssignmentByKey = new Map();
|
||
const rows = [];
|
||
for (let index = 0; index < samples.length; index += 1) {
|
||
const sample = samples[index];
|
||
const timelineItem = timeline[index] || {};
|
||
const cells = {};
|
||
const rawMetrics = turnMetricItems(sample, timelineItem);
|
||
const domIndexes = rawMetrics.map((item) => Number(item.domIndex)).filter(Number.isFinite);
|
||
const maxDomIndex = domIndexes.length > 0 ? Math.max(...domIndexes) : null;
|
||
for (const rawMetric of rawMetrics) {
|
||
const scopedKey = turnTimingScopedMetricKey(rawMetric, sample);
|
||
const samplePromptIndex = Number(timelineItem.promptIndex ?? 0);
|
||
const evidencePromptIndex = inferTurnMetricPromptIndex(rawMetric, samplePromptIndex, maxDomIndex);
|
||
if (evidencePromptIndex !== null) {
|
||
const existingPromptIndex = promptAssignmentByKey.get(scopedKey);
|
||
if (existingPromptIndex === undefined || evidencePromptIndex > existingPromptIndex) promptAssignmentByKey.set(scopedKey, evidencePromptIndex);
|
||
}
|
||
const assignedPromptIndex = promptAssignmentByKey.get(scopedKey) ?? null;
|
||
const metric = { ...rawMetric, key: scopedKey, baseKey: rawMetric.key, promptIndex: assignedPromptIndex, samplePromptIndex, pageEpoch: Number(sample?.pageEpoch ?? rawMetric.pageEpoch ?? 0) || 0 };
|
||
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,
|
||
pageRole: metric.pageRole ?? sample.pageRole ?? null,
|
||
pageId: metric.pageId ?? sample.pageId ?? null,
|
||
pageEpoch: metric.pageEpoch ?? null
|
||
};
|
||
registry.set(metric.key, column);
|
||
columns.push(column);
|
||
} else {
|
||
column.lastSeq = sample.seq ?? null;
|
||
column.lastTs = sample.ts ?? null;
|
||
if (column.source !== "turn" && metric.source === "turn") column.source = "turn";
|
||
if (metric.promptIndex && column.promptIndex !== metric.promptIndex) column.promptIndex = metric.promptIndex;
|
||
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,
|
||
pageRole: metric.pageRole ?? sample.pageRole ?? null,
|
||
pageId: metric.pageId ?? sample.pageId ?? null,
|
||
pageEpoch: metric.pageEpoch ?? null,
|
||
sampleGroupSeq: sample.sampleGroupSeq ?? null,
|
||
traceId: metric.traceId ?? null,
|
||
messageId: metric.messageId ?? null,
|
||
textHash: metric.textHash ?? null,
|
||
samplePromptIndex: metric.samplePromptIndex ?? null
|
||
};
|
||
}
|
||
rows.push({
|
||
ts: sample.ts ?? null,
|
||
seq: sample.seq ?? null,
|
||
sampleGroupSeq: sample.sampleGroupSeq ?? null,
|
||
pageRole: sample.pageRole ?? null,
|
||
pageId: sample.pageId ?? null,
|
||
pageEpoch: Number(sample?.pageEpoch ?? 0) || 0,
|
||
promptIndex: timelineItem.promptIndex ?? 0,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
cells
|
||
});
|
||
}
|
||
const timingEvents = detectTurnTimingNonMonotonic(columns, rows);
|
||
return {
|
||
columns,
|
||
rows,
|
||
nonMonotonic: timingEvents.anomalies,
|
||
elapsedZeroResets: timingEvents.elapsedZeroResets,
|
||
totalElapsedForwardJumps: timingEvents.totalElapsedForwardJumps,
|
||
terminalElapsedGrowth: timingEvents.terminalElapsedGrowth,
|
||
recentUpdateResets: timingEvents.recentUpdateResets,
|
||
recentUpdateSteps: timingEvents.recentUpdateSteps
|
||
};
|
||
}
|
||
|
||
function turnTimingScopedMetricKey(metric, sample) {
|
||
return [
|
||
metric?.key ?? "unknown",
|
||
metric?.pageRole ?? sample?.pageRole ?? "unknown-role",
|
||
metric?.pageId ?? sample?.pageId ?? "unknown-page",
|
||
Number(sample?.pageEpoch ?? metric?.pageEpoch ?? 0) || 0
|
||
].join("|page:");
|
||
}
|
||
|
||
function inferTurnMetricPromptIndex(metric, samplePromptIndex, maxDomIndex) {
|
||
const promptIndex = Number(samplePromptIndex);
|
||
if (!Number.isFinite(promptIndex) || promptIndex <= 0) return null;
|
||
if (metric?.source === "aggregate") return promptIndex;
|
||
if (isActiveTurnStatus(metric?.status)) return promptIndex;
|
||
const domIndex = Number(metric?.domIndex);
|
||
if (!isTerminalTurnStatus(metric?.status) && Number.isFinite(domIndex) && Number.isFinite(maxDomIndex) && domIndex === maxDomIndex && metric?.recentUpdateSeconds !== null && metric?.recentUpdateSeconds !== undefined) return promptIndex;
|
||
return null;
|
||
}
|
||
|
||
function detectTurnTimingNonMonotonic(columns, rows) {
|
||
const anomalies = [];
|
||
const elapsedZeroResets = [];
|
||
const totalElapsedForwardJumps = [];
|
||
const terminalElapsedGrowth = [];
|
||
const recentUpdateResets = [];
|
||
const recentUpdateSteps = [];
|
||
for (const column of columns) {
|
||
const previousByMetric = new Map();
|
||
let previousTerminalTotal = null;
|
||
for (const row of rows) {
|
||
const cell = row.cells?.[column.id];
|
||
if (!cell) continue;
|
||
for (const metric of ["totalElapsedSeconds", "recentUpdateSeconds"]) {
|
||
const value = cell[metric];
|
||
if (value === null || value === undefined || !Number.isFinite(Number(value))) continue;
|
||
const current = Number(value);
|
||
const previous = previousByMetric.get(metric);
|
||
if (previous && metric === "totalElapsedSeconds" && current < previous.value) {
|
||
const anomaly = previous.value > 0 && current === 0 ? "zero-reset" : "decrease";
|
||
const event = {
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric,
|
||
anomaly,
|
||
expectedPattern: anomaly === "zero-reset" ? "total-elapsed-should-not-return-to-zero" : "total-elapsed-monotonic",
|
||
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 ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
anomalies.push(event);
|
||
if (anomaly === "zero-reset") elapsedZeroResets.push(event);
|
||
}
|
||
if (previous && metric === "totalElapsedSeconds" && current > previous.value) {
|
||
const sampleDeltaSeconds = elapsedSecondsBetween(previous.ts, row.ts);
|
||
const delta = current - previous.value;
|
||
const allowedIncreaseSeconds = sampleDeltaSeconds + alertThresholds.turnTimingSampleSlackSeconds;
|
||
const terminalTransition = !isTerminalTurnStatus(previous.status) && isTerminalTurnStatus(cell.status);
|
||
if (delta > allowedIncreaseSeconds && !terminalTransition) {
|
||
totalElapsedForwardJumps.push({
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric,
|
||
anomaly: "forward-jump",
|
||
expectedPattern: "total-elapsed-increase-should-match-browser-sample-interval",
|
||
fromSeq: previous.seq,
|
||
fromTs: previous.ts,
|
||
fromValue: previous.value,
|
||
fromStatus: previous.status ?? null,
|
||
toSeq: row.seq ?? null,
|
||
toTs: row.ts ?? null,
|
||
toValue: current,
|
||
toStatus: cell.status ?? null,
|
||
delta,
|
||
sampleDeltaSeconds,
|
||
allowedIncreaseSeconds,
|
||
excessiveIncreaseSeconds: Number((delta - allowedIncreaseSeconds).toFixed(3)),
|
||
traceId: cell.traceId ?? column.traceId ?? null,
|
||
messageId: cell.messageId ?? column.messageId ?? null,
|
||
promptIndex: cell.promptIndex ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
}
|
||
if (metric === "totalElapsedSeconds" && isTerminalTurnStatus(cell.status)) {
|
||
if (previousTerminalTotal && current > previousTerminalTotal.value) {
|
||
terminalElapsedGrowth.push({
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric,
|
||
anomaly: "terminal-growth",
|
||
expectedPattern: "terminal-total-elapsed-sealed",
|
||
fromSeq: previousTerminalTotal.seq,
|
||
fromTs: previousTerminalTotal.ts,
|
||
fromValue: previousTerminalTotal.value,
|
||
fromStatus: previousTerminalTotal.status,
|
||
toSeq: row.seq ?? null,
|
||
toTs: row.ts ?? null,
|
||
toValue: current,
|
||
toStatus: cell.status ?? null,
|
||
delta: current - previousTerminalTotal.value,
|
||
traceId: cell.traceId ?? column.traceId ?? null,
|
||
messageId: cell.messageId ?? column.messageId ?? null,
|
||
promptIndex: cell.promptIndex ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
previousTerminalTotal = { value: current, seq: row.seq ?? null, ts: row.ts ?? null, status: cell.status ?? null };
|
||
}
|
||
if (previous && metric === "recentUpdateSeconds") {
|
||
const elapsedMs = Date.parse(String(row.ts ?? "")) - Date.parse(String(previous.ts ?? ""));
|
||
const elapsedSeconds = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs / 1000 : null;
|
||
const increase = current - previous.value;
|
||
const allowedIncrease = elapsedSeconds === null
|
||
? alertThresholds.turnTimingSampleSlackSeconds
|
||
: Math.max(alertThresholds.turnTimingSampleSlackSeconds, elapsedSeconds + alertThresholds.turnTimingSampleSlackSeconds);
|
||
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 ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? 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 ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? 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 ?? null,
|
||
samplePromptIndex: row.promptIndex ?? null,
|
||
source: cell.source ?? column.source ?? null,
|
||
pageRole: cell.pageRole ?? column.pageRole ?? null,
|
||
pageId: cell.pageId ?? column.pageId ?? null,
|
||
pageEpoch: cell.pageEpoch ?? row.pageEpoch ?? column.pageEpoch ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
}
|
||
previousByMetric.set(metric, { value: current, seq: row.seq ?? null, ts: row.ts ?? null, status: cell.status ?? null });
|
||
}
|
||
}
|
||
}
|
||
return { anomalies, elapsedZeroResets, totalElapsedForwardJumps, terminalElapsedGrowth, recentUpdateResets, recentUpdateSteps };
|
||
}
|
||
|
||
function elapsedSecondsBetween(fromTs, toTs) {
|
||
const from = Date.parse(fromTs);
|
||
const to = Date.parse(toTs);
|
||
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return 0;
|
||
return Number(((to - from) / 1000).toFixed(3));
|
||
}
|
||
|
||
function maxPositiveDelta(items) {
|
||
const values = (Array.isArray(items) ? items : [])
|
||
.map((item) => Number(item.delta))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
return values.length > 0 ? Math.max(...values) : 0;
|
||
}
|
||
|
||
function isTerminalTurnStatus(value) {
|
||
const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||
return ["completed", "succeeded", "success", "failed", "error", "blocked", "timeout", "canceled", "cancelled", "stale", "done", "terminal", "thread-resume-failed"].includes(status);
|
||
}
|
||
|
||
|
||
|
||
function buildTraceOrderMetrics(samples, timeline) {
|
||
const rows = [];
|
||
const orderAnomalies = [];
|
||
const completionNotLast = [];
|
||
const groups = new Map();
|
||
for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) {
|
||
const sample = samples[sampleIndex] || {};
|
||
const sampleRows = traceTimingRowsForSample(sample, timeline[sampleIndex] || {});
|
||
for (const row of sampleRows) {
|
||
const normalized = {
|
||
...row,
|
||
sampleIndex,
|
||
sampleSeq: sample.seq ?? null,
|
||
timestamp: sample.ts || sample.timestamp || sample.collectedAt || sample.time || null,
|
||
pageRole: row.pageRole || sample.pageRole || sample.role || sample.contextRole || null,
|
||
pageId: row.pageId || sample.pageId || sample.contextId || null,
|
||
sessionId: row.sessionId || sample.sessionId || sample.workbenchSessionId || null,
|
||
};
|
||
rows.push(normalized);
|
||
const key = traceRowGroupKey(normalized);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(normalized);
|
||
}
|
||
}
|
||
const slackSeconds = Math.max(1, Number(alertThresholds?.turnTimingSampleSlackSeconds || 0));
|
||
for (const groupRows of groups.values()) {
|
||
const sorted = groupRows.slice().sort((a, b) => {
|
||
if (a.sampleIndex !== b.sampleIndex) return a.sampleIndex - b.sampleIndex;
|
||
return (a.rowIndex ?? 0) - (b.rowIndex ?? 0);
|
||
});
|
||
let previous = null;
|
||
for (const row of sorted) {
|
||
if (previous) {
|
||
const reasons = [];
|
||
if (Number.isFinite(previous.totalSeconds) && Number.isFinite(row.totalSeconds) && row.totalSeconds + slackSeconds < previous.totalSeconds) {
|
||
reasons.push('total-decreased');
|
||
}
|
||
if (Number.isFinite(previous.projectedSeq) && Number.isFinite(row.projectedSeq) && row.projectedSeq < previous.projectedSeq) {
|
||
reasons.push('projected-seq-decreased');
|
||
}
|
||
if (Number.isFinite(previous.clockSeconds) && Number.isFinite(row.clockSeconds)) {
|
||
const diff = previous.clockSeconds - row.clockSeconds;
|
||
if (diff > slackSeconds && diff < 43200) reasons.push('clock-decreased');
|
||
}
|
||
if (Number.isFinite(previous.timestampMs) && Number.isFinite(row.timestampMs) && row.timestampMs + slackSeconds * 1000 < previous.timestampMs) {
|
||
reasons.push('timestamp-decreased');
|
||
}
|
||
if (reasons.length) {
|
||
orderAnomalies.push({
|
||
sampleIndex: row.sampleIndex,
|
||
sampleSeq: row.sampleSeq,
|
||
timestamp: row.timestamp,
|
||
pageRole: row.pageRole,
|
||
pageId: row.pageId,
|
||
sessionId: row.sessionId,
|
||
traceId: row.traceId || previous.traceId || null,
|
||
previousRowIndex: previous.rowIndex,
|
||
currentRowIndex: row.rowIndex,
|
||
reasons,
|
||
previousTotalSeconds: previous.totalSeconds,
|
||
currentTotalSeconds: row.totalSeconds,
|
||
previousProjectedSeq: previous.projectedSeq,
|
||
currentProjectedSeq: row.projectedSeq,
|
||
previousSourceSeq: previous.sourceSeq,
|
||
currentSourceSeq: row.sourceSeq,
|
||
previousEventSeq: previous.eventSeq,
|
||
currentEventSeq: row.eventSeq,
|
||
previousClockSeconds: previous.clockSeconds,
|
||
currentClockSeconds: row.clockSeconds,
|
||
previousTimestampMs: previous.timestampMs,
|
||
currentTimestampMs: row.timestampMs,
|
||
previousPreview: previous.preview,
|
||
currentPreview: row.preview,
|
||
});
|
||
}
|
||
}
|
||
previous = row;
|
||
}
|
||
for (let index = 0; index < sorted.length; index += 1) {
|
||
const row = sorted[index];
|
||
if (!row.isCompletion) continue;
|
||
const later = sorted.slice(index + 1).find((candidate) => {
|
||
if (candidate.isCompletion && candidate.preview === row.preview) return false;
|
||
return Number.isFinite(candidate.totalSeconds) || Number.isFinite(candidate.clockSeconds) || Number.isFinite(candidate.projectedSeq);
|
||
});
|
||
if (later) {
|
||
completionNotLast.push({
|
||
sampleIndex: row.sampleIndex,
|
||
sampleSeq: row.sampleSeq,
|
||
timestamp: row.timestamp,
|
||
pageRole: row.pageRole,
|
||
pageId: row.pageId,
|
||
sessionId: row.sessionId,
|
||
traceId: row.traceId || later.traceId || null,
|
||
completionRowIndex: row.rowIndex,
|
||
laterRowIndex: later.rowIndex,
|
||
completionTotalSeconds: row.totalSeconds,
|
||
laterTotalSeconds: later.totalSeconds,
|
||
completionProjectedSeq: row.projectedSeq,
|
||
laterProjectedSeq: later.projectedSeq,
|
||
completionSourceSeq: row.sourceSeq,
|
||
laterSourceSeq: later.sourceSeq,
|
||
completionEventSeq: row.eventSeq,
|
||
laterEventSeq: later.eventSeq,
|
||
completionPreview: row.preview,
|
||
laterPreview: later.preview,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
summary: {
|
||
sampleCount: samples.length,
|
||
traceRowCount: rows.length,
|
||
orderAnomalyCount: orderAnomalies.length,
|
||
completionNotLastCount: completionNotLast.length,
|
||
},
|
||
rows: rows.slice(0, 200),
|
||
orderAnomalies: orderAnomalies.slice(0, 100),
|
||
completionNotLast: completionNotLast.slice(0, 100),
|
||
};
|
||
}
|
||
|
||
function buildCodeAgentCardDurationUnderreportedMetrics(samples, timeline) {
|
||
const findings = [];
|
||
const slackSeconds = Math.max(5, Number(alertThresholds?.turnTimingSampleSlackSeconds || 0));
|
||
for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) {
|
||
const sample = samples[sampleIndex] || {};
|
||
const cards = codeAgentCardsForSample(sample);
|
||
if (!cards.length) continue;
|
||
const traceRows = traceTimingRowsForSample(sample, timeline[sampleIndex] || {});
|
||
const terminalCards = cards.filter((card) => isCodeAgentCardTerminal(card));
|
||
const sampleText = sampleVisibleText(sample, timeline[sampleIndex] || {});
|
||
for (const card of terminalCards) {
|
||
const cardText = codeAgentCardText(card);
|
||
const parsedCardSeconds = parseTotalElapsedSeconds(cardText).filter(Number.isFinite);
|
||
const cardSeconds = Number.isFinite(Number(card.totalElapsedSeconds)) ? Number(card.totalElapsedSeconds) : parsedCardSeconds.length > 0 ? Math.max(...parsedCardSeconds) : NaN;
|
||
if (!Number.isFinite(cardSeconds)) continue;
|
||
const traceMatched = traceRows.filter((row) => traceRowMatchesCard(row, card, terminalCards.length));
|
||
const traceEvidence = maxTraceDurationEvidence(traceMatched);
|
||
const textEvidence = maxSelfReportedDurationEvidence([card.text, card.preview, card.finalResponseText, card.runningRecordText, terminalCards.length === 1 ? sampleText : ''].filter(Boolean).join('\n'));
|
||
const evidences = [traceEvidence, textEvidence].filter((item) => item && item.exact === true && Number.isFinite(item.seconds));
|
||
if (!evidences.length) continue;
|
||
const strongest = evidences.sort((a, b) => b.seconds - a.seconds)[0];
|
||
const tolerance = Math.max(slackSeconds, Math.ceil(strongest.seconds * 0.05));
|
||
if (strongest.seconds > cardSeconds + tolerance) {
|
||
findings.push({
|
||
sampleIndex,
|
||
timestamp: sample.timestamp || sample.collectedAt || sample.time || null,
|
||
pageRole: card.pageRole || sample.pageRole || sample.role || sample.contextRole || null,
|
||
pageId: card.pageId || sample.pageId || sample.contextId || null,
|
||
sessionId: card.sessionId || sample.sessionId || sample.workbenchSessionId || null,
|
||
traceId: card.traceId || strongest.traceId || null,
|
||
status: card.status || card.state || card.phase || null,
|
||
cardTotalElapsedSeconds: cardSeconds,
|
||
expectedElapsedSeconds: strongest.seconds,
|
||
deltaSeconds: strongest.seconds - cardSeconds,
|
||
toleranceSeconds: tolerance,
|
||
evidenceKind: strongest.kind,
|
||
evidencePreview: strongest.preview,
|
||
cardPreview: card.preview || compactOneLine(cardText || ''),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return findings.slice(0, 100);
|
||
}
|
||
|
||
function buildCodeAgentCardDurationMismatchMetrics(samples, timeline) {
|
||
const findings = [];
|
||
const slackSeconds = Math.max(5, Number(alertThresholds?.turnTimingSampleSlackSeconds || 0));
|
||
for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) {
|
||
const sample = samples[sampleIndex] || {};
|
||
const cards = codeAgentCardsForSample(sample);
|
||
if (!cards.length) continue;
|
||
const traceRows = traceTimingRowsForSample(sample, timeline[sampleIndex] || {});
|
||
const terminalCards = cards.filter((card) => isCodeAgentCardTerminal(card));
|
||
const sampleText = sampleVisibleText(sample, timeline[sampleIndex] || {});
|
||
for (const card of terminalCards) {
|
||
const cardText = codeAgentCardText(card);
|
||
const parsedCardSeconds = parseTotalElapsedSeconds(cardText).filter(Number.isFinite);
|
||
const cardSeconds = Number.isFinite(Number(card.totalElapsedSeconds)) ? Number(card.totalElapsedSeconds) : parsedCardSeconds.length > 0 ? Math.max(...parsedCardSeconds) : NaN;
|
||
if (!Number.isFinite(cardSeconds)) continue;
|
||
const traceMatched = traceRows.filter((row) => traceRowMatchesCard(row, card, terminalCards.length));
|
||
const traceEvidence = maxTraceDurationEvidence(traceMatched);
|
||
const textEvidence = maxSelfReportedDurationEvidence([card.text, card.preview, card.finalResponseText, card.runningRecordText, terminalCards.length === 1 ? sampleText : ''].filter(Boolean).join('\n'));
|
||
const evidences = [traceEvidence, textEvidence].filter((item) => item && item.exact === true && Number.isFinite(item.seconds));
|
||
if (!evidences.length) continue;
|
||
const strongest = evidences.sort((a, b) => b.seconds - a.seconds)[0];
|
||
const exactEvidence = strongest.exact === true || strongest.kind === 'trace-completion-total' || strongest.kind === 'final-response-duration';
|
||
const tolerance = Math.max(slackSeconds, Math.ceil(strongest.seconds * 0.05));
|
||
const signedDelta = Number((cardSeconds - strongest.seconds).toFixed(3));
|
||
const absoluteDelta = Math.abs(signedDelta);
|
||
const underreported = strongest.seconds > cardSeconds + tolerance;
|
||
const overreported = exactEvidence && cardSeconds > strongest.seconds + tolerance;
|
||
if (!underreported && !overreported) continue;
|
||
findings.push({
|
||
sampleIndex,
|
||
timestamp: sample.timestamp || sample.collectedAt || sample.time || sample.ts || null,
|
||
pageRole: card.pageRole || sample.pageRole || sample.role || sample.contextRole || null,
|
||
pageId: card.pageId || sample.pageId || sample.contextId || null,
|
||
sessionId: card.sessionId || sample.sessionId || sample.workbenchSessionId || null,
|
||
traceId: card.traceId || strongest.traceId || null,
|
||
status: card.status || card.state || card.phase || null,
|
||
direction: underreported ? 'underreported' : 'overreported',
|
||
cardTotalElapsedSeconds: cardSeconds,
|
||
expectedElapsedSeconds: strongest.seconds,
|
||
signedDeltaSeconds: signedDelta,
|
||
deltaSeconds: Number(absoluteDelta.toFixed(3)),
|
||
toleranceSeconds: tolerance,
|
||
evidenceKind: strongest.kind,
|
||
exactEvidence,
|
||
evidencePreview: strongest.preview,
|
||
cardPreview: card.preview || compactOneLine(cardText || ''),
|
||
});
|
||
}
|
||
}
|
||
return findings.slice(0, 100);
|
||
}
|
||
|
||
function traceTimingRowsForSample(sample, timelineItem) {
|
||
const rows = [];
|
||
const seen = new Set();
|
||
const appendRowsFromText = (text, source, baseIndex, meta = {}) => {
|
||
for (const extracted of extractTraceRowsFromText(text, source, baseIndex, meta)) {
|
||
const key = [extracted.pageRole || '', extracted.pageId || '', extracted.rowIndex, extracted.preview].join('|');
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
rows.push(extracted);
|
||
}
|
||
};
|
||
for (const candidate of traceRowCandidateArrays(sample, timelineItem)) {
|
||
const array = Array.isArray(candidate.rows) ? candidate.rows : [];
|
||
array.forEach((item, index) => {
|
||
if (typeof item === 'string') {
|
||
appendRowsFromText(item, candidate.source, index, candidate.meta || {});
|
||
return;
|
||
}
|
||
if (!item || typeof item !== 'object') return;
|
||
const text = objectText(item);
|
||
if (!text) return;
|
||
appendRowsFromText(text, candidate.source, Number.isFinite(Number(item.index)) ? Number(item.index) : index, {
|
||
...(candidate.meta || {}),
|
||
pageRole: item.pageRole || item.role || candidate.meta?.pageRole || null,
|
||
pageId: item.pageId || item.contextId || candidate.meta?.pageId || null,
|
||
sessionId: item.sessionId || item.workbenchSessionId || candidate.meta?.sessionId || null,
|
||
traceId: item.traceId || item.trace_id || candidate.meta?.traceId || null,
|
||
projectedSeq: item.projectedSeq ?? item.projected_seq ?? item.projectedSequence ?? null,
|
||
sourceSeq: item.sourceSeq ?? item.source_seq ?? item.sourceSequence ?? null,
|
||
eventSeq: item.eventSeq ?? item.event_seq ?? item.sequence ?? null,
|
||
eventTimestamp: item.eventTimestamp ?? item.event_ts ?? item.timestamp ?? item.ts ?? null,
|
||
eventTimeText: item.eventTimeText ?? item.timeText ?? null,
|
||
eventKind: item.eventKind ?? item.kind ?? item.status ?? null,
|
||
});
|
||
});
|
||
}
|
||
if (!rows.length) {
|
||
appendRowsFromText(sampleVisibleText(sample, timelineItem), 'visible-text', 0, {
|
||
pageRole: sample.pageRole || sample.role || sample.contextRole || null,
|
||
pageId: sample.pageId || sample.contextId || null,
|
||
sessionId: sample.sessionId || sample.workbenchSessionId || null,
|
||
});
|
||
}
|
||
rows.sort((a, b) => (a.rowIndex ?? 0) - (b.rowIndex ?? 0));
|
||
return rows;
|
||
}
|
||
|
||
function extractTraceRowsFromText(text, source, baseIndex, meta = {}) {
|
||
const result = [];
|
||
const normalized = String(text || '').replace(/\r/g, '\n');
|
||
if (!normalized.trim()) return result;
|
||
const lines = normalized.split('\n').map((line) => line.trim()).filter(Boolean);
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
if (!traceLineLooksRelevant(line)) continue;
|
||
const nextLine = index + 1 < lines.length && !/^\d{1,2}:\d{2}:\d{2}\b/.test(lines[index + 1]) ? lines[index + 1] : '';
|
||
const preview = compactOneLine(nextLine ? line + ' ' + nextLine : line);
|
||
result.push(normalizeTraceTimingRow(preview, source, Number(baseIndex || 0) * 1000 + index, meta));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function normalizeTraceTimingRow(text, source, rowIndex, meta = {}) {
|
||
const preview = compactOneLine(text).slice(0, 240);
|
||
const projectedSeq = firstFiniteNumber(meta.projectedSeq, parseTraceRowProjectedSeq(preview));
|
||
const sourceSeq = firstFiniteNumber(meta.sourceSeq);
|
||
const eventSeq = firstFiniteNumber(meta.eventSeq);
|
||
const timestampMs = parseTraceRowTimestampMs(meta.eventTimestamp || meta.eventTimeText || preview);
|
||
return {
|
||
source,
|
||
rowIndex,
|
||
preview,
|
||
pageRole: meta.pageRole || null,
|
||
pageId: meta.pageId || null,
|
||
sessionId: meta.sessionId || null,
|
||
traceId: meta.traceId || parseTraceRowTraceId(preview),
|
||
clockSeconds: parseTraceRowClockSeconds(preview) ?? parseTraceRowClockSeconds(meta.eventTimeText || ""),
|
||
timestampMs,
|
||
totalSeconds: parseTraceRowTotalSeconds(preview),
|
||
projectedSeq,
|
||
sourceSeq,
|
||
eventSeq,
|
||
eventTimestamp: meta.eventTimestamp || null,
|
||
eventTimeText: meta.eventTimeText || null,
|
||
eventKind: meta.eventKind || null,
|
||
isCompletion: traceRowIsTerminalCompletionText(preview, meta.eventKind || ""),
|
||
};
|
||
}
|
||
|
||
function traceRowIsTerminalCompletionText(preview, eventKind = "") {
|
||
const value = [preview, eventKind].map((item) => String(item || "")).join(" ");
|
||
if (/\bnon[-_ ]?terminal\b/i.test(value)) return false;
|
||
if (/轮次完成|turn\s+completed|completed\s+turn|backend[_: -]?turn[_: -]?finished/i.test(value)) return true;
|
||
return /\bterminal(?:[_: -]?status|Status)?\s*[=: -]\s*(?:completed|failed|cancelled|canceled|timeout)\b/i.test(value);
|
||
}
|
||
|
||
function traceRowIsRoundCompletionText(preview, eventKind = "") {
|
||
const value = [preview, eventKind].map((item) => String(item || "")).join(" ");
|
||
if (/\bnon[-_ ]?terminal\b/i.test(value)) return false;
|
||
return /轮次完成|turn\s+completed|completed\s+turn|backend[_: -]?turn[_: -]?finished/i.test(value);
|
||
}
|
||
|
||
function traceRowCandidateArrays(sample, timelineItem) {
|
||
const candidates = [];
|
||
const pushArray = (rows, source, meta = {}) => {
|
||
if (Array.isArray(rows) && rows.length) candidates.push({ rows, source, meta });
|
||
};
|
||
const directSources = [
|
||
[sample?.traceRows, 'sample.traceRows'],
|
||
[sample?.eventRows, 'sample.eventRows'],
|
||
[sample?.activityRows, 'sample.activityRows'],
|
||
[sample?.timelineRows, 'sample.timelineRows'],
|
||
[sample?.dom?.traceRows, 'sample.dom.traceRows'],
|
||
[sample?.dom?.eventRows, 'sample.dom.eventRows'],
|
||
[sample?.dom?.activityRows, 'sample.dom.activityRows'],
|
||
[sample?.dom?.timelineRows, 'sample.dom.timelineRows'],
|
||
[timelineItem?.traceRows, 'timeline.traceRows'],
|
||
[timelineItem?.eventRows, 'timeline.eventRows'],
|
||
[timelineItem?.activityRows, 'timeline.activityRows'],
|
||
[timelineItem?.rows, 'timeline.rows'],
|
||
[timelineItem?.events, 'timeline.events'],
|
||
];
|
||
for (const [rows, source] of directSources) pushArray(rows, source, {});
|
||
if (!candidates.length) collectNamedTraceArrays(sample, candidates, 'sample', 0);
|
||
if (!candidates.length) collectNamedTraceArrays(timelineItem, candidates, 'timeline', 0);
|
||
return candidates;
|
||
}
|
||
|
||
function collectNamedTraceArrays(value, candidates, path, depth) {
|
||
if (!value || depth > 5) return;
|
||
if (Array.isArray(value)) {
|
||
const pathLooksTrace = /trace|timeline|activity|event|log|record/i.test(path);
|
||
const valueLooksTrace = value.slice(0, 5).some((item) => traceLineLooksRelevant(typeof item === 'string' ? item : objectText(item)));
|
||
if (pathLooksTrace || valueLooksTrace) candidates.push({ rows: value, source: path, meta: {} });
|
||
return;
|
||
}
|
||
if (typeof value !== 'object') return;
|
||
for (const [key, child] of Object.entries(value)) {
|
||
if (!child) continue;
|
||
if (Array.isArray(child)) {
|
||
const childPath = path + '.' + key;
|
||
const pathLooksTrace = /trace|timeline|activity|event|log|record/i.test(key);
|
||
const valueLooksTrace = child.slice(0, 5).some((item) => traceLineLooksRelevant(typeof item === 'string' ? item : objectText(item)));
|
||
if (pathLooksTrace || valueLooksTrace) candidates.push({ rows: child, source: childPath, meta: {} });
|
||
continue;
|
||
}
|
||
if (typeof child === 'object' && /dom|trace|timeline|activity|event|log|record|page|card|message|panel|diagnostic/i.test(key)) {
|
||
collectNamedTraceArrays(child, candidates, path + '.' + key, depth + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
function traceLineLooksRelevant(text) {
|
||
const value = String(text || '').trim();
|
||
if (!value) return false;
|
||
if (/^\d{1,2}:\d{2}:\d{2}\b/.test(value)) return true;
|
||
if (/\btotal=\d/.test(value)) return true;
|
||
if (/轮次完成(总耗时/.test(value)) return true;
|
||
if (/\bseq(?:uence)?[=:]\s*\d+/i.test(value)) return true;
|
||
return false;
|
||
}
|
||
|
||
function parseTraceRowClockSeconds(text) {
|
||
const match = String(text || '').match(/^\s*(\d{1,2}):(\d{2}):(\d{2})\b/);
|
||
if (!match) return null;
|
||
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
||
}
|
||
|
||
function parseTraceRowTotalSeconds(text) {
|
||
const value = String(text || '');
|
||
const totalMatch = value.match(/\btotal=([0-9:.]+)/i);
|
||
if (totalMatch) return parseTraceDurationSeconds(totalMatch[1]);
|
||
const completionMatch = value.match(/总耗时\s*([0-9:.]+)/);
|
||
if (completionMatch) return parseTraceDurationSeconds(completionMatch[1]);
|
||
return null;
|
||
}
|
||
|
||
function parseTraceDurationSeconds(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return null;
|
||
const parts = text.split(':').map((part) => Number(part));
|
||
if (parts.some((part) => !Number.isFinite(part))) return null;
|
||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||
if (parts.length === 1) return parts[0];
|
||
return null;
|
||
}
|
||
|
||
function parseTraceRowProjectedSeq(text) {
|
||
const value = String(text || '');
|
||
const match = value.match(/\b(?:projected[_-]?seq|seq(?:uence)?|event[_-]?seq)\s*[=:]\s*(\d+)/i);
|
||
return match ? Number(match[1]) : null;
|
||
}
|
||
|
||
function parseTraceRowTimestampMs(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return null;
|
||
const parsed = Date.parse(text);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function firstFiniteNumber(...values) {
|
||
for (const value of values) {
|
||
const numeric = Number(value);
|
||
if (Number.isFinite(numeric)) return numeric;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseTraceRowTraceId(text) {
|
||
const match = String(text || '').match(/\b(?:trace_id=|traceId[:=]?\s*)(trc_[a-z0-9_-]+|[a-f0-9]{16,})\b/i);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
function traceRowGroupKey(row) {
|
||
const identity = row.traceId ? 'trace:' + row.traceId : 'sample:' + (row.sampleIndex ?? '-') + ':' + (row.source || 'unknown');
|
||
return [row.pageRole || '', row.pageId || '', row.sessionId || '', row.source || '', row.sampleIndex ?? '', identity].join('|');
|
||
}
|
||
|
||
function traceRowMatchesCard(row, card, terminalCardCount) {
|
||
if (!row) return false;
|
||
if (card.traceId) return row.traceId === card.traceId;
|
||
if (row.traceId) return false;
|
||
if (row.sessionId && card.sessionId) return terminalCardCount === 1 && row.sessionId === card.sessionId;
|
||
if (terminalCardCount === 1) return true;
|
||
return false;
|
||
}
|
||
|
||
function maxTraceDurationEvidence(rows) {
|
||
const finiteTotals = rows.map((row) => Number(row.totalSeconds)).filter(Number.isFinite);
|
||
const finiteClocks = rows.map((row) => Number(row.clockSeconds)).filter(Number.isFinite);
|
||
const evidences = [];
|
||
if (finiteTotals.length) {
|
||
const maxTotal = Math.max(...finiteTotals);
|
||
const source = rows.find((row) => Number(row.totalSeconds) === maxTotal);
|
||
const exact = source?.isCompletion === true;
|
||
evidences.push({ kind: exact ? 'trace-completion-total' : 'trace-total', seconds: maxTotal, traceId: source?.traceId || null, preview: source?.preview || '', exact });
|
||
}
|
||
if (finiteClocks.length >= 2) {
|
||
const minClock = Math.min(...finiteClocks);
|
||
const maxClock = Math.max(...finiteClocks);
|
||
const span = maxClock - minClock;
|
||
if (span >= 0 && span < 43200) evidences.push({ kind: 'trace-clock-span', seconds: span, traceId: null, preview: 'visible trace row clock span', exact: false });
|
||
}
|
||
if (!evidences.length) return null;
|
||
evidences.sort((a, b) => b.seconds - a.seconds);
|
||
return evidences[0];
|
||
}
|
||
|
||
function maxSelfReportedDurationEvidence(text) {
|
||
const value = String(text || '');
|
||
const lines = value.split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
||
let best = null;
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
const previous = index > 0 ? lines[index - 1] : '';
|
||
const next = index + 1 < lines.length ? lines[index + 1] : '';
|
||
const candidateText = selfReportedDurationCandidateText(previous, line, next);
|
||
if (!candidateText) continue;
|
||
const seconds = parseSelfReportedRoundDurationSeconds(candidateText);
|
||
if (!Number.isFinite(seconds)) continue;
|
||
if (!best || seconds > best.seconds) {
|
||
best = { kind: 'final-response-duration', seconds, preview: compactOneLine(candidateText).slice(0, 240), exact: true };
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function selfReportedDurationCandidateText(previous, line, next) {
|
||
const current = String(line || '');
|
||
const before = String(previous || '');
|
||
const after = String(next || '');
|
||
const windowText = [before, current, after].filter(Boolean).join(' ');
|
||
const hasDurationKeyword = /耗时|用时|duration|elapsed/i.test(current);
|
||
const hasNearbyDurationHeading = /(?:本轮|整轮|全程|任务|round|turn)?\s*(?:耗时|用时|duration|elapsed)/i.test(before)
|
||
|| /(?:本轮|整轮|全程|任务|round|turn)?\s*(?:耗时|用时|duration|elapsed)/i.test(after);
|
||
const hasDurationValue = /(?:约|大约|around|about)?\s*\d+(?:\.\d+)?\s*(?:小时|分钟|分|秒|hour|hours|hr|hrs|min|mins|minute|minutes|sec|secs|second|seconds)/i.test(windowText);
|
||
const hasRoundContext = /本轮|整轮|全程|从.+到|全部通过|smoke|benchmark|round|turn|completed|passed/i.test(windowText);
|
||
if (hasDurationKeyword && hasDurationValue) return current;
|
||
if (hasNearbyDurationHeading && hasDurationValue) return windowText;
|
||
if (hasRoundContext && hasDurationValue && /(?:约|大约|around|about)\s*\d|\d+(?:\.\d+)?\s*(?:分钟|小时|minute|hour)/i.test(current)) return windowText;
|
||
return '';
|
||
}
|
||
|
||
function parseSelfReportedRoundDurationSeconds(text) {
|
||
const value = String(text || '');
|
||
const clock = value.match(/(?:耗时|用时|duration|elapsed)[^0-9]{0,24}(\d{1,2}:\d{2}:\d{2}|\d{1,2}:\d{2})/i);
|
||
if (clock) return parseTraceDurationSeconds(clock[1]);
|
||
const hour = value.match(/(?:约|大约|around|about)?\s*(\d+(?:\.\d+)?)\s*(?:小时|hour|hours|hr|hrs)/i);
|
||
const minute = value.match(/(?:约|大约|around|about)?\s*(\d+(?:\.\d+)?)\s*(?:分钟|分|min|mins|minute|minutes)/i);
|
||
const second = value.match(/(?:约|大约|around|about)?\s*(\d+(?:\.\d+)?)\s*(?:秒|sec|secs|second|seconds)/i);
|
||
let total = 0;
|
||
if (hour) total += Number(hour[1]) * 3600;
|
||
if (minute) total += Number(minute[1]) * 60;
|
||
if (second) total += Number(second[1]);
|
||
return total > 0 ? total : null;
|
||
}
|
||
|
||
function sampleVisibleText(sample, timelineItem) {
|
||
const chunks = [];
|
||
for (const source of [sample?.visibleText, sample?.text, sample?.innerText, sample?.dom?.visibleText, sample?.dom?.text, timelineItem?.visibleText, timelineItem?.text, timelineItem?.message, timelineItem?.summary]) {
|
||
if (typeof source === 'string' && source.trim()) chunks.push(source);
|
||
}
|
||
return chunks.join('\n');
|
||
}
|
||
|
||
function objectText(value) {
|
||
if (!value || typeof value !== 'object') return typeof value === 'string' ? value : '';
|
||
const keys = ['text', 'innerText', 'visibleText', 'label', 'title', 'summary', 'message', 'content', 'body', 'preview', 'description'];
|
||
const chunks = [];
|
||
for (const key of keys) {
|
||
const part = value[key];
|
||
if (typeof part === 'string' && part.trim()) chunks.push(part);
|
||
}
|
||
return chunks.join('\n');
|
||
}
|
||
|
||
function compactOneLine(value) {
|
||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function buildCodeAgentCardTimingMetrics(samples, timeline, turnTiming) {
|
||
const missingElapsed = [];
|
||
const missingRecentUpdate = [];
|
||
const cardRows = [];
|
||
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
|
||
const sample = samples[index];
|
||
const timelineItem = timeline[index] || {};
|
||
for (const card of codeAgentCardsForSample(sample)) {
|
||
const text = codeAgentCardText(card);
|
||
const totalElapsedValues = parseTotalElapsedSeconds(card?.durationText).filter(Number.isFinite);
|
||
const recentUpdateValues = parseRecentUpdateSeconds(card?.activityText).filter(Number.isFinite);
|
||
const terminal = isCodeAgentCardTerminal(card);
|
||
const row = {
|
||
...ref(sample),
|
||
promptIndex: timelineItem.promptIndex ?? 0,
|
||
source: card.source ?? "turn",
|
||
status: card.status ?? null,
|
||
messageId: card.messageId ?? null,
|
||
traceId: card.traceId ?? firstTraceId([text]),
|
||
sessionId: card.sessionId ?? sample.sessionId ?? sample.workbenchSessionId ?? sample.routeSessionId ?? sample.activeSessionId ?? null,
|
||
durationText: limitText(card.durationText, 120),
|
||
activityText: limitText(card.activityText, 120),
|
||
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
|
||
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
|
||
terminal,
|
||
textHash: card.textHash ?? sha256(text),
|
||
textPreview: limitText(text, 180),
|
||
valuesRedacted: true
|
||
};
|
||
cardRows.push(row);
|
||
if (row.totalElapsedSeconds === null) missingElapsed.push(row);
|
||
if (!terminal && row.recentUpdateSeconds === null) missingRecentUpdate.push(row);
|
||
}
|
||
}
|
||
const roundCompletion = buildRoundCompletionMetrics(samples, timeline, turnTiming);
|
||
return {
|
||
summary: {
|
||
cardSampleCount: cardRows.length,
|
||
terminalCardSampleCount: cardRows.filter((item) => item.terminal).length,
|
||
runningCardSampleCount: cardRows.filter((item) => !item.terminal).length,
|
||
missingElapsedCount: missingElapsed.length,
|
||
missingRecentUpdateCount: missingRecentUpdate.length,
|
||
roundCompletionEventCount: roundCompletion.events.length,
|
||
roundCompletionElapsedMismatchCount: roundCompletion.elapsedMismatches.length,
|
||
roundCompletionFinalResponseMissingCount: roundCompletion.finalResponseMissing.length,
|
||
roundCompletionPostTimingChangeCount: roundCompletion.postCompletionTimingChanges.length,
|
||
roundCompletionPostRecentUpdateVisibleCount: roundCompletion.postCompletionRecentUpdateVisible.length,
|
||
elapsedMismatchToleranceSeconds: alertThresholds.turnTimingSampleSlackSeconds,
|
||
},
|
||
cardSamples: cardRows.slice(0, 200),
|
||
missingElapsed: missingElapsed.slice(0, 200),
|
||
missingRecentUpdate: missingRecentUpdate.slice(0, 200),
|
||
roundCompletion,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function codeAgentCardsForSample(sample) {
|
||
const turnCards = (Array.isArray(sample?.turns) ? sample.turns : [])
|
||
.map((item) => ({ ...item, source: item?.source || "turn" }))
|
||
.filter(isCodeAgentCardLike);
|
||
if (turnCards.length > 0) return turnCards;
|
||
return (Array.isArray(sample?.messages) ? sample.messages : [])
|
||
.map((item) => ({ ...item, source: item?.source || "message" }))
|
||
.filter(isCodeAgentCardLike);
|
||
}
|
||
|
||
function isCodeAgentCardLike(item) {
|
||
const text = codeAgentCardText(item);
|
||
const role = String(item?.dataRole || item?.role || "").toLowerCase();
|
||
if (/agent|assistant/u.test(role)) return true;
|
||
return /Code Agent|运行记录|耗时|最近\s*(?:\d+|一|两|三)|轮次完成|trace_id=trc_/iu.test(text);
|
||
}
|
||
|
||
function codeAgentCardText(item) {
|
||
return [
|
||
item?.durationText,
|
||
item?.activityText,
|
||
item?.text,
|
||
item?.textPreview,
|
||
item?.status
|
||
].map((value) => String(value || "")).filter((value) => value.trim().length > 0).join("\n");
|
||
}
|
||
|
||
function isCodeAgentCardTerminal(item) {
|
||
const status = String(item?.status ?? item?.state ?? item?.phase ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||
if (isActiveTurnStatus(status)) return false;
|
||
if (isTerminalTurnStatus(status)) return true;
|
||
if (item?.terminal === true || item?.sealed === true) return true;
|
||
const text = codeAgentCardText(item);
|
||
return isTerminalTraceText(text) || /轮次完成|轮次失败|轮次取消|已记录|completed|failed|canceled|cancelled|blocked/iu.test(text);
|
||
}
|
||
|
||
function isActiveTurnStatus(value) {
|
||
const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||
return ["pending", "running", "queued", "admitted", "dispatching", "in-progress", "inprogress", "executing", "progress", "thinking", "working", "active", "streaming", "created", "started"].includes(status);
|
||
}
|
||
|
||
function buildRoundCompletionMetrics(samples, timeline, turnTiming) {
|
||
const events = [];
|
||
const elapsedMismatchToleranceSeconds = Math.max(5, Number(alertThresholds.turnTimingSampleSlackSeconds || 0));
|
||
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
|
||
const sample = samples[index];
|
||
const timelineItem = timeline[index] || {};
|
||
for (const event of roundCompletionEventsForSample(sample, timelineItem)) events.push(event);
|
||
}
|
||
const completionEvents = dedupeRoundCompletionEvents(events);
|
||
const elapsedMismatches = [];
|
||
const finalResponseMissing = [];
|
||
const postCompletionTimingChanges = [];
|
||
const postCompletionRecentUpdateVisible = [];
|
||
for (const event of completionEvents) {
|
||
const sampleIndex = samples.findIndex((sample) => sample?.seq === event.seq && sample?.pageRole === event.pageRole && sample?.pageId === event.pageId);
|
||
const sameSample = sampleIndex >= 0 ? samples[sampleIndex] : null;
|
||
const sameTimelineItem = sampleIndex >= 0 ? timeline[sampleIndex] || {} : {};
|
||
const cards = sameSample ? cardMetricItemsForCompletion(sameSample, sameTimelineItem, event) : [];
|
||
const bestCard = cards.filter((card) => Number.isFinite(Number(card.totalElapsedSeconds)))[0] || null;
|
||
if (Number.isFinite(Number(event.elapsedSeconds)) && bestCard) {
|
||
const delta = Math.abs(Number(bestCard.totalElapsedSeconds) - Number(event.elapsedSeconds));
|
||
if (delta > elapsedMismatchToleranceSeconds) {
|
||
elapsedMismatches.push({
|
||
...eventRef(event),
|
||
cardTotalElapsedSeconds: Number(bestCard.totalElapsedSeconds),
|
||
completionElapsedSeconds: Number(event.elapsedSeconds),
|
||
deltaSeconds: Number(delta.toFixed(3)),
|
||
toleranceSeconds: elapsedMismatchToleranceSeconds,
|
||
cardTraceId: bestCard.traceId ?? null,
|
||
cardMessageId: bestCard.messageId ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
}
|
||
if (!(sameSample && sampleHasTerminalAgentResultCard(sameSample, event)) && !(sameSample && sampleHasVisibleFinalResponse(sameSample, event)) && !hasFinalResponseAfterCompletion(samples, timeline, event)) {
|
||
finalResponseMissing.push({
|
||
...eventRef(event),
|
||
completionElapsedSeconds: event.elapsedSeconds,
|
||
finalResponseProbe: sameSample ? terminalAgentResultProbe(sameSample, event) : { ok: false, reason: "sample-not-found" },
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
const postTiming = detectPostCompletionTimingChanges(turnTiming, event);
|
||
postCompletionTimingChanges.push(...postTiming.timingChanges);
|
||
postCompletionRecentUpdateVisible.push(...postTiming.recentUpdateVisible);
|
||
}
|
||
return {
|
||
events: completionEvents.slice(0, 200),
|
||
elapsedMismatches: dedupeRoundCompletionRows(elapsedMismatches).slice(0, 200),
|
||
finalResponseMissing: dedupeRoundCompletionRows(finalResponseMissing).slice(0, 200),
|
||
postCompletionTimingChanges: dedupeRoundCompletionRows(postCompletionTimingChanges).slice(0, 200),
|
||
postCompletionRecentUpdateVisible: dedupeRoundCompletionRows(postCompletionRecentUpdateVisible).slice(0, 200),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function roundCompletionEventsForSample(sample, timelineItem) {
|
||
const rows = [];
|
||
for (const item of traceTimingRowsForSample(sample, timelineItem)) {
|
||
const text = String(item?.preview || item?.text || item?.textPreview || "");
|
||
if (!traceRowIsRoundCompletionText(text, item?.eventKind || "")) continue;
|
||
const elapsedValues = [
|
||
Number(item?.totalSeconds),
|
||
...parseTotalElapsedSeconds(text).filter(Number.isFinite)
|
||
].filter(Number.isFinite);
|
||
const attributed = inferRoundCompletionCard(sample, text);
|
||
rows.push({
|
||
...ref(sample),
|
||
promptIndex: timelineItem.promptIndex ?? 0,
|
||
traceId: item?.traceId ?? firstTraceId([text]) ?? attributed?.traceId ?? null,
|
||
messageId: item?.messageId ?? attributed?.messageId ?? null,
|
||
attributed: Boolean(item?.traceId || item?.messageId || attributed?.traceId || attributed?.messageId),
|
||
traceRowIndex: item?.rowIndex ?? item?.index ?? null,
|
||
elapsedSeconds: elapsedValues.length > 0 ? Math.max(...elapsedValues) : null,
|
||
textHash: item?.textHash ?? sha256(text),
|
||
preview: limitText(text, 180),
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function inferRoundCompletionCard(sample, text) {
|
||
const cards = codeAgentCardsForSample(sample)
|
||
.filter((card) => isCodeAgentCardTerminal(card))
|
||
.filter((card) => card?.traceId || card?.messageId);
|
||
const textHash = sha256(String(text || ""));
|
||
const direct = cards.filter((card) => {
|
||
const cardText = codeAgentCardText(card);
|
||
return card?.textHash === textHash || cardText.includes(String(text || "").slice(0, 80)) || /轮次完成/iu.test(cardText);
|
||
});
|
||
const candidates = direct.length > 0 ? direct : cards;
|
||
if (candidates.length !== 1) return null;
|
||
const card = candidates[0];
|
||
return { traceId: card.traceId ?? null, messageId: card.messageId ?? card.id ?? null };
|
||
}
|
||
|
||
function cardMetricItemsForCompletion(sample, timelineItem, event) {
|
||
const metrics = turnMetricItems(sample, timelineItem)
|
||
.filter((item) => item.promptIndex === event.promptIndex)
|
||
.filter((item) => item.pageRole === event.pageRole || !event.pageRole)
|
||
.filter((item) => item.pageId === event.pageId || !event.pageId);
|
||
if (!event.traceId && !event.messageId) {
|
||
const withElapsed = metrics.filter((item) => Number.isFinite(Number(item.totalElapsedSeconds)));
|
||
return withElapsed.length === 1 ? withElapsed : [];
|
||
}
|
||
return metrics
|
||
.filter((item) => !event.traceId || !item.traceId || item.traceId === event.traceId)
|
||
.filter((item) => !event.messageId || !item.messageId || item.messageId === event.messageId)
|
||
.sort((left, right) => {
|
||
const leftTraceMatch = event.traceId && left.traceId === event.traceId ? 0 : 1;
|
||
const rightTraceMatch = event.traceId && right.traceId === event.traceId ? 0 : 1;
|
||
const leftMessageMatch = event.messageId && left.messageId === event.messageId ? 0 : 1;
|
||
const rightMessageMatch = event.messageId && right.messageId === event.messageId ? 0 : 1;
|
||
return leftTraceMatch - rightTraceMatch || leftMessageMatch - rightMessageMatch || String(left.source || "").localeCompare(String(right.source || ""));
|
||
});
|
||
}
|
||
|
||
function hasFinalResponseAfterCompletion(samples, timeline, event) {
|
||
if (!event.traceId && !event.messageId && !event.promptIndex) return true;
|
||
const eventTsMs = Date.parse(String(event.ts || ""));
|
||
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
|
||
const sample = samples[index];
|
||
const tsMs = Date.parse(String(sample?.ts || ""));
|
||
if (Number.isFinite(eventTsMs) && Number.isFinite(tsMs) && tsMs < eventTsMs) continue;
|
||
if (sample?.pageRole !== event.pageRole) continue;
|
||
const sampleSession = sample?.routeSessionId || sample?.activeSessionId || null;
|
||
const eventSession = event.routeSessionId || event.activeSessionId || null;
|
||
if (sampleSession && eventSession && sampleSession !== eventSession) continue;
|
||
const promptIndex = timeline[index]?.promptIndex ?? 0;
|
||
if (!event.traceId && !event.messageId && event.promptIndex && promptIndex !== event.promptIndex) continue;
|
||
if (sampleHasTerminalAgentResultCard(sample, event)) return true;
|
||
if (sampleHasVisibleFinalResponse(sample, event)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function sampleHasTerminalAgentResultCard(sample, event = {}) {
|
||
return terminalAgentResultProbe(sample, event).ok === true;
|
||
}
|
||
|
||
function terminalAgentResultProbe(sample, event = {}) {
|
||
const decisions = [];
|
||
for (const item of codeAgentCardsForSample(sample)) {
|
||
const text = normalizedText(codeAgentCardText(item));
|
||
const traceMatched = Boolean(event.traceId && item?.traceId && item.traceId === event.traceId);
|
||
const decision = {
|
||
source: item?.source || null,
|
||
role: item?.role ?? null,
|
||
dataRole: item?.dataRole ?? null,
|
||
status: item?.status ?? null,
|
||
traceId: item?.traceId ?? null,
|
||
messageId: item?.messageId ?? null,
|
||
textBytes: Buffer.byteLength(text),
|
||
valuesRedacted: true
|
||
};
|
||
if (event.traceId && item?.traceId && item.traceId !== event.traceId) {
|
||
decisions.push({ ...decision, decision: "skip-trace" });
|
||
continue;
|
||
}
|
||
if (!traceMatched && event.messageId && item?.messageId && item.messageId !== event.messageId) {
|
||
decisions.push({ ...decision, decision: "skip-message" });
|
||
continue;
|
||
}
|
||
if (!isAssistantFinalResponseCandidate(item, item?.source || "turn")) {
|
||
decisions.push({ ...decision, decision: "skip-role" });
|
||
continue;
|
||
}
|
||
if (!isCodeAgentCardTerminal(item)) {
|
||
decisions.push({ ...decision, decision: "skip-non-terminal" });
|
||
continue;
|
||
}
|
||
if (text.length < 24) {
|
||
decisions.push({ ...decision, decision: "skip-short" });
|
||
continue;
|
||
}
|
||
decisions.push({ ...decision, decision: "accept-terminal-agent-card" });
|
||
return { ok: true, decisions: decisions.slice(-8), valuesRedacted: true };
|
||
}
|
||
return { ok: false, decisions: decisions.slice(-8), valuesRedacted: true };
|
||
}
|
||
|
||
function sampleHasVisibleFinalResponse(sample, event = {}) {
|
||
for (const [groupName, group] of [["messages", sample?.messages], ["turns", sample?.turns]]) {
|
||
if (!Array.isArray(group)) continue;
|
||
for (const item of group) {
|
||
const traceMatched = Boolean(event.traceId && item?.traceId && item.traceId === event.traceId);
|
||
if (event.traceId && item?.traceId && item.traceId !== event.traceId) continue;
|
||
if (!traceMatched && event.messageId && item?.messageId && item.messageId !== event.messageId) continue;
|
||
if (!isAssistantFinalResponseCandidate(item, groupName)) continue;
|
||
const text = normalizedText([item?.text, item?.textPreview].filter(Boolean).join(" "));
|
||
if (text.length < 24) continue;
|
||
if (groupName === "messages" && isTerminalTurnStatus(item?.status)) return true;
|
||
if (isDiagnosticText(text)) continue;
|
||
if (isFinalResultText(text)) return true;
|
||
if (/运行记录/iu.test(text) && /(?:已完成|完成|新增|实现|验证|测试|结果|README|文件|summary)/iu.test(text)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function normalizedDomRole(item) {
|
||
return String(item?.dataRole ?? item?.role ?? item?.ariaRole ?? "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[_\s]+/gu, "-");
|
||
}
|
||
|
||
function isAssistantFinalResponseCandidate(item, groupName) {
|
||
const role = normalizedDomRole(item);
|
||
if (/agent|assistant|code-agent|bot/iu.test(role)) return true;
|
||
if (/user|human|client|prompt/iu.test(role)) return false;
|
||
if (groupName === "turns") return isCodeAgentCardLike(item);
|
||
const text = codeAgentCardText(item);
|
||
if (isTerminalTurnStatus(item?.status) && /Code Agent|运行记录|assistant|agent/iu.test(text)) return true;
|
||
return false;
|
||
}
|
||
|
||
function detectPostCompletionTimingChanges(turnTiming, event) {
|
||
const timingChanges = [];
|
||
const recentUpdateVisible = [];
|
||
if (!event.traceId && !event.messageId) return { timingChanges, recentUpdateVisible };
|
||
const rows = Array.isArray(turnTiming?.rows) ? turnTiming.rows : [];
|
||
const columns = Array.isArray(turnTiming?.columns) ? turnTiming.columns : [];
|
||
const eventTsMs = Date.parse(String(event.ts || ""));
|
||
for (const column of columns) {
|
||
if (column.pageRole && event.pageRole && column.pageRole !== event.pageRole) continue;
|
||
if (event.traceId && column.traceId && column.traceId !== event.traceId) continue;
|
||
if (event.messageId && column.messageId && column.messageId !== event.messageId) continue;
|
||
if (event.promptIndex && column.promptIndex && column.promptIndex !== event.promptIndex && column.lastPromptIndex !== event.promptIndex) continue;
|
||
let previousTotal = null;
|
||
let previousRecent = null;
|
||
for (const row of rows) {
|
||
const rowTsMs = Date.parse(String(row.ts || ""));
|
||
if (Number.isFinite(eventTsMs) && Number.isFinite(rowTsMs) && rowTsMs < eventTsMs) continue;
|
||
if (row.pageRole && event.pageRole && row.pageRole !== event.pageRole) continue;
|
||
const cell = row.cells?.[column.id];
|
||
if (!cell) continue;
|
||
if (event.traceId && cell.traceId && cell.traceId !== event.traceId) continue;
|
||
if (event.messageId && cell.messageId && cell.messageId !== event.messageId) continue;
|
||
const total = cell.totalElapsedSeconds === null || cell.totalElapsedSeconds === undefined ? NaN : Number(cell.totalElapsedSeconds);
|
||
if (Number.isFinite(total)) {
|
||
if (previousTotal && Math.abs(total - previousTotal.value) > alertThresholds.turnTimingSampleSlackSeconds) {
|
||
timingChanges.push({
|
||
...eventRef(event),
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric: "totalElapsedSeconds",
|
||
fromSeq: previousTotal.seq,
|
||
fromTs: previousTotal.ts,
|
||
fromValue: previousTotal.value,
|
||
toSeq: row.seq ?? null,
|
||
toTs: row.ts ?? null,
|
||
toValue: total,
|
||
delta: Number((total - previousTotal.value).toFixed(3)),
|
||
toleranceSeconds: alertThresholds.turnTimingSampleSlackSeconds,
|
||
traceId: cell.traceId ?? column.traceId ?? event.traceId ?? null,
|
||
messageId: cell.messageId ?? column.messageId ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
previousTotal = { value: total, seq: row.seq ?? null, ts: row.ts ?? null };
|
||
}
|
||
const recent = cell.recentUpdateSeconds === null || cell.recentUpdateSeconds === undefined ? NaN : Number(cell.recentUpdateSeconds);
|
||
if (Number.isFinite(recent)) {
|
||
recentUpdateVisible.push({
|
||
...eventRef(event),
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric: "recentUpdateSeconds",
|
||
seq: row.seq ?? null,
|
||
ts: row.ts ?? null,
|
||
value: recent,
|
||
traceId: cell.traceId ?? column.traceId ?? event.traceId ?? null,
|
||
messageId: cell.messageId ?? column.messageId ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
if (previousRecent && recent !== previousRecent.value) {
|
||
timingChanges.push({
|
||
...eventRef(event),
|
||
columnId: column.id,
|
||
columnLabel: column.label,
|
||
metric: "recentUpdateSeconds",
|
||
fromSeq: previousRecent.seq,
|
||
fromTs: previousRecent.ts,
|
||
fromValue: previousRecent.value,
|
||
toSeq: row.seq ?? null,
|
||
toTs: row.ts ?? null,
|
||
toValue: recent,
|
||
delta: Number((recent - previousRecent.value).toFixed(3)),
|
||
traceId: cell.traceId ?? column.traceId ?? event.traceId ?? null,
|
||
messageId: cell.messageId ?? column.messageId ?? null,
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
previousRecent = { value: recent, seq: row.seq ?? null, ts: row.ts ?? null };
|
||
}
|
||
}
|
||
}
|
||
return { timingChanges, recentUpdateVisible };
|
||
}
|
||
|
||
function dedupeRoundCompletionEvents(rows) {
|
||
const result = [];
|
||
const seen = new Set();
|
||
for (const row of Array.isArray(rows) ? rows : []) {
|
||
const key = [
|
||
row?.pageRole ?? "",
|
||
row?.pageId ?? "",
|
||
row?.promptIndex ?? "",
|
||
row?.traceId ?? "",
|
||
row?.messageId ?? "",
|
||
row?.textHash ?? "",
|
||
row?.elapsedSeconds ?? ""
|
||
].join("|");
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(row);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function eventRef(event) {
|
||
return {
|
||
seq: event?.seq ?? null,
|
||
sampleGroupSeq: event?.sampleGroupSeq ?? null,
|
||
ts: event?.ts ?? null,
|
||
pageRole: event?.pageRole ?? null,
|
||
pageId: event?.pageId ?? null,
|
||
routeSessionId: event?.routeSessionId ?? null,
|
||
activeSessionId: event?.activeSessionId ?? null,
|
||
promptIndex: event?.promptIndex ?? null,
|
||
traceId: event?.traceId ?? null,
|
||
messageId: event?.messageId ?? null,
|
||
};
|
||
}
|
||
|
||
function dedupeRoundCompletionRows(rows) {
|
||
const result = [];
|
||
const seen = new Set();
|
||
for (const row of Array.isArray(rows) ? rows : []) {
|
||
const key = [
|
||
row?.seq ?? row?.fromSeq ?? "",
|
||
row?.toSeq ?? "",
|
||
row?.pageRole ?? "",
|
||
row?.promptIndex ?? "",
|
||
row?.traceId ?? "",
|
||
row?.metric ?? "",
|
||
row?.textHash ?? "",
|
||
row?.columnId ?? ""
|
||
].join("|");
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(row);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function turnMetricItems(sample, timelineItem) {
|
||
const promptIndex = timelineItem.promptIndex ?? 0;
|
||
const pageRole = sample?.pageRole || "control";
|
||
const pageId = sample?.pageId || "unknown-page";
|
||
const sessionKey = pageRole + ":" + pageId + ":" + (sample?.routeSessionId || sample?.activeSessionId || "unknown-session");
|
||
const roundKey = String(promptIndex);
|
||
const items = [];
|
||
if (Array.isArray(sample?.turns) && sample.turns.length > 0) {
|
||
for (const turn of sample.turns) {
|
||
const texts = turnTexts(turn);
|
||
const totalElapsedValues = texts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite);
|
||
const recentUpdateValues = texts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite);
|
||
const traceId = turn.traceId || firstTraceId(texts);
|
||
const messageId = turn.messageId || null;
|
||
const turnId = turn.turnId || traceId || null;
|
||
const stableId = traceId || messageId || turnId || null;
|
||
const domIndex = Number.isFinite(Number(turn.index)) ? Number(turn.index) : items.length;
|
||
const key = stableId
|
||
? "timing:" + sessionKey + ":id-" + stableId
|
||
: "turn:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex);
|
||
items.push({
|
||
key,
|
||
source: "turn",
|
||
pageRole,
|
||
pageId,
|
||
promptIndex,
|
||
traceId,
|
||
messageId,
|
||
turnId,
|
||
domIndex,
|
||
status: turn.status ?? null,
|
||
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
|
||
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
|
||
textHash: turn.textHash || sha256(texts.join("\n"))
|
||
});
|
||
}
|
||
return items;
|
||
}
|
||
if (Array.isArray(sample?.messages) && sample.messages.length > 0) {
|
||
for (const message of sample.messages) {
|
||
const text = [message?.durationText, message?.activityText].map((value) => String(value || "")).filter((value) => value.trim().length > 0).join("\n");
|
||
const totalElapsedValues = parseTotalElapsedSeconds(text).filter(Number.isFinite);
|
||
const recentUpdateValues = parseRecentUpdateSeconds(text).filter(Number.isFinite);
|
||
if (totalElapsedValues.length === 0 && recentUpdateValues.length === 0) continue;
|
||
const domIndex = Number.isFinite(Number(message.index)) ? Number(message.index) : items.length;
|
||
const traceId = message.traceId || firstTraceId([text]);
|
||
const messageId = message.messageId || null;
|
||
const stableId = traceId || messageId || message.turnId || null;
|
||
items.push({
|
||
key: stableId
|
||
? "timing:" + sessionKey + ":id-" + stableId
|
||
: "message:" + sessionKey + ":round-" + roundKey + ":dom-index-" + String(domIndex),
|
||
source: "message",
|
||
pageRole,
|
||
pageId,
|
||
promptIndex,
|
||
traceId,
|
||
messageId,
|
||
turnId: message.turnId || traceId || null,
|
||
domIndex,
|
||
status: message.status ?? null,
|
||
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
|
||
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
|
||
textHash: message.textHash || sha256(text)
|
||
});
|
||
}
|
||
if (items.length > 0) return items;
|
||
}
|
||
if (timelineItem.totalElapsedSeconds !== null || timelineItem.recentUpdateSeconds !== null) {
|
||
return [{
|
||
key: "aggregate:" + sessionKey + ":round-" + roundKey,
|
||
source: "aggregate",
|
||
pageRole,
|
||
pageId,
|
||
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
|
||
].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 timingColumns = Array.isArray(timing.columns) ? timing.columns : [];
|
||
const timingRows = Array.isArray(timing.rows) ? timing.rows : [];
|
||
const nonMonotonic = Array.isArray(timing.nonMonotonic) ? timing.nonMonotonic : [];
|
||
const totalElapsedForwardJumps = Array.isArray(timing.totalElapsedForwardJumps) ? timing.totalElapsedForwardJumps : [];
|
||
const elapsedZeroResets = Array.isArray(timing.elapsedZeroResets) ? timing.elapsedZeroResets : [];
|
||
const terminalElapsedGrowth = Array.isArray(timing.terminalElapsedGrowth) ? timing.terminalElapsedGrowth : [];
|
||
const recentUpdateResets = Array.isArray(timing.recentUpdateResets) ? timing.recentUpdateResets : [];
|
||
const recentUpdateSteps = Array.isArray(timing.recentUpdateSteps) ? timing.recentUpdateSteps : [];
|
||
for (let index = 0; index < promptCommands.length; index += 1) {
|
||
const promptIndex = index + 1;
|
||
const items = timeline.filter((item) => item.promptIndex === promptIndex);
|
||
const aggregateTotalElapsed = items.map((item) => item.totalElapsedSeconds).filter((value) => value !== null);
|
||
const aggregateRecentUpdate = items.map((item) => item.recentUpdateSeconds).filter((value) => value !== null);
|
||
const promptTurnTiming = roundPromptTurnTimingValues(timingRows, timingColumns, promptIndex, {
|
||
firstSeq: items[0]?.seq ?? null,
|
||
firstSampleAt: items[0]?.ts ?? null
|
||
});
|
||
const totalElapsed = promptTurnTiming.totalElapsed.length > 0 ? promptTurnTiming.totalElapsed : aggregateTotalElapsed;
|
||
const recentUpdate = promptTurnTiming.recentUpdate.length > 0 ? promptTurnTiming.recentUpdate : aggregateRecentUpdate;
|
||
const loadingCounts = items.map((item) => Number(item.loadingCount ?? 0)).filter(Number.isFinite);
|
||
const loadingOwners = new Set();
|
||
for (const item of items) {
|
||
for (const owner of Array.isArray(item.loadingOwners) ? item.loadingOwners : []) {
|
||
if (owner?.ownerKey) loadingOwners.add(owner.ownerKey);
|
||
}
|
||
}
|
||
const timingAnomalies = nonMonotonic.filter((item) => item.promptIndex === promptIndex);
|
||
const timingForwardJumps = totalElapsedForwardJumps.filter((item) => item.promptIndex === promptIndex);
|
||
const timingZeroResets = elapsedZeroResets.filter((item) => item.promptIndex === promptIndex);
|
||
const timingTerminalGrowth = terminalElapsedGrowth.filter((item) => item.promptIndex === promptIndex);
|
||
const timingResets = recentUpdateResets.filter((item) => item.promptIndex === promptIndex);
|
||
const timingSteps = recentUpdateSteps.filter((item) => item.promptIndex === promptIndex);
|
||
const terminalGrowthDeltas = timingTerminalGrowth.map((item) => Number(item.delta)).filter((value) => Number.isFinite(value) && value > 0);
|
||
const timingStepDeltas = timingSteps.map((item) => Number(item.delta)).filter((value) => Number.isFinite(value) && value >= 0);
|
||
const timingStepExcess = timingSteps.map((item) => Number(item.excessiveIncreaseSeconds)).filter((value) => Number.isFinite(value) && value > 0);
|
||
rounds.push({
|
||
promptIndex,
|
||
promptCommandId: promptCommands[index].commandId,
|
||
promptTextHash: promptCommands[index].textHash,
|
||
promptTextBytes: promptCommands[index].textBytes,
|
||
promptCompletedAt: promptCommands[index].ts,
|
||
sampleCount: items.length,
|
||
firstSeq: items[0]?.seq ?? null,
|
||
lastSeq: items[items.length - 1]?.seq ?? null,
|
||
firstSampleAt: items[0]?.ts ?? null,
|
||
lastSampleAt: items[items.length - 1]?.ts ?? null,
|
||
withTotalElapsed: totalElapsed.length,
|
||
withRecentUpdate: recentUpdate.length,
|
||
loadingSamples: loadingCounts.filter((value) => value > 0).length,
|
||
maxLoadingCount: loadingCounts.length > 0 ? Math.max(...loadingCounts) : 0,
|
||
loadingOwnerCount: loadingOwners.size,
|
||
maxTotalElapsedSeconds: totalElapsed.length > 0 ? Math.max(...totalElapsed) : null,
|
||
lastTotalElapsedSeconds: lastNonNull(totalElapsed),
|
||
maxRecentUpdateSeconds: recentUpdate.length > 0 ? Math.max(...recentUpdate) : null,
|
||
lastRecentUpdateSeconds: lastNonNull(recentUpdate),
|
||
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,
|
||
turnTimingTotalElapsedZeroResetCount: timingZeroResets.length,
|
||
turnTimingTotalElapsedForwardJumpCount: timingForwardJumps.length,
|
||
turnTimingTotalElapsedForwardJumpMaxSeconds: maxPositiveDelta(timingForwardJumps),
|
||
turnTimingTerminalElapsedGrowthCount: timingTerminalGrowth.length,
|
||
turnTimingTerminalElapsedGrowthMaxSeconds: terminalGrowthDeltas.length > 0 ? Math.max(...terminalGrowthDeltas) : 0,
|
||
turnTimingRecentUpdateJumpCount: timingAnomalies.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump").length,
|
||
turnTimingRecentUpdateSawtoothJumpCount: timingAnomalies.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump").length,
|
||
turnTimingRecentUpdateStepCount: timingSteps.length,
|
||
turnTimingRecentUpdateMaxIncreaseSeconds: timingStepDeltas.length > 0 ? Math.max(...timingStepDeltas) : null,
|
||
turnTimingRecentUpdateMaxExcessSeconds: timingStepExcess.length > 0 ? Math.max(...timingStepExcess) : 0,
|
||
turnTimingRecentUpdateResetCount: timingResets.length,
|
||
turnTimingRecentUpdateDecreaseCount: timingResets.length
|
||
});
|
||
}
|
||
return rounds;
|
||
}
|
||
|
||
function roundPromptTurnTimingValues(rows, columns, promptIndex, round = {}) {
|
||
const roundFirstSeq = Number(round.firstSeq);
|
||
const roundFirstMs = Date.parse(String(round.firstSampleAt ?? ""));
|
||
const seqSlack = 3;
|
||
const timeSlackMs = 3000;
|
||
const promptColumnIds = new Set(columns
|
||
.filter((column) => {
|
||
if (Number(column?.promptIndex) === Number(promptIndex)) return true;
|
||
const firstSeq = Number(column?.firstSeq);
|
||
if (Number.isFinite(roundFirstSeq) && Number.isFinite(firstSeq) && firstSeq >= roundFirstSeq - seqSlack) return true;
|
||
const firstMs = Date.parse(String(column?.firstTs ?? ""));
|
||
return Number.isFinite(roundFirstMs) && Number.isFinite(firstMs) && firstMs >= roundFirstMs - timeSlackMs;
|
||
})
|
||
.map((column) => String(column?.id || ""))
|
||
.filter(Boolean));
|
||
if (promptColumnIds.size === 0) return { totalElapsed: [], recentUpdate: [] };
|
||
const totalElapsed = [];
|
||
const recentUpdate = [];
|
||
for (const row of rows) {
|
||
if (Number(row?.promptIndex ?? 0) !== Number(promptIndex)) continue;
|
||
const cells = row?.cells && typeof row.cells === "object" ? row.cells : {};
|
||
for (const [columnId, cell] of Object.entries(cells)) {
|
||
if (!promptColumnIds.has(columnId)) continue;
|
||
const total = Number(cell?.totalElapsedSeconds);
|
||
if (Number.isFinite(total)) totalElapsed.push(total);
|
||
const recent = Number(cell?.recentUpdateSeconds);
|
||
if (Number.isFinite(recent)) recentUpdate.push(recent);
|
||
}
|
||
}
|
||
return { totalElapsed, recentUpdate };
|
||
}
|
||
|
||
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 sampleTurnTimingTexts(sample) {
|
||
const rows = [];
|
||
for (const group of [sample?.turns, sample?.messages]) {
|
||
if (!Array.isArray(group)) continue;
|
||
for (const item of group) {
|
||
for (const value of [item?.durationText, item?.activityText]) {
|
||
const text = String(value || "");
|
||
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 || /(?:天|小时|分钟|分|秒)/u.test(match[0] || "")) 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 = [];
|
||
const lastNonEmptyByScope = new Map();
|
||
for (const sample of samples) {
|
||
const scope = finalFlickerScope(sample);
|
||
if (!scope) continue;
|
||
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);
|
||
const diagnosticLike = /temporarily|timeout|无法连接|暂时|error|failed|超时/iu.test(messageText);
|
||
const lastNonEmpty = lastNonEmptyByScope.get(scope);
|
||
if (nonEmpty && finalLike && !diagnosticLike) lastNonEmptyByScope.set(scope, { sample, messageText });
|
||
if (lastNonEmpty && nonEmpty && diagnosticLike) flickers.push({ scope, from: ref(lastNonEmpty.sample), to: ref(sample) });
|
||
if (lastNonEmpty && !nonEmpty) flickers.push({ scope, from: ref(lastNonEmpty.sample), to: ref(sample), reason: "text-disappeared" });
|
||
}
|
||
return flickers;
|
||
}
|
||
|
||
function finalFlickerScope(sample) {
|
||
const pathname = samplePathname(sample);
|
||
const sessionId = sample?.routeSessionId || sample?.activeSessionId || workbenchSessionIdFromPath(pathname);
|
||
if (!sessionId) return null;
|
||
if (!pathname.startsWith("/workbench/sessions/" + sessionId)) return null;
|
||
return (sample?.pageRole || "control") + ":" + sessionId;
|
||
}
|
||
|
||
function samplePathname(sample) {
|
||
const raw = String(sample?.path || sample?.url || "").trim();
|
||
if (!raw) return "";
|
||
try {
|
||
return new URL(raw, "http://hwlab.local").pathname || raw;
|
||
} catch {
|
||
return raw.split(/[?#]/u, 1)[0] || raw;
|
||
}
|
||
}
|
||
|
||
function workbenchSessionIdFromPath(pathname) {
|
||
const match = String(pathname || "").match(/^\/workbench\/sessions\/([^/?#]+)/u);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
function detectTerminalZeroElapsed(samples) {
|
||
const rows = [];
|
||
for (const sample of samples) {
|
||
const turns = Array.isArray(sample?.turns) ? sample.turns : [];
|
||
const messages = Array.isArray(sample?.messages) ? sample.messages : [];
|
||
for (const item of [...turns, ...messages]) {
|
||
const text = [item?.durationText, item?.activityText, item?.status, item?.textPreview, item?.text].filter(Boolean).join("\n");
|
||
if (!/(?:Code Agent|运行记录|completed|failed|canceled|blocked)/iu.test(text)) continue;
|
||
if (!/(?:completed|failed|canceled|blocked|已完成|失败|取消)/iu.test(text)) continue;
|
||
const timingText = [item?.durationText, item?.activityText].filter(Boolean).join("\n");
|
||
const elapsedValues = parseTotalElapsedSeconds(timingText);
|
||
if (!elapsedValues.includes(0)) continue;
|
||
rows.push({
|
||
...ref(sample),
|
||
status: item?.status ?? null,
|
||
messageId: item?.messageId ?? null,
|
||
traceId: item?.traceId ?? null,
|
||
durationText: item?.durationText ?? null,
|
||
activityText: item?.activityText ?? null,
|
||
textPreview: limitText(item?.textPreview || item?.text || "", 180)
|
||
});
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function detectCrossPageProjectionDiffs(samples) {
|
||
const groups = new Map();
|
||
for (const sample of samples) {
|
||
const key = sample?.sampleGroupSeq ?? sample?.seq;
|
||
if (key === null || key === undefined) continue;
|
||
const group = groups.get(key) || {};
|
||
if (sample?.pageRole === "control") group.control = sample;
|
||
else if (sample?.pageRole === "observer") group.observer = sample;
|
||
groups.set(key, group);
|
||
}
|
||
const rows = [];
|
||
for (const [sampleGroupSeq, group] of groups.entries()) {
|
||
const control = group.control;
|
||
const observer = group.observer;
|
||
if (!control || !observer) continue;
|
||
const controlTraceIds = visibleTraceIds(control);
|
||
const observerTraceIds = visibleTraceIds(observer);
|
||
const missingInObserver = [...controlTraceIds].filter((item) => !observerTraceIds.has(item));
|
||
const controlMessages = Array.isArray(control.messages) ? control.messages.length : 0;
|
||
const observerMessages = Array.isArray(observer.messages) ? observer.messages.length : 0;
|
||
const controlTraceRows = Array.isArray(control.traceRows) ? control.traceRows.length : 0;
|
||
const observerTraceRows = Array.isArray(observer.traceRows) ? observer.traceRows.length : 0;
|
||
const controlZero = detectTerminalZeroElapsed([control]).length > 0;
|
||
const observerZero = detectTerminalZeroElapsed([observer]).length > 0;
|
||
const sameSession = (control.routeSessionId || control.activeSessionId || null) && (control.routeSessionId || control.activeSessionId || null) === (observer.routeSessionId || observer.activeSessionId || null);
|
||
const controlMessageDigest = digestMessageTexts(control);
|
||
const observerMessageDigest = digestMessageTexts(observer);
|
||
const messageTextDigestDiff = controlMessageDigest !== observerMessageDigest;
|
||
const projectionDivergent = sameSession && (Math.abs(controlMessages - observerMessages) > 0 || controlZero !== observerZero);
|
||
const traceVisibilityDivergent = sameSession && !projectionDivergent && (missingInObserver.length > 0 || Math.abs(controlTraceRows - observerTraceRows) > 0);
|
||
if (!projectionDivergent && !traceVisibilityDivergent) continue;
|
||
rows.push({
|
||
sampleGroupSeq,
|
||
diffKind: traceVisibilityDivergent ? "trace-visibility" : "projection",
|
||
control: ref(control),
|
||
observer: ref(observer),
|
||
controlTraceIds: [...controlTraceIds].slice(0, 8),
|
||
observerTraceIds: [...observerTraceIds].slice(0, 8),
|
||
missingTraceIdsInObserver: missingInObserver.slice(0, 8),
|
||
controlMessageCount: controlMessages,
|
||
observerMessageCount: observerMessages,
|
||
controlTraceRowCount: controlTraceRows,
|
||
observerTraceRowCount: observerTraceRows,
|
||
terminalZeroElapsedDiff: controlZero !== observerZero,
|
||
messageTextDigestDiff,
|
||
controlMessageDigest,
|
||
observerMessageDigest
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function mergeCrossPageDiffRows(...groups) {
|
||
const rows = [];
|
||
const seen = new Set();
|
||
for (const group of groups) {
|
||
if (!Array.isArray(group)) continue;
|
||
for (const row of group) {
|
||
const key = [
|
||
row?.diffKind || "projection",
|
||
row?.control?.seq ?? null,
|
||
row?.observer?.seq ?? null,
|
||
row?.controlMessageCount ?? null,
|
||
row?.observerMessageCount ?? null,
|
||
row?.controlTraceRowCount ?? null,
|
||
row?.observerTraceRowCount ?? null
|
||
].join(":");
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
rows.push(row);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function annotateCrossPageDiffTiming(rows) {
|
||
const groups = new Map();
|
||
for (const row of Array.isArray(rows) ? rows : []) {
|
||
const controlAt = Date.parse(String(row?.control?.ts || ""));
|
||
const observerAt = Date.parse(String(row?.observer?.ts || ""));
|
||
const timestamps = [controlAt, observerAt].filter(Number.isFinite);
|
||
const startMs = timestamps.length > 0 ? Math.min(...timestamps) : null;
|
||
const endMs = timestamps.length > 0 ? Math.max(...timestamps) : null;
|
||
const sessionId = row?.control?.routeSessionId || row?.control?.activeSessionId || row?.observer?.routeSessionId || row?.observer?.activeSessionId || "unknown-session";
|
||
const key = [row?.diffKind || "projection", sessionId].join(":");
|
||
const group = groups.get(key) || { rows: [], firstMs: null, lastMs: null };
|
||
const annotated = {
|
||
...row,
|
||
sampleStartAt: startMs === null ? null : new Date(startMs).toISOString(),
|
||
sampleEndAt: endMs === null ? null : new Date(endMs).toISOString(),
|
||
pairSkewMs: startMs === null || endMs === null ? null : endMs - startMs,
|
||
};
|
||
group.rows.push(annotated);
|
||
if (startMs !== null && (group.firstMs === null || startMs < group.firstMs)) group.firstMs = startMs;
|
||
if (endMs !== null && (group.lastMs === null || endMs > group.lastMs)) group.lastMs = endMs;
|
||
groups.set(key, group);
|
||
}
|
||
const result = [];
|
||
for (const group of groups.values()) {
|
||
const sortedRows = group.rows.slice().sort((a, b) => Number(Date.parse(String(a.sampleStartAt || ""))) - Number(Date.parse(String(b.sampleStartAt || ""))));
|
||
const segments = [];
|
||
const splitGapMs = Math.max(1000, Number(alertThresholds.crossPageProjectionDivergenceRedMs || alertThresholds.visibleLoadingSlowMs || 10_000));
|
||
for (const row of sortedRows) {
|
||
const startMs = Date.parse(String(row.sampleStartAt || ""));
|
||
const endMs = Date.parse(String(row.sampleEndAt || row.sampleStartAt || ""));
|
||
const last = segments.at(-1);
|
||
const lastEndMs = last && last.lastMs !== null ? last.lastMs : null;
|
||
if (!last || (Number.isFinite(startMs) && lastEndMs !== null && startMs - lastEndMs > splitGapMs)) {
|
||
segments.push({ rows: [row], firstMs: Number.isFinite(startMs) ? startMs : null, lastMs: Number.isFinite(endMs) ? endMs : Number.isFinite(startMs) ? startMs : null });
|
||
continue;
|
||
}
|
||
last.rows.push(row);
|
||
if (Number.isFinite(startMs) && (last.firstMs === null || startMs < last.firstMs)) last.firstMs = startMs;
|
||
const effectiveEndMs = Number.isFinite(endMs) ? endMs : Number.isFinite(startMs) ? startMs : null;
|
||
if (effectiveEndMs !== null && (last.lastMs === null || effectiveEndMs > last.lastMs)) last.lastMs = effectiveEndMs;
|
||
}
|
||
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
|
||
const segment = segments[segmentIndex];
|
||
const observedSpanMs = segment.firstMs === null || segment.lastMs === null ? null : segment.lastMs - segment.firstMs;
|
||
for (const row of segment.rows) {
|
||
result.push({
|
||
...row,
|
||
segmentIndex,
|
||
observedFirstAt: segment.firstMs === null ? null : new Date(segment.firstMs).toISOString(),
|
||
observedLastAt: segment.lastMs === null ? null : new Date(segment.lastMs).toISOString(),
|
||
observedSpanMs,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function detectAdjacentCrossPageProjectionDiffs(samples) {
|
||
const rows = [];
|
||
const ordered = (Array.isArray(samples) ? samples : []).slice().sort((a, b) => Number(a?.seq ?? 0) - Number(b?.seq ?? 0));
|
||
for (let i = 1; i < ordered.length; i += 1) {
|
||
const a = ordered[i - 1];
|
||
const b = ordered[i];
|
||
const roles = new Set([a?.pageRole, b?.pageRole]);
|
||
if (!roles.has("control") || !roles.has("observer")) continue;
|
||
const control = a?.pageRole === "control" ? a : b;
|
||
const observer = a?.pageRole === "observer" ? a : b;
|
||
const controlSession = control.routeSessionId || control.activeSessionId || null;
|
||
const observerSession = observer.routeSessionId || observer.activeSessionId || null;
|
||
if (!controlSession || controlSession !== observerSession) continue;
|
||
const controlAt = Date.parse(String(control.ts || ""));
|
||
const observerAt = Date.parse(String(observer.ts || ""));
|
||
if (Number.isFinite(controlAt) && Number.isFinite(observerAt) && Math.abs(controlAt - observerAt) > 1500) continue;
|
||
const controlMessages = Array.isArray(control.messages) ? control.messages.length : 0;
|
||
const observerMessages = Array.isArray(observer.messages) ? observer.messages.length : 0;
|
||
const controlTraceRows = Array.isArray(control.traceRows) ? control.traceRows.length : 0;
|
||
const observerTraceRows = Array.isArray(observer.traceRows) ? observer.traceRows.length : 0;
|
||
const controlZero = detectTerminalZeroElapsed([control]).length > 0;
|
||
const observerZero = detectTerminalZeroElapsed([observer]).length > 0;
|
||
const missingInObserver = [...visibleTraceIds(control)].filter((item) => !visibleTraceIds(observer).has(item));
|
||
const controlTraceIds = visibleTraceIds(control);
|
||
const observerTraceIds = visibleTraceIds(observer);
|
||
const controlMessageDigest = digestMessageTexts(control);
|
||
const observerMessageDigest = digestMessageTexts(observer);
|
||
const messageTextDigestDiff = controlMessageDigest !== observerMessageDigest;
|
||
const projectionDivergent = controlMessages !== observerMessages || controlZero !== observerZero;
|
||
const traceVisibilityDivergent = !projectionDivergent && (missingInObserver.length > 0 || controlTraceRows !== observerTraceRows);
|
||
if (!projectionDivergent && !traceVisibilityDivergent) continue;
|
||
rows.push({
|
||
sampleGroupSeq: control.sampleGroupSeq ?? observer.sampleGroupSeq ?? null,
|
||
adjacentPair: true,
|
||
diffKind: traceVisibilityDivergent ? "trace-visibility" : "projection",
|
||
control: ref(control),
|
||
observer: ref(observer),
|
||
controlTraceIds: [...controlTraceIds].slice(0, 8),
|
||
observerTraceIds: [...observerTraceIds].slice(0, 8),
|
||
missingTraceIdsInObserver: missingInObserver.slice(0, 8),
|
||
controlMessageCount: controlMessages,
|
||
observerMessageCount: observerMessages,
|
||
controlTraceRowCount: controlTraceRows,
|
||
observerTraceRowCount: observerTraceRows,
|
||
terminalZeroElapsedDiff: controlZero !== observerZero,
|
||
messageTextDigestDiff,
|
||
controlMessageDigest,
|
||
observerMessageDigest
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function detectTraceMessageDuplication(samples) {
|
||
const rows = [];
|
||
for (const sample of samples) {
|
||
const finalText = normalizedText((Array.isArray(sample?.messages) ? sample.messages : []).map((item) => item?.textPreview || item?.text || "").join("\n"));
|
||
if (finalText.length < 40) continue;
|
||
const traceRows = Array.isArray(sample?.traceRows) ? sample.traceRows : [];
|
||
for (const row of traceRows) {
|
||
const rowTextRaw = String(row?.textPreview || row?.text || "");
|
||
if (!/(?:助手消息|assistant\s+message|assistant)/iu.test(rowTextRaw)) continue;
|
||
const rowText = normalizedText(rowTextRaw);
|
||
if (rowText.length < 24) continue;
|
||
const overlap = longestSharedSubstringLength(finalText, rowText);
|
||
if (overlap < 40 && overlap < Math.min(rowText.length, finalText.length) * 0.55) continue;
|
||
rows.push({ ...ref(sample), traceId: row?.traceId ?? null, rowIndex: row?.index ?? null, rowTextPreview: limitText(rowTextRaw, 180) });
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function detectTurnTraceIdMissing(samples) {
|
||
const rows = [];
|
||
for (const sample of samples) {
|
||
for (const item of Array.isArray(sample?.turns) ? sample.turns : []) {
|
||
const text = [item?.status, item?.durationText, item?.activityText, item?.textPreview, item?.text].filter(Boolean).join("\n");
|
||
if (!/(?:Code Agent|运行记录|耗时|最近)/iu.test(text)) continue;
|
||
if (item?.traceId) continue;
|
||
rows.push({ ...ref(sample), status: item?.status ?? null, messageId: item?.messageId ?? null, textPreview: limitText(item?.textPreview || item?.text || "", 180) });
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function visibleTraceIds(sample) {
|
||
const ids = new Set();
|
||
for (const group of [sample?.turns, sample?.messages, sample?.traceRows]) {
|
||
if (!Array.isArray(group)) continue;
|
||
for (const item of group) if (item?.traceId) ids.add(String(item.traceId));
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function digestMessageTexts(sample) {
|
||
return sha256((Array.isArray(sample?.messages) ? sample.messages : []).map((item) => item?.textHash || normalizedText(item?.textPreview || item?.text || "")).join("|"));
|
||
}
|
||
|
||
function normalizedText(value) {
|
||
return String(value || "").replace(/\s+/gu, " ").trim();
|
||
}
|
||
|
||
function longestSharedSubstringLength(a, b) {
|
||
if (!a || !b) return 0;
|
||
const left = a.length <= b.length ? a : b;
|
||
const right = a.length <= b.length ? b : a;
|
||
const max = Math.min(left.length, 280);
|
||
let best = 0;
|
||
for (let start = 0; start < max; start += 1) {
|
||
for (let end = Math.min(max, start + 160); end > start + best; end -= 1) {
|
||
if (right.includes(left.slice(start, end))) {
|
||
best = end - start;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function digestSample(sample) {
|
||
const messages = Array.isArray(sample.messages) ? sample.messages.map((item) => stableDigestItem(item, ["dataRole", "role", "status", "sessionId", "messageId", "traceId", "turnId"])).join("|") : "";
|
||
const trace = Array.isArray(sample.traceRows) ? sample.traceRows.map((item) => stableDigestItem(item, ["status", "sessionId", "messageId", "traceId", "turnId", "projectedSeq", "sourceSeq", "eventSeq", "eventKind"])).join("|") : "";
|
||
const diagnostics = Array.isArray(sample.diagnostics) ? sample.diagnostics.map((item) => stableDigestItem(item, ["className", "status", "sessionId", "messageId", "traceId", "turnId", "diagnosticCode"])).join("|") : "";
|
||
return sha256((sample.routeSessionId || "") + "|" + (sample.activeSessionId || "") + "|" + messages + "|" + trace + "|" + diagnostics);
|
||
}
|
||
|
||
function samplePageKey(sample) {
|
||
return String(sample?.pageRole || "control") + ":" + String(sample?.pageId || "default");
|
||
}
|
||
|
||
function stableDigestItem(item, fields) {
|
||
if (!item || typeof item !== "object") return "";
|
||
const identity = fields.map((field) => String(item?.[field] ?? "")).join(":");
|
||
return identity + ":" + stableVisibleDigestText(item?.textPreview || item?.text || item?.textHash || "");
|
||
}
|
||
|
||
function stableVisibleDigestText(value) {
|
||
return normalizedText(value)
|
||
.replace(/\btotal=\d{1,2}:\d{2}:\d{2}\b/giu, "total=<duration>")
|
||
.replace(/(?:总耗时|耗时)\s*[=::]?\s*(?:(?:\d+\s*天)?\s*(?:\d+\s*小时)?\s*(?:\d+\s*(?:分钟|分))?\s*(?:\d+\s*秒)?|\d{1,2}:\d{2}(?::\d{2})?)/giu, "耗时 <duration>")
|
||
.replace(/最近\s*(?:(?:\d+\s*天)?\s*(?:\d+\s*小时)?\s*(?:\d+\s*(?:分钟|分))?\s*(?:\d+\s*秒)?)\s*前/giu, "最近 <duration>前");
|
||
}
|
||
|
||
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, sampleGroupSeq: sample.sampleGroupSeq ?? null, ts: sample.ts ?? null, pageRole: sample.pageRole ?? null, pageId: sample.pageId ?? 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, pageAuthority: value.pageAuthority ?? null, sampling: value.sampling, pageProvenance: value.pageProvenance ?? null, safety: value.safety };
|
||
}
|
||
|
||
function compactHeartbeat(value) {
|
||
if (!value) return null;
|
||
return { jobId: value.jobId, pid: value.pid, status: value.status, sampleSeq: value.sampleSeq, commandSeq: value.commandSeq, pageId: value.pageId ?? null, observerPageId: value.observerPageId ?? null, currentUrl: value.currentUrl, observerUrl: value.observerUrl ?? null, observerRefreshIntervalMs: value.observerRefreshIntervalMs ?? null, lastObserverRefreshAt: value.lastObserverRefreshAt ?? null, pageProvenance: value.pageProvenance ?? null, updatedAt: value.updatedAt, uptimeMs: value.uptimeMs };
|
||
}
|
||
|
||
function renderTurnTimingTable(sampleMetrics) {
|
||
const columns = Array.isArray(sampleMetrics?.turnColumns) ? sampleMetrics.turnColumns : [];
|
||
const rows = Array.isArray(sampleMetrics?.turnTimingTable) ? sampleMetrics.turnTimingTable : [];
|
||
const disclosure = sampleMetrics?.turnTimingTableDisclosure || null;
|
||
if (columns.length === 0 || rows.length === 0) return "- 无 turn 时间表。";
|
||
const header = ["时间戳"];
|
||
for (const column of columns) {
|
||
const columnLabel = formatTurnColumnDisplayLabel(column);
|
||
header.push(columnLabel + " 总耗时(s)");
|
||
header.push(columnLabel + " 最近更新(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) => "- " + formatTurnColumnDisplayLabel(column) + ": pageRole=" + (column.pageRole || "-") + " pageId=" + (column.pageId || "-") + " 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) => "- " + formatTurnEventDisplayLabel(item) + " " + item.metric + (item.anomaly ? " " + item.anomaly : "") + " " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " allowed=" + (item.allowedIncreaseSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n")
|
||
: "- 未观察到总耗时下降或最近更新异常跳增。";
|
||
const terminalGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : [];
|
||
const terminalGrowthLines = terminalGrowth.length > 0
|
||
? terminalGrowth.slice(0, 80).map((item) => "- " + formatTurnEventDisplayLabel(item) + " terminal totalElapsed growth " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " status " + (item.fromStatus || "-") + " -> " + (item.toStatus || "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n")
|
||
: "- 未观察到 terminal 后总耗时增长。";
|
||
const elapsedZeroResets = Array.isArray(sampleMetrics?.turnTimingElapsedZeroResets) ? sampleMetrics.turnTimingElapsedZeroResets : [];
|
||
const elapsedZeroResetLines = elapsedZeroResets.length > 0
|
||
? elapsedZeroResets.slice(0, 80).map((item) => "- " + formatTurnEventDisplayLabel(item) + " totalElapsed zero-reset " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n")
|
||
: "- 未观察到总耗时从非零跳回 0 秒。";
|
||
const totalElapsedForwardJumps = Array.isArray(sampleMetrics?.turnTimingTotalElapsedForwardJumps) ? sampleMetrics.turnTimingTotalElapsedForwardJumps : [];
|
||
const totalElapsedForwardJumpLines = totalElapsedForwardJumps.length > 0
|
||
? totalElapsedForwardJumps.slice(0, 80).map((item) => "- " + formatTurnEventDisplayLabel(item) + " totalElapsed forward-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 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) => "- " + formatTurnEventDisplayLabel(item) + " 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) => "- " + formatTurnEventDisplayLabel(item) + " 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) => "- " + formatTurnEventDisplayLabel(item) + " 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")
|
||
: "- 未观察到最近更新归零/回落。";
|
||
const disclosureLine = disclosure?.truncated
|
||
? "表格披露:已按 head/tail 有界输出,totalRows=" + disclosure.totalRows + " includedRows=" + disclosure.includedRows + " omittedRows=" + disclosure.omittedRows + ";异常计数在截断前基于全量采样计算。"
|
||
: "表格披露:完整输出当前分析窗口的采样点。";
|
||
return disclosureLine + "\n\n" + lines.join("\n") + "\n\n列说明:\n" + columnLines + "\n\n异常事件(仅报表暴露,不做下游 repair;最近更新按三角波模型检测异常跳增):\n" + nonMonotonicLines + "\n\nTerminal 后总耗时增长事件(终态 turn 的总耗时应 sealed,不应继续增长):\n" + terminalGrowthLines + "\n\n总耗时归零跳变事件(例如已显示真实耗时后又变成 0 秒):\n" + elapsedZeroResetLines + "\n\n总耗时异常前跳事件(预期按采样间隔近似递增;例如 14 秒 -> 1137 秒应被列入这里):\n" + totalElapsedForwardJumpLines + "\n\n最近更新 sawtooth jump 事件(预期每秒增长约 1,遇到新活动归零;例如 1 秒 -> 1 分 4 秒应被列入这里):\n" + sawtoothJumpLines + "\n\n最近更新相邻采样 step(按 delta 降序;用于人工识别一秒跳几十秒/一分钟的瞬态):\n" + stepLines + "\n\n最近更新 reset 事件(预期三角波归零,不计为异常):\n" + resetLines;
|
||
}
|
||
|
||
function formatTurnColumnDisplayLabel(column) {
|
||
const base = String(column?.label || column?.id || "-");
|
||
const role = String(column?.pageRole || "unknown");
|
||
const pageId = compactPageId(column?.pageId);
|
||
return pageId ? base + "@" + role + "/" + pageId : base + "@" + role;
|
||
}
|
||
|
||
function formatTurnEventDisplayLabel(item) {
|
||
const base = String(item?.columnLabel || item?.columnId || "-");
|
||
const role = String(item?.pageRole || "unknown");
|
||
const pageId = compactPageId(item?.pageId);
|
||
return pageId ? base + "@" + role + "/" + pageId : base + "@" + role;
|
||
}
|
||
|
||
function compactPageId(value) {
|
||
if (value === null || value === undefined || value === "") return "";
|
||
const text = String(value);
|
||
if (text.length <= 18) return text;
|
||
return text.slice(0, 12) + ".." + text.slice(-4);
|
||
}
|
||
|
||
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 commandFailureLines = Array.isArray(report.commandFailures) && report.commandFailures.length > 0
|
||
? report.commandFailures.slice(0, 80).map((item) => "- " + (item.ts || "-") + " type=" + (item.type || "-") + " commandId=" + (item.commandId || "-") + " durationMs=" + (item.durationMs ?? "-") + " sampleSeq=" + (item.sampleSeq ?? "-") + " path=" + (item.beforePath || "-") + "->" + (item.afterPath || "-") + " message=" + escapeMarkdownCell(item.message || item.failureKind || item.name || "-")).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 loading = report.sampleMetrics?.loading || {};
|
||
const loadingSummary = loading.summary || {};
|
||
const sessionRailTitles = report.sampleMetrics?.sessionRailTitles || {};
|
||
const sessionRailTitleSummary = sessionRailTitles.summary || {};
|
||
const codeAgentCardTiming = report.sampleMetrics?.codeAgentCardTiming || {};
|
||
const codeAgentCardTimingSummary = codeAgentCardTiming.summary || {};
|
||
const roundCompletion = codeAgentCardTiming.roundCompletion || {};
|
||
const traceOrder = report.sampleMetrics?.traceOrder || {};
|
||
const traceOrderSummary = traceOrder.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) + " modes=" + (Array.isArray(item.submitModes) && item.submitModes.length > 0 ? item.submitModes.join(",") : "-") + " 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 + " loadingSamples=" + (item.loadingSamples ?? 0) + " maxLoading=" + (item.maxLoadingCount ?? 0) + " loadingOwners=" + (item.loadingOwnerCount ?? 0) + " totalMax=" + (item.maxTotalElapsedSeconds ?? "-") + " totalLast=" + (item.lastTotalElapsedSeconds ?? "-") + " recentMax=" + (item.maxRecentUpdateSeconds ?? "-") + " recentLast=" + (item.lastRecentUpdateSeconds ?? "-") + " totalDecrease=" + (item.turnTimingTotalElapsedDecreaseCount ?? 0) + " totalForwardJump=" + (item.turnTimingTotalElapsedForwardJumpCount ?? 0) + " totalForwardJumpMax=" + (item.turnTimingTotalElapsedForwardJumpMaxSeconds ?? 0) + " terminalGrowth=" + (item.turnTimingTerminalElapsedGrowthCount ?? 0) + " terminalGrowthMax=" + (item.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + " recentJump=" + (item.turnTimingRecentUpdateJumpCount ?? 0) + " recentSawtoothJump=" + (item.turnTimingRecentUpdateSawtoothJumpCount ?? item.turnTimingRecentUpdateJumpCount ?? 0) + " recentStep=" + (item.turnTimingRecentUpdateStepCount ?? 0) + " recentMaxIncrease=" + (item.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + " recentMaxExcess=" + (item.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + " recentReset=" + (item.turnTimingRecentUpdateResetCount ?? 0) + " diagnostics=" + item.diagnosticSamples + " terminal=" + item.terminalSamples + " finalText=" + item.finalTextSamples).join("\n")
|
||
: "- 无轮次指标。";
|
||
const cardMissingElapsedLines = Array.isArray(codeAgentCardTiming.missingElapsed) && codeAgentCardTiming.missingElapsed.length > 0
|
||
? codeAgentCardTiming.missingElapsed.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " role=" + (item.pageRole || "-") + " status=" + (item.status || "-") + " traceId=" + (item.traceId || "-") + " messageId=" + (item.messageId || "-") + " preview=" + escapeMarkdownCell(item.textPreview || "")).join("\n")
|
||
: "- 未观察到 Code Agent 卡片缺少耗时。";
|
||
const cardMissingRecentLines = Array.isArray(codeAgentCardTiming.missingRecentUpdate) && codeAgentCardTiming.missingRecentUpdate.length > 0
|
||
? codeAgentCardTiming.missingRecentUpdate.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " role=" + (item.pageRole || "-") + " status=" + (item.status || "-") + " traceId=" + (item.traceId || "-") + " messageId=" + (item.messageId || "-") + " total=" + (item.totalElapsedSeconds ?? "-") + " preview=" + escapeMarkdownCell(item.textPreview || "")).join("\n")
|
||
: "- 未观察到未终态 Code Agent 卡片缺少最近更新。";
|
||
const roundCompletionLines = Array.isArray(roundCompletion.events) && roundCompletion.events.length > 0
|
||
? roundCompletion.events.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " role=" + (item.pageRole || "-") + " traceId=" + (item.traceId || "-") + " completionElapsed=" + (item.elapsedSeconds ?? "-") + " preview=" + escapeMarkdownCell(item.preview || "")).join("\n")
|
||
: "- 未观察到“轮次完成(总耗时 ...)”trace 行。";
|
||
const roundCompletionMismatchLines = Array.isArray(roundCompletion.elapsedMismatches) && roundCompletion.elapsedMismatches.length > 0
|
||
? roundCompletion.elapsedMismatches.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " traceId=" + (item.traceId || "-") + " completion=" + (item.completionElapsedSeconds ?? "-") + " card=" + (item.cardTotalElapsedSeconds ?? "-") + " delta=" + (item.deltaSeconds ?? "-") + " tolerance=" + (item.toleranceSeconds ?? "-")).join("\n")
|
||
: "- 未观察到轮次完成耗时与卡片耗时不一致。";
|
||
const roundCompletionFinalMissingLines = Array.isArray(roundCompletion.finalResponseMissing) && roundCompletion.finalResponseMissing.length > 0
|
||
? roundCompletion.finalResponseMissing.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " role=" + (item.pageRole || "-") + " traceId=" + (item.traceId || "-") + " completionElapsed=" + (item.completionElapsedSeconds ?? "-")).join("\n")
|
||
: "- 未观察到轮次完成后 final response 缺失。";
|
||
const roundCompletionPostTimingLines = Array.isArray(roundCompletion.postCompletionTimingChanges) && roundCompletion.postCompletionTimingChanges.length > 0
|
||
? roundCompletion.postCompletionTimingChanges.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " " + (item.metric || "-") + " " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " completionSeq=" + (item.seq ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " traceId=" + (item.traceId || "-")).join("\n")
|
||
: "- 未观察到轮次完成后耗时/最近更新继续变化。";
|
||
const durationUnderreportedLines = Array.isArray(codeAgentCardTiming.durationUnderreported) && codeAgentCardTiming.durationUnderreported.length > 0
|
||
? codeAgentCardTiming.durationUnderreported.slice(0, 80).map((item) => "- sample=" + (item.sampleIndex ?? "-") + " " + (item.timestamp || "-") + " role=" + (item.pageRole || "-") + " status=" + (item.status || "-") + " traceId=" + (item.traceId || "-") + " card=" + (item.cardTotalElapsedSeconds ?? "-") + "s expected=" + (item.expectedElapsedSeconds ?? "-") + "s delta=" + (item.deltaSeconds ?? "-") + "s evidence=" + (item.evidenceKind || "-") + " preview=" + escapeMarkdownCell(item.evidencePreview || item.cardPreview || "")).join("\n")
|
||
: "- 未观察到 Code Agent 卡片耗时低于 trace/final-response 证据。";
|
||
const durationMismatchLines = Array.isArray(codeAgentCardTiming.durationMismatches) && codeAgentCardTiming.durationMismatches.length > 0
|
||
? codeAgentCardTiming.durationMismatches.slice(0, 80).map((item) => "- sample=" + (item.sampleIndex ?? "-") + " " + (item.timestamp || "-") + " role=" + (item.pageRole || "-") + " status=" + (item.status || "-") + " traceId=" + (item.traceId || "-") + " direction=" + (item.direction || "-") + " card=" + (item.cardTotalElapsedSeconds ?? "-") + "s expected=" + (item.expectedElapsedSeconds ?? "-") + "s signedDelta=" + (item.signedDeltaSeconds ?? "-") + "s delta=" + (item.deltaSeconds ?? "-") + "s tolerance=" + (item.toleranceSeconds ?? "-") + "s evidence=" + (item.evidenceKind || "-") + " exact=" + String(item.exactEvidence === true) + " preview=" + escapeMarkdownCell(item.evidencePreview || item.cardPreview || "")).join("\n")
|
||
: "- 未观察到 Code Agent 卡片耗时与 completion/final-response 封口证据不一致。";
|
||
const traceOrderAnomalyLines = Array.isArray(traceOrder.orderAnomalies) && traceOrder.orderAnomalies.length > 0
|
||
? traceOrder.orderAnomalies.slice(0, 80).map((item) => "- sample=" + (item.sampleIndex ?? "-") + " " + (item.timestamp || "-") + " role=" + (item.pageRole || "-") + " traceId=" + (item.traceId || "-") + " rows=" + (item.previousRowIndex ?? "-") + "->" + (item.currentRowIndex ?? "-") + " reasons=" + (Array.isArray(item.reasons) ? item.reasons.join(",") : "-") + " total=" + (item.previousTotalSeconds ?? "-") + "->" + (item.currentTotalSeconds ?? "-") + " clock=" + (item.previousClockSeconds ?? "-") + "->" + (item.currentClockSeconds ?? "-") + " preview=" + escapeMarkdownCell((item.previousPreview || "") + " / " + (item.currentPreview || ""))).join("\n")
|
||
: "- 未观察到可见 trace 行顺序非单调。";
|
||
const traceCompletionNotLastLines = Array.isArray(traceOrder.completionNotLast) && traceOrder.completionNotLast.length > 0
|
||
? traceOrder.completionNotLast.slice(0, 80).map((item) => "- sample=" + (item.sampleIndex ?? "-") + " " + (item.timestamp || "-") + " role=" + (item.pageRole || "-") + " traceId=" + (item.traceId || "-") + " rows=" + (item.completionRowIndex ?? "-") + "->" + (item.laterRowIndex ?? "-") + " total=" + (item.completionTotalSeconds ?? "-") + "->" + (item.laterTotalSeconds ?? "-") + " completion=" + escapeMarkdownCell(item.completionPreview || "") + " later=" + escapeMarkdownCell(item.laterPreview || "")).join("\n")
|
||
: "- 未观察到 completion 行后还有同 trace 后续行。";
|
||
const loadingSegmentLines = Array.isArray(loading.segments) && loading.segments.length > 0
|
||
? loading.segments.slice(0, 80).map((item) => "- observedDuration=" + (item.durationSeconds ?? 0) + "s upperBound=" + (item.upperBoundSeconds ?? item.durationSeconds ?? 0) + "s endedGap=" + (item.endedGapSeconds ?? "-") + "s samples=" + (item.sampleCount ?? 0) + " countMax=" + (item.maxCount ?? 0) + " owners=" + (item.ownerCount ?? 0) + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " endedAt=" + (item.endedAt || (item.ongoing ? "ongoing" : "-")) + " ownerLabels=" + ((Array.isArray(item.owners) ? item.owners : []).slice(0, 6).map((owner) => (owner.ownerKind || "-") + ":" + (owner.ownerLabel || "-") + "x" + (owner.count ?? 0)).join(",") || "-")).join("\n")
|
||
: "- 未观察到“加载中”可见区间。";
|
||
const loadingOwnerLines = Array.isArray(loading.owners) && loading.owners.length > 0
|
||
? loading.owners.slice(0, 80).map((item) => "- " + (item.ownerKind || "-") + " " + escapeMarkdownCell(item.ownerLabel || item.ownerKey || "-") + " traceId=" + (item.ownerTraceId || "-") + " messageId=" + (item.ownerMessageId || "-") + " sessionId=" + (item.ownerSessionId || "-") + " samples=" + (item.sampleCount ?? 0) + " occurrences=" + (item.occurrenceCount ?? 0) + " maxCount=" + (item.maxSimultaneousCount ?? 0) + " longest=" + (item.longestContinuousSeconds ?? 0) + "s seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " prompts=" + (Array.isArray(item.promptIndexes) ? item.promptIndexes.join(",") : "-")).join("\n")
|
||
: "- 未观察到“加载中”归属。";
|
||
const loadingTimelineLines = Array.isArray(loading.timeline) && loading.timeline.length > 0
|
||
? loading.timeline.slice(0, 160).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " loadingCount=" + (item.loadingCount ?? 0) + " ownerCount=" + (item.ownerCount ?? 0) + " owners=" + ((Array.isArray(item.owners) ? item.owners : []).slice(0, 6).map((owner) => (owner.ownerKind || "-") + ":" + (owner.ownerLabel || "-") + " trace=" + (owner.ownerTraceId || "-") + "x" + (owner.count ?? 0)).join(",") || "-")).join("\n")
|
||
: "- 未观察到“加载中”采样点。";
|
||
const sessionRailTitleSampleLines = Array.isArray(sessionRailTitles.samples) && sessionRailTitles.samples.length > 0
|
||
? sessionRailTitles.samples.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " role=" + (item.pageRole || "-") + " visible=" + (item.visibleCount ?? 0) + " fallback=" + (item.fallbackTitleCount ?? 0) + " ratio=" + (item.fallbackTitleRatio ?? 0) + " examples=" + ((Array.isArray(item.examples) ? item.examples : []).slice(0, 4).map((example) => escapeMarkdownCell(example.titlePreview || example.titleHash || "-")).join(",") || "-")).join("\n")
|
||
: "- 未观察到超过一半 fallback 的 session 列表采样点。";
|
||
const sessionRailTitleExampleLines = Array.isArray(sessionRailTitles.examples) && sessionRailTitles.examples.length > 0
|
||
? sessionRailTitles.examples.slice(0, 80).map((item) => "- firstSeq=" + (item.firstSeq ?? "-") + " role=" + (item.pageRole || "-") + " active=" + String(item.active === true) + " sessionPrefix=" + (item.sessionIdPrefix || "-") + " titleHash=" + (item.titleHash || "-") + " preview=" + escapeMarkdownCell(item.titlePreview || "")).join("\n")
|
||
: "- 无 fallback 标题示例。";
|
||
const provenanceLines = Array.isArray(report.pageProvenance?.segments) && report.pageProvenance.segments.length > 0
|
||
? report.pageProvenance.segments.slice(0, 40).map((item) => "- fingerprint=" + (item.assetFingerprint || "-") + " samples=" + item.sampleCount + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " scripts=" + (item.scriptCount ?? "-") + " styles=" + (item.stylesheetCount ?? "-") + " urlPaths=" + (Array.isArray(item.urlPaths) ? item.urlPaths.slice(0, 4).join(",") : "-")).join("\n")
|
||
: "- 无页面 provenance segment。";
|
||
const ordinaryPerformanceItems = Array.isArray(report.pagePerformance?.sameOriginApiByPath) ? report.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true) : [];
|
||
const streamPerformanceItems = Array.isArray(report.pagePerformance?.sameOriginApiByPath) ? report.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream === true) : [];
|
||
const sameOriginApiBudgetMs = Number(report.alertThresholds?.sameOriginApiSlowMs ?? report.pagePerformance?.summary?.budgetMs);
|
||
const streamOpenBudgetMs = Number(report.alertThresholds?.longLivedStreamOpenSlowMs);
|
||
const performanceLines = ordinaryPerformanceItems.length > 0
|
||
? ordinaryPerformanceItems.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api") + " budgetMetric=" + (item.budgetMetric || "durationMs") + " samples=" + item.sampleCount + " p50=" + (item.p50Ms ?? "-") + "ms p75=" + (item.p75Ms ?? "-") + "ms p95=" + (item.p95Ms ?? "-") + "ms max=" + (item.maxMs ?? "-") + "ms >budget=" + (item.overBudgetCount ?? item.overFiveSecondCount ?? 0) + " budgetMs=" + (item.budgetMs ?? sameOriginApiBudgetMs) + " legacy>5s=" + (item.overFiveSecondCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n")
|
||
: "- 无同源 API Resource Timing 样本。";
|
||
const streamPerformanceLines = streamPerformanceItems.length > 0
|
||
? streamPerformanceItems.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api-stream") + " samples=" + item.sampleCount + " streamOpenP50=" + (item.streamOpenP50Ms ?? "-") + "ms streamOpenP75=" + (item.streamOpenP75Ms ?? "-") + "ms streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamOpenMax=" + (item.streamOpenMaxMs ?? "-") + "ms streamOpen>budget=" + (item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) + " streamOpenBudgetMs=" + (item.streamOpenBudgetMs ?? streamOpenBudgetMs) + " streamOpenLegacy>5s=" + (item.streamOpenOverFiveSecondCount ?? 0) + " streamLifetime>5s=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " lifetimeMax=" + (item.maxMs ?? "-") + "ms window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n")
|
||
: "- 无同源长连接 Resource Timing 样本。";
|
||
const metricLines = Array.isArray(report.sampleMetrics?.timeline) && report.sampleMetrics.timeline.length > 0
|
||
? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " loadingCount=" + (item.loadingCount ?? 0) + " loadingOwners=" + (item.loadingOwnerCount ?? 0) + " 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"
|
||
+ "## Command failures\n\n" + commandFailureLines + "\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"
|
||
+ "- loadingSamples: " + (metricSummary.loadingSampleCount ?? 0) + "\n"
|
||
+ "- loadingMaxCount: " + (metricSummary.loadingMaxCount ?? 0) + "\n"
|
||
+ "- loadingMaxOwnerCount: " + (metricSummary.loadingMaxOwnerCount ?? 0) + "\n"
|
||
+ "- loadingOwnerCount: " + (metricSummary.loadingOwnerCount ?? 0) + "\n"
|
||
+ "- loadingLongestContinuousSeconds: " + (metricSummary.loadingLongestContinuousSeconds ?? 0) + "\n"
|
||
+ "- loadingCurrentContinuousSeconds: " + (metricSummary.loadingCurrentContinuousSeconds ?? 0) + "\n"
|
||
+ "- loadingOverFiveSecondSegmentCount: " + (metricSummary.loadingOverFiveSecondSegmentCount ?? 0) + "\n"
|
||
+ "- sessionRailFallbackMajoritySampleCount: " + (metricSummary.sessionRailFallbackMajoritySampleCount ?? 0) + "\n"
|
||
+ "- sessionRailFallbackMaxRatio: " + (metricSummary.sessionRailFallbackMaxRatio ?? 0) + "\n"
|
||
+ "- sessionRailFallbackMaxCount: " + (metricSummary.sessionRailFallbackMaxCount ?? 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"
|
||
+ "- turnTimingTotalElapsedForwardJumpCount: " + (metricSummary.turnTimingTotalElapsedForwardJumpCount ?? 0) + "\n"
|
||
+ "- turnTimingTotalElapsedForwardJumpMaxSeconds: " + (metricSummary.turnTimingTotalElapsedForwardJumpMaxSeconds ?? 0) + "\n"
|
||
+ "- turnTimingTerminalElapsedGrowthCount: " + (metricSummary.turnTimingTerminalElapsedGrowthCount ?? 0) + "\n"
|
||
+ "- turnTimingTerminalElapsedGrowthMaxSeconds: " + (metricSummary.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + "\n"
|
||
+ "- turnTimingRecentUpdateJumpCount: " + (metricSummary.turnTimingRecentUpdateJumpCount ?? 0) + "\n"
|
||
+ "- turnTimingRecentUpdateSawtoothJumpCount: " + (metricSummary.turnTimingRecentUpdateSawtoothJumpCount ?? metricSummary.turnTimingRecentUpdateJumpCount ?? 0) + "\n"
|
||
+ "- turnTimingRecentUpdateStepCount: " + (metricSummary.turnTimingRecentUpdateStepCount ?? 0) + "\n"
|
||
+ "- turnTimingRecentUpdateMaxIncreaseSeconds: " + (metricSummary.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + "\n"
|
||
+ "- turnTimingRecentUpdateMaxExcessSeconds: " + (metricSummary.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + "\n"
|
||
+ "- turnTimingRecentUpdateResetCount: " + (metricSummary.turnTimingRecentUpdateResetCount ?? 0) + "\n\n"
|
||
+ "- codeAgentCardSampleCount: " + (metricSummary.codeAgentCardSampleCount ?? 0) + "\n"
|
||
+ "- codeAgentCardMissingElapsedCount: " + (metricSummary.codeAgentCardMissingElapsedCount ?? 0) + "\n"
|
||
+ "- codeAgentCardMissingRecentUpdateCount: " + (metricSummary.codeAgentCardMissingRecentUpdateCount ?? 0) + "\n"
|
||
+ "- codeAgentCardDurationUnderreportedCount: " + (metricSummary.codeAgentCardDurationUnderreportedCount ?? 0) + "\n"
|
||
+ "- codeAgentCardDurationMismatchCount: " + (metricSummary.codeAgentCardDurationMismatchCount ?? 0) + "\n"
|
||
+ "- traceRowCount: " + (metricSummary.traceRowCount ?? 0) + "\n"
|
||
+ "- traceRowOrderAnomalyCount: " + (metricSummary.traceRowOrderAnomalyCount ?? 0) + "\n"
|
||
+ "- traceRowCompletionNotLastCount: " + (metricSummary.traceRowCompletionNotLastCount ?? 0) + "\n"
|
||
+ "- roundCompletionEventCount: " + (metricSummary.roundCompletionEventCount ?? 0) + "\n"
|
||
+ "- roundCompletionElapsedMismatchCount: " + (metricSummary.roundCompletionElapsedMismatchCount ?? 0) + "\n"
|
||
+ "- roundCompletionFinalResponseMissingCount: " + (metricSummary.roundCompletionFinalResponseMissingCount ?? 0) + "\n"
|
||
+ "- roundCompletionPostTimingChangeCount: " + (metricSummary.roundCompletionPostTimingChangeCount ?? 0) + "\n\n"
|
||
+ "### Rounds\n\n" + roundLines + "\n\n"
|
||
+ "### Code Agent card timing display\n\n"
|
||
+ "- cardSampleCount: " + (codeAgentCardTimingSummary.cardSampleCount ?? 0) + "\n"
|
||
+ "- runningCardSampleCount: " + (codeAgentCardTimingSummary.runningCardSampleCount ?? 0) + "\n"
|
||
+ "- terminalCardSampleCount: " + (codeAgentCardTimingSummary.terminalCardSampleCount ?? 0) + "\n"
|
||
+ "- missingElapsedCount: " + (codeAgentCardTimingSummary.missingElapsedCount ?? 0) + "\n"
|
||
+ "- missingRecentUpdateCount: " + (codeAgentCardTimingSummary.missingRecentUpdateCount ?? 0) + "\n"
|
||
+ "- durationUnderreportedCount: " + (codeAgentCardTimingSummary.durationUnderreportedCount ?? 0) + "\n"
|
||
+ "- durationMismatchCount: " + (codeAgentCardTimingSummary.durationMismatchCount ?? 0) + "\n"
|
||
+ "- policy: Code Agent 卡片无论终态/非终态都必须显示耗时;非终态必须显示最近更新。该 analyzer 只报告采样到的页面表现,不做下游 repair。\n\n"
|
||
+ "#### Missing elapsed samples\n\n" + cardMissingElapsedLines + "\n\n"
|
||
+ "#### Missing recent update samples\n\n" + cardMissingRecentLines + "\n\n"
|
||
+ "#### Duration underreported samples\n\n" + durationUnderreportedLines + "\n\n"
|
||
+ "#### Duration mismatch samples\n\n" + durationMismatchLines + "\n\n"
|
||
+ "### Trace row visual order\n\n"
|
||
+ "- traceRowCount: " + (traceOrderSummary.traceRowCount ?? 0) + "\n"
|
||
+ "- orderAnomalyCount: " + (traceOrderSummary.orderAnomalyCount ?? 0) + "\n"
|
||
+ "- completionNotLastCount: " + (traceOrderSummary.completionNotLastCount ?? 0) + "\n"
|
||
+ "- policy: 可见 trace 行在同一 trace 内必须按 total/时钟/projected seq 单调展示;completion 行不得出现在同 trace 后续行之前。\n\n"
|
||
+ "#### Trace order anomalies\n\n" + traceOrderAnomalyLines + "\n\n"
|
||
+ "#### Completion row not last samples\n\n" + traceCompletionNotLastLines + "\n\n"
|
||
+ "### Round completion consistency\n\n"
|
||
+ "- completionEventCount: " + (codeAgentCardTimingSummary.roundCompletionEventCount ?? 0) + "\n"
|
||
+ "- elapsedMismatchCount: " + (codeAgentCardTimingSummary.roundCompletionElapsedMismatchCount ?? 0) + "\n"
|
||
+ "- finalResponseMissingCount: " + (codeAgentCardTimingSummary.roundCompletionFinalResponseMissingCount ?? 0) + "\n"
|
||
+ "- postTimingChangeCount: " + (codeAgentCardTimingSummary.roundCompletionPostTimingChangeCount ?? 0) + "\n"
|
||
+ "- postRecentUpdateVisibleCount: " + (codeAgentCardTimingSummary.roundCompletionPostRecentUpdateVisibleCount ?? 0) + "\n"
|
||
+ "- elapsedMismatchToleranceSeconds: " + (codeAgentCardTimingSummary.elapsedMismatchToleranceSeconds ?? "-") + "\n"
|
||
+ "- policy: 轮次完成(总耗时 ...) 的耗时必须与卡片总耗时一致;完成后 final response 必须可见,耗时/最近更新不得继续跳变。\n\n"
|
||
+ "#### Round completion events\n\n" + roundCompletionLines + "\n\n"
|
||
+ "#### Completion elapsed mismatches\n\n" + roundCompletionMismatchLines + "\n\n"
|
||
+ "#### Final response missing after completion\n\n" + roundCompletionFinalMissingLines + "\n\n"
|
||
+ "#### Post-completion timing changes\n\n" + roundCompletionPostTimingLines + "\n\n"
|
||
+ "### Loading visibility: visible 加载中\n\n"
|
||
+ "- sampleCount: " + (loadingSummary.sampleCount ?? 0) + "\n"
|
||
+ "- loadingSampleCount: " + (loadingSummary.loadingSampleCount ?? 0) + "\n"
|
||
+ "- maxSimultaneousCount: " + (loadingSummary.maxSimultaneousCount ?? 0) + "\n"
|
||
+ "- maxSimultaneousOwnerCount: " + (loadingSummary.maxSimultaneousOwnerCount ?? 0) + "\n"
|
||
+ "- concurrentLoadingSampleCount: " + (loadingSummary.concurrentLoadingSampleCount ?? 0) + "\n"
|
||
+ "- ownerCount: " + (loadingSummary.ownerCount ?? 0) + "\n"
|
||
+ "- segmentCount: " + (loadingSummary.segmentCount ?? 0) + "\n"
|
||
+ "- overFiveSecondSegmentCount: " + (loadingSummary.overFiveSecondSegmentCount ?? 0) + "\n"
|
||
+ "- longestContinuousSeconds: " + (loadingSummary.longestContinuousSeconds ?? 0) + "\n"
|
||
+ "- currentContinuousSeconds: " + (loadingSummary.currentContinuousSeconds ?? 0) + "\n"
|
||
+ "- budgetSeconds: " + (loadingSummary.budgetSeconds ?? (Number.isFinite(Number(report.alertThresholds?.visibleLoadingSlowMs)) ? Number(report.alertThresholds.visibleLoadingSlowMs) / 1000 : "unconfigured")) + "\n"
|
||
+ "- policy: 该指标只能证明用户真实看到“加载中”的持续时间;修复必须降低真实请求/投影/渲染耗时,禁止提前展示未加载完内容来压低该指标。\n\n"
|
||
+ "#### Loading segments\n\n" + loadingSegmentLines + "\n\n"
|
||
+ "#### Loading owners\n\n" + loadingOwnerLines + "\n\n"
|
||
+ "#### Loading sample timeline\n\n" + loadingTimelineLines + "\n\n"
|
||
+ "### Session rail titles\n\n"
|
||
+ "- sampleCount: " + (sessionRailTitleSummary.sampleCount ?? 0) + "\n"
|
||
+ "- visibleSampleCount: " + (sessionRailTitleSummary.visibleSampleCount ?? 0) + "\n"
|
||
+ "- fallbackSampleCount: " + (sessionRailTitleSummary.fallbackSampleCount ?? 0) + "\n"
|
||
+ "- majorityFallbackSampleCount: " + (sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) + "\n"
|
||
+ "- maxFallbackRatio: " + (sessionRailTitleSummary.maxFallbackRatio ?? 0) + "\n"
|
||
+ "- maxVisibleCount: " + (sessionRailTitleSummary.maxVisibleCount ?? 0) + "\n"
|
||
+ "- maxFallbackTitleCount: " + (sessionRailTitleSummary.maxFallbackTitleCount ?? 0) + "\n"
|
||
+ "- policy: 可见 session 列表中 'Session ses_...' fallback 标题超过一半必须报警;修复应让上游 session list projection 直接携带名称,不能靠点击详情后下游修补。\n\n"
|
||
+ "#### Session rail fallback samples\n\n" + sessionRailTitleSampleLines + "\n\n"
|
||
+ "#### Session rail fallback examples\n\n" + sessionRailTitleExampleLines + "\n\n"
|
||
+ "### Page provenance\n\n"
|
||
+ "- segmentCount: " + (report.pageProvenance?.summary?.segmentCount ?? 0) + "\n"
|
||
+ "- controlSegmentCount: " + (report.pageProvenance?.summary?.controlSegmentCount ?? 0) + "\n\n"
|
||
+ provenanceLines + "\n\n"
|
||
+ "### Page performance: same-origin API Resource Timing\n\n"
|
||
+ "- budgetMs: " + (report.pagePerformance?.summary?.budgetMs ?? sameOriginApiBudgetMs) + "\n"
|
||
+ "- sameOriginApiPathCount: " + (report.pagePerformance?.summary?.sameOriginApiPathCount ?? 0) + "\n"
|
||
+ "- sameOriginApiSampleCount: " + (report.pagePerformance?.summary?.sameOriginApiSampleCount ?? 0) + "\n"
|
||
+ "- longLivedStreamPathCount: " + (report.pagePerformance?.summary?.longLivedStreamPathCount ?? 0) + "\n"
|
||
+ "- longLivedStreamSampleCount: " + (report.pagePerformance?.summary?.longLivedStreamSampleCount ?? 0) + "\n"
|
||
+ "- longLivedStreamOpenOverFiveSecondPathCount: " + (report.pagePerformance?.summary?.longLivedStreamOpenOverFiveSecondPathCount ?? 0) + "\n"
|
||
+ "- longLivedStreamOpenOverFiveSecondSampleCount: " + (report.pagePerformance?.summary?.longLivedStreamOpenOverFiveSecondSampleCount ?? 0) + "\n"
|
||
+ "- longLivedStreamLifetimeOverFiveSecondSampleCount: " + (report.pagePerformance?.summary?.longLivedStreamLifetimeOverFiveSecondSampleCount ?? 0) + "\n"
|
||
+ "- slowPathCount: " + (report.pagePerformance?.summary?.slowPathCount ?? 0) + "\n"
|
||
+ "- slowSampleCount: " + (report.pagePerformance?.summary?.slowSampleCount ?? 0) + "\n"
|
||
+ "- worstP95Ms: " + (report.pagePerformance?.summary?.worstP95Ms ?? "-") + "\n\n"
|
||
+ performanceLines + "\n\n"
|
||
+ "### Page performance: long-lived streams\n\n"
|
||
+ "- policy: SSE/long-lived stream lifetime is not ordinary API load latency; only stream open latency is compared with the YAML usability budget, while disconnects remain runtime alerts.\n\n"
|
||
+ streamPerformanceLines + "\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)) + "…";
|
||
}
|
||
`;
|
||
}
|