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