feat(desktop): add monitor scheduler, Playwright CDP layer, and account health
Move monitor-task scheduling authority onto the client with a durable file-backed queue that survives restart, drops stale cross-day tasks, enforces per-platform serialism, and adapts global concurrency from Electron process metrics. Publish tasks keep their existing FIFO. Add a hidden Playwright CDP manager that attaches to Electron Chromium on account session partitions, lets adapters opt into `executionMode: "playwright"`, and leaves the existing hidden WebContentsView path in place for current adapters. Introduce an account-health subsystem with silent probes, projected health/auth states, and IPC invalidation events so the renderer can show accurate auth/probe status and verification timestamps. Server-side, derive and forward title/business_date/scheduler_group_key/ question_text metadata on desktop task events so the local scheduler can defer same-question fan-out before leasing.
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import { BrowserWindow, nativeTheme } from "electron/main";
|
||||
import type { BrowserWindow as ElectronBrowserWindow, Session } from "electron/main";
|
||||
import type { Browser as PlaywrightBrowser, Page as PlaywrightPage } from "playwright-core";
|
||||
|
||||
import { STANDARD_USER_AGENT } from "./user-agent";
|
||||
|
||||
const hiddenPlaywrightIdleTTLms = 5 * 60_000;
|
||||
const hiddenPlaywrightReaperIntervalMs = 60_000;
|
||||
const defaultConnectTimeoutMs = 10_000;
|
||||
const defaultPageTimeoutMs = 45_000;
|
||||
const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? "9339", 10);
|
||||
const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`;
|
||||
|
||||
interface HiddenPlaywrightRecord {
|
||||
key: string;
|
||||
accountId: string;
|
||||
title: string;
|
||||
targetURL: string;
|
||||
window: ElectronBrowserWindow;
|
||||
browser: PlaywrightBrowser;
|
||||
page: PlaywrightPage;
|
||||
retainCount: number;
|
||||
activatedAt: number;
|
||||
lastReleasedAt: number | null;
|
||||
}
|
||||
|
||||
export interface HiddenPlaywrightLease {
|
||||
accountId: string;
|
||||
browser: PlaywrightBrowser;
|
||||
page: PlaywrightPage;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
const records = new Map<string, HiddenPlaywrightRecord>();
|
||||
let reaperHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let connectPromise: Promise<PlaywrightBrowser> | null = null;
|
||||
let connectedBrowser: PlaywrightBrowser | null = null;
|
||||
|
||||
export function getPlaywrightCDPPort(): number {
|
||||
return cdpPort;
|
||||
}
|
||||
|
||||
export function startHiddenPlaywrightReaper(): void {
|
||||
if (reaperHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
reaperHandle = setInterval(() => {
|
||||
reapIdleHiddenPlaywrightPages();
|
||||
}, hiddenPlaywrightReaperIntervalMs);
|
||||
reaperHandle.unref?.();
|
||||
}
|
||||
|
||||
export async function retainHiddenPlaywrightPage(options: {
|
||||
accountId: string;
|
||||
session: Session;
|
||||
targetURL: string;
|
||||
title: string;
|
||||
}): Promise<HiddenPlaywrightLease> {
|
||||
startHiddenPlaywrightReaper();
|
||||
|
||||
const key = hiddenPlaywrightKey(options.accountId, options.targetURL);
|
||||
const existing = records.get(key);
|
||||
if (existing && !existing.window.isDestroyed() && !existing.page.isClosed()) {
|
||||
await ensureRecordTarget(existing, options.targetURL);
|
||||
existing.retainCount += 1;
|
||||
existing.activatedAt = Date.now();
|
||||
existing.lastReleasedAt = null;
|
||||
return createLease(existing);
|
||||
}
|
||||
|
||||
const browser = await connectBrowserOverCDP();
|
||||
const knownPages = new Set(defaultContext(browser).pages());
|
||||
const window = createHiddenWindow(options.title, options.session);
|
||||
const page = await waitForNewPage(browser, knownPages);
|
||||
|
||||
page.setDefaultTimeout(defaultPageTimeoutMs);
|
||||
page.setDefaultNavigationTimeout(defaultPageTimeoutMs);
|
||||
await page.goto(options.targetURL, {
|
||||
timeout: defaultPageTimeoutMs,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const record: HiddenPlaywrightRecord = {
|
||||
key,
|
||||
accountId: options.accountId,
|
||||
title: options.title,
|
||||
targetURL: options.targetURL,
|
||||
window,
|
||||
browser,
|
||||
page,
|
||||
retainCount: 1,
|
||||
activatedAt: Date.now(),
|
||||
lastReleasedAt: null,
|
||||
};
|
||||
|
||||
window.once("closed", () => {
|
||||
records.delete(key);
|
||||
});
|
||||
page.once("close", () => {
|
||||
records.delete(key);
|
||||
});
|
||||
|
||||
records.set(key, record);
|
||||
return createLease(record);
|
||||
}
|
||||
|
||||
export async function releaseHiddenPlaywrightPage(accountId: string, targetURL: string): Promise<void> {
|
||||
const key = hiddenPlaywrightKey(accountId, targetURL);
|
||||
const record = records.get(key);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.retainCount > 0) {
|
||||
record.retainCount -= 1;
|
||||
}
|
||||
if (record.retainCount === 0) {
|
||||
record.lastReleasedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
export function reapIdleHiddenPlaywrightPages(
|
||||
now = Date.now(),
|
||||
idleTTLms = hiddenPlaywrightIdleTTLms,
|
||||
): string[] {
|
||||
const reclaimed: string[] = [];
|
||||
|
||||
for (const [key, record] of records.entries()) {
|
||||
if (record.retainCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idleSince = record.lastReleasedAt ?? record.activatedAt;
|
||||
if (now - idleSince < idleTTLms) {
|
||||
continue;
|
||||
}
|
||||
|
||||
closeRecord(record);
|
||||
records.delete(key);
|
||||
reclaimed.push(key);
|
||||
}
|
||||
|
||||
return reclaimed;
|
||||
}
|
||||
|
||||
export function getHiddenPlaywrightSnapshot() {
|
||||
return {
|
||||
cdpPort,
|
||||
cdpEndpointURL,
|
||||
connected: Boolean(connectedBrowser?.isConnected()),
|
||||
pageCount: records.size,
|
||||
idleTTLms: hiddenPlaywrightIdleTTLms,
|
||||
records: [...records.values()].map((record) => ({
|
||||
accountId: record.accountId,
|
||||
title: record.title,
|
||||
targetURL: record.targetURL,
|
||||
retainCount: record.retainCount,
|
||||
activatedAt: record.activatedAt,
|
||||
lastReleasedAt: record.lastReleasedAt,
|
||||
windowDestroyed: record.window.isDestroyed(),
|
||||
pageClosed: record.page.isClosed(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createLease(record: HiddenPlaywrightRecord): HiddenPlaywrightLease {
|
||||
return {
|
||||
accountId: record.accountId,
|
||||
browser: record.browser,
|
||||
page: record.page,
|
||||
release: () => releaseHiddenPlaywrightPage(record.accountId, record.targetURL),
|
||||
};
|
||||
}
|
||||
|
||||
function createHiddenWindow(title: string, session: Session): ElectronBrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1320,
|
||||
height: 900,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea",
|
||||
title,
|
||||
webPreferences: {
|
||||
session,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
window.webContents.setUserAgent(STANDARD_USER_AGENT);
|
||||
void window.loadURL("about:blank");
|
||||
return window;
|
||||
}
|
||||
|
||||
async function ensureRecordTarget(record: HiddenPlaywrightRecord, targetURL: string): Promise<void> {
|
||||
record.targetURL = targetURL;
|
||||
|
||||
const currentURL = safePageURL(record.page);
|
||||
if (currentURL === targetURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
await record.page.goto(targetURL, {
|
||||
timeout: defaultPageTimeoutMs,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
}
|
||||
|
||||
async function connectBrowserOverCDP(): Promise<PlaywrightBrowser> {
|
||||
if (connectedBrowser?.isConnected()) {
|
||||
return connectedBrowser;
|
||||
}
|
||||
if (connectPromise) {
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
connectPromise = (async () => {
|
||||
await waitForCDPEndpoint();
|
||||
const { chromium } = await import("playwright-core");
|
||||
const browser = await chromium.connectOverCDP(cdpEndpointURL, {
|
||||
timeout: defaultConnectTimeoutMs,
|
||||
});
|
||||
|
||||
browser.on("disconnected", () => {
|
||||
connectedBrowser = null;
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
connectedBrowser = browser;
|
||||
return browser;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await connectPromise;
|
||||
} finally {
|
||||
if (!connectedBrowser?.isConnected()) {
|
||||
connectPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCDPEndpoint(timeoutMs = defaultConnectTimeoutMs): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`${cdpEndpointURL}/json/version`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore and continue polling.
|
||||
}
|
||||
|
||||
await wait(200);
|
||||
}
|
||||
|
||||
throw new Error("playwright_cdp_endpoint_unavailable");
|
||||
}
|
||||
|
||||
async function waitForNewPage(
|
||||
browser: PlaywrightBrowser,
|
||||
knownPages: Set<PlaywrightPage>,
|
||||
): Promise<PlaywrightPage> {
|
||||
const context = defaultContext(browser);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < defaultPageTimeoutMs) {
|
||||
const nextPage = context.pages().find((page) => !knownPages.has(page));
|
||||
if (nextPage) {
|
||||
return nextPage;
|
||||
}
|
||||
await wait(100);
|
||||
}
|
||||
|
||||
throw new Error("playwright_hidden_page_unavailable");
|
||||
}
|
||||
|
||||
function defaultContext(browser: PlaywrightBrowser) {
|
||||
const context = browser.contexts()[0];
|
||||
if (!context) {
|
||||
throw new Error("playwright_default_context_unavailable");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function hiddenPlaywrightKey(accountId: string, targetURL: string): string {
|
||||
return `${accountId}::${targetURL}`;
|
||||
}
|
||||
|
||||
function safePageURL(page: PlaywrightPage): string {
|
||||
try {
|
||||
return page.url();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function closeRecord(record: HiddenPlaywrightRecord): void {
|
||||
try {
|
||||
record.page.close().catch(() => undefined);
|
||||
} catch {
|
||||
// Ignore and fall through to window close.
|
||||
}
|
||||
|
||||
if (!record.window.isDestroyed()) {
|
||||
record.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
function wait(timeMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user