From e4ff941af5d136e1007fca84cd01103f66e90a64 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 23:48:45 +0800 Subject: [PATCH] fix: harden web probe performance sampling --- scripts/web-live-dom-probe.mjs | 400 +++++++++++++++++++++++++++++++-- 1 file changed, 387 insertions(+), 13 deletions(-) diff --git a/scripts/web-live-dom-probe.mjs b/scripts/web-live-dom-probe.mjs index e3330c9f..531534cb 100644 --- a/scripts/web-live-dom-probe.mjs +++ b/scripts/web-live-dom-probe.mjs @@ -225,6 +225,9 @@ export async function runProbe(args) { let page; let result; let traceSamples = []; + let sessionSummary = null; + let traceSummary = null; + let performanceSummary = null; if (args.credentialError) { result = { ok: false, @@ -271,6 +274,9 @@ export async function runProbe(args) { await page.screenshot({ path: screenshotPath, fullPage: true }); const dom = await collectDomEvidence(page, args); const promptValidation = validatePromptFinalResponse(dom, args); + sessionSummary = summarizeSessionRun(actions, dom); + traceSummary = summarizeTraceRun(traceSamples, dom, sessionSummary); + performanceSummary = summarizeProbePerformance(actions, startedAt, traceSamples, traceSummary); result = { ok: true, status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList && promptValidation.ok ? "pass" : "blocked", @@ -284,9 +290,13 @@ export async function runProbe(args) { actions, credentials: args.credentials, bootstrapRoutes: summarizeBootstrapRoutes(bootstrapRoutes), + session: sessionSummary, + trace: traceSummary, + performance: performanceSummary, traceSamples, dom, promptValidation, + degradedReason: runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation }), artifacts: { reportPath, screenshotPath }, safety: { repoOwnedLauncher: true, @@ -311,6 +321,7 @@ export async function runProbe(args) { actions, credentials: args.credentials, error: error instanceof Error ? error.message : String(error), + degradedReason: classifyRunProbeError(error), finalUrl: page ? page.url() : null, failureDom: page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null, artifacts: { reportPath, screenshotPath } @@ -605,10 +616,10 @@ async function createFreshSession(page, args, actions) { const locator = page.locator(selector).first(); if (!(await locator.isVisible({ timeout: 3000 }).catch(() => false))) { actions.push({ action: "fresh-session", selector, skipped: "not-visible" }); - return false; + throw new Error("fresh session create button is not visible"); } const shellReady = await waitForSessionShellReady(page, args, actions); - const before = await collectSessionState(page); + const before = await collectSessionState(page, args); await locator.click(); const timeoutMs = Math.min(args.timeoutMs, 30000); const settled = await page.waitForFunction((initial) => { @@ -622,34 +633,136 @@ async function createFreshSession(page, args, actions) { const input = document.querySelector("#command-input"); return statusReady && (activeChanged || statusChanged) && messageCount === 0 && input && !input.disabled; }, before, { timeout: timeoutMs }).catch(() => null); - const after = await collectSessionState(page); - actions.push({ action: "fresh-session", selector, settled: Boolean(settled), timeoutMs, shellReady, before, after }); - if (!settled) throw new Error(`fresh session did not settle: ${JSON.stringify({ before, after, timeoutMs })}`); + const alignment = await waitForFreshSessionAlignment(page, args, before, timeoutMs); + const after = alignment.after; + actions.push({ action: "fresh-session", selector, settled: Boolean(settled), aligned: alignment.ok, timeoutMs, shellReady, before, after, alignment }); + if (!settled || !alignment.ok) throw new Error(`fresh session did not settle: ${JSON.stringify({ settled: Boolean(settled), alignment, timeoutMs })}`); return true; } +async function waitForFreshSessionAlignment(page, args, before, timeoutMs) { + const startedAt = Date.now(); + const attempts = []; + let after = await collectSessionState(page, args); + let bestCandidate = null; + while (Date.now() - startedAt < timeoutMs) { + const check = freshSessionAlignment(before, after); + bestCandidate = bestCandidate ?? freshSessionCandidate(before, after); + attempts.push({ elapsedMs: Date.now() - startedAt, ok: check.ok, reason: check.reason, conversationId: check.conversationId, routeConversationId: check.routeConversationId, selectedConversationId: check.selectedConversationId, apiConversationPresent: check.apiConversationPresent }); + if (check.ok) return { ok: true, reason: null, attempts, after, check }; + await page.waitForTimeout(500); + after = await collectSessionState(page, args); + } + const check = freshSessionAlignment(before, after); + bestCandidate = bestCandidate ?? freshSessionCandidate(before, after); + attempts.push({ elapsedMs: Date.now() - startedAt, ok: check.ok, reason: check.reason, conversationId: check.conversationId, routeConversationId: check.routeConversationId, selectedConversationId: check.selectedConversationId, apiConversationPresent: check.apiConversationPresent }); + if (!check.ok && bestCandidate?.conversationId) { + const repair = await realignFreshSession(page, args, bestCandidate.conversationId); + after = await collectSessionState(page, args); + const repaired = freshSessionAlignment(before, after); + return { ok: repaired.ok, reason: repaired.reason, attempts, after, check: repaired, repair }; + } + return { ok: false, reason: check.reason, attempts, after, check }; +} + +function freshSessionCandidate(before, after) { + const previousConversationId = firstNonEmpty(before?.routeConversationId, before?.api?.workspace?.selectedConversationId, before?.activeTab?.conversationId); + const items = Array.isArray(after?.api?.conversations?.items) ? after.api.conversations.items : []; + return items.find((item) => item.conversationId && item.conversationId !== previousConversationId && Number(item.messagesCount ?? 0) === 0) ?? null; +} + +async function realignFreshSession(page, args, conversationId) { + const selected = await selectConversationViaApi(page, args, conversationId, "web-live-dom-probe-fresh-realign"); + const target = new URL(`/workbench/sessions/${encodeURIComponent(conversationId)}`, args.url).toString(); + await page.goto(target, { waitUntil: "domcontentloaded", timeout: args.timeoutMs }); + await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs }); + await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); + return { action: "fresh-session-realign", conversationId, target, selected }; +} + +async function selectConversationViaApi(page, args, conversationId, updatedByClient) { + return page.evaluate(async ({ projectId, conversationId, updatedByClient }) => { + const workspaceResponse = await fetch(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" }); + const workspaceBody = await workspaceResponse.json().catch(() => null); + const workspaceId = workspaceBody?.workspace?.workspaceId; + if (!workspaceId) return { ok: false, phase: "workspace", status: workspaceResponse.status, workspaceId: null, error: workspaceBody?.error ?? null }; + const selectResponse = await fetch(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectId, conversationId, updatedByClient }) + }); + const selectBody = await selectResponse.json().catch(() => null); + return { + ok: selectResponse.ok, + phase: "select-conversation", + status: selectResponse.status, + workspaceId, + conversationId: selectBody?.workspace?.selectedConversationId ?? conversationId, + revision: selectBody?.workspace?.revision ?? null, + error: selectBody?.error ?? null + }; + }, { projectId: args.projectId, conversationId, updatedByClient }); +} + +function freshSessionAlignment(before, after) { + const previousConversationId = firstNonEmpty(before?.routeConversationId, before?.api?.workspace?.selectedConversationId, before?.activeTab?.conversationId); + const conversationId = firstNonEmpty(after?.routeConversationId, after?.api?.workspace?.selectedConversationId, after?.activeTab?.conversationId); + const selectedConversationId = after?.api?.workspace?.selectedConversationId ?? null; + const routeConversationId = after?.routeConversationId ?? null; + const apiConversation = conversationId ? after?.api?.conversations?.items?.find((item) => item.conversationId === conversationId) ?? null : null; + const routeMatches = Boolean(routeConversationId && conversationId && routeConversationId === conversationId); + const workspaceMatches = Boolean(selectedConversationId && conversationId && selectedConversationId === conversationId); + const apiConversationPresent = Boolean(apiConversation); + const activeTabMatches = !after?.activeTab?.conversationId || after.activeTab.conversationId === conversationId; + const changed = Boolean(conversationId && conversationId !== previousConversationId); + const commandReady = after?.command?.inputDisabled === false; + const empty = after?.messageCount === 0; + const ok = changed && routeMatches && workspaceMatches && apiConversationPresent && activeTabMatches && commandReady && empty; + const reason = ok ? null + : !changed ? "fresh-session-conversation-not-changed" + : !routeMatches ? "fresh-session-route-not-aligned" + : !workspaceMatches ? "fresh-session-workspace-not-aligned" + : !apiConversationPresent ? "fresh-session-conversation-api-missing" + : !activeTabMatches ? "fresh-session-active-tab-not-aligned" + : !commandReady ? "fresh-session-command-not-ready" + : "fresh-session-not-empty"; + return { ok, reason, previousConversationId, conversationId, routeConversationId, selectedConversationId, apiConversationPresent, activeTabConversationId: after?.activeTab?.conversationId ?? null, commandReady, command: after?.command ?? null, messageCount: after?.messageCount ?? null }; +} + async function waitForSessionShellReady(page, args, actions) { const timeoutMs = Math.min(args.timeoutMs, 30000); - const before = await collectSessionState(page); + const before = await collectSessionState(page, args); const ready = await page.waitForFunction(() => { const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? ""; const workspace = document.querySelector("#workspace"); const create = document.querySelector("#session-create"); return Boolean(workspace && create && !create.disabled && statusText && !/加载中|等待 workspace/u.test(statusText)); }, null, { timeout: timeoutMs }).catch(() => null); - const after = await collectSessionState(page); + const after = await collectSessionState(page, args); const result = { action: "session-shell-ready", ready: Boolean(ready), timeoutMs, before, after }; actions.push(result); if (!ready) throw new Error(`session shell did not finish initial loading: ${JSON.stringify(result)}`); return after; } -async function collectSessionState(page) { - return page.evaluate(() => { +async function collectSessionState(page, args = null) { + const dom = await page.evaluate(() => { const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) ?? window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); return { + finalUrl: window.location.href, + locationPath: window.location.pathname, + routeConversationId: routeMatch ? decodeURIComponent(routeMatch[1]) : null, statusText: document.querySelector("#session-status")?.textContent?.trim() ?? null, activeTabTitle: activeTab?.querySelector("strong")?.textContent?.trim() ?? activeTab?.getAttribute("title") ?? null, + activeTab: activeTab ? { + title: activeTab.querySelector("strong")?.textContent?.trim() ?? activeTab.getAttribute("title") ?? null, + conversationId: activeTab.getAttribute("data-conversation-id") ?? null, + sessionId: activeTab.getAttribute("data-session-id") ?? null, + status: activeTab.getAttribute("data-status") ?? null, + running: activeTab.getAttribute("data-running") ?? null + } : null, tabCount: document.querySelectorAll(".session-tab").length, messageCount: document.querySelectorAll(".message-card").length, emptyVisible: Boolean(document.querySelector("[data-conversation-empty]")), @@ -661,6 +774,108 @@ async function collectSessionState(page) { } }; }); + if (!args) return dom; + const workspace = await fetchJsonFromPage(page, `/v1/workbench/workspace?projectId=${encodeURIComponent(args.projectId)}`, { timeoutMs: 8000 }); + const conversations = await fetchJsonFromPage(page, `/v1/agent/conversations?projectId=${encodeURIComponent(args.projectId)}`, { timeoutMs: 8000 }); + return { + ...dom, + api: { + workspace: summarizeWorkspaceFetch(workspace), + conversations: summarizeConversationsFetch(conversations) + } + }; +} + +async function fetchJsonFromPage(page, requestPath, options = {}) { + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000; + return page.evaluate(async ({ requestPath: path, timeoutMs: fetchTimeoutMs }) => { + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), fetchTimeoutMs); + try { + const response = await fetch(path, { credentials: "same-origin", headers: { accept: "application/json" }, signal: controller.signal }); + const text = await response.text(); + let json = null; + let parseError = null; + try { + json = text ? JSON.parse(text) : null; + } catch (error) { + parseError = error instanceof Error ? error.message : String(error); + } + return { + ok: response.ok && parseError === null, + path, + status: response.status, + statusText: response.statusText, + elapsedMs: Date.now() - startedAt, + contentType: response.headers.get("content-type"), + bodyPreview: text.slice(0, 500), + json, + parseError, + error: parseError + }; + } catch (error) { + return { + ok: false, + path, + status: null, + statusText: null, + elapsedMs: Date.now() - startedAt, + contentType: null, + bodyPreview: null, + json: null, + parseError: null, + error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : null + }; + } finally { + clearTimeout(timer); + } + }, { requestPath, timeoutMs }); +} + +function summarizeWorkspaceFetch(fetchResult) { + const workspace = fetchResult?.json?.workspace ?? fetchResult?.json?.data?.workspace ?? null; + const selected = workspace?.selectedConversation ?? null; + return { + ok: fetchResult?.ok === true, + path: fetchResult?.path ?? null, + status: fetchResult?.status ?? null, + error: fetchResult?.error ?? null, + bodyPreview: fetchResult?.ok === true ? null : fetchResult?.bodyPreview ?? null, + workspaceId: workspace?.workspaceId ?? null, + revision: workspace?.revision ?? null, + selectedConversationId: workspace?.selectedConversationId ?? selected?.conversationId ?? null, + selectedAgentSessionId: workspace?.selectedAgentSessionId ?? selected?.sessionId ?? null, + activeTraceId: workspace?.activeTraceId ?? selected?.lastTraceId ?? null, + selectedConversationStatus: selected?.status ?? workspace?.sessionStatus ?? null, + selectedMessagesCount: Array.isArray(selected?.messages) ? selected.messages.length : selected?.messagesCount ?? null + }; +} + +function summarizeConversationsFetch(fetchResult) { + const conversations = Array.isArray(fetchResult?.json?.conversations) + ? fetchResult.json.conversations + : Array.isArray(fetchResult?.json?.data?.conversations) + ? fetchResult.json.data.conversations + : []; + return { + ok: fetchResult?.ok === true, + path: fetchResult?.path ?? null, + status: fetchResult?.status ?? null, + error: fetchResult?.error ?? null, + bodyPreview: fetchResult?.ok === true ? null : fetchResult?.bodyPreview ?? null, + count: conversations.length, + items: conversations.slice(0, 16).map((item) => ({ + conversationId: item?.conversationId ?? null, + sessionId: item?.sessionId ?? null, + threadId: item?.threadId ?? null, + status: item?.status ?? null, + messagesCount: Array.isArray(item?.messages) ? item.messages.length : item?.messagesCount ?? null, + lastTraceId: item?.lastTraceId ?? null, + updatedAt: item?.updatedAt ?? null + })) + }; } async function submitPrompt(page, args, actions) { @@ -672,9 +887,10 @@ async function submitPrompt(page, args, actions) { return input?.value === expected && send && !send.disabled; }, args.message, { timeout: Math.min(args.timeoutMs, 5000) }); const afterFill = await collectCommandState(page); + const submittedAt = new Date().toISOString(); await page.locator("#command-send").click(); const afterClick = await collectCommandState(page); - actions.push({ action: "submit-prompt", chars: args.message.length, readyBefore, afterFill, afterClick }); + actions.push({ action: "submit-prompt", chars: args.message.length, submittedAt, readyBefore, afterFill, afterClick }); await page.waitForTimeout(args.waitAfterSubmitMs); } @@ -682,14 +898,14 @@ async function sampleTraceTimeline(page, args, actions) { const samples = []; for (let index = 0; index < args.traceSampleCount; index += 1) { if (index > 0 && args.traceSampleIntervalMs > 0) await page.waitForTimeout(args.traceSampleIntervalMs); - samples.push(await collectTraceTimelineSample(page, index)); + samples.push(await collectTraceTimelineSample(page, args, index)); } actions.push({ action: "trace-interval-samples", count: samples.length, intervalMs: args.traceSampleIntervalMs, samples }); return samples; } -async function collectTraceTimelineSample(page, index) { - return page.evaluate((sampleIndex) => { +async function collectTraceTimelineSample(page, args, index) { + const dom = await page.evaluate((sampleIndex) => { const cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-agent" || item === "message-user" || item === "message-system")?.replace(/^message-/, "") ?? null; const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null; const cards = [...document.querySelectorAll(".message-card")]; @@ -714,6 +930,155 @@ async function collectTraceTimelineSample(page, index) { finalTextChars: finalResponse?.textContent?.trim().length ?? 0 }; }, index); + const session = await collectSessionState(page, args); + const conversationId = firstNonEmpty(session.routeConversationId, session.api?.workspace?.selectedConversationId, session.activeTab?.conversationId); + const conversation = conversationId ? session.api?.conversations?.items?.find((item) => item.conversationId === conversationId) ?? null : null; + const traceId = firstNonEmpty(conversation?.lastTraceId, session.api?.workspace?.activeTraceId); + const traceFetch = traceId + ? await fetchTraceSummaryWithRetry(page, args, traceId) + : { ok: false, traceId: null, degradedReason: "trace-id-missing", attempts: [] }; + return { ...dom, conversationId, sessionId: conversation?.sessionId ?? session.activeTab?.sessionId ?? null, traceId, traceFetch }; +} + +async function fetchTraceSummaryWithRetry(page, args, traceId) { + const path = `/v1/agent/traces/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(args.projectId)}&sinceSeq=0&limit=20`; + const attempts = []; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const fetched = await fetchJsonFromPage(page, path, { timeoutMs: 8000 }); + const summary = summarizeTraceFetch(fetched, traceId, attempt); + attempts.push(summary); + if (summary.ok) return { ...summary, attempts }; + if (attempt < 3) await page.waitForTimeout(500 * attempt); + } + const last = attempts.at(-1) ?? { ok: false, traceId, degradedReason: "trace-fetch-failed" }; + return { ...last, attempts, degradedReason: last.degradedReason ?? "trace-fetch-failed" }; +} + +function summarizeTraceFetch(fetchResult, expectedTraceId, attempt) { + const body = fetchResult?.json?.data ?? fetchResult?.json ?? null; + const trace = body?.trace ?? body?.runnerTrace ?? body; + const events = Array.isArray(trace?.events) ? trace.events : Array.isArray(body?.events) ? body.events : []; + const latest = events.at(-1) ?? null; + const ok = fetchResult?.ok === true; + return { + ok, + attempt, + path: fetchResult?.path ?? null, + httpStatus: fetchResult?.status ?? null, + elapsedMs: fetchResult?.elapsedMs ?? null, + traceId: trace?.traceId ?? body?.traceId ?? expectedTraceId, + traceStatus: trace?.status ?? body?.status ?? null, + running: trace?.running ?? body?.running ?? null, + terminal: trace?.terminal ?? body?.terminal ?? null, + eventCount: events.length, + firstEventAt: events[0]?.createdAt ?? events[0]?.at ?? null, + latestEventAt: latest?.createdAt ?? latest?.at ?? null, + latestEventPreview: latest ? JSON.stringify(latest).replace(/\s+/gu, " ").slice(0, 300) : null, + error: fetchResult?.error ?? null, + bodyPreview: ok ? null : fetchResult?.bodyPreview ?? null, + degradedReason: ok ? null : fetchResult?.status ? "trace-fetch-http-status" : "trace-fetch-failed" + }; +} + +function summarizeSessionRun(actions, dom) { + const fresh = [...actions].reverse().find((item) => item.action === "fresh-session") ?? null; + const alignment = fresh?.alignment?.check ?? null; + const sessionRail = dom?.sessionRail ?? null; + const active = sessionRail?.active ?? null; + return { + freshSessionRequested: Boolean(fresh), + freshSessionAligned: fresh ? fresh.aligned === true : null, + conversationId: firstNonEmpty(alignment?.conversationId, active?.conversationId, dom?.routeConversationId), + routeConversationId: alignment?.routeConversationId ?? dom?.routeConversationId ?? null, + selectedConversationId: alignment?.selectedConversationId ?? null, + sessionId: active?.sessionId ?? null, + revision: fresh?.after?.api?.workspace?.revision ?? null, + messageCount: dom?.messageCount ?? null, + degradedReason: fresh && fresh.aligned !== true ? fresh.alignment?.reason ?? "fresh-session-not-aligned" : null, + freshSession: fresh ? { + settled: fresh.settled === true, + aligned: fresh.aligned === true, + reason: fresh.alignment?.reason ?? null, + before: compactSessionState(fresh.before), + after: compactSessionState(fresh.after) + } : null + }; +} + +function compactSessionState(state) { + if (!state) return null; + return { + finalUrl: state.finalUrl ?? null, + routeConversationId: state.routeConversationId ?? null, + selectedConversationId: state.api?.workspace?.selectedConversationId ?? null, + revision: state.api?.workspace?.revision ?? null, + activeTab: state.activeTab ?? null, + messageCount: state.messageCount ?? null, + command: state.command ?? null, + conversationsCount: state.api?.conversations?.count ?? null + }; +} + +function summarizeTraceRun(samples, dom, sessionSummary) { + const firstDomRow = samples.find((sample) => Number(sample.rowCount ?? 0) > 0) ?? null; + const firstRestEvent = samples.find((sample) => Number(sample.traceFetch?.eventCount ?? 0) > 0) ?? null; + const last = samples.at(-1) ?? null; + const failedFetches = samples.filter((sample) => sample.traceFetch && sample.traceFetch.ok !== true); + return { + requested: samples.length > 0, + sampleCount: samples.length, + conversationId: firstNonEmpty(last?.conversationId, sessionSummary?.conversationId), + sessionId: firstNonEmpty(last?.sessionId, sessionSummary?.sessionId), + traceId: firstNonEmpty(last?.traceId, firstRestEvent?.traceId, dom?.sessionRail?.traceIds?.[0]), + finalAgentStatus: last?.agentStatus ?? null, + finalTraceStatus: last?.traceStatus ?? null, + finalDomRowCount: last?.rowCount ?? null, + firstDomRowVisibleAt: firstDomRow?.sampledAt ?? null, + firstDomRowPreview: firstDomRow?.latestRowPreview ?? null, + firstRestEventVisibleAt: firstRestEvent?.sampledAt ?? null, + firstRestEventCount: firstRestEvent?.traceFetch?.eventCount ?? null, + restTraceOk: samples.some((sample) => sample.traceFetch?.ok === true), + failedFetchCount: failedFetches.length, + latestFetch: last?.traceFetch ?? null, + degradedReason: samples.length === 0 ? null : failedFetches.length === samples.length ? failedFetches.at(-1)?.traceFetch?.degradedReason ?? "trace-fetch-failed" : null + }; +} + +function summarizeProbePerformance(actions, startedAt, samples, traceSummary) { + const submit = [...actions].reverse().find((item) => item.action === "submit-prompt") ?? null; + const submittedAt = submit?.submittedAt ?? null; + return { + startedAt, + submittedAt, + firstDomTraceRowAt: traceSummary?.firstDomRowVisibleAt ?? null, + firstRestTraceEventAt: traceSummary?.firstRestEventVisibleAt ?? null, + submitToFirstDomTraceRowMs: submittedAt && traceSummary?.firstDomRowVisibleAt ? Math.max(0, new Date(traceSummary.firstDomRowVisibleAt).getTime() - new Date(submittedAt).getTime()) : null, + submitToFirstRestTraceEventMs: submittedAt && traceSummary?.firstRestEventVisibleAt ? Math.max(0, new Date(traceSummary.firstRestEventVisibleAt).getTime() - new Date(submittedAt).getTime()) : null, + totalSampleWindowMs: samples.length >= 2 ? Math.max(0, new Date(samples.at(-1).sampledAt).getTime() - new Date(samples[0].sampledAt).getTime()) : 0 + }; +} + +function runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation }) { + if (sessionSummary?.degradedReason) return sessionSummary.degradedReason; + if (traceSummary?.degradedReason) return traceSummary.degradedReason; + if (promptValidation?.ok === false) return "prompt-validation-failed"; + return null; +} + +function classifyRunProbeError(error) { + const message = error instanceof Error ? error.message : String(error); + if (/fresh session/u.test(message)) return "fresh-session-not-aligned"; + if (/login|auth/u.test(message)) return "auth-login-failed"; + if (/Timeout|timed out|timeout/u.test(message)) return "browser-timeout"; + if (/fetch/u.test(message)) return "trace-fetch-failed"; + return "web-probe-failed"; +} + +function firstNonEmpty(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + } + return null; } async function waitForCommandReady(page, args, actions) { @@ -868,6 +1233,7 @@ async function collectDomEvidence(page, args) { const conversations = Array.isArray(conversationBody?.conversations) ? conversationBody.conversations : []; const sessionTabs = [...document.querySelectorAll("#session-tabs .session-tab")]; const activeSessionTab = sessionTabs.find((item) => item.getAttribute("data-active") === "true") ?? null; + const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) ?? window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); const sessionIds = conversations.map((item) => item?.sessionId).filter((value) => typeof value === "string" && value).slice(0, 8); const traceIds = conversations.map((item) => item?.lastTraceId).filter((value) => typeof value === "string" && value).slice(0, 8); const scroll = workspace ? { @@ -880,6 +1246,7 @@ async function collectDomEvidence(page, args) { return { title: document.title, authState: document.body.getAttribute("data-auth-state"), + routeConversationId: routeMatch ? decodeURIComponent(routeMatch[1]) : null, requiredSelectors: { workspace: Boolean(workspace), commandInput: Boolean(document.querySelector("#command-input")), @@ -890,6 +1257,13 @@ async function collectDomEvidence(page, args) { sessionRail: { tabCount: sessionTabs.length, activeTitle: activeSessionTab?.querySelector("strong")?.textContent?.trim() ?? null, + active: activeSessionTab ? { + title: activeSessionTab.querySelector("strong")?.textContent?.trim() ?? activeSessionTab.getAttribute("title") ?? null, + conversationId: activeSessionTab.getAttribute("data-conversation-id") ?? null, + sessionId: activeSessionTab.getAttribute("data-session-id") ?? null, + status: activeSessionTab.getAttribute("data-status") ?? null, + running: activeSessionTab.getAttribute("data-running") ?? null + } : null, objectTitleCount: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").filter((text) => text.includes("[object Object]")).length, titles: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").slice(0, 8), sessionIds,