Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-runner-source.ts
T
2026-06-28 09:44:19 +00:00

4772 lines
244 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20);
const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000);
const authLoginInitialDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_INITIAL_DELAY_MS, 500, 0, 60000);
const authLoginMaxDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_DELAY_MS, 10000, 0, 120000);
const navigationMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_NAVIGATION_MAX_ATTEMPTS, 4, 1, 10);
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;
let heartbeatPulseTimer = 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" });
heartbeatPulseTimer = startHeartbeatPulse();
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));
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 (heartbeatPulseTimer) clearInterval(heartbeatPulseTimer);
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),
navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true },
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 startHeartbeatPulse() {
const timer = setInterval(() => {
if (stopping) return;
void writeHeartbeat({ status: terminalStatus, heartbeatPulse: true })
.catch((error) => appendJsonl(files.errors, eventRecord("heartbeat-pulse-error", { error: errorSummary(error) })));
}, 5000);
if (timer && typeof timer.unref === "function") timer.unref();
return timer;
}
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();
const base = {
pageRole,
pageId: targetPageId,
sampleSeq,
observerInitiated: false,
commandId: activeCommandId,
method: request.method(),
url: safeUrl(response.url()),
resourceType: request.resourceType(),
status: response.status(),
statusText: response.statusText(),
fromServiceWorker: response.fromServiceWorker(),
};
void (async () => {
const bodyFields = await summarizeWorkbenchResponseBody(response, request);
await appendJsonl(files.network, eventRecord("response", { ...base, ...bodyFields }));
})().catch((error) => appendJsonl(files.errors, eventRecord("response-body-summary-error", {
pageRole,
pageId: targetPageId,
sampleSeq,
commandId: activeCommandId,
method: request.method(),
url: safeUrl(response.url()),
error: errorSummary(error),
valuesRedacted: true
})));
});
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);
const heartbeatIntervalMs = Math.min(5000, intervalMs);
let stopped = false;
let timer = null;
let heartbeatTimer = null;
let inFlight = false;
const heartbeat = () => {
if (stopped) return;
void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true })
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })));
};
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();
});
};
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
schedule();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
};
}
async function processCommand(command) {
commandSeq += 1;
activeCommandId = command.id;
await writeHeartbeat({ status: "running", activeCommandId });
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context);
case "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,
expectedActionWaitMs: command.expectedActionWaitMs,
}), "sendPrompt");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "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 captureCommandScreenshot(command);
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 navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000;
const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000;
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 15000;
const shortCircuitReadinessTimeoutMs = Number.isFinite(Number(options.shortCircuitReadinessTimeoutMs)) ? Math.max(1, Number(options.shortCircuitReadinessTimeoutMs)) : 1000;
const shortCircuitHydrationTimeoutMs = Number.isFinite(Number(options.shortCircuitHydrationTimeoutMs)) ? Math.max(1, Number(options.shortCircuitHydrationTimeoutMs)) : 1000;
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);
const attempts = [];
if (sessionId && beforeSessionId === sessionId && !forceRefresh) {
const current = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: shortCircuitReadinessTimeoutMs, hydrationTimeoutMs: shortCircuitHydrationTimeoutMs });
if (current.ok) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, readiness: current.readiness, hydration: current.hydration, valuesRedacted: true };
attempts.push({ attempt: 0, ok: false, shortCircuitRejected: true, failureKind: current.failureKind, readiness: current.readiness, hydration: current.hydration, beforeUrl, afterUrl: pageUrl(observerPage), valuesRedacted: true });
}
const maxAttempts = Number.isFinite(Number(options?.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptBeforeUrl = pageUrl(observerPage);
observerPageEpoch += 1;
let status = null;
let statusText = null;
const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }).catch((error) => ({ observerGotoError: errorSummary(error) }));
if (response?.observerGotoError) {
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(response.observerGotoError?.message || response.observerGotoError?.name || "observer-navigation-error"), beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), error: response.observerGotoError, valuesRedacted: true });
if (attempt < maxAttempts && isRetryableNavigationError(response.observerGotoError?.message || response.observerGotoError?.name || "")) {
await recreateObserverPageForNavigation("observer-goto-error", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: false, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, attempts, 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: readinessTimeoutMs });
if (!readiness.ok) {
attempts.push({ attempt, ok: false, failureKind: readiness.reason || "observer-target-not-ready", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
if (attempt < maxAttempts && observerReadinessRetryable(readiness)) {
await recreateObserverPageForNavigation(readiness.reason || "observer-target-not-ready", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
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, attempts, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
}
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
lastObserverRefreshAtMs = Date.now();
attempts.push({ attempt, ok: true, beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
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, attempts, valuesRedacted: true };
}
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: hydrationTimeoutMs });
attempts.push({ attempt, ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, hydration, valuesRedacted: true });
if (hydration.ok === true) {
lastObserverRefreshAtMs = Date.now();
return { 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, attempts, failureKind: null, valuesRedacted: true };
}
if (attempt < maxAttempts && observerHydrationRetryable(hydration)) {
await recreateObserverPageForNavigation(hydration.reason || "observer-session-hydration-failed", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
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, attempts, failureKind: hydration.reason || "observer-session-hydration-failed", valuesRedacted: true };
}
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, attempts, failureKind: "observer-sync-retry-exhausted", valuesRedacted: true };
}
async function observerSessionReadiness(targetUrl, sessionId, options = {}) {
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: options.readinessTimeoutMs ?? 1000 });
if (!readiness.ok) return { ok: false, failureKind: readiness.reason || "observer-target-not-ready", readiness, hydration: null, valuesRedacted: true };
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) return { ok: true, readiness, hydration: null, valuesRedacted: true };
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: options.hydrationTimeoutMs ?? 1000 });
return { ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", readiness, hydration, valuesRedacted: true };
}
function observerReadinessRetryable(readiness) {
const reason = String(readiness?.reason || "");
const snapshot = readiness?.snapshot || {};
return /workbench-app-not-ready|observer-target-not-ready/iu.test(reason)
|| snapshot.workbenchShellVisible === false
|| snapshot.sessionRailPresent === false && snapshot.commandInputPresent === false;
}
function observerHydrationRetryable(hydration) {
const reason = String(hydration?.reason || "");
return /observer-session-hydration-timeout|observer-session-hydration-failed|active-session-not-hydrated|route-session-not-hydrated/iu.test(reason);
}
async function recreateObserverPageForNavigation(reason, attempt) {
const before = pageUrl(observerPage);
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
observerPage = await context.newPage();
attachPassiveListeners(observerPage, "observer", observerPageId);
await appendJsonl(files.control, eventRecord("observer-page-recreated", { reason, attempt, beforeUrl: before, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, 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) {
const loginUrl = new URL("/auth/login", baseUrl).toString();
const attempts = [];
const maxAttempts = authLoginMaxAttempts;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
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(browserContext, 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,
requestTimeoutMs: authLoginRequestTimeoutMs,
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,
requestTimeoutMs: authLoginRequestTimeoutMs,
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);
}
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(browserContext, loginUrl, credential = { username, password }) {
if (!browserContext?.request) throw new Error("auth browser context request is not ready");
const response = await browserContext.request.post(loginUrl, {
data: { username: credential.username, password: credential.password },
headers: { accept: "application/json", "content-type": "application/json" },
timeout: authLoginRequestTimeoutMs,
});
await response.text().catch(() => "");
return {
ok: response.ok(),
status: response.status(),
statusText: response.statusText() || "",
};
}
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 attempts = [];
let response = null;
let cookieState = null;
const maxAttempts = authLoginMaxAttempts;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
const retryLabel = attempt + "/" + maxAttempts;
try {
response = await pageAuthLogin(context, loginUrl, credential);
cookieState = await readAuthCookieState(context);
const retryable = isRetryableAuthStatus(response.status);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: response.status,
statusText: response.statusText,
retryable,
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
credentialSource: credential.source,
valuesRedacted: true,
});
if (response.ok && cookieState.cookiePresent) break;
if (!retryable) break;
} catch (error) {
const retryable = isRetryableAuthError(error);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: 0,
statusText: "request-error",
retryable,
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
cookiePresent: false,
cookieNames: [],
credentialSource: credential.source,
valuesRedacted: true,
});
response = { ok: false, status: 0, statusText: "request-error" };
cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] }));
if (!retryable) break;
}
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
}
response = response ?? { ok: false, status: 0, statusText: "api-login-failed" };
cookieState = cookieState ?? await readAuthCookieState(context);
if (!response.ok || !cookieState.cookiePresent) {
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
const retryable = attempts.some((item) => item && item.retryable === true);
error.details = {
accountId,
status: response.status,
statusText: response.statusText,
cookiePresent: cookieState.cookiePresent,
credentialSource: credential.source,
attempts,
retryCount: Math.max(0, attempts.length - 1),
retryMaxAttempts: maxAttempts,
lastRetryLabel: attempts[attempts.length - 1]?.retryLabel || null,
retryExhausted: retryable && attempts.length >= maxAttempts,
retryable,
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 authRetryDelayMs(attempt, maxAttempts) {
return attempt < maxAttempts ? Math.min(authLoginMaxDelayMs, authLoginInitialDelayMs * (2 ** (attempt - 1))) : 0;
}
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, options = {}) {
const target = new URL(String(rawTarget || targetPath), baseUrl).toString();
const beforeUrl = currentPageUrl();
const attempts = [];
const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : navigationMaxAttempts;
const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000;
const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000;
const settleMs = Number.isFinite(Number(options.settleMs)) ? Math.max(0, Number(options.settleMs)) : 1000;
const lateReadinessTimeoutMs = Number.isFinite(Number(options.lateReadinessTimeoutMs)) ? Math.max(0, Number(options.lateReadinessTimeoutMs)) : 5000;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs });
if (settleMs > 0) await page.waitForTimeout(settleMs).catch(() => {});
const httpStatus = response ? response.status() : null;
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: readinessTimeoutMs });
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 (lateReadinessTimeoutMs > 0 && /workbench-app-not-ready|navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(message)) {
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: lateReadinessTimeoutMs }).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 >= maxAttempts || !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 withHardTimeout(page.close(), 3000, "control page close exceeded 3000ms").catch((error) => appendJsonl(files.errors, eventRecord("control-page-close-timeout", { reason, attempt, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
controlPageEpoch += 1;
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, pageEpoch: controlPageEpoch, 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|net::ERR_NAME_NOT_RESOLVED|Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded|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 (/net::ERR_NAME_NOT_RESOLVED/iu.test(text)) return "net::ERR_NAME_NOT_RESOLVED";
if (/Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/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";
};
const sessionCreate = document.querySelector("#session-create");
const sessionRail = document.querySelector("#session-sidebar");
const sessionCollapseToggle = document.querySelector("#session-collapse-toggle");
return {
url: window.location.href,
path: window.location.pathname,
readyState: document.readyState,
workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")),
sessionCreatePresent: Boolean(sessionCreate),
sessionCreateVisible: visible(sessionCreate),
sessionRailPresent: Boolean(sessionRail),
sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null,
sessionCollapseTogglePresent: Boolean(sessionCollapseToggle),
sessionCollapseToggleVisible: visible(sessionCollapseToggle),
sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null,
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 }));
}
async function waitForProjectManagementCommandReady(options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
const started = Date.now();
const deadline = started + timeoutMs;
let last = null;
while (Date.now() <= deadline) {
last = await projectManagementCommandSnapshot();
const path = String(last?.path || safeUrlPath(currentPageUrl()) || "");
const baseReady = last?.pageKind === "project-management-mdtodo"
&& Number(last?.sourceCount || 0) > 0
&& Number(last?.fileCount || 0) > 0
&& Number(last?.taskCount || 0) > 0;
const needsTask = /\/tasks\//u.test(path);
const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
const needsReport = /\/reports\//u.test(path);
const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true;
if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
await page.waitForTimeout(250).catch(() => {});
}
return { ok: false, reason: "project-management-command-not-ready", durationMs: Date.now() - started, snapshot: last, 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 + "/"));
}
function isAgentSessionCreateRequest(requestOrUrl) {
const method = typeof requestOrUrl?.method === "function" ? requestOrUrl.method().toUpperCase() : "";
if (method && method !== "POST") return false;
const url = typeof requestOrUrl === "string" ? requestOrUrl : typeof requestOrUrl?.url === "function" ? requestOrUrl.url() : "";
try {
return new URL(url).pathname === "/v1/agent/sessions";
} catch {
return false;
}
}
function requestFailureSummary(request) {
let failure = null;
try {
failure = request.failure();
} catch {}
let urlPath = null;
try {
urlPath = new URL(request.url()).pathname;
} catch {}
return {
method: typeof request.method === "function" ? request.method().toUpperCase() : null,
urlPath,
failureText: failure?.errorText || null,
valuesRedacted: true
};
}
async function ensureSessionRailExpanded() {
const before = await workbenchReadinessSnapshot(page);
if (before?.sessionCreateVisible === true) {
return { ok: true, action: "already-visible", before, after: before, valuesRedacted: true };
}
const toggle = page.locator("#session-collapse-toggle").first();
const toggleVisible = await toggle.isVisible({ timeout: 2000 }).catch(() => false);
if (before?.sessionRailCollapsed !== true || !toggleVisible) {
return { ok: false, action: "not-expanded", reason: before?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before, after: before, valuesRedacted: true };
}
await toggle.click();
await page.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 rail = document.querySelector("#session-sidebar");
return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false"));
}, null, { timeout: 5000 }).catch(() => null);
const after = await workbenchReadinessSnapshot(page);
return {
ok: after?.sessionCreateVisible === true,
action: "expanded-session-rail",
before,
after,
valuesRedacted: true
};
}
async function clickAndWaitForAgentSessionCreate(create) {
let removeRequestFailedListener = () => {};
const requestFailedPromise = new Promise((resolve) => {
let timeout = null;
const handler = (request) => {
if (!isAgentSessionCreateRequest(request)) return;
removeRequestFailedListener();
resolve({ kind: "requestfailed", requestFailure: requestFailureSummary(request) });
};
removeRequestFailedListener = () => {
page.off("requestfailed", handler);
if (timeout !== null) clearTimeout(timeout);
timeout = null;
};
page.on("requestfailed", handler);
timeout = setTimeout(() => {
removeRequestFailedListener();
resolve(null);
}, 45000);
});
const createResponsePromise = page.waitForResponse((response) => {
const request = response.request();
return isAgentSessionCreateRequest(request) || isAgentSessionCreateRequest(response.url());
}, { timeout: 45000 }).then((response) => ({ kind: "response", response })).catch((error) => ({ kind: "wait-error", waitError: errorSummary(error) }));
await create.click();
const outcome = await Promise.race([createResponsePromise, requestFailedPromise]);
removeRequestFailedListener();
return outcome ?? await createResponsePromise;
}
async function createSessionFromUi() {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
const attempts = [];
let createResponse = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const railExpansion = await ensureSessionRailExpanded();
const readinessBeforeClick = railExpansion.after || await workbenchReadinessSnapshot(page);
const create = page.locator("#session-create").first();
try {
await create.waitFor({ state: "visible", timeout: 15000 });
} catch (error) {
const readinessAfterWait = await workbenchReadinessSnapshot(page);
const createError = new Error("newSession session create button is not visible");
createError.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, attempt, readinessBeforeClick, readinessAfterWait, railExpansion, waitError: errorSummary(error), pageId, valuesRedacted: true };
throw createError;
}
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 outcome = await clickAndWaitForAgentSessionCreate(create);
if (outcome?.kind === "response") {
createResponse = outcome.response;
attempts.push({ attempt, outcome: "response", readinessBeforeClick, railExpansion, createButtonState, valuesRedacted: true });
break;
}
const afterAttempt = await workbenchSessionSnapshot();
attempts.push({
attempt,
outcome: outcome?.kind || "unknown",
readinessBeforeClick,
railExpansion,
createButtonState,
waitError: outcome?.waitError || null,
requestFailure: outcome?.requestFailure || null,
after: afterAttempt,
valuesRedacted: true
});
if (attempt < 2 && outcome?.kind === "requestfailed") {
await page.waitForTimeout(1500);
continue;
}
const waitError = outcome?.waitError || outcome?.requestFailure || { name: outcome?.kind || "unknown", valuesRedacted: true };
const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (waitError.message || waitError.failureText || waitError.name || "timeout"));
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, pageId, valuesRedacted: true };
throw error;
}
if (createResponse === null) throw new Error("newSession did not produce an authoritative session create response");
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, attempts, pageId, valuesRedacted: true };
throw error;
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
ok,
before,
after,
attempts,
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);
}
function controlPageRecoveryTarget(snapshot, beforeUrl) {
const sessionId = snapshot?.routeSessionId || snapshot?.activeSessionId || routeSessionIdFromUrl(beforeUrl);
if (sessionId) return { sessionId, targetPath: "/workbench/sessions/" + encodeURIComponent(sessionId), valuesRedacted: true };
const path = safeUrlPath(beforeUrl);
if (isWorkbenchPathname(path || "")) return { sessionId: null, targetPath: path, valuesRedacted: true };
return { sessionId: null, targetPath, valuesRedacted: true };
}
function controlPageProjectionMissingForCommand(snapshot, beforeUrl) {
const path = safeUrlPath(snapshot?.url || beforeUrl);
if (!isWorkbenchPathname(path || "")) return false;
const routeSessionId = snapshot?.routeSessionId || routeSessionIdFromUrl(snapshot?.url || beforeUrl);
if (!routeSessionId) return false;
return snapshot?.activeSessionId !== routeSessionId
&& Number(snapshot?.tabCount || 0) === 0
&& Number(snapshot?.messageCount || 0) === 0
&& Number(snapshot?.traceRowCount || 0) === 0
&& snapshot?.composerReady !== true;
}
async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) {
const started = Date.now();
return withHardTimeout(workbenchSessionSnapshot(page), timeoutMs, "control page liveness snapshot exceeded " + timeoutMs + "ms")
.then((snapshot) => ({
ok: snapshot !== null,
reason,
durationMs: Date.now() - started,
snapshot,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}))
.catch((error) => ({
ok: false,
reason,
durationMs: Date.now() - started,
error: errorSummary(error),
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
}
async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null, options = {}) {
let navigation = null;
let hydration = null;
let afterLiveness = null;
const attempts = [];
let ok = false;
const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 12000;
const hydrationHardTimeoutMs = Number.isFinite(Number(options.hydrationHardTimeoutMs)) ? Math.max(hydrationTimeoutMs, Number(options.hydrationHardTimeoutMs)) : hydrationTimeoutMs + 2000;
const navigationOptions = options.navigation || {};
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt);
try {
navigation = await gotoTarget(target.targetPath, navigationOptions);
} catch (error) {
navigation = { ok: false, targetPath: target.targetPath, error: errorSummary(error), valuesRedacted: true };
}
if (!navigation?.error && target.sessionId) {
hydration = await withHardTimeout(
waitForWorkbenchSessionHydrated(page, target.sessionId, { timeoutMs: hydrationTimeoutMs }),
hydrationHardTimeoutMs,
"control page recovery hydration exceeded " + hydrationHardTimeoutMs + "ms"
).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true }));
} else {
hydration = null;
}
afterLiveness = await controlPageLivenessSnapshot(reason + "-post-recovery", 3000);
ok = !navigation?.error && afterLiveness.ok === true && (!target.sessionId || hydration?.ok === true);
attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true });
if (ok) break;
}
return {
ok,
recovered: ok,
reason,
beforeUrl,
afterUrl: currentPageUrl(),
target,
liveness,
navigation,
hydration,
afterLiveness,
attempts,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
}
async function promoteObserverPageToControlForCommand(reason, target, liveness = null) {
const beforeUrl = currentPageUrl();
const beforeObserverUrl = pageUrl(observerPage);
if (!observerPage || observerPage.isClosed()) {
return { ok: false, promoted: false, reason, failureKind: "observer-page-unavailable", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
}
const sessionId = target?.sessionId || routeSessionIdFromUrl(beforeUrl);
if (!sessionId) {
return { ok: false, promoted: false, reason, failureKind: "observer-promotion-needs-session", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
}
const observerSessionId = routeSessionIdFromUrl(beforeObserverUrl);
if (observerSessionId !== sessionId) {
return { ok: false, promoted: false, reason, failureKind: "observer-session-mismatch", beforeUrl, observerUrl: beforeObserverUrl, observerSessionId, sessionId, target, liveness, valuesRedacted: true };
}
const targetUrl = new URL(target?.targetPath || ("/workbench/sessions/" + encodeURIComponent(sessionId)), baseUrl).toString();
const readiness = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: 1000, hydrationTimeoutMs: 2000 });
const observerComposerReady = readiness?.hydration?.snapshot?.composerReady === true;
if (readiness.ok !== true || observerComposerReady !== true) {
return { ok: false, promoted: false, reason, failureKind: readiness.failureKind || (observerComposerReady ? "observer-not-ready" : "observer-composer-not-ready"), beforeUrl, observerUrl: beforeObserverUrl, sessionId, target, liveness, readiness, valuesRedacted: true };
}
const oldControlPage = page;
observerPageEpoch += 1;
controlPageEpoch += 1;
page = observerPage;
observerPage = null;
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
if (oldControlPage && !oldControlPage.isClosed() && oldControlPage !== page) {
await withHardTimeout(oldControlPage.close(), 2000, "old control page close exceeded 2000ms")
.catch((error) => appendJsonl(files.errors, eventRecord("old-control-page-close-timeout", { reason, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
}
observerPage = await context.newPage();
attachPassiveListeners(observerPage, "observer", observerPageId);
const observerSync = await syncObserverPageToControlSession(reason + "-observer-recreated-after-promotion", sessionId, {
maxAttempts: 1,
navigationTimeoutMs: 8000,
readinessTimeoutMs: 3000,
hydrationTimeoutMs: 3000,
shortCircuitReadinessTimeoutMs: 1000,
shortCircuitHydrationTimeoutMs: 1000,
});
return {
ok: true,
promoted: true,
reason,
beforeUrl,
beforeObserverUrl,
afterUrl: currentPageUrl(),
sessionId,
target,
liveness,
readiness,
observerSync,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
}
async function ensureControlPageResponsiveForCommand(reason) {
const beforeUrl = currentPageUrl();
const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000);
const projectionMissing = liveness.ok === true && controlPageProjectionMissingForCommand(liveness.snapshot, beforeUrl);
if (liveness.ok && !projectionMissing) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
await appendJsonl(files.control, eventRecord(projectionMissing ? "control-page-projection-missing-before-command" : "control-page-unresponsive-before-command", {
reason,
beforeUrl,
target,
liveness,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-promoted-from-observer-before-command" : "control-page-observer-promotion-skipped-before-command", promotion));
if (promotion.ok === true) return promotion;
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
maxAttempts: 1,
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
hydrationTimeoutMs: 4000,
hydrationHardTimeoutMs: 5000,
});
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery));
if (!recovery.ok) {
const error = new Error("control page recovery failed before " + reason);
error.details = recovery;
throw error;
}
return recovery;
}
async function forceRecoverControlPageForCommand(reason) {
const beforeUrl = currentPageUrl();
const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000);
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", {
reason,
beforeUrl,
target,
liveness,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-forced-promoted-from-observer-before-command" : "control-page-forced-observer-promotion-skipped-before-command", promotion));
if (promotion.ok === true) return promotion;
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
maxAttempts: 1,
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
hydrationTimeoutMs: 4000,
hydrationHardTimeoutMs: 5000,
});
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery));
return recovery;
}
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 controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt");
const beforeUrl = currentPageUrl();
const beforeEvidence = await promptSideEffectSnapshot();
let editor = null;
let composerRecovery = null;
let editorWaitError = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const primaryEditor = page.locator("#command-input").last();
const candidate = await primaryEditor.isVisible().catch(() => false)
? primaryEditor
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
try {
await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 8000 }), 10000, "sendPrompt composer editor did not become visible within 10s");
editor = candidate;
break;
} catch (error) {
editorWaitError = error;
if (attempt >= 2) break;
composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing");
if (composerRecovery.ok !== true) break;
}
}
if (!editor) {
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000);
const error = new Error("sendPrompt composer editor did not become visible");
error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
throw error;
}
editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery });
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 withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s");
if (options.expectedAction) {
const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === ""
? null
: Number(options.expectedActionWaitMs);
const actionWaitMs = Number.isFinite(configuredActionWaitMs)
? Math.max(1000, Math.trunc(configuredActionWaitMs))
: 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
},
controlRecovery,
pageId,
pageEpoch: controlPageEpoch,
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 },
controlRecovery,
pageId,
pageEpoch: controlPageEpoch
};
}
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 },
controlRecovery,
pageId,
pageEpoch: controlPageEpoch,
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
},
controlRecovery,
pageId,
pageEpoch: controlPageEpoch
};
}
async function fillComposerEditorWithRetry(initialEditor, text, context = {}) {
let editor = initialEditor;
let lastError = null;
let recovery = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
if (attempt > 1 || !editor) editor = await resolveComposerEditor(8000);
await fillComposerEditor(editor, text, { timeoutMs: 12000 });
const retained = await composerEditorRetainsText(editor, text);
if (retained) return editor;
throw new Error("sendPrompt composer editor did not retain filled text");
} catch (error) {
lastError = error;
await appendJsonl(files.control, eventRecord("sendPrompt-composer-fill-retry", {
attempt,
willRetry: attempt < 2,
error: errorSummary(error),
beforeUrl: context.beforeUrl || null,
afterUrl: currentPageUrl(),
controlRecovery: context.controlRecovery || null,
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
if (attempt >= 2) break;
recovery = await forceRecoverControlPageForCommand("sendPrompt-composer-fill-failed");
if (recovery.ok !== true) await page.waitForTimeout(250);
editor = null;
}
}
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-fill-failed-final", 3000);
const error = new Error("sendPrompt composer editor fill failed");
error.details = {
beforeUrl: context.beforeUrl || null,
afterUrl: currentPageUrl(),
controlRecovery: context.controlRecovery || null,
recovery,
snapshot,
fillError: errorSummary(lastError),
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
throw error;
}
async function resolveComposerEditor(timeoutMs = 8000) {
const primaryEditor = page.locator("#command-input").last();
const candidate = await primaryEditor.isVisible().catch(() => false)
? primaryEditor
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
await candidate.waitFor({ state: "visible", timeout: timeoutMs });
return candidate;
}
async function fillComposerEditor(editor, text, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1000, Math.trunc(Number(options.timeoutMs))) : 30000;
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, { timeout: timeoutMs });
else if (editable) {
await editor.click({ timeout: timeoutMs });
await page.keyboard.insertText(text);
} else {
await editor.click({ timeout: timeoutMs });
await page.keyboard.insertText(text);
}
}
async function composerEditorRetainsText(editor, expectedText) {
const expected = String(expectedText || "");
return editor.evaluate((element, value) => {
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return element.value === value;
return (element.textContent || "") === value;
}, expected).catch(() => false);
}
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 withHardTimeout(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
};
}), 3000, "prompt side-effect snapshot exceeded 3000ms")
.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();
let switchBack;
try {
switchBack = await clickSession(canarySessionId);
} catch (error) {
const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId));
switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true };
}
let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 });
let after = backSettle.snapshot ?? await workbenchSessionSnapshot();
let switchBackRecovery = null;
let routeOk = after?.routeSessionId === canarySessionId;
let activeOk = after?.activeSessionId === canarySessionId;
if (!routeOk || !activeOk) {
switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle);
if (switchBackRecovery?.ok === true) {
switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true };
backSettle = switchBackRecovery.settle;
after = switchBackRecovery.snapshot ?? after;
routeOk = after?.routeSessionId === canarySessionId;
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, recovery: switchBackRecovery, 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,
switchBackRecovery,
routeSessionId: after?.routeSessionId ?? null,
activeSessionId: after?.activeSessionId ?? null,
routeOk,
activeOk,
composerReady: after?.composerReady === true,
pageId,
valuesRedacted: true,
};
}
async function recoverControlPageSessionHydration(sessionId, previousSettle) {
const observerHydration = observerPage && !observerPage.isClosed()
? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 })
: { ok: false, reason: "observer-page-unavailable", valuesRedacted: true };
await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1);
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 });
const snapshot = settle.snapshot ?? await workbenchSessionSnapshot();
const routeOk = snapshot?.routeSessionId === sessionId;
const activeOk = snapshot?.activeSessionId === sessionId;
return {
ok: settle.ok === true && routeOk && activeOk,
attempted: true,
reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed",
previousSettle,
observerHydrated: observerHydration.ok === true,
observerHydration,
navigation,
settle,
snapshot: {
routeSessionId: snapshot?.routeSessionId ?? null,
activeSessionId: snapshot?.activeSessionId ?? null,
composerReady: snapshot?.composerReady === true,
messageCount: snapshot?.messageCount ?? null,
valuesRedacted: 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 attempts = [];
let response = null;
let afterProject = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const responsePromise = page.waitForResponse((item) => {
try {
const request = item.request();
return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview";
} catch {
return false;
}
}, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) }));
const clickMode = attempt === 1 ? "normal" : "force";
let click = { ok: true, mode: clickMode, valuesRedacted: true };
await link.scrollIntoViewIfNeeded().catch(() => null);
try {
if (attempt === 1) await link.click();
else await link.click({ force: true, timeout: 5000 });
} catch (error) {
click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true };
await link.evaluate((element) => element.click()).catch(() => null);
}
await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null);
afterProject = await projectManagementCommandSnapshot();
response = afterProject?.reportPreviewVisible === true
? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true }
: await responsePromise;
attempts.push({
attempt,
click,
response,
afterUrl: currentPageUrl(),
reportPreviewVisible: afterProject?.reportPreviewVisible === true,
reportErrorVisible: afterProject?.reportErrorVisible === true,
reportFullscreenVisible: afterProject?.reportFullscreenVisible === true,
valuesRedacted: true
});
if (afterProject?.reportPreviewVisible === true) break;
await page.waitForTimeout(700).catch(() => null);
}
afterProject = afterProject ?? await projectManagementCommandSnapshot();
if (afterProject?.reportPreviewVisible !== true) {
const error = new Error("openMdtodoReportPreview did not show markdown report preview");
error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true };
throw error;
}
return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, 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 reportError = document.querySelector('[data-testid="mdtodo-report-error"]');
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) : "",
reportErrorVisible: visible(reportError),
reportErrorText: visible(reportError) ? text(reportError) : "",
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,
reportErrorText: undefined,
reportError: value.reportErrorText ? textDigest(value.reportErrorText, 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 })
.catch((error) => appendJsonl(files.errors, eventRecord("control-sample-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) })));
}
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 withHardTimeout(captureScreenshot("checkpoint", "jpeg"), 15000, "captureScreenshot checkpoint exceeded 15s")
.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 evaluateTimeoutMs = Math.max(3000, Math.min(8000, Number(sampleIntervalMs) || 5000));
const dom = await withHardTimeout(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);
const measurePaneGap = (name, paneSelector, contentSelector) => {
const pane = document.querySelector(paneSelector);
if (!visible(pane)) return { name, visible: false };
const rect = pane.getBoundingClientRect();
const contentNodes = Array.from(pane.querySelectorAll(contentSelector)).filter(visible);
const contentBottom = Math.max(rect.top, ...contentNodes.map((element) => element.getBoundingClientRect().bottom));
const bottomGapPx = Math.max(0, Math.round(rect.bottom - contentBottom));
const heightPx = Math.max(0, Math.round(rect.height));
return {
name,
visible: true,
widthPx: Math.max(0, Math.round(rect.width)),
heightPx,
bottomGapPx,
bottomGapRatio: heightPx > 0 ? Number((bottomGapPx / heightPx).toFixed(3)) : 0,
contentNodeCount: contentNodes.length,
};
};
const paneGaps = [
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', '[data-task-ref], [role="treeitem"], [role="listitem"], li, button, input, select, .task-row-shell, .task-tools'),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
];
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,
paneGaps,
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 }), evaluateTimeoutMs, "sampleOnePage DOM evaluate exceeded " + evaluateTimeoutMs + "ms").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),
})) : [],
paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({
name: item?.name ?? null,
visible: item?.visible === true,
widthPx: Number.isFinite(Number(item?.widthPx)) ? Number(item.widthPx) : null,
heightPx: Number.isFinite(Number(item?.heightPx)) ? Number(item.heightPx) : null,
bottomGapPx: Number.isFinite(Number(item?.bottomGapPx)) ? Number(item.bottomGapPx) : null,
bottomGapRatio: Number.isFinite(Number(item?.bottomGapRatio)) ? Number(item.bottomGapRatio) : null,
contentNodeCount: Number.isFinite(Number(item?.contentNodeCount)) ? Number(item.contentNodeCount) : null,
valuesRedacted: true,
})) : [],
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;
}
async function captureCommandScreenshot(command) {
const shouldWaitProject = command.waitProjectManagementReady === true;
const readiness = shouldWaitProject ? await waitForProjectManagementCommandReady({ timeoutMs: 15000 }) : null;
if (readiness && readiness.ok !== true) {
const error = new Error("screenshot project-management readiness wait failed: " + (readiness.reason || "not-ready"));
error.details = { readiness, currentUrl: currentPageUrl(), pageId, valuesRedacted: true };
throw error;
}
const artifact = await captureScreenshot(command.reason || command.label || "manual", command.imageType || "png");
return { ...artifact, readiness, valuesRedacted: true };
}
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 };
}
async function summarizeWorkbenchResponseBody(response, request) {
const method = String(request.method() || "GET").toUpperCase();
const path = safeUrlPath(response.url()) || "";
const resourceType = String(request.resourceType() || "");
const status = Number(response.status());
if (!shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status })) return { bodyRead: false };
const headers = response.headers();
const contentType = String(headers["content-type"] || headers["Content-Type"] || "");
if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true };
const contentLength = Number(headers["content-length"] || headers["Content-Length"]);
const maxBytes = 512 * 1024;
if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true };
const text = await response.text();
const byteCount = Buffer.byteLength(text);
if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true };
let parsed = null;
try {
parsed = JSON.parse(text);
} catch (error) {
return { bodyRead: true, bodyReadSkipped: "json-parse-error", bodyByteCount: byteCount, bodyHash: sha256Text(text), bodyParseError: errorSummary(error), valuesRedacted: true };
}
return {
bodyRead: true,
bodyByteCount: byteCount,
bodyHash: sha256Text(text),
bodySummary: summarizeWorkbenchJsonBody(parsed, path),
valuesRedacted: true
};
}
function shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status }) {
if (method !== "GET" && method !== "POST") return false;
if (!Number.isFinite(status) || status < 200 || status >= 300) return false;
if (resourceType === "eventsource" || path === "/v1/workbench/events") return false;
return path === "/v1/agent/chat"
|| path === "/v1/agent/chat/steer"
|| path === "/v1/workbench/sessions"
|| /^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)
|| /^\/v1\/workbench\/turns\/[^/]+$/u.test(path)
|| /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path);
}
function summarizeWorkbenchJsonBody(value, path) {
const traceIds = new Set();
const sessionIds = new Set();
const terminalTraceIds = new Set();
const statusCounts = {};
const counters = {
objectCount: 0,
arrayCount: 0,
traceEventLikeCount: 0,
messageLikeCount: 0,
turnLikeCount: 0,
terminalStatusCount: 0,
runningStatusCount: 0,
terminalTextCount: 0,
finalTextFieldCount: 0,
finalTextByteCount: 0
};
const visit = (node, key = "", parent = null, depth = 0) => {
if (depth > 32 || node === null || node === undefined) return;
if (typeof node === "string") {
collectWorkbenchIdsFromText(node, traceIds, sessionIds);
const normalizedStatus = normalizeWorkbenchStatus(key, node);
if (normalizedStatus) {
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
if (isWorkbenchTerminalStatus(normalizedStatus)) counters.terminalStatusCount += 1;
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
}
if (isWorkbenchTerminalText(node)) {
counters.terminalTextCount += 1;
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
}
if (isLikelyWorkbenchFinalTextField(key, parent, node)) {
counters.finalTextFieldCount += 1;
counters.finalTextByteCount += Buffer.byteLength(node);
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
}
return;
}
if (typeof node !== "object") return;
if (Array.isArray(node)) {
counters.arrayCount += 1;
for (const item of node) visit(item, key, parent, depth + 1);
return;
}
counters.objectCount += 1;
const record = node;
const recordTraceIds = workbenchTraceIdsFromRecord(record);
for (const raw of [record.traceId, record.trace_id, record.id, record.turnId, record.messageId]) {
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
}
for (const raw of [record.sessionId, record.session_id]) {
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
}
const statusValue = record.status ?? record.state ?? record.phase ?? record.result ?? record.lifecycle;
if (typeof statusValue === "string") {
const normalizedStatus = normalizeWorkbenchStatus("status", statusValue);
if (normalizedStatus) {
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
if (isWorkbenchTerminalStatus(normalizedStatus)) {
counters.terminalStatusCount += 1;
for (const traceId of recordTraceIds) terminalTraceIds.add(traceId);
}
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
}
}
if (record.traceId || record.trace_id || record.turnId || record.turn_id) counters.turnLikeCount += 1;
if (record.messageId || record.message_id || record.role || record.author || record.content || record.text || record.finalResponse) counters.messageLikeCount += 1;
if (record.projectedSeq !== undefined || record.sourceSeq !== undefined || record.eventSeq !== undefined || record.eventKind !== undefined || record.eventTimestamp !== undefined) counters.traceEventLikeCount += 1;
for (const [childKey, childValue] of Object.entries(record)) visit(childValue, childKey, record, depth + 1);
};
visit(value);
return {
pathKind: workbenchBodyPathKind(path),
traceIds: Array.from(traceIds).sort().slice(0, 12),
terminalTraceIds: Array.from(terminalTraceIds).sort().slice(0, 12),
sessionIds: Array.from(sessionIds).sort().slice(0, 12),
statusCounts,
...counters,
terminalEvidenceCount: counters.terminalStatusCount + counters.terminalTextCount,
valuesRedacted: true
};
}
function collectWorkbenchIdsFromText(value, traceIds, sessionIds) {
const text = String(value || "");
for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) traceIds.add(match[0]);
for (const match of text.matchAll(/\bses_[A-Za-z0-9_-]+\b/gu)) sessionIds.add(match[0]);
}
function workbenchTraceIdsFromRecord(record) {
if (!record || typeof record !== "object") return [];
const values = [record.traceId, record.trace_id, record.turnId, record.turn_id, record.messageId, record.message_id, record.id];
const ids = new Set();
for (const raw of values) {
if (typeof raw !== "string") continue;
for (const match of raw.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]);
}
return Array.from(ids).sort();
}
function normalizeWorkbenchStatus(key, value) {
if (!/status|state|phase|result|lifecycle/iu.test(String(key || ""))) return null;
const text = String(value || "").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").replace(/^-+|-+$/gu, "");
if (!text) return null;
if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(text)) return "completed";
if (/^(failed|failure|error|errored)$/u.test(text)) return "failed";
if (/^(canceled|cancelled|aborted|cancel)$/u.test(text)) return "canceled";
if (/^(running|active|in-progress|in_progress|processing|streaming|executing)$/u.test(text)) return "running";
if (/^(queued|pending|admitted|created|waiting)$/u.test(text)) return "pending";
return text.slice(0, 80);
}
function isWorkbenchTerminalStatus(value) {
return value === "completed" || value === "failed" || value === "canceled";
}
function isWorkbenchRunningStatus(value) {
return value === "running" || value === "pending";
}
function isWorkbenchTerminalText(value) {
return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(value || ""));
}
function isLikelyWorkbenchFinalTextField(key, parent, value) {
const text = String(value || "").trim();
if (!text) return false;
const field = String(key || "");
if (!/final|assistant|response|content|markdown|text|message|output|result/iu.test(field)) return false;
const parentStatus = normalizeWorkbenchStatus("status", parent?.status ?? parent?.state ?? parent?.phase ?? parent?.result ?? "");
const parentRole = String(parent?.role ?? parent?.author ?? parent?.dataRole ?? "").toLowerCase();
return isWorkbenchTerminalStatus(parentStatus) || /assistant|agent|code/iu.test(parentRole) || /final|response|result/iu.test(field);
}
function workbenchBodyPathKind(path) {
if (path === "/v1/agent/chat" || path === "/v1/agent/chat/steer") return "agent-chat-submit";
if (path === "/v1/workbench/sessions") return "workbench-sessions";
if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "workbench-session-messages";
if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "workbench-turn";
if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "workbench-trace-events";
return "workbench";
}
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,
expectedActionWaitMs: command.expectedActionWaitMs === null || command.expectedActionWaitMs === undefined || command.expectedActionWaitMs === "" ? null : Number(command.expectedActionWaitMs),
requireComposerReady: command.requireComposerReady === true,
waitProjectManagementReady: command.waitProjectManagementReady === 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 boundedInteger(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
const integer = Math.floor(parsed);
if (integer < min || integer > max) return fallback;
return integer;
}
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)));
}
function withHardTimeout(promise, timeoutMs, message) {
const guarded = Promise.resolve(promise);
let timer = null;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1));
if (timer && typeof timer.unref === "function") timer.unref();
});
return Promise.race([guarded, timeout]).finally(() => {
if (timer) clearTimeout(timer);
guarded.catch(() => {});
});
}
`;
}