// 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; await writeHeartbeat({ status: terminalStatus }); } } function startCommandActiveSampler(command) { const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000); const heartbeatIntervalMs = Math.min(5000, intervalMs); let stopped = false; let timer = null; let heartbeatTimer = null; let inFlight = false; const heartbeat = () => { if (stopped) return; void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true }) .catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))); }; const schedule = () => { if (stopped) return; timer = setTimeout(tick, intervalMs); if (timer && typeof timer.unref === "function") timer.unref(); }; const tick = () => { if (stopped) return; if (inFlight) { schedule(); return; } inFlight = true; samplePage("command-active", { refreshObserver: false, screenshot: false }) .catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))) .finally(() => { inFlight = false; schedule(); }); }; heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs); if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref(); schedule(); return () => { stopped = true; if (timer) clearTimeout(timer); if (heartbeatTimer) clearInterval(heartbeatTimer); }; } async function processCommand(command) { commandSeq += 1; activeCommandId = command.id; await writeHeartbeat({ status: "running", activeCommandId }); await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command))); switch (command.type) { case "login": return authenticate(context); case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount"); case "logout": return withObserverSync(await logoutAccount(command), "logout"); case "listSessions": return withObserverSync(await listSessions(command), "listSessions"); case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions"); case "preflight": return preflightSummary(); case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "turn", responsePath: "/v1/agent/chat", alternateResponsePaths: ["/v1/agent/chat/steer"], noActiveReason: "send-no-turn-composer", throwOnActionMismatch: true, expectedActionWaitMs: command.expectedActionWaitMs, }), "sendPrompt"); case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer"); case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel"); case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider"); case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession"); case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession"); case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack"); case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant"); case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo"); case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command); case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command); case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command); case "probeMdtodoSource": return probeMdtodoSource(command); case "reindexMdtodoSource": return reindexMdtodoSource(command); case "selectProjectSource": return selectProjectSource(command); case "selectMdtodoSource": return selectMdtodoSource(command); case "selectMdtodoFile": return selectMdtodoFile(command); case "selectMdtodoTask": return selectMdtodoTask(command); case "expandMdtodoTask": return expandMdtodoTask(command); case "openMdtodoReportPreview": return openMdtodoReportPreview(command); case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command); case "editMdtodoTaskInline": return editMdtodoTaskInline(command); case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command); case "editMdtodoTaskBody": return editMdtodoTaskBody(command); case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command); case "addMdtodoRootTask": return addMdtodoRootTask(command); case "addMdtodoSubTask": return addMdtodoSubTask(command); case "continueMdtodoTask": return continueMdtodoTask(command); case "deleteMdtodoTask": return deleteMdtodoTask(command); case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask"); case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo"); case "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 })); } `; }