// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // Responsibility: Source string for the pure-client HWLAB web-probe observer runner. import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source"; export function nodeWebObserveRunnerSource(): string { return String.raw`#!/usr/bin/env node import { createHash, randomBytes } from "node:crypto"; import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; const specRef = "PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer"; const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL); const username = process.env.HWLAB_WEB_USER || "admin"; const password = process.env.HWLAB_WEB_PASS || ""; const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || ".state/web-observe/manual"); const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex")); const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench"; const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000); const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000); const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0); const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto"); const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON); const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON); const playwrightProxy = proxyConfigFromEnv(baseUrl); const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy); const pageId = "control-" + randomBytes(4).toString("hex"); const observerPageId = "observer-" + randomBytes(4).toString("hex"); const dirs = { commandsPending: path.join(stateDir, "commands", "pending"), commandsProcessing: path.join(stateDir, "commands", "processing"), commandsDone: path.join(stateDir, "commands", "done"), commandsFailed: path.join(stateDir, "commands", "failed"), screenshots: path.join(stateDir, "screenshots"), analysis: path.join(stateDir, "analysis"), archive: path.join(stateDir, "archive"), }; const files = { manifest: path.join(stateDir, "manifest.json"), heartbeat: path.join(stateDir, "heartbeat.json"), control: path.join(stateDir, "control.jsonl"), samples: path.join(stateDir, "samples.jsonl"), network: path.join(stateDir, "network.jsonl"), console: path.join(stateDir, "console.jsonl"), errors: path.join(stateDir, "errors.jsonl"), artifacts: path.join(stateDir, "artifacts.jsonl"), }; let browser; let context; let page; let observerPage; let lastObserverRefreshAtMs = Date.now(); let sampleSeq = 0; let commandSeq = 0; let artifactSeq = 0; let activeCommandId = null; let stopping = false; let terminalStatus = "starting"; let lastScreenshotAtMs = 0; let auth = null; let pageLoadSeq = 0; let controlPageEpoch = 0; let observerPageEpoch = 0; let currentPageProvenance = null; const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] }; try { if (!password) throw new Error("missing HWLAB_WEB_PASS"); await prepareDirs(); await rotateExistingJsonlArtifacts(); await writeManifest({ status: "starting" }); await writeHeartbeat({ status: "starting" }); if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true })); const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href); const { chromium } = await launcher.importPlaywright(); browser = await launcher.launchChromium(chromium, chromiumLaunchOptions); context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); page = await context.newPage(); attachPassiveListeners(page, "control", pageId); auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context, page)); await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath)); observerPage = await context.newPage(); attachPassiveListeners(observerPage, "observer", observerPageId); await runControlCommand({ id: "startup-observer-goto", type: "observerGoto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => { const result = await syncObserverPageToControlSession("startup"); if (!result?.ok) { await appendJsonl(files.control, eventRecord("observer-startup-degraded", { reason: result?.failureKind || result?.reason || "observer-not-ready", result: sanitize(result), valuesRedacted: true, })); } return result ?? { ok: false, reason: "observer-not-ready", pageRole: "observer", pageId: observerPageId, valuesRedacted: true }; }); terminalStatus = "running"; await writeManifest({ status: "running", auth: publicAuth(auth) }); await writeHeartbeat({ status: "running" }); while (!stopping) { await drainOneCommand(); await samplePage("interval"); if (maxSamples > 0 && sampleSeq >= maxSamples) { await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples })); break; } await sleep(sampleIntervalMs); } terminalStatus = "completed"; await writeHeartbeat({ status: "completed" }); await writeManifest({ status: "completed", completedAt: new Date().toISOString() }); process.exitCode = 0; } catch (error) { terminalStatus = "failed"; await appendJsonl(files.errors, eventRecord("runner-error", { error: errorSummary(error) })).catch(() => {}); await writeHeartbeat({ status: "failed", error: errorSummary(error) }).catch(() => {}); await writeManifest({ status: "failed", error: errorSummary(error) }).catch(() => {}); process.exitCode = 2; } finally { if (browser) await browser.close().catch(() => {}); } async function prepareDirs() { await mkdir(stateDir, { recursive: true, mode: 0o700 }); await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 }))); } async function rotateExistingJsonlArtifacts() { for (const [key, file] of Object.entries(files)) { if (!file.endsWith(".jsonl")) continue; let meta = null; try { meta = await stat(file); } catch (error) { if (error?.code === "ENOENT") continue; throw error; } if (!meta?.isFile()) continue; const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file)); await rename(file, archiveFile); jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size }); } } async function uniqueArchiveFile(name) { let candidate = path.join(dirs.archive, name); const parsed = path.parse(name); for (let index = 1; ; index += 1) { try { await stat(candidate); candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext); } catch (error) { if (error?.code === "ENOENT") return candidate; throw error; } } } function compactFileTimestamp(value) { return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z"); } async function writeManifest(extra = {}) { const manifest = { ok: extra.status !== "failed", command: "web-probe-observe", specRef, jobId, pid: process.pid, stateDir, baseUrl, targetPath, network: publicNetwork(playwrightProxy), pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true }, pageProvenance: compactPageProvenance(currentPageProvenance), sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false }, alertThresholds, projectManagement, jsonlRotation, commandDirs: dirs, artifacts: files, safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false }, startedAt, ...extra, }; await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); } async function writeHeartbeat(extra = {}) { const heartbeat = { ok: terminalStatus !== "failed", jobId, pid: process.pid, stateDir, status: terminalStatus, pageId, observerPageId, baseUrl, currentUrl: currentPageUrl(), observerUrl: pageUrl(observerPage), observerRefreshIntervalMs, lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null, pageProvenance: compactPageProvenance(currentPageProvenance), sampleSeq, commandSeq, activeCommandId, updatedAt: new Date().toISOString(), uptimeMs: Date.now() - startedAtMs, ...extra, }; await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 }); } function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) { targetPage.on("request", (request) => { void appendJsonl(files.network, eventRecord("request", { pageRole, pageId: targetPageId, observerInitiated: false, commandId: activeCommandId, method: request.method(), url: safeUrl(request.url()), resourceType: request.resourceType(), frameUrl: safeFrameUrl(request.frame()), })); }); targetPage.on("response", (response) => { const request = response.request(); void appendJsonl(files.network, eventRecord("response", { pageRole, pageId: targetPageId, observerInitiated: false, commandId: activeCommandId, method: request.method(), url: safeUrl(response.url()), resourceType: request.resourceType(), status: response.status(), statusText: response.statusText(), fromServiceWorker: response.fromServiceWorker(), bodyRead: false, })); }); targetPage.on("requestfailed", (request) => { void appendJsonl(files.network, eventRecord("requestfailed", { pageRole, pageId: targetPageId, observerInitiated: false, commandId: activeCommandId, method: request.method(), url: safeUrl(request.url()), resourceType: request.resourceType(), failure: request.failure()?.errorText ?? null, })); }); targetPage.on("console", (message) => { void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() })); }); targetPage.on("pageerror", (error) => { void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) })); }); targetPage.on("crash", () => { void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId })); }); targetPage.on("close", () => { void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" })); }); } async function drainOneCommand() { const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort(); const name = entries[0]; if (!name) return; const pending = path.join(dirs.commandsPending, name); const processing = path.join(dirs.commandsProcessing, name); await rename(pending, processing).catch(() => null); const raw = await readFile(processing, "utf8"); const command = JSON.parse(raw); const id = safeId(command.id || name.replace(/[.]json$/u, "")); command.id = id; const stopCommandSampler = startCommandActiveSampler(command); try { const result = await processCommand(command); const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) }; await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 }); await appendJsonl(files.control, controlRecord(command, "completed", done.result)); await unlink(processing).catch(() => {}); } catch (error) { const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false }) .then(() => ({ ok: true, sampleSeq, valuesRedacted: true })) .catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true })); const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample }; await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 }); await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample })); await unlink(processing).catch(() => {}); } finally { stopCommandSampler(); activeCommandId = null; await writeHeartbeat({ status: terminalStatus }); } } function startCommandActiveSampler(command) { const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000); let stopped = false; let timer = null; let inFlight = false; const schedule = () => { if (stopped) return; timer = setTimeout(tick, intervalMs); if (timer && typeof timer.unref === "function") timer.unref(); }; const tick = () => { if (stopped) return; if (inFlight) { schedule(); return; } inFlight = true; samplePage("command-active", { refreshObserver: false, screenshot: false }) .catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))) .finally(() => { inFlight = false; schedule(); }); }; schedule(); return () => { stopped = true; if (timer) clearTimeout(timer); }; } async function processCommand(command) { commandSeq += 1; activeCommandId = command.id; await writeHeartbeat({ status: "running", activeCommandId }); await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command))); switch (command.type) { case "login": return authenticate(context, page); case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount"); case "logout": return withObserverSync(await logoutAccount(command), "logout"); case "listSessions": return withObserverSync(await listSessions(command), "listSessions"); case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions"); case "preflight": return preflightSummary(); case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "turn", responsePath: "/v1/agent/chat", alternateResponsePaths: ["/v1/agent/chat/steer"], noActiveReason: "send-no-turn-composer", throwOnActionMismatch: true, }), "sendPrompt"); case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn" }), "steer"); case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel"); case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider"); case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession"); case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession"); case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack"); case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant"); case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo"); case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command); case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command); case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command); case "probeMdtodoSource": return probeMdtodoSource(command); case "reindexMdtodoSource": return reindexMdtodoSource(command); case "selectProjectSource": return selectProjectSource(command); case "selectMdtodoSource": return selectMdtodoSource(command); case "selectMdtodoFile": return selectMdtodoFile(command); case "selectMdtodoTask": return selectMdtodoTask(command); case "expandMdtodoTask": return expandMdtodoTask(command); case "openMdtodoReportPreview": return openMdtodoReportPreview(command); case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command); case "editMdtodoTaskInline": return editMdtodoTaskInline(command); case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command); case "editMdtodoTaskBody": return editMdtodoTaskBody(command); case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command); case "addMdtodoRootTask": return addMdtodoRootTask(command); case "addMdtodoSubTask": return addMdtodoSubTask(command); case "continueMdtodoTask": return continueMdtodoTask(command); case "deleteMdtodoTask": return deleteMdtodoTask(command); case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask"); case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo"); case "screenshot": return captureScreenshot(command.reason || "manual", command.imageType || "png"); case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId }; case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId }; default: throw new Error("unsupported observer command type: " + command.type); } } async function withObserverSync(result, reason) { return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) }; } async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) { if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" }; const forceRefresh = options?.forceRefresh === true; const snapshot = await workbenchSessionSnapshot(); const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl()); const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath; const targetUrl = new URL(target, baseUrl).toString(); const beforeUrl = pageUrl(observerPage); const beforeSessionId = routeSessionIdFromUrl(beforeUrl); if (sessionId && beforeSessionId === sessionId && !forceRefresh) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch }; observerPageEpoch += 1; let status = null; let statusText = null; const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 45000 }).catch((error) => ({ observerGotoError: errorSummary(error) })); if (response?.observerGotoError) return { ok: false, reason, changed: false, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, valuesRedacted: true }; status = typeof response?.status === "function" ? response.status() : null; statusText = typeof response?.statusText === "function" ? response.statusText() : null; const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: 15000 }); if (!readiness.ok) { lastObserverRefreshAtMs = Date.now(); return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true }; } if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) { lastObserverRefreshAtMs = Date.now(); return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, valuesRedacted: true }; } const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 15000 }); lastObserverRefreshAtMs = Date.now(); return { ok: hydration.ok === true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", valuesRedacted: true }; } async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) { const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000; const started = Date.now(); const deadline = started + Math.max(1, timeoutMs); let last = null; while (Date.now() <= deadline) { last = await workbenchSessionSnapshot(targetPage); const expected = String(sessionId || "").trim(); const observedPath = safeUrlPath(last?.url || pageUrl(targetPage)); const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || ""); const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || ""); if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; await targetPage.waitForTimeout(250).catch(() => {}); } return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true }; } async function maybeRefreshObserverPage(reason) { if (!observerPage || observerPage.isClosed()) return null; if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null; if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null; const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true }); await appendJsonl(files.control, eventRecord("observer-periodic-refresh", { pageRole: "observer", pageId: observerPageId, reason, intervalMs: observerRefreshIntervalMs, result, valuesRedacted: true })); return result; } async function runControlCommand(command, fn) { activeCommandId = command.id; commandSeq += 1; const beforeUrl = currentPageUrl(); const started = Date.now(); await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) })); try { const result = await fn(); await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) })); return result; } catch (error) { let failurePageProvenance = null; let failureScreenshot = null; try { failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null)); } catch (captureError) { await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); } try { failureScreenshot = await captureScreenshot("command-failed", "jpeg"); } catch (captureError) { await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) })); } await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot })); throw error; } finally { activeCommandId = null; } } async function authenticate(browserContext, authPage) { const loginUrl = new URL("/auth/login", baseUrl).toString(); let activeAuthPage = authPage && !authPage.isClosed() ? authPage : null; let closeAuthPage = false; if (!activeAuthPage) { activeAuthPage = await browserContext.newPage(); closeAuthPage = true; } const attempts = []; const maxAttempts = 5; const initialDelayMs = 250; const maxDelayMs = 5000; try { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const retryDelayMs = attempt < maxAttempts ? Math.min(maxDelayMs, initialDelayMs * (2 ** (attempt - 1))) : 0; const retryLabel = attempt + "/" + maxAttempts; await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); try { const response = await pageAuthLogin(activeAuthPage, loginUrl); const cookieState = await readAuthCookieState(browserContext); const retryable = isRetryableAuthStatus(response.status); const item = { attempt, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryLabel, retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, method: "api", status: response.status, statusText: response.statusText, retryable, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true, }; attempts.push(item); await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {}); if (response.ok && cookieState.cookiePresent) { return { ok: true, method: "api", loginPath: new URL(loginUrl).pathname, status: response.status, statusText: response.statusText, cookiePresent: true, cookieNames: cookieState.cookieNames, attempts, retryCount: attempt - 1, retryMaxAttempts: maxAttempts, lastRetryLabel: attempt + "/" + maxAttempts, retryExhausted: false, retryable: false, valuesRedacted: true, }; } if (!retryable) break; } catch (error) { const retryable = isRetryableAuthError(error); attempts.push({ attempt, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryLabel, retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0, method: "api", status: 0, statusText: "request-error", retryable, error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500), cookiePresent: false, cookieNames: [], valuesRedacted: true, }); const item = attempts[attempts.length - 1] || null; await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {}); if (!retryable) break; } if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs); } } finally { if (closeAuthPage && activeAuthPage && !activeAuthPage.isClosed()) await activeAuthPage.close().catch(() => {}); } const cookieState = await readAuthCookieState(browserContext); const last = attempts[attempts.length - 1] || null; const retryable = attempts.some((attempt) => attempt && attempt.retryable === true); const failure = { ok: false, method: "api", loginPath: new URL(loginUrl).pathname, status: typeof last?.status === "number" ? last.status : 0, statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed", cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, attempts, retryCount: Math.max(0, attempts.length - 1), retryMaxAttempts: maxAttempts, lastRetryLabel: last?.retryLabel || null, retryExhausted: retryable && attempts.length >= maxAttempts, retryable, lastError: last?.error || null, valuesRedacted: true, }; await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {}); const error = new Error(authFailureMessage(failure)); error.webProbeAuth = failure; throw error; } async function pageAuthLogin(authPage, loginUrl, credential = { username, password }) { if (!authPage) throw new Error("auth page is not ready"); await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 }); return authPage.evaluate(async (input) => { const response = await fetch(input.loginUrl, { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ username: input.username, password: input.password }), credentials: "include", }); await response.text().catch(() => ""); return { ok: response.ok, status: response.status, statusText: response.statusText || "", }; }, { username: credential.username, password: credential.password, loginUrl }); } async function loginAccount(command) { const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]); const credential = credentialForAccount(accountId); const loginUrl = new URL("/auth/login", baseUrl).toString(); const before = await accountSessionSnapshot(); const response = await pageAuthLogin(page, loginUrl, credential); const cookieState = await readAuthCookieState(context); if (!response.ok || !cookieState.cookiePresent) { const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || "")); error.details = { accountId, status: response.status, statusText: response.statusText, cookiePresent: cookieState.cookiePresent, credentialSource: credential.source, valuesRedacted: true }; throw error; } const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath; const navigation = await gotoTarget(target || targetPath); const after = await accountSessionSnapshot(); return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }; } async function logoutAccount(command = {}) { const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; const before = await accountSessionSnapshot(); const logoutUrl = new URL("/logout", baseUrl).toString(); const response = await page.evaluate(async (input) => { const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" }); await res.text().catch(() => ""); return { ok: res.ok, status: res.status, statusText: res.statusText || "" }; }, { logoutUrl }); await context.clearCookies().catch(() => {}); const cookieState = await readAuthCookieState(context); const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl()); const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true }; if (!result.ok) { const error = new Error("logout failed status=" + response.status + " " + (response.statusText || "")); error.details = result; throw error; } return result; } async function listSessions(command = {}) { const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null; if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath); const snapshot = await workbenchSessionSnapshot(); const sessions = await page.evaluate(() => { const seen = new Set(); const rows = []; for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) { const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || ""; if (!sessionId || seen.has(sessionId)) continue; seen.add(sessionId); rows.push({ sessionId, active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", status: element.getAttribute("data-status") || null, conversationId: element.getAttribute("data-conversation-id") || null, }); } return rows.slice(0, 50); }).catch(() => []); return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true }; } async function switchSessions(command) { const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]); const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]); const before = await accountSessionSnapshot(); if (fromAccountId) { const beforeAccount = before.accountId || null; await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true })); } const logout = await logoutAccount({ ...command, accountId: fromAccountId || null }); const login = await loginAccount({ ...command, accountId: toAccountId }); const sessions = await listSessions({ ...command, accountId: toAccountId }); return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true }; } async function accountSessionSnapshot() { const cookieState = await readAuthCookieState(context); const workbench = await workbenchSessionSnapshot().catch(() => null); return { url: currentPageUrl(), path: safeUrlPath(currentPageUrl()), cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, activeSessionId: workbench?.activeSessionId || null, routeSessionId: workbench?.routeSessionId || null, tabCount: workbench?.tabCount ?? null, messageCount: workbench?.messageCount ?? null, valuesRedacted: true, }; } function requiredAccountId(command, keys) { const accountId = commandValue(command, keys); if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id"); return accountId; } function credentialForAccount(accountId) { if (accountId === "bootstrap-admin" || accountId === "admin") { if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS"); return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true }; } const env = accountCredentialEnvCandidates(accountId); for (const jsonKey of env.jsonKeys) { const raw = process.env[jsonKey]; if (!raw) continue; const parsed = parseCredentialJson(raw); if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true }; } for (const pair of env.pairs) { const user = process.env[pair.userKey]; const pass = process.env[pair.passKey]; if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true }; } throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(",")); } function accountCredentialEnvCandidates(accountId) { const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); return { jsonKeys: [ "HWLAB_WEB_" + segment + "_JSON", "HWLAB_WEB_ACCOUNT_" + segment + "_JSON", ], pairs: [ { userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" }, { userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" }, ], }; } function parseCredentialJson(raw) { try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : ""; const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : ""; if (!user || !pass) return null; return { username: user, password: pass, valuesRedacted: true }; } catch { return null; } } function isSafeAccountId(value) { return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || "")); } function publicAuth(value) { if (!value) return null; return { ok: value.ok === true, method: value.method, status: value.status, cookiePresent: value.cookiePresent === true, cookieNames: value.cookieNames || [], retryCount: value.retryCount ?? null, retryMaxAttempts: value.retryMaxAttempts ?? null, lastRetryLabel: value.lastRetryLabel ?? null, retryExhausted: value.retryExhausted === true, valuesRedacted: true }; } async function readAuthCookieState(browserContext) { const cookies = await browserContext.cookies(baseUrl); const cookieNames = cookies.map((cookie) => cookie.name).sort(); return { cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)), cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)), }; } function isRetryableAuthStatus(status) { return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500; } function isRetryableAuthError(error) { const message = error && error.message ? String(error.message) : String(error || ""); return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message); } function authFailureMessage(failure) { const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null; const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : ""; const exhausted = failure.retryExhausted ? " exhausted=true" : ""; const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : ""; const error = last?.error ? " error=" + truncate(last.error, 160) : ""; return ("auth login failed:" + retry + exhausted + status + error).trim(); } function proxyConfigFromEnv(targetBaseUrl) { if (browserProxyMode === "direct") return null; let target; try { target = new URL(targetBaseUrl); } catch { return null; } const noProxy = process.env.NO_PROXY || process.env.no_proxy || ""; if (noProxyMatches(target.hostname, noProxy)) return null; const raw = target.protocol === "https:" ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "" : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || ""; if (!raw) return null; return { server: raw }; } function chromiumLaunchOptionsForProxy(proxy) { const base = { env: browserProcessEnvWithoutProxy() }; if (proxy === null) return { ...base, args: ["--no-proxy-server"] }; return { ...base, proxy }; } function browserProcessEnvWithoutProxy() { const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]); const env = {}; for (const [key, value] of Object.entries(process.env)) { if (!blocked.has(key) && value !== undefined) env[key] = value; } return env; } function noProxyMatches(hostname, rawList) { const host = String(hostname || "").toLowerCase(); if (!host) return false; return String(rawList || "").split(",").some((raw) => { let item = raw.trim().toLowerCase(); if (!item) return false; if (item === "*") return true; item = item.replace(/^\*\./u, "."); const portIndex = item.lastIndexOf(":"); if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex); if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item); return host === item; }); } function publicNetwork(proxy) { return { proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : { enabled: true, source: "env", server: publicProxyServer(proxy.server), valuesRedacted: true, }, browser: { proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy", requestedProxyMode: browserProxyMode, proxyEnvCleared: true, valuesRedacted: true, }, valuesRedacted: true, }; } function parseBrowserProxyMode(raw) { if (raw === "auto" || raw === "direct") return raw; return "auto"; } function publicProxyServer(raw) { try { const parsed = new URL(String(raw || "")); parsed.username = ""; parsed.password = ""; const value = parsed.toString(); if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, ""); return value; } catch { return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@"); } } async function gotoTarget(rawTarget) { const target = new URL(String(rawTarget || targetPath), baseUrl).toString(); const beforeUrl = currentPageUrl(); const attempts = []; for (let attempt = 1; attempt <= 2; attempt += 1) { try { const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45000 }); await page.waitForTimeout(1000).catch(() => {}); const httpStatus = response ? response.status() : null; const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 15000 }); if (!readiness.ok) { const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null); attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" }); return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; } const pageProvenance = await refreshPageProvenance("goto", httpStatus); attempts.push({ attempt, ok: true, httpStatus, readiness }); return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; } catch (error) { const message = error instanceof Error ? error.message : String(error); attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null }); if (/workbench-app-not-ready/iu.test(message)) { const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: 5000 }).catch(() => null); if (lateReadiness?.ok) { const pageProvenance = await refreshPageProvenance("goto-late-ready", null); attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness }); return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts }; } } if (attempt >= 2 || !isRetryableNavigationError(message)) { throw Object.assign(new Error(message), { attempts, target }); } if (!observerPage) { await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); } else { await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) }))); } await page.waitForTimeout(1500 * attempt).catch(() => {}); } } return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts }; } async function recreateControlPageForNavigation(reason, attempt) { const before = currentPageUrl(); if (page && !page.isClosed()) await page.close().catch(() => {}); page = await context.newPage(); attachPassiveListeners(page, "control", pageId); currentPageProvenance = null; await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, valuesRedacted: true })); } async function recreateAuthenticatedContextForNavigation(reason, attempt) { const before = currentPageUrl(); if (page && !page.isClosed()) await page.close().catch(() => {}); if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {}); observerPage = null; if (context) await context.close().catch(() => {}); context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); auth = await authenticate(context); page = await context.newPage(); attachPassiveListeners(page, "control", pageId); currentPageProvenance = null; await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true })); } async function refreshPageProvenance(reason, httpStatus = null) { if (!page || page.isClosed()) return currentPageProvenance; const observed = await page.evaluate(() => { const assetPath = (raw) => { if (!raw) return null; try { const url = new URL(raw, location.href); const keys = Array.from(url.searchParams.keys()).sort(); return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); } catch { return null; } }; const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), content: String(element.getAttribute("content") || "").slice(0, 200), })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)); const navigation = performance.getEntriesByType("navigation")[0] || null; return { url: location.href, path: location.pathname, title: document.title, readyState: document.readyState, timeOrigin: Math.round(performance.timeOrigin || 0), navigationStartTime: navigation ? Math.round(navigation.startTime) : null, scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(), stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(), meta, }; }).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] })); pageLoadSeq += 1; currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq }); await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) })); return currentPageProvenance; } function normalizePageProvenance(value, options = {}) { const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : []; const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : []; const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({ key: String(item?.key || "").slice(0, 120), contentHash: sha256Text(String(item?.content || "")), })).filter((item) => item.key) : []; const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta }); return { pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq, reason: options.reason || "sample", observedAt: new Date().toISOString(), urlPath: safeUrlPath(value?.url || currentPageUrl()), documentPath: value?.path || null, titleHash: sha256Text(String(value?.title || "")), documentReadyState: value?.readyState || null, timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null, navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null, httpStatus: options.httpStatus ?? null, assetFingerprint: sha256Text(fingerprintInput), scriptCount: scripts.length, stylesheetCount: stylesheets.length, metaCount: meta.length, scripts: scripts.slice(0, 30), stylesheets: stylesheets.slice(0, 30), meta: meta.slice(0, 30), error: value?.error || null, valuesRedacted: true, }; } function compactPageProvenance(value) { if (!value) return null; return { pageLoadSeq: value.pageLoadSeq ?? null, reason: value.reason || null, observedAt: value.observedAt || null, urlPath: value.urlPath || null, documentReadyState: value.documentReadyState || null, timeOrigin: value.timeOrigin ?? null, httpStatus: value.httpStatus ?? null, assetFingerprint: value.assetFingerprint || null, scriptCount: value.scriptCount ?? 0, stylesheetCount: value.stylesheetCount ?? 0, metaCount: value.metaCount ?? 0, scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [], stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [], error: value.error || null, valuesRedacted: true, }; } function isRetryableNavigationError(message) { return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|Navigation timeout|workbench-app-not-ready/iu.test(String(message || "")); } function navigationFailureKind(message) { const text = String(message || ""); if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED"; if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED"; if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET"; if (/Navigation timeout/iu.test(text)) return "navigation-timeout"; if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready"; return "navigation-error"; } function redactErrorMessage(message) { return String(message || "") .replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]") .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]"); } async function waitForTargetPageReady(targetPage, targetUrl, options = {}) { const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000; const targetPathname = safeUrlPath(targetUrl) || ""; if (isProjectManagementPathname(targetPathname)) { const started = Date.now(); const selectors = projectManagement.readinessSelectors; await targetPage.waitForFunction((input) => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; return input.selectors.some((selector) => { try { return visible(document.querySelector(selector)); } catch { return false; } }); }, { selectors }, { timeout: timeoutMs }).catch(() => null); const snapshot = await projectManagementReadinessSnapshot(targetPage); const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true; return { ok, reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready", durationMs: Date.now() - started, snapshot, valuesRedacted: true }; } if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true }; const started = Date.now(); await targetPage.waitForFunction(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const workspace = document.querySelector("#workspace, .workbench-route"); const login = document.querySelector("form.login-card, .login-card, [data-testid='login']"); return Boolean(visible(workspace) || visible(login)); }, null, { timeout: timeoutMs }).catch(() => null); const snapshot = await workbenchReadinessSnapshot(targetPage); const ok = snapshot.workbenchShellVisible === true; return { ok, reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready", durationMs: Date.now() - started, snapshot, valuesRedacted: true }; } async function workbenchReadinessSnapshot(targetPage) { const snapshot = await targetPage.evaluate(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; return { url: window.location.href, path: window.location.pathname, readyState: document.readyState, workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")), sessionCreateVisible: visible(document.querySelector("#session-create")), commandInputPresent: visible(document.querySelector("#command-input")), activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")), warningPresent: visible(document.querySelector(".composer-warning")), loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")), bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000), valuesRedacted: true }; }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); if (snapshot && typeof snapshot.bodyTextPreview === "string") { snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview); delete snapshot.bodyTextPreview; } return snapshot; } async function projectManagementReadinessSnapshot(targetPage) { const selectors = projectManagement.readinessSelectors; return targetPage.evaluate((input) => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const selectorStates = input.selectors.map((selector) => { let matched = false; let visibleMatched = false; try { const element = document.querySelector(selector); matched = Boolean(element); visibleMatched = visible(element); } catch {} return { selector, matched, visible: visibleMatched }; }); return { url: window.location.href, path: window.location.pathname, readyState: document.readyState, projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')), mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')), loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")), selectorStates, valuesRedacted: true }; }, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); } function isWorkbenchPathname(value) { const pathname = String(value || ""); return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/"); } function isProjectManagementPathname(value) { if (projectManagement.enabled !== true) return false; const pathname = String(value || ""); return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/")); } async function createSessionFromUi() { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const readinessBeforeClick = await workbenchReadinessSnapshot(page); const create = page.locator("#session-create").first(); await create.waitFor({ state: "visible", timeout: 15000 }); const createButtonState = await create.evaluate((element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return { tag: element.tagName.toLowerCase(), id: element.id || null, disabled: Boolean(element.disabled), ariaDisabled: element.getAttribute("aria-disabled") || null, visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none", rect: { width: Math.round(rect.width), height: Math.round(rect.height) }, valuesRedacted: true }; }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); const createResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return new URL(response.url()).pathname === "/v1/agent/sessions"; } catch { return false; } }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); await create.click(); const createResponse = await createResponsePromise; if (createResponse?.waitError) { const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (createResponse.waitError.message || createResponse.waitError.name || "timeout")); error.details = { beforeUrl, afterUrl: currentPageUrl(), before, readinessBeforeClick, createButtonState, waitError: createResponse.waitError, pageId, valuesRedacted: true }; throw error; } const createStatus = createResponse.status(); let createPayload = null; let createPayloadError = null; try { createPayload = await createResponse.json(); } catch (error) { createPayloadError = errorSummary(error); } const createdSessionId = sessionIdFromAgentSessionPayload(createPayload); if (createStatus < 200 || createStatus >= 300 || !createdSessionId) { const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions"); error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }; throw error; } await page.waitForFunction((expectedSessionId) => { const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); const sessionId = activeTab?.getAttribute("data-session-id") || ""; const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : ""; const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; const input = document.querySelector("#command-input"); return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning); }, createdSessionId, { timeout: 45000 }).catch(() => null); const after = await workbenchSessionSnapshot(); const afterSessionId = after?.activeSessionId || after?.routeSessionId || ""; const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady); if (!ok) { const error = new Error("newSession did not select the authoritative newly created workbench session"); error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, pageId, valuesRedacted: true }; throw error; } return { beforeUrl, afterUrl: currentPageUrl(), ok, before, after, sessionId: createdSessionId, createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true }, pageId }; } function sessionIdFromAgentSessionPayload(payload) { const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id; const directText = String(direct || "").trim(); if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText; const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u); return match ? match[0] : null; } function traceIdFromAgentChatPayload(payload) { const direct = payload?.traceId ?? payload?.turn?.traceId ?? payload?.message?.traceId ?? payload?.data?.traceId ?? payload?.data?.turn?.traceId ?? payload?.data?.message?.traceId; const directText = String(direct || "").trim(); if (/^(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})$/u.test(directText)) return directText; const match = JSON.stringify(payload ?? "").match(/\b(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/u); return match ? match[0] : null; } async function workbenchSessionSnapshot(targetPage = page) { return targetPage.evaluate(() => { const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); const input = document.querySelector("#command-input"); const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null; const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; return { url: window.location.href, routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null, activeSessionId: activeTab?.getAttribute("data-session-id") || null, activeConversationId: activeTab?.getAttribute("data-conversation-id") || null, activeStatus: activeTab?.getAttribute("data-status") || null, tabCount: document.querySelectorAll(".session-tab").length, messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length, loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length, composerReady: Boolean(activeTab && input && !input.disabled && !warning), warning }; }).catch(() => null); } async function sendPrompt(text, options = {}) { if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); const responsePath = options.responsePath || "/v1/agent/chat"; const beforeUrl = currentPageUrl(); const beforeEvidence = await promptSideEffectSnapshot(); const primaryEditor = page.locator("#command-input").last(); const editor = await primaryEditor.isVisible().catch(() => false) ? primaryEditor : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); await editor.waitFor({ state: "visible", timeout: 15000 }); await fillComposerEditor(editor, text); const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'; const primarySubmit = page.locator(primarySubmitSelector).last(); const submit = await primarySubmit.isVisible().catch(() => false) ? primarySubmit : page.locator([ 'button[type="submit"]', 'button:has-text("发送")', 'button:has-text("Send")', '[data-testid*="send" i]', '[aria-label*="send" i]', '[aria-label*="发送"]' ].join(", ")).last(); await submit.waitFor({ state: "visible", timeout: 15000 }); if (options.expectedAction) { const actionWaitMs = Number.isFinite(Number(options.expectedActionWaitMs)) ? Math.max(1000, Math.trunc(Number(options.expectedActionWaitMs))) : options.expectedAction === "turn" ? 180000 : 1000; const actionDeadline = Date.now() + actionWaitMs; let composer = null; while (Date.now() <= actionDeadline) { composer = await composerButtonState(submit); if (composer.action === options.expectedAction && composer.disabled !== true) break; await page.waitForTimeout(250); } composer = composer || await composerButtonState(submit); if (composer.action !== options.expectedAction || composer.disabled === true) { await clearComposerEditor(editor).catch(() => {}); const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot()); const blocked = { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), submitted: false, blocked: true, degradedReason: options.noActiveReason || "composer-action-mismatch", composer, chatSubmit: { status: null, statusText: null, urlPath: responsePath, responseObserved: false, sideEffectObserved: sideEffectHasAuthoritativePromptSubmission(sideEffect), sideEffect, actionWaitMs, expectedAction: options.expectedAction, actualAction: composer.action, valuesRedacted: true }, pageId, valuesRedacted: true }; if (options.throwOnActionMismatch === true) { const error = new Error("sendPrompt composer action mismatch: expected " + options.expectedAction + " actual " + (composer.action || "null")); error.details = blocked; throw error; } return blocked; } } const acceptedResponsePaths = [responsePath, ...(Array.isArray(options.alternateResponsePaths) ? options.alternateResponsePaths : [])]; const chatResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return acceptedResponsePaths.includes(new URL(response.url()).pathname); } catch { return false; } }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); await submit.click(); const chatResponse = await chatResponsePromise; await page.waitForTimeout(500); if (chatResponse?.waitError) { const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000); if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) { return { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect }, pageId }; } const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout")); error.details = { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect }, pageId, valuesRedacted: true }; throw error; } const chatStatus = chatResponse.status(); let chatUrlPath = responsePath; try { chatUrlPath = new URL(chatResponse.url()).pathname; } catch { chatUrlPath = responsePath; } if (chatStatus < 200 || chatStatus >= 300) { throw new Error("sendPrompt observed POST " + chatUrlPath + " HTTP " + chatStatus + " " + chatResponse.statusText()); } let chatPayload = null; let chatPayloadError = null; try { chatPayload = await chatResponse.json(); } catch (error) { chatPayloadError = errorSummary(error); } const payloadText = chatPayload ? JSON.stringify(chatPayload) : ""; const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null; const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId) ? chatPayload.otelTrace.traceId : null; return { beforeUrl, afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), chatSubmit: { status: chatStatus, statusText: chatResponse.statusText(), urlPath: chatUrlPath, traceId, otelTraceId, resultUrl: safeUrlPath(chatPayload?.resultUrl), turnUrl: safeUrlPath(chatPayload?.turnUrl), streamUrl: safeUrlPath(chatPayload?.streamUrl), responseParsed: chatPayload !== null, responseParseError: chatPayloadError, valuesRedacted: true }, pageId }; } async function fillComposerEditor(editor, text) { const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); if (tag === "textarea" || tag === "input") await editor.fill(text); else if (editable) { await editor.click(); await page.keyboard.insertText(text); } else { await editor.click(); await page.keyboard.insertText(text); } } async function clearComposerEditor(editor) { const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => ""); const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false); if (tag === "textarea" || tag === "input") { await editor.fill(""); return; } if (editable) { await editor.click(); await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {}); await page.keyboard.press("Backspace").catch(() => {}); } } async function composerButtonState(button) { return button.evaluate((element) => ({ action: element.getAttribute("data-action") || null, disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true", text: (element.textContent || "").trim().slice(0, 80), title: element.getAttribute("title") || null, ariaLabel: element.getAttribute("aria-label") || null, testId: element.getAttribute("data-testid") || null, })).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true })); } function sideEffectHasAuthoritativePromptSubmission(sideEffect) { return Boolean( (Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0) || (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0) || Number(sideEffect?.messageCountDelta || 0) > 0 ); } async function waitForPromptSideEffect(beforeEvidence, timeoutMs) { await page.waitForFunction((before) => { const current = (() => { const text = document.body?.innerText || ""; const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20); const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text); const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text); const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length; const textBytes = new TextEncoder().encode(text).length; return { runIds, traceIds, running, executionError, messageCount, textBytes }; })(); const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : []; const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : []; const newRun = current.runIds.some((id) => !beforeRuns.includes(id)); const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id)); const newMessage = current.messageCount > Number(before?.messageCount || 0); return newRun || newTrace || newMessage; }, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null); const after = await promptSideEffectSnapshot(); const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : []; const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : []; const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id)); const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id)); const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0)); return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true }; } async function promptSideEffectSnapshot() { return page.evaluate(() => { const text = document.body?.innerText || ""; const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; return { runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20), running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text), executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text), messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length, textBytes: new TextEncoder().encode(text).length, valuesRedacted: true }; }).catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true })); } ${nodeWebObserveRunnerCommandActionsSource()} async function selectProvider(provider) { const target = String(provider || "").trim(); if (!target) throw new Error("selectProvider requires provider name"); const beforeUrl = currentPageUrl(); const beforePath = safeUrlPath(beforeUrl); if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first"); const nativeSelect = await page.evaluate((name) => { const normalized = String(name).toLowerCase(); const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { const options = Array.from(select.options || []); const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized)); if (!option) continue; select.value = option.value; select.dispatchEvent(new Event("input", { bubbles: true })); select.dispatchEvent(new Event("change", { bubbles: true })); return { kind: "native-select", value: option.value, text: option.textContent || "" }; } return null; }, target).catch(() => null); if (nativeSelect) { await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId }; } const optionVisible = page.getByText(target, { exact: false }).last(); if (await optionVisible.isVisible().catch(() => false)) { await optionVisible.click(); await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId }; } const control = page.locator([ '[data-testid*="provider" i]', '[data-testid*="model" i]', '[aria-label*="provider" i]', '[aria-label*="model" i]', '[aria-label*="模型"]', '[aria-label*="提供"]', '[role="combobox"]', '[aria-haspopup="listbox"]', ].join(", ")); const count = Math.min(await control.count().catch(() => 0), 60); const attempts = []; for (let index = count - 1; index >= 0; index -= 1) { const item = control.nth(index); const visible = await item.isVisible().catch(() => false); if (!visible) continue; const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => ""); if (isProviderNavigationLabel(label)) continue; if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue; attempts.push({ index, label }); await item.click({ timeout: 3000 }).catch(() => null); await page.waitForTimeout(300); const option = page.getByText(target, { exact: false }).last(); if (await option.isVisible().catch(() => false)) { await option.click(); await page.waitForTimeout(700); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId }; } await page.keyboard.press("Escape").catch(() => null); } const providerCandidates = await collectProviderCandidates(); throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10))); } async function collectProviderCandidates() { return page.evaluate(() => { const rows = []; const seen = new Set(); const isNavigationLabel = (value) => { const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); if (!label) return false; if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true; return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, "")); }; const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const push = (kind, element, value, text) => { const normalizedValue = String(value || "").trim().slice(0, 160); const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220); const label = (normalizedValue + " " + normalizedText).trim(); if (!label) return; const key = (kind + ":" + label).toLowerCase(); if (seen.has(key)) return; seen.add(key); rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) }); }; for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || ""); } const selector = [ '[role="option"]', '[role="menuitem"]', '[data-radix-collection-item]', '[data-testid*="provider" i]', '[data-testid*="profile" i]', '[data-testid*="model" i]', '[aria-label*="provider" i]', '[aria-label*="profile" i]', '[aria-label*="model" i]', '[aria-label*="模型"]', '[aria-label*="提供"]', 'button' ].join(", "); for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) { const text = String(element.textContent || "").replace(/\s+/gu, " ").trim(); const ariaLabel = String(element.getAttribute("aria-label") || ""); const testId = String(element.getAttribute("data-testid") || ""); const haystack = text + " " + ariaLabel + " " + testId; if (isNavigationLabel(haystack)) continue; if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue; push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId); } return rows.slice(0, 80); }).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]); } function isProviderNavigationLabel(value) { const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); if (!text) return false; if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true; return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, "")); } async function clickSession(sessionId) { if (!sessionId) throw new Error("clickSession requires --session-id or --value"); const beforeUrl = currentPageUrl(); const escaped = cssEscape(sessionId); const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first(); const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first(); await candidate.waitFor({ state: "visible", timeout: 15000 }); await candidate.click(); await page.waitForTimeout(1000); return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId }; } async function refreshCurrentSession(command) { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session"); const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); const after = await workbenchSessionSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type: "refreshCurrentSession", afterRound: integerOrNull(command.afterRound), canarySessionId: sessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, routeOk: after?.routeSessionId === sessionId, activeOk: after?.activeSessionId === sessionId, composerReady: after?.composerReady === true, navigation, pageId, valuesRedacted: true, }; } async function switchAwayAndBack(command) { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session"); const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create"); const beforeSessionIds = await visibleWorkbenchSessionIds(); let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null; let alternateSource = alternateSessionId === null ? null : "existing"; let createResult = null; if (alternateSessionId === null && strategy === "existing-or-create") { createResult = await createSessionFromUi(); alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null; alternateSource = "created"; } if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy); const switchAway = await clickSession(alternateSessionId); const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 }); const away = awaySettle.snapshot ?? await workbenchSessionSnapshot(); const switchBack = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId)); const backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 }); const after = backSettle.snapshot ?? await workbenchSessionSnapshot(); const routeOk = after?.routeSessionId === canarySessionId; const activeOk = after?.activeSessionId === canarySessionId; if (awaySettle.ok !== true) { const error = new Error("switchAwayAndBack did not settle on the alternate session"); error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true }; throw error; } if (!routeOk || !activeOk) { const error = new Error("switchAwayAndBack did not return to the canary session"); error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, pageId, valuesRedacted: true }; throw error; } return { beforeUrl, afterUrl: currentPageUrl(), type: "switchAwayAndBack", afterRound: integerOrNull(command.afterRound), canarySessionId, alternateSessionId, alternateSessionStrategy: strategy, alternateSource, beforeSessionCount: beforeSessionIds.length, createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true }, switchAway, awaySettle, away: { routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, messageCount: away?.messageCount ?? null, valuesRedacted: true, }, switchBack, backSettle, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, routeOk, activeOk, composerReady: after?.composerReady === true, pageId, valuesRedacted: true, }; } async function assertSessionInvariant(command) { const beforeUrl = currentPageUrl(); const snapshot = await workbenchSessionSnapshot(); const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim(); if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session"); const routeOk = snapshot?.routeSessionId === canarySessionId; const activeOk = snapshot?.activeSessionId === canarySessionId; const composerReady = snapshot?.composerReady === true; if (!routeOk || !activeOk) { const error = new Error("assertSessionInvariant saw route/active session mismatch"); error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true }; throw error; } if (command.requireComposerReady === true && !composerReady) { const error = new Error("assertSessionInvariant requires composer ready for the next round"); error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true }; throw error; } const messageOrder = await visibleMessageOrderSummary(); await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true }))); return { beforeUrl, afterUrl: currentPageUrl(), type: "assertSessionInvariant", afterRound: integerOrNull(command.afterRound), canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, routeOk, activeOk, composerReady, expectedSentinelRange: command.expectedSentinelRange || null, findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation", severity: command.severity || "amber", blocking: command.blocking === true, messageOrder, sampleSeq, pageRole: "control", pageId, valuesRedacted: true, }; } async function visibleWorkbenchSessionIds() { return page.evaluate(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const sessionIdForElement = (element) => { const direct = element.getAttribute("data-session-id"); if (direct) return direct; const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); return match ? decodeURIComponent(match[1] || "") : null; }; const ids = []; for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) { if (!visible(element)) continue; const id = sessionIdForElement(element); if (id && !ids.includes(id)) ids.push(id); } return ids.slice(0, 50); }).catch(() => []); } async function visibleMessageOrderSummary() { const rawMessages = await page.evaluate(() => { const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").trim().slice(0, limit); const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const stableMessageText = (element) => { const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"]; const parts = []; for (const selector of selectors) { for (const candidate of Array.from(element.querySelectorAll(selector))) { if (!visible(candidate)) continue; const text = trim(candidate.textContent || "", 1200); if (text && !parts.includes(text)) parts.push(text); } } return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200); }; return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({ index, dataRole: element.getAttribute("data-role") || null, role: element.getAttribute("role") || null, testId: element.getAttribute("data-testid") || null, status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, sessionId: element.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, traceId: element.getAttribute("data-trace-id") || null, turnId: element.getAttribute("data-turn-id") || null, text: stableMessageText(element), })); }).catch(() => []); const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => { const text = String(item?.text || ""); const kind = messageKindForOrder(item, text); const markers = sentinelMarkers(text); return { index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index, kind, role: item?.dataRole || item?.role || null, status: item?.status || null, sessionId: item?.sessionId || null, messageId: item?.messageId || null, traceId: item?.traceId || null, turnId: item?.turnId || null, markers, textHash: sha256Text(text), textBytes: Buffer.byteLength(text), valuesRedacted: true, }; }) : []; const clusters = userClusters(entries); const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null; return { messageCount: entries.length, sequence: entries.map((item) => ({ index: item.index, kind: item.kind, marker: item.markers[0] || null, markerCount: item.markers.length, traceId: item.traceId, status: item.status, textHash: item.textHash, textBytes: item.textBytes, valuesRedacted: true, })).slice(-40), userClustered: clusters.length > 0, consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0, sentinelRange: maxCluster?.sentinelRange ?? null, traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12), clusters, valuesRedacted: true, }; } function messageKindForOrder(item, text) { const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" "); if (/\b(user|human)\b/u.test(signal)) return "user"; if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent"; const body = String(text || "").trim(); if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user"; if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent"; return "unknown"; } function sentinelMarkers(text) { return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase())); } function userClusters(entries) { const clusters = []; let current = []; const flush = () => { if (current.length >= 2) { const markers = current.flatMap((item) => item.markers); clusters.push({ startIndex: current[0].index, endIndex: current[current.length - 1].index, consecutiveUserMessageCount: current.length, sentinelRange: sentinelRange(markers), markers: uniqueStrings(markers).slice(0, 12), messageTextHashes: current.map((item) => item.textHash).slice(0, 12), traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12), valuesRedacted: true, }); } current = []; }; for (const entry of entries) { if (entry.kind === "user") { current.push(entry); } else { flush(); } } flush(); return clusters.slice(0, 20); } function sentinelRange(markers) { const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b)); if (unique.length === 0) return null; return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1]; } function sentinelMarkerNumber(marker) { const match = String(marker || "").match(/sentinel-(\d+)/u); return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; } function uniqueStrings(values) { return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))); } function integerOrNull(value) { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : null; } function ensureProjectManagementCommand(type) { if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane"); if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane"); } async function gotoProjectMdtodo() { ensureProjectManagementCommand("gotoProjectMdtodo"); return gotoTarget("/projects/mdtodo"); } function commandValue(command, keys) { for (const key of keys) { const value = command?.[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return ""; } async function visibleLocator(locator) { return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false); } async function selectHtmlOptionByValueOrLabel(locator, value) { const select = locator.first(); const targetValue = typeof value === "string" && value.trim() ? value.trim() : ""; if (targetValue) { const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); if (byValue.ok && byValue.selected.length > 0) return { mode: "select-value", selectedValue: byValue.selected[0] || targetValue }; const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] })); if (byLabel.ok && byLabel.selected.length > 0) return { mode: "select-label", selectedValue: byLabel.selected[0] || targetValue }; } const selectedValue = await select.evaluate((element) => { const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value); const chosen = options[0] || null; if (!chosen) return ""; element.value = chosen.value; element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); return chosen.value; }); return { mode: "select-first", selectedValue }; } async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const targetValue = typeof value === "string" && value.trim() ? value.trim() : null; if (selectTestId) { const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]'); if (await visibleLocator(select)) { const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || ""); await page.waitForTimeout(700); const afterProject = await projectManagementCommandSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type, attr, mode: selected.mode, selected: opaqueIdSummary(selected.selectedValue || targetValue), beforeProject, afterProject, pageId, valuesRedacted: true }; } } const selector = targetValue ? "[" + attr + "=\"" + cssEscape(targetValue) + "\"]" : fallbackSelector; const locator = page.locator(selector).first(); await locator.waitFor({ state: "visible", timeout: 15000 }); const clickedValue = await locator.evaluate((element, name) => element.getAttribute(name), attr).catch(() => targetValue); await locator.click(); await page.waitForTimeout(700); const afterProject = await projectManagementCommandSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type, attr, selected: opaqueIdSummary(clickedValue || targetValue), beforeProject, afterProject, pageId, valuesRedacted: true }; } async function selectProjectSource(command) { return clickProjectItemByAttr({ type: "selectProjectSource", attr: "data-source-id", value: command.sourceId || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', selectTestId: "mdtodo-source-select" }); } async function selectMdtodoSource(command) { return clickProjectItemByAttr({ type: "selectMdtodoSource", attr: "data-source-id", value: command.sourceId || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', selectTestId: "mdtodo-source-select" }); } async function selectMdtodoFile(command) { return clickProjectItemByAttr({ type: "selectMdtodoFile", attr: "data-file-ref", value: command.fileRef || command.filename || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]', selectTestId: "mdtodo-file-select" }); } async function mdtodoTaskLocator(command) { const taskRef = commandValue(command, ["taskRef"]); const taskId = commandValue(command, ["taskId", "task", "value", "text"]); const selectors = []; if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]'); if (taskId) { selectors.push('[data-task-id="' + cssEscape(taskId) + '"]'); selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]'); } for (const selector of selectors) { const locator = page.locator(selector).first(); if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector }; } if (taskId) { const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first(); if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId }; } const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first(); return { locator: fallback, taskRef, taskId, selector: "first-visible-task" }; } async function selectMdtodoTask(command) { ensureProjectManagementCommand("selectMdtodoTask"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const target = await mdtodoTaskLocator(command); await target.locator.waitFor({ state: "visible", timeout: 15000 }); const clicked = await target.locator.evaluate((element) => ({ taskRef: element.getAttribute("data-task-ref") || null, taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null, status: element.getAttribute("data-task-status") || null, selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true" })).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null })); if (!clicked.selected) { await target.locator.scrollIntoViewIfNeeded().catch(() => null); await target.locator.click(); await page.waitForTimeout(700); } const afterProject = await projectManagementCommandSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type: "selectMdtodoTask", selector: target.selector, selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef), selectedTaskId: clicked.taskId || target.taskId || null, selectedTaskStatus: clicked.status || null, alreadySelected: clicked.selected === true, beforeProject, afterProject, pageId, valuesRedacted: true }; } async function expandMdtodoTask(command) { ensureProjectManagementCommand("expandMdtodoTask"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const target = await mdtodoTaskLocator(command); await target.locator.waitFor({ state: "visible", timeout: 15000 }); const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first(); const toggleVisible = await visibleLocator(toggle); if (toggleVisible) await toggle.click(); else await target.locator.click(); await page.waitForTimeout(700); return { beforeUrl, afterUrl: currentPageUrl(), type: "expandMdtodoTask", selector: target.selector, toggleVisible, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function openMdtodoSourceConfig(command) { ensureProjectManagementCommand("openMdtodoSourceConfig"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first(); if (await visibleLocator(existingDialog)) { return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoSourceConfig", alreadyOpen: true, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } const button = page.locator('[data-testid="mdtodo-source-config-open"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); await button.click(); await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null); return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoSourceConfig", beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function ensureMdtodoSourceConfigOpen() { const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first(); if (await visibleLocator(form)) return { opened: false }; await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" }); return { opened: true }; } async function closeMdtodoSourceConfig(command) { ensureProjectManagementCommand("closeMdtodoSourceConfig"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const close = await closeMdtodoSourceConfigIfOpen({ required: true }); return { beforeUrl, afterUrl: currentPageUrl(), type: "closeMdtodoSourceConfig", close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function closeMdtodoSourceConfigIfOpen(options = {}) { const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({ has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]') }).first(); if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false }; const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first(); let closeClick = null; try { await closeButton.waitFor({ state: "visible", timeout: 5000 }); await closeButton.click({ timeout: 5000 }); closeClick = { attempted: true, ok: true }; } catch (error) { closeClick = { attempted: true, ok: false, error: errorSummary(error) }; await page.keyboard.press("Escape").catch(() => null); } await page.waitForTimeout(400); const stillVisible = await visibleLocator(dialog); const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true }; if (options.required && stillVisible) { const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt"); error.details = result; throw error; } return result; } async function fillMdtodoField(testId, value) { if (typeof value !== "string" || !value.trim()) return { testId, filled: false }; const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); await locator.waitFor({ state: "visible", timeout: 10000 }); await locator.fill(value); return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; } async function clickProjectButtonAndMaybeWait(testId, pathPattern) { const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); const responsePromise = pathPattern ? page.waitForResponse((response) => { try { const pathname = new URL(response.url()).pathname; return pathPattern.test(pathname); } catch { return false; } }, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null); const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) })); if (buttonState.disabled === true) { const error = new Error(testId + " button is disabled"); error.details = { buttonState, valuesRedacted: true }; throw error; } await button.click(); const response = await responsePromise; await page.waitForTimeout(900); return { buttonState, response, valuesRedacted: true }; } async function configureMdtodoHwpodSource(command) { ensureProjectManagementCommand("configureMdtodoHwpodSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const dialog = await ensureMdtodoSourceConfigOpen(); const fields = [ await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])), await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])), await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])), await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])), ]; const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "configureMdtodoHwpodSource", dialog, fields, save, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function probeMdtodoSource(command) { ensureProjectManagementCommand("probeMdtodoSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); await ensureMdtodoSourceConfigOpen(); const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function reindexMdtodoSource(command) { ensureProjectManagementCommand("reindexMdtodoSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); await ensureMdtodoSourceConfigOpen(); const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first(); const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex"; const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function selectTaskIfCommandTargetsOne(command) { if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null; return selectMdtodoTask(command); } async function saveMdtodoTaskWithButton(command, type, testId, fields) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const inlineEditors = []; for (const field of fields) { if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId)); if (field.kind === "fill") await fillMdtodoField(field.testId, field.value); if (field.kind === "select") { const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first(); await locator.waitFor({ state: "visible", timeout: 10000 }); await selectHtmlOptionByValueOrLabel(locator, field.value); } } const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) { const candidates = (testIds || []).filter(Boolean); for (const testId of candidates) { const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern); } if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid"); return clickProjectButtonAndMaybeWait(candidates[0], pathPattern); } async function ensureMdtodoInlineEditor(editorTestId, readTestId) { const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first(); if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false }; const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first(); await read.waitFor({ state: "visible", timeout: 10000 }); await read.dblclick(); await editor.waitFor({ state: "visible", timeout: 10000 }); return { editorTestId, readTestId, opened: true }; } async function editMdtodoTaskTitle(command) { const title = commandValue(command, ["title", "text", "value"]); if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); } async function editMdtodoTaskBody(command) { const body = commandValue(command, ["text", "body", "value"]); if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); } async function toggleMdtodoTaskStatus(command) { const status = commandValue(command, ["status", "value", "text"]); if (!status) throw new Error("toggleMdtodoTaskStatus requires --status"); return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); } async function editMdtodoTaskInline(command) { const field = commandValue(command, ["field", "value"]).toLowerCase(); if (field === "title") { const title = commandValue(command, ["title", "text"]); if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); } if (field === "body" || field === "content") { const body = commandValue(command, ["body", "text"]); if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); } if (field === "status") { const status = commandValue(command, ["status", "text"]); if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); } throw new Error("editMdtodoTaskInline requires --field title, body, or status"); } async function openMdtodoReportPreview(command) { ensureProjectManagementCommand("openMdtodoReportPreview"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const linkText = commandValue(command, ["link", "value", "text"]); const links = page.locator('[data-testid="mdtodo-report-link"]'); await links.first().waitFor({ state: "visible", timeout: 15000 }); const count = await links.count(); let index = 0; if (linkText) { for (let i = 0; i < count; i += 1) { const item = links.nth(i); const text = await item.textContent().catch(() => ""); if (String(text || "").includes(linkText)) { index = i; break; } } } const link = links.nth(index); const linkStateRaw = await link.evaluate((element, selectedIndex) => ({ index: selectedIndex, disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240), valuesRedacted: true }), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true })); const linkState = { ...linkStateRaw, textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null, textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null, text: undefined, valuesRedacted: true }; if (linkState.disabled === true) { const error = new Error("openMdtodoReportPreview selected report link is disabled"); error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true }; throw error; } const responsePromise = page.waitForResponse((response) => { try { const request = response.request(); return request.method().toUpperCase() === "GET" && new URL(response.url()).pathname === "/v1/project-management/mdtodo/report-preview"; } catch { return false; } }, { timeout: 30000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })); await link.scrollIntoViewIfNeeded().catch(() => null); await link.click(); const response = await responsePromise; await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null); return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function toggleMdtodoReportFullscreen(command) { ensureProjectManagementCommand("toggleMdtodoReportFullscreen"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const desired = commandValue(command, ["value", "text"]).toLowerCase(); const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first(); const initiallyOpen = await visibleLocator(dialog); let action = "open"; if (desired === "close" || desired === "off") action = "close"; else if (desired === "toggle") action = initiallyOpen ? "close" : "open"; if (action === "close") { const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first(); await closeButton.waitFor({ state: "visible", timeout: 10000 }); await closeButton.click(); } else if (!initiallyOpen) { const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first(); await button.waitFor({ state: "visible", timeout: 10000 }); await button.click(); } await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function fillNewTaskDraft(command) { const title = commandValue(command, ["title", "text", "value"]); if (!title) throw new Error(command.type + " requires --title or --text"); const fields = [await fillMdtodoField("mdtodo-new-title", title)]; const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : ""); if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body)); return fields; } async function addMdtodoTaskWithButton(command, type, testId) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command); const fields = await fillNewTaskDraft(command); const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function addMdtodoRootTask(command) { return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root"); } async function addMdtodoSubTask(command) { return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask"); } async function continueMdtodoTask(command) { return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task"); } async function deleteMdtodoTask(command) { ensureProjectManagementCommand("deleteMdtodoTask"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null); let confirmClick = null; const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()); if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function launchWorkbenchFromTask(command) { const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask"; ensureProjectManagementCommand(commandType); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot({ includeRaw: true }); const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null; const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null; if ((requestedTaskRef && beforeProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId) { await selectMdtodoTask({ ...command, taskRef: requestedTaskRef }); } const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true }); const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", textHash: element.textContent ? null : null, testId: element.getAttribute("data-testid") || null, action: element.getAttribute("data-action") || null, valuesRedacted: true })).catch((error) => ({ disabled: null, error: errorSummary(error), valuesRedacted: true })); if (buttonState.disabled === true) { const error = new Error("launchWorkbenchFromTask button is disabled"); error.details = { beforeUrl, project: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, valuesRedacted: true }; throw error; } const launchPath = projectManagement.launchRoute; const launchResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return new URL(response.url()).pathname === launchPath; } catch { return false; } }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); const chatResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return new URL(response.url()).pathname === "/v1/agent/chat"; } catch { return false; } }, { timeout: 30000 }).then(async (response) => { let chatPayload = null; let chatPayloadError = null; try { chatPayload = await response.json(); } catch (error) { chatPayloadError = errorSummary(error); } const headers = response.headers(); return { observed: true, status: response.status(), statusText: response.statusText(), path: new URL(response.url()).pathname, sessionId: sessionIdFromAgentSessionPayload(chatPayload), traceId: traceIdFromAgentChatPayload(chatPayload), otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null, responseParsed: chatPayload !== null, responseParseError: chatPayloadError, valuesRedacted: true }; }).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true })); await button.click(); const launchResponse = await launchResponsePromise; if (launchResponse?.waitError) { const error = new Error("launchWorkbenchFromTask did not observe POST " + launchPath + " response after button click"); error.details = { beforeUrl, afterUrl: currentPageUrl(), launchPath, project: sanitizeProjectCommandSnapshot(projectBeforeClick), waitError: launchResponse.waitError, valuesRedacted: true }; throw error; } const launchStatus = launchResponse.status(); const headers = launchResponse.headers(); const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null; let payload = null; let responseParseError = null; try { payload = await launchResponse.json(); } catch (error) { responseParseError = errorSummary(error); } const sessionId = sessionIdFromAgentSessionPayload(payload); const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || ""); const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null; if (launchStatus < 200 || launchStatus >= 300 || !sessionId) { const error = new Error("launchWorkbenchFromTask did not receive a successful authoritative Workbench session"); error.details = { beforeUrl, afterUrl: currentPageUrl(), launchStatus, statusText: launchResponse.statusText(), contractVersion, responseParsed: payload !== null, responseParseError, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, valuesRedacted: true }; throw error; } if (workbenchUrl) { await page.waitForFunction((expectedPath) => window.location.pathname === expectedPath, workbenchUrl, { timeout: 20000 }).catch(() => null); } const chat = await chatResponsePromise; await page.waitForTimeout(1500); const workbenchSnapshot = await workbenchSessionSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), launchPath, launchStatus, statusText: launchResponse.statusText(), contractVersion, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, chatObserved: chat?.observed === true, chatStatus: chat?.status ?? null, chatSessionId: chat?.sessionId ?? null, chatTraceId: chat?.traceId ?? null, chatOtelTraceId: chat?.otelTraceId ?? null, chat, workbenchSnapshot, selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw), projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, responseParsed: payload !== null, responseParseError, pageId, valuesRedacted: true }; } async function launchWorkbenchFromMdtodo(command) { return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" }); } async function projectManagementCommandSnapshot(options = {}) { const raw = await page.evaluate(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const text = (element) => String(element?.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240); const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected'); const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected'); const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected'); const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ value: option.value, label: text(option), selected: option.selected === true })) : []; const selectedFileOption = fileOptions.find((option) => option.selected) || null; const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); return { path: window.location.pathname, pageKind: visible(document.querySelector('[data-testid="project-management-mdtodo"]')) ? "project-management-mdtodo" : visible(document.querySelector('[data-testid="project-management-root"]')) ? "project-management-root" : null, sourceCount: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount), fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount), taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length, selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null, selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null, selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null, fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20), selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null, selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null, selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, sourceSelectVisible: visible(sourceSelect), fileSelectVisible: visible(fileSelect), sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), taskBodyVisible: visible(bodyRendered), taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "", newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), reportLinkCount: reportLinks.length, reportPreviewVisible: visible(reportPreview), reportPreviewText: visible(reportPreview) ? text(reportPreview) : "", reportFullscreenVisible: visible(reportFullscreen), launchButtonVisible: visible(launch), launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", launchButtonText: text(launch), blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8), workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length, valuesRedacted: true }; }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); if (options.includeRaw === true) return raw; return sanitizeProjectCommandSnapshot(raw); } function sanitizeProjectCommandSnapshot(value) { if (!value || typeof value !== "object") return value; const textDigest = (raw, limit = 160) => { const text = String(raw || ""); return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true }; }; const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); const suspiciousFileLabel = (label) => { const text = String(label || "").trim(); return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); }; const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; return { ...value, selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw), selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw), selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw), selectedSourceIdRaw: undefined, selectedFileRefRaw: undefined, selectedTaskRefRaw: undefined, selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)), fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, fileOptionLabels: undefined, taskBodyText: undefined, taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, reportPreviewText: undefined, reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null, launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null, launchButtonText: undefined, blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [], valuesRedacted: true }; } function opaqueIdSummary(value) { const text = String(value || ""); if (!text) return null; return { hash: sha256Text(text), preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5), bytes: Buffer.byteLength(text), valuesRedacted: true }; } async function preflightSummary() { return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) }; } async function samplePage(reason, options = {}) { if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason); const groupSeq = sampleSeq + 1; if (page && !page.isClosed()) await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }); if (observerPage && !observerPage.isClosed()) { await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) }))); } if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) { await captureScreenshot("checkpoint", "jpeg").catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) }))); } await writeHeartbeat({ status: terminalStatus }); } async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) { sampleSeq += 1; const dom = await targetPage.evaluate((input) => { const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit); const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const textHashInput = (element) => trim(element.textContent || "", 800); const loadingTextPattern = /加载中|\bLoading\b/iu; const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu; const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]"; const elementTextForLoading = (element) => [ element.textContent || "", element.getAttribute("aria-label") || "", element.getAttribute("title") || "", element.getAttribute("data-testid") || "" ].map((value) => trim(value, 240)).filter(Boolean).join(" "); const elementLooksLikeLoadingUi = (element) => { const signal = [ element.getAttribute("class") || "", element.getAttribute("data-testid") || "", element.getAttribute("role") || "", element.getAttribute("aria-busy") || "", element.getAttribute("aria-label") || "", element.getAttribute("title") || "" ].join(" "); return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal); }; const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element); const hasLoadingText = (element) => { const text = elementTextForLoading(element); if (!loadingTextPattern.test(text)) return false; if (elementIsCodeLike(element)) return false; return true; }; const elementDescriptor = (element) => { if (!element) return null; const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" "); const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]"); return { tag: element.tagName.toLowerCase(), testId: element.getAttribute("data-testid") || null, role: element.getAttribute("role") || null, id: element.getAttribute("id") || null, className: className || null, status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null, traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null, ariaLabel: element.getAttribute("aria-label") || null }; }; const ownerKindFor = (element) => { const value = [ element.getAttribute("data-testid") || "", element.getAttribute("class") || "", element.getAttribute("role") || "", element.tagName || "" ].join(" ").toLowerCase(); if (/message|turn|agent|assistant/.test(value)) return "turn"; if (/session|rail|sidebar|nav/.test(value)) return "session-nav"; if (/composer|prompt|input|textarea/.test(value)) return "composer"; if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic"; if (/trace|event|terminal|log/.test(value)) return "trace"; if (/performance|metric|chart|table/.test(value)) return "performance"; if (/main|workspace|workbench|root/.test(value)) return "workbench"; return "unknown"; }; const ownerLabelFor = (element) => { const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]"); return trim( element.getAttribute("aria-label") || element.getAttribute("data-testid") || (heading ? heading.textContent || "" : "") || element.getAttribute("class") || element.tagName, 160 ); }; const ownerKeyFor = (element) => { const descriptor = elementDescriptor(element) || {}; return [ ownerKindFor(element), descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown", descriptor.sessionId || descriptor.messageId || descriptor.traceId || "" ].filter(Boolean).join(":").slice(0, 240); }; const collectLoadingNodes = () => { const candidates = Array.from(document.querySelectorAll("body *")) .filter(visible) .filter(hasLoadingText) .filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child))) .slice(-80); return candidates.map((element, index) => { const rect = element.getBoundingClientRect(); const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]'); const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]'); const owner = identityOwner || structuralOwner || element; const ownerDescriptor = elementDescriptor(owner); return { index, tag: element.tagName.toLowerCase(), className: String(element.className || "").slice(0, 180), testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), text: trim(elementTextForLoading(element), 240), ownerKind: ownerKindFor(owner), ownerKey: ownerKeyFor(owner), ownerLabel: ownerLabelFor(owner), owner: ownerDescriptor, ownerText: trim(owner.textContent || "", 300), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }); }; const sessionIdForElement = (element) => { const direct = element.getAttribute("data-session-id"); if (direct) return direct; const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; if (!href) return null; try { const parsed = new URL(href, location.href); const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); return match ? decodeURIComponent(match[1] || "") : null; } catch { const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); return match ? decodeURIComponent(match[1] || "") : null; } }; const sessionTitleTextForElement = (element) => { const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]"); return trim( (titleNode ? titleNode.textContent || "" : "") || element.getAttribute("aria-label") || element.getAttribute("title") || element.textContent || "", 240 ); }; const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu; const looksLikeSessionTitleFallback = (title, sessionId) => { const text = trim(title, 240); const id = String(sessionId || "").trim(); if (!text) return true; if (sessionTitleFallbackPattern.test(text)) return true; if (!id) return false; return text === id || text.startsWith(id) || text.startsWith("Session " + id); }; const collectSessionRailTitles = () => { const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']")) .filter(visible); const seen = new Set(); const items = []; for (const element of candidates) { const sessionId = sessionIdForElement(element); const titleText = sessionTitleTextForElement(element); const key = (sessionId || "") + "|" + titleText; if (seen.has(key)) continue; seen.add(key); const rect = element.getBoundingClientRect(); const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId); items.push({ index: items.length, tag: element.tagName.toLowerCase(), testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true", sessionId, sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null, titleText, fallbackTitle, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }); } const visibleItems = items.slice(0, 120); const fallbackItems = visibleItems.filter((item) => item.fallbackTitle); const visibleCount = visibleItems.length; const fallbackTitleCount = fallbackItems.length; return { visibleCount, fallbackTitleCount, fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0, items: visibleItems.slice(0, 60), fallbackItems: fallbackItems.slice(0, 12), }; }; const diagnosticSummaryText = (element) => { const summarySelectors = [ ".api-error-diagnostic-summary-text p", ".api-error-diagnostic-summary-text", "[class*='diagnostic-summary-text' i] p", "[class*='diagnostic-summary-text' i]", "[data-testid*='diagnostic-summary' i]", "[data-testid*='error-summary' i]", "[role='alert'] p", "[role='alert']" ]; const parts = []; for (const selector of summarySelectors) { for (const candidate of Array.from(element.querySelectorAll(selector))) { if (!visible(candidate)) continue; const text = trim(candidate.textContent || "", 800); if (text && !parts.includes(text)) parts.push(text); } } const ownText = textHashInput(element); const text = parts.length > 0 ? parts.join(" ") : ownText; return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim(); }; const diagnosticToggleOnly = (element, text) => { const compact = String(text || "").trim(); if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false; const tag = element.tagName.toLowerCase(); const role = element.getAttribute("role"); const aria = element.getAttribute("aria-label") || ""; const title = element.getAttribute("title") || ""; return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title); }; const stableMessageText = (element) => { const bodySelectors = [ ".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]" ]; const parts = []; for (const selector of bodySelectors) { for (const candidate of Array.from(element.querySelectorAll(selector))) { if (!visible(candidate)) continue; const text = trim(candidate.textContent || "", 1200); if (text && !parts.includes(text)) parts.push(text); } } if (parts.length > 0) return parts.join(" "); const clone = element.cloneNode(true); for (const selector of [ ".message-duration-meta", ".message-activity-meta", ".api-error-diagnostic", "[class*='diagnostic' i]", "[class*='trace' i]", "[data-trace-id]", "[data-testid*='trace' i]", "[data-testid*='event' i]", "[role='status']", "[role='alert']", "button" ]) { for (const child of Array.from(clone.querySelectorAll(selector))) child.remove(); } return trim(clone.textContent || "", 1200); }; const numericAttr = (element, names) => { for (const name of names) { const raw = element.getAttribute(name); const value = Number(raw); if (Number.isFinite(value)) return value; } return null; }; const textAttr = (element, names) => { for (const name of names) { const raw = element.getAttribute(name); if (raw && String(raw).trim()) return String(raw).trim(); } return null; }; const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => { const rect = element.getBoundingClientRect(); const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]'); const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]"); return { index, tag: element.tagName.toLowerCase(), testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null, traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null, turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null, projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]), sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]), eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]), eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null), eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null, eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null, text: textHashInput(element), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }); const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit); const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { const rect = element.getBoundingClientRect(); return { index, tag: element.tagName.toLowerCase(), testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), dataRole: element.getAttribute("data-role"), status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, sessionId: element.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, traceId: element.getAttribute("data-trace-id") || null, turnId: element.getAttribute("data-turn-id") || null, durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120), activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120), text: stableMessageText(element), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }); const resourceTimingSample = (entry) => ({ name: entry.name.split(/[?#]/u)[0].slice(0, 240), initiatorType: entry.initiatorType, startTime: Math.round(entry.startTime), duration: Math.round(entry.duration), workerStart: Math.round(entry.workerStart || 0), redirectStart: Math.round(entry.redirectStart || 0), redirectEnd: Math.round(entry.redirectEnd || 0), fetchStart: Math.round(entry.fetchStart || 0), domainLookupStart: Math.round(entry.domainLookupStart || 0), domainLookupEnd: Math.round(entry.domainLookupEnd || 0), connectStart: Math.round(entry.connectStart || 0), connectEnd: Math.round(entry.connectEnd || 0), secureConnectionStart: Math.round(entry.secureConnectionStart || 0), requestStart: Math.round(entry.requestStart || 0), responseStart: Math.round(entry.responseStart || 0), responseEnd: Math.round(entry.responseEnd || 0), transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null, encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null, decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null, nextHopProtocol: entry.nextHopProtocol || null, responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null, serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({ name: String(item.name || "").slice(0, 80), duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null, description: String(item.description || "").slice(0, 120) })), }); const opaqueDomId = (value) => String(value || "").trim(); const collectProjectManagement = () => { const config = input?.projectManagement || {}; const targetPaths = Array.isArray(config.targetPaths) ? config.targetPaths : []; const path = location.pathname; const configuredPath = targetPaths.some((target) => path === target || path.startsWith(String(target) + "/")); const root = document.querySelector('[data-testid="project-management-root"]'); const mdtodoRoot = document.querySelector('[data-testid="project-management-mdtodo"]'); const rootVisible = visible(root); const mdtodoVisible = visible(mdtodoRoot); if (!configuredPath && !rootVisible && !mdtodoVisible) return null; const sourceItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]')).filter(visible); const fileItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]')).filter(visible); const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ value: option.value, label: trim(option.textContent || option.label || "", 180), selected: option.selected === true, })) : []; const selectedFileOption = fileOptions.find((option) => option.selected) || null; const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible); const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible); const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected'); const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected'); const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected'); const statusCounts = {}; for (const task of taskItems) { const status = task.getAttribute("data-task-status") || "unknown"; statusCounts[status] = (statusCounts[status] || 0) + 1; } const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({ index, testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), text: trim(element.textContent || "", 260), })).filter((item) => item.text); const workbenchLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible); return { pageKind: mdtodoVisible || path.startsWith("/projects/mdtodo") ? "project-management-mdtodo" : rootVisible || path === "/projects" || path.startsWith("/projects/") ? "project-management-root" : "project-management-unknown", configuredPath, rootVisible, mdtodoVisible, sourceCount: Math.max(sourceItems.length, sourceOptionCount), fileCount: Math.max(fileItems.length, fileOptionCount), taskCount: taskItems.length, taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length), selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value), selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value), selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null, fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24), selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")), selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, sourceSelectVisible: visible(sourceSelect), fileSelectVisible: visible(fileSelect), sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), taskBodyVisible: visible(bodyRendered), taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "", newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), taskStatusCounts: statusCounts, reportLinkCount: reportLinks.length, reportPreviewVisible: visible(reportPreview), reportPreviewText: visible(reportPreview) ? trim(reportPreview.textContent || "", 500) : "", reportFullscreenVisible: visible(reportFullscreen), launchButtonVisible: visible(launch), launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", launchButtonText: trim(launch?.textContent || "", 120), blockerCount: blockers.length, blockers, workbenchLinkCount: workbenchLinks.length, valuesRedacted: true, }; }; const url = location.href; const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u); const activeSession = document.querySelector('[data-active="true"][data-session-id], [aria-selected="true"][data-session-id], .active[data-session-id]'); const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null; const commandInput = document.querySelector("#command-input"); const commandSubmit = document.querySelector('#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'); const composerWarning = document.querySelector(".composer-warning"); const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]'; const stableTraceSelector = 'li.trace-render-row[data-row-id], li.trace-render-row[data-testid="trace-render-row"], [data-testid="trace-render-row"][data-row-id]'; const fallbackTraceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]'; const messages = summarizeMessages(messageSelector, 80); const stableTraceElements = Array.from(document.querySelectorAll(stableTraceSelector)); const fallbackTraceElements = Array.from(document.querySelectorAll(fallbackTraceSelector)).filter((element) => element.matches('li,[role="listitem"],[data-testid*="trace-row" i],[data-testid*="event-row" i]')); const traceRows = summarizeElements(stableTraceElements.length > 0 ? stableTraceElements : fallbackTraceElements, 30); const loadings = collectLoadingNodes(); const sessionRail = collectSessionRailTitles(); const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => { const rect = element.getBoundingClientRect(); const text = diagnosticSummaryText(element); if (!text || diagnosticToggleOnly(element, text)) return null; const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu); const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu); const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu); const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu); const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu); const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic"; return { index, tag: element.tagName.toLowerCase(), className: String(element.className || "").slice(0, 240), testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), compact: element.getAttribute("data-compact"), expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"), title: element.getAttribute("title"), diagnosticCode, traceId: traceMatch?.[1] || null, httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null, idleSeconds: idleMatch ? Number(idleMatch[1]) : null, waitingFor: waitingForMatch?.[1] || null, lastEventLabel: lastEventLabelMatch?.[1] || null, text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }).filter(Boolean); const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { const rect = element.getBoundingClientRect(); const text = textHashInput(element); const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]"); const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u); const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120); const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120); const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null; return { index, role: element.getAttribute("data-role") || "agent", status: element.getAttribute("data-status") || null, sessionId: element.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, traceId: directTraceId, turnId: element.getAttribute("data-turn-id") || directTraceId, durationText, activityText, text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }).slice(-80); const active = document.activeElement; return { url, path: location.pathname, routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null, activeSessionId, title: document.title, focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null, viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }, scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) }, messages, traceRows, loadings, sessionRail, diagnostics, turns, composer: { inputPresent: visible(commandInput), inputDisabled: Boolean(commandInput?.disabled) || commandInput?.getAttribute("aria-disabled") === "true", warningPresent: visible(composerWarning), warningText: trim(composerWarning?.textContent || "", 160), submitPresent: visible(commandSubmit), submitDisabled: Boolean(commandSubmit?.disabled) || commandSubmit?.getAttribute("aria-disabled") === "true", submitAction: commandSubmit?.getAttribute("data-action") || null, submitText: trim(commandSubmit?.textContent || "", 80), submitTestId: commandSubmit?.getAttribute("data-testid") || null, activeStatus: activeSession?.getAttribute("data-status") || null, valuesRedacted: true }, projectManagement: collectProjectManagement(), pageProvenance: { url: location.href, path: location.pathname, title: document.title, readyState: document.readyState, timeOrigin: Math.round(performance.timeOrigin || 0), navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null, scripts: Array.from(document.scripts).map((element) => { if (!element.src) return null; try { const url = new URL(element.src, location.href); const keys = Array.from(url.searchParams.keys()).sort(); return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); } catch { return null; } }).filter(Boolean).sort(), stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => { try { const url = new URL(element.href, location.href); const keys = Array.from(url.searchParams.keys()).sort(); return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : ""); } catch { return null; } }).filter(Boolean).sort(), meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({ key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120), content: String(element.getAttribute("content") || "").slice(0, 200), })).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)), }, performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample), }; }, { projectManagement }).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) })); const sample = { seq: sampleSeq, sampleGroupSeq: groupSeq, ts: new Date().toISOString(), reason, pageRole, pageId: targetPageId, pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0, commandId: activeCommandId, observerInitiated: false, ...digestDom(dom, pageRole), }; await appendJsonl(files.samples, sample); } function digestDom(dom, pageRole = "control") { if (dom && dom.error) return dom; const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : []; const sessionRail = digestSessionRail(dom.sessionRail); const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : []; const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : []; const projectManagementSample = digestProjectManagement(dom.projectManagement); const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq }); if (pageRole === "control") currentPageProvenance = pageProvenance; return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, projectManagement: projectManagementSample, pageProvenance: compactPageProvenance(pageProvenance) }; } function digestProjectManagement(value) { if (!value || typeof value !== "object") return null; const opaque = (raw) => { const text = String(raw || ""); if (!text) return null; return { hash: sha256Text(text), preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5), bytes: Buffer.byteLength(text), valuesRedacted: true }; }; const textDigest = (raw, limit = 160) => { const text = String(raw || ""); return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true }; }; const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); const suspiciousFileLabel = (label) => { const text = String(label || "").trim(); return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); }; const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; return { pageKind: value.pageKind ?? null, configuredPath: value.configuredPath === true, rootVisible: value.rootVisible === true, mdtodoVisible: value.mdtodoVisible === true, sourceCount: Number.isFinite(Number(value.sourceCount)) ? Number(value.sourceCount) : 0, fileCount: Number.isFinite(Number(value.fileCount)) ? Number(value.fileCount) : 0, taskCount: Number.isFinite(Number(value.taskCount)) ? Number(value.taskCount) : 0, taskRefMissingCount: Number.isFinite(Number(value.taskRefMissingCount)) ? Number(value.taskRefMissingCount) : 0, selectedSourceId: opaque(value.selectedSourceId), selectedFileRef: opaque(value.selectedFileRef), selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, fileOptionLabelSamples: fileLabels.slice(0, 10).map((item) => textDigest(item, 120)), fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, selectedTaskRef: opaque(value.selectedTaskRef), selectedTaskStatus: value.selectedTaskStatus ?? null, sourceSelectVisible: value.sourceSelectVisible === true, fileSelectVisible: value.fileSelectVisible === true, sourceConfigVisible: value.sourceConfigVisible === true, taskEditorVisible: value.taskEditorVisible === true, taskBodyVisible: value.taskBodyVisible === true, taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, newTaskDraftVisible: value.newTaskDraftVisible === true, taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {}, reportLinkCount: Number.isFinite(Number(value.reportLinkCount)) ? Number(value.reportLinkCount) : 0, reportPreviewVisible: value.reportPreviewVisible === true, reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, reportFullscreenVisible: value.reportFullscreenVisible === true, launchButtonVisible: value.launchButtonVisible === true, launchButtonEnabled: value.launchButtonEnabled === true, launchButtonText: value.launchButtonText ? textDigest(value.launchButtonText, 120) : null, blockerCount: Number.isFinite(Number(value.blockerCount)) ? Number(value.blockerCount) : 0, blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 12).map((item) => ({ index: item?.index ?? null, testId: item?.testId ?? null, role: item?.role ?? null, ...textDigest(item?.text || "", 160), })) : [], workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0, valuesRedacted: true }; } function digestSessionRail(value) { if (!value || typeof value !== "object") return null; const items = Array.isArray(value.items) ? value.items.map((item) => { const titleText = String(item?.titleText || item?.titlePreview || ""); return { index: item?.index ?? null, tag: item?.tag ?? null, testId: item?.testId ?? null, role: item?.role ?? null, active: item?.active === true, sessionId: item?.sessionId ?? null, sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), fallbackTitle: item?.fallbackTitle === true, titleHash: sha256Text(titleText), titlePreview: truncate(titleText, 160), titleBytes: Buffer.byteLength(titleText), rect: item?.rect ?? null, }; }) : []; const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12); const visibleCount = Number(value.visibleCount ?? items.length); const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length); return { visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length, fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length, fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0), items, fallbackItems, valuesRedacted: true, }; } async function captureScreenshot(reason, imageType = "png") { if (!page || page.isClosed()) throw new Error("page is not available for screenshot"); artifactSeq += 1; const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual"; const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png"; const ext = type === "jpeg" ? "jpg" : "png"; const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext); const options = type === "jpeg" ? { path: file, type: "jpeg", quality: 70, fullPage: false } : { path: file, type: "png", fullPage: false }; await page.screenshot(options); const meta = await fileMeta(file); const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl() }; await appendJsonl(files.artifacts, artifact); lastScreenshotAtMs = Date.now(); return artifact; } function eventRecord(type, data) { const clean = sanitize(data) || {}; return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean }; } function controlRecord(command, phase, detail) { return { ts: new Date().toISOString(), seq: commandSeq, phase, commandId: command.id, type: command.type, source: command.source || "file", input: commandInputSummary(command), beforeUrl: command.beforeUrl || null, afterUrl: currentPageUrl(), pageId, detail: sanitize(detail), }; } function commandInputSummary(command) { const text = typeof command.text === "string" ? command.text : null; const opaque = (value) => { const raw = typeof value === "string" ? value : null; if (!raw) return null; return { hash: sha256Text(raw), preview: raw.length <= 18 ? raw : raw.slice(0, 10) + "..." + raw.slice(-5), bytes: Buffer.byteLength(raw), valuesRedacted: true }; }; return { type: command.type, path: command.path || null, url: command.url ? safeUrl(command.url) : null, sessionId: command.sessionId || command.value || null, provider: command.provider || null, afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null, severity: command.severity || null, alternateSessionStrategy: command.alternateSessionStrategy || null, expectedSentinelRange: command.expectedSentinelRange || null, requireComposerReady: command.requireComposerReady === true, findingId: command.findingId || null, blocking: command.blocking === true ? true : command.blocking === false ? false : null, sourceId: opaque(command.sourceId), fileRef: opaque(command.fileRef), filename: command.filename ? truncate(command.filename, 200) : null, taskRef: opaque(command.taskRef), taskId: command.taskId || null, field: command.field || null, link: command.link ? truncate(command.link, 200) : null, titleHash: command.title ? sha256Text(command.title) : null, titleBytes: command.title ? Buffer.byteLength(command.title) : null, bodyHash: command.body ? sha256Text(command.body) : null, bodyBytes: command.body ? Buffer.byteLength(command.body) : null, status: command.status || null, hwpodId: opaque(command.hwpodId), nodeId: opaque(command.nodeId), workspaceRoot: opaque(command.workspaceRoot), root: opaque(command.root), label: command.label ? truncate(command.label, 200) : null, textHash: text === null ? null : sha256Text(text), textBytes: text === null ? null : Buffer.byteLength(text), textPreview: null, valuesRedacted: true, }; } async function appendJsonl(file, value) { await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 }); } async function fileMeta(file) { const [buffer, stats] = await Promise.all([readFile(file), stat(file)]); return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") }; } function currentPageUrl() { return pageUrl(page); } function pageUrl(targetPage) { try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; } } function routeSessionIdFromUrl(value) { try { const pathname = new URL(String(value || ""), baseUrl).pathname; const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u); return match ? decodeURIComponent(match[1] || "") : null; } catch { return null; } } function safeFrameUrl(frame) { try { return frame ? safeUrl(frame.url()) : null; } catch { return null; } } function safeUrl(value) { try { const url = new URL(String(value), baseUrl); for (const key of Array.from(url.searchParams.keys())) { if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]"); } return url.toString(); } catch { return truncate(String(value || ""), 300); } } function safeUrlPath(value) { try { return new URL(String(value || ""), baseUrl).pathname; } catch { return null; } } function normalizeBaseUrl(value) { const raw = value || "http://127.0.0.1:3000"; const url = new URL(raw); return url.origin; } function parseViewport(value) { const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u); return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 }; } function positiveInteger(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback; } function positiveNumber(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function requiredPositiveThreshold(raw, key) { const parsed = Number(raw?.[key]); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds"); } return parsed; } function parseAlertThresholds(value) { if (!value) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane"); } const raw = (() => { try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } })(); const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio"); if (sessionRailFallbackRatio > 1) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1"); } return { sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"), partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"), longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"), visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"), turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"), turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"), uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"), scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"), scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), sessionRailFallbackRatio, crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), source: "yaml-env", }; } function parseProjectManagementConfig(value) { if (!value || value === "null") { return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true }; } const raw = (() => { try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); } })(); const stringList = (key) => { const list = raw?.[key]; if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement"); return list; }; const positive = (key) => { const numeric = Number(raw?.[key]); if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement"); return numeric; }; if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled"); if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true }; const targetPaths = stringList("targetPaths"); const readinessSelectors = stringList("readinessSelectors"); const naturalApiPathPrefixes = stringList("naturalApiPathPrefixes"); const commandAllowlist = stringList("commandAllowlist"); const launchRoute = String(raw.launchRoute || ""); if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path"); return { enabled: true, targetPaths, readinessSelectors, naturalApiPathPrefixes, commandAllowlist, launchRoute, slowApiBudgetMs: positive("slowApiBudgetMs"), source: "yaml-env", valuesRedacted: true }; } function sha256Text(value) { return "sha256:" + createHash("sha256").update(String(value)).digest("hex"); } function safeId(value) { return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item"; } function cssEscape(value) { return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\""); } function truncate(value, limit) { const text = String(value || ""); return text.length > limit ? text.slice(0, limit) + "..." : text; } function sanitize(value) { if (value === null || value === undefined) return value; if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]"); if (typeof value === "number" || typeof value === "boolean") return value; if (Array.isArray(value)) return value.map(sanitize); if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)])); return String(value); } function errorSummary(error) { const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null }; if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth); if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5)); if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness); if (error && error.details) summary.details = sanitize(error.details); if (error && error.target) summary.target = sanitize(error.target); return summary; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); } `; }