#!/usr/bin/env node // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-010401 Web工作台 - Workbench 可见性与性能探针 import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { importPlaywright, launchChromium } from "./src/browser-launcher.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; const scriptPath = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(scriptPath), ".."); const stateRoot = path.join(repoRoot, ".state", "web-live-dom-probe"); if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { const args = parseProbeArgs(process.argv.slice(2)); const result = args.command === "start" ? await startProbe(args) : args.command === "status" ? await probeStatus(args) : args.command === "help" ? helpPayload() : await runProbe(args); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); process.exitCode = ["pass", "running", "usage"].includes(result.status) ? 0 : 2; } catch (error) { process.stderr.write(`[web-live-dom-probe] ${error instanceof Error ? error.stack : String(error)}\n`); process.stdout.write(`${JSON.stringify({ ok: false, command: "web-live-dom-probe", status: "blocked", error: error instanceof Error ? error.message : String(error) }, null, 2)}\n`); process.exitCode = 2; } } export function parseProbeArgs(argv) { const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run"; const startIndex = command === "run" ? (argv[0] === "run" ? 1 : 0) : 1; const args = { command, url: process.env.HWLAB_WEB_PROBE_URL ?? process.env.HWLAB_WEB_URL ?? "http://74.48.78.17:19666/", user: process.env.HWLAB_WEB_USER ?? "admin", pass: process.env.HWLAB_WEB_PASS ?? "", credentials: { userSource: process.env.HWLAB_WEB_USER ? "env:HWLAB_WEB_USER" : "default", passSource: process.env.HWLAB_WEB_PASS ? "env:HWLAB_WEB_PASS" : "unresolved", valuesRedacted: true }, credentialError: null, viewport: process.env.HWLAB_WEB_PROBE_VIEWPORT ?? "1440x900", message: process.env.HWLAB_WEB_PROBE_MESSAGE ?? "", freshSession: false, cancelRunning: true, waitAfterSubmitMs: 1500, timeoutMs: 30000, projectId: process.env.HWLAB_WEB_PROBE_PROJECT_ID ?? "prj_hwpod_workbench", conversationId: process.env.HWLAB_WEB_PROBE_CONVERSATION_ID ?? "", waitMessagesMs: 2500, waitAgentTerminalMs: Number(process.env.HWLAB_WEB_PROBE_WAIT_AGENT_TERMINAL_MS ?? "0"), traceSampleCount: envNonNegativeInteger("HWLAB_WEB_PROBE_TRACE_SAMPLE_COUNT", 0), traceSampleIntervalMs: envNonNegativeInteger("HWLAB_WEB_PROBE_TRACE_SAMPLE_INTERVAL_MS", 0), reportPath: tempReportPath("web-live-dom-probe.json"), screenshotPath: tempReportPath("web-live-dom-probe.png"), jobId: "" }; if (command === "help" || argv.includes("--help") || argv.includes("-h")) return { ...args, command: "help" }; for (let index = startIndex; index < argv.length; index += 1) { const arg = argv[index]; const readValue = (name) => { const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u")); if (inline) return inline[1]; const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); index += 1; return value; }; if (arg === "--url" || arg.startsWith("--url=")) args.url = readValue("--url"); else if (arg === "--user" || arg.startsWith("--user=")) { args.user = readValue("--user"); args.credentials.userSource = "cli:--user"; } else if (arg === "--pass" || arg.startsWith("--pass=")) { args.pass = readValue("--pass"); args.credentials.passSource = "cli:--pass"; } else if (arg === "--viewport" || arg.startsWith("--viewport=")) args.viewport = readValue("--viewport"); else if (arg === "--message" || arg.startsWith("--message=")) args.message = readValue("--message"); else if (arg === "--project-id" || arg.startsWith("--project-id=")) args.projectId = readValue("--project-id"); else if (arg === "--conversation-id" || arg.startsWith("--conversation-id=")) args.conversationId = readValue("--conversation-id"); else if (arg === "--report" || arg.startsWith("--report=")) args.reportPath = readValue("--report"); else if (arg === "--screenshot" || arg.startsWith("--screenshot=")) args.screenshotPath = readValue("--screenshot"); else if (arg === "--job-id" || arg.startsWith("--job-id=")) args.jobId = readValue("--job-id"); else if (arg === "--wait-after-submit-ms" || arg.startsWith("--wait-after-submit-ms=")) args.waitAfterSubmitMs = positiveInteger(readValue("--wait-after-submit-ms"), "--wait-after-submit-ms"); else if (arg === "--wait-messages-ms" || arg.startsWith("--wait-messages-ms=")) args.waitMessagesMs = positiveInteger(readValue("--wait-messages-ms"), "--wait-messages-ms"); else if (arg === "--wait-agent-terminal-ms" || arg.startsWith("--wait-agent-terminal-ms=")) args.waitAgentTerminalMs = positiveInteger(readValue("--wait-agent-terminal-ms"), "--wait-agent-terminal-ms"); else if (arg === "--trace-sample-count" || arg.startsWith("--trace-sample-count=")) args.traceSampleCount = nonNegativeInteger(readValue("--trace-sample-count"), "--trace-sample-count"); else if (arg === "--trace-sample-interval-ms" || arg.startsWith("--trace-sample-interval-ms=")) args.traceSampleIntervalMs = nonNegativeInteger(readValue("--trace-sample-interval-ms"), "--trace-sample-interval-ms"); else if (arg === "--timeout-ms" || arg.startsWith("--timeout-ms=")) args.timeoutMs = positiveInteger(readValue("--timeout-ms"), "--timeout-ms"); else if (arg === "--fresh-session") args.freshSession = true; else if (arg === "--no-cancel-running") args.cancelRunning = false; else if (command === "status" && !args.jobId) args.jobId = arg; else throw new Error(`unknown web-live-dom-probe argument: ${arg}`); } resolveProbeCredentials(args); return args; } function resolveProbeCredentials(args) { if (args.command === "help" || args.command === "status") return; if (args.pass) return; const target = inferProbeCredentialTarget(args.url); if (!target) { args.pass = "hwlab2026"; args.credentials = { ...args.credentials, passSource: "legacy-default", valuesRedacted: true }; return; } if (args.credentials.userSource === "default") { args.user = target.username; args.credentials.userSource = "target-default"; } const material = readProbeSecretSource(target.sourceRef, target.sourceKey); args.credentials = { ...args.credentials, node: target.node, lane: target.lane, sourceRef: target.sourceRef, sourceKey: target.sourceKey, sourcePath: material.sourcePath ? displayProbeSecretPath(material.sourcePath) : null, sourcePresent: material.present, sourceFingerprint: material.fingerprint, passSource: material.ok ? "secret-source" : "missing-secret-source", valuesRedacted: true }; if (material.ok) { args.pass = material.value; return; } args.credentialError = { code: "web_login_secret_missing", message: `Web login secret source is missing for ${target.node}/${target.lane}`, reason: material.reason }; } function inferProbeCredentialTarget(url) { let parsed; try { parsed = new URL(url); } catch { return null; } if (parsed.hostname === "hwlab.pikapython.com") { return { node: "D601", lane: "v03", username: "admin", sourceRef: "hwlab/d601-v03-bootstrap-admin.env", sourceKey: "HWLAB_BOOTSTRAP_ADMIN_PASSWORD" }; } if (parsed.hostname === "74.48.78.17" && (parsed.port === "20666" || parsed.port === "20667")) { return { node: "G14", lane: "v03", username: "admin", sourceRef: "hwlab/g14-v03-bootstrap-admin.env", sourceKey: "HWLAB_BOOTSTRAP_ADMIN_PASSWORD" }; } return null; } function readProbeSecretSource(sourceRef, sourceKey) { const sourcePath = probeSecretSourcePaths(sourceRef).find((candidate) => fs.existsSync(candidate)); if (!sourcePath) return { ok: false, present: false, sourcePath: probeSecretSourcePaths(sourceRef)[0] ?? null, fingerprint: null, reason: "secret-source-missing" }; const values = parseEnvFile(fs.readFileSync(sourcePath, "utf8")); const value = values[sourceKey]; if (!value) return { ok: false, present: true, sourcePath, fingerprint: null, reason: "secret-key-missing" }; return { ok: true, present: true, sourcePath, value, fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`, reason: null }; } function probeSecretSourcePaths(sourceRef) { const roots = [ process.env.HWLAB_WEB_SECRET_SOURCE_ROOT, process.env.UNIDESK_SECRET_SOURCE_ROOT, path.join(repoRoot, ".state", "secrets"), repoRoot.includes("/.worktree/") ? path.join(repoRoot.slice(0, repoRoot.indexOf("/.worktree/")), ".state", "secrets") : null, "/root/unidesk/.state/secrets" ].filter(Boolean); return [...new Set(roots.map((root) => path.join(root, sourceRef)))]; } function parseEnvFile(text) { const values = {}; for (const rawLine of text.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); const rawValue = line.slice(eq + 1).trim(); values[key] = rawValue.startsWith("'") && rawValue.endsWith("'") ? rawValue.slice(1, -1).replace(/'"'"'/gu, "'") : rawValue.startsWith('"') && rawValue.endsWith('"') ? rawValue.slice(1, -1) : rawValue; } return values; } function displayProbeSecretPath(sourcePath) { if (sourcePath.startsWith(`${repoRoot}/`)) return sourcePath.slice(repoRoot.length + 1); const unideskRoot = "/root/unidesk/"; if (sourcePath.startsWith(unideskRoot)) return sourcePath.slice(unideskRoot.length); return sourcePath; } export async function runProbe(args) { const startedAt = new Date().toISOString(); const reportPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.reportPath), "--report"); const screenshotPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.screenshotPath), "--screenshot"); const { width, height } = parseViewport(args.viewport); const actions = []; let browser; let page; let result; let traceSamples = []; let sessionSummary = null; let traceSummary = null; let performanceSummary = null; if (args.credentialError) { result = { ok: false, status: "blocked", command: "web-live-dom-probe run", generatedAt: new Date().toISOString(), startedAt, url: args.url, actions, credentials: args.credentials, error: args.credentialError.code, credentialError: args.credentialError, artifacts: { reportPath, screenshotPath } }; await mkdir(path.dirname(reportPath), { recursive: true }); await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); result.artifacts = { ...(result.artifacts ?? {}), reportPath, reportSha256: await fileSha256(reportPath), screenshotPath, screenshotSha256: await fileSha256(screenshotPath) }; return result; } try { const { chromium } = await importPlaywright(); browser = await launchChromium(chromium); const context = await browser.newContext({ viewport: { width, height } }); page = await context.newPage(); const bootstrapRoutes = observeBootstrapRoutes(page); await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs }); await waitForAuthBootstrap(page, args, actions); await maybeLogin(page, args, actions); await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs }); await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); if (args.conversationId) await selectConversationInBrowserSession(page, args, actions); if (args.freshSession) await createFreshSession(page, args, actions); if (args.message) await submitPrompt(page, args, actions); if (args.message && args.traceSampleCount > 0) traceSamples = await sampleTraceTimeline(page, args, actions); if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions); if (args.message && args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message"); await waitForMessageCards(page, args, actions); 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); const degradedReason = runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation, actions }); result = { ok: true, status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList && promptValidation.ok && !degradedReason ? "pass" : "blocked", command: "web-live-dom-probe run", generatedAt: new Date().toISOString(), startedAt, url: args.url, finalUrl: page.url(), viewport: { width, height }, browser: browser.hwlabLaunchPlan ?? null, actions, credentials: args.credentials, bootstrapRoutes: summarizeBootstrapRoutes(bootstrapRoutes), session: sessionSummary, trace: traceSummary, performance: performanceSummary, traceSamples, dom, promptValidation, degradedReason, artifacts: { reportPath, screenshotPath }, safety: { repoOwnedLauncher: true, directChromiumLaunch: false, projectId: args.projectId, conversationSelected: Boolean(args.conversationId), freshSessionRequested: args.freshSession, promptSubmitted: Boolean(args.message), traceSamplingRequested: args.traceSampleCount > 0, cancelRunningRequested: args.cancelRunning } }; } catch (error) { const finalUrl = page ? page.url() : null; const failureDom = page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null; let screenshotCapture = null; if (page && !page.isClosed()) { try { await page.screenshot({ path: screenshotPath, fullPage: true }); screenshotCapture = { ok: true }; } catch (screenshotError) { screenshotCapture = { ok: false, error: screenshotError instanceof Error ? screenshotError.message : String(screenshotError) }; } } result = { ok: false, status: "blocked", command: "web-live-dom-probe run", generatedAt: new Date().toISOString(), startedAt, url: args.url, browser: browser?.hwlabLaunchPlan ?? error?.hwlabBrowserLaunch ?? null, actions, credentials: args.credentials, error: error instanceof Error ? error.message : String(error), degradedReason: classifyRunProbeError(error, { finalUrl, failureDom }), finalUrl, failureDom, artifacts: { reportPath, screenshotPath, screenshotCapture } }; } finally { if (browser) await browser.close().catch(() => {}); } await mkdir(path.dirname(reportPath), { recursive: true }); await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); result.artifacts = { ...(result.artifacts ?? {}), reportPath, reportSha256: await fileSha256(reportPath), screenshotPath, screenshotSha256: await fileSha256(screenshotPath) }; return result; } async function collectFailureDomEvidence(page) { return page.evaluate(() => { const text = (selector) => document.querySelector(selector)?.textContent?.trim() ?? null; const bodyText = document.body?.innerText?.replace(/\s+/gu, " ").trim() ?? ""; return { authState: document.body?.getAttribute("data-auth-state") ?? null, title: document.title || null, locationPath: window.location.pathname, login: { cardVisible: Boolean(document.querySelector(".login-card")), usernameVisible: Boolean(document.querySelector('#login-username, input[autocomplete="username"]')), passwordVisible: Boolean(document.querySelector('#login-password, input[autocomplete="current-password"], input[type="password"]')), errorText: text(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive") }, requiredSelectors: { workspace: Boolean(document.querySelector("#workspace")), commandInput: Boolean(document.querySelector("#command-input")), conversationList: Boolean(document.querySelector("#conversation-list")) }, bodyTextPreview: bodyText.slice(0, 500) }; }); } function validatePromptFinalResponse(dom, args) { if (!args.message) return { ok: true, applicable: false, reason: "no-prompt-submitted" }; if (args.traceSampleCount > 0 && !args.waitAgentTerminalMs) return { ok: true, applicable: false, reason: "trace-sampling-without-terminal-wait" }; const messages = Array.isArray(dom.messages) ? dom.messages : []; const latestAgent = [...messages].reverse().find((message) => message.role === "agent"); const finalResponse = latestAgent?.finalResponse ?? null; const markdown = finalResponse?.markdown ?? null; const failures = []; if (!latestAgent) failures.push("agent-message-missing"); if (latestAgent?.status !== "completed") failures.push("agent-not-completed"); if (finalResponse?.present !== true) failures.push("final-response-missing"); if (!finalResponse || Number(finalResponse.textChars ?? 0) <= 0) failures.push("final-response-empty"); if (Number(markdown?.headings ?? 0) <= 0) failures.push("markdown-heading-missing"); if (Number(markdown?.lists ?? 0) <= 0 || Number(markdown?.listItems ?? 0) <= 0) failures.push("markdown-list-missing"); if (Number(markdown?.codeBlocks ?? 0) <= 0) failures.push("markdown-code-block-missing"); return { ok: failures.length === 0, applicable: true, failures, finalResponse: finalResponse ? { textChars: finalResponse.textChars ?? null, textPreview: finalResponse.textPreview ?? null, markdown: finalResponse.markdown ?? null } : null }; } async function startProbe(args) { if (args.credentialError) { return { ok: false, status: "blocked", command: "web-live-dom-probe start", error: args.credentialError.code, credentialError: args.credentialError, credentials: args.credentials }; } await mkdir(stateRoot, { recursive: true }); const jobId = args.jobId || `web-live-dom-probe-${Date.now()}-${process.pid}`; const reportPath = path.join(stateRoot, `${jobId}.result.json`); const screenshotPath = path.join(stateRoot, `${jobId}.png`); const stdoutPath = path.join(stateRoot, `${jobId}.stdout.log`); const stderrPath = path.join(stateRoot, `${jobId}.stderr.log`); const metaPath = path.join(stateRoot, `${jobId}.json`); const childArgs = [ scriptPath, "run", "--url", args.url, "--user", args.user, "--viewport", args.viewport, "--project-id", args.projectId, "--report", reportPath, "--screenshot", screenshotPath, "--timeout-ms", String(args.timeoutMs), "--wait-after-submit-ms", String(args.waitAfterSubmitMs), "--wait-messages-ms", String(args.waitMessagesMs) ]; if (args.waitAgentTerminalMs > 0) childArgs.push("--wait-agent-terminal-ms", String(args.waitAgentTerminalMs)); if (args.traceSampleCount > 0) childArgs.push("--trace-sample-count", String(args.traceSampleCount), "--trace-sample-interval-ms", String(args.traceSampleIntervalMs)); if (args.freshSession) childArgs.push("--fresh-session"); if (!args.cancelRunning) childArgs.push("--no-cancel-running"); if (args.conversationId) childArgs.push("--conversation-id", args.conversationId); if (args.message) childArgs.push("--message", args.message); const stdout = fs.openSync(stdoutPath, "a"); const stderr = fs.openSync(stderrPath, "a"); const child = spawn(process.execPath, childArgs, { cwd: repoRoot, detached: true, env: { ...process.env, HWLAB_WEB_USER: args.user, HWLAB_WEB_PASS: args.pass }, stdio: ["ignore", stdout, stderr] }); child.unref(); const meta = { ok: true, status: "running", command: "web-live-dom-probe start", jobId, pid: child.pid, startedAt: new Date().toISOString(), url: args.url, projectId: args.projectId, conversationId: args.conversationId || null, traceSampling: args.traceSampleCount > 0 ? { count: args.traceSampleCount, intervalMs: args.traceSampleIntervalMs } : null, reportPath, screenshotPath, stdoutPath, stderrPath, statusCommand: `node scripts/web-live-dom-probe.mjs status ${jobId}` }; await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); return meta; } async function probeStatus(args) { if (!args.jobId) throw new Error("status requires a job id"); const metaPath = path.join(stateRoot, `${args.jobId}.json`); const meta = JSON.parse(await readFile(metaPath, "utf8")); const report = fs.existsSync(meta.reportPath) ? await attachReportArtifactFingerprints(JSON.parse(await readFile(meta.reportPath, "utf8")), meta) : null; const running = !report && typeof meta.pid === "number" && processAlive(meta.pid); return { ok: report ? report.ok === true : running, status: report ? report.status : running ? "running" : "blocked", command: "web-live-dom-probe status", jobId: args.jobId, pid: meta.pid, startedAt: meta.startedAt, reportPath: meta.reportPath, screenshotPath: meta.screenshotPath, stdoutPath: meta.stdoutPath, stderrPath: meta.stderrPath, running, report, reportSummary: report ? { status: report.status, url: report.url, finalUrl: report.finalUrl, browser: report.browser, dom: report.dom } : null, stdoutTail: await readTail(meta.stdoutPath), stderrTail: await readTail(meta.stderrPath) }; } async function attachReportArtifactFingerprints(report, meta) { if (!report) return report; const reportPath = report.artifacts?.reportPath ?? meta.reportPath ?? null; const screenshotPath = report.artifacts?.screenshotPath ?? meta.screenshotPath ?? null; return { ...report, artifacts: { ...(report.artifacts ?? {}), reportPath, reportSha256: await fileSha256(reportPath), screenshotPath, screenshotSha256: await fileSha256(screenshotPath) } }; } async function maybeLogin(page, args, actions) { const username = page.locator("#login-username"); if (await username.isVisible({ timeout: Math.min(args.timeoutMs, 12000) }).catch(() => false)) { await username.fill(args.user); await page.locator("#login-password").fill(args.pass); await page.locator("#login-submit").click(); actions.push({ action: "login", user: args.user, selectorMode: "legacy-id" }); await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); const finalUrl = new URL(page.url()); if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) { throw new Error("login credentials leaked into URL query"); } return; } const loginSurface = await waitForLoginSurface(page, args); if (loginSurface === "workspace") { actions.push({ action: "login", skipped: "already-authenticated", selectorMode: "workspace", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); return; } if (loginSurface === "legacy-id") { await username.fill(args.user); await page.locator("#login-password").fill(args.pass); await page.locator("#login-submit").click(); actions.push({ action: "login", user: args.user, selectorMode: "legacy-id-delayed" }); await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); const finalUrl = new URL(page.url()); if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) { throw new Error("login credentials leaked into URL query"); } return; } if (loginSurface !== "login-card") { actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); return; } await submitCurrentLoginForm(page, args, actions, "current-autocomplete"); } async function waitForLoginSurface(page, args) { const handle = await page.waitForFunction(() => { if (document.querySelector("#workspace")) return "workspace"; if (document.querySelector("#login-username")) return "legacy-id"; if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card"; if (document.querySelector(".login-card input")) return "login-card"; return ""; }, null, { timeout: args.timeoutMs }).catch(() => null); if (!handle) return null; return handle.jsonValue().catch(() => null); } async function submitCurrentLoginForm(page, args, actions, selectorMode) { const username = page.locator('input[autocomplete="username"], .login-card input').first(); const password = page.locator('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first(); const submit = page.locator('.login-card button[type="submit"], button[type="submit"]').first(); const inputCount = await page.locator(".login-card input, input[autocomplete=\"username\"], input[autocomplete=\"current-password\"]").count().catch(() => 0); if (!(await username.isVisible({ timeout: 5000 }).catch(() => false)) || !(await password.isVisible({ timeout: 5000 }).catch(() => false))) { actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode, inputCount, authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); return; } await username.fill(args.user); await password.fill(args.pass); const valuesReady = await waitForCurrentLoginValues(page, args); await Promise.allSettled([ page.waitForURL(/\/workbench/u, { timeout: Math.min(args.timeoutMs, 15000) }), submit.click() ]); actions.push({ action: "login", user: args.user, selectorMode, valuesReady }); const outcome = await waitForLoginOutcome(page, args); if (outcome?.kind === "error") throw new Error(`login failed: ${outcome.errorText || "unknown login error"}`); await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); assertNoCredentialLeak(page.url()); } async function waitForCurrentLoginValues(page, args) { const ready = await page.waitForFunction((expected) => { const username = document.querySelector('input[autocomplete="username"], .login-card input'); const password = document.querySelector('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]'); const submit = document.querySelector('.login-card button[type="submit"], button[type="submit"]'); return Boolean(username && password && username.value === expected.user && password.value.length === expected.passwordLength && !submit?.disabled); }, { user: args.user, passwordLength: args.pass.length }, { timeout: Math.min(args.timeoutMs, 5000) }).catch(() => null); return Boolean(ready); } async function waitForLoginOutcome(page, args) { const handle = await page.waitForFunction(() => { if (document.querySelector("#command-input")) return { kind: "workspace" }; const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim(); if (errorText) return { kind: "error", errorText }; return null; }, null, { timeout: Math.min(args.timeoutMs, 15000) }).catch(() => null); if (!handle) return null; return handle.jsonValue().catch(() => null); } function assertNoCredentialLeak(value) { const finalUrl = new URL(value); if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) { throw new Error("login credentials leaked into URL query"); } } async function waitForAuthBootstrap(page, args, actions) { const timeout = Math.min(args.timeoutMs, 12000); const settled = await page.waitForFunction(() => { const authState = document.body.getAttribute("data-auth-state"); const loginVisible = Boolean(document.querySelector("#login-username") || document.querySelector('input[autocomplete="username"]') || document.querySelector(".login-card input")); const workspaceVisible = Boolean(document.querySelector("#workspace")); return authState !== "checking" || loginVisible || workspaceVisible; }, null, { timeout }).catch(() => null); actions.push({ action: "auth-bootstrap", settled: Boolean(settled), authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); } async function maybeClick(page, selector, actions, action) { const locator = page.locator(selector).first(); if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { await locator.click(); actions.push({ action, selector }); await page.waitForTimeout(1000); return true; } actions.push({ action, selector, skipped: "not-visible" }); return false; } async function createFreshSession(page, args, actions) { const selector = "#session-create"; const locator = page.locator(selector).first(); if (!(await locator.isVisible({ timeout: 3000 }).catch(() => false))) { actions.push({ action: "fresh-session", selector, skipped: "not-visible" }); throw new Error("fresh session create button is not visible"); } const shellReady = await waitForSessionShellReady(page, args, actions); const before = await collectSessionState(page, args); await locator.click(); const timeoutMs = Math.min(args.timeoutMs, 30000); const settled = await page.waitForFunction((initial) => { const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? null; const statusReady = Boolean(statusText && !/加载中|等待 workspace/u.test(statusText)); const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); const activeTitle = activeTab?.querySelector("strong")?.textContent?.trim() ?? activeTab?.getAttribute("title") ?? null; const activeChanged = Boolean(activeTitle && activeTitle !== initial.activeTabTitle); const statusChanged = Boolean(statusText && statusText !== initial.statusText); const messageCount = document.querySelectorAll(".message-card").length; const input = document.querySelector("#command-input"); return statusReady && (activeChanged || statusChanged) && messageCount === 0 && input && !input.disabled; }, before, { timeout: timeoutMs }).catch(() => null); 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 (!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(freshSessionAttemptSummary(check, Date.now() - startedAt)); 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(freshSessionAttemptSummary(check, Date.now() - startedAt)); if (!check.ok && bestCandidate?.id) { const repair = await realignFreshSession(page, args, bestCandidate.id); 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 freshSessionAttemptSummary(check, elapsedMs) { return { elapsedMs, ok: check.ok, reason: check.reason, id: check.id, routeId: check.routeId, selectedConversationId: check.selectedConversationId, activeTabSessionId: check.activeTabSessionId, activeTabConversationId: check.activeTabConversationId, apiConversationPresent: check.apiConversationPresent, workspaceApiAvailable: check.workspaceApiAvailable, conversationsApiAvailable: check.conversationsApiAvailable }; } function freshSessionCandidate(before, after) { const previousConversationId = sessionAuthorityId(before); const items = Array.isArray(after?.api?.conversations?.items) ? after.api.conversations.items : []; const apiCandidate = items.find((item) => item.conversationId && item.conversationId !== previousConversationId && Number(item.messagesCount ?? 0) === 0); if (apiCandidate) return { ...apiCandidate, id: apiCandidate.conversationId, source: "api-conversations" }; const tabs = Array.isArray(after?.sessionTabs) ? after.sessionTabs : []; const tabCandidate = tabs.find((item) => firstNonEmpty(item.sessionId, item.conversationId) && firstNonEmpty(item.sessionId, item.conversationId) !== previousConversationId && item.running !== "true"); return tabCandidate ? { ...tabCandidate, id: firstNonEmpty(tabCandidate.sessionId, tabCandidate.conversationId), source: "session-tab" } : null; } async function realignFreshSession(page, args, sessionOrConversationId) { const selected = await selectSessionViaRoute(page, args, sessionOrConversationId, "web-live-dom-probe-fresh-realign").catch((error) => ({ ok: false, phase: "route-session", error: error instanceof Error ? error.message : String(error) })); const targetUrl = new URL(`/workbench/sessions/${encodeURIComponent(sessionOrConversationId)}`, args.url); targetUrl.searchParams.set("projectId", args.projectId); const target = targetUrl.toString(); const attempts = []; for (let attempt = 1; attempt <= 2; attempt += 1) { const navigation = await page.goto(target, { waitUntil: "domcontentloaded", timeout: Math.min(args.timeoutMs, 30000) }) .then((response) => ({ ok: true, status: response?.status() ?? null, url: page.url() })) .catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: page.url() })); const ready = await waitForWorkbenchSessionRouteReady(page, args, attempt === 1 ? 15000 : 30000); attempts.push({ attempt, navigation, ready }); if (ready.ok) return { action: "fresh-session-realign", id: sessionOrConversationId, target, selected, attempts }; await page.waitForTimeout(1000); } return { action: "fresh-session-realign", id: sessionOrConversationId, target, selected, attempts, ok: false }; } async function waitForWorkbenchSessionRouteReady(page, args, timeoutMs) { await page.waitForFunction(() => { const authState = document.body.getAttribute("data-auth-state"); const workspace = document.querySelector("#workspace"); const loginVisible = Boolean(document.querySelector("#login-username") || document.querySelector('input[autocomplete="username"]') || document.querySelector(".login-card input")); return authState !== "checking" || workspace || loginVisible; }, null, { timeout: Math.min(timeoutMs, 10000) }).catch(() => null); const workspace = await page.locator("#workspace").waitFor({ state: "visible", timeout: timeoutMs }).then(() => ({ ok: true })).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); const commandInput = workspace.ok ? await page.locator("#command-input").waitFor({ state: "visible", timeout: timeoutMs }).then(() => ({ ok: true })).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })) : { ok: false, skipped: "workspace-not-visible" }; const dom = await page.evaluate(() => ({ href: window.location.href, authState: document.body.getAttribute("data-auth-state"), title: document.title, workspaceVisible: Boolean(document.querySelector("#workspace")), commandInputVisible: Boolean(document.querySelector("#command-input")), bodyTextPreview: document.body.textContent?.replace(/\s+/gu, " ").trim().slice(0, 200) ?? "" })).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })); return { ok: workspace.ok && commandInput.ok, timeoutMs, workspace, commandInput, dom }; } async function selectSessionViaRoute(page, args, conversationId, updatedByClient) { const targetUrl = new URL(`/workbench/sessions/${encodeURIComponent(conversationId)}`, args.url); const target = targetUrl.toString(); const navigation = await page.goto(target, { waitUntil: "domcontentloaded", timeout: Math.min(args.timeoutMs, 30000) }) .then((response) => ({ ok: true, status: response?.status() ?? null, url: page.url() })) .catch((error) => ({ ok: false, status: null, error: error instanceof Error ? error.message : String(error), url: page.url() })); const ready = await waitForWorkbenchSessionRouteReady(page, args, Math.min(args.timeoutMs, 30000)); return { ok: navigation.ok && ready.ok, phase: "route-session", status: navigation.status, sessionId: conversationId, conversationId, updatedByClient, target, navigation, ready }; } function freshSessionAlignment(before, after) { const previousConversationId = sessionAuthorityId(before); const conversationId = sessionAuthorityId(after); const selectedConversationId = after?.api?.workspace?.selectedConversationId ?? null; const routeConversationId = after?.routeConversationId ?? null; const activeTabSessionId = after?.activeTab?.sessionId ?? null; const activeTabConversationId = after?.activeTab?.conversationId ?? null; const workspaceApiAvailable = after?.api?.workspace?.ok === true; const conversationsApiAvailable = after?.api?.conversations?.ok === true; const apiConversation = conversationId ? after?.api?.conversations?.items?.find((item) => item.conversationId === conversationId || item.sessionId === conversationId) ?? null : null; const routeMatches = Boolean(routeConversationId && conversationId && routeConversationId === conversationId); const workspaceMatches = workspaceApiAvailable ? Boolean(selectedConversationId && conversationId && selectedConversationId === conversationId) : true; const apiConversationPresent = conversationsApiAvailable ? Boolean(apiConversation) : true; const activeTabMatches = Boolean(!activeTabSessionId && !activeTabConversationId) || activeTabSessionId === conversationId || activeTabConversationId === 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, id: conversationId, routeId: routeConversationId, routeConversationId, selectedConversationId, workspaceApiAvailable, conversationsApiAvailable, apiConversationPresent, activeTabConversationId, activeTabSessionId, commandReady, command: after?.command ?? null, messageCount: after?.messageCount ?? null }; } function sessionAuthorityId(state) { return firstNonEmpty(state?.routeConversationId, state?.api?.workspace?.selectedConversationId, state?.activeTab?.conversationId, state?.activeTab?.sessionId); } function sessionAuthorityAlignment(state, expectedId, options = {}) { const routeConversationId = state?.routeConversationId ?? null; const selectedConversationId = state?.api?.workspace?.selectedConversationId ?? null; const selectedAgentSessionId = state?.api?.workspace?.selectedAgentSessionId ?? null; const activeTabSessionId = state?.activeTab?.sessionId ?? null; const activeTabConversationId = state?.activeTab?.conversationId ?? null; const workspaceApiAvailable = state?.api?.workspace?.ok === true; const conversationsApiAvailable = state?.api?.conversations?.ok === true; const apiConversation = expectedId ? state?.api?.conversations?.items?.find((item) => item.conversationId === expectedId || item.sessionId === expectedId) ?? null : null; const routeMatches = Boolean(routeConversationId && expectedId && routeConversationId === expectedId); const workspaceMatches = workspaceApiAvailable ? Boolean(expectedId && (selectedConversationId === expectedId || selectedAgentSessionId === expectedId)) : true; const apiConversationPresent = conversationsApiAvailable ? Boolean(apiConversation) : true; const activeTabMatches = Boolean(!activeTabSessionId && !activeTabConversationId) || activeTabSessionId === expectedId || activeTabConversationId === expectedId; const commandReady = state?.command?.inputDisabled === false; const empty = options.requireEmpty === true ? state?.messageCount === 0 : true; const ok = Boolean(expectedId && routeMatches && workspaceMatches && apiConversationPresent && activeTabMatches && commandReady && empty); const reason = ok ? null : !expectedId ? "session-id-missing" : !routeMatches ? "session-route-not-aligned" : !workspaceMatches ? "session-workspace-not-aligned" : !apiConversationPresent ? "session-conversation-api-missing" : !activeTabMatches ? "session-active-tab-not-aligned" : !commandReady ? "session-command-not-ready" : "session-not-empty"; return { ok, reason, expectedId, routeConversationId, selectedConversationId, selectedAgentSessionId, activeTabConversationId, activeTabSessionId, workspaceApiAvailable, conversationsApiAvailable, apiConversationPresent, commandReady, messageCount: state?.messageCount ?? null }; } async function waitForSessionShellReady(page, args, actions) { const timeoutMs = Math.min(args.timeoutMs, 30000); 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"); const input = document.querySelector("#command-input"); return Boolean(workspace && create && !create.disabled && input && !input.disabled && !/加载中|等待 workspace/u.test(statusText)); }, null, { timeout: timeoutMs }).catch(() => null); 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, args = null) { const dom = await page.evaluate(() => { const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); const sessionTabs = [...document.querySelectorAll(".session-tab")].map((tab) => ({ title: tab.querySelector("strong")?.textContent?.trim() ?? tab.getAttribute("title") ?? null, conversationId: tab.getAttribute("data-conversation-id") ?? null, sessionId: tab.getAttribute("data-session-id") ?? null, status: tab.getAttribute("data-status") ?? null, running: tab.getAttribute("data-running") ?? null, active: tab.getAttribute("data-active") === "true" || tab.getAttribute("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, sessionTabs: sessionTabs.slice(0, 20), messageCount: document.querySelectorAll(".message-card").length, emptyVisible: Boolean(document.querySelector("[data-conversation-empty]")), command: { inputDisabled: Boolean(document.querySelector("#command-input")?.disabled), sendDisabled: Boolean(document.querySelector("#command-send")?.disabled), sendText: document.querySelector("#command-send")?.textContent?.trim() ?? null, formTitle: document.querySelector("#command-form")?.getAttribute("title") ?? null } }; }); if (!args) return dom; const currentSessionId = firstNonEmpty(dom.routeConversationId, dom.activeTab?.sessionId, dom.activeTab?.conversationId); const sessions = await fetchJsonFromPage(page, "/v1/workbench/sessions?limit=50", { timeoutMs: 8000 }); const sessionDetail = currentSessionId ? await fetchJsonFromPage(page, `/v1/workbench/sessions/${encodeURIComponent(currentSessionId)}`, { timeoutMs: 8000 }) : null; const sessionMessages = currentSessionId ? await fetchJsonFromPage(page, `/v1/workbench/sessions/${encodeURIComponent(currentSessionId)}/messages`, { timeoutMs: 8000 }) : null; return { ...dom, api: { workspace: summarizeCurrentSessionFetch(currentSessionId, sessionDetail, sessionMessages), conversations: summarizeWorkbenchSessionsFetch(sessions) } }; } 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 summarizeCurrentSessionFetch(sessionId, detailFetch, messagesFetch) { const detailBody = detailFetch?.json?.session ?? detailFetch?.json?.data?.session ?? detailFetch?.json?.data ?? detailFetch?.json ?? null; const messages = messagesFromFetch(messagesFetch); const latestMessage = messages.at(-1) ?? null; return { ok: detailFetch?.ok === true || messagesFetch?.ok === true, path: detailFetch?.path ?? messagesFetch?.path ?? null, status: detailFetch?.status ?? messagesFetch?.status ?? null, error: detailFetch?.error ?? messagesFetch?.error ?? null, bodyPreview: detailFetch?.ok === true || messagesFetch?.ok === true ? null : detailFetch?.bodyPreview ?? messagesFetch?.bodyPreview ?? null, workspaceId: null, projectId: normalizeProbeProjectId(detailBody?.projectId) ?? null, revision: detailBody?.revision ?? detailBody?.updatedAt ?? null, selectedConversationId: firstNonEmpty(detailBody?.sessionId, detailBody?.id, sessionId), selectedAgentSessionId: firstNonEmpty(detailBody?.sessionId, detailBody?.id, sessionId), activeTraceId: firstNonEmpty(detailBody?.lastTraceId, detailBody?.traceId, latestMessage?.traceId, latestMessage?.runnerTrace?.traceId), selectedConversationStatus: detailBody?.status ?? detailBody?.state ?? latestMessage?.status ?? null, selectedMessagesCount: messages.length }; } function summarizeWorkbenchSessionsFetch(fetchResult) { const sessions = sessionsFromFetch(fetchResult); 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: sessions.length, items: sessions.slice(0, 16).map((item) => ({ conversationId: firstNonEmpty(item?.sessionId, item?.id), projectId: normalizeProbeProjectId(item?.projectId) ?? null, sessionId: firstNonEmpty(item?.sessionId, item?.id), threadId: item?.threadId ?? null, status: item?.status ?? null, messagesCount: Array.isArray(item?.messages) ? item.messages.length : item?.messagesCount ?? item?.messageCount ?? null, lastTraceId: firstNonEmpty(item?.lastTraceId, item?.traceId), updatedAt: item?.updatedAt ?? null })) }; } function sessionsFromFetch(fetchResult) { if (Array.isArray(fetchResult?.json?.sessions)) return fetchResult.json.sessions; if (Array.isArray(fetchResult?.json?.data?.sessions)) return fetchResult.json.data.sessions; if (Array.isArray(fetchResult?.json?.items)) return fetchResult.json.items; if (Array.isArray(fetchResult?.json?.data?.items)) return fetchResult.json.data.items; return []; } function messagesFromFetch(fetchResult) { if (Array.isArray(fetchResult?.json?.messages)) return fetchResult.json.messages; if (Array.isArray(fetchResult?.json?.data?.messages)) return fetchResult.json.data.messages; if (Array.isArray(fetchResult?.json?.items)) return fetchResult.json.items; if (Array.isArray(fetchResult?.json?.data?.items)) return fetchResult.json.data.items; return []; } async function submitPrompt(page, args, actions) { const readyBefore = await waitForCommandReady(page, args, actions); const fresh = [...actions].reverse().find((item) => item.action === "fresh-session") ?? null; const targetConversationId = firstNonEmpty(fresh?.alignment?.check?.conversationId, fresh?.alignment?.after?.routeConversationId, fresh?.after?.routeConversationId); if (targetConversationId) await ensureConversationAligned(page, args, targetConversationId, actions, "pre-submit-realign"); await page.locator("#command-input").fill(args.message); const enabled = await page.waitForFunction((expected) => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); return input?.value === expected && send && !send.disabled; }, args.message, { timeout: Math.min(args.timeoutMs, 5000) }).catch(() => null); const afterFill = await collectCommandState(page); if (!enabled) { const session = await collectSessionState(page, args).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })); throw new Error(`command composer is not ready for submit: ${JSON.stringify({ readyBefore, afterFill, session: compactSessionState(session) })}`); } 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, submittedAt, readyBefore, afterFill, afterClick }); await page.waitForTimeout(args.waitAfterSubmitMs); } async function ensureConversationAligned(page, args, conversationId, actions, action) { const before = await collectSessionState(page, args); const beforeAlignment = sessionAuthorityAlignment(before, conversationId); if (beforeAlignment.ok) { actions.push({ action, conversationId, aligned: true, repaired: false, before: compactSessionState(before), alignment: beforeAlignment }); return before; } const repair = await realignFreshSession(page, args, conversationId); const after = await collectSessionState(page, args); const afterAlignment = sessionAuthorityAlignment(after, conversationId); actions.push({ action, conversationId, aligned: afterAlignment.ok, repaired: true, before: compactSessionState(before), after: compactSessionState(after), beforeAlignment, afterAlignment, repair }); if (!afterAlignment.ok) throw new Error(`${action} failed: ${JSON.stringify({ conversationId, reason: afterAlignment.reason, before: compactSessionState(before), after: compactSessionState(after), beforeAlignment, afterAlignment, repair })}`); return after; } 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, args, index)); } actions.push({ action: "trace-interval-samples", count: samples.length, intervalMs: args.traceSampleIntervalMs, samples }); return samples; } async function collectTraceTimelineSample(page, args, index) { const dom = await page.evaluate((sampleIndex) => { const firstTraceId = (...sources) => { for (const source of sources) { const match = String(source ?? "").match(/\btrc_[A-Za-z0-9_.:-]+\b/u); if (match) return match[0]; } return null; }; 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")]; const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent")); const trace = latestAgent?.querySelector(".trace-timeline") ?? null; const details = trace?.querySelector("details") ?? null; const rows = latestAgent ? [...latestAgent.querySelectorAll(".trace-render-row")] : []; const rowPreview = (row) => row?.textContent?.replace(/\s+/gu, " ").trim().slice(0, 240) ?? null; const rowSummary = (row) => row ? { rowId: row.getAttribute("data-row-id") ?? null, kind: row.getAttribute("data-row-kind") ?? null, terminal: row.getAttribute("data-terminal") === "true", status: row.getAttribute("data-event-status") ?? null, preview: rowPreview(row) } : null; const isUserVisibleOutputRow = (row) => { const rowId = row.getAttribute("data-row-id") ?? ""; const kind = row.getAttribute("data-row-kind") ?? ""; const text = row.textContent?.replace(/\s+/gu, " ").trim() ?? ""; if (kind === "tool" || rowId.startsWith("tool:")) return true; if (rowId.startsWith("trace-final-response:")) return true; if (/助手(?:最终)?消息|assistant (?:final )?message|final response/iu.test(text)) return true; if (row.getAttribute("data-terminal") === "true" && row.querySelector(".trace-row-body")?.textContent?.trim()) return true; return false; }; const latestRow = rows.at(-1) ?? null; const userVisibleRows = rows.filter(isUserVisibleOutputRow); const latestUserVisibleRow = userVisibleRows.at(-1) ?? null; const empty = latestAgent?.querySelector(".trace-empty") ?? null; const finalResponse = latestAgent?.querySelector(".message-final-response") ?? null; const modeText = document.querySelector(".composer-mode")?.textContent?.trim() ?? null; const traceId = firstTraceId(latestAgent?.getAttribute("data-trace-id"), trace?.getAttribute("data-trace-id"), trace?.getAttribute("data-traceid"), modeText, latestAgent?.textContent, trace?.textContent); return { index: sampleIndex, sampledAt: new Date().toISOString(), messageCount: cards.length, agentStatus: latestAgent ? cardStatus(latestAgent) : null, traceId, modeText, tracePresent: Boolean(trace), traceStatus: trace?.getAttribute("data-status") ?? null, traceOpen: details instanceof HTMLDetailsElement ? details.open : null, rowCount: rows.length, emptyLabel: empty?.textContent?.replace(/\s+/gu, " ").trim() ?? null, latestRowPreview: rowPreview(latestRow), latestRow: rowSummary(latestRow), userVisibleRowCount: userVisibleRows.length, latestUserVisibleRowPreview: rowPreview(latestUserVisibleRow), latestUserVisibleRow: rowSummary(latestUserVisibleRow), 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 || item.sessionId === conversationId) ?? null : null; const traceId = firstNonEmpty(dom.traceId, conversation?.lastTraceId, session.api?.workspace?.activeTraceId); const traceProjectId = normalizeProbeProjectId(conversation?.projectId) ?? normalizeProbeProjectId(session.api?.workspace?.projectId) ?? normalizeProbeProjectId(args.projectId); const traceFetch = traceId ? await fetchTraceSummaryWithRetry(page, args, traceId, traceProjectId) : { ok: false, traceId: null, degradedReason: "trace-id-missing", attempts: [] }; return { ...dom, conversationId, projectId: traceProjectId, sessionId: conversation?.sessionId ?? session.activeTab?.sessionId ?? null, traceId, traceFetch }; } async function fetchTraceSummaryWithRetry(page, args, traceId, projectId) { const paths = [traceSummaryPath(traceId, projectId)]; const attempts = []; for (const candidate of paths) { for (let attempt = 1; attempt <= 3; attempt += 1) { const fetched = await fetchJsonFromPage(page, candidate.path, { timeoutMs: 8000 }); const summary = summarizeTraceFetch(fetched, traceId, attempt, candidate); attempts.push(summary); if (summary.ok) return { ...summary, attempts }; if (summary.errorCode === "trace_project_mismatch" && candidate.projectId) break; 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 traceSummaryPath(traceId, projectId) { const params = new URLSearchParams({ sinceSeq: "0", limit: "20" }); return { path: `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?${params.toString()}`, projectId, scoped: Boolean(projectId) }; } function summarizeTraceFetch(fetchResult, expectedTraceId, attempt, request) { 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, requestProjectId: request?.projectId ?? null, requestScoped: request?.scoped === true, path: fetchResult?.path ?? null, httpStatus: fetchResult?.status ?? null, elapsedMs: fetchResult?.elapsedMs ?? null, traceId: trace?.traceId ?? body?.traceId ?? expectedTraceId, traceStatus: trace?.traceStatus ?? trace?.status ?? body?.traceStatus ?? 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, errorCode: body?.error?.code ?? 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, sessionTabs: Array.isArray(state.sessionTabs) ? state.sessionTabs.slice(0, 6) : [], 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 firstUserVisibleRow = samples.find((sample) => Number(sample.userVisibleRowCount ?? 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, finalUserVisibleDomRowCount: last?.userVisibleRowCount ?? null, firstDomRowVisibleAt: firstDomRow?.sampledAt ?? null, firstDomRowPreview: firstDomRow?.latestRowPreview ?? null, firstUserVisibleRowAt: firstUserVisibleRow?.sampledAt ?? null, firstUserVisibleRowPreview: firstUserVisibleRow?.latestUserVisibleRowPreview ?? null, firstUserVisibleRow: firstUserVisibleRow?.latestUserVisibleRow ?? 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, firstUserVisibleTraceRowAt: traceSummary?.firstUserVisibleRowAt ?? null, firstRestTraceEventAt: traceSummary?.firstRestEventVisibleAt ?? null, submitToFirstDomTraceRowMs: submittedAt && traceSummary?.firstDomRowVisibleAt ? Math.max(0, new Date(traceSummary.firstDomRowVisibleAt).getTime() - new Date(submittedAt).getTime()) : null, submitToFirstUserVisibleTraceRowMs: submittedAt && traceSummary?.firstUserVisibleRowAt ? Math.max(0, new Date(traceSummary.firstUserVisibleRowAt).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, actions }) { if (sessionSummary?.degradedReason) return sessionSummary.degradedReason; if (traceSummary?.degradedReason) return traceSummary.degradedReason; const terminalWait = Array.isArray(actions) ? [...actions].reverse().find((item) => item.action === "wait-agent-terminal") : null; if (terminalWait?.terminal === false) return terminalWait.degradedReason ?? "agent-terminal-timeout"; if (promptValidation?.ok === false) return "prompt-validation-failed"; return null; } function classifyRunProbeError(error, context = {}) { const message = error instanceof Error ? error.message : String(error); const finalUrl = typeof context.finalUrl === "string" ? context.finalUrl : ""; const failureDom = context.failureDom && typeof context.failureDom === "object" ? context.failureDom : {}; const login = failureDom.login && typeof failureDom.login === "object" ? failureDom.login : {}; if (/\/login(?:\?|$)/u.test(finalUrl) || failureDom.authState === "login" || login.cardVisible === true) return "auth-redirect-login"; if (/session_required|session-not-selected/u.test(message)) return "session-not-selected"; if (/pre-submit-realign|session-(?:route|workspace|active-tab)-not-aligned|route-session/u.test(message)) return "session-not-selected"; if (/composer|command-send|command bar|disabledReason|button_disabled/u.test(message)) return "composer-disabled"; if (/fresh session/u.test(message)) return "fresh-session-not-aligned"; if (/agent final response did not reach terminal DOM state/u.test(message)) return "agent-terminal-timeout"; 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; } function normalizeProbeProjectId(value) { const text = typeof value === "string" ? value.trim() : ""; return /^prj_[A-Za-z0-9_.:-]{3,96}$/u.test(text) ? text : null; } async function waitForCommandReady(page, args, actions) { const timeoutMs = Math.min(args.timeoutMs, 30000); const before = await collectCommandState(page); const ready = await page.waitForFunction(() => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); const warning = document.querySelector(".composer-warning")?.textContent?.trim() ?? ""; return Boolean(input && send && !input.disabled && !warning); }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectCommandState(page); const result = { action: "command-ready", ready: Boolean(ready), timeoutMs, before, after }; actions.push(result); if (!ready) throw new Error(`command composer is not ready for submit: ${JSON.stringify(result)}`); return after; } async function collectCommandState(page) { return page.evaluate(() => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); const form = document.querySelector("#command-form"); return { inputVisible: Boolean(input), inputDisabled: input ? Boolean(input.disabled) : null, inputLength: input && typeof input.value === "string" ? input.value.length : null, sendVisible: Boolean(send), sendDisabled: send ? Boolean(send.disabled) : null, sendAction: send?.getAttribute("data-action") ?? null, disabledReason: document.querySelector(".composer-warning")?.textContent?.trim() ?? null, modeText: document.querySelector(".composer-mode")?.textContent?.trim() ?? null, sendText: send?.textContent?.trim() ?? null, formTitle: form?.getAttribute("title") ?? null, messageCount: document.querySelectorAll(".message-card").length, authState: document.body.getAttribute("data-auth-state") }; }); } async function selectConversationInBrowserSession(page, args, actions) { const authSession = await page.evaluate(async () => { const response = await fetch("/auth/session", { credentials: "same-origin" }); const body = await response.json().catch(() => null); return { status: response.status, authenticated: body?.authenticated ?? null, userId: body?.user?.id ?? body?.actor?.id ?? null, username: body?.user?.username ?? body?.actor?.username ?? null, role: body?.user?.role ?? body?.actor?.role ?? null, accountStatus: body?.user?.status ?? body?.actor?.status ?? null }; }); const result = await selectSessionViaRoute(page, args, args.conversationId, "web-live-dom-probe"); actions.push({ action: "select-session", authSession, ...result }); if (!result.ok) throw new Error(`browser session route selection failed: ${JSON.stringify(result)}`); } async function waitForMessageCards(page, args, actions) { if (!args.waitMessagesMs) return; const before = await page.locator(".message-card").count().catch(() => 0); const found = await page.waitForFunction(() => document.querySelectorAll(".message-card").length > 0, null, { timeout: args.waitMessagesMs }).catch(() => null); const after = await page.locator(".message-card").count().catch(() => 0); actions.push({ action: "wait-message-cards", before, after, found: Boolean(found), timeoutMs: args.waitMessagesMs }); } async function waitForAgentTerminal(page, args, actions) { const timeoutMs = Math.min(args.waitAgentTerminalMs, args.timeoutMs); const before = await collectLatestAgentMessageState(page); const terminal = await page.waitForFunction(() => { 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")]; const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent")); if (!latestAgent) return false; const status = cardStatus(latestAgent) ?? ""; const finalResponse = latestAgent.querySelector(".message-final-response"); const text = finalResponse?.textContent?.trim() ?? ""; return status && status !== "running" && text.length > 0; }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectLatestAgentMessageState(page); actions.push({ action: "wait-agent-terminal", terminal: Boolean(terminal), timeoutMs, before, after, degradedReason: terminal ? null : "agent-terminal-timeout" }); return Boolean(terminal); } async function collectLatestAgentMessageState(page) { return page.evaluate(() => { const firstTraceId = (...sources) => { for (const source of sources) { const match = String(source ?? "").match(/\btrc_[A-Za-z0-9_.:-]+\b/u); if (match) return match[0]; } return null; }; 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")]; const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent")); const trace = latestAgent?.querySelector(".trace-timeline, .message-trace") ?? null; const finalResponse = latestAgent?.querySelector(".message-final-response"); const modeText = document.querySelector(".composer-mode")?.textContent?.trim() ?? null; return { messageCount: cards.length, status: latestAgent ? cardStatus(latestAgent) : null, traceId: firstTraceId(latestAgent?.getAttribute("data-trace-id"), trace?.getAttribute("data-trace-id"), trace?.getAttribute("data-traceid"), modeText, latestAgent?.textContent, trace?.textContent), modeText, textChars: finalResponse?.textContent?.trim().length ?? 0, textPreview: (finalResponse?.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 240) }; }); } async function collectDomEvidence(page, args) { return page.evaluate(async ({ projectId }) => { const firstTraceId = (...sources) => { for (const source of sources) { const match = String(source ?? "").match(/\btrc_[A-Za-z0-9_.:-]+\b/u); if (match) return match[0]; } return null; }; const cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system")?.replace(/^message-/, "") ?? null; const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null; const workspace = document.querySelector("#workspace"); const list = document.querySelector("#conversation-list"); const cards = [...document.querySelectorAll(".message-card")]; const sessionsResponse = await fetch("/v1/workbench/sessions?limit=50", { credentials: "same-origin" }).catch(() => null); const sessionsBody = sessionsResponse ? await sessionsResponse.json().catch(() => null) : null; const sessions = Array.isArray(sessionsBody?.sessions) ? sessionsBody.sessions : Array.isArray(sessionsBody?.data?.sessions) ? sessionsBody.data.sessions : Array.isArray(sessionsBody?.items) ? sessionsBody.items : Array.isArray(sessionsBody?.data?.items) ? sessionsBody.data.items : []; 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 = sessions.map((item) => item?.sessionId ?? item?.id).filter((value) => typeof value === "string" && value).slice(0, 8); const sessionTraceIds = sessions.map((item) => item?.lastTraceId ?? item?.traceId).filter((value) => typeof value === "string" && value); const domTraceIds = cards.map((card) => { const trace = card.querySelector(".trace-timeline, .message-trace"); return firstTraceId(card.getAttribute("data-trace-id"), trace?.getAttribute("data-trace-id"), trace?.getAttribute("data-traceid"), card.textContent, trace?.textContent, document.querySelector(".composer-mode")?.textContent); }).filter((value) => typeof value === "string" && value); const traceIds = [...new Set([...sessionTraceIds, ...domTraceIds])].slice(0, 8); const scroll = workspace ? { scrollTop: Math.round(workspace.scrollTop), scrollHeight: Math.round(workspace.scrollHeight), clientHeight: Math.round(workspace.clientHeight), overflowY: getComputedStyle(workspace).overflowY, followState: workspace.getAttribute("data-scroll-follow") } : null; 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")), commandSend: Boolean(document.querySelector("#command-send")), conversationList: Boolean(list), sessionCreate: Boolean(document.querySelector("#session-create")) }, 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, traceIds, sessionsStatus: sessionsResponse?.status ?? null, sessionsCount: sessions.length }, messageCount: cards.length, messageRoles: cards.map((card) => cardRole(card) ?? "unknown"), messages: cards.map((card) => { const trace = card.querySelector(".trace-timeline, .message-trace"); const traceEvents = card.querySelector(".trace-disclosure-body ol, .message-trace-events"); const finalResponse = card.querySelector(".message-final-response, .message-text"); const traceStyle = traceEvents ? getComputedStyle(traceEvents) : null; const finalStyle = finalResponse ? getComputedStyle(finalResponse) : null; const traceRect = traceEvents?.getBoundingClientRect(); const finalRect = finalResponse?.getBoundingClientRect(); const finalBody = finalResponse?.querySelector(".message-body"); const traceId = firstTraceId(card.getAttribute("data-trace-id"), trace?.getAttribute("data-trace-id"), trace?.getAttribute("data-traceid"), card.textContent, trace?.textContent, document.querySelector(".composer-mode")?.textContent); return { status: cardStatus(card), role: cardRole(card), traceId, className: card.className, trace: trace ? { present: true, traceId, open: trace instanceof HTMLDetailsElement ? trace.open : null, status: trace.getAttribute("data-trace-status"), mode: trace.getAttribute("data-trace-mode"), maxHeight: traceStyle?.maxHeight ?? null, overflowY: traceStyle?.overflowY ?? null, height: traceRect ? Math.round(traceRect.height) : null, scrollHeight: traceEvents ? Math.round(traceEvents.scrollHeight) : null } : { present: false }, finalResponse: finalResponse ? { present: true, maxHeight: finalStyle?.maxHeight ?? null, overflowY: finalStyle?.overflowY ?? null, height: finalRect ? Math.round(finalRect.height) : null, scrollHeight: Math.round(finalResponse.scrollHeight), textChars: finalResponse.textContent?.trim().length ?? 0, textPreview: (finalResponse.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 500), markdown: finalBody ? { paragraphs: finalBody.querySelectorAll("p").length, headings: finalBody.querySelectorAll("h1,h2,h3,h4,h5,h6").length, lists: finalBody.querySelectorAll("ul,ol").length, listItems: finalBody.querySelectorAll("li").length, codeBlocks: finalBody.querySelectorAll("pre code").length, inlineCode: finalBody.querySelectorAll("p code,li code").length, tables: finalBody.querySelectorAll("table").length } : null } : { present: false } }; }), scroll, textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800) }; }, { projectId: args.projectId }); } function observeBootstrapRoutes(page) { const routes = []; page.on("request", (request) => { try { const url = new URL(request.url()); if (!["/auth/bootstrap", "/auth/session", "/auth/workspace-bootstrap", "/v1/workbench/sessions"].includes(url.pathname)) return; routes.push({ method: request.method(), path: url.pathname }); } catch { // Ignore malformed browser-internal URLs. } }); return routes; } function summarizeBootstrapRoutes(routes) { const paths = routes.map((route) => route.path); return { authBootstrapRequested: paths.includes("/auth/bootstrap"), authSessionRequested: paths.includes("/auth/session"), workbenchSessionsRequested: paths.includes("/v1/workbench/sessions"), legacyWorkspaceBootstrapRequested: paths.includes("/auth/workspace-bootstrap"), routes: routes.slice(0, 20) }; } async function fileSha256(file) { if (!file || !fs.existsSync(file)) return null; return createHash("sha256").update(await readFile(file)).digest("hex"); } function parseViewport(value) { const [width, height] = String(value).split("x").map(Number); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error(`invalid --viewport: ${value}`); return { width, height }; } function positiveInteger(value, label) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer`); return parsed; } function nonNegativeInteger(value, label) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative integer`); return parsed; } function envNonNegativeInteger(name, fallback) { const parsed = Number(process.env[name] ?? fallback); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } function processAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } async function readTail(file, maxChars = 4000) { if (!file || !fs.existsSync(file)) return ""; const text = await readFile(file, "utf8"); return text.length > maxChars ? text.slice(-maxChars) : text; } function helpPayload() { return { ok: true, status: "usage", command: "web-live-dom-probe", usage: [ "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/ --fresh-session --report /tmp/hwlab-dev-gate/web-live-dom-probe.json", "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session --message '...' --no-cancel-running --wait-agent-terminal-ms 180000", "node scripts/web-live-dom-probe.mjs run --url https://hwlab.pikapython.com/ --fresh-session --message '...' --no-cancel-running --trace-sample-count 6 --trace-sample-interval-ms 5000", "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/#/workspace --conversation-id cnv_...", "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session", "node scripts/web-live-dom-probe.mjs status " ], output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata." }; }