Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-runner-source.ts
T
2026-06-25 23:19:00 +08:00

2994 lines
152 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.
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
export function nodeWebObserveRunnerSource(): string {
return String.raw`#!/usr/bin/env node
import { createHash, randomBytes } from "node:crypto";
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const specRef = "PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer";
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL);
const username = process.env.HWLAB_WEB_USER || "admin";
const password = process.env.HWLAB_WEB_PASS || "";
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || ".state/web-observe/manual");
const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex"));
const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench";
const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000);
const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000);
const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0);
const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000);
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
const playwrightProxy = proxyConfigFromEnv(baseUrl);
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
const pageId = "control-" + randomBytes(4).toString("hex");
const observerPageId = "observer-" + randomBytes(4).toString("hex");
const dirs = {
commandsPending: path.join(stateDir, "commands", "pending"),
commandsProcessing: path.join(stateDir, "commands", "processing"),
commandsDone: path.join(stateDir, "commands", "done"),
commandsFailed: path.join(stateDir, "commands", "failed"),
screenshots: path.join(stateDir, "screenshots"),
analysis: path.join(stateDir, "analysis"),
archive: path.join(stateDir, "archive"),
};
const files = {
manifest: path.join(stateDir, "manifest.json"),
heartbeat: path.join(stateDir, "heartbeat.json"),
control: path.join(stateDir, "control.jsonl"),
samples: path.join(stateDir, "samples.jsonl"),
network: path.join(stateDir, "network.jsonl"),
console: path.join(stateDir, "console.jsonl"),
errors: path.join(stateDir, "errors.jsonl"),
artifacts: path.join(stateDir, "artifacts.jsonl"),
};
let browser;
let context;
let page;
let observerPage;
let lastObserverRefreshAtMs = Date.now();
let sampleSeq = 0;
let commandSeq = 0;
let artifactSeq = 0;
let activeCommandId = null;
let stopping = false;
let terminalStatus = "starting";
let lastScreenshotAtMs = 0;
let auth = null;
let pageLoadSeq = 0;
let controlPageEpoch = 0;
let observerPageEpoch = 0;
let currentPageProvenance = null;
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
try {
if (!password) throw new Error("missing HWLAB_WEB_PASS");
await prepareDirs();
await rotateExistingJsonlArtifacts();
await writeManifest({ status: "starting" });
await writeHeartbeat({ status: "starting" });
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
const { chromium } = await launcher.importPlaywright();
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
page = await context.newPage();
attachPassiveListeners(page, "control", pageId);
auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context, page));
await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath));
observerPage = await context.newPage();
attachPassiveListeners(observerPage, "observer", observerPageId);
await runControlCommand({ id: "startup-observer-goto", type: "observerGoto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => {
const result = await syncObserverPageToControlSession("startup");
if (!result?.ok) {
await appendJsonl(files.control, eventRecord("observer-startup-degraded", {
reason: result?.failureKind || result?.reason || "observer-not-ready",
result: sanitize(result),
valuesRedacted: true,
}));
}
return result ?? { ok: false, reason: "observer-not-ready", pageRole: "observer", pageId: observerPageId, valuesRedacted: true };
});
terminalStatus = "running";
await writeManifest({ status: "running", auth: publicAuth(auth) });
await writeHeartbeat({ status: "running" });
while (!stopping) {
await drainOneCommand();
await samplePage("interval");
if (maxSamples > 0 && sampleSeq >= maxSamples) {
await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples }));
break;
}
await sleep(sampleIntervalMs);
}
terminalStatus = "completed";
await writeHeartbeat({ status: "completed" });
await writeManifest({ status: "completed", completedAt: new Date().toISOString() });
process.exitCode = 0;
} catch (error) {
terminalStatus = "failed";
await appendJsonl(files.errors, eventRecord("runner-error", { error: errorSummary(error) })).catch(() => {});
await writeHeartbeat({ status: "failed", error: errorSummary(error) }).catch(() => {});
await writeManifest({ status: "failed", error: errorSummary(error) }).catch(() => {});
process.exitCode = 2;
} finally {
if (browser) await browser.close().catch(() => {});
}
async function prepareDirs() {
await mkdir(stateDir, { recursive: true, mode: 0o700 });
await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 })));
}
async function rotateExistingJsonlArtifacts() {
for (const [key, file] of Object.entries(files)) {
if (!file.endsWith(".jsonl")) continue;
let meta = null;
try {
meta = await stat(file);
} catch (error) {
if (error?.code === "ENOENT") continue;
throw error;
}
if (!meta?.isFile()) continue;
const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file));
await rename(file, archiveFile);
jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size });
}
}
async function uniqueArchiveFile(name) {
let candidate = path.join(dirs.archive, name);
const parsed = path.parse(name);
for (let index = 1; ; index += 1) {
try {
await stat(candidate);
candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext);
} catch (error) {
if (error?.code === "ENOENT") return candidate;
throw error;
}
}
}
function compactFileTimestamp(value) {
return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
}
async function writeManifest(extra = {}) {
const manifest = {
ok: extra.status !== "failed",
command: "web-probe-observe",
specRef,
jobId,
pid: process.pid,
stateDir,
baseUrl,
targetPath,
network: publicNetwork(playwrightProxy),
pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true },
pageProvenance: compactPageProvenance(currentPageProvenance),
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
alertThresholds,
projectManagement,
jsonlRotation,
commandDirs: dirs,
artifacts: files,
safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false },
startedAt,
...extra,
};
await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
}
async function writeHeartbeat(extra = {}) {
const heartbeat = {
ok: terminalStatus !== "failed",
jobId,
pid: process.pid,
stateDir,
status: terminalStatus,
pageId,
observerPageId,
baseUrl,
currentUrl: currentPageUrl(),
observerUrl: pageUrl(observerPage),
observerRefreshIntervalMs,
lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null,
pageProvenance: compactPageProvenance(currentPageProvenance),
sampleSeq,
commandSeq,
activeCommandId,
updatedAt: new Date().toISOString(),
uptimeMs: Date.now() - startedAtMs,
...extra,
};
await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 });
}
function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) {
targetPage.on("request", (request) => {
void appendJsonl(files.network, eventRecord("request", {
pageRole,
pageId: targetPageId,
observerInitiated: false,
commandId: activeCommandId,
method: request.method(),
url: safeUrl(request.url()),
resourceType: request.resourceType(),
frameUrl: safeFrameUrl(request.frame()),
}));
});
targetPage.on("response", (response) => {
const request = response.request();
void appendJsonl(files.network, eventRecord("response", {
pageRole,
pageId: targetPageId,
observerInitiated: false,
commandId: activeCommandId,
method: request.method(),
url: safeUrl(response.url()),
resourceType: request.resourceType(),
status: response.status(),
statusText: response.statusText(),
fromServiceWorker: response.fromServiceWorker(),
bodyRead: false,
}));
});
targetPage.on("requestfailed", (request) => {
void appendJsonl(files.network, eventRecord("requestfailed", {
pageRole,
pageId: targetPageId,
observerInitiated: false,
commandId: activeCommandId,
method: request.method(),
url: safeUrl(request.url()),
resourceType: request.resourceType(),
failure: request.failure()?.errorText ?? null,
}));
});
targetPage.on("console", (message) => {
void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() }));
});
targetPage.on("pageerror", (error) => {
void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) }));
});
targetPage.on("crash", () => {
void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId }));
});
targetPage.on("close", () => {
void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" }));
});
}
async function drainOneCommand() {
const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
const name = entries[0];
if (!name) return;
const pending = path.join(dirs.commandsPending, name);
const processing = path.join(dirs.commandsProcessing, name);
await rename(pending, processing).catch(() => null);
const raw = await readFile(processing, "utf8");
const command = JSON.parse(raw);
const id = safeId(command.id || name.replace(/[.]json$/u, ""));
command.id = id;
const stopCommandSampler = startCommandActiveSampler(command);
try {
const result = await processCommand(command);
const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) };
await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 });
await appendJsonl(files.control, controlRecord(command, "completed", done.result));
await unlink(processing).catch(() => {});
} catch (error) {
const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false })
.then(() => ({ ok: true, sampleSeq, valuesRedacted: true }))
.catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true }));
const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample };
await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 });
await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample }));
await unlink(processing).catch(() => {});
} finally {
stopCommandSampler();
activeCommandId = null;
await writeHeartbeat({ status: terminalStatus });
}
}
function startCommandActiveSampler(command) {
const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000);
let stopped = false;
let timer = null;
let inFlight = false;
const schedule = () => {
if (stopped) return;
timer = setTimeout(tick, intervalMs);
if (timer && typeof timer.unref === "function") timer.unref();
};
const tick = () => {
if (stopped) return;
if (inFlight) {
schedule();
return;
}
inFlight = true;
samplePage("command-active", { refreshObserver: false, screenshot: false })
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })))
.finally(() => {
inFlight = false;
schedule();
});
};
schedule();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}
async function processCommand(command) {
commandSeq += 1;
activeCommandId = command.id;
await writeHeartbeat({ status: "running", activeCommandId });
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context, page);
case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || "")), "sendPrompt");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn" }), "steer");
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo");
case "openMdtodoSourceConfig": return openMdtodoSourceConfig(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 "editMdtodoTaskTitle": return editMdtodoTaskTitle(command);
case "editMdtodoTaskBody": return editMdtodoTaskBody(command);
case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command);
case "addMdtodoRootTask": return addMdtodoRootTask(command);
case "addMdtodoSubTask": return addMdtodoSubTask(command);
case "continueMdtodoTask": return continueMdtodoTask(command);
case "deleteMdtodoTask": return deleteMdtodoTask(command);
case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask");
case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo");
case "screenshot": return captureScreenshot(command.reason || "manual", command.imageType || "png");
case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId };
case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId };
default: throw new Error("unsupported observer command type: " + command.type);
}
}
async function withObserverSync(result, reason) {
return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) };
}
async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) {
if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" };
const forceRefresh = options?.forceRefresh === true;
const snapshot = await workbenchSessionSnapshot();
const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath;
const targetUrl = new URL(target, baseUrl).toString();
const beforeUrl = pageUrl(observerPage);
const beforeSessionId = routeSessionIdFromUrl(beforeUrl);
if (sessionId && beforeSessionId === sessionId && !forceRefresh) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch };
observerPageEpoch += 1;
let status = null;
let statusText = null;
const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 45000 }).catch((error) => ({ observerGotoError: errorSummary(error) }));
if (response?.observerGotoError) return { ok: false, reason, changed: false, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, valuesRedacted: true };
status = typeof response?.status === "function" ? response.status() : null;
statusText = typeof response?.statusText === "function" ? response.statusText() : null;
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: 15000 });
if (!readiness.ok) {
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
}
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
lastObserverRefreshAtMs = Date.now();
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, valuesRedacted: true };
}
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 15000 });
lastObserverRefreshAtMs = Date.now();
return { ok: hydration.ok === true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", valuesRedacted: true };
}
async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000;
const started = Date.now();
const deadline = started + Math.max(1, timeoutMs);
let last = null;
while (Date.now() <= deadline) {
last = await workbenchSessionSnapshot(targetPage);
const expected = String(sessionId || "").trim();
const observedPath = safeUrlPath(last?.url || pageUrl(targetPage));
const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || "");
const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || "");
if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
await targetPage.waitForTimeout(250).catch(() => {});
}
return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true };
}
async function maybeRefreshObserverPage(reason) {
if (!observerPage || observerPage.isClosed()) return null;
if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null;
if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null;
const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true });
await appendJsonl(files.control, eventRecord("observer-periodic-refresh", {
pageRole: "observer",
pageId: observerPageId,
reason,
intervalMs: observerRefreshIntervalMs,
result,
valuesRedacted: true
}));
return result;
}
async function runControlCommand(command, fn) {
activeCommandId = command.id;
commandSeq += 1;
const beforeUrl = currentPageUrl();
const started = Date.now();
await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) }));
try {
const result = await fn();
await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) }));
return result;
} catch (error) {
let failurePageProvenance = null;
let failureScreenshot = null;
try {
failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null));
} catch (captureError) {
await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
}
try {
failureScreenshot = await captureScreenshot("command-failed", "jpeg");
} catch (captureError) {
await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
}
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot }));
throw error;
} finally {
activeCommandId = null;
}
}
async function authenticate(browserContext, authPage) {
const loginUrl = new URL("/auth/login", baseUrl).toString();
let activeAuthPage = authPage && !authPage.isClosed() ? authPage : null;
let closeAuthPage = false;
if (!activeAuthPage) {
activeAuthPage = await browserContext.newPage();
closeAuthPage = true;
}
const attempts = [];
const maxAttempts = 5;
const initialDelayMs = 250;
const maxDelayMs = 5000;
try {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const retryDelayMs = attempt < maxAttempts ? Math.min(maxDelayMs, initialDelayMs * (2 ** (attempt - 1))) : 0;
const retryLabel = attempt + "/" + maxAttempts;
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
try {
const response = await pageAuthLogin(activeAuthPage, loginUrl);
const cookieState = await readAuthCookieState(browserContext);
const retryable = isRetryableAuthStatus(response.status);
const item = {
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
method: "api",
status: response.status,
statusText: response.statusText,
retryable,
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
valuesRedacted: true,
};
attempts.push(item);
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
if (response.ok && cookieState.cookiePresent) {
return {
ok: true,
method: "api",
loginPath: new URL(loginUrl).pathname,
status: response.status,
statusText: response.statusText,
cookiePresent: true,
cookieNames: cookieState.cookieNames,
attempts,
retryCount: attempt - 1,
retryMaxAttempts: maxAttempts,
lastRetryLabel: attempt + "/" + maxAttempts,
retryExhausted: false,
retryable: false,
valuesRedacted: true,
};
}
if (!retryable) break;
} catch (error) {
const retryable = isRetryableAuthError(error);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
method: "api",
status: 0,
statusText: "request-error",
retryable,
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
cookiePresent: false,
cookieNames: [],
valuesRedacted: true,
});
const item = attempts[attempts.length - 1] || null;
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {});
if (!retryable) break;
}
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
}
} finally {
if (closeAuthPage && activeAuthPage && !activeAuthPage.isClosed()) await activeAuthPage.close().catch(() => {});
}
const cookieState = await readAuthCookieState(browserContext);
const last = attempts[attempts.length - 1] || null;
const retryable = attempts.some((attempt) => attempt && attempt.retryable === true);
const failure = {
ok: false,
method: "api",
loginPath: new URL(loginUrl).pathname,
status: typeof last?.status === "number" ? last.status : 0,
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
attempts,
retryCount: Math.max(0, attempts.length - 1),
retryMaxAttempts: maxAttempts,
lastRetryLabel: last?.retryLabel || null,
retryExhausted: retryable && attempts.length >= maxAttempts,
retryable,
lastError: last?.error || null,
valuesRedacted: true,
};
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {});
const error = new Error(authFailureMessage(failure));
error.webProbeAuth = failure;
throw error;
}
async function pageAuthLogin(authPage, loginUrl) {
if (!authPage) throw new Error("auth page is not ready");
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
return authPage.evaluate(async (input) => {
const response = await fetch(input.loginUrl, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ username: input.username, password: input.password }),
credentials: "include",
});
await response.text().catch(() => "");
return {
ok: response.ok,
status: response.status,
statusText: response.statusText || "",
};
}, { username, password, loginUrl });
}
function publicAuth(value) {
if (!value) return null;
return {
ok: value.ok === true,
method: value.method,
status: value.status,
cookiePresent: value.cookiePresent === true,
cookieNames: value.cookieNames || [],
retryCount: value.retryCount ?? null,
retryMaxAttempts: value.retryMaxAttempts ?? null,
lastRetryLabel: value.lastRetryLabel ?? null,
retryExhausted: value.retryExhausted === true,
valuesRedacted: true
};
}
async function readAuthCookieState(browserContext) {
const cookies = await browserContext.cookies(baseUrl);
const cookieNames = cookies.map((cookie) => cookie.name).sort();
return {
cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)),
cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)),
};
}
function isRetryableAuthStatus(status) {
return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
}
function isRetryableAuthError(error) {
const message = error && error.message ? String(error.message) : String(error || "");
return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message);
}
function authFailureMessage(failure) {
const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null;
const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : "";
const exhausted = failure.retryExhausted ? " exhausted=true" : "";
const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : "";
const error = last?.error ? " error=" + truncate(last.error, 160) : "";
return ("auth login failed:" + retry + exhausted + status + error).trim();
}
function proxyConfigFromEnv(targetBaseUrl) {
if (browserProxyMode === "direct") return null;
let target;
try {
target = new URL(targetBaseUrl);
} catch {
return null;
}
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
if (noProxyMatches(target.hostname, noProxy)) return null;
const raw = target.protocol === "https:"
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || "";
if (!raw) return null;
return { server: raw };
}
function chromiumLaunchOptionsForProxy(proxy) {
const base = { env: browserProcessEnvWithoutProxy() };
if (proxy === null) return { ...base, args: ["--no-proxy-server"] };
return { ...base, proxy };
}
function browserProcessEnvWithoutProxy() {
const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
const env = {};
for (const [key, value] of Object.entries(process.env)) {
if (!blocked.has(key) && value !== undefined) env[key] = value;
}
return env;
}
function noProxyMatches(hostname, rawList) {
const host = String(hostname || "").toLowerCase();
if (!host) return false;
return String(rawList || "").split(",").some((raw) => {
let item = raw.trim().toLowerCase();
if (!item) return false;
if (item === "*") return true;
item = item.replace(/^\*\./u, ".");
const portIndex = item.lastIndexOf(":");
if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex);
if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item);
return host === item;
});
}
function publicNetwork(proxy) {
return {
proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : {
enabled: true,
source: "env",
server: publicProxyServer(proxy.server),
valuesRedacted: true,
},
browser: {
proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy",
requestedProxyMode: browserProxyMode,
proxyEnvCleared: true,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
function parseBrowserProxyMode(raw) {
if (raw === "auto" || raw === "direct") return raw;
return "auto";
}
function publicProxyServer(raw) {
try {
const parsed = new URL(String(raw || ""));
parsed.username = "";
parsed.password = "";
const value = parsed.toString();
if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, "");
return value;
} catch {
return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@");
}
}
async function gotoTarget(rawTarget) {
const target = new URL(String(rawTarget || targetPath), baseUrl).toString();
const beforeUrl = currentPageUrl();
const attempts = [];
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45000 });
await page.waitForTimeout(1000).catch(() => {});
const httpStatus = response ? response.status() : null;
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 15000 });
if (!readiness.ok) {
const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null);
attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
}
const pageProvenance = await refreshPageProvenance("goto", httpStatus);
attempts.push({ attempt, ok: true, httpStatus, readiness });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null });
if (/workbench-app-not-ready/iu.test(message)) {
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: 5000 }).catch(() => null);
if (lateReadiness?.ok) {
const pageProvenance = await refreshPageProvenance("goto-late-ready", null);
attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts };
}
}
if (attempt >= 2 || !isRetryableNavigationError(message)) {
throw Object.assign(new Error(message), { attempts, target });
}
if (!observerPage) {
await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
} else {
await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
}
await page.waitForTimeout(1500 * attempt).catch(() => {});
}
}
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts };
}
async function recreateControlPageForNavigation(reason, attempt) {
const before = currentPageUrl();
if (page && !page.isClosed()) await page.close().catch(() => {});
page = await context.newPage();
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, valuesRedacted: true }));
}
async function recreateAuthenticatedContextForNavigation(reason, attempt) {
const before = currentPageUrl();
if (page && !page.isClosed()) await page.close().catch(() => {});
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
observerPage = null;
if (context) await context.close().catch(() => {});
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
auth = await authenticate(context);
page = await context.newPage();
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true }));
}
async function refreshPageProvenance(reason, httpStatus = null) {
if (!page || page.isClosed()) return currentPageProvenance;
const observed = await page.evaluate(() => {
const assetPath = (raw) => {
if (!raw) return null;
try {
const url = new URL(raw, location.href);
const keys = Array.from(url.searchParams.keys()).sort();
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
} catch {
return null;
}
};
const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
content: String(element.getAttribute("content") || "").slice(0, 200),
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key));
const navigation = performance.getEntriesByType("navigation")[0] || null;
return {
url: location.href,
path: location.pathname,
title: document.title,
readyState: document.readyState,
timeOrigin: Math.round(performance.timeOrigin || 0),
navigationStartTime: navigation ? Math.round(navigation.startTime) : null,
scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(),
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(),
meta,
};
}).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] }));
pageLoadSeq += 1;
currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq });
await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) }));
return currentPageProvenance;
}
function normalizePageProvenance(value, options = {}) {
const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : [];
const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : [];
const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({
key: String(item?.key || "").slice(0, 120),
contentHash: sha256Text(String(item?.content || "")),
})).filter((item) => item.key) : [];
const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta });
return {
pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq,
reason: options.reason || "sample",
observedAt: new Date().toISOString(),
urlPath: safeUrlPath(value?.url || currentPageUrl()),
documentPath: value?.path || null,
titleHash: sha256Text(String(value?.title || "")),
documentReadyState: value?.readyState || null,
timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null,
navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null,
httpStatus: options.httpStatus ?? null,
assetFingerprint: sha256Text(fingerprintInput),
scriptCount: scripts.length,
stylesheetCount: stylesheets.length,
metaCount: meta.length,
scripts: scripts.slice(0, 30),
stylesheets: stylesheets.slice(0, 30),
meta: meta.slice(0, 30),
error: value?.error || null,
valuesRedacted: true,
};
}
function compactPageProvenance(value) {
if (!value) return null;
return {
pageLoadSeq: value.pageLoadSeq ?? null,
reason: value.reason || null,
observedAt: value.observedAt || null,
urlPath: value.urlPath || null,
documentReadyState: value.documentReadyState || null,
timeOrigin: value.timeOrigin ?? null,
httpStatus: value.httpStatus ?? null,
assetFingerprint: value.assetFingerprint || null,
scriptCount: value.scriptCount ?? 0,
stylesheetCount: value.stylesheetCount ?? 0,
metaCount: value.metaCount ?? 0,
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [],
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [],
error: value.error || null,
valuesRedacted: true,
};
}
function isRetryableNavigationError(message) {
return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|Navigation timeout|workbench-app-not-ready/iu.test(String(message || ""));
}
function navigationFailureKind(message) {
const text = String(message || "");
if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED";
if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED";
if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET";
if (/Navigation timeout/iu.test(text)) return "navigation-timeout";
if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready";
return "navigation-error";
}
function redactErrorMessage(message) {
return String(message || "")
.replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]");
}
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
const targetPathname = safeUrlPath(targetUrl) || "";
if (isProjectManagementPathname(targetPathname)) {
const started = Date.now();
const selectors = projectManagement.readinessSelectors;
await targetPage.waitForFunction((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return input.selectors.some((selector) => {
try { return visible(document.querySelector(selector)); } catch { return false; }
});
}, { selectors }, { timeout: timeoutMs }).catch(() => null);
const snapshot = await projectManagementReadinessSnapshot(targetPage);
const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true;
return {
ok,
reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready",
durationMs: Date.now() - started,
snapshot,
valuesRedacted: true
};
}
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
const started = Date.now();
await targetPage.waitForFunction(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const workspace = document.querySelector("#workspace, .workbench-route");
const login = document.querySelector("form.login-card, .login-card, [data-testid='login']");
return Boolean(visible(workspace) || visible(login));
}, null, { timeout: timeoutMs }).catch(() => null);
const snapshot = await workbenchReadinessSnapshot(targetPage);
const ok = snapshot.workbenchShellVisible === true;
return {
ok,
reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready",
durationMs: Date.now() - started,
snapshot,
valuesRedacted: true
};
}
async function workbenchReadinessSnapshot(targetPage) {
const snapshot = await targetPage.evaluate(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
url: window.location.href,
path: window.location.pathname,
readyState: document.readyState,
workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")),
sessionCreateVisible: visible(document.querySelector("#session-create")),
commandInputPresent: visible(document.querySelector("#command-input")),
activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")),
warningPresent: visible(document.querySelector(".composer-warning")),
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000),
valuesRedacted: true
};
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
if (snapshot && typeof snapshot.bodyTextPreview === "string") {
snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview);
delete snapshot.bodyTextPreview;
}
return snapshot;
}
async function projectManagementReadinessSnapshot(targetPage) {
const selectors = projectManagement.readinessSelectors;
return targetPage.evaluate((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const selectorStates = input.selectors.map((selector) => {
let matched = false;
let visibleMatched = false;
try {
const element = document.querySelector(selector);
matched = Boolean(element);
visibleMatched = visible(element);
} catch {}
return { selector, matched, visible: visibleMatched };
});
return {
url: window.location.href,
path: window.location.pathname,
readyState: document.readyState,
projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')),
mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')),
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
selectorStates,
valuesRedacted: true
};
}, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
}
function isWorkbenchPathname(value) {
const pathname = String(value || "");
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
}
function isProjectManagementPathname(value) {
if (projectManagement.enabled !== true) return false;
const pathname = String(value || "");
return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/"));
}
async function createSessionFromUi() {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
const readinessBeforeClick = await workbenchReadinessSnapshot(page);
const create = page.locator("#session-create").first();
await create.waitFor({ state: "visible", timeout: 15000 });
const createButtonState = await create.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
tag: element.tagName.toLowerCase(),
id: element.id || null,
disabled: Boolean(element.disabled),
ariaDisabled: element.getAttribute("aria-disabled") || null,
visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none",
rect: { width: Math.round(rect.width), height: Math.round(rect.height) },
valuesRedacted: true
};
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
const createResponsePromise = page.waitForResponse((response) => {
const request = response.request();
if (request.method().toUpperCase() !== "POST") return false;
try {
return new URL(response.url()).pathname === "/v1/agent/sessions";
} catch {
return false;
}
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
await create.click();
const createResponse = await createResponsePromise;
if (createResponse?.waitError) {
const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (createResponse.waitError.message || createResponse.waitError.name || "timeout"));
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, readinessBeforeClick, createButtonState, waitError: createResponse.waitError, pageId, valuesRedacted: true };
throw error;
}
const createStatus = createResponse.status();
let createPayload = null;
let createPayloadError = null;
try {
createPayload = await createResponse.json();
} catch (error) {
createPayloadError = errorSummary(error);
}
const createdSessionId = sessionIdFromAgentSessionPayload(createPayload);
if (createStatus < 200 || createStatus >= 300 || !createdSessionId) {
const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions");
error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true };
throw error;
}
await page.waitForFunction((expectedSessionId) => {
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const sessionId = activeTab?.getAttribute("data-session-id") || "";
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : "";
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || "";
const input = document.querySelector("#command-input");
return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning);
}, createdSessionId, { timeout: 45000 }).catch(() => null);
const after = await workbenchSessionSnapshot();
const afterSessionId = after?.activeSessionId || after?.routeSessionId || "";
const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady);
if (!ok) {
const error = new Error("newSession did not select the authoritative newly created workbench session");
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, pageId, valuesRedacted: true };
throw error;
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
ok,
before,
after,
sessionId: createdSessionId,
createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true },
pageId
};
}
function sessionIdFromAgentSessionPayload(payload) {
const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id;
const directText = String(direct || "").trim();
if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText;
const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u);
return match ? match[0] : null;
}
async function workbenchSessionSnapshot(targetPage = page) {
return targetPage.evaluate(() => {
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
const input = document.querySelector("#command-input");
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null;
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
url: window.location.href,
routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null,
activeSessionId: activeTab?.getAttribute("data-session-id") || null,
activeConversationId: activeTab?.getAttribute("data-conversation-id") || null,
activeStatus: activeTab?.getAttribute("data-status") || null,
tabCount: document.querySelectorAll(".session-tab").length,
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length,
loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length,
composerReady: Boolean(activeTab && input && !input.disabled && !warning),
warning
};
}).catch(() => null);
}
async function sendPrompt(text, options = {}) {
if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text");
const responsePath = options.responsePath || "/v1/agent/chat";
const beforeUrl = currentPageUrl();
const beforeEvidence = await promptSideEffectSnapshot();
const primaryEditor = page.locator("#command-input").last();
const editor = await primaryEditor.isVisible().catch(() => false)
? primaryEditor
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
await editor.waitFor({ state: "visible", timeout: 15000 });
await fillComposerEditor(editor, text);
const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]';
const primarySubmit = page.locator(primarySubmitSelector).last();
const submit = await primarySubmit.isVisible().catch(() => false)
? primarySubmit
: page.locator([
'button[type="submit"]',
'button:has-text("发送")',
'button:has-text("Send")',
'[data-testid*="send" i]',
'[aria-label*="send" i]',
'[aria-label*="发送"]'
].join(", ")).last();
await submit.waitFor({ state: "visible", timeout: 15000 });
if (options.expectedAction) {
await page.waitForFunction(({ selector, expected }) => {
const element = document.querySelector(selector);
return element?.getAttribute("data-action") === expected || element?.getAttribute("disabled") !== null;
}, { selector: primarySubmitSelector, expected: options.expectedAction }, { timeout: 1000 }).catch(() => null);
const composer = await composerButtonState(submit);
if (composer.action !== options.expectedAction || composer.disabled === true) {
await clearComposerEditor(editor).catch(() => {});
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot());
return {
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,
expectedAction: options.expectedAction,
actualAction: composer.action,
valuesRedacted: true
},
pageId,
valuesRedacted: true
};
}
}
const chatResponsePromise = page.waitForResponse((response) => {
const request = response.request();
if (request.method().toUpperCase() !== "POST") return false;
try {
return new URL(response.url()).pathname === responsePath;
} catch {
return false;
}
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
await submit.click();
const chatResponse = await chatResponsePromise;
await page.waitForTimeout(500);
if (chatResponse?.waitError) {
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000);
if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) {
return {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect },
pageId
};
}
const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout"));
error.details = {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect },
pageId,
valuesRedacted: true
};
throw error;
}
const chatStatus = chatResponse.status();
if (chatStatus < 200 || chatStatus >= 300) {
throw new Error("sendPrompt observed POST " + responsePath + " HTTP " + chatStatus + " " + chatResponse.statusText());
}
let chatPayload = null;
let chatPayloadError = null;
try {
chatPayload = await chatResponse.json();
} catch (error) {
chatPayloadError = errorSummary(error);
}
let chatUrlPath = responsePath;
try {
chatUrlPath = new URL(chatResponse.url()).pathname;
} catch {
chatUrlPath = responsePath;
}
const payloadText = chatPayload ? JSON.stringify(chatPayload) : "";
const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null;
const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId)
? chatPayload.otelTrace.traceId
: null;
return {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: {
status: chatStatus,
statusText: chatResponse.statusText(),
urlPath: chatUrlPath,
traceId,
otelTraceId,
resultUrl: safeUrlPath(chatPayload?.resultUrl),
turnUrl: safeUrlPath(chatPayload?.turnUrl),
streamUrl: safeUrlPath(chatPayload?.streamUrl),
responseParsed: chatPayload !== null,
responseParseError: chatPayloadError,
valuesRedacted: true
},
pageId
};
}
async function fillComposerEditor(editor, text) {
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
if (tag === "textarea" || tag === "input") await editor.fill(text);
else if (editable) {
await editor.click();
await page.keyboard.insertText(text);
} else {
await editor.click();
await page.keyboard.insertText(text);
}
}
async function clearComposerEditor(editor) {
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
if (tag === "textarea" || tag === "input") {
await editor.fill("");
return;
}
if (editable) {
await editor.click();
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {});
await page.keyboard.press("Backspace").catch(() => {});
}
}
async function composerButtonState(button) {
return button.evaluate((element) => ({
action: element.getAttribute("data-action") || null,
disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true",
text: (element.textContent || "").trim().slice(0, 80),
title: element.getAttribute("title") || null,
ariaLabel: element.getAttribute("aria-label") || null,
testId: element.getAttribute("data-testid") || null,
})).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true }));
}
function sideEffectHasAuthoritativePromptSubmission(sideEffect) {
return Boolean(
(Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0)
|| (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0)
|| Number(sideEffect?.messageCountDelta || 0) > 0
);
}
async function waitForPromptSideEffect(beforeEvidence, timeoutMs) {
await page.waitForFunction((before) => {
const current = (() => {
const text = document.body?.innerText || "";
const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text);
const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text);
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length;
const textBytes = new TextEncoder().encode(text).length;
return { runIds, traceIds, running, executionError, messageCount, textBytes };
})();
const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : [];
const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : [];
const newRun = current.runIds.some((id) => !beforeRuns.includes(id));
const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id));
const newMessage = current.messageCount > Number(before?.messageCount || 0);
return newRun || newTrace || newMessage;
}, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null);
const after = await promptSideEffectSnapshot();
const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : [];
const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : [];
const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id));
const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id));
const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0));
return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true };
}
async function promptSideEffectSnapshot() {
return page.evaluate(() => {
const text = document.body?.innerText || "";
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text),
executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text),
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
textBytes: new TextEncoder().encode(text).length,
valuesRedacted: true
};
}).catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true }));
}
${nodeWebObserveRunnerCommandActionsSource()}
async function selectProvider(provider) {
const target = String(provider || "").trim();
if (!target) throw new Error("selectProvider requires provider name");
const beforeUrl = currentPageUrl();
const beforePath = safeUrlPath(beforeUrl);
if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first");
const nativeSelect = await page.evaluate((name) => {
const normalized = String(name).toLowerCase();
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
const options = Array.from(select.options || []);
const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized));
if (!option) continue;
select.value = option.value;
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
return { kind: "native-select", value: option.value, text: option.textContent || "" };
}
return null;
}, target).catch(() => null);
if (nativeSelect) {
await page.waitForTimeout(500);
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId };
}
const optionVisible = page.getByText(target, { exact: false }).last();
if (await optionVisible.isVisible().catch(() => false)) {
await optionVisible.click();
await page.waitForTimeout(500);
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId };
}
const control = page.locator([
'[data-testid*="provider" i]',
'[data-testid*="model" i]',
'[aria-label*="provider" i]',
'[aria-label*="model" i]',
'[aria-label*="模型"]',
'[aria-label*="提供"]',
'[role="combobox"]',
'[aria-haspopup="listbox"]',
].join(", "));
const count = Math.min(await control.count().catch(() => 0), 60);
const attempts = [];
for (let index = count - 1; index >= 0; index -= 1) {
const item = control.nth(index);
const visible = await item.isVisible().catch(() => false);
if (!visible) continue;
const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => "");
if (isProviderNavigationLabel(label)) continue;
if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue;
attempts.push({ index, label });
await item.click({ timeout: 3000 }).catch(() => null);
await page.waitForTimeout(300);
const option = page.getByText(target, { exact: false }).last();
if (await option.isVisible().catch(() => false)) {
await option.click();
await page.waitForTimeout(700);
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId };
}
await page.keyboard.press("Escape").catch(() => null);
}
const providerCandidates = await collectProviderCandidates();
throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10)));
}
async function collectProviderCandidates() {
return page.evaluate(() => {
const rows = [];
const seen = new Set();
const isNavigationLabel = (value) => {
const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
if (!label) return false;
if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true;
return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, ""));
};
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const push = (kind, element, value, text) => {
const normalizedValue = String(value || "").trim().slice(0, 160);
const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220);
const label = (normalizedValue + " " + normalizedText).trim();
if (!label) return;
const key = (kind + ":" + label).toLowerCase();
if (seen.has(key)) return;
seen.add(key);
rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) });
};
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || "");
}
const selector = [
'[role="option"]',
'[role="menuitem"]',
'[data-radix-collection-item]',
'[data-testid*="provider" i]',
'[data-testid*="profile" i]',
'[data-testid*="model" i]',
'[aria-label*="provider" i]',
'[aria-label*="profile" i]',
'[aria-label*="model" i]',
'[aria-label*="模型"]',
'[aria-label*="提供"]',
'button'
].join(", ");
for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) {
const text = String(element.textContent || "").replace(/\s+/gu, " ").trim();
const ariaLabel = String(element.getAttribute("aria-label") || "");
const testId = String(element.getAttribute("data-testid") || "");
const haystack = text + " " + ariaLabel + " " + testId;
if (isNavigationLabel(haystack)) continue;
if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue;
push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId);
}
return rows.slice(0, 80);
}).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]);
}
function isProviderNavigationLabel(value) {
const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
if (!text) return false;
if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true;
return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, ""));
}
async function clickSession(sessionId) {
if (!sessionId) throw new Error("clickSession requires --session-id or --value");
const beforeUrl = currentPageUrl();
const escaped = cssEscape(sessionId);
const candidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"], text=" + sessionId).first();
await candidate.waitFor({ state: "visible", timeout: 15000 });
await candidate.click();
await page.waitForTimeout(1000);
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
}
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.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
})).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: 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,
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 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);
return {
beforeUrl,
afterUrl: currentPageUrl(),
type: "configureMdtodoHwpodSource",
dialog,
fields,
save,
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);
return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, 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);
return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, 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);
for (const field of fields) {
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 clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u);
return { beforeUrl, afterUrl: currentPageUrl(), type, selection, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: 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 }]);
}
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 }]);
}
async function toggleMdtodoTaskStatus(command) {
const status = commandValue(command, ["status", "value", "text"]);
if (!status) throw new Error("toggleMdtodoTaskStatus requires --status");
return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", "mdtodo-edit-save", [{ kind: "select", testId: "mdtodo-edit-status", value: status }]);
}
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) }));
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);
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
launchPath,
launchStatus,
statusText: launchResponse.statusText(),
contractVersion,
sessionId,
workbenchUrl,
otelTraceId: launchTraceHeader,
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 launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
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,
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"]')),
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
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;
return {
...value,
selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw),
selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw),
selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw),
selectedSourceIdRaw: undefined,
selectedFileRefRaw: undefined,
selectedTaskRefRaw: undefined,
launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null,
launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null,
launchButtonText: undefined,
blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [],
valuesRedacted: true
};
}
function opaqueIdSummary(value) {
const text = String(value || "");
if (!text) return null;
return {
hash: sha256Text(text),
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
bytes: Buffer.byteLength(text),
valuesRedacted: true
};
}
async function preflightSummary() {
return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) };
}
async function samplePage(reason, options = {}) {
if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason);
const groupSeq = sampleSeq + 1;
if (page && !page.isClosed()) await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch });
if (observerPage && !observerPage.isClosed()) {
await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) })));
}
if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) {
await captureScreenshot("checkpoint", "jpeg").catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) })));
}
await writeHeartbeat({ status: terminalStatus });
}
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
sampleSeq += 1;
const dom = await targetPage.evaluate((input) => {
const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const textHashInput = (element) => trim(element.textContent || "", 800);
const loadingTextPattern = /加载中|\bLoading\b/iu;
const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu;
const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]";
const elementTextForLoading = (element) => [
element.textContent || "",
element.getAttribute("aria-label") || "",
element.getAttribute("title") || "",
element.getAttribute("data-testid") || ""
].map((value) => trim(value, 240)).filter(Boolean).join(" ");
const elementLooksLikeLoadingUi = (element) => {
const signal = [
element.getAttribute("class") || "",
element.getAttribute("data-testid") || "",
element.getAttribute("role") || "",
element.getAttribute("aria-busy") || "",
element.getAttribute("aria-label") || "",
element.getAttribute("title") || ""
].join(" ");
return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal);
};
const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element);
const hasLoadingText = (element) => {
const text = elementTextForLoading(element);
if (!loadingTextPattern.test(text)) return false;
if (elementIsCodeLike(element)) return false;
return true;
};
const elementDescriptor = (element) => {
if (!element) return null;
const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]");
return {
tag: element.tagName.toLowerCase(),
testId: element.getAttribute("data-testid") || null,
role: element.getAttribute("role") || null,
id: element.getAttribute("id") || null,
className: className || null,
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null,
messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null,
traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null,
ariaLabel: element.getAttribute("aria-label") || null
};
};
const ownerKindFor = (element) => {
const value = [
element.getAttribute("data-testid") || "",
element.getAttribute("class") || "",
element.getAttribute("role") || "",
element.tagName || ""
].join(" ").toLowerCase();
if (/message|turn|agent|assistant/.test(value)) return "turn";
if (/session|rail|sidebar|nav/.test(value)) return "session-nav";
if (/composer|prompt|input|textarea/.test(value)) return "composer";
if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic";
if (/trace|event|terminal|log/.test(value)) return "trace";
if (/performance|metric|chart|table/.test(value)) return "performance";
if (/main|workspace|workbench|root/.test(value)) return "workbench";
return "unknown";
};
const ownerLabelFor = (element) => {
const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]");
return trim(
element.getAttribute("aria-label")
|| element.getAttribute("data-testid")
|| (heading ? heading.textContent || "" : "")
|| element.getAttribute("class")
|| element.tagName,
160
);
};
const ownerKeyFor = (element) => {
const descriptor = elementDescriptor(element) || {};
return [
ownerKindFor(element),
descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown",
descriptor.sessionId || descriptor.messageId || descriptor.traceId || ""
].filter(Boolean).join(":").slice(0, 240);
};
const collectLoadingNodes = () => {
const candidates = Array.from(document.querySelectorAll("body *"))
.filter(visible)
.filter(hasLoadingText)
.filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child)))
.slice(-80);
return candidates.map((element, index) => {
const rect = element.getBoundingClientRect();
const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]');
const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]');
const owner = identityOwner || structuralOwner || element;
const ownerDescriptor = elementDescriptor(owner);
return {
index,
tag: element.tagName.toLowerCase(),
className: String(element.className || "").slice(0, 180),
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
text: trim(elementTextForLoading(element), 240),
ownerKind: ownerKindFor(owner),
ownerKey: ownerKeyFor(owner),
ownerLabel: ownerLabelFor(owner),
owner: ownerDescriptor,
ownerText: trim(owner.textContent || "", 300),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
};
});
};
const sessionIdForElement = (element) => {
const direct = element.getAttribute("data-session-id");
if (direct) return direct;
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
if (!href) return null;
try {
const parsed = new URL(href, location.href);
const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
} catch {
const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
}
};
const sessionTitleTextForElement = (element) => {
const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]");
return trim(
(titleNode ? titleNode.textContent || "" : "")
|| element.getAttribute("aria-label")
|| element.getAttribute("title")
|| element.textContent
|| "",
240
);
};
const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu;
const looksLikeSessionTitleFallback = (title, sessionId) => {
const text = trim(title, 240);
const id = String(sessionId || "").trim();
if (!text) return true;
if (sessionTitleFallbackPattern.test(text)) return true;
if (!id) return false;
return text === id || text.startsWith(id) || text.startsWith("Session " + id);
};
const collectSessionRailTitles = () => {
const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))
.filter(visible);
const seen = new Set();
const items = [];
for (const element of candidates) {
const sessionId = sessionIdForElement(element);
const titleText = sessionTitleTextForElement(element);
const key = (sessionId || "") + "|" + titleText;
if (seen.has(key)) continue;
seen.add(key);
const rect = element.getBoundingClientRect();
const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId);
items.push({
index: items.length,
tag: element.tagName.toLowerCase(),
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
sessionId,
sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null,
titleText,
fallbackTitle,
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
});
}
const visibleItems = items.slice(0, 120);
const fallbackItems = visibleItems.filter((item) => item.fallbackTitle);
const visibleCount = visibleItems.length;
const fallbackTitleCount = fallbackItems.length;
return {
visibleCount,
fallbackTitleCount,
fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0,
items: visibleItems.slice(0, 60),
fallbackItems: fallbackItems.slice(0, 12),
};
};
const diagnosticSummaryText = (element) => {
const summarySelectors = [
".api-error-diagnostic-summary-text p",
".api-error-diagnostic-summary-text",
"[class*='diagnostic-summary-text' i] p",
"[class*='diagnostic-summary-text' i]",
"[data-testid*='diagnostic-summary' i]",
"[data-testid*='error-summary' i]",
"[role='alert'] p",
"[role='alert']"
];
const parts = [];
for (const selector of summarySelectors) {
for (const candidate of Array.from(element.querySelectorAll(selector))) {
if (!visible(candidate)) continue;
const text = trim(candidate.textContent || "", 800);
if (text && !parts.includes(text)) parts.push(text);
}
}
const ownText = textHashInput(element);
const text = parts.length > 0 ? parts.join(" ") : ownText;
return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim();
};
const diagnosticToggleOnly = (element, text) => {
const compact = String(text || "").trim();
if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false;
const tag = element.tagName.toLowerCase();
const role = element.getAttribute("role");
const aria = element.getAttribute("aria-label") || "";
const title = element.getAttribute("title") || "";
return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title);
};
const stableMessageText = (element) => {
const bodySelectors = [
".message-markdown.message-text",
".message-text",
"[data-message-body]",
"[data-testid='message-body']",
"[data-testid*='message-text' i]",
"[data-testid*='final-response' i]"
];
const parts = [];
for (const selector of bodySelectors) {
for (const candidate of Array.from(element.querySelectorAll(selector))) {
if (!visible(candidate)) continue;
const text = trim(candidate.textContent || "", 1200);
if (text && !parts.includes(text)) parts.push(text);
}
}
if (parts.length > 0) return parts.join(" ");
const clone = element.cloneNode(true);
for (const selector of [
".message-duration-meta",
".message-activity-meta",
".api-error-diagnostic",
"[class*='diagnostic' i]",
"[class*='trace' i]",
"[data-trace-id]",
"[data-testid*='trace' i]",
"[data-testid*='event' i]",
"[role='status']",
"[role='alert']",
"button"
]) {
for (const child of Array.from(clone.querySelectorAll(selector))) child.remove();
}
return trim(clone.textContent || "", 1200);
};
const numericAttr = (element, names) => {
for (const name of names) {
const raw = element.getAttribute(name);
const value = Number(raw);
if (Number.isFinite(value)) return value;
}
return null;
};
const textAttr = (element, names) => {
for (const name of names) {
const raw = element.getAttribute(name);
if (raw && String(raw).trim()) return String(raw).trim();
}
return null;
};
const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => {
const rect = element.getBoundingClientRect();
const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]');
const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]");
return {
index,
tag: element.tagName.toLowerCase(),
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null,
messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null,
traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null,
turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null,
projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]),
sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]),
eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]),
eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null),
eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null,
eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null,
text: textHashInput(element),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
};
});
const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit);
const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => {
const rect = element.getBoundingClientRect();
return {
index,
tag: element.tagName.toLowerCase(),
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
dataRole: element.getAttribute("data-role"),
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
sessionId: element.getAttribute("data-session-id") || null,
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
traceId: element.getAttribute("data-trace-id") || null,
turnId: element.getAttribute("data-turn-id") || null,
durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120),
activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120),
text: stableMessageText(element),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
};
});
const resourceTimingSample = (entry) => ({
name: entry.name.split(/[?#]/u)[0].slice(0, 240),
initiatorType: entry.initiatorType,
startTime: Math.round(entry.startTime),
duration: Math.round(entry.duration),
workerStart: Math.round(entry.workerStart || 0),
redirectStart: Math.round(entry.redirectStart || 0),
redirectEnd: Math.round(entry.redirectEnd || 0),
fetchStart: Math.round(entry.fetchStart || 0),
domainLookupStart: Math.round(entry.domainLookupStart || 0),
domainLookupEnd: Math.round(entry.domainLookupEnd || 0),
connectStart: Math.round(entry.connectStart || 0),
connectEnd: Math.round(entry.connectEnd || 0),
secureConnectionStart: Math.round(entry.secureConnectionStart || 0),
requestStart: Math.round(entry.requestStart || 0),
responseStart: Math.round(entry.responseStart || 0),
responseEnd: Math.round(entry.responseEnd || 0),
transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null,
encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
nextHopProtocol: entry.nextHopProtocol || null,
responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null,
serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({
name: String(item.name || "").slice(0, 80),
duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null,
description: String(item.description || "").slice(0, 120)
})),
});
const opaqueDomId = (value) => String(value || "").trim();
const collectProjectManagement = () => {
const config = input?.projectManagement || {};
const targetPaths = Array.isArray(config.targetPaths) ? config.targetPaths : [];
const path = location.pathname;
const configuredPath = targetPaths.some((target) => path === target || path.startsWith(String(target) + "/"));
const root = document.querySelector('[data-testid="project-management-root"]');
const mdtodoRoot = document.querySelector('[data-testid="project-management-mdtodo"]');
const rootVisible = visible(root);
const mdtodoVisible = visible(mdtodoRoot);
if (!configuredPath && !rootVisible && !mdtodoVisible) return null;
const sourceItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]')).filter(visible);
const fileItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]')).filter(visible);
const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]');
const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]');
const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0;
const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0;
const 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 blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
index,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
text: trim(element.textContent || "", 260),
})).filter((item) => item.text);
const workbenchLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible);
return {
pageKind: mdtodoVisible || path.startsWith("/projects/mdtodo") ? "project-management-mdtodo" : rootVisible || path === "/projects" || path.startsWith("/projects/") ? "project-management-root" : "project-management-unknown",
configuredPath,
rootVisible,
mdtodoVisible,
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
fileCount: Math.max(fileItems.length, fileOptionCount),
taskCount: taskItems.length,
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
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"]')),
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
taskStatusCounts: statusCounts,
launchButtonVisible: visible(launch),
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
launchButtonText: trim(launch?.textContent || "", 120),
blockerCount: blockers.length,
blockers,
workbenchLinkCount: workbenchLinks.length,
valuesRedacted: true,
};
};
const url = location.href;
const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u);
const activeSession = document.querySelector('[data-active="true"][data-session-id], [aria-selected="true"][data-session-id], .active[data-session-id]');
const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null;
const 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,
projectManagement: collectProjectManagement(),
pageProvenance: {
url: location.href,
path: location.pathname,
title: document.title,
readyState: document.readyState,
timeOrigin: Math.round(performance.timeOrigin || 0),
navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null,
scripts: Array.from(document.scripts).map((element) => {
if (!element.src) return null;
try {
const url = new URL(element.src, location.href);
const keys = Array.from(url.searchParams.keys()).sort();
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
} catch {
return null;
}
}).filter(Boolean).sort(),
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => {
try {
const url = new URL(element.href, location.href);
const keys = Array.from(url.searchParams.keys()).sort();
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
} catch {
return null;
}
}).filter(Boolean).sort(),
meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
content: String(element.getAttribute("content") || "").slice(0, 200),
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)),
},
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
};
}, { projectManagement }).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
const sample = {
seq: sampleSeq,
sampleGroupSeq: groupSeq,
ts: new Date().toISOString(),
reason,
pageRole,
pageId: targetPageId,
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
commandId: activeCommandId,
observerInitiated: false,
...digestDom(dom, pageRole),
};
await appendJsonl(files.samples, sample);
}
function digestDom(dom, pageRole = "control") {
if (dom && dom.error) return dom;
const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : [];
const sessionRail = digestSessionRail(dom.sessionRail);
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : [];
const projectManagementSample = digestProjectManagement(dom.projectManagement);
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
if (pageRole === "control") currentPageProvenance = pageProvenance;
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, projectManagement: projectManagementSample, pageProvenance: compactPageProvenance(pageProvenance) };
}
function digestProjectManagement(value) {
if (!value || typeof value !== "object") return null;
const opaque = (raw) => {
const text = String(raw || "");
if (!text) return null;
return {
hash: sha256Text(text),
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
bytes: Buffer.byteLength(text),
valuesRedacted: true
};
};
const textDigest = (raw, limit = 160) => {
const text = String(raw || "");
return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true };
};
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),
selectedTaskRef: opaque(value.selectedTaskRef),
selectedTaskStatus: value.selectedTaskStatus ?? null,
sourceSelectVisible: value.sourceSelectVisible === true,
fileSelectVisible: value.fileSelectVisible === true,
sourceConfigVisible: value.sourceConfigVisible === true,
taskEditorVisible: value.taskEditorVisible === true,
newTaskDraftVisible: value.newTaskDraftVisible === true,
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
launchButtonVisible: value.launchButtonVisible === true,
launchButtonEnabled: value.launchButtonEnabled === true,
launchButtonText: value.launchButtonText ? textDigest(value.launchButtonText, 120) : null,
blockerCount: Number.isFinite(Number(value.blockerCount)) ? Number(value.blockerCount) : 0,
blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 12).map((item) => ({
index: item?.index ?? null,
testId: item?.testId ?? null,
role: item?.role ?? null,
...textDigest(item?.text || "", 160),
})) : [],
workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0,
valuesRedacted: true
};
}
function digestSessionRail(value) {
if (!value || typeof value !== "object") return null;
const items = Array.isArray(value.items) ? value.items.map((item) => {
const titleText = String(item?.titleText || item?.titlePreview || "");
return {
index: item?.index ?? null,
tag: item?.tag ?? null,
testId: item?.testId ?? null,
role: item?.role ?? null,
active: item?.active === true,
sessionId: item?.sessionId ?? null,
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
fallbackTitle: item?.fallbackTitle === true,
titleHash: sha256Text(titleText),
titlePreview: truncate(titleText, 160),
titleBytes: Buffer.byteLength(titleText),
rect: item?.rect ?? null,
};
}) : [];
const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12);
const visibleCount = Number(value.visibleCount ?? items.length);
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
return {
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
items,
fallbackItems,
valuesRedacted: true,
};
}
async function captureScreenshot(reason, imageType = "png") {
if (!page || page.isClosed()) throw new Error("page is not available for screenshot");
artifactSeq += 1;
const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual";
const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png";
const ext = type === "jpeg" ? "jpg" : "png";
const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext);
const options = type === "jpeg" ? { path: file, type: "jpeg", quality: 70, fullPage: false } : { path: file, type: "png", fullPage: false };
await page.screenshot(options);
const meta = await fileMeta(file);
const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl() };
await appendJsonl(files.artifacts, artifact);
lastScreenshotAtMs = Date.now();
return artifact;
}
function eventRecord(type, data) {
const clean = sanitize(data) || {};
return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean };
}
function controlRecord(command, phase, detail) {
return {
ts: new Date().toISOString(),
seq: commandSeq,
phase,
commandId: command.id,
type: command.type,
source: command.source || "file",
input: commandInputSummary(command),
beforeUrl: command.beforeUrl || null,
afterUrl: currentPageUrl(),
pageId,
detail: sanitize(detail),
};
}
function commandInputSummary(command) {
const text = typeof command.text === "string" ? command.text : null;
const opaque = (value) => {
const raw = typeof value === "string" ? value : null;
if (!raw) return null;
return {
hash: sha256Text(raw),
preview: raw.length <= 18 ? raw : raw.slice(0, 10) + "..." + raw.slice(-5),
bytes: Buffer.byteLength(raw),
valuesRedacted: true
};
};
return {
type: command.type,
path: command.path || null,
url: command.url ? safeUrl(command.url) : null,
sessionId: command.sessionId || command.value || null,
provider: command.provider || null,
sourceId: opaque(command.sourceId),
fileRef: opaque(command.fileRef),
taskRef: opaque(command.taskRef),
taskId: command.taskId || null,
titleHash: command.title ? sha256Text(command.title) : null,
titleBytes: command.title ? Buffer.byteLength(command.title) : null,
bodyHash: command.body ? sha256Text(command.body) : null,
bodyBytes: command.body ? Buffer.byteLength(command.body) : null,
status: command.status || null,
hwpodId: opaque(command.hwpodId),
nodeId: opaque(command.nodeId),
workspaceRoot: opaque(command.workspaceRoot),
root: opaque(command.root),
label: command.label ? truncate(command.label, 200) : null,
textHash: text === null ? null : sha256Text(text),
textBytes: text === null ? null : Buffer.byteLength(text),
textPreview: null,
valuesRedacted: true,
};
}
async function appendJsonl(file, value) {
await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 });
}
async function fileMeta(file) {
const [buffer, stats] = await Promise.all([readFile(file), stat(file)]);
return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") };
}
function currentPageUrl() {
return pageUrl(page);
}
function pageUrl(targetPage) {
try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; }
}
function routeSessionIdFromUrl(value) {
try {
const pathname = new URL(String(value || ""), baseUrl).pathname;
const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
} catch {
return null;
}
}
function safeFrameUrl(frame) {
try { return frame ? safeUrl(frame.url()) : null; } catch { return null; }
}
function safeUrl(value) {
try {
const url = new URL(String(value), baseUrl);
for (const key of Array.from(url.searchParams.keys())) {
if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]");
}
return url.toString();
} catch {
return truncate(String(value || ""), 300);
}
}
function safeUrlPath(value) {
try {
return new URL(String(value || ""), baseUrl).pathname;
} catch {
return null;
}
}
function normalizeBaseUrl(value) {
const raw = value || "http://127.0.0.1:3000";
const url = new URL(raw);
return url.origin;
}
function parseViewport(value) {
const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u);
return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 };
}
function positiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
}
function positiveNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function requiredPositiveThreshold(raw, key) {
const parsed = Number(raw?.[key]);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
}
return parsed;
}
function parseAlertThresholds(value) {
if (!value) {
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
}
const raw = (() => {
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
})();
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
if (sessionRailFallbackRatio > 1) {
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
}
return {
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
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)));
}
`;
}