Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-runner-control-source.ts
T

1942 lines
100 KiB
TypeScript

// SPEC: PJ2026-01040111 long-running Workbench observation.
// Responsibility: Runner command queue, authentication, navigation, control page recovery, and prompt submission source fragment.
export function nodeWebObserveRunnerControlSource(): string {
return String.raw`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;
activeCommandType = null;
await writeHeartbeat({ status: terminalStatus });
}
}
function startCommandActiveSampler(command) {
const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000);
const heartbeatIntervalMs = Math.min(5000, intervalMs);
let stopped = false;
let timer = null;
let heartbeatTimer = null;
let inFlight = false;
const heartbeat = () => {
if (stopped) return;
void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true })
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })));
};
const schedule = () => {
if (stopped) return;
timer = setTimeout(tick, intervalMs);
if (timer && typeof timer.unref === "function") timer.unref();
};
const tick = () => {
if (stopped) return;
if (inFlight) {
schedule();
return;
}
inFlight = true;
samplePage("command-active", { refreshObserver: false, screenshot: false })
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })))
.finally(() => {
inFlight = false;
schedule();
});
};
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
schedule();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
};
}
async function processCommand(command) {
commandSeq += 1;
activeCommandId = command.id;
activeCommandType = command.type;
await writeHeartbeat({ status: "running", activeCommandId, activeCommandType });
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context);
case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
case "logout": return withObserverSync(await logoutAccount(command), "logout");
case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), {
expectedAction: "turn",
responsePath: "/v1/agent/chat",
alternateResponsePaths: ["/v1/agent/chat/steer"],
noActiveReason: "send-no-turn-composer",
throwOnActionMismatch: true,
expectedActionWaitMs: command.expectedActionWaitMs,
}), "sendPrompt");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer");
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession");
case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack");
case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant");
case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo");
case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command);
case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command);
case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command);
case "probeMdtodoSource": return probeMdtodoSource(command);
case "reindexMdtodoSource": return reindexMdtodoSource(command);
case "selectProjectSource": return selectProjectSource(command);
case "selectMdtodoSource": return selectMdtodoSource(command);
case "selectMdtodoFile": return selectMdtodoFile(command);
case "selectMdtodoTask": return selectMdtodoTask(command);
case "expandMdtodoTask": return expandMdtodoTask(command);
case "openMdtodoReportPreview": return openMdtodoReportPreview(command);
case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command);
case "editMdtodoTaskInline": return editMdtodoTaskInline(command);
case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command);
case "editMdtodoTaskBody": return editMdtodoTaskBody(command);
case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command);
case "addMdtodoRootTask": return addMdtodoRootTask(command);
case "addMdtodoSubTask": return addMdtodoSubTask(command);
case "continueMdtodoTask": return continueMdtodoTask(command);
case "deleteMdtodoTask": return deleteMdtodoTask(command);
case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask");
case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo");
case "performanceCapture": return capturePerformanceProfile(command);
case "screenshot": return captureCommandScreenshot(command);
case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId };
case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId };
default: throw new Error("unsupported observer command type: " + command.type);
}
}
async function withObserverSync(result, reason) {
return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) };
}
async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) {
if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" };
const forceRefresh = options?.forceRefresh === true;
const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000;
const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000;
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 15000;
const shortCircuitReadinessTimeoutMs = Number.isFinite(Number(options.shortCircuitReadinessTimeoutMs)) ? Math.max(1, Number(options.shortCircuitReadinessTimeoutMs)) : 1000;
const shortCircuitHydrationTimeoutMs = Number.isFinite(Number(options.shortCircuitHydrationTimeoutMs)) ? Math.max(1, Number(options.shortCircuitHydrationTimeoutMs)) : 1000;
const snapshot = await workbenchSessionSnapshot();
const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath;
const targetUrl = new URL(target, baseUrl).toString();
const beforeUrl = pageUrl(observerPage);
const beforeSessionId = routeSessionIdFromUrl(beforeUrl);
const attempts = [];
if (sessionId && beforeSessionId === sessionId && !forceRefresh) {
const current = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: shortCircuitReadinessTimeoutMs, hydrationTimeoutMs: shortCircuitHydrationTimeoutMs });
if (current.ok) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, readiness: current.readiness, hydration: current.hydration, valuesRedacted: true };
attempts.push({ attempt: 0, ok: false, shortCircuitRejected: true, failureKind: current.failureKind, readiness: current.readiness, hydration: current.hydration, beforeUrl, afterUrl: pageUrl(observerPage), valuesRedacted: true });
}
const maxAttempts = Number.isFinite(Number(options?.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptBeforeUrl = pageUrl(observerPage);
observerPageEpoch += 1;
let status = null;
let statusText = null;
const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }).catch((error) => ({ observerGotoError: errorSummary(error) }));
if (response?.observerGotoError) {
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(response.observerGotoError?.message || response.observerGotoError?.name || "observer-navigation-error"), beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), error: response.observerGotoError, valuesRedacted: true });
if (attempt < maxAttempts && isRetryableNavigationError(response.observerGotoError?.message || response.observerGotoError?.name || "")) {
await recreateObserverPageForNavigation("observer-goto-error", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: false, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, attempts, valuesRedacted: true };
}
status = typeof response?.status === "function" ? response.status() : null;
statusText = typeof response?.statusText === "function" ? response.statusText() : null;
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: readinessTimeoutMs });
if (!readiness.ok) {
attempts.push({ attempt, ok: false, failureKind: readiness.reason || "observer-target-not-ready", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
if (attempt < maxAttempts && observerReadinessRetryable(readiness)) {
await recreateObserverPageForNavigation(readiness.reason || "observer-target-not-ready", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, attempts, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
}
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
lastObserverRefreshAtMs = Date.now();
attempts.push({ attempt, ok: true, beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, attempts, valuesRedacted: true };
}
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: hydrationTimeoutMs });
attempts.push({ attempt, ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, hydration, valuesRedacted: true });
if (hydration.ok === true) {
lastObserverRefreshAtMs = Date.now();
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, attempts, failureKind: null, valuesRedacted: true };
}
if (attempt < maxAttempts && observerHydrationRetryable(hydration)) {
await recreateObserverPageForNavigation(hydration.reason || "observer-session-hydration-failed", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
continue;
}
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, attempts, failureKind: hydration.reason || "observer-session-hydration-failed", valuesRedacted: true };
}
lastObserverRefreshAtMs = Date.now();
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, attempts, failureKind: "observer-sync-retry-exhausted", valuesRedacted: true };
}
async function observerSessionReadiness(targetUrl, sessionId, options = {}) {
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: options.readinessTimeoutMs ?? 1000 });
if (!readiness.ok) return { ok: false, failureKind: readiness.reason || "observer-target-not-ready", readiness, hydration: null, valuesRedacted: true };
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) return { ok: true, readiness, hydration: null, valuesRedacted: true };
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: options.hydrationTimeoutMs ?? 1000 });
return { ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", readiness, hydration, valuesRedacted: true };
}
function observerReadinessRetryable(readiness) {
const reason = String(readiness?.reason || "");
const snapshot = readiness?.snapshot || {};
return /workbench-app-not-ready|observer-target-not-ready/iu.test(reason)
|| snapshot.workbenchShellVisible === false
|| snapshot.sessionRailPresent === false && snapshot.commandInputPresent === false;
}
function observerHydrationRetryable(hydration) {
const reason = String(hydration?.reason || "");
return /observer-session-hydration-timeout|observer-session-hydration-failed|active-session-not-hydrated|route-session-not-hydrated/iu.test(reason);
}
async function recreateObserverPageForNavigation(reason, attempt) {
const before = pageUrl(observerPage);
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
observerPage = await context.newPage();
attachPassiveListeners(observerPage, "observer", observerPageId);
await appendJsonl(files.control, eventRecord("observer-page-recreated", { reason, attempt, beforeUrl: before, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, valuesRedacted: true }));
}
async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000;
const started = Date.now();
const deadline = started + Math.max(1, timeoutMs);
let last = null;
while (Date.now() <= deadline) {
last = await workbenchSessionSnapshot(targetPage);
const expected = String(sessionId || "").trim();
const observedPath = safeUrlPath(last?.url || pageUrl(targetPage));
const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || "");
const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || "");
if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
await targetPage.waitForTimeout(250).catch(() => {});
}
return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true };
}
async function maybeRefreshObserverPage(reason) {
if (!observerPage || observerPage.isClosed()) return null;
if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null;
if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null;
const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true });
await appendJsonl(files.control, eventRecord("observer-periodic-refresh", {
pageRole: "observer",
pageId: observerPageId,
reason,
intervalMs: observerRefreshIntervalMs,
result,
valuesRedacted: true
}));
return result;
}
async function runControlCommand(command, fn) {
activeCommandId = command.id;
commandSeq += 1;
const beforeUrl = currentPageUrl();
const started = Date.now();
await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) }));
try {
const result = await fn();
await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) }));
return result;
} catch (error) {
let failurePageProvenance = null;
let failureScreenshot = null;
try {
failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null));
} catch (captureError) {
await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
}
try {
failureScreenshot = await captureScreenshot("command-failed", "jpeg");
} catch (captureError) {
await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
}
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot }));
throw error;
} finally {
activeCommandId = null;
}
}
async function authenticate(browserContext) {
const loginUrl = new URL("/auth/login", baseUrl).toString();
const attempts = [];
const maxAttempts = authLoginMaxAttempts;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
const retryLabel = attempt + "/" + maxAttempts;
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
try {
const response = await pageAuthLogin(browserContext, loginUrl);
const cookieState = await readAuthCookieState(browserContext);
const retryable = isRetryableAuthStatus(response.status);
const item = {
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: response.status,
statusText: response.statusText,
retryable,
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
valuesRedacted: true,
};
attempts.push(item);
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
if (response.ok && cookieState.cookiePresent) {
return {
ok: true,
method: "api",
loginPath: new URL(loginUrl).pathname,
status: response.status,
statusText: response.statusText,
cookiePresent: true,
cookieNames: cookieState.cookieNames,
attempts,
retryCount: attempt - 1,
retryMaxAttempts: maxAttempts,
lastRetryLabel: attempt + "/" + maxAttempts,
retryExhausted: false,
retryable: false,
valuesRedacted: true,
};
}
if (!retryable) break;
} catch (error) {
const retryable = isRetryableAuthError(error);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: 0,
statusText: "request-error",
retryable,
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
cookiePresent: false,
cookieNames: [],
valuesRedacted: true,
});
const item = attempts[attempts.length - 1] || null;
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {});
if (!retryable) break;
}
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
}
const cookieState = await readAuthCookieState(browserContext);
const last = attempts[attempts.length - 1] || null;
const retryable = attempts.some((attempt) => attempt && attempt.retryable === true);
const failure = {
ok: false,
method: "api",
loginPath: new URL(loginUrl).pathname,
status: typeof last?.status === "number" ? last.status : 0,
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
attempts,
retryCount: Math.max(0, attempts.length - 1),
retryMaxAttempts: maxAttempts,
lastRetryLabel: last?.retryLabel || null,
retryExhausted: retryable && attempts.length >= maxAttempts,
retryable,
lastError: last?.error || null,
valuesRedacted: true,
};
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {});
const error = new Error(authFailureMessage(failure));
error.webProbeAuth = failure;
throw error;
}
async function pageAuthLogin(browserContext, loginUrl, credential = { username, password }) {
if (!browserContext?.request) throw new Error("auth browser context request is not ready");
const response = await browserContext.request.post(loginUrl, {
data: { username: credential.username, password: credential.password },
headers: { accept: "application/json", "content-type": "application/json" },
timeout: authLoginRequestTimeoutMs,
});
await response.text().catch(() => "");
return {
ok: response.ok(),
status: response.status(),
statusText: response.statusText() || "",
};
}
async function loginAccount(command) {
const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
const credential = credentialForAccount(accountId);
const loginUrl = new URL("/auth/login", baseUrl).toString();
const before = await accountSessionSnapshot();
const attempts = [];
let response = null;
let cookieState = null;
const maxAttempts = authLoginMaxAttempts;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
const retryLabel = attempt + "/" + maxAttempts;
try {
response = await pageAuthLogin(context, loginUrl, credential);
cookieState = await readAuthCookieState(context);
const retryable = isRetryableAuthStatus(response.status);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: response.status,
statusText: response.statusText,
retryable,
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
credentialSource: credential.source,
valuesRedacted: true,
});
if (response.ok && cookieState.cookiePresent) break;
if (!retryable) break;
} catch (error) {
const retryable = isRetryableAuthError(error);
attempts.push({
attempt,
retryAttempt: attempt,
retryMaxAttempts: maxAttempts,
retryLabel,
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
requestTimeoutMs: authLoginRequestTimeoutMs,
method: "api",
status: 0,
statusText: "request-error",
retryable,
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
cookiePresent: false,
cookieNames: [],
credentialSource: credential.source,
valuesRedacted: true,
});
response = { ok: false, status: 0, statusText: "request-error" };
cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] }));
if (!retryable) break;
}
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
}
response = response ?? { ok: false, status: 0, statusText: "api-login-failed" };
cookieState = cookieState ?? await readAuthCookieState(context);
if (!response.ok || !cookieState.cookiePresent) {
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
const retryable = attempts.some((item) => item && item.retryable === true);
error.details = {
accountId,
status: response.status,
statusText: response.statusText,
cookiePresent: cookieState.cookiePresent,
credentialSource: credential.source,
attempts,
retryCount: Math.max(0, attempts.length - 1),
retryMaxAttempts: maxAttempts,
lastRetryLabel: attempts[attempts.length - 1]?.retryLabel || null,
retryExhausted: retryable && attempts.length >= maxAttempts,
retryable,
valuesRedacted: true,
};
throw error;
}
const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
const navigation = await gotoTarget(target || targetPath);
const after = await accountSessionSnapshot();
return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
}
async function logoutAccount(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
const before = await accountSessionSnapshot();
const logoutUrl = new URL("/logout", baseUrl).toString();
const response = await page.evaluate(async (input) => {
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
await res.text().catch(() => "");
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
}, { logoutUrl });
await context.clearCookies().catch(() => {});
const cookieState = await readAuthCookieState(context);
const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
if (!result.ok) {
const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
error.details = result;
throw error;
}
return result;
}
async function listSessions(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
const snapshot = await workbenchSessionSnapshot();
const sessions = await page.evaluate(() => {
const seen = new Set();
const rows = [];
for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
if (!sessionId || seen.has(sessionId)) continue;
seen.add(sessionId);
rows.push({
sessionId,
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
status: element.getAttribute("data-status") || null,
conversationId: element.getAttribute("data-conversation-id") || null,
});
}
return rows.slice(0, 50);
}).catch(() => []);
return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
}
async function switchSessions(command) {
const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
const before = await accountSessionSnapshot();
if (fromAccountId) {
const beforeAccount = before.accountId || null;
await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
}
const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
const login = await loginAccount({ ...command, accountId: toAccountId });
const sessions = await listSessions({ ...command, accountId: toAccountId });
return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
}
async function accountSessionSnapshot() {
const cookieState = await readAuthCookieState(context);
const workbench = await workbenchSessionSnapshot().catch(() => null);
return {
url: currentPageUrl(),
path: safeUrlPath(currentPageUrl()),
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
activeSessionId: workbench?.activeSessionId || null,
routeSessionId: workbench?.routeSessionId || null,
tabCount: workbench?.tabCount ?? null,
messageCount: workbench?.messageCount ?? null,
valuesRedacted: true,
};
}
function requiredAccountId(command, keys) {
const accountId = commandValue(command, keys);
if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
return accountId;
}
function credentialForAccount(accountId) {
if (accountId === "bootstrap-admin" || accountId === "admin") {
if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
}
const env = accountCredentialEnvCandidates(accountId);
for (const jsonKey of env.jsonKeys) {
const raw = process.env[jsonKey];
if (!raw) continue;
const parsed = parseCredentialJson(raw);
if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
}
for (const pair of env.pairs) {
const user = process.env[pair.userKey];
const pass = process.env[pair.passKey];
if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
}
throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
}
function accountCredentialEnvCandidates(accountId) {
const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return {
jsonKeys: [
"HWLAB_WEB_" + segment + "_JSON",
"HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
],
pairs: [
{ userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
{ userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
],
};
}
function parseCredentialJson(raw) {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
if (!user || !pass) return null;
return { username: user, password: pass, valuesRedacted: true };
} catch {
return null;
}
}
function isSafeAccountId(value) {
return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
}
function publicAuth(value) {
if (!value) return null;
return {
ok: value.ok === true,
method: value.method,
status: value.status,
cookiePresent: value.cookiePresent === true,
cookieNames: value.cookieNames || [],
retryCount: value.retryCount ?? null,
retryMaxAttempts: value.retryMaxAttempts ?? null,
lastRetryLabel: value.lastRetryLabel ?? null,
retryExhausted: value.retryExhausted === true,
valuesRedacted: true
};
}
async function readAuthCookieState(browserContext) {
const cookies = await browserContext.cookies(baseUrl);
const cookieNames = cookies.map((cookie) => cookie.name).sort();
return {
cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)),
cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)),
};
}
function isRetryableAuthStatus(status) {
return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
}
function isRetryableAuthError(error) {
const message = error && error.message ? String(error.message) : String(error || "");
return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message);
}
function authRetryDelayMs(attempt, maxAttempts) {
return attempt < maxAttempts ? Math.min(authLoginMaxDelayMs, authLoginInitialDelayMs * (2 ** (attempt - 1))) : 0;
}
function authFailureMessage(failure) {
const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null;
const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : "";
const exhausted = failure.retryExhausted ? " exhausted=true" : "";
const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : "";
const error = last?.error ? " error=" + truncate(last.error, 160) : "";
return ("auth login failed:" + retry + exhausted + status + error).trim();
}
function proxyConfigFromEnv(targetBaseUrl) {
if (browserProxyMode === "direct") return null;
let target;
try {
target = new URL(targetBaseUrl);
} catch {
return null;
}
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
if (noProxyMatches(target.hostname, noProxy)) return null;
const raw = target.protocol === "https:"
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || "";
if (!raw) return null;
return { server: raw };
}
function chromiumLaunchOptionsForProxy(proxy) {
const baseArgs = chromiumLowResourceArgs();
const base = { env: browserProcessEnvWithoutProxy() };
if (proxy === null) return { ...base, args: [...baseArgs, "--no-proxy-server"] };
return { ...base, proxy, args: baseArgs };
}
function chromiumLowResourceArgs() {
return [];
}
function browserProcessEnvWithoutProxy() {
const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
const env = {};
for (const [key, value] of Object.entries(process.env)) {
if (!blocked.has(key) && value !== undefined) env[key] = value;
}
return env;
}
function noProxyMatches(hostname, rawList) {
const host = String(hostname || "").toLowerCase();
if (!host) return false;
return String(rawList || "").split(",").some((raw) => {
let item = raw.trim().toLowerCase();
if (!item) return false;
if (item === "*") return true;
item = item.replace(/^\*\./u, ".");
const portIndex = item.lastIndexOf(":");
if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex);
if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item);
return host === item;
});
}
function publicNetwork(proxy) {
return {
proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : {
enabled: true,
source: "env",
server: publicProxyServer(proxy.server),
valuesRedacted: true,
},
browser: {
proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy",
requestedProxyMode: browserProxyMode,
proxyEnvCleared: true,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
function parseBrowserProxyMode(raw) {
if (raw === "auto" || raw === "direct") return raw;
return "auto";
}
function publicProxyServer(raw) {
try {
const parsed = new URL(String(raw || ""));
parsed.username = "";
parsed.password = "";
const value = parsed.toString();
if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, "");
return value;
} catch {
return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@");
}
}
async function gotoTarget(rawTarget, options = {}) {
const target = new URL(String(rawTarget || targetPath), baseUrl).toString();
const beforeUrl = currentPageUrl();
const attempts = [];
const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : navigationMaxAttempts;
const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000;
const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000;
const settleMs = Number.isFinite(Number(options.settleMs)) ? Math.max(0, Number(options.settleMs)) : 1000;
const lateReadinessTimeoutMs = Number.isFinite(Number(options.lateReadinessTimeoutMs)) ? Math.max(0, Number(options.lateReadinessTimeoutMs)) : 5000;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs });
if (settleMs > 0) await page.waitForTimeout(settleMs).catch(() => {});
const httpStatus = response ? response.status() : null;
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: readinessTimeoutMs });
if (!readiness.ok) {
const pageProvenance = await refreshPageProvenance("goto-degraded", httpStatus).catch(() => null);
attempts.push({ attempt, ok: false, degraded: true, httpStatus, readiness, failureKind: readiness.reason || "workbench-app-not-ready" });
if (!readinessRetryable(readiness)) {
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
}
if (attempt >= maxAttempts) {
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
}
await recreateControlPageForNavigation("retryable-readiness-" + (readiness.reason || "target-not-ready"), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
await page.waitForTimeout(1500 * attempt).catch(() => {});
continue;
}
const pageProvenance = await refreshPageProvenance("goto", httpStatus);
attempts.push({ attempt, ok: true, httpStatus, readiness });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null });
if (lateReadinessTimeoutMs > 0 && /workbench-app-not-ready|navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(message)) {
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: lateReadinessTimeoutMs }).catch(() => null);
if (lateReadiness?.ok) {
const pageProvenance = await refreshPageProvenance("goto-late-ready", null);
attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness });
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts };
}
}
if (attempt >= maxAttempts || !isRetryableNavigationError(message)) {
throw Object.assign(new Error(message), { attempts, target });
}
if (!observerPage) {
await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
} else {
await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
}
await page.waitForTimeout(1500 * attempt).catch(() => {});
}
}
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts };
}
async function recreateControlPageForNavigation(reason, attempt) {
const before = currentPageUrl();
if (page && !page.isClosed()) await withHardTimeout(page.close(), 3000, "control page close exceeded 3000ms").catch((error) => appendJsonl(files.errors, eventRecord("control-page-close-timeout", { reason, attempt, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
controlPageEpoch += 1;
page = await context.newPage();
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }));
}
async function recreateAuthenticatedContextForNavigation(reason, attempt) {
const before = currentPageUrl();
if (page && !page.isClosed()) await page.close().catch(() => {});
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
observerPage = null;
if (context) await context.close().catch(() => {});
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
auth = await authenticate(context);
page = await context.newPage();
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true }));
}
async function refreshPageProvenance(reason, httpStatus = null) {
if (!page || page.isClosed()) return currentPageProvenance;
const observed = await page.evaluate(() => {
const assetPath = (raw) => {
if (!raw) return null;
try {
const url = new URL(raw, location.href);
const keys = Array.from(url.searchParams.keys()).sort();
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
} catch {
return null;
}
};
const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
content: String(element.getAttribute("content") || "").slice(0, 200),
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key));
const navigation = performance.getEntriesByType("navigation")[0] || null;
return {
url: location.href,
path: location.pathname,
title: document.title,
readyState: document.readyState,
timeOrigin: Math.round(performance.timeOrigin || 0),
navigationStartTime: navigation ? Math.round(navigation.startTime) : null,
scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(),
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(),
meta,
};
}).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] }));
pageLoadSeq += 1;
currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq });
await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) }));
return currentPageProvenance;
}
function normalizePageProvenance(value, options = {}) {
const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : [];
const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : [];
const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({
key: String(item?.key || "").slice(0, 120),
contentHash: sha256Text(String(item?.content || "")),
})).filter((item) => item.key) : [];
const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta });
return {
pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq,
reason: options.reason || "sample",
observedAt: new Date().toISOString(),
urlPath: safeUrlPath(value?.url || currentPageUrl()),
documentPath: value?.path || null,
titleHash: sha256Text(String(value?.title || "")),
documentReadyState: value?.readyState || null,
timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null,
navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null,
httpStatus: options.httpStatus ?? null,
assetFingerprint: sha256Text(fingerprintInput),
scriptCount: scripts.length,
stylesheetCount: stylesheets.length,
metaCount: meta.length,
scripts: scripts.slice(0, 30),
stylesheets: stylesheets.slice(0, 30),
meta: meta.slice(0, 30),
error: value?.error || null,
valuesRedacted: true,
};
}
function compactPageProvenance(value) {
if (!value) return null;
return {
pageLoadSeq: value.pageLoadSeq ?? null,
reason: value.reason || null,
observedAt: value.observedAt || null,
urlPath: value.urlPath || null,
documentReadyState: value.documentReadyState || null,
timeOrigin: value.timeOrigin ?? null,
httpStatus: value.httpStatus ?? null,
assetFingerprint: value.assetFingerprint || null,
scriptCount: value.scriptCount ?? 0,
stylesheetCount: value.stylesheetCount ?? 0,
metaCount: value.metaCount ?? 0,
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [],
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [],
error: value.error || null,
valuesRedacted: true,
};
}
function isRetryableNavigationError(message) {
return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|net::ERR_NAME_NOT_RESOLVED|Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded|workbench-app-not-ready/iu.test(String(message || ""));
}
function readinessRetryable(readiness) {
const reason = String(readiness?.reason || "");
return !/nav-access-denied|workbench-blank-document|login-visible/iu.test(reason);
}
function navigationFailureKind(message) {
const text = String(message || "");
if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED";
if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED";
if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET";
if (/net::ERR_NAME_NOT_RESOLVED/iu.test(text)) return "net::ERR_NAME_NOT_RESOLVED";
if (/Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(text)) return "navigation-timeout";
if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready";
return "navigation-error";
}
function redactErrorMessage(message) {
return String(message || "")
.replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]");
}
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
const targetPathname = safeUrlPath(targetUrl) || "";
if (isProjectManagementPathname(targetPathname)) {
const started = Date.now();
const selectors = projectManagement.readinessSelectors;
await targetPage.waitForFunction((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return input.selectors.some((selector) => {
try { return visible(document.querySelector(selector)); } catch { return false; }
});
}, { selectors }, { timeout: timeoutMs }).catch(() => null);
const snapshot = await projectManagementReadinessSnapshot(targetPage);
const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true;
return {
ok,
reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready",
durationMs: Date.now() - started,
snapshot,
valuesRedacted: true
};
}
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
const started = Date.now();
await targetPage.waitForFunction(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const workspace = document.querySelector("#workspace, .workbench-route");
const login = document.querySelector("form.login-card, .login-card, [data-testid='login']");
return Boolean(visible(workspace) || visible(login));
}, null, { timeout: timeoutMs }).catch(() => null);
const snapshot = await workbenchReadinessSnapshot(targetPage);
const ok = snapshot.workbenchShellVisible === true;
const reason = workbenchReadinessReason(snapshot, ok);
return {
ok,
reason,
durationMs: Date.now() - started,
snapshot,
valuesRedacted: true
};
}
async function workbenchReadinessSnapshot(targetPage) {
const snapshot = await targetPage.evaluate(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const sessionCreate = document.querySelector("#session-create");
const sessionRail = document.querySelector("#session-sidebar");
const sessionCollapseToggle = document.querySelector("#session-collapse-toggle");
const params = new URLSearchParams(window.location.search || "");
const bodyText = String(document.body?.innerText || "");
return {
url: window.location.href,
path: window.location.pathname,
search: window.location.search || "",
blockedReason: params.get("blocked"),
readyState: document.readyState,
workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")),
sessionCreatePresent: Boolean(sessionCreate),
sessionCreateVisible: visible(sessionCreate),
sessionRailPresent: Boolean(sessionRail),
sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null,
sessionCollapseTogglePresent: Boolean(sessionCollapseToggle),
sessionCollapseToggleVisible: visible(sessionCollapseToggle),
sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null,
commandInputPresent: visible(document.querySelector("#command-input")),
activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")),
warningPresent: visible(document.querySelector(".composer-warning")),
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
bodyTextBytes: new TextEncoder().encode(bodyText).length,
bodyTextPreview: bodyText.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;
}
function workbenchReadinessReason(snapshot, ok = false) {
if (ok) return "workbench-ready";
const blocked = String(snapshot?.blockedReason || "");
if (blocked === "nav_access_denied") return "nav-access-denied";
if (snapshot?.loginVisible === true) return "login-visible";
if (
snapshot?.readyState === "complete"
&& snapshot?.workbenchShellVisible === false
&& snapshot?.sessionRailPresent === false
&& snapshot?.commandInputPresent === false
&& (snapshot?.bodyTextBytes === 0 || snapshot?.bodyTextHash === "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
) {
return "workbench-blank-document";
}
return "workbench-app-not-ready";
}
async function projectManagementReadinessSnapshot(targetPage) {
const selectors = projectManagement.readinessSelectors;
return targetPage.evaluate((input) => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const selectorStates = input.selectors.map((selector) => {
let matched = false;
let visibleMatched = false;
try {
const element = document.querySelector(selector);
matched = Boolean(element);
visibleMatched = visible(element);
} catch {}
return { selector, matched, visible: visibleMatched };
});
return {
url: window.location.href,
path: window.location.pathname,
readyState: document.readyState,
projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')),
mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')),
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
selectorStates,
valuesRedacted: true
};
}, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
}
async function waitForProjectManagementCommandReady(options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
const started = Date.now();
const deadline = started + timeoutMs;
let last = null;
while (Date.now() <= deadline) {
last = await projectManagementCommandSnapshot();
const path = String(last?.path || safeUrlPath(currentPageUrl()) || "");
const baseReady = last?.pageKind === "project-management-mdtodo"
&& Number(last?.sourceCount || 0) > 0
&& Number(last?.fileCount || 0) > 0
&& Number(last?.taskCount || 0) > 0;
const needsTask = /\/tasks\//u.test(path);
const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
const needsReport = /\/reports\//u.test(path);
const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true;
if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
await page.waitForTimeout(250).catch(() => {});
}
return { ok: false, reason: "project-management-command-not-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
}
function isWorkbenchPathname(value) {
const pathname = String(value || "");
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
}
function isProjectManagementPathname(value) {
if (projectManagement.enabled !== true) return false;
const pathname = String(value || "");
return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/"));
}
function isAgentSessionCreateRequest(requestOrUrl) {
const method = typeof requestOrUrl?.method === "function" ? requestOrUrl.method().toUpperCase() : "";
if (method && method !== "POST") return false;
const url = typeof requestOrUrl === "string" ? requestOrUrl : typeof requestOrUrl?.url === "function" ? requestOrUrl.url() : "";
try {
return new URL(url).pathname === "/v1/agent/sessions";
} catch {
return false;
}
}
function requestFailureSummary(request) {
let failure = null;
try {
failure = request.failure();
} catch {}
let urlPath = null;
try {
urlPath = new URL(request.url()).pathname;
} catch {}
return {
method: typeof request.method === "function" ? request.method().toUpperCase() : null,
urlPath,
failureText: failure?.errorText || null,
valuesRedacted: true
};
}
async function ensureSessionRailExpanded() {
const before = await workbenchReadinessSnapshot(page);
if (before?.sessionCreateVisible === true) {
return { ok: true, action: "already-visible", before, after: before, valuesRedacted: true };
}
const toggle = page.locator("#session-collapse-toggle").first();
const toggleVisible = await toggle.isVisible({ timeout: 2000 }).catch(() => false);
if (before?.sessionRailCollapsed !== true || !toggleVisible) {
return { ok: false, action: "not-expanded", reason: before?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before, after: before, valuesRedacted: true };
}
await toggle.click();
await page.waitForFunction(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const rail = document.querySelector("#session-sidebar");
return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false"));
}, null, { timeout: 5000 }).catch(() => null);
const after = await workbenchReadinessSnapshot(page);
return {
ok: after?.sessionCreateVisible === true,
action: "expanded-session-rail",
before,
after,
valuesRedacted: true
};
}
async function clickAndWaitForAgentSessionCreate(create) {
let removeRequestFailedListener = () => {};
const requestFailedPromise = new Promise((resolve) => {
let timeout = null;
const handler = (request) => {
if (!isAgentSessionCreateRequest(request)) return;
removeRequestFailedListener();
resolve({ kind: "requestfailed", requestFailure: requestFailureSummary(request) });
};
removeRequestFailedListener = () => {
page.off("requestfailed", handler);
if (timeout !== null) clearTimeout(timeout);
timeout = null;
};
page.on("requestfailed", handler);
timeout = setTimeout(() => {
removeRequestFailedListener();
resolve(null);
}, 45000);
});
const createResponsePromise = page.waitForResponse((response) => {
const request = response.request();
return isAgentSessionCreateRequest(request) || isAgentSessionCreateRequest(response.url());
}, { timeout: 45000 }).then((response) => ({ kind: "response", response })).catch((error) => ({ kind: "wait-error", waitError: errorSummary(error) }));
await create.click();
const outcome = await Promise.race([createResponsePromise, requestFailedPromise]);
removeRequestFailedListener();
return outcome ?? await createResponsePromise;
}
async function createSessionFromUi() {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
const attempts = [];
let createResponse = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const railExpansion = await ensureSessionRailExpanded();
const readinessBeforeClick = railExpansion.after || await workbenchReadinessSnapshot(page);
const create = page.locator("#session-create").first();
try {
await create.waitFor({ state: "visible", timeout: 15000 });
} catch (error) {
const readinessAfterWait = await workbenchReadinessSnapshot(page);
const createError = new Error("newSession session create button is not visible");
createError.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, attempt, readinessBeforeClick, readinessAfterWait, railExpansion, waitError: errorSummary(error), pageId, valuesRedacted: true };
throw createError;
}
const createButtonState = await create.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
tag: element.tagName.toLowerCase(),
id: element.id || null,
disabled: Boolean(element.disabled),
ariaDisabled: element.getAttribute("aria-disabled") || null,
visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none",
rect: { width: Math.round(rect.width), height: Math.round(rect.height) },
valuesRedacted: true
};
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
const outcome = await clickAndWaitForAgentSessionCreate(create);
if (outcome?.kind === "response") {
createResponse = outcome.response;
attempts.push({ attempt, outcome: "response", readinessBeforeClick, railExpansion, createButtonState, valuesRedacted: true });
break;
}
const afterAttempt = await workbenchSessionSnapshot();
attempts.push({
attempt,
outcome: outcome?.kind || "unknown",
readinessBeforeClick,
railExpansion,
createButtonState,
waitError: outcome?.waitError || null,
requestFailure: outcome?.requestFailure || null,
after: afterAttempt,
valuesRedacted: true
});
if (attempt < 2 && outcome?.kind === "requestfailed") {
await page.waitForTimeout(1500);
continue;
}
const waitError = outcome?.waitError || outcome?.requestFailure || { name: outcome?.kind || "unknown", valuesRedacted: true };
const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (waitError.message || waitError.failureText || waitError.name || "timeout"));
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, pageId, valuesRedacted: true };
throw error;
}
if (createResponse === null) throw new Error("newSession did not produce an authoritative session create response");
const createStatus = createResponse.status();
let createPayload = null;
let createPayloadError = null;
try {
createPayload = await createResponse.json();
} catch (error) {
createPayloadError = errorSummary(error);
}
const createdSessionId = sessionIdFromAgentSessionPayload(createPayload);
if (createStatus < 200 || createStatus >= 300 || !createdSessionId) {
const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions");
error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true };
throw error;
}
await page.waitForFunction((expectedSessionId) => {
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const sessionId = activeTab?.getAttribute("data-session-id") || "";
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : "";
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || "";
const input = document.querySelector("#command-input");
return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning);
}, createdSessionId, { timeout: 45000 }).catch(() => null);
const after = await workbenchSessionSnapshot();
const afterSessionId = after?.activeSessionId || after?.routeSessionId || "";
const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady);
if (!ok) {
const error = new Error("newSession did not select the authoritative newly created workbench session");
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, attempts, pageId, valuesRedacted: true };
throw error;
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
ok,
before,
after,
attempts,
sessionId: createdSessionId,
createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true },
pageId
};
}
function sessionIdFromAgentSessionPayload(payload) {
const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id;
const directText = String(direct || "").trim();
if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText;
const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u);
return match ? match[0] : null;
}
function traceIdFromAgentChatPayload(payload) {
const direct = payload?.traceId ?? payload?.turn?.traceId ?? payload?.message?.traceId ?? payload?.data?.traceId ?? payload?.data?.turn?.traceId ?? payload?.data?.message?.traceId;
const directText = String(direct || "").trim();
if (/^(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})$/u.test(directText)) return directText;
const match = JSON.stringify(payload ?? "").match(/\b(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/u);
return match ? match[0] : null;
}
async function workbenchSessionSnapshot(targetPage = page) {
return targetPage.evaluate(() => {
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
const input = document.querySelector("#command-input");
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null;
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
url: window.location.href,
routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null,
activeSessionId: activeTab?.getAttribute("data-session-id") || null,
activeConversationId: activeTab?.getAttribute("data-conversation-id") || null,
activeStatus: activeTab?.getAttribute("data-status") || null,
tabCount: document.querySelectorAll(".session-tab").length,
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length,
loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length,
composerReady: Boolean(activeTab && input && !input.disabled && !warning),
warning
};
}).catch(() => null);
}
function controlPageRecoveryTarget(snapshot, beforeUrl) {
const sessionId = snapshot?.routeSessionId || snapshot?.activeSessionId || routeSessionIdFromUrl(beforeUrl);
if (sessionId) return { sessionId, targetPath: "/workbench/sessions/" + encodeURIComponent(sessionId), valuesRedacted: true };
const path = safeUrlPath(beforeUrl);
if (isWorkbenchPathname(path || "")) return { sessionId: null, targetPath: path, valuesRedacted: true };
return { sessionId: null, targetPath, valuesRedacted: true };
}
function controlPageProjectionMissingForCommand(snapshot, beforeUrl) {
const path = safeUrlPath(snapshot?.url || beforeUrl);
if (!isWorkbenchPathname(path || "")) return false;
const routeSessionId = snapshot?.routeSessionId || routeSessionIdFromUrl(snapshot?.url || beforeUrl);
if (!routeSessionId) return false;
return snapshot?.activeSessionId !== routeSessionId
&& Number(snapshot?.tabCount || 0) === 0
&& Number(snapshot?.messageCount || 0) === 0
&& Number(snapshot?.traceRowCount || 0) === 0
&& snapshot?.composerReady !== true;
}
async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) {
const started = Date.now();
return withHardTimeout(workbenchSessionSnapshot(page), timeoutMs, "control page liveness snapshot exceeded " + timeoutMs + "ms")
.then((snapshot) => ({
ok: snapshot !== null,
reason,
durationMs: Date.now() - started,
snapshot,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}))
.catch((error) => ({
ok: false,
reason,
durationMs: Date.now() - started,
error: errorSummary(error),
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
}
async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null, options = {}) {
let navigation = null;
let hydration = null;
let afterLiveness = null;
const attempts = [];
let ok = false;
const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 12000;
const hydrationHardTimeoutMs = Number.isFinite(Number(options.hydrationHardTimeoutMs)) ? Math.max(hydrationTimeoutMs, Number(options.hydrationHardTimeoutMs)) : hydrationTimeoutMs + 2000;
const navigationOptions = options.navigation || {};
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt);
try {
navigation = await gotoTarget(target.targetPath, navigationOptions);
} catch (error) {
navigation = { ok: false, targetPath: target.targetPath, error: errorSummary(error), valuesRedacted: true };
}
if (!navigation?.error && target.sessionId) {
hydration = await withHardTimeout(
waitForWorkbenchSessionHydrated(page, target.sessionId, { timeoutMs: hydrationTimeoutMs }),
hydrationHardTimeoutMs,
"control page recovery hydration exceeded " + hydrationHardTimeoutMs + "ms"
).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true }));
} else {
hydration = null;
}
afterLiveness = await controlPageLivenessSnapshot(reason + "-post-recovery", 3000);
ok = !navigation?.error && afterLiveness.ok === true && (!target.sessionId || hydration?.ok === true);
attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true });
if (ok) break;
}
return {
ok,
recovered: ok,
reason,
beforeUrl,
afterUrl: currentPageUrl(),
target,
liveness,
navigation,
hydration,
afterLiveness,
attempts,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
}
async function promoteObserverPageToControlForCommand(reason, target, liveness = null) {
const beforeUrl = currentPageUrl();
const beforeObserverUrl = pageUrl(observerPage);
if (!observerPage || observerPage.isClosed()) {
return { ok: false, promoted: false, reason, failureKind: "observer-page-unavailable", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
}
const sessionId = target?.sessionId || routeSessionIdFromUrl(beforeUrl);
if (!sessionId) {
return { ok: false, promoted: false, reason, failureKind: "observer-promotion-needs-session", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
}
const observerSessionId = routeSessionIdFromUrl(beforeObserverUrl);
if (observerSessionId !== sessionId) {
return { ok: false, promoted: false, reason, failureKind: "observer-session-mismatch", beforeUrl, observerUrl: beforeObserverUrl, observerSessionId, sessionId, target, liveness, valuesRedacted: true };
}
const targetUrl = new URL(target?.targetPath || ("/workbench/sessions/" + encodeURIComponent(sessionId)), baseUrl).toString();
const readiness = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: 1000, hydrationTimeoutMs: 2000 });
const observerComposerReady = readiness?.hydration?.snapshot?.composerReady === true;
if (readiness.ok !== true || observerComposerReady !== true) {
return { ok: false, promoted: false, reason, failureKind: readiness.failureKind || (observerComposerReady ? "observer-not-ready" : "observer-composer-not-ready"), beforeUrl, observerUrl: beforeObserverUrl, sessionId, target, liveness, readiness, valuesRedacted: true };
}
const oldControlPage = page;
observerPageEpoch += 1;
controlPageEpoch += 1;
page = observerPage;
observerPage = null;
attachPassiveListeners(page, "control", pageId);
currentPageProvenance = null;
if (oldControlPage && !oldControlPage.isClosed() && oldControlPage !== page) {
await withHardTimeout(oldControlPage.close(), 2000, "old control page close exceeded 2000ms")
.catch((error) => appendJsonl(files.errors, eventRecord("old-control-page-close-timeout", { reason, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
}
observerPage = await context.newPage();
attachPassiveListeners(observerPage, "observer", observerPageId);
const observerSync = await syncObserverPageToControlSession(reason + "-observer-recreated-after-promotion", sessionId, {
maxAttempts: 1,
navigationTimeoutMs: 8000,
readinessTimeoutMs: 3000,
hydrationTimeoutMs: 3000,
shortCircuitReadinessTimeoutMs: 1000,
shortCircuitHydrationTimeoutMs: 1000,
});
return {
ok: true,
promoted: true,
reason,
beforeUrl,
beforeObserverUrl,
afterUrl: currentPageUrl(),
sessionId,
target,
liveness,
readiness,
observerSync,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
}
async function ensureControlPageResponsiveForCommand(reason) {
const beforeUrl = currentPageUrl();
const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000);
const projectionMissing = liveness.ok === true && controlPageProjectionMissingForCommand(liveness.snapshot, beforeUrl);
if (liveness.ok && !projectionMissing) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
await appendJsonl(files.control, eventRecord(projectionMissing ? "control-page-projection-missing-before-command" : "control-page-unresponsive-before-command", {
reason,
beforeUrl,
target,
liveness,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-promoted-from-observer-before-command" : "control-page-observer-promotion-skipped-before-command", promotion));
if (promotion.ok === true) return promotion;
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
maxAttempts: 1,
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
hydrationTimeoutMs: 4000,
hydrationHardTimeoutMs: 5000,
});
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery));
if (!recovery.ok) {
const error = new Error("control page recovery failed before " + reason);
error.details = recovery;
throw error;
}
return recovery;
}
async function forceRecoverControlPageForCommand(reason) {
const beforeUrl = currentPageUrl();
const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000);
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", {
reason,
beforeUrl,
target,
liveness,
pageRole: "control",
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-forced-promoted-from-observer-before-command" : "control-page-forced-observer-promotion-skipped-before-command", promotion));
if (promotion.ok === true) return promotion;
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
maxAttempts: 1,
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
hydrationTimeoutMs: 4000,
hydrationHardTimeoutMs: 5000,
});
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery));
return recovery;
}
async function sendPrompt(text, options = {}) {
if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text");
const responsePath = options.responsePath || "/v1/agent/chat";
const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt");
const beforeUrl = currentPageUrl();
const beforeEvidence = await promptSideEffectSnapshot();
let editor = null;
let composerRecovery = null;
let editorWaitError = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const primaryEditor = page.locator("#command-input").last();
const candidate = await primaryEditor.isVisible().catch(() => false)
? primaryEditor
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
try {
await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 8000 }), 10000, "sendPrompt composer editor did not become visible within 10s");
editor = candidate;
break;
} catch (error) {
editorWaitError = error;
if (attempt >= 2) break;
composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing");
if (composerRecovery.ok !== true) break;
}
}
if (!editor) {
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000);
const error = new Error("sendPrompt composer editor did not become visible");
error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
throw error;
}
editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery });
const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]';
const primarySubmit = page.locator(primarySubmitSelector).last();
const submit = await primarySubmit.isVisible().catch(() => false)
? primarySubmit
: page.locator([
'button[type="submit"]',
'button:has-text("发送")',
'button:has-text("Send")',
'[data-testid*="send" i]',
'[aria-label*="send" i]',
'[aria-label*="发送"]'
].join(", ")).last();
await withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s");
if (options.expectedAction) {
const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === ""
? null
: Number(options.expectedActionWaitMs);
const actionWaitMs = Number.isFinite(configuredActionWaitMs)
? Math.max(1000, Math.trunc(configuredActionWaitMs))
: options.expectedAction === "turn" ? 180000 : 1000;
const actionDeadline = Date.now() + actionWaitMs;
let composer = null;
while (Date.now() <= actionDeadline) {
composer = await composerButtonState(submit);
if (composer.action === options.expectedAction && composer.disabled !== true) break;
await page.waitForTimeout(250);
}
composer = composer || await composerButtonState(submit);
if (composer.action !== options.expectedAction || composer.disabled === true) {
await clearComposerEditor(editor).catch(() => {});
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot());
const blocked = {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
submitted: false,
blocked: true,
degradedReason: options.noActiveReason || "composer-action-mismatch",
composer,
chatSubmit: {
status: null,
statusText: null,
urlPath: responsePath,
responseObserved: false,
sideEffectObserved: sideEffectHasAuthoritativePromptSubmission(sideEffect),
sideEffect,
actionWaitMs,
expectedAction: options.expectedAction,
actualAction: composer.action,
valuesRedacted: true
},
controlRecovery,
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
if (options.throwOnActionMismatch === true) {
const error = new Error("sendPrompt composer action mismatch: expected " + options.expectedAction + " actual " + (composer.action || "null"));
error.details = blocked;
throw error;
}
return blocked;
}
}
const acceptedResponsePaths = [responsePath, ...(Array.isArray(options.alternateResponsePaths) ? options.alternateResponsePaths : [])];
const chatResponsePromise = page.waitForResponse((response) => {
const request = response.request();
if (request.method().toUpperCase() !== "POST") return false;
try {
return acceptedResponsePaths.includes(new URL(response.url()).pathname);
} catch {
return false;
}
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
await submit.click();
const chatResponse = await chatResponsePromise;
await page.waitForTimeout(500);
if (chatResponse?.waitError) {
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000);
if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) {
return {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect },
controlRecovery,
pageId,
pageEpoch: controlPageEpoch
};
}
const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout"));
error.details = {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect },
controlRecovery,
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
throw error;
}
const chatStatus = chatResponse.status();
let chatUrlPath = responsePath;
try {
chatUrlPath = new URL(chatResponse.url()).pathname;
} catch {
chatUrlPath = responsePath;
}
if (chatStatus < 200 || chatStatus >= 300) {
throw new Error("sendPrompt observed POST " + chatUrlPath + " HTTP " + chatStatus + " " + chatResponse.statusText());
}
let chatPayload = null;
let chatPayloadError = null;
try {
chatPayload = await chatResponse.json();
} catch (error) {
chatPayloadError = errorSummary(error);
}
const payloadText = chatPayload ? JSON.stringify(chatPayload) : "";
const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null;
const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId)
? chatPayload.otelTrace.traceId
: null;
return {
beforeUrl,
afterUrl: currentPageUrl(),
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
chatSubmit: {
status: chatStatus,
statusText: chatResponse.statusText(),
urlPath: chatUrlPath,
traceId,
otelTraceId,
resultUrl: safeUrlPath(chatPayload?.resultUrl),
turnUrl: safeUrlPath(chatPayload?.turnUrl),
streamUrl: safeUrlPath(chatPayload?.streamUrl),
responseParsed: chatPayload !== null,
responseParseError: chatPayloadError,
valuesRedacted: true
},
controlRecovery,
pageId,
pageEpoch: controlPageEpoch
};
}
async function fillComposerEditorWithRetry(initialEditor, text, context = {}) {
let editor = initialEditor;
let lastError = null;
let recovery = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
if (attempt > 1 || !editor) editor = await resolveComposerEditor(8000);
await fillComposerEditor(editor, text, { timeoutMs: 12000 });
const retained = await composerEditorRetainsText(editor, text);
if (retained) return editor;
throw new Error("sendPrompt composer editor did not retain filled text");
} catch (error) {
lastError = error;
await appendJsonl(files.control, eventRecord("sendPrompt-composer-fill-retry", {
attempt,
willRetry: attempt < 2,
error: errorSummary(error),
beforeUrl: context.beforeUrl || null,
afterUrl: currentPageUrl(),
controlRecovery: context.controlRecovery || null,
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
}));
if (attempt >= 2) break;
recovery = await forceRecoverControlPageForCommand("sendPrompt-composer-fill-failed");
if (recovery.ok !== true) await page.waitForTimeout(250);
editor = null;
}
}
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-fill-failed-final", 3000);
const error = new Error("sendPrompt composer editor fill failed");
error.details = {
beforeUrl: context.beforeUrl || null,
afterUrl: currentPageUrl(),
controlRecovery: context.controlRecovery || null,
recovery,
snapshot,
fillError: errorSummary(lastError),
pageId,
pageEpoch: controlPageEpoch,
valuesRedacted: true
};
throw error;
}
async function resolveComposerEditor(timeoutMs = 8000) {
const primaryEditor = page.locator("#command-input").last();
const candidate = await primaryEditor.isVisible().catch(() => false)
? primaryEditor
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
await candidate.waitFor({ state: "visible", timeout: timeoutMs });
return candidate;
}
async function fillComposerEditor(editor, text, options = {}) {
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1000, Math.trunc(Number(options.timeoutMs))) : 30000;
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
if (tag === "textarea" || tag === "input") await editor.fill(text, { timeout: timeoutMs });
else if (editable) {
await editor.click({ timeout: timeoutMs });
await page.keyboard.insertText(text);
} else {
await editor.click({ timeout: timeoutMs });
await page.keyboard.insertText(text);
}
}
async function composerEditorRetainsText(editor, expectedText) {
const expected = String(expectedText || "");
return editor.evaluate((element, value) => {
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return element.value === value;
return (element.textContent || "") === value;
}, expected).catch(() => false);
}
async function clearComposerEditor(editor) {
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
if (tag === "textarea" || tag === "input") {
await editor.fill("");
return;
}
if (editable) {
await editor.click();
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {});
await page.keyboard.press("Backspace").catch(() => {});
}
}
async function composerButtonState(button) {
return button.evaluate((element) => ({
action: element.getAttribute("data-action") || null,
disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true",
text: (element.textContent || "").trim().slice(0, 80),
title: element.getAttribute("title") || null,
ariaLabel: element.getAttribute("aria-label") || null,
testId: element.getAttribute("data-testid") || null,
})).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true }));
}
function sideEffectHasAuthoritativePromptSubmission(sideEffect) {
return Boolean(
(Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0)
|| (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0)
|| Number(sideEffect?.messageCountDelta || 0) > 0
);
}
async function waitForPromptSideEffect(beforeEvidence, timeoutMs) {
await page.waitForFunction((before) => {
const current = (() => {
const text = document.body?.innerText || "";
const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text);
const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text);
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length;
const textBytes = new TextEncoder().encode(text).length;
return { runIds, traceIds, running, executionError, messageCount, textBytes };
})();
const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : [];
const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : [];
const newRun = current.runIds.some((id) => !beforeRuns.includes(id));
const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id));
const newMessage = current.messageCount > Number(before?.messageCount || 0);
return newRun || newTrace || newMessage;
}, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null);
const after = await promptSideEffectSnapshot();
const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : [];
const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : [];
const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id));
const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id));
const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0));
return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true };
}
async function promptSideEffectSnapshot() {
return withHardTimeout(page.evaluate(() => {
const text = document.body?.innerText || "";
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text),
executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text),
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
textBytes: new TextEncoder().encode(text).length,
valuesRedacted: true
};
}), 3000, "prompt side-effect snapshot exceeded 3000ms")
.catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true }));
}
`;
}