5649 lines
285 KiB
TypeScript
5649 lines
285 KiB
TypeScript
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
|
||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
||
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
|
||
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
|
||
|
||
export function nodeWebObserveRunnerSource(): string {
|
||
return String.raw`#!/usr/bin/env node
|
||
import { createHash, randomBytes } from "node:crypto";
|
||
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
|
||
const specRef = "PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer";
|
||
const startedAtMs = Date.now();
|
||
const startedAt = new Date(startedAtMs).toISOString();
|
||
const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL);
|
||
const username = process.env.HWLAB_WEB_USER || "admin";
|
||
const password = process.env.HWLAB_WEB_PASS || "";
|
||
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || ".state/web-observe/manual");
|
||
const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex"));
|
||
const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench";
|
||
const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000);
|
||
const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000);
|
||
const screenshotCaptureTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_CAPTURE_TIMEOUT_MS, 15000, 1000, 120000);
|
||
const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0);
|
||
const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000);
|
||
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
|
||
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
|
||
const authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20);
|
||
const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000);
|
||
const authLoginInitialDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_INITIAL_DELAY_MS, 500, 0, 60000);
|
||
const authLoginMaxDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_DELAY_MS, 10000, 0, 120000);
|
||
const navigationMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_NAVIGATION_MAX_ATTEMPTS, 4, 1, 10);
|
||
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
||
const browserFreezePolicy = parseBrowserFreezePolicy(process.env.UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON);
|
||
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
|
||
const playwrightProxy = proxyConfigFromEnv(baseUrl);
|
||
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
|
||
const pageId = "control-" + randomBytes(4).toString("hex");
|
||
const observerPageId = "observer-" + randomBytes(4).toString("hex");
|
||
const dirs = {
|
||
commandsPending: path.join(stateDir, "commands", "pending"),
|
||
commandsProcessing: path.join(stateDir, "commands", "processing"),
|
||
commandsDone: path.join(stateDir, "commands", "done"),
|
||
commandsFailed: path.join(stateDir, "commands", "failed"),
|
||
screenshots: path.join(stateDir, "screenshots"),
|
||
analysis: path.join(stateDir, "analysis"),
|
||
archive: path.join(stateDir, "archive"),
|
||
};
|
||
const files = {
|
||
manifest: path.join(stateDir, "manifest.json"),
|
||
heartbeat: path.join(stateDir, "heartbeat.json"),
|
||
control: path.join(stateDir, "control.jsonl"),
|
||
samples: path.join(stateDir, "samples.jsonl"),
|
||
network: path.join(stateDir, "network.jsonl"),
|
||
console: path.join(stateDir, "console.jsonl"),
|
||
errors: path.join(stateDir, "errors.jsonl"),
|
||
artifacts: path.join(stateDir, "artifacts.jsonl"),
|
||
browserProcess: path.join(stateDir, "browser-process.jsonl"),
|
||
};
|
||
|
||
let browser;
|
||
let context;
|
||
let page;
|
||
let observerPage;
|
||
let lastObserverRefreshAtMs = Date.now();
|
||
let sampleSeq = 0;
|
||
let commandSeq = 0;
|
||
let artifactSeq = 0;
|
||
let activeCommandId = null;
|
||
let stopping = false;
|
||
let terminalStatus = "starting";
|
||
let lastScreenshotAtMs = 0;
|
||
let screenshotCaptureState = null;
|
||
let auth = null;
|
||
let pageLoadSeq = 0;
|
||
let controlPageEpoch = 0;
|
||
let observerPageEpoch = 0;
|
||
let currentPageProvenance = null;
|
||
let heartbeatPulseTimer = null;
|
||
let browserProcessMonitorStop = null;
|
||
let browserProcessMonitorSeq = 0;
|
||
let browserFreezeBlocker = null;
|
||
const browserProcessHistory = [];
|
||
const browserPageRuntimeBaselines = new Map();
|
||
const browserFreezeSignalHistory = [];
|
||
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
|
||
|
||
try {
|
||
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
||
await prepareDirs();
|
||
await rotateExistingJsonlArtifacts();
|
||
await writeManifest({ status: "starting" });
|
||
await writeHeartbeat({ status: "starting" });
|
||
heartbeatPulseTimer = startHeartbeatPulse();
|
||
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
|
||
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||
const { chromium } = await launcher.importPlaywright();
|
||
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context));
|
||
await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath));
|
||
observerPage = await context.newPage();
|
||
attachPassiveListeners(observerPage, "observer", observerPageId);
|
||
await runControlCommand({ id: "startup-observer-goto", type: "observerGoto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => {
|
||
const result = await syncObserverPageToControlSession("startup");
|
||
if (!result?.ok) {
|
||
await appendJsonl(files.control, eventRecord("observer-startup-degraded", {
|
||
reason: result?.failureKind || result?.reason || "observer-not-ready",
|
||
result: sanitize(result),
|
||
valuesRedacted: true,
|
||
}));
|
||
}
|
||
return result ?? { ok: false, reason: "observer-not-ready", pageRole: "observer", pageId: observerPageId, valuesRedacted: true };
|
||
});
|
||
browserProcessMonitorStop = startBrowserProcessMonitor();
|
||
terminalStatus = "running";
|
||
await writeManifest({ status: "running", auth: publicAuth(auth) });
|
||
await writeHeartbeat({ status: "running" });
|
||
while (!stopping) {
|
||
await drainOneCommand();
|
||
if (browserFreezeBlocker) break;
|
||
await samplePage("interval");
|
||
if (browserFreezeBlocker) break;
|
||
if (maxSamples > 0 && sampleSeq >= maxSamples) {
|
||
await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples }));
|
||
break;
|
||
}
|
||
await sleep(sampleIntervalMs);
|
||
}
|
||
if (browserFreezeBlocker) {
|
||
terminalStatus = "failed";
|
||
await writeHeartbeat({ status: "failed", blocker: browserFreezeBlocker });
|
||
await writeManifest({ status: "failed", completedAt: new Date().toISOString(), blocker: browserFreezeBlocker });
|
||
process.exitCode = browserFreezePolicy.kill.exitCode;
|
||
} else {
|
||
terminalStatus = "completed";
|
||
await writeHeartbeat({ status: "completed" });
|
||
await writeManifest({ status: "completed", completedAt: new Date().toISOString() });
|
||
process.exitCode = 0;
|
||
}
|
||
} catch (error) {
|
||
terminalStatus = "failed";
|
||
await appendJsonl(files.errors, eventRecord("runner-error", { error: errorSummary(error) })).catch(() => {});
|
||
await writeHeartbeat({ status: "failed", error: errorSummary(error) }).catch(() => {});
|
||
await writeManifest({ status: "failed", error: errorSummary(error) }).catch(() => {});
|
||
process.exitCode = 2;
|
||
} finally {
|
||
if (browserProcessMonitorStop) browserProcessMonitorStop();
|
||
if (heartbeatPulseTimer) clearInterval(heartbeatPulseTimer);
|
||
if (browser) await browser.close().catch(() => {});
|
||
}
|
||
|
||
async function prepareDirs() {
|
||
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||
await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 })));
|
||
}
|
||
|
||
async function rotateExistingJsonlArtifacts() {
|
||
for (const [key, file] of Object.entries(files)) {
|
||
if (!file.endsWith(".jsonl")) continue;
|
||
let meta = null;
|
||
try {
|
||
meta = await stat(file);
|
||
} catch (error) {
|
||
if (error?.code === "ENOENT") continue;
|
||
throw error;
|
||
}
|
||
if (!meta?.isFile()) continue;
|
||
const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file));
|
||
await rename(file, archiveFile);
|
||
jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size });
|
||
}
|
||
}
|
||
|
||
async function uniqueArchiveFile(name) {
|
||
let candidate = path.join(dirs.archive, name);
|
||
const parsed = path.parse(name);
|
||
for (let index = 1; ; index += 1) {
|
||
try {
|
||
await stat(candidate);
|
||
candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext);
|
||
} catch (error) {
|
||
if (error?.code === "ENOENT") return candidate;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
function compactFileTimestamp(value) {
|
||
return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
|
||
}
|
||
|
||
async function writeManifest(extra = {}) {
|
||
const manifest = {
|
||
ok: extra.status !== "failed",
|
||
command: "web-probe-observe",
|
||
specRef,
|
||
jobId,
|
||
pid: process.pid,
|
||
stateDir,
|
||
baseUrl,
|
||
targetPath,
|
||
network: publicNetwork(playwrightProxy),
|
||
navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true },
|
||
pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true },
|
||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
|
||
alertThresholds,
|
||
browserFreezePolicy,
|
||
projectManagement,
|
||
jsonlRotation,
|
||
commandDirs: dirs,
|
||
artifacts: files,
|
||
safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false },
|
||
startedAt,
|
||
...extra,
|
||
};
|
||
await writeFile(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
||
}
|
||
|
||
async function writeHeartbeat(extra = {}) {
|
||
const heartbeat = {
|
||
ok: terminalStatus !== "failed",
|
||
jobId,
|
||
pid: process.pid,
|
||
stateDir,
|
||
status: terminalStatus,
|
||
pageId,
|
||
observerPageId,
|
||
baseUrl,
|
||
currentUrl: currentPageUrl(),
|
||
observerUrl: pageUrl(observerPage),
|
||
observerRefreshIntervalMs,
|
||
lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null,
|
||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||
sampleSeq,
|
||
commandSeq,
|
||
activeCommandId,
|
||
updatedAt: new Date().toISOString(),
|
||
uptimeMs: Date.now() - startedAtMs,
|
||
...extra,
|
||
};
|
||
await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 });
|
||
}
|
||
|
||
function startHeartbeatPulse() {
|
||
const timer = setInterval(() => {
|
||
if (stopping) return;
|
||
void writeHeartbeat({ status: terminalStatus, heartbeatPulse: true })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("heartbeat-pulse-error", { error: errorSummary(error) })));
|
||
}, 5000);
|
||
if (timer && typeof timer.unref === "function") timer.unref();
|
||
return timer;
|
||
}
|
||
|
||
function startBrowserProcessMonitor() {
|
||
const intervalMs = Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000));
|
||
let stopped = false;
|
||
let running = false;
|
||
const tick = async (reason) => {
|
||
if (stopped || running || stopping || browserFreezeBlocker) return;
|
||
running = true;
|
||
try {
|
||
await collectBrowserProcessSample(reason || "interval");
|
||
} catch (error) {
|
||
await appendJsonl(files.errors, eventRecord("browser-process-monitor-error", { error: errorSummary(error), valuesRedacted: true })).catch(() => {});
|
||
} finally {
|
||
running = false;
|
||
}
|
||
};
|
||
void tick("startup");
|
||
const timer = setInterval(() => {
|
||
void tick("interval");
|
||
}, intervalMs);
|
||
if (timer && typeof timer.unref === "function") timer.unref();
|
||
return () => {
|
||
stopped = true;
|
||
clearInterval(timer);
|
||
};
|
||
}
|
||
|
||
async function collectBrowserProcessSample(reason) {
|
||
browserProcessMonitorSeq += 1;
|
||
const tsMs = Date.now();
|
||
const browserPid = browserProcessPid();
|
||
const processSummary = await collectChromiumProcessSummary(browserPid);
|
||
const growth = updateBrowserProcessHistory(tsMs, processSummary);
|
||
const pages = [];
|
||
if (page && !page.isClosed()) {
|
||
pages.push(await collectBrowserPageRuntimeMetrics(page, { pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch }).catch((error) => browserPageRuntimeMetricError("control", pageId, controlPageEpoch, error)));
|
||
}
|
||
if (observerPage && !observerPage.isClosed()) {
|
||
pages.push(await collectBrowserPageRuntimeMetrics(observerPage, { pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => browserPageRuntimeMetricError("observer", observerPageId, observerPageEpoch, error)));
|
||
}
|
||
const sample = eventRecord("browser-process-sample", {
|
||
seq: browserProcessMonitorSeq,
|
||
reason,
|
||
monitorIntervalMs: Math.max(250, Math.floor(Number(alertThresholds.browserProcessSampleIntervalMs) || 1000)),
|
||
browserPid,
|
||
process: processSummary,
|
||
growth,
|
||
pages,
|
||
valuesRedacted: true,
|
||
});
|
||
await appendJsonl(files.browserProcess, sample);
|
||
await enforceBrowserFreezePolicy(sample);
|
||
}
|
||
|
||
async function enforceBrowserFreezePolicy(sample) {
|
||
if (browserFreezePolicy.enabled !== true || browserFreezeBlocker) return;
|
||
const processSummary = sample && typeof sample.process === "object" ? sample.process : {};
|
||
const growth = sample && typeof sample.growth === "object" ? sample.growth : {};
|
||
const totalRssMb = Number(processSummary.totalRssMb);
|
||
const processRssMb = Number(processSummary.maxProcessRssMb);
|
||
const totalGrowthMb = Number(growth.totalRssGrowthMb);
|
||
const processGrowthMb = Number(growth.maxProcessRssGrowthMb);
|
||
|
||
for (const pageMetric of Array.isArray(sample.pages) ? sample.pages : []) {
|
||
const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {};
|
||
const effectiveHeapUsedMb = Number(effectiveMemory.effectiveHeapUsedMb);
|
||
const effectiveJsHeapUsedMb = Number(effectiveMemory.effectiveJsHeapUsedMb);
|
||
const heapGrowthMb = Number(effectiveMemory.heapUsedGrowthMb);
|
||
const jsHeapGrowthMb = Number(effectiveMemory.jsHeapUsedGrowthMb);
|
||
if (
|
||
(Number.isFinite(effectiveHeapUsedMb) && effectiveHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb)
|
||
|| (Number.isFinite(effectiveJsHeapUsedMb) && effectiveJsHeapUsedMb >= browserFreezePolicy.memory.processRssBlockerMb)
|
||
) {
|
||
await triggerBrowserFreezeBlocker({
|
||
kind: "memory-page-effective",
|
||
rootCause: "frontend_browser_page_effective_memory_pressure",
|
||
observed: {
|
||
pageRole: pageMetric?.pageRole ?? null,
|
||
pageId: pageMetric?.pageId ?? null,
|
||
pageEpoch: pageMetric?.pageEpoch ?? null,
|
||
totalRssMb,
|
||
processRssMb,
|
||
totalGrowthMb,
|
||
processGrowthMb,
|
||
effectiveHeapUsedMb: Number.isFinite(effectiveHeapUsedMb) ? effectiveHeapUsedMb : null,
|
||
effectiveJsHeapUsedMb: Number.isFinite(effectiveJsHeapUsedMb) ? effectiveJsHeapUsedMb : null,
|
||
baseline: pageMetric?.baseline ?? null,
|
||
valuesRedacted: true,
|
||
},
|
||
threshold: { processRssBlockerMb: browserFreezePolicy.memory.processRssBlockerMb, policyScope: "per-page-effective-memory", valuesRedacted: true },
|
||
sample: browserProcessSampleRef(sample),
|
||
page: browserPageMetricRef(pageMetric),
|
||
});
|
||
return;
|
||
}
|
||
if (
|
||
(Number.isFinite(heapGrowthMb) && heapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb)
|
||
|| (Number.isFinite(jsHeapGrowthMb) && jsHeapGrowthMb >= browserFreezePolicy.memory.growthBlockerMb)
|
||
) {
|
||
await triggerBrowserFreezeBlocker({
|
||
kind: "memory-page-effective-growth",
|
||
rootCause: "frontend_browser_page_memory_leak_or_unbounded_render_growth",
|
||
observed: {
|
||
pageRole: pageMetric?.pageRole ?? null,
|
||
pageId: pageMetric?.pageId ?? null,
|
||
pageEpoch: pageMetric?.pageEpoch ?? null,
|
||
totalRssMb,
|
||
processRssMb,
|
||
totalGrowthMb,
|
||
processGrowthMb,
|
||
heapGrowthMb: Number.isFinite(heapGrowthMb) ? heapGrowthMb : null,
|
||
jsHeapGrowthMb: Number.isFinite(jsHeapGrowthMb) ? jsHeapGrowthMb : null,
|
||
baseline: pageMetric?.baseline ?? null,
|
||
valuesRedacted: true,
|
||
},
|
||
threshold: { growthBlockerMb: browserFreezePolicy.memory.growthBlockerMb, windowMs: browserFreezePolicy.blockerWindowMs, policyScope: "per-page-effective-memory", valuesRedacted: true },
|
||
sample: browserProcessSampleRef(sample),
|
||
page: browserPageMetricRef(pageMetric),
|
||
});
|
||
return;
|
||
}
|
||
const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {};
|
||
const responsivenessLatencyMs = Number(responsiveness.latencyMs);
|
||
if (responsiveness.timeout === true || (Number.isFinite(responsivenessLatencyMs) && responsivenessLatencyMs >= browserFreezePolicy.responsiveness.latencyBlockerMs)) {
|
||
const signal = recordBrowserFreezeSignal("playwright-responsiveness", sample, pageMetric, {
|
||
rootCause: "frontend_browser_page_unresponsive_to_playwright",
|
||
observed: {
|
||
responsivenessLatencyMs: Number.isFinite(responsivenessLatencyMs) ? responsivenessLatencyMs : null,
|
||
responsivenessTimeout: responsiveness.timeout === true,
|
||
valuesRedacted: true,
|
||
},
|
||
threshold: {
|
||
latencyBlockerMs: browserFreezePolicy.responsiveness.latencyBlockerMs,
|
||
eventBlockerCount: browserFreezePolicy.responsiveness.eventBlockerCount,
|
||
windowMs: browserFreezePolicy.blockerWindowMs,
|
||
valuesRedacted: true,
|
||
},
|
||
});
|
||
if (signal.burst.length >= browserFreezePolicy.responsiveness.eventBlockerCount) {
|
||
await triggerBrowserFreezeBlocker(signal);
|
||
return;
|
||
}
|
||
}
|
||
const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {};
|
||
const calls = Array.isArray(cdp.calls) ? cdp.calls : [];
|
||
const metricTimeoutCalls = calls.filter((call) => call?.timeout === true && call?.method !== "Runtime.evaluate");
|
||
const sessionTimeoutCount = calls.length === 0 ? Number(cdp.timeoutCount || 0) : 0;
|
||
const metricTimeoutCount = metricTimeoutCalls.length + (Number.isFinite(sessionTimeoutCount) ? sessionTimeoutCount : 0);
|
||
if (metricTimeoutCount > 0) {
|
||
const signal = recordBrowserFreezeSignal("cdp-metrics-timeout", sample, pageMetric, {
|
||
rootCause: "frontend_browser_cdp_metrics_unresponsive",
|
||
observed: {
|
||
cdpMetricsTimeoutCount: metricTimeoutCount,
|
||
methods: metricTimeoutCalls.map((call) => call.method || "unknown").slice(0, 8),
|
||
valuesRedacted: true,
|
||
},
|
||
threshold: {
|
||
metricsTimeoutBlockerCount: browserFreezePolicy.cdp.metricsTimeoutBlockerCount,
|
||
windowMs: browserFreezePolicy.blockerWindowMs,
|
||
valuesRedacted: true,
|
||
},
|
||
});
|
||
if (signal.burst.length >= browserFreezePolicy.cdp.metricsTimeoutBlockerCount) {
|
||
await triggerBrowserFreezeBlocker(signal);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function recordBrowserFreezeSignal(kind, sample, pageMetric, detail) {
|
||
const tsMs = Date.parse(String(sample?.ts || ""));
|
||
const signal = {
|
||
kind,
|
||
ts: sample?.ts ?? new Date().toISOString(),
|
||
tsMs: Number.isFinite(tsMs) ? tsMs : Date.now(),
|
||
pageRole: pageMetric?.pageRole ?? null,
|
||
pageId: pageMetric?.pageId ?? null,
|
||
pageEpoch: pageMetric?.pageEpoch ?? null,
|
||
sample: browserProcessSampleRef(sample),
|
||
page: browserPageMetricRef(pageMetric),
|
||
...detail,
|
||
valuesRedacted: true,
|
||
};
|
||
browserFreezeSignalHistory.push(signal);
|
||
const windowMs = browserFreezePolicy.blockerWindowMs;
|
||
const cutoff = signal.tsMs - windowMs;
|
||
while (browserFreezeSignalHistory.length > 0 && Number(browserFreezeSignalHistory[0].tsMs || 0) < cutoff) browserFreezeSignalHistory.shift();
|
||
return {
|
||
kind,
|
||
rootCause: detail.rootCause,
|
||
observed: detail.observed,
|
||
threshold: detail.threshold,
|
||
sample: browserProcessSampleRef(sample),
|
||
page: browserPageMetricRef(pageMetric),
|
||
burst: browserFreezeSignalHistory.filter((item) => item.kind === kind && item.tsMs >= cutoff).slice(-20),
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function triggerBrowserFreezeBlocker(trigger) {
|
||
if (browserFreezeBlocker) return;
|
||
stopping = true;
|
||
terminalStatus = "failed";
|
||
const base = {
|
||
id: "browser-freeze-policy-blocker",
|
||
severity: "red",
|
||
blocking: true,
|
||
kind: trigger.kind,
|
||
summary: "web-probe runner matched YAML browserFreezePolicy and stopped the browser; this run must stay red instead of refreshing or falling back",
|
||
rootCause: trigger.rootCause,
|
||
rootCauseStatus: "confirmed-from-runner-browser-freeze-policy",
|
||
rootCauseConfidence: "high",
|
||
fallbackAllowed: false,
|
||
observerRefreshAllowed: false,
|
||
policySource: "config/hwlab-node-lanes.yaml#webProbe.browserFreezePolicy via UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON",
|
||
observed: trigger.observed || null,
|
||
threshold: trigger.threshold || null,
|
||
sample: trigger.sample || null,
|
||
page: trigger.page || null,
|
||
burst: Array.isArray(trigger.burst) ? trigger.burst.slice(0, 20) : [],
|
||
valuesRedacted: true,
|
||
};
|
||
browserFreezeBlocker = { ...base, browserKill: { ok: false, pending: true, valuesRedacted: true }, valuesRedacted: true };
|
||
const browserKill = browserFreezePolicy.kill.enabled === true
|
||
? await terminateBrowserForFreezeBlocker(base).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true }))
|
||
: { ok: true, skipped: true, reason: "kill-disabled-by-yaml-policy", valuesRedacted: true };
|
||
browserFreezeBlocker = { ...base, browserKill, valuesRedacted: true };
|
||
await appendJsonl(files.browserProcess, eventRecord("browser-freeze-blocker", browserFreezeBlocker)).catch(() => {});
|
||
await appendJsonl(files.errors, eventRecord("browser-freeze-blocker", {
|
||
...browserFreezeBlocker,
|
||
error: {
|
||
name: "BrowserFreezeBlocker",
|
||
message: "YAML browserFreezePolicy matched: " + String(trigger.kind || "unknown"),
|
||
details: browserFreezeBlocker,
|
||
valuesRedacted: true,
|
||
},
|
||
})).catch(() => {});
|
||
await writeHeartbeat({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {});
|
||
await writeManifest({ status: "failed", blocker: browserFreezeBlocker }).catch(() => {});
|
||
}
|
||
|
||
async function terminateBrowserForFreezeBlocker(blocker) {
|
||
const childProcess = browser && typeof browser.process === "function" ? browser.process() : null;
|
||
const pid = Number(childProcess?.pid);
|
||
if (!Number.isFinite(pid) || pid <= 0) {
|
||
return { ok: false, reason: "browser-process-unavailable", blockerKind: blocker.kind, valuesRedacted: true };
|
||
}
|
||
const result = {
|
||
ok: false,
|
||
pid: Math.floor(pid),
|
||
gracefulSignal: browserFreezePolicy.kill.gracefulSignal,
|
||
forceSignal: browserFreezePolicy.kill.forceSignal,
|
||
graceMs: browserFreezePolicy.kill.graceMs,
|
||
pollIntervalMs: browserFreezePolicy.kill.pollIntervalMs,
|
||
gracefulSent: false,
|
||
forceSent: false,
|
||
exitedAfterGrace: false,
|
||
exitedAfterForce: false,
|
||
valuesRedacted: true,
|
||
};
|
||
try {
|
||
childProcess.kill(browserFreezePolicy.kill.gracefulSignal);
|
||
result.gracefulSent = true;
|
||
} catch (error) {
|
||
result.gracefulError = errorSummary(error);
|
||
}
|
||
result.exitedAfterGrace = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||
if (!result.exitedAfterGrace) {
|
||
try {
|
||
childProcess.kill(browserFreezePolicy.kill.forceSignal);
|
||
result.forceSent = true;
|
||
} catch (error) {
|
||
result.forceError = errorSummary(error);
|
||
}
|
||
result.exitedAfterForce = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||
}
|
||
result.ok = result.exitedAfterGrace || result.exitedAfterForce;
|
||
return result;
|
||
}
|
||
|
||
async function waitForPidExit(pid, timeoutMs, pollIntervalMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() <= deadline) {
|
||
if (!pidAlive(pid)) return true;
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
return !pidAlive(pid);
|
||
}
|
||
|
||
function pidAlive(pid) {
|
||
try {
|
||
process.kill(pid, 0);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function browserProcessSampleRef(sample) {
|
||
return {
|
||
ts: sample?.ts ?? null,
|
||
seq: sample?.seq ?? null,
|
||
sampleSeq: sample?.sampleSeq ?? null,
|
||
browserPid: sample?.browserPid ?? null,
|
||
totalRssMb: sample?.process?.totalRssMb ?? null,
|
||
maxProcessRssMb: sample?.process?.maxProcessRssMb ?? null,
|
||
totalRssGrowthMb: sample?.growth?.totalRssGrowthMb ?? null,
|
||
maxProcessRssGrowthMb: sample?.growth?.maxProcessRssGrowthMb ?? null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function browserPageMetricRef(pageMetric) {
|
||
const responsiveness = pageMetric?.responsiveness && typeof pageMetric.responsiveness === "object" ? pageMetric.responsiveness : {};
|
||
const cdp = pageMetric?.cdp && typeof pageMetric.cdp === "object" ? pageMetric.cdp : {};
|
||
const effectiveMemory = pageMetric?.effectiveMemory && typeof pageMetric.effectiveMemory === "object" ? pageMetric.effectiveMemory : {};
|
||
return {
|
||
pageRole: pageMetric?.pageRole ?? null,
|
||
pageId: pageMetric?.pageId ?? null,
|
||
pageEpoch: pageMetric?.pageEpoch ?? null,
|
||
timeoutMs: pageMetric?.timeoutMs ?? null,
|
||
responsivenessLatencyMs: responsiveness.latencyMs ?? null,
|
||
responsivenessTimeout: responsiveness.timeout === true,
|
||
cdpTimeoutCount: cdp.timeoutCount ?? null,
|
||
cdpErrorCount: cdp.errorCount ?? null,
|
||
effectiveHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveHeapUsedMb)) ? Number(effectiveMemory.effectiveHeapUsedMb) : null,
|
||
effectiveJsHeapUsedMb: Number.isFinite(Number(effectiveMemory.effectiveJsHeapUsedMb)) ? Number(effectiveMemory.effectiveJsHeapUsedMb) : null,
|
||
heapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.heapUsedGrowthMb)) ? Number(effectiveMemory.heapUsedGrowthMb) : null,
|
||
jsHeapUsedGrowthMb: Number.isFinite(Number(effectiveMemory.jsHeapUsedGrowthMb)) ? Number(effectiveMemory.jsHeapUsedGrowthMb) : null,
|
||
baselineCapturedAt: pageMetric?.baseline?.capturedAt ?? null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function browserProcessPid() {
|
||
try {
|
||
const childProcess = browser && typeof browser.process === "function" ? browser.process() : null;
|
||
const pid = Number(childProcess?.pid);
|
||
return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function collectChromiumProcessSummary(browserPid) {
|
||
const all = await readProcProcessTable();
|
||
const childrenByPpid = new Map();
|
||
for (const item of all) {
|
||
if (!Number.isFinite(item.ppid)) continue;
|
||
const list = childrenByPpid.get(item.ppid) || [];
|
||
list.push(item.pid);
|
||
childrenByPpid.set(item.ppid, list);
|
||
}
|
||
const roots = [process.pid, browserPid].filter((pid, index, items) => Number.isFinite(Number(pid)) && Number(pid) > 0 && items.indexOf(pid) === index).map((pid) => Number(pid));
|
||
const descendants = new Set(roots);
|
||
const queue = roots.slice();
|
||
while (queue.length > 0) {
|
||
const pid = queue.shift();
|
||
for (const child of childrenByPpid.get(pid) || []) {
|
||
if (descendants.has(child)) continue;
|
||
descendants.add(child);
|
||
queue.push(child);
|
||
}
|
||
}
|
||
const processes = all
|
||
.filter((item) => descendants.has(item.pid))
|
||
.map((item) => ({ ...item, role: classifyChromiumProcess(item) }))
|
||
.filter((item) => item.role)
|
||
.map((item) => ({
|
||
pid: item.pid,
|
||
ppid: item.ppid,
|
||
name: item.name || null,
|
||
role: item.role,
|
||
rssBytes: item.rssBytes,
|
||
vmSizeBytes: item.vmSizeBytes,
|
||
commandHash: item.cmdline ? sha256Text(item.cmdline) : null,
|
||
commandPreview: redactProcessCommandPreview(item.cmdline),
|
||
valuesRedacted: true,
|
||
}))
|
||
.sort((a, b) => Number(b.rssBytes || 0) - Number(a.rssBytes || 0));
|
||
const roles = {};
|
||
for (const item of processes) {
|
||
const role = item.role || "unknown";
|
||
const summary = roles[role] || { count: 0, rssBytes: 0, maxRssBytes: 0 };
|
||
summary.count += 1;
|
||
summary.rssBytes += Number(item.rssBytes || 0);
|
||
summary.maxRssBytes = Math.max(summary.maxRssBytes, Number(item.rssBytes || 0));
|
||
roles[role] = summary;
|
||
}
|
||
const totalRssBytes = processes.reduce((sum, item) => sum + Number(item.rssBytes || 0), 0);
|
||
const maxProcess = processes[0] || null;
|
||
return {
|
||
sampledRootPids: roots,
|
||
chromiumProcessCount: processes.length,
|
||
totalRssBytes,
|
||
totalRssMb: bytesToMb(totalRssBytes),
|
||
maxProcessRssBytes: maxProcess ? Number(maxProcess.rssBytes || 0) : 0,
|
||
maxProcessRssMb: maxProcess ? bytesToMb(maxProcess.rssBytes) : 0,
|
||
maxProcess,
|
||
roles,
|
||
processes: processes.slice(0, 20),
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function readProcProcessTable() {
|
||
let entries = [];
|
||
try {
|
||
entries = await readdir("/proc");
|
||
} catch {
|
||
return [];
|
||
}
|
||
const numeric = entries.map((name) => Number(name)).filter((pid) => Number.isInteger(pid) && pid > 0);
|
||
const rows = await Promise.all(numeric.map((pid) => readOneProcProcess(pid).catch(() => null)));
|
||
return rows.filter(Boolean);
|
||
}
|
||
|
||
async function readOneProcProcess(pid) {
|
||
const [statText, statusText, cmdlineText] = await Promise.all([
|
||
readFile(path.join("/proc", String(pid), "stat"), "utf8").catch(() => ""),
|
||
readFile(path.join("/proc", String(pid), "status"), "utf8").catch(() => ""),
|
||
readFile(path.join("/proc", String(pid), "cmdline"), "utf8").catch(() => ""),
|
||
]);
|
||
if (!statText && !statusText) return null;
|
||
const ppid = procPpidFromStat(statText);
|
||
const name = procStatusField(statusText, "Name") || null;
|
||
const rssKb = procStatusKb(statusText, "VmRSS");
|
||
const vmSizeKb = procStatusKb(statusText, "VmSize");
|
||
return {
|
||
pid,
|
||
ppid,
|
||
name,
|
||
cmdline: String(cmdlineText || "").replace(/\0/gu, " ").replace(/\s+/gu, " ").trim(),
|
||
rssBytes: Number.isFinite(rssKb) ? rssKb * 1024 : 0,
|
||
vmSizeBytes: Number.isFinite(vmSizeKb) ? vmSizeKb * 1024 : 0,
|
||
};
|
||
}
|
||
|
||
function procPpidFromStat(value) {
|
||
const text = String(value || "");
|
||
const end = text.lastIndexOf(")");
|
||
if (end < 0) return null;
|
||
const parts = text.slice(end + 1).trim().split(/\s+/u);
|
||
const ppid = Number(parts[1]);
|
||
return Number.isFinite(ppid) ? ppid : null;
|
||
}
|
||
|
||
function procStatusField(value, key) {
|
||
const prefix = String(key || "") + ":";
|
||
for (const line of String(value || "").split(/\n/u)) {
|
||
if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function procStatusKb(value, key) {
|
||
const raw = procStatusField(value, key);
|
||
const match = String(raw || "").match(/^(\d+)\s+kB$/u);
|
||
return match ? Number(match[1]) : null;
|
||
}
|
||
|
||
function classifyChromiumProcess(item) {
|
||
const cmdline = String(item?.cmdline || "");
|
||
const combined = (cmdline + " " + String(item?.name || "")).toLowerCase();
|
||
if (!/(?:chromium|chrome|headless_shell)/u.test(combined)) return null;
|
||
if (/--type=renderer\b/u.test(cmdline)) return "renderer";
|
||
if (/--type=gpu-process\b/u.test(cmdline)) return "gpu";
|
||
if (/--type=utility\b/u.test(cmdline)) return "utility";
|
||
if (/--type=zygote\b/u.test(cmdline)) return "zygote";
|
||
if (/--type=broker\b/u.test(cmdline)) return "broker";
|
||
if (/--type=/u.test(cmdline)) return "other";
|
||
return "browser";
|
||
}
|
||
|
||
function redactProcessCommandPreview(value) {
|
||
const text = String(value || "");
|
||
if (!text) return null;
|
||
const parts = text.split(/\s+/u).slice(0, 18).map((part) => {
|
||
if (/--(?:proxy|token|secret|password|cookie|auth|key)[^=]*=/iu.test(part)) return part.replace(/=.*/u, "=[redacted]");
|
||
return part.replace(/:\/\/[^/@\s]+@/u, "://[redacted]@");
|
||
});
|
||
return truncate(parts.join(" "), 260);
|
||
}
|
||
|
||
function updateBrowserProcessHistory(tsMs, processSummary) {
|
||
const windowMs = Math.max(1000, Number(alertThresholds.browserRssGrowthWindowMs) || 30000);
|
||
const totalRssBytes = Number(processSummary?.totalRssBytes || 0);
|
||
const maxProcessRssBytes = Number(processSummary?.maxProcessRssBytes || 0);
|
||
browserProcessHistory.push({ tsMs, totalRssBytes, maxProcessRssBytes });
|
||
const retentionMs = Math.max(windowMs * 3, 180000);
|
||
while (browserProcessHistory.length > 0 && tsMs - browserProcessHistory[0].tsMs > retentionMs) browserProcessHistory.shift();
|
||
const windowStartMs = tsMs - windowMs;
|
||
const candidates = browserProcessHistory.filter((item) => item.tsMs >= windowStartMs && item.tsMs <= tsMs);
|
||
const baseline = candidates[0] || browserProcessHistory[0] || null;
|
||
const totalRssGrowthBytes = baseline ? totalRssBytes - Number(baseline.totalRssBytes || 0) : 0;
|
||
const maxProcessRssGrowthBytes = baseline ? maxProcessRssBytes - Number(baseline.maxProcessRssBytes || 0) : 0;
|
||
return {
|
||
windowMs,
|
||
baselineAt: baseline ? new Date(baseline.tsMs).toISOString() : null,
|
||
baselineTotalRssBytes: baseline ? baseline.totalRssBytes : null,
|
||
baselineMaxProcessRssBytes: baseline ? baseline.maxProcessRssBytes : null,
|
||
totalRssGrowthBytes,
|
||
totalRssGrowthMb: bytesToMb(totalRssGrowthBytes),
|
||
maxProcessRssGrowthBytes,
|
||
maxProcessRssGrowthMb: bytesToMb(maxProcessRssGrowthBytes),
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function collectBrowserPageRuntimeMetrics(targetPage, { pageRole, targetPageId, pageEpoch }) {
|
||
const timeoutMs = Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000));
|
||
const startedAtMs = Date.now();
|
||
let session = null;
|
||
const result = {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
|
||
url: pageUrl(targetPage),
|
||
timeoutMs,
|
||
responsiveness: null,
|
||
cdp: { timeoutCount: 0, errorCount: 0, calls: [] },
|
||
valuesRedacted: true,
|
||
};
|
||
try {
|
||
session = await withHardTimeout(targetPage.context().newCDPSession(targetPage), timeoutMs, "newCDPSession exceeded " + timeoutMs + "ms");
|
||
const enable = await timedCdpSend(session, "Performance.enable", {}, timeoutMs);
|
||
if (enable.ok !== true) result.cdp.calls.push({ method: "Performance.enable", ...enable });
|
||
result.responsiveness = await timedCdpSend(session, "Runtime.evaluate", { expression: "1", returnByValue: true }, timeoutMs);
|
||
result.cdp.calls.push({ method: "Runtime.evaluate", ...result.responsiveness });
|
||
const performance = await timedCdpSend(session, "Performance.getMetrics", {}, timeoutMs);
|
||
result.cdp.calls.push({ method: "Performance.getMetrics", ...performance });
|
||
result.performance = performance.value;
|
||
const heap = await timedCdpSend(session, "Runtime.getHeapUsage", {}, timeoutMs);
|
||
result.cdp.calls.push({ method: "Runtime.getHeapUsage", ...heap });
|
||
result.heapUsage = heap.value;
|
||
const domCounters = await timedCdpSend(session, "Memory.getDOMCounters", {}, timeoutMs);
|
||
result.cdp.calls.push({ method: "Memory.getDOMCounters", ...domCounters });
|
||
result.domCounters = domCounters.value;
|
||
result.cdp.timeoutCount = result.cdp.calls.filter((item) => item.timeout === true).length;
|
||
result.cdp.errorCount = result.cdp.calls.filter((item) => item.ok !== true).length;
|
||
} catch (error) {
|
||
result.sessionError = errorSummary(error);
|
||
result.cdp.timeoutCount = isTimeoutErrorMessage(error?.message) ? 1 : 0;
|
||
result.cdp.errorCount = 1;
|
||
} finally {
|
||
result.latencyMs = Date.now() - startedAtMs;
|
||
applyBrowserPageRuntimeBaseline(result);
|
||
if (session) await withHardTimeout(session.detach(), 1000, "CDP session detach exceeded 1000ms").catch(() => {});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function applyBrowserPageRuntimeBaseline(result) {
|
||
const key = [result.pageRole || "unknown", result.pageId || "unknown", Number.isFinite(Number(result.pageEpoch)) ? Number(result.pageEpoch) : 0].join(":");
|
||
const current = browserPageRuntimeMemorySnapshot(result);
|
||
const existing = browserPageRuntimeBaselines.get(key);
|
||
if (!existing && current) browserPageRuntimeBaselines.set(key, { ...current, capturedAt: new Date().toISOString(), source: "first-page-runtime-sample", valuesRedacted: true });
|
||
const baseline = browserPageRuntimeBaselines.get(key) || null;
|
||
result.baseline = baseline ? {
|
||
capturedAt: baseline.capturedAt,
|
||
source: baseline.source,
|
||
heapUsedMb: baseline.heapUsedMb,
|
||
jsHeapUsedMb: baseline.jsHeapUsedMb,
|
||
domNodes: baseline.domNodes,
|
||
valuesRedacted: true,
|
||
} : null;
|
||
result.effectiveMemory = browserPageEffectiveMemory(current, baseline);
|
||
}
|
||
|
||
function browserPageRuntimeMemorySnapshot(result) {
|
||
const heapUsedBytes = Number(result?.heapUsage?.usedSize);
|
||
const metrics = result?.performance?.metrics && typeof result.performance.metrics === "object" ? result.performance.metrics : {};
|
||
const jsHeapUsedBytes = Number(metrics.JSHeapUsedSize);
|
||
const domNodes = Number(result?.domCounters?.nodes ?? metrics.Nodes);
|
||
if (!Number.isFinite(heapUsedBytes) && !Number.isFinite(jsHeapUsedBytes) && !Number.isFinite(domNodes)) return null;
|
||
return {
|
||
heapUsedBytes: Number.isFinite(heapUsedBytes) ? heapUsedBytes : null,
|
||
heapUsedMb: Number.isFinite(heapUsedBytes) ? bytesToMb(heapUsedBytes) : null,
|
||
jsHeapUsedBytes: Number.isFinite(jsHeapUsedBytes) ? jsHeapUsedBytes : null,
|
||
jsHeapUsedMb: Number.isFinite(jsHeapUsedBytes) ? bytesToMb(jsHeapUsedBytes) : null,
|
||
domNodes: Number.isFinite(domNodes) ? domNodes : null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function browserPageEffectiveMemory(current, baseline) {
|
||
if (!current) return { available: false, baselineAvailable: Boolean(baseline), valuesRedacted: true };
|
||
const heapUsedGrowthBytes = numericDelta(current.heapUsedBytes, baseline?.heapUsedBytes);
|
||
const jsHeapUsedGrowthBytes = numericDelta(current.jsHeapUsedBytes, baseline?.jsHeapUsedBytes);
|
||
const domNodesGrowth = numericDelta(current.domNodes, baseline?.domNodes);
|
||
return {
|
||
available: true,
|
||
baselineAvailable: Boolean(baseline),
|
||
heapUsedMb: current.heapUsedMb,
|
||
jsHeapUsedMb: current.jsHeapUsedMb,
|
||
effectiveHeapUsedMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : current.heapUsedMb,
|
||
effectiveJsHeapUsedMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : current.jsHeapUsedMb,
|
||
heapUsedGrowthMb: Number.isFinite(heapUsedGrowthBytes) ? bytesToMb(heapUsedGrowthBytes) : null,
|
||
jsHeapUsedGrowthMb: Number.isFinite(jsHeapUsedGrowthBytes) ? bytesToMb(jsHeapUsedGrowthBytes) : null,
|
||
domNodes: current.domNodes,
|
||
domNodesGrowth: Number.isFinite(domNodesGrowth) ? domNodesGrowth : null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function numericDelta(current, baseline) {
|
||
const value = Number(current);
|
||
const base = Number(baseline);
|
||
if (!Number.isFinite(value) || !Number.isFinite(base)) return null;
|
||
return value - base;
|
||
}
|
||
|
||
function browserPageRuntimeMetricError(pageRole, targetPageId, pageEpoch, error) {
|
||
return {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
|
||
url: null,
|
||
timeoutMs: Math.max(1000, Math.floor(Number(alertThresholds.playwrightResponsivenessRedMs) || 5000)),
|
||
responsiveness: { ok: false, timeout: isTimeoutErrorMessage(error?.message), error: errorSummary(error), valuesRedacted: true },
|
||
cdp: { timeoutCount: isTimeoutErrorMessage(error?.message) ? 1 : 0, errorCount: 1, calls: [] },
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function timedCdpSend(session, method, params, timeoutMs) {
|
||
const startedAtMs = Date.now();
|
||
try {
|
||
const value = await withHardTimeout(session.send(method, params || {}), timeoutMs, method + " exceeded " + timeoutMs + "ms");
|
||
return { ok: true, timeout: false, latencyMs: Date.now() - startedAtMs, value: compactCdpValue(method, value), valuesRedacted: true };
|
||
} catch (error) {
|
||
return { ok: false, timeout: isTimeoutErrorMessage(error?.message), latencyMs: Date.now() - startedAtMs, error: errorSummary(error), valuesRedacted: true };
|
||
}
|
||
}
|
||
|
||
function compactCdpValue(method, value) {
|
||
if (!value || typeof value !== "object") return value ?? null;
|
||
if (method === "Performance.getMetrics") {
|
||
const wanted = new Set(["Timestamp", "Documents", "Frames", "JSEventListeners", "Nodes", "LayoutCount", "RecalcStyleCount", "LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "TaskDuration", "JSHeapUsedSize", "JSHeapTotalSize"]);
|
||
const metrics = {};
|
||
for (const item of Array.isArray(value.metrics) ? value.metrics : []) {
|
||
if (wanted.has(item?.name)) metrics[item.name] = Number.isFinite(Number(item?.value)) ? Number(item.value) : null;
|
||
}
|
||
return { metricCount: Array.isArray(value.metrics) ? value.metrics.length : 0, metrics, valuesRedacted: true };
|
||
}
|
||
if (method === "Runtime.getHeapUsage") {
|
||
return {
|
||
usedSize: Number.isFinite(Number(value.usedSize)) ? Number(value.usedSize) : null,
|
||
totalSize: Number.isFinite(Number(value.totalSize)) ? Number(value.totalSize) : null,
|
||
embedderHeapUsedSize: Number.isFinite(Number(value.embedderHeapUsedSize)) ? Number(value.embedderHeapUsedSize) : null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
if (method === "Memory.getDOMCounters") {
|
||
return {
|
||
documents: Number.isFinite(Number(value.documents)) ? Number(value.documents) : null,
|
||
nodes: Number.isFinite(Number(value.nodes)) ? Number(value.nodes) : null,
|
||
jsEventListeners: Number.isFinite(Number(value.jsEventListeners)) ? Number(value.jsEventListeners) : null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
if (method === "Runtime.evaluate") {
|
||
return { resultType: value.result?.type ?? null, unserializableValue: value.result?.unserializableValue ?? null, exceptionDetails: value.exceptionDetails ? { text: truncate(value.exceptionDetails.text || "", 200), valuesRedacted: true } : null, valuesRedacted: true };
|
||
}
|
||
return { valuesRedacted: true };
|
||
}
|
||
|
||
function isTimeoutErrorMessage(value) {
|
||
return /timeout|timed\s*out|exceeded\s+\d+\s*ms/iu.test(String(value || ""));
|
||
}
|
||
|
||
function bytesToMb(value) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
return Number((numeric / 1024 / 1024).toFixed(1));
|
||
}
|
||
|
||
function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) {
|
||
targetPage.on("request", (request) => {
|
||
void appendJsonl(files.network, eventRecord("request", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
observerInitiated: false,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(request.url()),
|
||
resourceType: request.resourceType(),
|
||
frameUrl: safeFrameUrl(request.frame()),
|
||
}));
|
||
});
|
||
targetPage.on("response", (response) => {
|
||
const request = response.request();
|
||
const base = {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
sampleSeq,
|
||
observerInitiated: false,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(response.url()),
|
||
resourceType: request.resourceType(),
|
||
status: response.status(),
|
||
statusText: response.statusText(),
|
||
fromServiceWorker: response.fromServiceWorker(),
|
||
};
|
||
void (async () => {
|
||
const bodyFields = await summarizeWorkbenchResponseBody(response, request);
|
||
await appendJsonl(files.network, eventRecord("response", { ...base, ...bodyFields }));
|
||
})().catch((error) => appendJsonl(files.errors, eventRecord("response-body-summary-error", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
sampleSeq,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(response.url()),
|
||
error: errorSummary(error),
|
||
valuesRedacted: true
|
||
})));
|
||
});
|
||
targetPage.on("requestfailed", (request) => {
|
||
void appendJsonl(files.network, eventRecord("requestfailed", {
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
observerInitiated: false,
|
||
commandId: activeCommandId,
|
||
method: request.method(),
|
||
url: safeUrl(request.url()),
|
||
resourceType: request.resourceType(),
|
||
failure: request.failure()?.errorText ?? null,
|
||
}));
|
||
});
|
||
targetPage.on("console", (message) => {
|
||
void appendJsonl(files.console, eventRecord("console", { pageRole, pageId: targetPageId, type: message.type(), text: truncate(message.text(), 1000), location: message.location() }));
|
||
});
|
||
targetPage.on("pageerror", (error) => {
|
||
void appendJsonl(files.errors, eventRecord("pageerror", { pageRole, pageId: targetPageId, error: errorSummary(error) }));
|
||
});
|
||
targetPage.on("crash", () => {
|
||
void appendJsonl(files.errors, eventRecord("page-crash", { pageRole, pageId: targetPageId }));
|
||
});
|
||
targetPage.on("close", () => {
|
||
void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" }));
|
||
});
|
||
}
|
||
|
||
async function drainOneCommand() {
|
||
const entries = (await readdir(dirs.commandsPending).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
|
||
const name = entries[0];
|
||
if (!name) return;
|
||
const pending = path.join(dirs.commandsPending, name);
|
||
const processing = path.join(dirs.commandsProcessing, name);
|
||
await rename(pending, processing).catch(() => null);
|
||
const raw = await readFile(processing, "utf8");
|
||
const command = JSON.parse(raw);
|
||
const id = safeId(command.id || name.replace(/[.]json$/u, ""));
|
||
command.id = id;
|
||
const stopCommandSampler = startCommandActiveSampler(command);
|
||
try {
|
||
const result = await processCommand(command);
|
||
const done = { ok: true, commandId: id, type: command.type, completedAt: new Date().toISOString(), result: sanitize(result) };
|
||
await writeFile(path.join(dirs.commandsDone, id + ".json"), JSON.stringify(done, null, 2) + "\n", { mode: 0o600 });
|
||
await appendJsonl(files.control, controlRecord(command, "completed", done.result));
|
||
await unlink(processing).catch(() => {});
|
||
} catch (error) {
|
||
const failureSample = await samplePage("command-failed", { refreshObserver: false, screenshot: false })
|
||
.then(() => ({ ok: true, sampleSeq, valuesRedacted: true }))
|
||
.catch((sampleError) => ({ ok: false, error: errorSummary(sampleError), valuesRedacted: true }));
|
||
const failed = { ok: false, commandId: id, type: command.type, failedAt: new Date().toISOString(), error: errorSummary(error), failureSample };
|
||
await writeFile(path.join(dirs.commandsFailed, id + ".json"), JSON.stringify(failed, null, 2) + "\n", { mode: 0o600 });
|
||
await appendJsonl(files.control, controlRecord(command, "failed", { error: failed.error, failureSample }));
|
||
await unlink(processing).catch(() => {});
|
||
} finally {
|
||
stopCommandSampler();
|
||
activeCommandId = null;
|
||
await writeHeartbeat({ status: terminalStatus });
|
||
}
|
||
}
|
||
|
||
function startCommandActiveSampler(command) {
|
||
const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000);
|
||
const heartbeatIntervalMs = Math.min(5000, intervalMs);
|
||
let stopped = false;
|
||
let timer = null;
|
||
let heartbeatTimer = null;
|
||
let inFlight = false;
|
||
const heartbeat = () => {
|
||
if (stopped) return;
|
||
void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })));
|
||
};
|
||
const schedule = () => {
|
||
if (stopped) return;
|
||
timer = setTimeout(tick, intervalMs);
|
||
if (timer && typeof timer.unref === "function") timer.unref();
|
||
};
|
||
const tick = () => {
|
||
if (stopped) return;
|
||
if (inFlight) {
|
||
schedule();
|
||
return;
|
||
}
|
||
inFlight = true;
|
||
samplePage("command-active", { refreshObserver: false, screenshot: false })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("command-active-sample-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) })))
|
||
.finally(() => {
|
||
inFlight = false;
|
||
schedule();
|
||
});
|
||
};
|
||
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
|
||
if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
|
||
schedule();
|
||
return () => {
|
||
stopped = true;
|
||
if (timer) clearTimeout(timer);
|
||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||
};
|
||
}
|
||
|
||
async function processCommand(command) {
|
||
commandSeq += 1;
|
||
activeCommandId = command.id;
|
||
await writeHeartbeat({ status: "running", activeCommandId });
|
||
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
|
||
switch (command.type) {
|
||
case "login": return authenticate(context);
|
||
case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
|
||
case "logout": return withObserverSync(await logoutAccount(command), "logout");
|
||
case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
|
||
case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
|
||
case "preflight": return preflightSummary();
|
||
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
|
||
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
|
||
case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), {
|
||
expectedAction: "turn",
|
||
responsePath: "/v1/agent/chat",
|
||
alternateResponsePaths: ["/v1/agent/chat/steer"],
|
||
noActiveReason: "send-no-turn-composer",
|
||
throwOnActionMismatch: true,
|
||
expectedActionWaitMs: command.expectedActionWaitMs,
|
||
}), "sendPrompt");
|
||
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer");
|
||
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
|
||
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
|
||
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
|
||
case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession");
|
||
case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack");
|
||
case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant");
|
||
case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo");
|
||
case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command);
|
||
case "closeMdtodoSourceConfig": return closeMdtodoSourceConfig(command);
|
||
case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command);
|
||
case "probeMdtodoSource": return probeMdtodoSource(command);
|
||
case "reindexMdtodoSource": return reindexMdtodoSource(command);
|
||
case "selectProjectSource": return selectProjectSource(command);
|
||
case "selectMdtodoSource": return selectMdtodoSource(command);
|
||
case "selectMdtodoFile": return selectMdtodoFile(command);
|
||
case "selectMdtodoTask": return selectMdtodoTask(command);
|
||
case "expandMdtodoTask": return expandMdtodoTask(command);
|
||
case "openMdtodoReportPreview": return openMdtodoReportPreview(command);
|
||
case "toggleMdtodoReportFullscreen": return toggleMdtodoReportFullscreen(command);
|
||
case "editMdtodoTaskInline": return editMdtodoTaskInline(command);
|
||
case "editMdtodoTaskTitle": return editMdtodoTaskTitle(command);
|
||
case "editMdtodoTaskBody": return editMdtodoTaskBody(command);
|
||
case "toggleMdtodoTaskStatus": return toggleMdtodoTaskStatus(command);
|
||
case "addMdtodoRootTask": return addMdtodoRootTask(command);
|
||
case "addMdtodoSubTask": return addMdtodoSubTask(command);
|
||
case "continueMdtodoTask": return continueMdtodoTask(command);
|
||
case "deleteMdtodoTask": return deleteMdtodoTask(command);
|
||
case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask");
|
||
case "launchWorkbenchFromMdtodo": return withObserverSync(await launchWorkbenchFromMdtodo(command), "launchWorkbenchFromMdtodo");
|
||
case "screenshot": return captureCommandScreenshot(command);
|
||
case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId };
|
||
case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId };
|
||
default: throw new Error("unsupported observer command type: " + command.type);
|
||
}
|
||
}
|
||
|
||
async function withObserverSync(result, reason) {
|
||
return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) };
|
||
}
|
||
|
||
async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) {
|
||
if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" };
|
||
const forceRefresh = options?.forceRefresh === true;
|
||
const navigationTimeoutMs = Number.isFinite(Number(options.navigationTimeoutMs)) ? Math.max(1, Number(options.navigationTimeoutMs)) : 45000;
|
||
const readinessTimeoutMs = Number.isFinite(Number(options.readinessTimeoutMs)) ? Math.max(1, Number(options.readinessTimeoutMs)) : 15000;
|
||
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 15000;
|
||
const shortCircuitReadinessTimeoutMs = Number.isFinite(Number(options.shortCircuitReadinessTimeoutMs)) ? Math.max(1, Number(options.shortCircuitReadinessTimeoutMs)) : 1000;
|
||
const shortCircuitHydrationTimeoutMs = Number.isFinite(Number(options.shortCircuitHydrationTimeoutMs)) ? Math.max(1, Number(options.shortCircuitHydrationTimeoutMs)) : 1000;
|
||
const snapshot = await workbenchSessionSnapshot();
|
||
const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
|
||
const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath;
|
||
const targetUrl = new URL(target, baseUrl).toString();
|
||
const beforeUrl = pageUrl(observerPage);
|
||
const beforeSessionId = routeSessionIdFromUrl(beforeUrl);
|
||
const attempts = [];
|
||
if (sessionId && beforeSessionId === sessionId && !forceRefresh) {
|
||
const current = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: shortCircuitReadinessTimeoutMs, hydrationTimeoutMs: shortCircuitHydrationTimeoutMs });
|
||
if (current.ok) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, readiness: current.readiness, hydration: current.hydration, valuesRedacted: true };
|
||
attempts.push({ attempt: 0, ok: false, shortCircuitRejected: true, failureKind: current.failureKind, readiness: current.readiness, hydration: current.hydration, beforeUrl, afterUrl: pageUrl(observerPage), valuesRedacted: true });
|
||
}
|
||
const maxAttempts = Number.isFinite(Number(options?.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
const attemptBeforeUrl = pageUrl(observerPage);
|
||
observerPageEpoch += 1;
|
||
let status = null;
|
||
let statusText = null;
|
||
const response = await observerPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs }).catch((error) => ({ observerGotoError: errorSummary(error) }));
|
||
if (response?.observerGotoError) {
|
||
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(response.observerGotoError?.message || response.observerGotoError?.name || "observer-navigation-error"), beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), error: response.observerGotoError, valuesRedacted: true });
|
||
if (attempt < maxAttempts && isRetryableNavigationError(response.observerGotoError?.message || response.observerGotoError?.name || "")) {
|
||
await recreateObserverPageForNavigation("observer-goto-error", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
continue;
|
||
}
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: false, reason, changed: false, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: response.observerGotoError, attempts, valuesRedacted: true };
|
||
}
|
||
status = typeof response?.status === "function" ? response.status() : null;
|
||
statusText = typeof response?.statusText === "function" ? response.statusText() : null;
|
||
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: readinessTimeoutMs });
|
||
if (!readiness.ok) {
|
||
attempts.push({ attempt, ok: false, failureKind: readiness.reason || "observer-target-not-ready", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
|
||
if (attempt < maxAttempts && observerReadinessRetryable(readiness)) {
|
||
await recreateObserverPageForNavigation(readiness.reason || "observer-target-not-ready", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
continue;
|
||
}
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, attempts, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
|
||
}
|
||
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
|
||
lastObserverRefreshAtMs = Date.now();
|
||
attempts.push({ attempt, ok: true, beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, valuesRedacted: true });
|
||
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, attempts, valuesRedacted: true };
|
||
}
|
||
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: hydrationTimeoutMs });
|
||
attempts.push({ attempt, ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", beforeUrl: attemptBeforeUrl, afterUrl: pageUrl(observerPage), httpStatus: status, statusText, readiness, hydration, valuesRedacted: true });
|
||
if (hydration.ok === true) {
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, attempts, failureKind: null, valuesRedacted: true };
|
||
}
|
||
if (attempt < maxAttempts && observerHydrationRetryable(hydration)) {
|
||
await recreateObserverPageForNavigation(hydration.reason || "observer-session-hydration-failed", attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("observer-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
continue;
|
||
}
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration, attempts, failureKind: hydration.reason || "observer-session-hydration-failed", valuesRedacted: true };
|
||
}
|
||
lastObserverRefreshAtMs = Date.now();
|
||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, attempts, failureKind: "observer-sync-retry-exhausted", valuesRedacted: true };
|
||
}
|
||
|
||
async function observerSessionReadiness(targetUrl, sessionId, options = {}) {
|
||
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: options.readinessTimeoutMs ?? 1000 });
|
||
if (!readiness.ok) return { ok: false, failureKind: readiness.reason || "observer-target-not-ready", readiness, hydration: null, valuesRedacted: true };
|
||
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) return { ok: true, readiness, hydration: null, valuesRedacted: true };
|
||
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: options.hydrationTimeoutMs ?? 1000 });
|
||
return { ok: hydration.ok === true, failureKind: hydration.ok === true ? null : hydration.reason || "observer-session-hydration-failed", readiness, hydration, valuesRedacted: true };
|
||
}
|
||
|
||
function observerReadinessRetryable(readiness) {
|
||
const reason = String(readiness?.reason || "");
|
||
const snapshot = readiness?.snapshot || {};
|
||
return /workbench-app-not-ready|observer-target-not-ready/iu.test(reason)
|
||
|| snapshot.workbenchShellVisible === false
|
||
|| snapshot.sessionRailPresent === false && snapshot.commandInputPresent === false;
|
||
}
|
||
|
||
function observerHydrationRetryable(hydration) {
|
||
const reason = String(hydration?.reason || "");
|
||
return /observer-session-hydration-timeout|observer-session-hydration-failed|active-session-not-hydrated|route-session-not-hydrated/iu.test(reason);
|
||
}
|
||
|
||
async function recreateObserverPageForNavigation(reason, attempt) {
|
||
const before = pageUrl(observerPage);
|
||
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
|
||
observerPage = await context.newPage();
|
||
attachPassiveListeners(observerPage, "observer", observerPageId);
|
||
await appendJsonl(files.control, eventRecord("observer-page-recreated", { reason, attempt, beforeUrl: before, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, valuesRedacted: true }));
|
||
}
|
||
|
||
async function waitForWorkbenchSessionHydrated(targetPage, sessionId, options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 15000;
|
||
const started = Date.now();
|
||
const deadline = started + Math.max(1, timeoutMs);
|
||
let last = null;
|
||
while (Date.now() <= deadline) {
|
||
last = await workbenchSessionSnapshot(targetPage);
|
||
const expected = String(sessionId || "").trim();
|
||
const observedPath = safeUrlPath(last?.url || pageUrl(targetPage));
|
||
const routeOk = expected ? last?.routeSessionId === expected : isWorkbenchPathname(observedPath || "");
|
||
const activeOk = expected ? last?.activeSessionId === expected : isWorkbenchPathname(observedPath || "");
|
||
if (routeOk && activeOk) return { ok: true, durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
|
||
await targetPage.waitForTimeout(250).catch(() => {});
|
||
}
|
||
return { ok: false, durationMs: Date.now() - started, snapshot: last, reason: "observer-session-hydration-timeout", expectedSessionId: sessionId || null, valuesRedacted: true };
|
||
}
|
||
|
||
async function maybeRefreshObserverPage(reason) {
|
||
if (!observerPage || observerPage.isClosed()) return null;
|
||
if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null;
|
||
if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null;
|
||
const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true });
|
||
await appendJsonl(files.control, eventRecord("observer-periodic-refresh", {
|
||
pageRole: "observer",
|
||
pageId: observerPageId,
|
||
reason,
|
||
intervalMs: observerRefreshIntervalMs,
|
||
result,
|
||
valuesRedacted: true
|
||
}));
|
||
return result;
|
||
}
|
||
|
||
async function runControlCommand(command, fn) {
|
||
activeCommandId = command.id;
|
||
commandSeq += 1;
|
||
const beforeUrl = currentPageUrl();
|
||
const started = Date.now();
|
||
await appendJsonl(files.control, controlRecord(command, "started", { beforeUrl, input: commandInputSummary(command) }));
|
||
try {
|
||
const result = await fn();
|
||
await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) }));
|
||
return result;
|
||
} catch (error) {
|
||
let failurePageProvenance = null;
|
||
let failureScreenshot = null;
|
||
try {
|
||
failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null));
|
||
} catch (captureError) {
|
||
await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||
}
|
||
try {
|
||
failureScreenshot = await captureScreenshot("command-failed", "jpeg");
|
||
} catch (captureError) {
|
||
await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||
}
|
||
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot }));
|
||
throw error;
|
||
} finally {
|
||
activeCommandId = null;
|
||
}
|
||
}
|
||
|
||
async function authenticate(browserContext) {
|
||
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
||
const attempts = [];
|
||
const maxAttempts = authLoginMaxAttempts;
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
|
||
const retryLabel = attempt + "/" + maxAttempts;
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", retryAttempt: attempt, retryMaxAttempts: maxAttempts, lastRetryLabel: retryLabel, retryDelayMs: 0, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
|
||
try {
|
||
const response = await pageAuthLogin(browserContext, loginUrl);
|
||
const cookieState = await readAuthCookieState(browserContext);
|
||
const retryable = isRetryableAuthStatus(response.status);
|
||
const item = {
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
requestTimeoutMs: authLoginRequestTimeoutMs,
|
||
method: "api",
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
retryable,
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
valuesRedacted: true,
|
||
};
|
||
attempts.push(item);
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item.retryLabel, retryAttempt: item.retryAttempt, retryMaxAttempts: item.retryMaxAttempts, retryDelayMs: item.retryDelayMs, lastStatus: item.status, lastStatusText: item.statusText, retryable: item.retryable, cookiePresent: item.cookiePresent, retryExhausted: false, valuesRedacted: true } }).catch(() => {});
|
||
if (response.ok && cookieState.cookiePresent) {
|
||
return {
|
||
ok: true,
|
||
method: "api",
|
||
loginPath: new URL(loginUrl).pathname,
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
cookiePresent: true,
|
||
cookieNames: cookieState.cookieNames,
|
||
attempts,
|
||
retryCount: attempt - 1,
|
||
retryMaxAttempts: maxAttempts,
|
||
lastRetryLabel: attempt + "/" + maxAttempts,
|
||
retryExhausted: false,
|
||
retryable: false,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
if (!retryable) break;
|
||
} catch (error) {
|
||
const retryable = isRetryableAuthError(error);
|
||
attempts.push({
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
requestTimeoutMs: authLoginRequestTimeoutMs,
|
||
method: "api",
|
||
status: 0,
|
||
statusText: "request-error",
|
||
retryable,
|
||
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
|
||
cookiePresent: false,
|
||
cookieNames: [],
|
||
valuesRedacted: true,
|
||
});
|
||
const item = attempts[attempts.length - 1] || null;
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {});
|
||
if (!retryable) break;
|
||
}
|
||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
||
}
|
||
const cookieState = await readAuthCookieState(browserContext);
|
||
const last = attempts[attempts.length - 1] || null;
|
||
const retryable = attempts.some((attempt) => attempt && attempt.retryable === true);
|
||
const failure = {
|
||
ok: false,
|
||
method: "api",
|
||
loginPath: new URL(loginUrl).pathname,
|
||
status: typeof last?.status === "number" ? last.status : 0,
|
||
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
attempts,
|
||
retryCount: Math.max(0, attempts.length - 1),
|
||
retryMaxAttempts: maxAttempts,
|
||
lastRetryLabel: last?.retryLabel || null,
|
||
retryExhausted: retryable && attempts.length >= maxAttempts,
|
||
retryable,
|
||
lastError: last?.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: failure.lastRetryLabel, retryAttempt: attempts.length, retryMaxAttempts: maxAttempts, retryDelayMs: 0, lastStatus: failure.status, lastStatusText: failure.statusText, retryable: failure.retryable, cookiePresent: failure.cookiePresent, retryExhausted: failure.retryExhausted, lastError: failure.lastError, valuesRedacted: true } }).catch(() => {});
|
||
const error = new Error(authFailureMessage(failure));
|
||
error.webProbeAuth = failure;
|
||
throw error;
|
||
}
|
||
|
||
async function pageAuthLogin(browserContext, loginUrl, credential = { username, password }) {
|
||
if (!browserContext?.request) throw new Error("auth browser context request is not ready");
|
||
const response = await browserContext.request.post(loginUrl, {
|
||
data: { username: credential.username, password: credential.password },
|
||
headers: { accept: "application/json", "content-type": "application/json" },
|
||
timeout: authLoginRequestTimeoutMs,
|
||
});
|
||
await response.text().catch(() => "");
|
||
return {
|
||
ok: response.ok(),
|
||
status: response.status(),
|
||
statusText: response.statusText() || "",
|
||
};
|
||
}
|
||
|
||
async function loginAccount(command) {
|
||
const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
|
||
const credential = credentialForAccount(accountId);
|
||
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
||
const before = await accountSessionSnapshot();
|
||
const attempts = [];
|
||
let response = null;
|
||
let cookieState = null;
|
||
const maxAttempts = authLoginMaxAttempts;
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
const retryDelayMs = authRetryDelayMs(attempt, maxAttempts);
|
||
const retryLabel = attempt + "/" + maxAttempts;
|
||
try {
|
||
response = await pageAuthLogin(context, loginUrl, credential);
|
||
cookieState = await readAuthCookieState(context);
|
||
const retryable = isRetryableAuthStatus(response.status);
|
||
attempts.push({
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
requestTimeoutMs: authLoginRequestTimeoutMs,
|
||
method: "api",
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
retryable,
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
credentialSource: credential.source,
|
||
valuesRedacted: true,
|
||
});
|
||
if (response.ok && cookieState.cookiePresent) break;
|
||
if (!retryable) break;
|
||
} catch (error) {
|
||
const retryable = isRetryableAuthError(error);
|
||
attempts.push({
|
||
attempt,
|
||
retryAttempt: attempt,
|
||
retryMaxAttempts: maxAttempts,
|
||
retryLabel,
|
||
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
||
requestTimeoutMs: authLoginRequestTimeoutMs,
|
||
method: "api",
|
||
status: 0,
|
||
statusText: "request-error",
|
||
retryable,
|
||
error: error && error.message ? truncate(error.message, 500) : truncate(String(error), 500),
|
||
cookiePresent: false,
|
||
cookieNames: [],
|
||
credentialSource: credential.source,
|
||
valuesRedacted: true,
|
||
});
|
||
response = { ok: false, status: 0, statusText: "request-error" };
|
||
cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] }));
|
||
if (!retryable) break;
|
||
}
|
||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
||
}
|
||
response = response ?? { ok: false, status: 0, statusText: "api-login-failed" };
|
||
cookieState = cookieState ?? await readAuthCookieState(context);
|
||
if (!response.ok || !cookieState.cookiePresent) {
|
||
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
|
||
const retryable = attempts.some((item) => item && item.retryable === true);
|
||
error.details = {
|
||
accountId,
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
cookiePresent: cookieState.cookiePresent,
|
||
credentialSource: credential.source,
|
||
attempts,
|
||
retryCount: Math.max(0, attempts.length - 1),
|
||
retryMaxAttempts: maxAttempts,
|
||
lastRetryLabel: attempts[attempts.length - 1]?.retryLabel || null,
|
||
retryExhausted: retryable && attempts.length >= maxAttempts,
|
||
retryable,
|
||
valuesRedacted: true,
|
||
};
|
||
throw error;
|
||
}
|
||
const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
|
||
const navigation = await gotoTarget(target || targetPath);
|
||
const after = await accountSessionSnapshot();
|
||
return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
|
||
}
|
||
|
||
async function logoutAccount(command = {}) {
|
||
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
|
||
const before = await accountSessionSnapshot();
|
||
const logoutUrl = new URL("/logout", baseUrl).toString();
|
||
const response = await page.evaluate(async (input) => {
|
||
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
|
||
await res.text().catch(() => "");
|
||
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
|
||
}, { logoutUrl });
|
||
await context.clearCookies().catch(() => {});
|
||
const cookieState = await readAuthCookieState(context);
|
||
const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
|
||
const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
|
||
if (!result.ok) {
|
||
const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
|
||
error.details = result;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function listSessions(command = {}) {
|
||
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
|
||
if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
|
||
const snapshot = await workbenchSessionSnapshot();
|
||
const sessions = await page.evaluate(() => {
|
||
const seen = new Set();
|
||
const rows = [];
|
||
for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
|
||
const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
|
||
if (!sessionId || seen.has(sessionId)) continue;
|
||
seen.add(sessionId);
|
||
rows.push({
|
||
sessionId,
|
||
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
|
||
status: element.getAttribute("data-status") || null,
|
||
conversationId: element.getAttribute("data-conversation-id") || null,
|
||
});
|
||
}
|
||
return rows.slice(0, 50);
|
||
}).catch(() => []);
|
||
return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
|
||
}
|
||
|
||
async function switchSessions(command) {
|
||
const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
|
||
const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
|
||
const before = await accountSessionSnapshot();
|
||
if (fromAccountId) {
|
||
const beforeAccount = before.accountId || null;
|
||
await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
|
||
}
|
||
const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
|
||
const login = await loginAccount({ ...command, accountId: toAccountId });
|
||
const sessions = await listSessions({ ...command, accountId: toAccountId });
|
||
return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
|
||
}
|
||
|
||
async function accountSessionSnapshot() {
|
||
const cookieState = await readAuthCookieState(context);
|
||
const workbench = await workbenchSessionSnapshot().catch(() => null);
|
||
return {
|
||
url: currentPageUrl(),
|
||
path: safeUrlPath(currentPageUrl()),
|
||
cookiePresent: cookieState.cookiePresent,
|
||
cookieNames: cookieState.cookieNames,
|
||
activeSessionId: workbench?.activeSessionId || null,
|
||
routeSessionId: workbench?.routeSessionId || null,
|
||
tabCount: workbench?.tabCount ?? null,
|
||
messageCount: workbench?.messageCount ?? null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function requiredAccountId(command, keys) {
|
||
const accountId = commandValue(command, keys);
|
||
if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
|
||
return accountId;
|
||
}
|
||
|
||
function credentialForAccount(accountId) {
|
||
if (accountId === "bootstrap-admin" || accountId === "admin") {
|
||
if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
|
||
return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
|
||
}
|
||
const env = accountCredentialEnvCandidates(accountId);
|
||
for (const jsonKey of env.jsonKeys) {
|
||
const raw = process.env[jsonKey];
|
||
if (!raw) continue;
|
||
const parsed = parseCredentialJson(raw);
|
||
if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
|
||
}
|
||
for (const pair of env.pairs) {
|
||
const user = process.env[pair.userKey];
|
||
const pass = process.env[pair.passKey];
|
||
if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
|
||
}
|
||
throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
|
||
}
|
||
|
||
function accountCredentialEnvCandidates(accountId) {
|
||
const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
|
||
return {
|
||
jsonKeys: [
|
||
"HWLAB_WEB_" + segment + "_JSON",
|
||
"HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
|
||
],
|
||
pairs: [
|
||
{ userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
|
||
{ userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
|
||
],
|
||
};
|
||
}
|
||
|
||
function parseCredentialJson(raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||
const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
|
||
const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
|
||
if (!user || !pass) return null;
|
||
return { username: user, password: pass, valuesRedacted: true };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isSafeAccountId(value) {
|
||
return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
|
||
}
|
||
|
||
function publicAuth(value) {
|
||
if (!value) return null;
|
||
return {
|
||
ok: value.ok === true,
|
||
method: value.method,
|
||
status: value.status,
|
||
cookiePresent: value.cookiePresent === true,
|
||
cookieNames: value.cookieNames || [],
|
||
retryCount: value.retryCount ?? null,
|
||
retryMaxAttempts: value.retryMaxAttempts ?? null,
|
||
lastRetryLabel: value.lastRetryLabel ?? null,
|
||
retryExhausted: value.retryExhausted === true,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function readAuthCookieState(browserContext) {
|
||
const cookies = await browserContext.cookies(baseUrl);
|
||
const cookieNames = cookies.map((cookie) => cookie.name).sort();
|
||
return {
|
||
cookiePresent: cookieNames.includes("hwlab_session") || cookieNames.some((name) => /session|auth|token/iu.test(name)),
|
||
cookieNames: cookieNames.filter((name) => /session|auth|token/iu.test(name)),
|
||
};
|
||
}
|
||
|
||
function isRetryableAuthStatus(status) {
|
||
return status === 0 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
||
}
|
||
|
||
function isRetryableAuthError(error) {
|
||
const message = error && error.message ? String(error.message) : String(error || "");
|
||
return /AbortError|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket hang up|ERR_NETWORK_CHANGED|fetch failed|failed to fetch|network|timeout|aborted/iu.test(message);
|
||
}
|
||
|
||
function authRetryDelayMs(attempt, maxAttempts) {
|
||
return attempt < maxAttempts ? Math.min(authLoginMaxDelayMs, authLoginInitialDelayMs * (2 ** (attempt - 1))) : 0;
|
||
}
|
||
|
||
function authFailureMessage(failure) {
|
||
const last = Array.isArray(failure.attempts) && failure.attempts.length > 0 ? failure.attempts[failure.attempts.length - 1] : null;
|
||
const retry = failure.lastRetryLabel ? " retry=" + failure.lastRetryLabel : "";
|
||
const exhausted = failure.retryExhausted ? " exhausted=true" : "";
|
||
const status = last ? " status=" + (last.status ?? "-") + " " + (last.statusText ?? "") : "";
|
||
const error = last?.error ? " error=" + truncate(last.error, 160) : "";
|
||
return ("auth login failed:" + retry + exhausted + status + error).trim();
|
||
}
|
||
|
||
function proxyConfigFromEnv(targetBaseUrl) {
|
||
if (browserProxyMode === "direct") return null;
|
||
let target;
|
||
try {
|
||
target = new URL(targetBaseUrl);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
|
||
if (noProxyMatches(target.hostname, noProxy)) return null;
|
||
const raw = target.protocol === "https:"
|
||
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""
|
||
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || "";
|
||
if (!raw) return null;
|
||
return { server: raw };
|
||
}
|
||
|
||
function chromiumLaunchOptionsForProxy(proxy) {
|
||
const 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" });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, degraded: true, degradedReason: readiness.reason || "workbench-app-not-ready", pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
|
||
}
|
||
const pageProvenance = await refreshPageProvenance("goto", httpStatus);
|
||
attempts.push({ attempt, ok: true, httpStatus, readiness });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null });
|
||
if (lateReadinessTimeoutMs > 0 && /workbench-app-not-ready|navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(message)) {
|
||
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: lateReadinessTimeoutMs }).catch(() => null);
|
||
if (lateReadiness?.ok) {
|
||
const pageProvenance = await refreshPageProvenance("goto-late-ready", null);
|
||
attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness });
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts };
|
||
}
|
||
}
|
||
if (attempt >= maxAttempts || !isRetryableNavigationError(message)) {
|
||
throw Object.assign(new Error(message), { attempts, target });
|
||
}
|
||
if (!observerPage) {
|
||
await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
} else {
|
||
await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||
}
|
||
await page.waitForTimeout(1500 * attempt).catch(() => {});
|
||
}
|
||
}
|
||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts };
|
||
}
|
||
|
||
async function recreateControlPageForNavigation(reason, attempt) {
|
||
const before = currentPageUrl();
|
||
if (page && !page.isClosed()) await withHardTimeout(page.close(), 3000, "control page close exceeded 3000ms").catch((error) => appendJsonl(files.errors, eventRecord("control-page-close-timeout", { reason, attempt, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
|
||
controlPageEpoch += 1;
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
currentPageProvenance = null;
|
||
await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }));
|
||
}
|
||
|
||
async function recreateAuthenticatedContextForNavigation(reason, attempt) {
|
||
const before = currentPageUrl();
|
||
if (page && !page.isClosed()) await page.close().catch(() => {});
|
||
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
|
||
observerPage = null;
|
||
if (context) await context.close().catch(() => {});
|
||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||
auth = await authenticate(context);
|
||
page = await context.newPage();
|
||
attachPassiveListeners(page, "control", pageId);
|
||
currentPageProvenance = null;
|
||
await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true }));
|
||
}
|
||
|
||
async function refreshPageProvenance(reason, httpStatus = null) {
|
||
if (!page || page.isClosed()) return currentPageProvenance;
|
||
const observed = await page.evaluate(() => {
|
||
const assetPath = (raw) => {
|
||
if (!raw) return null;
|
||
try {
|
||
const url = new URL(raw, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const meta = Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
|
||
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
|
||
content: String(element.getAttribute("content") || "").slice(0, 200),
|
||
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key));
|
||
const navigation = performance.getEntriesByType("navigation")[0] || null;
|
||
return {
|
||
url: location.href,
|
||
path: location.pathname,
|
||
title: document.title,
|
||
readyState: document.readyState,
|
||
timeOrigin: Math.round(performance.timeOrigin || 0),
|
||
navigationStartTime: navigation ? Math.round(navigation.startTime) : null,
|
||
scripts: Array.from(document.scripts).map((element) => assetPath(element.src)).filter(Boolean).sort(),
|
||
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => assetPath(element.href)).filter(Boolean).sort(),
|
||
meta,
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), url: currentPageUrl(), path: null, scripts: [], stylesheets: [], meta: [] }));
|
||
pageLoadSeq += 1;
|
||
currentPageProvenance = normalizePageProvenance(observed, { reason, httpStatus, pageLoadSeq });
|
||
await appendJsonl(files.control, eventRecord("page-provenance", { reason, httpStatus, pageProvenance: compactPageProvenance(currentPageProvenance) }));
|
||
return currentPageProvenance;
|
||
}
|
||
|
||
function normalizePageProvenance(value, options = {}) {
|
||
const scripts = Array.isArray(value?.scripts) ? value.scripts.map(String).filter(Boolean) : [];
|
||
const stylesheets = Array.isArray(value?.stylesheets) ? value.stylesheets.map(String).filter(Boolean) : [];
|
||
const meta = Array.isArray(value?.meta) ? value.meta.map((item) => ({
|
||
key: String(item?.key || "").slice(0, 120),
|
||
contentHash: sha256Text(String(item?.content || "")),
|
||
})).filter((item) => item.key) : [];
|
||
const fingerprintInput = JSON.stringify({ scripts, stylesheets, meta });
|
||
return {
|
||
pageLoadSeq: options.pageLoadSeq ?? pageLoadSeq,
|
||
reason: options.reason || "sample",
|
||
observedAt: new Date().toISOString(),
|
||
urlPath: safeUrlPath(value?.url || currentPageUrl()),
|
||
documentPath: value?.path || null,
|
||
titleHash: sha256Text(String(value?.title || "")),
|
||
documentReadyState: value?.readyState || null,
|
||
timeOrigin: Number.isFinite(Number(value?.timeOrigin)) ? Number(value.timeOrigin) : null,
|
||
navigationStartTime: Number.isFinite(Number(value?.navigationStartTime)) ? Number(value.navigationStartTime) : null,
|
||
httpStatus: options.httpStatus ?? null,
|
||
assetFingerprint: sha256Text(fingerprintInput),
|
||
scriptCount: scripts.length,
|
||
stylesheetCount: stylesheets.length,
|
||
metaCount: meta.length,
|
||
scripts: scripts.slice(0, 30),
|
||
stylesheets: stylesheets.slice(0, 30),
|
||
meta: meta.slice(0, 30),
|
||
error: value?.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function compactPageProvenance(value) {
|
||
if (!value) return null;
|
||
return {
|
||
pageLoadSeq: value.pageLoadSeq ?? null,
|
||
reason: value.reason || null,
|
||
observedAt: value.observedAt || null,
|
||
urlPath: value.urlPath || null,
|
||
documentReadyState: value.documentReadyState || null,
|
||
timeOrigin: value.timeOrigin ?? null,
|
||
httpStatus: value.httpStatus ?? null,
|
||
assetFingerprint: value.assetFingerprint || null,
|
||
scriptCount: value.scriptCount ?? 0,
|
||
stylesheetCount: value.stylesheetCount ?? 0,
|
||
metaCount: value.metaCount ?? 0,
|
||
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 12) : [],
|
||
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 12) : [],
|
||
error: value.error || null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function isRetryableNavigationError(message) {
|
||
return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|net::ERR_NAME_NOT_RESOLVED|Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded|workbench-app-not-ready/iu.test(String(message || ""));
|
||
}
|
||
|
||
function navigationFailureKind(message) {
|
||
const text = String(message || "");
|
||
if (/net::ERR_NETWORK_CHANGED/iu.test(text)) return "net::ERR_NETWORK_CHANGED";
|
||
if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED";
|
||
if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET";
|
||
if (/net::ERR_NAME_NOT_RESOLVED/iu.test(text)) return "net::ERR_NAME_NOT_RESOLVED";
|
||
if (/Navigation timeout|page\.goto:\s*timeout|timeout\s+\d+ms\s+exceeded/iu.test(text)) return "navigation-timeout";
|
||
if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready";
|
||
return "navigation-error";
|
||
}
|
||
|
||
function redactErrorMessage(message) {
|
||
return String(message || "")
|
||
.replace(/([?&](?:token|key|password|secret|authorization)=)[^&\s]+/giu, "$1[redacted]")
|
||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]");
|
||
}
|
||
|
||
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
|
||
const targetPathname = safeUrlPath(targetUrl) || "";
|
||
if (isProjectManagementPathname(targetPathname)) {
|
||
const started = Date.now();
|
||
const selectors = projectManagement.readinessSelectors;
|
||
await targetPage.waitForFunction((input) => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return input.selectors.some((selector) => {
|
||
try { return visible(document.querySelector(selector)); } catch { return false; }
|
||
});
|
||
}, { selectors }, { timeout: timeoutMs }).catch(() => null);
|
||
const snapshot = await projectManagementReadinessSnapshot(targetPage);
|
||
const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true;
|
||
return {
|
||
ok,
|
||
reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready",
|
||
durationMs: Date.now() - started,
|
||
snapshot,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
|
||
const started = Date.now();
|
||
await targetPage.waitForFunction(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const workspace = document.querySelector("#workspace, .workbench-route");
|
||
const login = document.querySelector("form.login-card, .login-card, [data-testid='login']");
|
||
return Boolean(visible(workspace) || visible(login));
|
||
}, null, { timeout: timeoutMs }).catch(() => null);
|
||
const snapshot = await workbenchReadinessSnapshot(targetPage);
|
||
const ok = snapshot.workbenchShellVisible === true;
|
||
return {
|
||
ok,
|
||
reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready",
|
||
durationMs: Date.now() - started,
|
||
snapshot,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function workbenchReadinessSnapshot(targetPage) {
|
||
const snapshot = await targetPage.evaluate(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const sessionCreate = document.querySelector("#session-create");
|
||
const sessionRail = document.querySelector("#session-sidebar");
|
||
const sessionCollapseToggle = document.querySelector("#session-collapse-toggle");
|
||
return {
|
||
url: window.location.href,
|
||
path: window.location.pathname,
|
||
readyState: document.readyState,
|
||
workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")),
|
||
sessionCreatePresent: Boolean(sessionCreate),
|
||
sessionCreateVisible: visible(sessionCreate),
|
||
sessionRailPresent: Boolean(sessionRail),
|
||
sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null,
|
||
sessionCollapseTogglePresent: Boolean(sessionCollapseToggle),
|
||
sessionCollapseToggleVisible: visible(sessionCollapseToggle),
|
||
sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null,
|
||
commandInputPresent: visible(document.querySelector("#command-input")),
|
||
activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")),
|
||
warningPresent: visible(document.querySelector(".composer-warning")),
|
||
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
|
||
bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000),
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
if (snapshot && typeof snapshot.bodyTextPreview === "string") {
|
||
snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview);
|
||
delete snapshot.bodyTextPreview;
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
async function projectManagementReadinessSnapshot(targetPage) {
|
||
const selectors = projectManagement.readinessSelectors;
|
||
return targetPage.evaluate((input) => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const selectorStates = input.selectors.map((selector) => {
|
||
let matched = false;
|
||
let visibleMatched = false;
|
||
try {
|
||
const element = document.querySelector(selector);
|
||
matched = Boolean(element);
|
||
visibleMatched = visible(element);
|
||
} catch {}
|
||
return { selector, matched, visible: visibleMatched };
|
||
});
|
||
return {
|
||
url: window.location.href,
|
||
path: window.location.pathname,
|
||
readyState: document.readyState,
|
||
projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')),
|
||
mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')),
|
||
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
|
||
selectorStates,
|
||
valuesRedacted: true
|
||
};
|
||
}, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
}
|
||
|
||
async function waitForProjectManagementCommandReady(options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
|
||
const started = Date.now();
|
||
const deadline = started + timeoutMs;
|
||
let last = null;
|
||
while (Date.now() <= deadline) {
|
||
last = await projectManagementCommandSnapshot();
|
||
const path = String(last?.path || safeUrlPath(currentPageUrl()) || "");
|
||
const baseReady = last?.pageKind === "project-management-mdtodo"
|
||
&& Number(last?.sourceCount || 0) > 0
|
||
&& Number(last?.fileCount || 0) > 0
|
||
&& Number(last?.taskCount || 0) > 0;
|
||
const needsTask = /\/tasks\//u.test(path);
|
||
const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
|
||
const needsReport = /\/reports\//u.test(path);
|
||
const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true;
|
||
if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
|
||
await page.waitForTimeout(250).catch(() => {});
|
||
}
|
||
return { ok: false, reason: "project-management-command-not-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
|
||
}
|
||
|
||
function isWorkbenchPathname(value) {
|
||
const pathname = String(value || "");
|
||
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
|
||
}
|
||
|
||
function isProjectManagementPathname(value) {
|
||
if (projectManagement.enabled !== true) return false;
|
||
const pathname = String(value || "");
|
||
return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/"));
|
||
}
|
||
|
||
function isAgentSessionCreateRequest(requestOrUrl) {
|
||
const method = typeof requestOrUrl?.method === "function" ? requestOrUrl.method().toUpperCase() : "";
|
||
if (method && method !== "POST") return false;
|
||
const url = typeof requestOrUrl === "string" ? requestOrUrl : typeof requestOrUrl?.url === "function" ? requestOrUrl.url() : "";
|
||
try {
|
||
return new URL(url).pathname === "/v1/agent/sessions";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function requestFailureSummary(request) {
|
||
let failure = null;
|
||
try {
|
||
failure = request.failure();
|
||
} catch {}
|
||
let urlPath = null;
|
||
try {
|
||
urlPath = new URL(request.url()).pathname;
|
||
} catch {}
|
||
return {
|
||
method: typeof request.method === "function" ? request.method().toUpperCase() : null,
|
||
urlPath,
|
||
failureText: failure?.errorText || null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function ensureSessionRailExpanded() {
|
||
const before = await workbenchReadinessSnapshot(page);
|
||
if (before?.sessionCreateVisible === true) {
|
||
return { ok: true, action: "already-visible", before, after: before, valuesRedacted: true };
|
||
}
|
||
const toggle = page.locator("#session-collapse-toggle").first();
|
||
const toggleVisible = await toggle.isVisible({ timeout: 2000 }).catch(() => false);
|
||
if (before?.sessionRailCollapsed !== true || !toggleVisible) {
|
||
return { ok: false, action: "not-expanded", reason: before?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before, after: before, valuesRedacted: true };
|
||
}
|
||
await toggle.click();
|
||
await page.waitForFunction(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const rail = document.querySelector("#session-sidebar");
|
||
return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false"));
|
||
}, null, { timeout: 5000 }).catch(() => null);
|
||
const after = await workbenchReadinessSnapshot(page);
|
||
return {
|
||
ok: after?.sessionCreateVisible === true,
|
||
action: "expanded-session-rail",
|
||
before,
|
||
after,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function clickAndWaitForAgentSessionCreate(create) {
|
||
let removeRequestFailedListener = () => {};
|
||
const requestFailedPromise = new Promise((resolve) => {
|
||
let timeout = null;
|
||
const handler = (request) => {
|
||
if (!isAgentSessionCreateRequest(request)) return;
|
||
removeRequestFailedListener();
|
||
resolve({ kind: "requestfailed", requestFailure: requestFailureSummary(request) });
|
||
};
|
||
removeRequestFailedListener = () => {
|
||
page.off("requestfailed", handler);
|
||
if (timeout !== null) clearTimeout(timeout);
|
||
timeout = null;
|
||
};
|
||
page.on("requestfailed", handler);
|
||
timeout = setTimeout(() => {
|
||
removeRequestFailedListener();
|
||
resolve(null);
|
||
}, 45000);
|
||
});
|
||
const createResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
return isAgentSessionCreateRequest(request) || isAgentSessionCreateRequest(response.url());
|
||
}, { timeout: 45000 }).then((response) => ({ kind: "response", response })).catch((error) => ({ kind: "wait-error", waitError: errorSummary(error) }));
|
||
await create.click();
|
||
const outcome = await Promise.race([createResponsePromise, requestFailedPromise]);
|
||
removeRequestFailedListener();
|
||
return outcome ?? await createResponsePromise;
|
||
}
|
||
|
||
async function createSessionFromUi() {
|
||
const beforeUrl = currentPageUrl();
|
||
const before = await workbenchSessionSnapshot();
|
||
const attempts = [];
|
||
let createResponse = null;
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
const railExpansion = await ensureSessionRailExpanded();
|
||
const readinessBeforeClick = railExpansion.after || await workbenchReadinessSnapshot(page);
|
||
const create = page.locator("#session-create").first();
|
||
try {
|
||
await create.waitFor({ state: "visible", timeout: 15000 });
|
||
} catch (error) {
|
||
const readinessAfterWait = await workbenchReadinessSnapshot(page);
|
||
const createError = new Error("newSession session create button is not visible");
|
||
createError.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, attempt, readinessBeforeClick, readinessAfterWait, railExpansion, waitError: errorSummary(error), pageId, valuesRedacted: true };
|
||
throw createError;
|
||
}
|
||
const createButtonState = await create.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return {
|
||
tag: element.tagName.toLowerCase(),
|
||
id: element.id || null,
|
||
disabled: Boolean(element.disabled),
|
||
ariaDisabled: element.getAttribute("aria-disabled") || null,
|
||
visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none",
|
||
rect: { width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
const outcome = await clickAndWaitForAgentSessionCreate(create);
|
||
if (outcome?.kind === "response") {
|
||
createResponse = outcome.response;
|
||
attempts.push({ attempt, outcome: "response", readinessBeforeClick, railExpansion, createButtonState, valuesRedacted: true });
|
||
break;
|
||
}
|
||
const afterAttempt = await workbenchSessionSnapshot();
|
||
attempts.push({
|
||
attempt,
|
||
outcome: outcome?.kind || "unknown",
|
||
readinessBeforeClick,
|
||
railExpansion,
|
||
createButtonState,
|
||
waitError: outcome?.waitError || null,
|
||
requestFailure: outcome?.requestFailure || null,
|
||
after: afterAttempt,
|
||
valuesRedacted: true
|
||
});
|
||
if (attempt < 2 && outcome?.kind === "requestfailed") {
|
||
await page.waitForTimeout(1500);
|
||
continue;
|
||
}
|
||
const waitError = outcome?.waitError || outcome?.requestFailure || { name: outcome?.kind || "unknown", valuesRedacted: true };
|
||
const error = new Error("newSession did not observe POST /v1/agent/sessions response after click: " + (waitError.message || waitError.failureText || waitError.name || "timeout"));
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, attempts, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
if (createResponse === null) throw new Error("newSession did not produce an authoritative session create response");
|
||
const createStatus = createResponse.status();
|
||
let createPayload = null;
|
||
let createPayloadError = null;
|
||
try {
|
||
createPayload = await createResponse.json();
|
||
} catch (error) {
|
||
createPayloadError = errorSummary(error);
|
||
}
|
||
const createdSessionId = sessionIdFromAgentSessionPayload(createPayload);
|
||
if (createStatus < 200 || createStatus >= 300 || !createdSessionId) {
|
||
const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions");
|
||
error.details = { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
await page.waitForFunction((expectedSessionId) => {
|
||
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
|
||
const sessionId = activeTab?.getAttribute("data-session-id") || "";
|
||
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
|
||
const routeSessionId = routeMatch ? decodeURIComponent(routeMatch[1] || "") : "";
|
||
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || "";
|
||
const input = document.querySelector("#command-input");
|
||
return Boolean(activeTab && sessionId === expectedSessionId && routeSessionId === expectedSessionId && input && !input.disabled && !warning);
|
||
}, createdSessionId, { timeout: 45000 }).catch(() => null);
|
||
const after = await workbenchSessionSnapshot();
|
||
const afterSessionId = after?.activeSessionId || after?.routeSessionId || "";
|
||
const ok = Boolean(afterSessionId === createdSessionId && after?.routeSessionId === createdSessionId && after?.composerReady);
|
||
if (!ok) {
|
||
const error = new Error("newSession did not select the authoritative newly created workbench session");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), before, after, createdSessionId, attempts, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
ok,
|
||
before,
|
||
after,
|
||
attempts,
|
||
sessionId: createdSessionId,
|
||
createSession: { status: createStatus, statusText: createResponse.statusText(), responseParsed: createPayload !== null, responseParseError: createPayloadError, createdSessionId, valuesRedacted: true },
|
||
pageId
|
||
};
|
||
}
|
||
|
||
function sessionIdFromAgentSessionPayload(payload) {
|
||
const direct = payload?.sessionId ?? payload?.id ?? payload?.session?.sessionId ?? payload?.session?.id ?? payload?.data?.sessionId ?? payload?.data?.id ?? payload?.data?.session?.sessionId ?? payload?.data?.session?.id;
|
||
const directText = String(direct || "").trim();
|
||
if (/^ses_[A-Za-z0-9_-]+$/u.test(directText)) return directText;
|
||
const match = JSON.stringify(payload ?? "").match(/\bses_[A-Za-z0-9_-]+\b/u);
|
||
return match ? match[0] : null;
|
||
}
|
||
|
||
function traceIdFromAgentChatPayload(payload) {
|
||
const direct = payload?.traceId ?? payload?.turn?.traceId ?? payload?.message?.traceId ?? payload?.data?.traceId ?? payload?.data?.turn?.traceId ?? payload?.data?.message?.traceId;
|
||
const directText = String(direct || "").trim();
|
||
if (/^(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})$/u.test(directText)) return directText;
|
||
const match = JSON.stringify(payload ?? "").match(/\b(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/u);
|
||
return match ? match[0] : null;
|
||
}
|
||
|
||
async function workbenchSessionSnapshot(targetPage = page) {
|
||
return targetPage.evaluate(() => {
|
||
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
|
||
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
|
||
const input = document.querySelector("#command-input");
|
||
const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null;
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return {
|
||
url: window.location.href,
|
||
routeSessionId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null,
|
||
activeSessionId: activeTab?.getAttribute("data-session-id") || null,
|
||
activeConversationId: activeTab?.getAttribute("data-conversation-id") || null,
|
||
activeStatus: activeTab?.getAttribute("data-status") || null,
|
||
tabCount: document.querySelectorAll(".session-tab").length,
|
||
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
|
||
traceRowCount: Array.from(document.querySelectorAll('[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]')).filter(visible).length,
|
||
loadingCount: Array.from(document.querySelectorAll('[aria-busy="true"], [data-loading="true"], [class*="loading" i], [data-testid*="loading" i]')).filter(visible).length,
|
||
composerReady: Boolean(activeTab && input && !input.disabled && !warning),
|
||
warning
|
||
};
|
||
}).catch(() => null);
|
||
}
|
||
|
||
function controlPageRecoveryTarget(snapshot, beforeUrl) {
|
||
const sessionId = snapshot?.routeSessionId || snapshot?.activeSessionId || routeSessionIdFromUrl(beforeUrl);
|
||
if (sessionId) return { sessionId, targetPath: "/workbench/sessions/" + encodeURIComponent(sessionId), valuesRedacted: true };
|
||
const path = safeUrlPath(beforeUrl);
|
||
if (isWorkbenchPathname(path || "")) return { sessionId: null, targetPath: path, valuesRedacted: true };
|
||
return { sessionId: null, targetPath, valuesRedacted: true };
|
||
}
|
||
|
||
function controlPageProjectionMissingForCommand(snapshot, beforeUrl) {
|
||
const path = safeUrlPath(snapshot?.url || beforeUrl);
|
||
if (!isWorkbenchPathname(path || "")) return false;
|
||
const routeSessionId = snapshot?.routeSessionId || routeSessionIdFromUrl(snapshot?.url || beforeUrl);
|
||
if (!routeSessionId) return false;
|
||
return snapshot?.activeSessionId !== routeSessionId
|
||
&& Number(snapshot?.tabCount || 0) === 0
|
||
&& Number(snapshot?.messageCount || 0) === 0
|
||
&& Number(snapshot?.traceRowCount || 0) === 0
|
||
&& snapshot?.composerReady !== true;
|
||
}
|
||
|
||
async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) {
|
||
const started = Date.now();
|
||
return withHardTimeout(workbenchSessionSnapshot(page), timeoutMs, "control page liveness snapshot exceeded " + timeoutMs + "ms")
|
||
.then((snapshot) => ({
|
||
ok: snapshot !== null,
|
||
reason,
|
||
durationMs: Date.now() - started,
|
||
snapshot,
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
}))
|
||
.catch((error) => ({
|
||
ok: false,
|
||
reason,
|
||
durationMs: Date.now() - started,
|
||
error: errorSummary(error),
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
}));
|
||
}
|
||
|
||
async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null, options = {}) {
|
||
let navigation = null;
|
||
let hydration = null;
|
||
let afterLiveness = null;
|
||
const attempts = [];
|
||
let ok = false;
|
||
const maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Math.max(1, Number(options.maxAttempts)) : 2;
|
||
const hydrationTimeoutMs = Number.isFinite(Number(options.hydrationTimeoutMs)) ? Math.max(1, Number(options.hydrationTimeoutMs)) : 12000;
|
||
const hydrationHardTimeoutMs = Number.isFinite(Number(options.hydrationHardTimeoutMs)) ? Math.max(hydrationTimeoutMs, Number(options.hydrationHardTimeoutMs)) : hydrationTimeoutMs + 2000;
|
||
const navigationOptions = options.navigation || {};
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt);
|
||
try {
|
||
navigation = await gotoTarget(target.targetPath, navigationOptions);
|
||
} catch (error) {
|
||
navigation = { ok: false, targetPath: target.targetPath, error: errorSummary(error), valuesRedacted: true };
|
||
}
|
||
if (!navigation?.error && target.sessionId) {
|
||
hydration = await withHardTimeout(
|
||
waitForWorkbenchSessionHydrated(page, target.sessionId, { timeoutMs: hydrationTimeoutMs }),
|
||
hydrationHardTimeoutMs,
|
||
"control page recovery hydration exceeded " + hydrationHardTimeoutMs + "ms"
|
||
).catch((error) => ({ ok: false, error: errorSummary(error), valuesRedacted: true }));
|
||
} else {
|
||
hydration = null;
|
||
}
|
||
afterLiveness = await controlPageLivenessSnapshot(reason + "-post-recovery", 3000);
|
||
ok = !navigation?.error && afterLiveness.ok === true && (!target.sessionId || hydration?.ok === true);
|
||
attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true });
|
||
if (ok) break;
|
||
}
|
||
return {
|
||
ok,
|
||
recovered: ok,
|
||
reason,
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
target,
|
||
liveness,
|
||
navigation,
|
||
hydration,
|
||
afterLiveness,
|
||
attempts,
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function promoteObserverPageToControlForCommand(reason, target, liveness = null) {
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeObserverUrl = pageUrl(observerPage);
|
||
if (!observerPage || observerPage.isClosed()) {
|
||
return { ok: false, promoted: false, reason, failureKind: "observer-page-unavailable", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
|
||
}
|
||
const sessionId = target?.sessionId || routeSessionIdFromUrl(beforeUrl);
|
||
if (!sessionId) {
|
||
return { ok: false, promoted: false, reason, failureKind: "observer-promotion-needs-session", beforeUrl, observerUrl: beforeObserverUrl, target, liveness, valuesRedacted: true };
|
||
}
|
||
const observerSessionId = routeSessionIdFromUrl(beforeObserverUrl);
|
||
if (observerSessionId !== sessionId) {
|
||
return { ok: false, promoted: false, reason, failureKind: "observer-session-mismatch", beforeUrl, observerUrl: beforeObserverUrl, observerSessionId, sessionId, target, liveness, valuesRedacted: true };
|
||
}
|
||
const targetUrl = new URL(target?.targetPath || ("/workbench/sessions/" + encodeURIComponent(sessionId)), baseUrl).toString();
|
||
const readiness = await observerSessionReadiness(targetUrl, sessionId, { readinessTimeoutMs: 1000, hydrationTimeoutMs: 2000 });
|
||
const observerComposerReady = readiness?.hydration?.snapshot?.composerReady === true;
|
||
if (readiness.ok !== true || observerComposerReady !== true) {
|
||
return { ok: false, promoted: false, reason, failureKind: readiness.failureKind || (observerComposerReady ? "observer-not-ready" : "observer-composer-not-ready"), beforeUrl, observerUrl: beforeObserverUrl, sessionId, target, liveness, readiness, valuesRedacted: true };
|
||
}
|
||
const oldControlPage = page;
|
||
observerPageEpoch += 1;
|
||
controlPageEpoch += 1;
|
||
page = observerPage;
|
||
observerPage = null;
|
||
attachPassiveListeners(page, "control", pageId);
|
||
currentPageProvenance = null;
|
||
if (oldControlPage && !oldControlPage.isClosed() && oldControlPage !== page) {
|
||
await withHardTimeout(oldControlPage.close(), 2000, "old control page close exceeded 2000ms")
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("old-control-page-close-timeout", { reason, error: errorSummary(error), pageRole: "control", pageId, pageEpoch: controlPageEpoch })));
|
||
}
|
||
observerPage = await context.newPage();
|
||
attachPassiveListeners(observerPage, "observer", observerPageId);
|
||
const observerSync = await syncObserverPageToControlSession(reason + "-observer-recreated-after-promotion", sessionId, {
|
||
maxAttempts: 1,
|
||
navigationTimeoutMs: 8000,
|
||
readinessTimeoutMs: 3000,
|
||
hydrationTimeoutMs: 3000,
|
||
shortCircuitReadinessTimeoutMs: 1000,
|
||
shortCircuitHydrationTimeoutMs: 1000,
|
||
});
|
||
return {
|
||
ok: true,
|
||
promoted: true,
|
||
reason,
|
||
beforeUrl,
|
||
beforeObserverUrl,
|
||
afterUrl: currentPageUrl(),
|
||
sessionId,
|
||
target,
|
||
liveness,
|
||
readiness,
|
||
observerSync,
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function ensureControlPageResponsiveForCommand(reason) {
|
||
const beforeUrl = currentPageUrl();
|
||
const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000);
|
||
const projectionMissing = liveness.ok === true && controlPageProjectionMissingForCommand(liveness.snapshot, beforeUrl);
|
||
if (liveness.ok && !projectionMissing) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
|
||
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
|
||
await appendJsonl(files.control, eventRecord(projectionMissing ? "control-page-projection-missing-before-command" : "control-page-unresponsive-before-command", {
|
||
reason,
|
||
beforeUrl,
|
||
target,
|
||
liveness,
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
}));
|
||
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
|
||
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-promoted-from-observer-before-command" : "control-page-observer-promotion-skipped-before-command", promotion));
|
||
if (promotion.ok === true) return promotion;
|
||
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
|
||
maxAttempts: 1,
|
||
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
|
||
hydrationTimeoutMs: 4000,
|
||
hydrationHardTimeoutMs: 5000,
|
||
});
|
||
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery));
|
||
if (!recovery.ok) {
|
||
const error = new Error("control page recovery failed before " + reason);
|
||
error.details = recovery;
|
||
throw error;
|
||
}
|
||
return recovery;
|
||
}
|
||
|
||
async function forceRecoverControlPageForCommand(reason) {
|
||
const beforeUrl = currentPageUrl();
|
||
const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000);
|
||
const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl);
|
||
await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", {
|
||
reason,
|
||
beforeUrl,
|
||
target,
|
||
liveness,
|
||
pageRole: "control",
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
}));
|
||
const promotion = await promoteObserverPageToControlForCommand(reason + "-observer-promotion", target, liveness);
|
||
await appendJsonl(files.control, eventRecord(promotion.ok ? "control-page-forced-promoted-from-observer-before-command" : "control-page-forced-observer-promotion-skipped-before-command", promotion));
|
||
if (promotion.ok === true) return promotion;
|
||
const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness, {
|
||
maxAttempts: 1,
|
||
navigation: { maxAttempts: 1, navigationTimeoutMs: 8000, readinessTimeoutMs: 4000, settleMs: 250, lateReadinessTimeoutMs: 1000 },
|
||
hydrationTimeoutMs: 4000,
|
||
hydrationHardTimeoutMs: 5000,
|
||
});
|
||
await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery));
|
||
return recovery;
|
||
}
|
||
|
||
async function sendPrompt(text, options = {}) {
|
||
if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text");
|
||
const responsePath = options.responsePath || "/v1/agent/chat";
|
||
const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeEvidence = await promptSideEffectSnapshot();
|
||
let editor = null;
|
||
let composerRecovery = null;
|
||
let editorWaitError = null;
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
const primaryEditor = page.locator("#command-input").last();
|
||
const candidate = await primaryEditor.isVisible().catch(() => false)
|
||
? primaryEditor
|
||
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
|
||
try {
|
||
await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 8000 }), 10000, "sendPrompt composer editor did not become visible within 10s");
|
||
editor = candidate;
|
||
break;
|
||
} catch (error) {
|
||
editorWaitError = error;
|
||
if (attempt >= 2) break;
|
||
composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing");
|
||
if (composerRecovery.ok !== true) break;
|
||
}
|
||
}
|
||
if (!editor) {
|
||
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000);
|
||
const error = new Error("sendPrompt composer editor did not become visible");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery });
|
||
const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]';
|
||
const primarySubmit = page.locator(primarySubmitSelector).last();
|
||
const submit = await primarySubmit.isVisible().catch(() => false)
|
||
? primarySubmit
|
||
: page.locator([
|
||
'button[type="submit"]',
|
||
'button:has-text("发送")',
|
||
'button:has-text("Send")',
|
||
'[data-testid*="send" i]',
|
||
'[aria-label*="send" i]',
|
||
'[aria-label*="发送"]'
|
||
].join(", ")).last();
|
||
await withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s");
|
||
if (options.expectedAction) {
|
||
const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === ""
|
||
? null
|
||
: Number(options.expectedActionWaitMs);
|
||
const actionWaitMs = Number.isFinite(configuredActionWaitMs)
|
||
? Math.max(1000, Math.trunc(configuredActionWaitMs))
|
||
: options.expectedAction === "turn" ? 180000 : 1000;
|
||
const actionDeadline = Date.now() + actionWaitMs;
|
||
let composer = null;
|
||
while (Date.now() <= actionDeadline) {
|
||
composer = await composerButtonState(submit);
|
||
if (composer.action === options.expectedAction && composer.disabled !== true) break;
|
||
await page.waitForTimeout(250);
|
||
}
|
||
composer = composer || await composerButtonState(submit);
|
||
if (composer.action !== options.expectedAction || composer.disabled === true) {
|
||
await clearComposerEditor(editor).catch(() => {});
|
||
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 200).catch(() => promptSideEffectSnapshot());
|
||
const blocked = {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
submitted: false,
|
||
blocked: true,
|
||
degradedReason: options.noActiveReason || "composer-action-mismatch",
|
||
composer,
|
||
chatSubmit: {
|
||
status: null,
|
||
statusText: null,
|
||
urlPath: responsePath,
|
||
responseObserved: false,
|
||
sideEffectObserved: sideEffectHasAuthoritativePromptSubmission(sideEffect),
|
||
sideEffect,
|
||
actionWaitMs,
|
||
expectedAction: options.expectedAction,
|
||
actualAction: composer.action,
|
||
valuesRedacted: true
|
||
},
|
||
controlRecovery,
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
};
|
||
if (options.throwOnActionMismatch === true) {
|
||
const error = new Error("sendPrompt composer action mismatch: expected " + options.expectedAction + " actual " + (composer.action || "null"));
|
||
error.details = blocked;
|
||
throw error;
|
||
}
|
||
return blocked;
|
||
}
|
||
}
|
||
const acceptedResponsePaths = [responsePath, ...(Array.isArray(options.alternateResponsePaths) ? options.alternateResponsePaths : [])];
|
||
const chatResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
if (request.method().toUpperCase() !== "POST") return false;
|
||
try {
|
||
return acceptedResponsePaths.includes(new URL(response.url()).pathname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||
await submit.click();
|
||
const chatResponse = await chatResponsePromise;
|
||
await page.waitForTimeout(500);
|
||
if (chatResponse?.waitError) {
|
||
const sideEffect = await waitForPromptSideEffect(beforeEvidence, 5000);
|
||
if (sideEffectHasAuthoritativePromptSubmission(sideEffect)) {
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect },
|
||
controlRecovery,
|
||
pageId,
|
||
pageEpoch: controlPageEpoch
|
||
};
|
||
}
|
||
const error = new Error("sendPrompt did not observe POST " + responsePath + " response or an authoritative new turn after submit: " + (chatResponse.waitError.message || chatResponse.waitError.name || "timeout"));
|
||
error.details = {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect },
|
||
controlRecovery,
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
};
|
||
throw error;
|
||
}
|
||
const chatStatus = chatResponse.status();
|
||
let chatUrlPath = responsePath;
|
||
try {
|
||
chatUrlPath = new URL(chatResponse.url()).pathname;
|
||
} catch {
|
||
chatUrlPath = responsePath;
|
||
}
|
||
if (chatStatus < 200 || chatStatus >= 300) {
|
||
throw new Error("sendPrompt observed POST " + chatUrlPath + " HTTP " + chatStatus + " " + chatResponse.statusText());
|
||
}
|
||
let chatPayload = null;
|
||
let chatPayloadError = null;
|
||
try {
|
||
chatPayload = await chatResponse.json();
|
||
} catch (error) {
|
||
chatPayloadError = errorSummary(error);
|
||
}
|
||
const payloadText = chatPayload ? JSON.stringify(chatPayload) : "";
|
||
const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null;
|
||
const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId)
|
||
? chatPayload.otelTrace.traceId
|
||
: null;
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
chatSubmit: {
|
||
status: chatStatus,
|
||
statusText: chatResponse.statusText(),
|
||
urlPath: chatUrlPath,
|
||
traceId,
|
||
otelTraceId,
|
||
resultUrl: safeUrlPath(chatPayload?.resultUrl),
|
||
turnUrl: safeUrlPath(chatPayload?.turnUrl),
|
||
streamUrl: safeUrlPath(chatPayload?.streamUrl),
|
||
responseParsed: chatPayload !== null,
|
||
responseParseError: chatPayloadError,
|
||
valuesRedacted: true
|
||
},
|
||
controlRecovery,
|
||
pageId,
|
||
pageEpoch: controlPageEpoch
|
||
};
|
||
}
|
||
|
||
async function fillComposerEditorWithRetry(initialEditor, text, context = {}) {
|
||
let editor = initialEditor;
|
||
let lastError = null;
|
||
let recovery = null;
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
try {
|
||
if (attempt > 1 || !editor) editor = await resolveComposerEditor(8000);
|
||
await fillComposerEditor(editor, text, { timeoutMs: 12000 });
|
||
const retained = await composerEditorRetainsText(editor, text);
|
||
if (retained) return editor;
|
||
throw new Error("sendPrompt composer editor did not retain filled text");
|
||
} catch (error) {
|
||
lastError = error;
|
||
await appendJsonl(files.control, eventRecord("sendPrompt-composer-fill-retry", {
|
||
attempt,
|
||
willRetry: attempt < 2,
|
||
error: errorSummary(error),
|
||
beforeUrl: context.beforeUrl || null,
|
||
afterUrl: currentPageUrl(),
|
||
controlRecovery: context.controlRecovery || null,
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
}));
|
||
if (attempt >= 2) break;
|
||
recovery = await forceRecoverControlPageForCommand("sendPrompt-composer-fill-failed");
|
||
if (recovery.ok !== true) await page.waitForTimeout(250);
|
||
editor = null;
|
||
}
|
||
}
|
||
const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-fill-failed-final", 3000);
|
||
const error = new Error("sendPrompt composer editor fill failed");
|
||
error.details = {
|
||
beforeUrl: context.beforeUrl || null,
|
||
afterUrl: currentPageUrl(),
|
||
controlRecovery: context.controlRecovery || null,
|
||
recovery,
|
||
snapshot,
|
||
fillError: errorSummary(lastError),
|
||
pageId,
|
||
pageEpoch: controlPageEpoch,
|
||
valuesRedacted: true
|
||
};
|
||
throw error;
|
||
}
|
||
|
||
async function resolveComposerEditor(timeoutMs = 8000) {
|
||
const primaryEditor = page.locator("#command-input").last();
|
||
const candidate = await primaryEditor.isVisible().catch(() => false)
|
||
? primaryEditor
|
||
: page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last();
|
||
await candidate.waitFor({ state: "visible", timeout: timeoutMs });
|
||
return candidate;
|
||
}
|
||
|
||
async function fillComposerEditor(editor, text, options = {}) {
|
||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1000, Math.trunc(Number(options.timeoutMs))) : 30000;
|
||
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
|
||
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
|
||
if (tag === "textarea" || tag === "input") await editor.fill(text, { timeout: timeoutMs });
|
||
else if (editable) {
|
||
await editor.click({ timeout: timeoutMs });
|
||
await page.keyboard.insertText(text);
|
||
} else {
|
||
await editor.click({ timeout: timeoutMs });
|
||
await page.keyboard.insertText(text);
|
||
}
|
||
}
|
||
|
||
async function composerEditorRetainsText(editor, expectedText) {
|
||
const expected = String(expectedText || "");
|
||
return editor.evaluate((element, value) => {
|
||
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return element.value === value;
|
||
return (element.textContent || "") === value;
|
||
}, expected).catch(() => false);
|
||
}
|
||
|
||
async function clearComposerEditor(editor) {
|
||
const tag = await editor.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
|
||
const editable = await editor.evaluate((element) => element.getAttribute("contenteditable") === "true").catch(() => false);
|
||
if (tag === "textarea" || tag === "input") {
|
||
await editor.fill("");
|
||
return;
|
||
}
|
||
if (editable) {
|
||
await editor.click();
|
||
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => {});
|
||
await page.keyboard.press("Backspace").catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function composerButtonState(button) {
|
||
return button.evaluate((element) => ({
|
||
action: element.getAttribute("data-action") || null,
|
||
disabled: element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true",
|
||
text: (element.textContent || "").trim().slice(0, 80),
|
||
title: element.getAttribute("title") || null,
|
||
ariaLabel: element.getAttribute("aria-label") || null,
|
||
testId: element.getAttribute("data-testid") || null,
|
||
})).catch((error) => ({ action: null, disabled: null, error: errorSummary(error), valuesRedacted: true }));
|
||
}
|
||
|
||
function sideEffectHasAuthoritativePromptSubmission(sideEffect) {
|
||
return Boolean(
|
||
(Array.isArray(sideEffect?.newRunIds) && sideEffect.newRunIds.length > 0)
|
||
|| (Array.isArray(sideEffect?.newTraceIds) && sideEffect.newTraceIds.length > 0)
|
||
|| Number(sideEffect?.messageCountDelta || 0) > 0
|
||
);
|
||
}
|
||
|
||
async function waitForPromptSideEffect(beforeEvidence, timeoutMs) {
|
||
await page.waitForFunction((before) => {
|
||
const current = (() => {
|
||
const text = document.body?.innerText || "";
|
||
const runIds = Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
|
||
const traceIds = Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20);
|
||
const running = /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text);
|
||
const executionError = /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text);
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const messageCount = Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length;
|
||
const textBytes = new TextEncoder().encode(text).length;
|
||
return { runIds, traceIds, running, executionError, messageCount, textBytes };
|
||
})();
|
||
const beforeRuns = Array.isArray(before?.runIds) ? before.runIds : [];
|
||
const beforeTraces = Array.isArray(before?.traceIds) ? before.traceIds : [];
|
||
const newRun = current.runIds.some((id) => !beforeRuns.includes(id));
|
||
const newTrace = current.traceIds.some((id) => !beforeTraces.includes(id));
|
||
const newMessage = current.messageCount > Number(before?.messageCount || 0);
|
||
return newRun || newTrace || newMessage;
|
||
}, beforeEvidence || {}, { timeout: timeoutMs }).catch(() => null);
|
||
const after = await promptSideEffectSnapshot();
|
||
const beforeRuns = Array.isArray(beforeEvidence?.runIds) ? beforeEvidence.runIds : [];
|
||
const beforeTraces = Array.isArray(beforeEvidence?.traceIds) ? beforeEvidence.traceIds : [];
|
||
const newRunIds = after.runIds.filter((id) => !beforeRuns.includes(id));
|
||
const newTraceIds = after.traceIds.filter((id) => !beforeTraces.includes(id));
|
||
const messageCountDelta = Math.max(0, Number(after.messageCount || 0) - Number(beforeEvidence?.messageCount || 0));
|
||
return { ...after, newRunIds, newTraceIds, messageCountDelta, submitted: newRunIds.length > 0 || newTraceIds.length > 0 || messageCountDelta > 0, valuesRedacted: true };
|
||
}
|
||
|
||
async function promptSideEffectSnapshot() {
|
||
return withHardTimeout(page.evaluate(() => {
|
||
const text = document.body?.innerText || "";
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
return {
|
||
runIds: Array.from(new Set(text.match(/run_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
|
||
traceIds: Array.from(new Set(text.match(/trc_[A-Za-z0-9_:-]+/gu) || [])).slice(-20),
|
||
running: /Trace running|最近\s*\d+\s*(?:秒|分钟|分|小时)前|Code Agent\s*耗时/iu.test(text),
|
||
executionError: /AgentRun error|agentrun:error:|provider-stream-disconnected|provider-unavailable/iu.test(text),
|
||
messageCount: Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).length,
|
||
textBytes: new TextEncoder().encode(text).length,
|
||
valuesRedacted: true
|
||
};
|
||
}), 3000, "prompt side-effect snapshot exceeded 3000ms")
|
||
.catch(() => ({ runIds: [], traceIds: [], running: false, executionError: false, messageCount: 0, textBytes: 0, valuesRedacted: true }));
|
||
}
|
||
|
||
${nodeWebObserveRunnerCommandActionsSource()}
|
||
|
||
async function selectProvider(provider) {
|
||
const target = String(provider || "").trim();
|
||
if (!target) throw new Error("selectProvider requires provider name");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforePath = safeUrlPath(beforeUrl);
|
||
if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first");
|
||
const nativeSelect = await page.evaluate((name) => {
|
||
const normalized = String(name).toLowerCase();
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
||
const options = Array.from(select.options || []);
|
||
const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized));
|
||
if (!option) continue;
|
||
select.value = option.value;
|
||
select.dispatchEvent(new Event("input", { bubbles: true }));
|
||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||
return { kind: "native-select", value: option.value, text: option.textContent || "" };
|
||
}
|
||
return null;
|
||
}, target).catch(() => null);
|
||
if (nativeSelect) {
|
||
await page.waitForTimeout(500);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId };
|
||
}
|
||
const optionVisible = page.getByText(target, { exact: false }).last();
|
||
if (await optionVisible.isVisible().catch(() => false)) {
|
||
await optionVisible.click();
|
||
await page.waitForTimeout(500);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId };
|
||
}
|
||
const control = page.locator([
|
||
'[data-testid*="provider" i]',
|
||
'[data-testid*="model" i]',
|
||
'[aria-label*="provider" i]',
|
||
'[aria-label*="model" i]',
|
||
'[aria-label*="模型"]',
|
||
'[aria-label*="提供"]',
|
||
'[role="combobox"]',
|
||
'[aria-haspopup="listbox"]',
|
||
].join(", "));
|
||
const count = Math.min(await control.count().catch(() => 0), 60);
|
||
const attempts = [];
|
||
for (let index = count - 1; index >= 0; index -= 1) {
|
||
const item = control.nth(index);
|
||
const visible = await item.isVisible().catch(() => false);
|
||
if (!visible) continue;
|
||
const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => "");
|
||
if (isProviderNavigationLabel(label)) continue;
|
||
if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue;
|
||
attempts.push({ index, label });
|
||
await item.click({ timeout: 3000 }).catch(() => null);
|
||
await page.waitForTimeout(300);
|
||
const option = page.getByText(target, { exact: false }).last();
|
||
if (await option.isVisible().catch(() => false)) {
|
||
await option.click();
|
||
await page.waitForTimeout(700);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId };
|
||
}
|
||
await page.keyboard.press("Escape").catch(() => null);
|
||
}
|
||
const providerCandidates = await collectProviderCandidates();
|
||
throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10)));
|
||
}
|
||
|
||
async function collectProviderCandidates() {
|
||
return page.evaluate(() => {
|
||
const rows = [];
|
||
const seen = new Set();
|
||
const isNavigationLabel = (value) => {
|
||
const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||
if (!label) return false;
|
||
if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true;
|
||
return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, ""));
|
||
};
|
||
const visible = (element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const push = (kind, element, value, text) => {
|
||
const normalizedValue = String(value || "").trim().slice(0, 160);
|
||
const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220);
|
||
const label = (normalizedValue + " " + normalizedText).trim();
|
||
if (!label) return;
|
||
const key = (kind + ":" + label).toLowerCase();
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) });
|
||
};
|
||
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
||
for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || "");
|
||
}
|
||
const selector = [
|
||
'[role="option"]',
|
||
'[role="menuitem"]',
|
||
'[data-radix-collection-item]',
|
||
'[data-testid*="provider" i]',
|
||
'[data-testid*="profile" i]',
|
||
'[data-testid*="model" i]',
|
||
'[aria-label*="provider" i]',
|
||
'[aria-label*="profile" i]',
|
||
'[aria-label*="model" i]',
|
||
'[aria-label*="模型"]',
|
||
'[aria-label*="提供"]',
|
||
'button'
|
||
].join(", ");
|
||
for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) {
|
||
const text = String(element.textContent || "").replace(/\s+/gu, " ").trim();
|
||
const ariaLabel = String(element.getAttribute("aria-label") || "");
|
||
const testId = String(element.getAttribute("data-testid") || "");
|
||
const haystack = text + " " + ariaLabel + " " + testId;
|
||
if (isNavigationLabel(haystack)) continue;
|
||
if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue;
|
||
push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId);
|
||
}
|
||
return rows.slice(0, 80);
|
||
}).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]);
|
||
}
|
||
|
||
function isProviderNavigationLabel(value) {
|
||
const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||
if (!text) return false;
|
||
if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true;
|
||
return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, ""));
|
||
}
|
||
|
||
async function clickSession(sessionId) {
|
||
if (!sessionId) throw new Error("clickSession requires --session-id or --value");
|
||
const beforeUrl = currentPageUrl();
|
||
const escaped = cssEscape(sessionId);
|
||
const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first();
|
||
const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first();
|
||
await candidate.waitFor({ state: "visible", timeout: 15000 });
|
||
await candidate.click();
|
||
await page.waitForTimeout(1000);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
|
||
}
|
||
|
||
async function refreshCurrentSession(command) {
|
||
const beforeUrl = currentPageUrl();
|
||
const before = await workbenchSessionSnapshot();
|
||
const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
||
if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session");
|
||
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
||
const after = await workbenchSessionSnapshot();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "refreshCurrentSession",
|
||
afterRound: integerOrNull(command.afterRound),
|
||
canarySessionId: sessionId,
|
||
routeSessionId: after?.routeSessionId ?? null,
|
||
activeSessionId: after?.activeSessionId ?? null,
|
||
routeOk: after?.routeSessionId === sessionId,
|
||
activeOk: after?.activeSessionId === sessionId,
|
||
composerReady: after?.composerReady === true,
|
||
navigation,
|
||
pageId,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function switchAwayAndBack(command) {
|
||
const beforeUrl = currentPageUrl();
|
||
const before = await workbenchSessionSnapshot();
|
||
const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
||
if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session");
|
||
const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create");
|
||
const beforeSessionIds = await visibleWorkbenchSessionIds();
|
||
let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null;
|
||
let alternateSource = alternateSessionId === null ? null : "existing";
|
||
let createResult = null;
|
||
if (alternateSessionId === null && strategy === "existing-or-create") {
|
||
createResult = await createSessionFromUi();
|
||
alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null;
|
||
alternateSource = "created";
|
||
}
|
||
if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy);
|
||
const switchAway = await clickSession(alternateSessionId);
|
||
const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 });
|
||
const away = awaySettle.snapshot ?? await workbenchSessionSnapshot();
|
||
let switchBack;
|
||
try {
|
||
switchBack = await clickSession(canarySessionId);
|
||
} catch (error) {
|
||
const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId));
|
||
switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true };
|
||
}
|
||
let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 });
|
||
let after = backSettle.snapshot ?? await workbenchSessionSnapshot();
|
||
let switchBackRecovery = null;
|
||
let routeOk = after?.routeSessionId === canarySessionId;
|
||
let activeOk = after?.activeSessionId === canarySessionId;
|
||
if (!routeOk || !activeOk) {
|
||
switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle);
|
||
if (switchBackRecovery?.ok === true) {
|
||
switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true };
|
||
backSettle = switchBackRecovery.settle;
|
||
after = switchBackRecovery.snapshot ?? after;
|
||
routeOk = after?.routeSessionId === canarySessionId;
|
||
activeOk = after?.activeSessionId === canarySessionId;
|
||
}
|
||
}
|
||
if (awaySettle.ok !== true) {
|
||
const error = new Error("switchAwayAndBack did not settle on the alternate session");
|
||
error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
if (!routeOk || !activeOk) {
|
||
const error = new Error("switchAwayAndBack did not return to the canary session");
|
||
error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, recovery: switchBackRecovery, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "switchAwayAndBack",
|
||
afterRound: integerOrNull(command.afterRound),
|
||
canarySessionId,
|
||
alternateSessionId,
|
||
alternateSessionStrategy: strategy,
|
||
alternateSource,
|
||
beforeSessionCount: beforeSessionIds.length,
|
||
createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true },
|
||
switchAway,
|
||
awaySettle,
|
||
away: {
|
||
routeSessionId: away?.routeSessionId ?? null,
|
||
activeSessionId: away?.activeSessionId ?? null,
|
||
messageCount: away?.messageCount ?? null,
|
||
valuesRedacted: true,
|
||
},
|
||
switchBack,
|
||
backSettle,
|
||
switchBackRecovery,
|
||
routeSessionId: after?.routeSessionId ?? null,
|
||
activeSessionId: after?.activeSessionId ?? null,
|
||
routeOk,
|
||
activeOk,
|
||
composerReady: after?.composerReady === true,
|
||
pageId,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function recoverControlPageSessionHydration(sessionId, previousSettle) {
|
||
const observerHydration = observerPage && !observerPage.isClosed()
|
||
? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 })
|
||
: { ok: false, reason: "observer-page-unavailable", valuesRedacted: true };
|
||
await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1);
|
||
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
||
const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 });
|
||
const snapshot = settle.snapshot ?? await workbenchSessionSnapshot();
|
||
const routeOk = snapshot?.routeSessionId === sessionId;
|
||
const activeOk = snapshot?.activeSessionId === sessionId;
|
||
return {
|
||
ok: settle.ok === true && routeOk && activeOk,
|
||
attempted: true,
|
||
reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed",
|
||
previousSettle,
|
||
observerHydrated: observerHydration.ok === true,
|
||
observerHydration,
|
||
navigation,
|
||
settle,
|
||
snapshot: {
|
||
routeSessionId: snapshot?.routeSessionId ?? null,
|
||
activeSessionId: snapshot?.activeSessionId ?? null,
|
||
composerReady: snapshot?.composerReady === true,
|
||
messageCount: snapshot?.messageCount ?? null,
|
||
valuesRedacted: true,
|
||
},
|
||
pageId,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function assertSessionInvariant(command) {
|
||
const beforeUrl = currentPageUrl();
|
||
const snapshot = await workbenchSessionSnapshot();
|
||
const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim();
|
||
if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session");
|
||
const routeOk = snapshot?.routeSessionId === canarySessionId;
|
||
const activeOk = snapshot?.activeSessionId === canarySessionId;
|
||
const composerReady = snapshot?.composerReady === true;
|
||
if (!routeOk || !activeOk) {
|
||
const error = new Error("assertSessionInvariant saw route/active session mismatch");
|
||
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
if (command.requireComposerReady === true && !composerReady) {
|
||
const error = new Error("assertSessionInvariant requires composer ready for the next round");
|
||
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const messageOrder = await visibleMessageOrderSummary();
|
||
await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true })));
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "assertSessionInvariant",
|
||
afterRound: integerOrNull(command.afterRound),
|
||
canarySessionId,
|
||
routeSessionId: snapshot?.routeSessionId ?? null,
|
||
activeSessionId: snapshot?.activeSessionId ?? null,
|
||
routeOk,
|
||
activeOk,
|
||
composerReady,
|
||
expectedSentinelRange: command.expectedSentinelRange || null,
|
||
findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation",
|
||
severity: command.severity || "amber",
|
||
blocking: command.blocking === true,
|
||
messageOrder,
|
||
sampleSeq,
|
||
pageRole: "control",
|
||
pageId,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function visibleWorkbenchSessionIds() {
|
||
return page.evaluate(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const sessionIdForElement = (element) => {
|
||
const direct = element.getAttribute("data-session-id");
|
||
if (direct) return direct;
|
||
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
||
const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
};
|
||
const ids = [];
|
||
for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) {
|
||
if (!visible(element)) continue;
|
||
const id = sessionIdForElement(element);
|
||
if (id && !ids.includes(id)) ids.push(id);
|
||
}
|
||
return ids.slice(0, 50);
|
||
}).catch(() => []);
|
||
}
|
||
|
||
async function visibleMessageOrderSummary() {
|
||
const rawMessages = await page.evaluate(() => {
|
||
const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").trim().slice(0, limit);
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const stableMessageText = (element) => {
|
||
const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"];
|
||
const parts = [];
|
||
for (const selector of selectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 1200);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200);
|
||
};
|
||
return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({
|
||
index,
|
||
dataRole: element.getAttribute("data-role") || null,
|
||
role: element.getAttribute("role") || null,
|
||
testId: element.getAttribute("data-testid") || null,
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || null,
|
||
text: stableMessageText(element),
|
||
}));
|
||
}).catch(() => []);
|
||
const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => {
|
||
const text = String(item?.text || "");
|
||
const kind = messageKindForOrder(item, text);
|
||
const markers = sentinelMarkers(text);
|
||
return {
|
||
index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index,
|
||
kind,
|
||
role: item?.dataRole || item?.role || null,
|
||
status: item?.status || null,
|
||
sessionId: item?.sessionId || null,
|
||
messageId: item?.messageId || null,
|
||
traceId: item?.traceId || null,
|
||
turnId: item?.turnId || null,
|
||
markers,
|
||
textHash: sha256Text(text),
|
||
textBytes: Buffer.byteLength(text),
|
||
valuesRedacted: true,
|
||
};
|
||
}) : [];
|
||
const clusters = userClusters(entries);
|
||
const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null;
|
||
return {
|
||
messageCount: entries.length,
|
||
sequence: entries.map((item) => ({
|
||
index: item.index,
|
||
kind: item.kind,
|
||
marker: item.markers[0] || null,
|
||
markerCount: item.markers.length,
|
||
traceId: item.traceId,
|
||
status: item.status,
|
||
textHash: item.textHash,
|
||
textBytes: item.textBytes,
|
||
valuesRedacted: true,
|
||
})).slice(-40),
|
||
userClustered: clusters.length > 0,
|
||
consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0,
|
||
sentinelRange: maxCluster?.sentinelRange ?? null,
|
||
traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12),
|
||
clusters,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function messageKindForOrder(item, text) {
|
||
const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" ");
|
||
if (/\b(user|human)\b/u.test(signal)) return "user";
|
||
if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent";
|
||
const body = String(text || "").trim();
|
||
if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user";
|
||
if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent";
|
||
return "unknown";
|
||
}
|
||
|
||
function sentinelMarkers(text) {
|
||
return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase()));
|
||
}
|
||
|
||
function userClusters(entries) {
|
||
const clusters = [];
|
||
let current = [];
|
||
const flush = () => {
|
||
if (current.length >= 2) {
|
||
const markers = current.flatMap((item) => item.markers);
|
||
clusters.push({
|
||
startIndex: current[0].index,
|
||
endIndex: current[current.length - 1].index,
|
||
consecutiveUserMessageCount: current.length,
|
||
sentinelRange: sentinelRange(markers),
|
||
markers: uniqueStrings(markers).slice(0, 12),
|
||
messageTextHashes: current.map((item) => item.textHash).slice(0, 12),
|
||
traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12),
|
||
valuesRedacted: true,
|
||
});
|
||
}
|
||
current = [];
|
||
};
|
||
for (const entry of entries) {
|
||
if (entry.kind === "user") {
|
||
current.push(entry);
|
||
} else {
|
||
flush();
|
||
}
|
||
}
|
||
flush();
|
||
return clusters.slice(0, 20);
|
||
}
|
||
|
||
function sentinelRange(markers) {
|
||
const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b));
|
||
if (unique.length === 0) return null;
|
||
return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1];
|
||
}
|
||
|
||
function sentinelMarkerNumber(marker) {
|
||
const match = String(marker || "").match(/sentinel-(\d+)/u);
|
||
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
|
||
}
|
||
|
||
function uniqueStrings(values) {
|
||
return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean)));
|
||
}
|
||
|
||
function integerOrNull(value) {
|
||
const parsed = Number(value);
|
||
return Number.isInteger(parsed) ? parsed : null;
|
||
}
|
||
|
||
function ensureProjectManagementCommand(type) {
|
||
if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane");
|
||
if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane");
|
||
}
|
||
|
||
async function gotoProjectMdtodo() {
|
||
ensureProjectManagementCommand("gotoProjectMdtodo");
|
||
return gotoTarget("/projects/mdtodo");
|
||
}
|
||
|
||
function commandValue(command, keys) {
|
||
for (const key of keys) {
|
||
const value = command?.[key];
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function visibleLocator(locator) {
|
||
return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false);
|
||
}
|
||
|
||
async function selectHtmlOptionByValueOrLabel(locator, value) {
|
||
const select = locator.first();
|
||
const targetValue = typeof value === "string" && value.trim() ? value.trim() : "";
|
||
if (targetValue) {
|
||
const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
||
if (byValue.ok && byValue.selected.length > 0) return { mode: "select-value", selectedValue: byValue.selected[0] || targetValue };
|
||
const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
||
if (byLabel.ok && byLabel.selected.length > 0) return { mode: "select-label", selectedValue: byLabel.selected[0] || targetValue };
|
||
}
|
||
const selectedValue = await select.evaluate((element) => {
|
||
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value);
|
||
const chosen = options[0] || null;
|
||
if (!chosen) return "";
|
||
element.value = chosen.value;
|
||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||
return chosen.value;
|
||
});
|
||
return { mode: "select-first", selectedValue };
|
||
}
|
||
|
||
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) {
|
||
ensureProjectManagementCommand(type);
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const targetValue = typeof value === "string" && value.trim() ? value.trim() : null;
|
||
if (selectTestId) {
|
||
const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]');
|
||
if (await visibleLocator(select)) {
|
||
const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || "");
|
||
await page.waitForTimeout(700);
|
||
const afterProject = await projectManagementCommandSnapshot();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type,
|
||
attr,
|
||
mode: selected.mode,
|
||
selected: opaqueIdSummary(selected.selectedValue || targetValue),
|
||
beforeProject,
|
||
afterProject,
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
}
|
||
const selector = targetValue ? "[" + attr + "=\"" + cssEscape(targetValue) + "\"]" : fallbackSelector;
|
||
const locator = page.locator(selector).first();
|
||
await locator.waitFor({ state: "visible", timeout: 15000 });
|
||
const clickedValue = await locator.evaluate((element, name) => element.getAttribute(name), attr).catch(() => targetValue);
|
||
await locator.click();
|
||
await page.waitForTimeout(700);
|
||
const afterProject = await projectManagementCommandSnapshot();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type,
|
||
attr,
|
||
selected: opaqueIdSummary(clickedValue || targetValue),
|
||
beforeProject,
|
||
afterProject,
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function selectProjectSource(command) {
|
||
return clickProjectItemByAttr({
|
||
type: "selectProjectSource",
|
||
attr: "data-source-id",
|
||
value: command.sourceId || command.value || command.text || "",
|
||
fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]',
|
||
selectTestId: "mdtodo-source-select"
|
||
});
|
||
}
|
||
|
||
async function selectMdtodoSource(command) {
|
||
return clickProjectItemByAttr({
|
||
type: "selectMdtodoSource",
|
||
attr: "data-source-id",
|
||
value: command.sourceId || command.value || command.text || "",
|
||
fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]',
|
||
selectTestId: "mdtodo-source-select"
|
||
});
|
||
}
|
||
|
||
async function selectMdtodoFile(command) {
|
||
return clickProjectItemByAttr({
|
||
type: "selectMdtodoFile",
|
||
attr: "data-file-ref",
|
||
value: command.fileRef || command.filename || command.value || command.text || "",
|
||
fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]',
|
||
selectTestId: "mdtodo-file-select"
|
||
});
|
||
}
|
||
|
||
async function mdtodoTaskLocator(command) {
|
||
const taskRef = commandValue(command, ["taskRef"]);
|
||
const taskId = commandValue(command, ["taskId", "task", "value", "text"]);
|
||
const selectors = [];
|
||
if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]');
|
||
if (taskId) {
|
||
selectors.push('[data-task-id="' + cssEscape(taskId) + '"]');
|
||
selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]');
|
||
}
|
||
for (const selector of selectors) {
|
||
const locator = page.locator(selector).first();
|
||
if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector };
|
||
}
|
||
if (taskId) {
|
||
const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first();
|
||
if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId };
|
||
}
|
||
const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first();
|
||
return { locator: fallback, taskRef, taskId, selector: "first-visible-task" };
|
||
}
|
||
|
||
async function selectMdtodoTask(command) {
|
||
ensureProjectManagementCommand("selectMdtodoTask");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const target = await mdtodoTaskLocator(command);
|
||
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
||
const clicked = await target.locator.evaluate((element) => ({
|
||
taskRef: element.getAttribute("data-task-ref") || null,
|
||
taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null,
|
||
status: element.getAttribute("data-task-status") || null,
|
||
selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true"
|
||
})).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null }));
|
||
if (!clicked.selected) {
|
||
await target.locator.scrollIntoViewIfNeeded().catch(() => null);
|
||
await target.locator.click();
|
||
await page.waitForTimeout(700);
|
||
}
|
||
const afterProject = await projectManagementCommandSnapshot();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "selectMdtodoTask",
|
||
selector: target.selector,
|
||
selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef),
|
||
selectedTaskId: clicked.taskId || target.taskId || null,
|
||
selectedTaskStatus: clicked.status || null,
|
||
alreadySelected: clicked.selected === true,
|
||
beforeProject,
|
||
afterProject,
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function expandMdtodoTask(command) {
|
||
ensureProjectManagementCommand("expandMdtodoTask");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const target = await mdtodoTaskLocator(command);
|
||
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
||
const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first();
|
||
const toggleVisible = await visibleLocator(toggle);
|
||
if (toggleVisible) await toggle.click();
|
||
else await target.locator.click();
|
||
await page.waitForTimeout(700);
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "expandMdtodoTask",
|
||
selector: target.selector,
|
||
toggleVisible,
|
||
beforeProject,
|
||
afterProject: await projectManagementCommandSnapshot(),
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function openMdtodoSourceConfig(command) {
|
||
ensureProjectManagementCommand("openMdtodoSourceConfig");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first();
|
||
if (await visibleLocator(existingDialog)) {
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "openMdtodoSourceConfig",
|
||
alreadyOpen: true,
|
||
beforeProject,
|
||
afterProject: await projectManagementCommandSnapshot(),
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
const button = page.locator('[data-testid="mdtodo-source-config-open"]').first();
|
||
await button.waitFor({ state: "visible", timeout: 15000 });
|
||
await button.click();
|
||
await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null);
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "openMdtodoSourceConfig",
|
||
beforeProject,
|
||
afterProject: await projectManagementCommandSnapshot(),
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function ensureMdtodoSourceConfigOpen() {
|
||
const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first();
|
||
if (await visibleLocator(form)) return { opened: false };
|
||
await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" });
|
||
return { opened: true };
|
||
}
|
||
|
||
async function closeMdtodoSourceConfig(command) {
|
||
ensureProjectManagementCommand("closeMdtodoSourceConfig");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const close = await closeMdtodoSourceConfigIfOpen({ required: true });
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "closeMdtodoSourceConfig",
|
||
close,
|
||
beforeProject,
|
||
afterProject: await projectManagementCommandSnapshot(),
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function closeMdtodoSourceConfigIfOpen(options = {}) {
|
||
const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({
|
||
has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]')
|
||
}).first();
|
||
if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false };
|
||
const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first();
|
||
let closeClick = null;
|
||
try {
|
||
await closeButton.waitFor({ state: "visible", timeout: 5000 });
|
||
await closeButton.click({ timeout: 5000 });
|
||
closeClick = { attempted: true, ok: true };
|
||
} catch (error) {
|
||
closeClick = { attempted: true, ok: false, error: errorSummary(error) };
|
||
await page.keyboard.press("Escape").catch(() => null);
|
||
}
|
||
await page.waitForTimeout(400);
|
||
const stillVisible = await visibleLocator(dialog);
|
||
const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true };
|
||
if (options.required && stillVisible) {
|
||
const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt");
|
||
error.details = result;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function fillMdtodoField(testId, value) {
|
||
if (typeof value !== "string" || !value.trim()) return { testId, filled: false };
|
||
const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
||
await locator.waitFor({ state: "visible", timeout: 10000 });
|
||
await locator.fill(value);
|
||
return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
|
||
}
|
||
|
||
async function clickProjectButtonAndMaybeWait(testId, pathPattern) {
|
||
const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
||
await button.waitFor({ state: "visible", timeout: 15000 });
|
||
const responsePromise = pathPattern ? page.waitForResponse((response) => {
|
||
try {
|
||
const pathname = new URL(response.url()).pathname;
|
||
return pathPattern.test(pathname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null);
|
||
const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) }));
|
||
if (buttonState.disabled === true) {
|
||
const error = new Error(testId + " button is disabled");
|
||
error.details = { buttonState, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
await button.click();
|
||
const response = await responsePromise;
|
||
await page.waitForTimeout(900);
|
||
return { buttonState, response, valuesRedacted: true };
|
||
}
|
||
|
||
async function configureMdtodoHwpodSource(command) {
|
||
ensureProjectManagementCommand("configureMdtodoHwpodSource");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const dialog = await ensureMdtodoSourceConfigOpen();
|
||
const fields = [
|
||
await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])),
|
||
await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])),
|
||
await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])),
|
||
await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])),
|
||
];
|
||
const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u);
|
||
const close = await closeMdtodoSourceConfigIfOpen();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
type: "configureMdtodoHwpodSource",
|
||
dialog,
|
||
fields,
|
||
save,
|
||
close,
|
||
beforeProject,
|
||
afterProject: await projectManagementCommandSnapshot(),
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function probeMdtodoSource(command) {
|
||
ensureProjectManagementCommand("probeMdtodoSource");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
await ensureMdtodoSourceConfigOpen();
|
||
const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u);
|
||
const close = await closeMdtodoSourceConfigIfOpen();
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function reindexMdtodoSource(command) {
|
||
ensureProjectManagementCommand("reindexMdtodoSource");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
await ensureMdtodoSourceConfigOpen();
|
||
const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first();
|
||
const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex";
|
||
const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u);
|
||
const close = await closeMdtodoSourceConfigIfOpen();
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function selectTaskIfCommandTargetsOne(command) {
|
||
if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null;
|
||
return selectMdtodoTask(command);
|
||
}
|
||
|
||
async function selectMdtodoProviderProfileForLaunch(command) {
|
||
const provider = commandValue(command, ["provider", "providerProfile"]);
|
||
if (!provider) return null;
|
||
const select = page.locator('[data-testid="mdtodo-provider-profile"]').first();
|
||
if (!(await visibleLocator(select))) {
|
||
return { provider, visible: false, selected: null, valuesRedacted: true };
|
||
}
|
||
const selected = await selectHtmlOptionByValueOrLabel(select, provider);
|
||
await page.waitForTimeout(300);
|
||
return { provider, visible: true, ...selected, valuesRedacted: true };
|
||
}
|
||
|
||
async function saveMdtodoTaskWithButton(command, type, testId, fields) {
|
||
ensureProjectManagementCommand(type);
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const selection = await selectTaskIfCommandTargetsOne(command);
|
||
const inlineEditors = [];
|
||
for (const field of fields) {
|
||
if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId));
|
||
if (field.kind === "fill") await fillMdtodoField(field.testId, field.value);
|
||
if (field.kind === "select") {
|
||
const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first();
|
||
await locator.waitFor({ state: "visible", timeout: 10000 });
|
||
await selectHtmlOptionByValueOrLabel(locator, field.value);
|
||
}
|
||
}
|
||
const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) {
|
||
const candidates = (testIds || []).filter(Boolean);
|
||
for (const testId of candidates) {
|
||
const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
||
if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern);
|
||
}
|
||
if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid");
|
||
return clickProjectButtonAndMaybeWait(candidates[0], pathPattern);
|
||
}
|
||
|
||
async function ensureMdtodoInlineEditor(editorTestId, readTestId) {
|
||
const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first();
|
||
if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false };
|
||
const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first();
|
||
await read.waitFor({ state: "visible", timeout: 10000 });
|
||
await read.dblclick();
|
||
await editor.waitFor({ state: "visible", timeout: 10000 });
|
||
return { editorTestId, readTestId, opened: true };
|
||
}
|
||
|
||
async function editMdtodoTaskTitle(command) {
|
||
const title = commandValue(command, ["title", "text", "value"]);
|
||
if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text");
|
||
return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]);
|
||
}
|
||
|
||
async function editMdtodoTaskBody(command) {
|
||
const body = commandValue(command, ["text", "body", "value"]);
|
||
if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin");
|
||
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
|
||
}
|
||
|
||
async function toggleMdtodoTaskStatus(command) {
|
||
const status = commandValue(command, ["status", "value", "text"]);
|
||
if (!status) throw new Error("toggleMdtodoTaskStatus requires --status");
|
||
return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]);
|
||
}
|
||
|
||
async function editMdtodoTaskInline(command) {
|
||
const field = commandValue(command, ["field", "value"]).toLowerCase();
|
||
if (field === "title") {
|
||
const title = commandValue(command, ["title", "text"]);
|
||
if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text");
|
||
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]);
|
||
}
|
||
if (field === "body" || field === "content") {
|
||
const body = commandValue(command, ["body", "text"]);
|
||
if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin");
|
||
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
|
||
}
|
||
if (field === "status") {
|
||
const status = commandValue(command, ["status", "text"]);
|
||
if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text");
|
||
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]);
|
||
}
|
||
throw new Error("editMdtodoTaskInline requires --field title, body, or status");
|
||
}
|
||
|
||
async function openMdtodoReportPreview(command) {
|
||
ensureProjectManagementCommand("openMdtodoReportPreview");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const selection = await selectTaskIfCommandTargetsOne(command);
|
||
const linkText = commandValue(command, ["link", "value", "text"]);
|
||
const links = page.locator('[data-testid="mdtodo-report-link"]');
|
||
await links.first().waitFor({ state: "visible", timeout: 15000 });
|
||
const count = await links.count();
|
||
let index = 0;
|
||
if (linkText) {
|
||
for (let i = 0; i < count; i += 1) {
|
||
const item = links.nth(i);
|
||
const text = await item.textContent().catch(() => "");
|
||
if (String(text || "").includes(linkText)) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const link = links.nth(index);
|
||
const linkStateRaw = await link.evaluate((element, selectedIndex) => ({
|
||
index: selectedIndex,
|
||
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
|
||
text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240),
|
||
valuesRedacted: true
|
||
}), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true }));
|
||
const linkState = {
|
||
...linkStateRaw,
|
||
textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null,
|
||
textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null,
|
||
text: undefined,
|
||
valuesRedacted: true
|
||
};
|
||
if (linkState.disabled === true) {
|
||
const error = new Error("openMdtodoReportPreview selected report link is disabled");
|
||
error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const attempts = [];
|
||
let response = null;
|
||
let afterProject = null;
|
||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||
const responsePromise = page.waitForResponse((item) => {
|
||
try {
|
||
const request = item.request();
|
||
return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) }));
|
||
const clickMode = attempt === 1 ? "normal" : "force";
|
||
let click = { ok: true, mode: clickMode, valuesRedacted: true };
|
||
await link.scrollIntoViewIfNeeded().catch(() => null);
|
||
try {
|
||
if (attempt === 1) await link.click();
|
||
else await link.click({ force: true, timeout: 5000 });
|
||
} catch (error) {
|
||
click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true };
|
||
await link.evaluate((element) => element.click()).catch(() => null);
|
||
}
|
||
await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null);
|
||
afterProject = await projectManagementCommandSnapshot();
|
||
response = afterProject?.reportPreviewVisible === true
|
||
? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true }
|
||
: await responsePromise;
|
||
attempts.push({
|
||
attempt,
|
||
click,
|
||
response,
|
||
afterUrl: currentPageUrl(),
|
||
reportPreviewVisible: afterProject?.reportPreviewVisible === true,
|
||
reportErrorVisible: afterProject?.reportErrorVisible === true,
|
||
reportFullscreenVisible: afterProject?.reportFullscreenVisible === true,
|
||
valuesRedacted: true
|
||
});
|
||
if (afterProject?.reportPreviewVisible === true) break;
|
||
await page.waitForTimeout(700).catch(() => null);
|
||
}
|
||
afterProject = afterProject ?? await projectManagementCommandSnapshot();
|
||
if (afterProject?.reportPreviewVisible !== true) {
|
||
const error = new Error("openMdtodoReportPreview did not show markdown report preview");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function toggleMdtodoReportFullscreen(command) {
|
||
ensureProjectManagementCommand("toggleMdtodoReportFullscreen");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const desired = commandValue(command, ["value", "text"]).toLowerCase();
|
||
const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first();
|
||
const initiallyOpen = await visibleLocator(dialog);
|
||
let action = "open";
|
||
if (desired === "close" || desired === "off") action = "close";
|
||
else if (desired === "toggle") action = initiallyOpen ? "close" : "open";
|
||
if (action === "close") {
|
||
const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first();
|
||
await closeButton.waitFor({ state: "visible", timeout: 10000 });
|
||
await closeButton.click();
|
||
} else if (!initiallyOpen) {
|
||
const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first();
|
||
await button.waitFor({ state: "visible", timeout: 10000 });
|
||
await button.click();
|
||
}
|
||
await page.waitForTimeout(500);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function fillNewTaskDraft(command) {
|
||
const title = commandValue(command, ["title", "text", "value"]);
|
||
if (!title) throw new Error(command.type + " requires --title or --text");
|
||
const fields = [await fillMdtodoField("mdtodo-new-title", title)];
|
||
const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : "");
|
||
if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body));
|
||
return fields;
|
||
}
|
||
|
||
async function addMdtodoTaskWithButton(command, type, testId) {
|
||
ensureProjectManagementCommand(type);
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command);
|
||
const fields = await fillNewTaskDraft(command);
|
||
const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function addMdtodoRootTask(command) {
|
||
return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root");
|
||
}
|
||
|
||
async function addMdtodoSubTask(command) {
|
||
return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask");
|
||
}
|
||
|
||
async function continueMdtodoTask(command) {
|
||
return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task");
|
||
}
|
||
|
||
async function deleteMdtodoTask(command) {
|
||
ensureProjectManagementCommand("deleteMdtodoTask");
|
||
const beforeUrl = currentPageUrl();
|
||
const beforeProject = await projectManagementCommandSnapshot();
|
||
const selection = await selectTaskIfCommandTargetsOne(command);
|
||
const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null);
|
||
let confirmClick = null;
|
||
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first());
|
||
if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u);
|
||
return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
||
}
|
||
|
||
async function launchWorkbenchFromTask(command) {
|
||
const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask";
|
||
ensureProjectManagementCommand(commandType);
|
||
const beforeUrl = currentPageUrl();
|
||
const initialProject = await projectManagementCommandSnapshot({ includeRaw: true });
|
||
const requestedSourceId = commandValue(command, ["sourceId", "source"]);
|
||
const requestedFileRef = commandValue(command, ["fileRef"]);
|
||
const requestedFilename = commandValue(command, ["filename", "fileName"]);
|
||
const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null;
|
||
const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null;
|
||
const sourceSelection = requestedSourceId && initialProject.selectedSourceIdRaw !== requestedSourceId
|
||
? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: requestedSourceId })
|
||
: null;
|
||
const fileProject = sourceSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : initialProject;
|
||
const fileSelection = (requestedFileRef && fileProject.selectedFileRefRaw !== requestedFileRef) || requestedFilename
|
||
? await selectMdtodoFile({ ...command, type: "selectMdtodoFile", fileRef: requestedFileRef || command.fileRef, filename: requestedFilename || command.filename })
|
||
: null;
|
||
const taskProject = fileSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : fileProject;
|
||
const taskSelection = (requestedTaskRef && taskProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId
|
||
? await selectMdtodoTask({ ...command, type: "selectMdtodoTask", taskRef: requestedTaskRef || command.taskRef, taskId: requestedTaskId || command.taskId })
|
||
: null;
|
||
const providerSelection = await selectMdtodoProviderProfileForLaunch(command);
|
||
const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true });
|
||
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first();
|
||
await button.waitFor({ state: "visible", timeout: 15000 });
|
||
const buttonState = await button.evaluate((element) => ({
|
||
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
|
||
textHash: element.textContent ? null : null,
|
||
testId: element.getAttribute("data-testid") || null,
|
||
action: element.getAttribute("data-action") || null,
|
||
valuesRedacted: true
|
||
})).catch((error) => ({ disabled: null, error: errorSummary(error), valuesRedacted: true }));
|
||
if (buttonState.disabled === true) {
|
||
const error = new Error("launchWorkbenchFromTask button is disabled");
|
||
error.details = { beforeUrl, project: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const launchPath = projectManagement.launchRoute;
|
||
const launchResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
if (request.method().toUpperCase() !== "POST") return false;
|
||
try {
|
||
return new URL(response.url()).pathname === launchPath;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||
const chatResponsePromise = page.waitForResponse((response) => {
|
||
const request = response.request();
|
||
if (request.method().toUpperCase() !== "POST") return false;
|
||
try {
|
||
return new URL(response.url()).pathname === "/v1/agent/chat";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, { timeout: 30000 }).then(async (response) => {
|
||
let chatPayload = null;
|
||
let chatPayloadError = null;
|
||
try {
|
||
chatPayload = await response.json();
|
||
} catch (error) {
|
||
chatPayloadError = errorSummary(error);
|
||
}
|
||
const headers = response.headers();
|
||
return {
|
||
observed: true,
|
||
status: response.status(),
|
||
statusText: response.statusText(),
|
||
path: new URL(response.url()).pathname,
|
||
sessionId: sessionIdFromAgentSessionPayload(chatPayload),
|
||
traceId: traceIdFromAgentChatPayload(chatPayload),
|
||
otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null,
|
||
responseParsed: chatPayload !== null,
|
||
responseParseError: chatPayloadError,
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true }));
|
||
await button.click();
|
||
const launchResponse = await launchResponsePromise;
|
||
if (launchResponse?.waitError) {
|
||
const error = new Error("launchWorkbenchFromTask did not observe POST " + launchPath + " response after button click");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchPath, project: sanitizeProjectCommandSnapshot(projectBeforeClick), waitError: launchResponse.waitError, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const launchStatus = launchResponse.status();
|
||
const headers = launchResponse.headers();
|
||
const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null;
|
||
let payload = null;
|
||
let responseParseError = null;
|
||
try {
|
||
payload = await launchResponse.json();
|
||
} catch (error) {
|
||
responseParseError = errorSummary(error);
|
||
}
|
||
const sessionId = sessionIdFromAgentSessionPayload(payload);
|
||
const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || "");
|
||
const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null;
|
||
if (launchStatus < 200 || launchStatus >= 300 || !sessionId) {
|
||
const error = new Error("launchWorkbenchFromTask did not receive a successful authoritative Workbench session");
|
||
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchStatus, statusText: launchResponse.statusText(), contractVersion, responseParsed: payload !== null, responseParseError, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
if (workbenchUrl) {
|
||
await page.waitForFunction((expectedPath) => window.location.pathname === expectedPath, workbenchUrl, { timeout: 20000 }).catch(() => null);
|
||
}
|
||
const chat = await chatResponsePromise;
|
||
await page.waitForTimeout(1500);
|
||
const workbenchSnapshot = await workbenchSessionSnapshot();
|
||
return {
|
||
beforeUrl,
|
||
afterUrl: currentPageUrl(),
|
||
launchPath,
|
||
launchStatus,
|
||
statusText: launchResponse.statusText(),
|
||
contractVersion,
|
||
sessionId,
|
||
workbenchUrl,
|
||
otelTraceId: launchTraceHeader,
|
||
chatObserved: chat?.observed === true,
|
||
chatStatus: chat?.status ?? null,
|
||
chatSessionId: chat?.sessionId ?? null,
|
||
chatTraceId: chat?.traceId ?? null,
|
||
chatOtelTraceId: chat?.otelTraceId ?? null,
|
||
chat,
|
||
workbenchSnapshot,
|
||
selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw),
|
||
selection: {
|
||
source: sourceSelection,
|
||
file: fileSelection,
|
||
task: taskSelection,
|
||
provider: providerSelection,
|
||
valuesRedacted: true
|
||
},
|
||
initialProject: sanitizeProjectCommandSnapshot(initialProject),
|
||
projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick),
|
||
buttonState,
|
||
responseParsed: payload !== null,
|
||
responseParseError,
|
||
pageId,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function launchWorkbenchFromMdtodo(command) {
|
||
return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" });
|
||
}
|
||
|
||
async function projectManagementCommandSnapshot(options = {}) {
|
||
const raw = await page.evaluate(() => {
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const text = (element) => String(element?.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240);
|
||
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
|
||
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
|
||
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
|
||
const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]');
|
||
const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]');
|
||
const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({
|
||
value: option.value,
|
||
label: text(option),
|
||
selected: option.selected === true
|
||
})) : [];
|
||
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
|
||
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
|
||
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
|
||
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
|
||
const reportError = document.querySelector('[data-testid="mdtodo-report-error"]');
|
||
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
|
||
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
|
||
return {
|
||
path: window.location.pathname,
|
||
pageKind: visible(document.querySelector('[data-testid="project-management-mdtodo"]')) ? "project-management-mdtodo" : visible(document.querySelector('[data-testid="project-management-root"]')) ? "project-management-root" : null,
|
||
sourceCount: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount),
|
||
fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount),
|
||
taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length,
|
||
selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null,
|
||
selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null,
|
||
selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null,
|
||
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20),
|
||
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
|
||
selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null,
|
||
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
|
||
sourceSelectVisible: visible(sourceSelect),
|
||
fileSelectVisible: visible(fileSelect),
|
||
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
|
||
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
|
||
taskBodyVisible: visible(bodyRendered),
|
||
taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "",
|
||
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
|
||
reportLinkCount: reportLinks.length,
|
||
reportPreviewVisible: visible(reportPreview),
|
||
reportPreviewText: visible(reportPreview) ? text(reportPreview) : "",
|
||
reportErrorVisible: visible(reportError),
|
||
reportErrorText: visible(reportError) ? text(reportError) : "",
|
||
reportFullscreenVisible: visible(reportFullscreen),
|
||
launchButtonVisible: visible(launch),
|
||
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
|
||
launchButtonText: text(launch),
|
||
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
|
||
workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length,
|
||
valuesRedacted: true
|
||
};
|
||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||
if (options.includeRaw === true) return raw;
|
||
return sanitizeProjectCommandSnapshot(raw);
|
||
}
|
||
|
||
function sanitizeProjectCommandSnapshot(value) {
|
||
if (!value || typeof value !== "object") return value;
|
||
const textDigest = (raw, limit = 160) => {
|
||
const text = String(raw || "");
|
||
return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true };
|
||
};
|
||
const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim());
|
||
const suspiciousFileLabel = (label) => {
|
||
const text = String(label || "").trim();
|
||
return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text)));
|
||
};
|
||
const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : [];
|
||
return {
|
||
...value,
|
||
selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw),
|
||
selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw),
|
||
selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw),
|
||
selectedSourceIdRaw: undefined,
|
||
selectedFileRefRaw: undefined,
|
||
selectedTaskRefRaw: undefined,
|
||
selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null,
|
||
selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null,
|
||
fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)),
|
||
fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length,
|
||
fileOptionLabels: undefined,
|
||
taskBodyText: undefined,
|
||
taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null,
|
||
reportPreviewText: undefined,
|
||
reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null,
|
||
reportErrorText: undefined,
|
||
reportError: value.reportErrorText ? textDigest(value.reportErrorText, 200) : null,
|
||
launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null,
|
||
launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null,
|
||
launchButtonText: undefined,
|
||
blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [],
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function opaqueIdSummary(value) {
|
||
const text = String(value || "");
|
||
if (!text) return null;
|
||
return {
|
||
hash: sha256Text(text),
|
||
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
|
||
bytes: Buffer.byteLength(text),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function preflightSummary() {
|
||
return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) };
|
||
}
|
||
|
||
async function samplePage(reason, options = {}) {
|
||
if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason);
|
||
const groupSeq = sampleSeq + 1;
|
||
if (page && !page.isClosed()) {
|
||
await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("control-sample-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) })));
|
||
}
|
||
if (observerPage && !observerPage.isClosed()) {
|
||
await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) })));
|
||
}
|
||
if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) {
|
||
await captureScreenshot("checkpoint", "jpeg")
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) })));
|
||
}
|
||
await writeHeartbeat({ status: terminalStatus });
|
||
}
|
||
|
||
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
|
||
sampleSeq += 1;
|
||
const evaluateTimeoutMs = Math.max(3000, Math.min(8000, Number(sampleIntervalMs) || 5000));
|
||
const dom = await withHardTimeout(targetPage.evaluate((input) => {
|
||
const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const textHashInput = (element) => trim(element.textContent || "", 800);
|
||
const loadingTextPattern = /加载中|\bLoading\b/iu;
|
||
const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu;
|
||
const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]";
|
||
const elementTextForLoading = (element) => [
|
||
element.textContent || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || "",
|
||
element.getAttribute("data-testid") || ""
|
||
].map((value) => trim(value, 240)).filter(Boolean).join(" ");
|
||
const elementLooksLikeLoadingUi = (element) => {
|
||
const signal = [
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("role") || "",
|
||
element.getAttribute("aria-busy") || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || ""
|
||
].join(" ");
|
||
return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal);
|
||
};
|
||
const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element);
|
||
const hasLoadingText = (element) => {
|
||
const text = elementTextForLoading(element);
|
||
if (!loadingTextPattern.test(text)) return false;
|
||
if (elementIsCodeLike(element)) return false;
|
||
return true;
|
||
};
|
||
const elementDescriptor = (element) => {
|
||
if (!element) return null;
|
||
const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
|
||
const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]");
|
||
return {
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid") || null,
|
||
role: element.getAttribute("role") || null,
|
||
id: element.getAttribute("id") || null,
|
||
className: className || null,
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null,
|
||
ariaLabel: element.getAttribute("aria-label") || null
|
||
};
|
||
};
|
||
const ownerKindFor = (element) => {
|
||
const value = [
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("role") || "",
|
||
element.tagName || ""
|
||
].join(" ").toLowerCase();
|
||
if (/message|turn|agent|assistant/.test(value)) return "turn";
|
||
if (/session|rail|sidebar|nav/.test(value)) return "session-nav";
|
||
if (/composer|prompt|input|textarea/.test(value)) return "composer";
|
||
if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic";
|
||
if (/trace|event|terminal|log/.test(value)) return "trace";
|
||
if (/performance|metric|chart|table/.test(value)) return "performance";
|
||
if (/main|workspace|workbench|root/.test(value)) return "workbench";
|
||
return "unknown";
|
||
};
|
||
const ownerLabelFor = (element) => {
|
||
const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]");
|
||
return trim(
|
||
element.getAttribute("aria-label")
|
||
|| element.getAttribute("data-testid")
|
||
|| (heading ? heading.textContent || "" : "")
|
||
|| element.getAttribute("class")
|
||
|| element.tagName,
|
||
160
|
||
);
|
||
};
|
||
const ownerKeyFor = (element) => {
|
||
const descriptor = elementDescriptor(element) || {};
|
||
return [
|
||
ownerKindFor(element),
|
||
descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown",
|
||
descriptor.sessionId || descriptor.messageId || descriptor.traceId || ""
|
||
].filter(Boolean).join(":").slice(0, 240);
|
||
};
|
||
const collectLoadingNodes = () => {
|
||
const candidates = Array.from(document.querySelectorAll("body *"))
|
||
.filter(visible)
|
||
.filter(hasLoadingText)
|
||
.filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child)))
|
||
.slice(-80);
|
||
return candidates.map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]');
|
||
const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]');
|
||
const owner = identityOwner || structuralOwner || element;
|
||
const ownerDescriptor = elementDescriptor(owner);
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 180),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
text: trim(elementTextForLoading(element), 240),
|
||
ownerKind: ownerKindFor(owner),
|
||
ownerKey: ownerKeyFor(owner),
|
||
ownerLabel: ownerLabelFor(owner),
|
||
owner: ownerDescriptor,
|
||
ownerText: trim(owner.textContent || "", 300),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
};
|
||
const sessionIdForElement = (element) => {
|
||
const direct = element.getAttribute("data-session-id");
|
||
if (direct) return direct;
|
||
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
||
if (!href) return null;
|
||
try {
|
||
const parsed = new URL(href, location.href);
|
||
const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
} catch {
|
||
const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
}
|
||
};
|
||
const sessionTitleTextForElement = (element) => {
|
||
const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]");
|
||
return trim(
|
||
(titleNode ? titleNode.textContent || "" : "")
|
||
|| element.getAttribute("aria-label")
|
||
|| element.getAttribute("title")
|
||
|| element.textContent
|
||
|| "",
|
||
240
|
||
);
|
||
};
|
||
const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu;
|
||
const looksLikeSessionTitleFallback = (title, sessionId) => {
|
||
const text = trim(title, 240);
|
||
const id = String(sessionId || "").trim();
|
||
if (!text) return true;
|
||
if (sessionTitleFallbackPattern.test(text)) return true;
|
||
if (!id) return false;
|
||
return text === id || text.startsWith(id) || text.startsWith("Session " + id);
|
||
};
|
||
const collectSessionRailTitles = () => {
|
||
const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))
|
||
.filter(visible);
|
||
const seen = new Set();
|
||
const items = [];
|
||
for (const element of candidates) {
|
||
const sessionId = sessionIdForElement(element);
|
||
const titleText = sessionTitleTextForElement(element);
|
||
const key = (sessionId || "") + "|" + titleText;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const rect = element.getBoundingClientRect();
|
||
const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId);
|
||
items.push({
|
||
index: items.length,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
|
||
sessionId,
|
||
sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null,
|
||
titleText,
|
||
fallbackTitle,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
});
|
||
}
|
||
const visibleItems = items.slice(0, 120);
|
||
const fallbackItems = visibleItems.filter((item) => item.fallbackTitle);
|
||
const visibleCount = visibleItems.length;
|
||
const fallbackTitleCount = fallbackItems.length;
|
||
return {
|
||
visibleCount,
|
||
fallbackTitleCount,
|
||
fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0,
|
||
items: visibleItems.slice(0, 60),
|
||
fallbackItems: fallbackItems.slice(0, 12),
|
||
};
|
||
};
|
||
const diagnosticSummaryText = (element) => {
|
||
const summarySelectors = [
|
||
".api-error-diagnostic-summary-text p",
|
||
".api-error-diagnostic-summary-text",
|
||
"[class*='diagnostic-summary-text' i] p",
|
||
"[class*='diagnostic-summary-text' i]",
|
||
"[data-testid*='diagnostic-summary' i]",
|
||
"[data-testid*='error-summary' i]",
|
||
"[role='alert'] p",
|
||
"[role='alert']"
|
||
];
|
||
const parts = [];
|
||
for (const selector of summarySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 800);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
const ownText = textHashInput(element);
|
||
const text = parts.length > 0 ? parts.join(" ") : ownText;
|
||
return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim();
|
||
};
|
||
const diagnosticToggleOnly = (element, text) => {
|
||
const compact = String(text || "").trim();
|
||
if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false;
|
||
const tag = element.tagName.toLowerCase();
|
||
const role = element.getAttribute("role");
|
||
const aria = element.getAttribute("aria-label") || "";
|
||
const title = element.getAttribute("title") || "";
|
||
return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title);
|
||
};
|
||
const stableMessageText = (element) => {
|
||
const bodySelectors = [
|
||
".message-markdown.message-text",
|
||
".message-text",
|
||
"[data-message-body]",
|
||
"[data-testid='message-body']",
|
||
"[data-testid*='message-text' i]",
|
||
"[data-testid*='final-response' i]"
|
||
];
|
||
const parts = [];
|
||
for (const selector of bodySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 1200);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
if (parts.length > 0) return parts.join(" ");
|
||
const clone = element.cloneNode(true);
|
||
for (const selector of [
|
||
".message-duration-meta",
|
||
".message-activity-meta",
|
||
".api-error-diagnostic",
|
||
"[class*='diagnostic' i]",
|
||
"[class*='trace' i]",
|
||
"[data-trace-id]",
|
||
"[data-testid*='trace' i]",
|
||
"[data-testid*='event' i]",
|
||
"[role='status']",
|
||
"[role='alert']",
|
||
"button"
|
||
]) {
|
||
for (const child of Array.from(clone.querySelectorAll(selector))) child.remove();
|
||
}
|
||
return trim(clone.textContent || "", 1200);
|
||
};
|
||
const numericAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
const value = Number(raw);
|
||
if (Number.isFinite(value)) return value;
|
||
}
|
||
return null;
|
||
};
|
||
const textAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
if (raw && String(raw).trim()) return String(raw).trim();
|
||
}
|
||
return null;
|
||
};
|
||
const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]');
|
||
const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]");
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null,
|
||
projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]),
|
||
sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]),
|
||
eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]),
|
||
eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null),
|
||
eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null,
|
||
eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null,
|
||
text: textHashInput(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit);
|
||
const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
dataRole: element.getAttribute("data-role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || null,
|
||
durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120),
|
||
activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120),
|
||
text: stableMessageText(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const resourceTimingSample = (entry) => ({
|
||
name: entry.name.split(/[?#]/u)[0].slice(0, 240),
|
||
initiatorType: entry.initiatorType,
|
||
startTime: Math.round(entry.startTime),
|
||
duration: Math.round(entry.duration),
|
||
workerStart: Math.round(entry.workerStart || 0),
|
||
redirectStart: Math.round(entry.redirectStart || 0),
|
||
redirectEnd: Math.round(entry.redirectEnd || 0),
|
||
fetchStart: Math.round(entry.fetchStart || 0),
|
||
domainLookupStart: Math.round(entry.domainLookupStart || 0),
|
||
domainLookupEnd: Math.round(entry.domainLookupEnd || 0),
|
||
connectStart: Math.round(entry.connectStart || 0),
|
||
connectEnd: Math.round(entry.connectEnd || 0),
|
||
secureConnectionStart: Math.round(entry.secureConnectionStart || 0),
|
||
requestStart: Math.round(entry.requestStart || 0),
|
||
responseStart: Math.round(entry.responseStart || 0),
|
||
responseEnd: Math.round(entry.responseEnd || 0),
|
||
transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null,
|
||
encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
||
decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
||
nextHopProtocol: entry.nextHopProtocol || null,
|
||
responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null,
|
||
serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({
|
||
name: String(item.name || "").slice(0, 80),
|
||
duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null,
|
||
description: String(item.description || "").slice(0, 120)
|
||
})),
|
||
});
|
||
const opaqueDomId = (value) => String(value || "").trim();
|
||
const collectProjectManagement = () => {
|
||
const config = input?.projectManagement || {};
|
||
const targetPaths = Array.isArray(config.targetPaths) ? config.targetPaths : [];
|
||
const path = location.pathname;
|
||
const configuredPath = targetPaths.some((target) => path === target || path.startsWith(String(target) + "/"));
|
||
const root = document.querySelector('[data-testid="project-management-root"]');
|
||
const mdtodoRoot = document.querySelector('[data-testid="project-management-mdtodo"]');
|
||
const rootVisible = visible(root);
|
||
const mdtodoVisible = visible(mdtodoRoot);
|
||
if (!configuredPath && !rootVisible && !mdtodoVisible) return null;
|
||
const sourceItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]')).filter(visible);
|
||
const fileItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]')).filter(visible);
|
||
const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]');
|
||
const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]');
|
||
const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({
|
||
value: option.value,
|
||
label: trim(option.textContent || option.label || "", 180),
|
||
selected: option.selected === true,
|
||
})) : [];
|
||
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
|
||
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
|
||
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
|
||
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
|
||
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
|
||
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
|
||
const statusCounts = {};
|
||
for (const task of taskItems) {
|
||
const status = task.getAttribute("data-task-status") || "unknown";
|
||
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
||
}
|
||
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
|
||
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
|
||
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
|
||
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
|
||
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
|
||
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
|
||
index,
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
text: trim(element.textContent || "", 260),
|
||
})).filter((item) => item.text);
|
||
const workbenchLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible);
|
||
const measurePaneGap = (name, paneSelector, contentSelector) => {
|
||
const pane = document.querySelector(paneSelector);
|
||
if (!visible(pane)) return { name, visible: false };
|
||
const rect = pane.getBoundingClientRect();
|
||
const contentNodes = Array.from(pane.querySelectorAll(contentSelector)).filter(visible);
|
||
const contentBottom = Math.max(rect.top, ...contentNodes.map((element) => element.getBoundingClientRect().bottom));
|
||
const bottomGapPx = Math.max(0, Math.round(rect.bottom - contentBottom));
|
||
const heightPx = Math.max(0, Math.round(rect.height));
|
||
return {
|
||
name,
|
||
visible: true,
|
||
widthPx: Math.max(0, Math.round(rect.width)),
|
||
heightPx,
|
||
bottomGapPx,
|
||
bottomGapRatio: heightPx > 0 ? Number((bottomGapPx / heightPx).toFixed(3)) : 0,
|
||
contentNodeCount: contentNodes.length,
|
||
};
|
||
};
|
||
const paneGaps = [
|
||
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', '[data-task-ref], [role="treeitem"], [role="listitem"], li, button, input, select, .task-row-shell, .task-tools'),
|
||
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
|
||
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
|
||
];
|
||
return {
|
||
pageKind: mdtodoVisible || path.startsWith("/projects/mdtodo") ? "project-management-mdtodo" : rootVisible || path === "/projects" || path.startsWith("/projects/") ? "project-management-root" : "project-management-unknown",
|
||
configuredPath,
|
||
rootVisible,
|
||
mdtodoVisible,
|
||
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
|
||
fileCount: Math.max(fileItems.length, fileOptionCount),
|
||
taskCount: taskItems.length,
|
||
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
|
||
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
|
||
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
|
||
selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null,
|
||
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24),
|
||
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
|
||
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
|
||
sourceSelectVisible: visible(sourceSelect),
|
||
fileSelectVisible: visible(fileSelect),
|
||
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
|
||
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
|
||
taskBodyVisible: visible(bodyRendered),
|
||
taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "",
|
||
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
|
||
taskStatusCounts: statusCounts,
|
||
reportLinkCount: reportLinks.length,
|
||
reportPreviewVisible: visible(reportPreview),
|
||
reportPreviewText: visible(reportPreview) ? trim(reportPreview.textContent || "", 500) : "",
|
||
reportFullscreenVisible: visible(reportFullscreen),
|
||
launchButtonVisible: visible(launch),
|
||
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
|
||
launchButtonText: trim(launch?.textContent || "", 120),
|
||
blockerCount: blockers.length,
|
||
blockers,
|
||
paneGaps,
|
||
workbenchLinkCount: workbenchLinks.length,
|
||
valuesRedacted: true,
|
||
};
|
||
};
|
||
const url = location.href;
|
||
const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u);
|
||
const activeSession = document.querySelector('[data-active="true"][data-session-id], [aria-selected="true"][data-session-id], .active[data-session-id]');
|
||
const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null;
|
||
const commandInput = document.querySelector("#command-input");
|
||
const commandSubmit = document.querySelector('#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]');
|
||
const composerWarning = document.querySelector(".composer-warning");
|
||
const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]';
|
||
const stableTraceSelector = 'li.trace-render-row[data-row-id], li.trace-render-row[data-testid="trace-render-row"], [data-testid="trace-render-row"][data-row-id]';
|
||
const fallbackTraceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]';
|
||
const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]';
|
||
const messages = summarizeMessages(messageSelector, 80);
|
||
const stableTraceElements = Array.from(document.querySelectorAll(stableTraceSelector));
|
||
const fallbackTraceElements = Array.from(document.querySelectorAll(fallbackTraceSelector)).filter((element) => element.matches('li,[role="listitem"],[data-testid*="trace-row" i],[data-testid*="event-row" i]'));
|
||
const traceRows = summarizeElements(stableTraceElements.length > 0 ? stableTraceElements : fallbackTraceElements, 30);
|
||
const loadings = collectLoadingNodes();
|
||
const sessionRail = collectSessionRailTitles();
|
||
const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = diagnosticSummaryText(element);
|
||
if (!text || diagnosticToggleOnly(element, text)) return null;
|
||
const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu);
|
||
const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu);
|
||
const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu);
|
||
const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu);
|
||
const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic";
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 240),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
compact: element.getAttribute("data-compact"),
|
||
expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"),
|
||
title: element.getAttribute("title"),
|
||
diagnosticCode,
|
||
traceId: traceMatch?.[1] || null,
|
||
httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null,
|
||
idleSeconds: idleMatch ? Number(idleMatch[1]) : null,
|
||
waitingFor: waitingForMatch?.[1] || null,
|
||
lastEventLabel: lastEventLabelMatch?.[1] || null,
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).filter(Boolean);
|
||
const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = textHashInput(element);
|
||
const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]");
|
||
const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120);
|
||
const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120);
|
||
const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null;
|
||
return {
|
||
index,
|
||
role: element.getAttribute("data-role") || "agent",
|
||
status: element.getAttribute("data-status") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: directTraceId,
|
||
turnId: element.getAttribute("data-turn-id") || directTraceId,
|
||
durationText,
|
||
activityText,
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).slice(-80);
|
||
const active = document.activeElement;
|
||
return {
|
||
url,
|
||
path: location.pathname,
|
||
routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null,
|
||
activeSessionId,
|
||
title: document.title,
|
||
focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null,
|
||
viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
|
||
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) },
|
||
messages,
|
||
traceRows,
|
||
loadings,
|
||
sessionRail,
|
||
diagnostics,
|
||
turns,
|
||
composer: {
|
||
inputPresent: visible(commandInput),
|
||
inputDisabled: Boolean(commandInput?.disabled) || commandInput?.getAttribute("aria-disabled") === "true",
|
||
warningPresent: visible(composerWarning),
|
||
warningText: trim(composerWarning?.textContent || "", 160),
|
||
submitPresent: visible(commandSubmit),
|
||
submitDisabled: Boolean(commandSubmit?.disabled) || commandSubmit?.getAttribute("aria-disabled") === "true",
|
||
submitAction: commandSubmit?.getAttribute("data-action") || null,
|
||
submitText: trim(commandSubmit?.textContent || "", 80),
|
||
submitTestId: commandSubmit?.getAttribute("data-testid") || null,
|
||
activeStatus: activeSession?.getAttribute("data-status") || null,
|
||
valuesRedacted: true
|
||
},
|
||
projectManagement: collectProjectManagement(),
|
||
pageProvenance: {
|
||
url: location.href,
|
||
path: location.pathname,
|
||
title: document.title,
|
||
readyState: document.readyState,
|
||
timeOrigin: Math.round(performance.timeOrigin || 0),
|
||
navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null,
|
||
scripts: Array.from(document.scripts).map((element) => {
|
||
if (!element.src) return null;
|
||
try {
|
||
const url = new URL(element.src, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => {
|
||
try {
|
||
const url = new URL(element.href, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
|
||
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
|
||
content: String(element.getAttribute("content") || "").slice(0, 200),
|
||
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)),
|
||
},
|
||
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
|
||
};
|
||
}, { projectManagement }), evaluateTimeoutMs, "sampleOnePage DOM evaluate exceeded " + evaluateTimeoutMs + "ms").catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
|
||
const sample = {
|
||
seq: sampleSeq,
|
||
sampleGroupSeq: groupSeq,
|
||
ts: new Date().toISOString(),
|
||
reason,
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
|
||
commandId: activeCommandId,
|
||
observerInitiated: false,
|
||
...digestDom(dom, pageRole),
|
||
};
|
||
await appendJsonl(files.samples, sample);
|
||
}
|
||
|
||
function digestDom(dom, pageRole = "control") {
|
||
if (dom && dom.error) return dom;
|
||
const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : [];
|
||
const sessionRail = digestSessionRail(dom.sessionRail);
|
||
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const projectManagementSample = digestProjectManagement(dom.projectManagement);
|
||
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
|
||
if (pageRole === "control") currentPageProvenance = pageProvenance;
|
||
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, projectManagement: projectManagementSample, pageProvenance: compactPageProvenance(pageProvenance) };
|
||
}
|
||
|
||
function digestProjectManagement(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const opaque = (raw) => {
|
||
const text = String(raw || "");
|
||
if (!text) return null;
|
||
return {
|
||
hash: sha256Text(text),
|
||
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
|
||
bytes: Buffer.byteLength(text),
|
||
valuesRedacted: true
|
||
};
|
||
};
|
||
const textDigest = (raw, limit = 160) => {
|
||
const text = String(raw || "");
|
||
return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true };
|
||
};
|
||
const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim());
|
||
const suspiciousFileLabel = (label) => {
|
||
const text = String(label || "").trim();
|
||
return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text)));
|
||
};
|
||
const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : [];
|
||
return {
|
||
pageKind: value.pageKind ?? null,
|
||
configuredPath: value.configuredPath === true,
|
||
rootVisible: value.rootVisible === true,
|
||
mdtodoVisible: value.mdtodoVisible === true,
|
||
sourceCount: Number.isFinite(Number(value.sourceCount)) ? Number(value.sourceCount) : 0,
|
||
fileCount: Number.isFinite(Number(value.fileCount)) ? Number(value.fileCount) : 0,
|
||
taskCount: Number.isFinite(Number(value.taskCount)) ? Number(value.taskCount) : 0,
|
||
taskRefMissingCount: Number.isFinite(Number(value.taskRefMissingCount)) ? Number(value.taskRefMissingCount) : 0,
|
||
selectedSourceId: opaque(value.selectedSourceId),
|
||
selectedFileRef: opaque(value.selectedFileRef),
|
||
selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null,
|
||
selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null,
|
||
fileOptionLabelSamples: fileLabels.slice(0, 10).map((item) => textDigest(item, 120)),
|
||
fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length,
|
||
selectedTaskRef: opaque(value.selectedTaskRef),
|
||
selectedTaskStatus: value.selectedTaskStatus ?? null,
|
||
sourceSelectVisible: value.sourceSelectVisible === true,
|
||
fileSelectVisible: value.fileSelectVisible === true,
|
||
sourceConfigVisible: value.sourceConfigVisible === true,
|
||
taskEditorVisible: value.taskEditorVisible === true,
|
||
taskBodyVisible: value.taskBodyVisible === true,
|
||
taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null,
|
||
newTaskDraftVisible: value.newTaskDraftVisible === true,
|
||
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
|
||
reportLinkCount: Number.isFinite(Number(value.reportLinkCount)) ? Number(value.reportLinkCount) : 0,
|
||
reportPreviewVisible: value.reportPreviewVisible === true,
|
||
reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null,
|
||
reportFullscreenVisible: value.reportFullscreenVisible === true,
|
||
launchButtonVisible: value.launchButtonVisible === true,
|
||
launchButtonEnabled: value.launchButtonEnabled === true,
|
||
launchButtonText: value.launchButtonText ? textDigest(value.launchButtonText, 120) : null,
|
||
blockerCount: Number.isFinite(Number(value.blockerCount)) ? Number(value.blockerCount) : 0,
|
||
blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 12).map((item) => ({
|
||
index: item?.index ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
...textDigest(item?.text || "", 160),
|
||
})) : [],
|
||
paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({
|
||
name: item?.name ?? null,
|
||
visible: item?.visible === true,
|
||
widthPx: Number.isFinite(Number(item?.widthPx)) ? Number(item.widthPx) : null,
|
||
heightPx: Number.isFinite(Number(item?.heightPx)) ? Number(item.heightPx) : null,
|
||
bottomGapPx: Number.isFinite(Number(item?.bottomGapPx)) ? Number(item.bottomGapPx) : null,
|
||
bottomGapRatio: Number.isFinite(Number(item?.bottomGapRatio)) ? Number(item.bottomGapRatio) : null,
|
||
contentNodeCount: Number.isFinite(Number(item?.contentNodeCount)) ? Number(item.contentNodeCount) : null,
|
||
valuesRedacted: true,
|
||
})) : [],
|
||
workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function digestSessionRail(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const items = Array.isArray(value.items) ? value.items.map((item) => {
|
||
const titleText = String(item?.titleText || item?.titlePreview || "");
|
||
return {
|
||
index: item?.index ?? null,
|
||
tag: item?.tag ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
active: item?.active === true,
|
||
sessionId: item?.sessionId ?? null,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
fallbackTitle: item?.fallbackTitle === true,
|
||
titleHash: sha256Text(titleText),
|
||
titlePreview: truncate(titleText, 160),
|
||
titleBytes: Buffer.byteLength(titleText),
|
||
rect: item?.rect ?? null,
|
||
};
|
||
}) : [];
|
||
const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12);
|
||
const visibleCount = Number(value.visibleCount ?? items.length);
|
||
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
|
||
return {
|
||
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
|
||
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
|
||
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
|
||
items,
|
||
fallbackItems,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function captureScreenshot(reason, imageType = "png") {
|
||
if (!page || page.isClosed()) throw new Error("page is not available for screenshot");
|
||
if (screenshotCaptureState && screenshotCaptureState.settled !== true) {
|
||
const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now());
|
||
const error = new Error("screenshot capture already in progress");
|
||
error.details = {
|
||
reason,
|
||
currentUrl: currentPageUrl(),
|
||
pageId,
|
||
activeReason: screenshotCaptureState.reason,
|
||
activeStartedAt: screenshotCaptureState.startedAt,
|
||
activeAgeMs: ageMs,
|
||
activeTimedOut: screenshotCaptureState.timedOut === true,
|
||
timeoutMs: screenshotCaptureTimeoutMs,
|
||
valuesRedacted: true,
|
||
};
|
||
lastScreenshotAtMs = Date.now();
|
||
throw error;
|
||
}
|
||
artifactSeq += 1;
|
||
const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual";
|
||
const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png";
|
||
const ext = type === "jpeg" ? "jpg" : "png";
|
||
const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext);
|
||
const timeoutMs = screenshotCaptureTimeoutMs;
|
||
const options = type === "jpeg"
|
||
? { path: file, type: "jpeg", quality: 70, fullPage: false, animations: "disabled", timeout: timeoutMs }
|
||
: { path: file, type: "png", fullPage: false, animations: "disabled", timeout: timeoutMs };
|
||
const state = { reason, startedAtMs: Date.now(), startedAt: new Date().toISOString(), timeoutMs, settled: false, timedOut: false };
|
||
screenshotCaptureState = state;
|
||
const screenshotPromise = page.screenshot(options)
|
||
.then((value) => {
|
||
state.settled = true;
|
||
return value;
|
||
})
|
||
.catch((error) => {
|
||
state.settled = true;
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
if (screenshotCaptureState === state) screenshotCaptureState = null;
|
||
});
|
||
try {
|
||
await withHardTimeout(screenshotPromise, timeoutMs + 1000, "captureScreenshot " + safeReason + " exceeded " + timeoutMs + "ms");
|
||
const meta = await fileMeta(file);
|
||
const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl(), timeoutMs };
|
||
await appendJsonl(files.artifacts, artifact);
|
||
lastScreenshotAtMs = Date.now();
|
||
return artifact;
|
||
} catch (error) {
|
||
if (String(error?.message || "").includes("exceeded " + timeoutMs + "ms")) state.timedOut = true;
|
||
lastScreenshotAtMs = Date.now();
|
||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||
wrapped.details = {
|
||
...(wrapped.details || {}),
|
||
reason,
|
||
currentUrl: currentPageUrl(),
|
||
pageId,
|
||
timeoutMs,
|
||
file,
|
||
valuesRedacted: true,
|
||
};
|
||
throw wrapped;
|
||
}
|
||
}
|
||
|
||
async function captureCommandScreenshot(command) {
|
||
const shouldWaitProject = command.waitProjectManagementReady === true;
|
||
const readiness = shouldWaitProject ? await waitForProjectManagementCommandReady({ timeoutMs: 15000 }) : null;
|
||
if (readiness && readiness.ok !== true) {
|
||
const error = new Error("screenshot project-management readiness wait failed: " + (readiness.reason || "not-ready"));
|
||
error.details = { readiness, currentUrl: currentPageUrl(), pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const artifact = await captureScreenshot(command.reason || command.label || "manual", command.imageType || "png");
|
||
return { ...artifact, readiness, valuesRedacted: true };
|
||
}
|
||
|
||
function eventRecord(type, data) {
|
||
const clean = sanitize(data) || {};
|
||
return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean };
|
||
}
|
||
|
||
async function summarizeWorkbenchResponseBody(response, request) {
|
||
const method = String(request.method() || "GET").toUpperCase();
|
||
const path = safeUrlPath(response.url()) || "";
|
||
const resourceType = String(request.resourceType() || "");
|
||
const status = Number(response.status());
|
||
if (!shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status })) return { bodyRead: false };
|
||
const headers = response.headers();
|
||
const contentType = String(headers["content-type"] || headers["Content-Type"] || "");
|
||
if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true };
|
||
const contentLength = Number(headers["content-length"] || headers["Content-Length"]);
|
||
const maxBytes = 512 * 1024;
|
||
if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true };
|
||
const text = await response.text();
|
||
const byteCount = Buffer.byteLength(text);
|
||
if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true };
|
||
let parsed = null;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch (error) {
|
||
return { bodyRead: true, bodyReadSkipped: "json-parse-error", bodyByteCount: byteCount, bodyHash: sha256Text(text), bodyParseError: errorSummary(error), valuesRedacted: true };
|
||
}
|
||
return {
|
||
bodyRead: true,
|
||
bodyByteCount: byteCount,
|
||
bodyHash: sha256Text(text),
|
||
bodySummary: summarizeWorkbenchJsonBody(parsed, path),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status }) {
|
||
if (method !== "GET" && method !== "POST") return false;
|
||
if (!Number.isFinite(status) || status < 200 || status >= 300) return false;
|
||
if (resourceType === "eventsource" || path === "/v1/workbench/events") return false;
|
||
return path === "/v1/agent/chat"
|
||
|| path === "/v1/agent/chat/steer"
|
||
|| path === "/v1/workbench/sessions"
|
||
|| /^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)
|
||
|| /^\/v1\/workbench\/turns\/[^/]+$/u.test(path)
|
||
|| /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path);
|
||
}
|
||
|
||
function summarizeWorkbenchJsonBody(value, path) {
|
||
const traceIds = new Set();
|
||
const sessionIds = new Set();
|
||
const terminalTraceIds = new Set();
|
||
const statusCounts = {};
|
||
const counters = {
|
||
objectCount: 0,
|
||
arrayCount: 0,
|
||
traceEventLikeCount: 0,
|
||
messageLikeCount: 0,
|
||
turnLikeCount: 0,
|
||
terminalStatusCount: 0,
|
||
runningStatusCount: 0,
|
||
terminalTextCount: 0,
|
||
finalTextFieldCount: 0,
|
||
finalTextByteCount: 0
|
||
};
|
||
|
||
const visit = (node, key = "", parent = null, depth = 0) => {
|
||
if (depth > 32 || node === null || node === undefined) return;
|
||
if (typeof node === "string") {
|
||
collectWorkbenchIdsFromText(node, traceIds, sessionIds);
|
||
const normalizedStatus = normalizeWorkbenchStatus(key, node);
|
||
if (normalizedStatus) {
|
||
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
|
||
if (isWorkbenchTerminalStatus(normalizedStatus)) counters.terminalStatusCount += 1;
|
||
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
|
||
}
|
||
if (isWorkbenchTerminalText(node)) {
|
||
counters.terminalTextCount += 1;
|
||
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
|
||
}
|
||
if (isLikelyWorkbenchFinalTextField(key, parent, node)) {
|
||
counters.finalTextFieldCount += 1;
|
||
counters.finalTextByteCount += Buffer.byteLength(node);
|
||
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
|
||
}
|
||
return;
|
||
}
|
||
if (typeof node !== "object") return;
|
||
if (Array.isArray(node)) {
|
||
counters.arrayCount += 1;
|
||
for (const item of node) visit(item, key, parent, depth + 1);
|
||
return;
|
||
}
|
||
counters.objectCount += 1;
|
||
const record = node;
|
||
const recordTraceIds = workbenchTraceIdsFromRecord(record);
|
||
for (const raw of [record.traceId, record.trace_id, record.id, record.turnId, record.messageId]) {
|
||
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
|
||
}
|
||
for (const raw of [record.sessionId, record.session_id]) {
|
||
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
|
||
}
|
||
const statusValue = record.status ?? record.state ?? record.phase ?? record.result ?? record.lifecycle;
|
||
if (typeof statusValue === "string") {
|
||
const normalizedStatus = normalizeWorkbenchStatus("status", statusValue);
|
||
if (normalizedStatus) {
|
||
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
|
||
if (isWorkbenchTerminalStatus(normalizedStatus)) {
|
||
counters.terminalStatusCount += 1;
|
||
for (const traceId of recordTraceIds) terminalTraceIds.add(traceId);
|
||
}
|
||
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
|
||
}
|
||
}
|
||
if (record.traceId || record.trace_id || record.turnId || record.turn_id) counters.turnLikeCount += 1;
|
||
if (record.messageId || record.message_id || record.role || record.author || record.content || record.text || record.finalResponse) counters.messageLikeCount += 1;
|
||
if (record.projectedSeq !== undefined || record.sourceSeq !== undefined || record.eventSeq !== undefined || record.eventKind !== undefined || record.eventTimestamp !== undefined) counters.traceEventLikeCount += 1;
|
||
for (const [childKey, childValue] of Object.entries(record)) visit(childValue, childKey, record, depth + 1);
|
||
};
|
||
visit(value);
|
||
return {
|
||
pathKind: workbenchBodyPathKind(path),
|
||
traceIds: Array.from(traceIds).sort().slice(0, 12),
|
||
terminalTraceIds: Array.from(terminalTraceIds).sort().slice(0, 12),
|
||
sessionIds: Array.from(sessionIds).sort().slice(0, 12),
|
||
statusCounts,
|
||
...counters,
|
||
terminalEvidenceCount: counters.terminalStatusCount + counters.terminalTextCount,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function collectWorkbenchIdsFromText(value, traceIds, sessionIds) {
|
||
const text = String(value || "");
|
||
for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) traceIds.add(match[0]);
|
||
for (const match of text.matchAll(/\bses_[A-Za-z0-9_-]+\b/gu)) sessionIds.add(match[0]);
|
||
}
|
||
|
||
function workbenchTraceIdsFromRecord(record) {
|
||
if (!record || typeof record !== "object") return [];
|
||
const values = [record.traceId, record.trace_id, record.turnId, record.turn_id, record.messageId, record.message_id, record.id];
|
||
const ids = new Set();
|
||
for (const raw of values) {
|
||
if (typeof raw !== "string") continue;
|
||
for (const match of raw.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]);
|
||
}
|
||
return Array.from(ids).sort();
|
||
}
|
||
|
||
function normalizeWorkbenchStatus(key, value) {
|
||
if (!/status|state|phase|result|lifecycle/iu.test(String(key || ""))) return null;
|
||
const text = String(value || "").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").replace(/^-+|-+$/gu, "");
|
||
if (!text) return null;
|
||
if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(text)) return "completed";
|
||
if (/^(failed|failure|error|errored)$/u.test(text)) return "failed";
|
||
if (/^(canceled|cancelled|aborted|cancel)$/u.test(text)) return "canceled";
|
||
if (/^(running|active|in-progress|in_progress|processing|streaming|executing)$/u.test(text)) return "running";
|
||
if (/^(queued|pending|admitted|created|waiting)$/u.test(text)) return "pending";
|
||
return text.slice(0, 80);
|
||
}
|
||
|
||
function isWorkbenchTerminalStatus(value) {
|
||
return value === "completed" || value === "failed" || value === "canceled";
|
||
}
|
||
|
||
function isWorkbenchRunningStatus(value) {
|
||
return value === "running" || value === "pending";
|
||
}
|
||
|
||
function isWorkbenchTerminalText(value) {
|
||
return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(value || ""));
|
||
}
|
||
|
||
function isLikelyWorkbenchFinalTextField(key, parent, value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return false;
|
||
const field = String(key || "");
|
||
if (!/final|assistant|response|content|markdown|text|message|output|result/iu.test(field)) return false;
|
||
const parentStatus = normalizeWorkbenchStatus("status", parent?.status ?? parent?.state ?? parent?.phase ?? parent?.result ?? "");
|
||
const parentRole = String(parent?.role ?? parent?.author ?? parent?.dataRole ?? "").toLowerCase();
|
||
return isWorkbenchTerminalStatus(parentStatus) || /assistant|agent|code/iu.test(parentRole) || /final|response|result/iu.test(field);
|
||
}
|
||
|
||
function workbenchBodyPathKind(path) {
|
||
if (path === "/v1/agent/chat" || path === "/v1/agent/chat/steer") return "agent-chat-submit";
|
||
if (path === "/v1/workbench/sessions") return "workbench-sessions";
|
||
if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "workbench-session-messages";
|
||
if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "workbench-turn";
|
||
if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "workbench-trace-events";
|
||
return "workbench";
|
||
}
|
||
|
||
function controlRecord(command, phase, detail) {
|
||
return {
|
||
ts: new Date().toISOString(),
|
||
seq: commandSeq,
|
||
phase,
|
||
commandId: command.id,
|
||
type: command.type,
|
||
source: command.source || "file",
|
||
input: commandInputSummary(command),
|
||
beforeUrl: command.beforeUrl || null,
|
||
afterUrl: currentPageUrl(),
|
||
pageId,
|
||
detail: sanitize(detail),
|
||
};
|
||
}
|
||
|
||
function commandInputSummary(command) {
|
||
const text = typeof command.text === "string" ? command.text : null;
|
||
const opaque = (value) => {
|
||
const raw = typeof value === "string" ? value : null;
|
||
if (!raw) return null;
|
||
return {
|
||
hash: sha256Text(raw),
|
||
preview: raw.length <= 18 ? raw : raw.slice(0, 10) + "..." + raw.slice(-5),
|
||
bytes: Buffer.byteLength(raw),
|
||
valuesRedacted: true
|
||
};
|
||
};
|
||
return {
|
||
type: command.type,
|
||
path: command.path || null,
|
||
url: command.url ? safeUrl(command.url) : null,
|
||
sessionId: command.sessionId || command.value || null,
|
||
provider: command.provider || null,
|
||
afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null,
|
||
severity: command.severity || null,
|
||
alternateSessionStrategy: command.alternateSessionStrategy || null,
|
||
expectedSentinelRange: command.expectedSentinelRange || null,
|
||
expectedActionWaitMs: command.expectedActionWaitMs === null || command.expectedActionWaitMs === undefined || command.expectedActionWaitMs === "" ? null : Number(command.expectedActionWaitMs),
|
||
requireComposerReady: command.requireComposerReady === true,
|
||
waitProjectManagementReady: command.waitProjectManagementReady === true,
|
||
findingId: command.findingId || null,
|
||
blocking: command.blocking === true ? true : command.blocking === false ? false : null,
|
||
sourceId: opaque(command.sourceId),
|
||
fileRef: opaque(command.fileRef),
|
||
filename: command.filename ? truncate(command.filename, 200) : null,
|
||
taskRef: opaque(command.taskRef),
|
||
taskId: command.taskId || null,
|
||
field: command.field || null,
|
||
link: command.link ? truncate(command.link, 200) : null,
|
||
titleHash: command.title ? sha256Text(command.title) : null,
|
||
titleBytes: command.title ? Buffer.byteLength(command.title) : null,
|
||
bodyHash: command.body ? sha256Text(command.body) : null,
|
||
bodyBytes: command.body ? Buffer.byteLength(command.body) : null,
|
||
status: command.status || null,
|
||
hwpodId: opaque(command.hwpodId),
|
||
nodeId: opaque(command.nodeId),
|
||
workspaceRoot: opaque(command.workspaceRoot),
|
||
root: opaque(command.root),
|
||
label: command.label ? truncate(command.label, 200) : null,
|
||
textHash: text === null ? null : sha256Text(text),
|
||
textBytes: text === null ? null : Buffer.byteLength(text),
|
||
textPreview: null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function appendJsonl(file, value) {
|
||
await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 });
|
||
}
|
||
|
||
async function fileMeta(file) {
|
||
const [buffer, stats] = await Promise.all([readFile(file), stat(file)]);
|
||
return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") };
|
||
}
|
||
|
||
function currentPageUrl() {
|
||
return pageUrl(page);
|
||
}
|
||
|
||
function pageUrl(targetPage) {
|
||
try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; }
|
||
}
|
||
|
||
function routeSessionIdFromUrl(value) {
|
||
try {
|
||
const pathname = new URL(String(value || ""), baseUrl).pathname;
|
||
const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function safeFrameUrl(frame) {
|
||
try { return frame ? safeUrl(frame.url()) : null; } catch { return null; }
|
||
}
|
||
|
||
function safeUrl(value) {
|
||
try {
|
||
const url = new URL(String(value), baseUrl);
|
||
for (const key of Array.from(url.searchParams.keys())) {
|
||
if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]");
|
||
}
|
||
return url.toString();
|
||
} catch {
|
||
return truncate(String(value || ""), 300);
|
||
}
|
||
}
|
||
|
||
function safeUrlPath(value) {
|
||
try {
|
||
return new URL(String(value || ""), baseUrl).pathname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeBaseUrl(value) {
|
||
const raw = value || "http://127.0.0.1:3000";
|
||
const url = new URL(raw);
|
||
return url.origin;
|
||
}
|
||
|
||
function parseViewport(value) {
|
||
const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u);
|
||
return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 };
|
||
}
|
||
|
||
function positiveInteger(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
|
||
}
|
||
|
||
function boundedInteger(value, fallback, min, max) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
const integer = Math.floor(parsed);
|
||
if (integer < min || integer > max) return fallback;
|
||
return integer;
|
||
}
|
||
|
||
function positiveNumber(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function requiredPositiveThreshold(raw, key) {
|
||
const parsed = Number(raw?.[key]);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseAlertThresholds(value) {
|
||
if (!value) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
|
||
if (sessionRailFallbackRatio > 1) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
|
||
}
|
||
return {
|
||
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
|
||
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
|
||
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
|
||
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
|
||
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
|
||
turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"),
|
||
domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"),
|
||
domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"),
|
||
screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"),
|
||
pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"),
|
||
browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"),
|
||
browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"),
|
||
browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"),
|
||
browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"),
|
||
browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"),
|
||
playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"),
|
||
playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"),
|
||
cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"),
|
||
uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"),
|
||
scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"),
|
||
scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"),
|
||
scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"),
|
||
sessionRailFallbackRatio,
|
||
crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")),
|
||
source: "yaml-env",
|
||
};
|
||
}
|
||
|
||
function parseBrowserFreezePolicy(value) {
|
||
if (!value) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane");
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy");
|
||
const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy");
|
||
const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy");
|
||
const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy");
|
||
return {
|
||
enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"),
|
||
blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"),
|
||
memory: {
|
||
totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
||
processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
||
growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
||
},
|
||
responsiveness: {
|
||
latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"),
|
||
eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"),
|
||
},
|
||
cdp: {
|
||
metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"),
|
||
},
|
||
kill: {
|
||
enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"),
|
||
gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"),
|
||
forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"),
|
||
graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000),
|
||
pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000),
|
||
exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125),
|
||
},
|
||
source: "yaml-env",
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
function requiredPolicyRecord(raw, key, pathLabel) {
|
||
const value = raw?.[key];
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requiredPolicyBoolean(raw, key, pathLabel) {
|
||
const value = raw?.[key];
|
||
if (typeof value !== "boolean") {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requiredPolicyPositiveNumber(raw, key, pathLabel) {
|
||
const value = Number(raw?.[key]);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) {
|
||
const value = Number(raw?.[key]);
|
||
if (!Number.isInteger(value) || value < min || value > max) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requiredPolicySignal(raw, key, pathLabel, expected) {
|
||
const value = String(raw?.[key] || "");
|
||
if (value !== expected) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function parseProjectManagementConfig(value) {
|
||
if (!value || value === "null") {
|
||
return {
|
||
enabled: false,
|
||
targetPaths: [],
|
||
readinessSelectors: [],
|
||
naturalApiPathPrefixes: [],
|
||
commandAllowlist: [],
|
||
launchRoute: "",
|
||
slowApiBudgetMs: 0,
|
||
source: "yaml-env",
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const stringList = (key) => {
|
||
const list = raw?.[key];
|
||
if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
||
return list;
|
||
};
|
||
const positive = (key) => {
|
||
const numeric = Number(raw?.[key]);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
||
return numeric;
|
||
};
|
||
if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled");
|
||
if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true };
|
||
const targetPaths = stringList("targetPaths");
|
||
const readinessSelectors = stringList("readinessSelectors");
|
||
const naturalApiPathPrefixes = stringList("naturalApiPathPrefixes");
|
||
const commandAllowlist = stringList("commandAllowlist");
|
||
const launchRoute = String(raw.launchRoute || "");
|
||
if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path");
|
||
return {
|
||
enabled: true,
|
||
targetPaths,
|
||
readinessSelectors,
|
||
naturalApiPathPrefixes,
|
||
commandAllowlist,
|
||
launchRoute,
|
||
slowApiBudgetMs: positive("slowApiBudgetMs"),
|
||
source: "yaml-env",
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function sha256Text(value) {
|
||
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
|
||
}
|
||
|
||
function safeId(value) {
|
||
return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item";
|
||
}
|
||
|
||
function cssEscape(value) {
|
||
return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
|
||
}
|
||
|
||
function truncate(value, limit) {
|
||
const text = String(value || "");
|
||
return text.length > limit ? text.slice(0, limit) + "..." : text;
|
||
}
|
||
|
||
function sanitize(value) {
|
||
if (value === null || value === undefined) return value;
|
||
if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]");
|
||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||
if (Array.isArray(value)) return value.map(sanitize);
|
||
if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)]));
|
||
return String(value);
|
||
}
|
||
|
||
function errorSummary(error) {
|
||
const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null };
|
||
if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth);
|
||
if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5));
|
||
if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness);
|
||
if (error && error.details) summary.details = sanitize(error.details);
|
||
if (error && error.target) summary.target = sanitize(error.target);
|
||
return summary;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||
}
|
||
|
||
function withHardTimeout(promise, timeoutMs, message) {
|
||
const guarded = Promise.resolve(promise);
|
||
let timer = null;
|
||
const timeout = new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1));
|
||
if (timer && typeof timer.unref === "function") timer.unref();
|
||
});
|
||
return Promise.race([guarded, timeout]).finally(() => {
|
||
if (timer) clearTimeout(timer);
|
||
guarded.catch(() => {});
|
||
});
|
||
}
|
||
`;
|
||
}
|