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:
@@ -37,6 +37,7 @@ import {
|
||||
normalizeAccountPlatformUid,
|
||||
sameAccountPlatformUid,
|
||||
} from "../shared/account-identity";
|
||||
import type { AccountHealthProfile, AuthProbeResult } from "./auth-types";
|
||||
|
||||
interface DetectedAccount {
|
||||
platformUid: string;
|
||||
@@ -57,7 +58,7 @@ interface PublishPlatformBindingDefinition {
|
||||
detect(context: DetectContext): Promise<DetectedAccount | null>;
|
||||
}
|
||||
|
||||
interface PublishAccountIdentity {
|
||||
export interface PublishAccountIdentity {
|
||||
id: string;
|
||||
platform: string;
|
||||
platformUid: string;
|
||||
@@ -689,6 +690,7 @@ interface GenericAIPageState {
|
||||
strongAuthSignalCount: number;
|
||||
loginSignalCount: number;
|
||||
loggedOutSignalCount: number;
|
||||
challengeSignalCount: number;
|
||||
}
|
||||
|
||||
async function readGenericAIPageState(
|
||||
@@ -721,6 +723,7 @@ async function readGenericAIPageState(
|
||||
const stopWords = /^(登录|注册|设置|帮助|历史|聊天|新建|菜单|账户|账号|个人中心|联网搜索|深度思考|工具|更多|安装电脑版|下载电脑版|DeepSeek|元宝|混元|豆包|Kimi|Qwen|通义千问)$/i;
|
||||
const loginPattern = /(登录|注册|立即登录|扫码登录|微信登录|QQ登录|手机号登录|请先登录)/i;
|
||||
const loggedOutPattern = /(未登录|扫码登录|快捷登录|使用其他头像、昵称或账号|登录后继续|请先登录|立即登录|微信快捷登录)/i;
|
||||
const challengePattern = /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|手机验证码|扫码验证|verify|verification|captcha|security check)/i;
|
||||
const credentialKeyPattern = /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i;
|
||||
const isInsideAuthSurface = (node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
@@ -850,6 +853,26 @@ async function readGenericAIPageState(
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectChallengeSignals = () => {
|
||||
const matches = [];
|
||||
if (bodyText && challengePattern.test(bodyText)) {
|
||||
matches.push('body-text');
|
||||
}
|
||||
const selectors = ['button', 'a', '[role="button"]', '[role="dialog"]', 'input', 'label'];
|
||||
for (const selector of selectors) {
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (!isVisible(node)) continue;
|
||||
const text = normalize(node.textContent || node.getAttribute?.('aria-label') || "");
|
||||
if (!text || !challengePattern.test(text)) continue;
|
||||
matches.push(text);
|
||||
if (matches.length >= 4) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectCredentialStorage = (storage, prefix) => {
|
||||
const items = [];
|
||||
try {
|
||||
@@ -902,6 +925,7 @@ async function readGenericAIPageState(
|
||||
}
|
||||
const loginSignals = collectLoginSignals();
|
||||
const loggedOutSignals = collectLoggedOutSignals();
|
||||
const challengeSignals = collectChallengeSignals();
|
||||
const credentialFingerprintParts = [
|
||||
...collectCredentialStorage(window.localStorage, 'local'),
|
||||
...collectCredentialStorage(window.sessionStorage, 'session'),
|
||||
@@ -924,6 +948,7 @@ async function readGenericAIPageState(
|
||||
strongAuthSignalCount: strongSignals.length,
|
||||
loginSignalCount: loginSignals.length,
|
||||
loggedOutSignalCount: loggedOutSignals.length,
|
||||
challengeSignalCount: challengeSignals.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -955,6 +980,10 @@ async function readGenericAIPageState(
|
||||
typeof typed.loggedOutSignalCount === "number" && Number.isFinite(typed.loggedOutSignalCount)
|
||||
? typed.loggedOutSignalCount
|
||||
: 0,
|
||||
challengeSignalCount:
|
||||
typeof typed.challengeSignalCount === "number" && Number.isFinite(typed.challengeSignalCount)
|
||||
? typed.challengeSignalCount
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1285,6 +1314,29 @@ function toPublishAccountProfile(detected: DetectedAccount | null): PublishAccou
|
||||
};
|
||||
}
|
||||
|
||||
function toAccountHealthProfile(profile: PublishAccountProfile | null): AccountHealthProfile | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
subjectUid: profile.platformUid,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackAccountHealthProfile(account: PublishAccountIdentity, overrides: {
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
} = {}): AccountHealthProfile {
|
||||
return {
|
||||
subjectUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||||
displayName: overrides.displayName ?? account.displayName,
|
||||
avatarUrl: overrides.avatarUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePublishAccountProfileFromHandle(
|
||||
account: PublishAccountIdentity,
|
||||
handle: SessionHandle,
|
||||
@@ -1332,6 +1384,328 @@ async function resolvePublishAccountProfileFromHandle(
|
||||
return toPublishAccountProfile(detected);
|
||||
}
|
||||
|
||||
export async function probePublishAccountSession(
|
||||
account: PublishAccountIdentity,
|
||||
partition?: string,
|
||||
): Promise<AuthProbeResult> {
|
||||
const handle = partition
|
||||
? createSessionHandleForPartition(account.id, partition)
|
||||
: await findLocalPublishAccountSessionHandle(account);
|
||||
|
||||
if (!handle) {
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "missing_partition",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
const definition = platformBindingDefinitions[account.platform];
|
||||
if (!definition) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
platform: account.platform,
|
||||
code: "desktop_platform_not_supported",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const window = createBoundWindow(
|
||||
`${definition.label} 静默探针`,
|
||||
definition.consoleUrl,
|
||||
handle.session,
|
||||
{ show: false },
|
||||
);
|
||||
|
||||
let mainFrameLoadError:
|
||||
| {
|
||||
code: number;
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
| undefined;
|
||||
window.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame && errorCode !== -3) {
|
||||
mainFrameLoadError = {
|
||||
code: errorCode,
|
||||
description: errorDescription,
|
||||
url: validatedURL,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForWindowNavigationSettled(window);
|
||||
if (window.isDestroyed()) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
const loadError = mainFrameLoadError;
|
||||
if (loadError) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
errorCode: loadError.code,
|
||||
errorDescription: loadError.description,
|
||||
url: loadError.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentURL = window.webContents.getURL();
|
||||
if (!currentURL || currentURL.startsWith("data:text/html")) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeLoginRedirect(currentURL, definition.loginUrl)) {
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "login_redirect",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (account.platform === "toutiaohao") {
|
||||
const detected = await detectToutiaoFromSession(handle.session);
|
||||
if (detected) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||||
sessionFingerprint: detected.platformUid,
|
||||
};
|
||||
}
|
||||
|
||||
const consoleReady = await verifyToutiaoConsoleAccess(window, handle.session);
|
||||
if (consoleReady) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: fallbackAccountHealthProfile(account),
|
||||
sessionFingerprint: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "login_redirect",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isAIPlatformBinding(account.platform)) {
|
||||
const pageState = await readGenericAIPageState(window.webContents);
|
||||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||
return {
|
||||
verdict: "challenge_required",
|
||||
reason: "captcha_gate",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
challengeSignalCount: pageState?.challengeSignalCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const detected = await detectGenericAIPlatform(account.platform, definition.label, {
|
||||
session: handle.session,
|
||||
webContents: window.webContents,
|
||||
});
|
||||
if (detected) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||||
sessionFingerprint: detected.platformUid,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pageState) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pageState.loggedOutSignalCount > 0 || looksLikeLoginRedirect(currentURL, definition.loginUrl)) {
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "login_redirect",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ((pageState.loginSignalCount ?? 0) > 0 && (pageState.strongAuthSignalCount ?? 0) === 0) {
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "login_redirect",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
loginSignalCount: pageState.loginSignalCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fingerprint = await buildGenericAISessionFingerprint(account.platform, handle.session, pageState);
|
||||
if (genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: fallbackAccountHealthProfile(account, {
|
||||
displayName: pageState.displayName ?? account.displayName,
|
||||
avatarUrl: pageState.avatarUrl ?? null,
|
||||
}),
|
||||
sessionFingerprint: fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: fingerprint,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
strongAuthSignalCount: pageState.strongAuthSignalCount,
|
||||
loginSignalCount: pageState.loginSignalCount,
|
||||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const detected = await definition.detect({
|
||||
session: handle.session,
|
||||
webContents: window.webContents,
|
||||
});
|
||||
if (detected) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||||
};
|
||||
}
|
||||
|
||||
const consoleReady = await verifyPublishAccountConsoleAccess(account, handle);
|
||||
if (consoleReady) {
|
||||
return {
|
||||
verdict: "active",
|
||||
reason: "probe_success",
|
||||
profile: fallbackAccountHealthProfile(account),
|
||||
sessionFingerprint: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: "expired",
|
||||
reason: "login_redirect",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function silentRefreshPublishAccountSession(
|
||||
account: PublishAccountIdentity,
|
||||
partition?: string,
|
||||
): Promise<boolean> {
|
||||
const handle = partition
|
||||
? createSessionHandleForPartition(account.id, partition)
|
||||
: await findLocalPublishAccountSessionHandle(account);
|
||||
|
||||
if (!handle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const definition = platformBindingDefinitions[account.platform];
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const window = createBoundWindow(
|
||||
`${definition.label} 静默续期`,
|
||||
definition.consoleUrl,
|
||||
handle.session,
|
||||
{ show: false },
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForWindowNavigationSettled(window, 10_000);
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await sleep(1_200);
|
||||
await flushSessionPersistence(handle.session);
|
||||
return !looksLikeLoginRedirect(window.webContents.getURL(), definition.loginUrl);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectPublishAccountLocalSession(
|
||||
account: PublishAccountIdentity,
|
||||
): Promise<PublishAccountLocalInspection> {
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { app } from "electron/main";
|
||||
import type { DesktopAccountInfo } from "@geo/shared-types";
|
||||
|
||||
import type {
|
||||
AccountAuthReason,
|
||||
AccountAuthState,
|
||||
AccountHealthProfile,
|
||||
AccountHealthSnapshot,
|
||||
AccountProbeState,
|
||||
AuthProbeResult,
|
||||
PlatformFailureClassification,
|
||||
} from "./auth-types";
|
||||
import { emitRuntimeInvalidated } from "./runtime-events";
|
||||
import { getPersistedPartition, getSessionHandle } from "./session-registry";
|
||||
import {
|
||||
getPlatformAdapter,
|
||||
type PlatformFailureInput,
|
||||
} from "./platform-auth-adapters";
|
||||
import type { PublishAccountIdentity } from "./account-binder";
|
||||
|
||||
const PROBE_TICK_MS = 10_000;
|
||||
const FIRST_PROBE_MIN_MS = 5_000;
|
||||
const FIRST_PROBE_MAX_MS = 15_000;
|
||||
const REGULAR_PROBE_MIN_MS = 30 * 60_000;
|
||||
const REGULAR_PROBE_MAX_MS = 60 * 60_000;
|
||||
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const;
|
||||
const NETWORK_RETRY_MS = 2 * 60_000;
|
||||
const STALE_AFTER_MS = 45 * 60_000;
|
||||
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000;
|
||||
|
||||
interface PersistedAccountHealthRecord {
|
||||
accountId: string;
|
||||
platform: string;
|
||||
lastVerifiedAt: number | null;
|
||||
lastAuthFailureAt: number | null;
|
||||
profile: AccountHealthProfile | null;
|
||||
sessionFingerprint: string | null;
|
||||
}
|
||||
|
||||
interface AccountHealthRecord {
|
||||
accountId: string;
|
||||
platform: string;
|
||||
authState: AccountAuthState;
|
||||
probeState: AccountProbeState;
|
||||
authReason: AccountAuthReason;
|
||||
lastVerifiedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
nextProbeAt: number | null;
|
||||
lastAuthFailureAt: number | null;
|
||||
profile: AccountHealthProfile | null;
|
||||
sessionFingerprint: string | null;
|
||||
suspectedExpiredAt: number | null;
|
||||
confirmFailureCount: number;
|
||||
}
|
||||
|
||||
const trackedAccounts = new Map<string, PublishAccountIdentity>();
|
||||
const records = new Map<string, AccountHealthRecord>();
|
||||
const accountLocks = new Map<string, Promise<void>>();
|
||||
let initialized = false;
|
||||
let probeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return min + Math.floor(Math.random() * (max - min));
|
||||
}
|
||||
|
||||
function persistedHealthPath(): string {
|
||||
return join(app.getPath("userData"), "desktop-account-health.json");
|
||||
}
|
||||
|
||||
function resolvePartition(accountId: string): string {
|
||||
return getSessionHandle(accountId)?.partition ?? getPersistedPartition(accountId) ?? `persist:acc-${accountId}`;
|
||||
}
|
||||
|
||||
function nextInitialProbeAt(now = Date.now()): number {
|
||||
return now + randomInt(FIRST_PROBE_MIN_MS, FIRST_PROBE_MAX_MS);
|
||||
}
|
||||
|
||||
function nextRegularProbeAt(now = Date.now()): number {
|
||||
return now + randomInt(REGULAR_PROBE_MIN_MS, REGULAR_PROBE_MAX_MS);
|
||||
}
|
||||
|
||||
function normalizePersisted(
|
||||
record: PersistedAccountHealthRecord,
|
||||
): AccountHealthRecord {
|
||||
return {
|
||||
accountId: record.accountId,
|
||||
platform: record.platform,
|
||||
authState: "unknown",
|
||||
probeState: "idle",
|
||||
authReason: null,
|
||||
lastVerifiedAt: record.lastVerifiedAt ?? null,
|
||||
lastProbeAt: null,
|
||||
nextProbeAt: nextInitialProbeAt(),
|
||||
lastAuthFailureAt: record.lastAuthFailureAt ?? null,
|
||||
profile: record.profile ?? null,
|
||||
sessionFingerprint: record.sessionFingerprint ?? null,
|
||||
suspectedExpiredAt: null,
|
||||
confirmFailureCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function loadPersistedRecords(): void {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(persistedHealthPath(), "utf8")) as PersistedAccountHealthRecord[];
|
||||
for (const item of raw) {
|
||||
if (!item?.accountId || !item?.platform) {
|
||||
continue;
|
||||
}
|
||||
records.set(item.accountId, normalizePersisted(item));
|
||||
}
|
||||
} catch {
|
||||
records.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function persistRecords(): void {
|
||||
const payload: PersistedAccountHealthRecord[] = [...records.values()].map((record) => ({
|
||||
accountId: record.accountId,
|
||||
platform: record.platform,
|
||||
lastVerifiedAt: record.lastVerifiedAt,
|
||||
lastAuthFailureAt: record.lastAuthFailureAt,
|
||||
profile: record.profile,
|
||||
sessionFingerprint: record.sessionFingerprint,
|
||||
}));
|
||||
|
||||
const target = persistedHealthPath();
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, JSON.stringify(payload, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function ensureInitialized(): void {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
loadPersistedRecords();
|
||||
}
|
||||
|
||||
function ensureProbeTimer(): void {
|
||||
ensureInitialized();
|
||||
if (probeTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
probeTimer = setInterval(() => {
|
||||
void runDueProbes();
|
||||
}, PROBE_TICK_MS);
|
||||
}
|
||||
|
||||
function coerceDerivedAuthState(record: AccountHealthRecord, now = Date.now()): AccountAuthState {
|
||||
if (
|
||||
record.authState === "active"
|
||||
&& record.lastVerifiedAt
|
||||
&& now - record.lastVerifiedAt >= STALE_AFTER_MS
|
||||
) {
|
||||
return "expiring_soon";
|
||||
}
|
||||
|
||||
return record.authState;
|
||||
}
|
||||
|
||||
function toPublicSnapshot(record: AccountHealthRecord): AccountHealthSnapshot {
|
||||
return {
|
||||
accountId: record.accountId,
|
||||
platform: record.platform,
|
||||
partition: resolvePartition(record.accountId),
|
||||
authState: coerceDerivedAuthState(record),
|
||||
probeState: record.probeState,
|
||||
authReason: record.authReason,
|
||||
lastVerifiedAt: record.lastVerifiedAt,
|
||||
lastProbeAt: record.lastProbeAt,
|
||||
nextProbeAt: record.nextProbeAt,
|
||||
lastAuthFailureAt: record.lastAuthFailureAt,
|
||||
profile: record.profile,
|
||||
sessionFingerprint: record.sessionFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotSignature(record: AccountHealthRecord): string {
|
||||
return JSON.stringify(toPublicSnapshot(record));
|
||||
}
|
||||
|
||||
function commitRecord(record: AccountHealthRecord, previousSignature: string | null): AccountHealthSnapshot {
|
||||
records.set(record.accountId, record);
|
||||
persistRecords();
|
||||
|
||||
const nextSignature = snapshotSignature(record);
|
||||
if (previousSignature !== nextSignature) {
|
||||
emitRuntimeInvalidated("account-health");
|
||||
}
|
||||
|
||||
return toPublicSnapshot(record);
|
||||
}
|
||||
|
||||
function ensureRecord(account: PublishAccountIdentity): AccountHealthRecord {
|
||||
ensureInitialized();
|
||||
|
||||
const existing = records.get(account.id);
|
||||
if (existing) {
|
||||
existing.platform = account.platform;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: AccountHealthRecord = {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
authState: "unknown",
|
||||
probeState: "idle",
|
||||
authReason: null,
|
||||
lastVerifiedAt: null,
|
||||
lastProbeAt: null,
|
||||
nextProbeAt: nextInitialProbeAt(),
|
||||
lastAuthFailureAt: null,
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
suspectedExpiredAt: null,
|
||||
confirmFailureCount: 0,
|
||||
};
|
||||
|
||||
records.set(account.id, created);
|
||||
persistRecords();
|
||||
return created;
|
||||
}
|
||||
|
||||
async function withAccountLock<T>(accountId: string, work: () => Promise<T>): Promise<T> {
|
||||
const previous = accountLocks.get(accountId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
accountLocks.set(accountId, current);
|
||||
await previous.catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
release();
|
||||
if (accountLocks.get(accountId) === current) {
|
||||
accountLocks.delete(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBackoffMs(failureCount: number): number {
|
||||
const index = Math.min(Math.max(failureCount - 1, 0), CONFIRM_BACKOFF_STEPS_MS.length - 1);
|
||||
return CONFIRM_BACKOFF_STEPS_MS[index] ?? CONFIRM_BACKOFF_STEPS_MS[CONFIRM_BACKOFF_STEPS_MS.length - 1];
|
||||
}
|
||||
|
||||
function applyActiveResult(
|
||||
record: AccountHealthRecord,
|
||||
result: AuthProbeResult,
|
||||
now: number,
|
||||
): AccountHealthSnapshot {
|
||||
const previousSignature = snapshotSignature(record);
|
||||
|
||||
record.authState = "active";
|
||||
record.probeState = "idle";
|
||||
record.authReason = result.reason ?? "probe_success";
|
||||
record.lastVerifiedAt = now;
|
||||
record.lastProbeAt = now;
|
||||
record.nextProbeAt = nextRegularProbeAt(now);
|
||||
record.profile = result.profile ?? record.profile;
|
||||
record.sessionFingerprint = result.sessionFingerprint ?? record.sessionFingerprint;
|
||||
record.suspectedExpiredAt = null;
|
||||
record.confirmFailureCount = 0;
|
||||
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
function applyExpiredResult(
|
||||
record: AccountHealthRecord,
|
||||
result: AuthProbeResult,
|
||||
now: number,
|
||||
): AccountHealthSnapshot {
|
||||
const previousSignature = snapshotSignature(record);
|
||||
|
||||
record.authState = "expired";
|
||||
record.probeState = "idle";
|
||||
record.authReason = result.reason ?? "login_redirect";
|
||||
record.lastProbeAt = now;
|
||||
record.nextProbeAt = nextRegularProbeAt(now);
|
||||
record.lastAuthFailureAt = now;
|
||||
record.suspectedExpiredAt = null;
|
||||
record.confirmFailureCount = 0;
|
||||
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
function applyChallengeResult(
|
||||
record: AccountHealthRecord,
|
||||
result: AuthProbeResult,
|
||||
now: number,
|
||||
): AccountHealthSnapshot {
|
||||
const previousSignature = snapshotSignature(record);
|
||||
|
||||
record.authState = "challenge_required";
|
||||
record.probeState = "idle";
|
||||
record.authReason = result.reason ?? "captcha_gate";
|
||||
record.lastProbeAt = now;
|
||||
record.nextProbeAt = nextRegularProbeAt(now);
|
||||
record.lastAuthFailureAt = now;
|
||||
record.suspectedExpiredAt = null;
|
||||
record.confirmFailureCount = 0;
|
||||
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
function applyNetworkResult(
|
||||
record: AccountHealthRecord,
|
||||
result: AuthProbeResult,
|
||||
now: number,
|
||||
trigger: "scheduled" | "manual" | "preflight" | "confirm",
|
||||
): AccountHealthSnapshot {
|
||||
const previousSignature = snapshotSignature(record);
|
||||
const hadSuspicion = Boolean(record.suspectedExpiredAt);
|
||||
|
||||
record.probeState = "network_error";
|
||||
record.authReason = result.reason ?? "network_error";
|
||||
record.lastProbeAt = now;
|
||||
if (hadSuspicion && trigger === "confirm") {
|
||||
record.confirmFailureCount += 1;
|
||||
record.authState = record.authState === "unknown" ? "unknown" : "expiring_soon";
|
||||
record.nextProbeAt = now + confirmBackoffMs(record.confirmFailureCount);
|
||||
} else {
|
||||
record.nextProbeAt = now + NETWORK_RETRY_MS;
|
||||
if (record.authState === "active") {
|
||||
record.authState = "expiring_soon";
|
||||
}
|
||||
}
|
||||
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
async function performProbeLocked(
|
||||
account: PublishAccountIdentity,
|
||||
options: {
|
||||
trigger: "scheduled" | "manual" | "preflight" | "confirm";
|
||||
allowSilentRefresh: boolean;
|
||||
},
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
const record = ensureRecord(account);
|
||||
const adapter = getPlatformAdapter(account.platform);
|
||||
const previousSignature = snapshotSignature(record);
|
||||
|
||||
record.probeState = "probing";
|
||||
commitRecord(record, previousSignature);
|
||||
|
||||
const partition = resolvePartition(account.id);
|
||||
|
||||
if (options.allowSilentRefresh) {
|
||||
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false);
|
||||
if (!refreshed) {
|
||||
const currentSignature = snapshotSignature(record);
|
||||
record.authReason = "silent_refresh_failed";
|
||||
commitRecord(record, currentSignature);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await adapter.probe(account, partition).catch<AuthProbeResult>(() => ({
|
||||
verdict: "network_error",
|
||||
reason: "network_error",
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
}));
|
||||
|
||||
const now = Date.now();
|
||||
switch (result.verdict) {
|
||||
case "active":
|
||||
return applyActiveResult(record, result, now);
|
||||
case "expired":
|
||||
return applyExpiredResult(record, result, now);
|
||||
case "challenge_required":
|
||||
return applyChallengeResult(record, result, now);
|
||||
default:
|
||||
return applyNetworkResult(record, result, now, options.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDueProbes(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
||||
const record = records.get(account.id);
|
||||
return Boolean(record?.nextProbeAt && record.nextProbeAt <= now);
|
||||
});
|
||||
|
||||
for (const account of dueAccounts) {
|
||||
const record = records.get(account.id);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
|
||||
void withAccountLock(account.id, async () => {
|
||||
await performProbeLocked(account, {
|
||||
trigger,
|
||||
allowSilentRefresh: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseVerifiedAt(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function fallbackAuthState(
|
||||
health: DesktopAccountInfo["health"] | null | undefined,
|
||||
): AccountAuthState {
|
||||
switch (health) {
|
||||
case "live":
|
||||
return "unknown";
|
||||
case "captcha":
|
||||
return "challenge_required";
|
||||
case "expired":
|
||||
return "expired";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function initAccountHealth(): void {
|
||||
ensureInitialized();
|
||||
ensureProbeTimer();
|
||||
}
|
||||
|
||||
export function syncTrackedAccounts(accounts: PublishAccountIdentity[]): void {
|
||||
syncTrackedAccountsInternal(accounts, true);
|
||||
}
|
||||
|
||||
function syncTrackedAccountsInternal(
|
||||
accounts: PublishAccountIdentity[],
|
||||
pruneMissing: boolean,
|
||||
): void {
|
||||
initAccountHealth();
|
||||
let removed = false;
|
||||
|
||||
if (pruneMissing) {
|
||||
const nextIds = new Set(accounts.map((account) => account.id));
|
||||
for (const [accountId] of trackedAccounts.entries()) {
|
||||
if (!nextIds.has(accountId)) {
|
||||
trackedAccounts.delete(accountId);
|
||||
records.delete(accountId);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
trackedAccounts.set(account.id, account);
|
||||
ensureRecord(account);
|
||||
}
|
||||
|
||||
persistRecords();
|
||||
if (removed) {
|
||||
emitRuntimeInvalidated("account-health");
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetTrackedAccountHealth(
|
||||
accountId: string,
|
||||
): void {
|
||||
trackedAccounts.delete(accountId);
|
||||
if (records.delete(accountId)) {
|
||||
persistRecords();
|
||||
emitRuntimeInvalidated("account-health");
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountHealthSnapshot(accountId: string): AccountHealthSnapshot | null {
|
||||
ensureInitialized();
|
||||
const record = records.get(accountId);
|
||||
return record ? toPublicSnapshot(record) : null;
|
||||
}
|
||||
|
||||
export function getProjectedAccountHealth(input: {
|
||||
accountId: string;
|
||||
platform: string;
|
||||
health?: DesktopAccountInfo["health"];
|
||||
verifiedAt?: string | null;
|
||||
}) {
|
||||
const snapshot = getAccountHealthSnapshot(input.accountId);
|
||||
if (!snapshot) {
|
||||
return {
|
||||
health: input.health ?? "risk",
|
||||
authState: fallbackAuthState(input.health),
|
||||
probeState: "idle" as AccountProbeState,
|
||||
authReason: null as AccountAuthReason,
|
||||
lastVerifiedAt: parseVerifiedAt(input.verifiedAt),
|
||||
lastProbeAt: null,
|
||||
nextProbeAt: null,
|
||||
lastAuthFailureAt: null,
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
partition: resolvePartition(input.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
const health =
|
||||
snapshot.authState === "active"
|
||||
? ("live" as const)
|
||||
: snapshot.authState === "challenge_required"
|
||||
? ("captcha" as const)
|
||||
: snapshot.authState === "expired" || snapshot.authState === "revoked"
|
||||
? ("expired" as const)
|
||||
: ("risk" as const);
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeTrackedAccount(
|
||||
account: PublishAccountIdentity,
|
||||
options: {
|
||||
trigger?: "scheduled" | "manual" | "preflight" | "confirm";
|
||||
allowSilentRefresh?: boolean;
|
||||
} = {},
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
syncTrackedAccountsInternal([account], false);
|
||||
return await withAccountLock(account.id, async () => {
|
||||
return await performProbeLocked(account, {
|
||||
trigger: options.trigger ?? "manual",
|
||||
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureAccountReady(
|
||||
account: PublishAccountIdentity,
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
syncTrackedAccountsInternal([account], false);
|
||||
const snapshot = getAccountHealthSnapshot(account.id);
|
||||
const isFresh =
|
||||
snapshot?.lastVerifiedAt
|
||||
? Date.now() - snapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
|
||||
: false;
|
||||
|
||||
if (
|
||||
snapshot
|
||||
&& snapshot.authState === "active"
|
||||
&& snapshot.probeState !== "network_error"
|
||||
&& isFresh
|
||||
) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (snapshot && ["expired", "challenge_required", "revoked"].includes(snapshot.authState)) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return await withAccountLock(account.id, async () => {
|
||||
const lockedSnapshot = getAccountHealthSnapshot(account.id);
|
||||
const lockedFresh =
|
||||
lockedSnapshot?.lastVerifiedAt
|
||||
? Date.now() - lockedSnapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
|
||||
: false;
|
||||
|
||||
if (
|
||||
lockedSnapshot
|
||||
&& lockedSnapshot.authState === "active"
|
||||
&& lockedSnapshot.probeState !== "network_error"
|
||||
&& lockedFresh
|
||||
) {
|
||||
return lockedSnapshot;
|
||||
}
|
||||
|
||||
return await performProbeLocked(account, {
|
||||
trigger: "preflight",
|
||||
allowSilentRefresh: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportAccountFailure(
|
||||
account: PublishAccountIdentity,
|
||||
input: PlatformFailureInput,
|
||||
): Promise<AccountHealthSnapshot | null> {
|
||||
syncTrackedAccountsInternal([account], false);
|
||||
const adapter = getPlatformAdapter(account.platform);
|
||||
const classification = adapter.classifyFailure(input);
|
||||
|
||||
if (classification === "ok") {
|
||||
return getAccountHealthSnapshot(account.id);
|
||||
}
|
||||
|
||||
return await withAccountLock(account.id, async () => {
|
||||
const record = ensureRecord(account);
|
||||
const previousSignature = snapshotSignature(record);
|
||||
const now = Date.now();
|
||||
|
||||
if (classification === "challenge") {
|
||||
record.authState = "challenge_required";
|
||||
record.probeState = "idle";
|
||||
record.authReason = "captcha_gate";
|
||||
record.lastAuthFailureAt = now;
|
||||
record.lastProbeAt = now;
|
||||
record.nextProbeAt = nextRegularProbeAt(now);
|
||||
record.suspectedExpiredAt = null;
|
||||
record.confirmFailureCount = 0;
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
if (classification === "network") {
|
||||
record.probeState = "network_error";
|
||||
record.authReason = "network_error";
|
||||
record.lastProbeAt = now;
|
||||
record.nextProbeAt = now + NETWORK_RETRY_MS;
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
record.lastAuthFailureAt = now;
|
||||
record.suspectedExpiredAt = record.suspectedExpiredAt ?? now;
|
||||
if (record.authState === "active") {
|
||||
record.authState = "expiring_soon";
|
||||
}
|
||||
record.nextProbeAt = now;
|
||||
commitRecord(record, previousSignature);
|
||||
|
||||
return await performProbeLocked(account, {
|
||||
trigger: "confirm",
|
||||
allowSilentRefresh: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DesktopArticleContent, JsonValue } from "@geo/shared-types";
|
||||
import type { Session, WebContentsView } from "electron";
|
||||
import type { Browser as PlaywrightBrowser, Page as PlaywrightPage } from "playwright-core";
|
||||
|
||||
export type AdapterTaskPhase = "initial" | "resume";
|
||||
export type AdapterCompletionStatus = "succeeded" | "failed" | "unknown";
|
||||
@@ -10,6 +11,10 @@ export interface AdapterContext {
|
||||
accountId: string;
|
||||
session: Session;
|
||||
view: WebContentsView | null;
|
||||
playwright: {
|
||||
browser: PlaywrightBrowser;
|
||||
page: PlaywrightPage;
|
||||
} | null;
|
||||
signal: AbortSignal;
|
||||
phase: AdapterTaskPhase;
|
||||
reportProgress(stage: string): void;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export type AccountAuthState =
|
||||
| "unknown"
|
||||
| "active"
|
||||
| "expiring_soon"
|
||||
| "challenge_required"
|
||||
| "expired"
|
||||
| "revoked";
|
||||
|
||||
export type AccountProbeState =
|
||||
| "idle"
|
||||
| "probing"
|
||||
| "network_error";
|
||||
|
||||
export type AccountAuthReason =
|
||||
| "bind_success"
|
||||
| "probe_success"
|
||||
| "login_redirect"
|
||||
| "http_401"
|
||||
| "captcha_gate"
|
||||
| "missing_partition"
|
||||
| "silent_refresh_failed"
|
||||
| "manual_unbind"
|
||||
| "too_many_failures"
|
||||
| "network_error"
|
||||
| null;
|
||||
|
||||
export interface AccountHealthProfile {
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
subjectUid: string | null;
|
||||
}
|
||||
|
||||
export interface AccountHealthSnapshot {
|
||||
accountId: string;
|
||||
platform: string;
|
||||
partition: string;
|
||||
authState: AccountAuthState;
|
||||
probeState: AccountProbeState;
|
||||
authReason: AccountAuthReason;
|
||||
lastVerifiedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
nextProbeAt: number | null;
|
||||
lastAuthFailureAt: number | null;
|
||||
profile: AccountHealthProfile | null;
|
||||
sessionFingerprint: string | null;
|
||||
}
|
||||
|
||||
export interface AuthProbeResult {
|
||||
verdict: "active" | "expired" | "challenge_required" | "network_error";
|
||||
reason?: AccountAuthReason;
|
||||
profile?: AccountHealthProfile | null;
|
||||
sessionFingerprint?: string | null;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PlatformFailureClassification =
|
||||
| "auth_failure"
|
||||
| "challenge"
|
||||
| "network"
|
||||
| "ok";
|
||||
|
||||
@@ -4,13 +4,17 @@ import { join } from "node:path";
|
||||
import { BrowserWindow, WebContentsView, app, ipcMain, nativeTheme } from "electron/main";
|
||||
import type {
|
||||
BrowserWindow as ElectronBrowserWindow,
|
||||
WebContents as ElectronWebContents,
|
||||
WebContentsView as ElectronWebContentsView,
|
||||
} from "electron/main";
|
||||
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types";
|
||||
|
||||
import { initAccountHealth } from "./account-health";
|
||||
import { bindPublishAccount, openPublishAccountConsole } from "./account-binder";
|
||||
import { initProcessMetricsSampler } from "./process-metrics";
|
||||
import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from "./playwright-cdp";
|
||||
import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
|
||||
import { onRuntimeInvalidated } from "./runtime-events";
|
||||
import {
|
||||
refreshRuntimeAccounts,
|
||||
releaseRuntimeSession,
|
||||
@@ -32,6 +36,7 @@ app.commandLine.appendSwitch(
|
||||
);
|
||||
app.commandLine.appendSwitch("disable-quic");
|
||||
app.commandLine.appendSwitch("disable-background-networking");
|
||||
app.commandLine.appendSwitch("remote-debugging-port", String(getPlaywrightCDPPort()));
|
||||
app.userAgentFallback = STANDARD_USER_AGENT;
|
||||
|
||||
console.info("[desktop-main] boot", {
|
||||
@@ -50,6 +55,7 @@ process.on("uncaughtException", (error) => {
|
||||
});
|
||||
|
||||
let mainWindow: ElectronBrowserWindow | null = null;
|
||||
let mainRendererContents: ElectronWebContents | null = null;
|
||||
let quitReleaseInFlight = false;
|
||||
|
||||
function rendererURL(): string | null {
|
||||
@@ -77,9 +83,15 @@ async function mountRendererView(window: ElectronBrowserWindow): Promise<void> {
|
||||
|
||||
view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
registerRendererDevtoolsProxyTarget(view.webContents);
|
||||
mainRendererContents = view.webContents;
|
||||
window.contentView.addChildView(view);
|
||||
syncViewBounds(window, view);
|
||||
window.on("resize", () => syncViewBounds(window, view));
|
||||
window.on("closed", () => {
|
||||
if (mainRendererContents === view.webContents) {
|
||||
mainRendererContents = null;
|
||||
}
|
||||
});
|
||||
|
||||
const devServerURL = rendererURL();
|
||||
if (devServerURL) {
|
||||
@@ -189,10 +201,18 @@ if (!initSingleInstance(() => {
|
||||
app.whenReady().then(async () => {
|
||||
registerBridgeHandlers();
|
||||
await initSessionRegistry();
|
||||
initAccountHealth();
|
||||
initTransport();
|
||||
initScheduler();
|
||||
initProcessMetricsSampler();
|
||||
startHotViewReaper();
|
||||
startHiddenPlaywrightReaper();
|
||||
onRuntimeInvalidated((event) => {
|
||||
if (!mainRendererContents || mainRendererContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
mainRendererContents.send("desktop:runtime-invalidated", event);
|
||||
});
|
||||
mainWindow = await createMainWindow();
|
||||
initTray(() => {
|
||||
if (!mainWindow) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface LeaseSnapshot {
|
||||
activeLeaseId: string | null;
|
||||
activeTaskId: string | null;
|
||||
activeTaskIds: string[];
|
||||
activeCount: number;
|
||||
attemptId: string | null;
|
||||
startedAt: number | null;
|
||||
leaseExpiresAt: number | null;
|
||||
@@ -13,11 +15,16 @@ interface ActiveLeaseState {
|
||||
taskId: string;
|
||||
attemptId: string | null;
|
||||
leaseExpiresAt: number | null;
|
||||
startedAt: number;
|
||||
lastExtendedAt: number | null;
|
||||
}
|
||||
|
||||
const activeLeases = new Map<string, ActiveLeaseState>();
|
||||
const leaseState: LeaseSnapshot = {
|
||||
activeLeaseId: null,
|
||||
activeTaskId: null,
|
||||
activeTaskIds: [],
|
||||
activeCount: 0,
|
||||
attemptId: null,
|
||||
startedAt: null,
|
||||
leaseExpiresAt: null,
|
||||
@@ -45,8 +52,11 @@ export function setActiveLease(input: {
|
||||
leaseExpiresAt?: number | string | null;
|
||||
} | null): void {
|
||||
if (!input) {
|
||||
activeLeases.clear();
|
||||
leaseState.activeLeaseId = null;
|
||||
leaseState.activeTaskId = null;
|
||||
leaseState.activeTaskIds = [];
|
||||
leaseState.activeCount = 0;
|
||||
leaseState.attemptId = null;
|
||||
leaseState.startedAt = null;
|
||||
leaseState.leaseExpiresAt = null;
|
||||
@@ -59,28 +69,51 @@ export function setActiveLease(input: {
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId ?? null,
|
||||
leaseExpiresAt: normalizeLeaseExpiresAt(input.leaseExpiresAt),
|
||||
startedAt: Date.now(),
|
||||
lastExtendedAt: null,
|
||||
};
|
||||
|
||||
leaseState.activeLeaseId = normalized.taskId;
|
||||
leaseState.activeTaskId = normalized.taskId;
|
||||
leaseState.attemptId = normalized.attemptId;
|
||||
leaseState.startedAt = Date.now();
|
||||
leaseState.leaseExpiresAt = normalized.leaseExpiresAt;
|
||||
activeLeases.set(normalized.taskId, normalized);
|
||||
syncLeaseSnapshot();
|
||||
leaseState.lastOutcome = "leased";
|
||||
}
|
||||
|
||||
export function noteLeaseExtended(leaseExpiresAt?: number | string | null): void {
|
||||
export function noteLeaseExtended(
|
||||
leaseExpiresAt?: number | string | null,
|
||||
taskId?: string,
|
||||
): void {
|
||||
if (taskId) {
|
||||
const active = activeLeases.get(taskId);
|
||||
if (active) {
|
||||
active.leaseExpiresAt = normalizeLeaseExpiresAt(leaseExpiresAt) ?? active.leaseExpiresAt;
|
||||
active.lastExtendedAt = Date.now();
|
||||
activeLeases.set(taskId, active);
|
||||
}
|
||||
} else {
|
||||
const first = activeLeases.values().next().value as ActiveLeaseState | undefined;
|
||||
if (first) {
|
||||
first.leaseExpiresAt = normalizeLeaseExpiresAt(leaseExpiresAt) ?? first.leaseExpiresAt;
|
||||
first.lastExtendedAt = Date.now();
|
||||
activeLeases.set(first.taskId, first);
|
||||
}
|
||||
}
|
||||
|
||||
syncLeaseSnapshot();
|
||||
leaseState.lastExtendedAt = Date.now();
|
||||
leaseState.leaseExpiresAt = normalizeLeaseExpiresAt(leaseExpiresAt) ?? leaseState.leaseExpiresAt;
|
||||
leaseState.lastOutcome = "extended";
|
||||
}
|
||||
|
||||
export function noteLeaseReleased(reason: Exclude<LeaseSnapshot["lastOutcome"], "leased" | "extended" | null>): void {
|
||||
leaseState.activeLeaseId = null;
|
||||
leaseState.activeTaskId = null;
|
||||
leaseState.attemptId = null;
|
||||
leaseState.startedAt = null;
|
||||
leaseState.leaseExpiresAt = null;
|
||||
export function noteLeaseReleased(
|
||||
reason: Exclude<LeaseSnapshot["lastOutcome"], "leased" | "extended" | null>,
|
||||
taskId?: string,
|
||||
): void {
|
||||
if (taskId) {
|
||||
activeLeases.delete(taskId);
|
||||
} else {
|
||||
activeLeases.clear();
|
||||
}
|
||||
|
||||
syncLeaseSnapshot();
|
||||
leaseState.lastReleasedAt = Date.now();
|
||||
leaseState.lastOutcome = reason;
|
||||
}
|
||||
@@ -88,3 +121,15 @@ export function noteLeaseReleased(reason: Exclude<LeaseSnapshot["lastOutcome"],
|
||||
export function getLeaseManagerSnapshot(): LeaseSnapshot {
|
||||
return { ...leaseState };
|
||||
}
|
||||
|
||||
function syncLeaseSnapshot(): void {
|
||||
const first = activeLeases.values().next().value as ActiveLeaseState | undefined;
|
||||
|
||||
leaseState.activeTaskIds = [...activeLeases.keys()];
|
||||
leaseState.activeCount = activeLeases.size;
|
||||
leaseState.activeLeaseId = first?.taskId ?? null;
|
||||
leaseState.activeTaskId = first?.taskId ?? null;
|
||||
leaseState.attemptId = first?.attemptId ?? null;
|
||||
leaseState.startedAt = first?.startedAt ?? null;
|
||||
leaseState.leaseExpiresAt = first?.leaseExpiresAt ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { app } from "electron/main";
|
||||
import type { DesktopTaskEventMessage, DesktopTaskInfo, JsonValue } from "@geo/shared-types";
|
||||
|
||||
export type MonitorSchedulerRouting = "rabbitmq-primary" | "db-recovery";
|
||||
type MonitorTaskState = "queued" | "leasing" | "active";
|
||||
|
||||
export interface MonitorSchedulerTaskRecord {
|
||||
taskId: string;
|
||||
jobId: string;
|
||||
accountId: string;
|
||||
clientId: string;
|
||||
platform: string;
|
||||
routing: MonitorSchedulerRouting;
|
||||
state: MonitorTaskState;
|
||||
title: string | null;
|
||||
businessDate: string | null;
|
||||
questionKey: string | null;
|
||||
questionText: string | null;
|
||||
updatedAt: number;
|
||||
enqueuedAt: number;
|
||||
availableAt: number;
|
||||
lastSeenAt: number;
|
||||
lastLeaseAttemptAt: number | null;
|
||||
lastStartedAt: number | null;
|
||||
leaseMisses: number;
|
||||
}
|
||||
|
||||
interface PersistedMonitorSchedulerState {
|
||||
version: number;
|
||||
tasks: Record<string, MonitorSchedulerTaskRecord>;
|
||||
questionCooldowns: Record<string, number>;
|
||||
platformCooldowns: Record<string, number>;
|
||||
lastHydratedAt: number;
|
||||
}
|
||||
|
||||
export interface MonitorSchedulerSnapshot {
|
||||
hydratedAt: number | null;
|
||||
queueDepth: number;
|
||||
activeCount: number;
|
||||
leasingCount: number;
|
||||
queuedCount: number;
|
||||
questionCooldownCount: number;
|
||||
platformCooldownCount: number;
|
||||
tasks: MonitorSchedulerTaskRecord[];
|
||||
}
|
||||
|
||||
export interface MonitorSchedulerSelection {
|
||||
taskId: string;
|
||||
routing: MonitorSchedulerRouting;
|
||||
}
|
||||
|
||||
interface MonitorSchedulerSelectionOptions {
|
||||
now?: number;
|
||||
maxConcurrency: number;
|
||||
activePlatforms: ReadonlySet<string>;
|
||||
activeQuestionKeys: ReadonlySet<string>;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
interface MonitorTaskMetadata {
|
||||
title: string | null;
|
||||
businessDate: string | null;
|
||||
questionKey: string | null;
|
||||
questionText: string | null;
|
||||
}
|
||||
|
||||
const schedulerStateVersion = 1;
|
||||
const schedulerQuestionCooldownMs = 45_000;
|
||||
const schedulerPlatformCooldownMs = 5_000;
|
||||
const schedulerRestartRecoveryDelayMs = 90_000;
|
||||
const schedulerUnknownQuestionParallelismFloor = 1;
|
||||
const schedulerMaxLeaseMisses = 4;
|
||||
|
||||
let persistedStateCache: PersistedMonitorSchedulerState | null = null;
|
||||
|
||||
function persistedStatePath(): string {
|
||||
return join(app.getPath("userData"), "desktop-monitor-scheduler.json");
|
||||
}
|
||||
|
||||
function defaultPersistedState(): PersistedMonitorSchedulerState {
|
||||
return {
|
||||
version: schedulerStateVersion,
|
||||
tasks: {},
|
||||
questionCooldowns: {},
|
||||
platformCooldowns: {},
|
||||
lastHydratedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function clonePersistedState(state: PersistedMonitorSchedulerState): PersistedMonitorSchedulerState {
|
||||
return {
|
||||
version: state.version,
|
||||
tasks: Object.fromEntries(
|
||||
Object.entries(state.tasks).map(([taskId, task]) => [taskId, { ...task }]),
|
||||
),
|
||||
questionCooldowns: { ...state.questionCooldowns },
|
||||
platformCooldowns: { ...state.platformCooldowns },
|
||||
lastHydratedAt: state.lastHydratedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readPersistedState(): PersistedMonitorSchedulerState {
|
||||
if (persistedStateCache) {
|
||||
return persistedStateCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(persistedStatePath(), "utf8")) as PersistedMonitorSchedulerState;
|
||||
persistedStateCache = normalizePersistedState(parsed);
|
||||
} catch {
|
||||
persistedStateCache = defaultPersistedState();
|
||||
}
|
||||
|
||||
return persistedStateCache;
|
||||
}
|
||||
|
||||
function writePersistedState(next: PersistedMonitorSchedulerState): void {
|
||||
persistedStateCache = next;
|
||||
const target = persistedStatePath();
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, JSON.stringify(next, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function mutatePersistedState(
|
||||
mutator: (draft: PersistedMonitorSchedulerState) => void,
|
||||
): PersistedMonitorSchedulerState {
|
||||
const draft = clonePersistedState(readPersistedState());
|
||||
mutator(draft);
|
||||
prunePersistedState(draft, Date.now());
|
||||
writePersistedState(draft);
|
||||
return draft;
|
||||
}
|
||||
|
||||
function normalizePersistedState(input: PersistedMonitorSchedulerState | null | undefined): PersistedMonitorSchedulerState {
|
||||
const next = defaultPersistedState();
|
||||
if (!input || typeof input !== "object") {
|
||||
return next;
|
||||
}
|
||||
|
||||
next.version = typeof input.version === "number" ? input.version : schedulerStateVersion;
|
||||
next.lastHydratedAt = typeof input.lastHydratedAt === "number" ? input.lastHydratedAt : 0;
|
||||
|
||||
if (input.tasks && typeof input.tasks === "object") {
|
||||
for (const [taskId, value] of Object.entries(input.tasks)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = value as Partial<MonitorSchedulerTaskRecord>;
|
||||
if (typeof task.taskId !== "string" || task.taskId !== taskId || typeof task.platform !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
next.tasks[taskId] = {
|
||||
taskId,
|
||||
jobId: typeof task.jobId === "string" ? task.jobId : "",
|
||||
accountId: typeof task.accountId === "string" ? task.accountId : "",
|
||||
clientId: typeof task.clientId === "string" ? task.clientId : "",
|
||||
platform: task.platform,
|
||||
routing: task.routing === "db-recovery" ? "db-recovery" : "rabbitmq-primary",
|
||||
state: task.state === "active" || task.state === "leasing" ? task.state : "queued",
|
||||
title: typeof task.title === "string" ? task.title : null,
|
||||
businessDate: normalizeBusinessDate(task.businessDate),
|
||||
questionKey: normalizeOptionalString(task.questionKey),
|
||||
questionText: normalizeOptionalString(task.questionText),
|
||||
updatedAt: typeof task.updatedAt === "number" ? task.updatedAt : Date.now(),
|
||||
enqueuedAt: typeof task.enqueuedAt === "number" ? task.enqueuedAt : Date.now(),
|
||||
availableAt: typeof task.availableAt === "number" ? task.availableAt : Date.now(),
|
||||
lastSeenAt: typeof task.lastSeenAt === "number" ? task.lastSeenAt : Date.now(),
|
||||
lastLeaseAttemptAt: typeof task.lastLeaseAttemptAt === "number" ? task.lastLeaseAttemptAt : null,
|
||||
lastStartedAt: typeof task.lastStartedAt === "number" ? task.lastStartedAt : null,
|
||||
leaseMisses: typeof task.leaseMisses === "number" ? Math.max(0, task.leaseMisses) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.questionCooldowns && typeof input.questionCooldowns === "object") {
|
||||
for (const [key, value] of Object.entries(input.questionCooldowns)) {
|
||||
if (typeof value === "number" && value > 0) {
|
||||
next.questionCooldowns[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.platformCooldowns && typeof input.platformCooldowns === "object") {
|
||||
for (const [key, value] of Object.entries(input.platformCooldowns)) {
|
||||
if (typeof value === "number" && value > 0) {
|
||||
next.platformCooldowns[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prunePersistedState(next, Date.now());
|
||||
return next;
|
||||
}
|
||||
|
||||
function prunePersistedState(state: PersistedMonitorSchedulerState, now: number): void {
|
||||
const currentBusinessDate = currentMonitoringBusinessDate(now);
|
||||
|
||||
for (const [taskId, task] of Object.entries(state.tasks)) {
|
||||
if (task.businessDate && task.businessDate < currentBusinessDate) {
|
||||
delete state.tasks[taskId];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.state === "active" || task.state === "leasing") {
|
||||
task.state = "queued";
|
||||
task.availableAt = Math.max(task.availableAt, now + schedulerRestartRecoveryDelayMs);
|
||||
}
|
||||
|
||||
if (task.leaseMisses >= schedulerMaxLeaseMisses) {
|
||||
delete state.tasks[taskId];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [questionKey, until] of Object.entries(state.questionCooldowns)) {
|
||||
if (until <= now) {
|
||||
delete state.questionCooldowns[questionKey];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, until] of Object.entries(state.platformCooldowns)) {
|
||||
if (until <= now) {
|
||||
delete state.platformCooldowns[platform];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initMonitorScheduler(): void {
|
||||
const now = Date.now();
|
||||
const next = clonePersistedState(readPersistedState());
|
||||
prunePersistedState(next, now);
|
||||
next.lastHydratedAt = now;
|
||||
writePersistedState(next);
|
||||
}
|
||||
|
||||
export function enqueueMonitorTaskFromEvent(
|
||||
event: DesktopTaskEventMessage,
|
||||
routing: MonitorSchedulerRouting,
|
||||
): void {
|
||||
const metadata = metadataFromEvent(event);
|
||||
const now = Date.now();
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[event.task_id];
|
||||
draft.tasks[event.task_id] = {
|
||||
taskId: event.task_id,
|
||||
jobId: event.job_id,
|
||||
accountId: event.target_account_id,
|
||||
clientId: event.target_client_id,
|
||||
platform: event.platform,
|
||||
routing,
|
||||
state: existing?.state === "active" ? "active" : "queued",
|
||||
title: metadata.title ?? existing?.title ?? null,
|
||||
businessDate: metadata.businessDate ?? existing?.businessDate ?? null,
|
||||
questionKey: metadata.questionKey ?? existing?.questionKey ?? null,
|
||||
questionText: metadata.questionText ?? existing?.questionText ?? null,
|
||||
updatedAt: parseTimestamp(event.updated_at) ?? now,
|
||||
enqueuedAt: existing?.enqueuedAt ?? now,
|
||||
availableAt: existing?.availableAt ?? now,
|
||||
lastSeenAt: now,
|
||||
lastLeaseAttemptAt: existing?.lastLeaseAttemptAt ?? null,
|
||||
lastStartedAt: existing?.lastStartedAt ?? null,
|
||||
leaseMisses: existing?.leaseMisses ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskLeased(
|
||||
task: DesktopTaskInfo,
|
||||
routing: MonitorSchedulerRouting,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const metadata = metadataFromPayload(task.payload ?? null);
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[task.id];
|
||||
draft.tasks[task.id] = {
|
||||
taskId: task.id,
|
||||
jobId: task.job_id,
|
||||
accountId: task.target_account_id,
|
||||
clientId: task.target_client_id,
|
||||
platform: task.platform,
|
||||
routing,
|
||||
state: "active",
|
||||
title: metadata.title ?? existing?.title ?? null,
|
||||
businessDate: metadata.businessDate ?? existing?.businessDate ?? null,
|
||||
questionKey: metadata.questionKey ?? existing?.questionKey ?? null,
|
||||
questionText: metadata.questionText ?? existing?.questionText ?? null,
|
||||
updatedAt: parseTimestamp(task.updated_at) ?? now,
|
||||
enqueuedAt: existing?.enqueuedAt ?? now,
|
||||
availableAt: now,
|
||||
lastSeenAt: now,
|
||||
lastLeaseAttemptAt: now,
|
||||
lastStartedAt: now,
|
||||
leaseMisses: existing?.leaseMisses ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskLeaseMiss(taskId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[taskId];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaseMisses = existing.leaseMisses + 1;
|
||||
if (leaseMisses >= schedulerMaxLeaseMisses) {
|
||||
delete draft.tasks[taskId];
|
||||
return;
|
||||
}
|
||||
|
||||
existing.state = "queued";
|
||||
existing.leaseMisses = leaseMisses;
|
||||
existing.lastLeaseAttemptAt = now;
|
||||
existing.availableAt = now + leaseMissBackoffMs(leaseMisses);
|
||||
existing.lastSeenAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskCompleted(taskId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[taskId];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.questionKey) {
|
||||
draft.questionCooldowns[existing.questionKey] = now + schedulerQuestionCooldownMs;
|
||||
}
|
||||
draft.platformCooldowns[existing.platform] = now + schedulerPlatformCooldownMs;
|
||||
delete draft.tasks[taskId];
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskCanceled(taskId: string): void {
|
||||
mutatePersistedState((draft) => {
|
||||
delete draft.tasks[taskId];
|
||||
});
|
||||
}
|
||||
|
||||
export function selectNextMonitorTask(
|
||||
options: MonitorSchedulerSelectionOptions,
|
||||
): MonitorSchedulerSelection | null {
|
||||
const now = options.now ?? Date.now();
|
||||
const draft = clonePersistedState(readPersistedState());
|
||||
prunePersistedState(draft, now);
|
||||
|
||||
if (options.activeCount >= options.maxConcurrency) {
|
||||
writePersistedState(draft);
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(draft.tasks)
|
||||
.filter((task) => task.state === "queued" && task.availableAt <= now)
|
||||
.sort((left, right) => {
|
||||
if (left.availableAt !== right.availableAt) {
|
||||
return left.availableAt - right.availableAt;
|
||||
}
|
||||
if (left.enqueuedAt !== right.enqueuedAt) {
|
||||
return left.enqueuedAt - right.enqueuedAt;
|
||||
}
|
||||
return left.updatedAt - right.updatedAt;
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (options.activePlatforms.has(candidate.platform)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const platformCooldown = draft.platformCooldowns[candidate.platform] ?? 0;
|
||||
if (platformCooldown > now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidate.questionKey && options.activeCount >= schedulerUnknownQuestionParallelismFloor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.questionKey) {
|
||||
if (options.activeQuestionKeys.has(candidate.questionKey)) {
|
||||
continue;
|
||||
}
|
||||
const questionCooldown = draft.questionCooldowns[candidate.questionKey] ?? 0;
|
||||
if (questionCooldown > now) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
candidate.state = "leasing";
|
||||
candidate.lastLeaseAttemptAt = now;
|
||||
candidate.lastSeenAt = now;
|
||||
writePersistedState(draft);
|
||||
return {
|
||||
taskId: candidate.taskId,
|
||||
routing: candidate.routing,
|
||||
};
|
||||
}
|
||||
|
||||
writePersistedState(draft);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMonitorSchedulerSnapshot(): MonitorSchedulerSnapshot {
|
||||
const state = readPersistedState();
|
||||
const tasks = Object.values(state.tasks).sort((left, right) => left.enqueuedAt - right.enqueuedAt);
|
||||
return {
|
||||
hydratedAt: state.lastHydratedAt || null,
|
||||
queueDepth: tasks.length,
|
||||
activeCount: tasks.filter((task) => task.state === "active").length,
|
||||
leasingCount: tasks.filter((task) => task.state === "leasing").length,
|
||||
queuedCount: tasks.filter((task) => task.state === "queued").length,
|
||||
questionCooldownCount: Object.keys(state.questionCooldowns).length,
|
||||
platformCooldownCount: Object.keys(state.platformCooldowns).length,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
function leaseMissBackoffMs(leaseMisses: number): number {
|
||||
if (leaseMisses <= 1) {
|
||||
return 20_000;
|
||||
}
|
||||
if (leaseMisses === 2) {
|
||||
return 60_000;
|
||||
}
|
||||
return 3 * 60_000;
|
||||
}
|
||||
|
||||
function metadataFromEvent(event: DesktopTaskEventMessage): MonitorTaskMetadata {
|
||||
return {
|
||||
title: normalizeOptionalString(event.title),
|
||||
businessDate: normalizeBusinessDate(event.business_date),
|
||||
questionKey: normalizeOptionalString(event.scheduler_group_key),
|
||||
questionText: normalizeOptionalString(event.question_text),
|
||||
};
|
||||
}
|
||||
|
||||
function metadataFromPayload(payload: Record<string, JsonValue> | null): MonitorTaskMetadata {
|
||||
if (!payload) {
|
||||
return {
|
||||
title: null,
|
||||
businessDate: null,
|
||||
questionKey: null,
|
||||
questionText: null,
|
||||
};
|
||||
}
|
||||
|
||||
const title = firstString(payload, ["title", "question_title", "questionTitle"]);
|
||||
const businessDate = normalizeBusinessDate(
|
||||
firstString(payload, ["business_date", "businessDate", "metric_date", "date"]),
|
||||
);
|
||||
const questionText = firstString(payload, ["question_text", "questionText", "query", "question", "prompt", "content"]);
|
||||
const questionHash = firstString(payload, ["question_hash", "questionHash"]);
|
||||
const questionID = firstString(payload, ["question_id", "questionId"]);
|
||||
|
||||
return {
|
||||
title,
|
||||
businessDate,
|
||||
questionKey: normalizeOptionalString(firstString(payload, ["scheduler_group_key", "schedulerGroupKey"]))
|
||||
?? normalizeOptionalString(questionHash)
|
||||
?? (questionID ? `qid:${questionID}` : questionText ? `qtxt:${digestText(questionText)}` : null),
|
||||
questionText,
|
||||
};
|
||||
}
|
||||
|
||||
function firstString(
|
||||
payload: Record<string, JsonValue>,
|
||||
keys: string[],
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentMonitoringBusinessDate(now: number): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
|
||||
const year = parts.find((part) => part.type === "year")?.value ?? "";
|
||||
const month = parts.find((part) => part.type === "month")?.value ?? "";
|
||||
const day = parts.find((part) => part.type === "day")?.value ?? "";
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function normalizeBusinessDate(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function digestText(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
probePublishAccountSession,
|
||||
silentRefreshPublishAccountSession,
|
||||
type PublishAccountIdentity,
|
||||
} from "./account-binder";
|
||||
import type {
|
||||
AuthProbeResult,
|
||||
PlatformFailureClassification,
|
||||
} from "./auth-types";
|
||||
|
||||
export interface PlatformFailureInput {
|
||||
summary?: string | null;
|
||||
error?: Record<string, unknown> | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
probe(account: PublishAccountIdentity, partition: string): Promise<AuthProbeResult>;
|
||||
silentRefresh(account: PublishAccountIdentity, partition: string): Promise<boolean>;
|
||||
classifyFailure(input: PlatformFailureInput): PlatformFailureClassification;
|
||||
}
|
||||
|
||||
function normalizeFailureText(input: PlatformFailureInput): string {
|
||||
const values = [
|
||||
input.summary,
|
||||
input.message,
|
||||
typeof input.error?.code === "string" ? input.error.code : null,
|
||||
typeof input.error?.message === "string" ? input.error.message : null,
|
||||
typeof input.error?.detail === "string" ? input.error.detail : null,
|
||||
typeof input.error?.verify_scene === "string" ? input.error.verify_scene : null,
|
||||
];
|
||||
|
||||
return values
|
||||
.filter((value): value is string => typeof value === "string" && Boolean(value.trim()))
|
||||
.join(" | ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function classifyFailure(input: PlatformFailureInput): PlatformFailureClassification {
|
||||
const text = normalizeFailureText(input);
|
||||
if (!text) {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
if (
|
||||
/(verify_scene|verification required|captcha|challenge|验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证)/i.test(text)
|
||||
) {
|
||||
return "challenge";
|
||||
}
|
||||
|
||||
if (
|
||||
/(not_logged_in|unauthorized|unauthenticated|login redirect|login required|signin|sign in|expired|401|登录态失效|请先登录|未登录)/i
|
||||
.test(text)
|
||||
) {
|
||||
return "auth_failure";
|
||||
}
|
||||
|
||||
if (
|
||||
/(network|timeout|timed out|failed to fetch|econnreset|econnrefused|enotfound|dns|socket hang up|502|503|504)/i
|
||||
.test(text)
|
||||
) {
|
||||
return "network";
|
||||
}
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
const genericAdapter: PlatformAdapter = {
|
||||
async probe(account, partition) {
|
||||
return await probePublishAccountSession(account, partition);
|
||||
},
|
||||
async silentRefresh(account, partition) {
|
||||
return await silentRefreshPublishAccountSession(account, partition);
|
||||
},
|
||||
classifyFailure,
|
||||
};
|
||||
|
||||
const adapterRegistry = new Map<string, PlatformAdapter>(
|
||||
[
|
||||
...aiPlatformCatalog.map((platform) => platform.id),
|
||||
"toutiaohao",
|
||||
"baijiahao",
|
||||
"sohu",
|
||||
"qiehao",
|
||||
"zhihu",
|
||||
"wangyihao",
|
||||
"jianshu",
|
||||
"bilibili",
|
||||
"juejin",
|
||||
"smzdm",
|
||||
"weixin_gzh",
|
||||
"zol",
|
||||
"dongchedi",
|
||||
].map((platform) => [platform, genericAdapter]),
|
||||
);
|
||||
|
||||
export function getPlatformAdapter(platformId: string): PlatformAdapter {
|
||||
return adapterRegistry.get(platformId) ?? genericAdapter;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
JsonValue,
|
||||
LeaseDesktopTaskResponse,
|
||||
} from "@geo/shared-types";
|
||||
import { isAIPlatformId } from "@geo/shared-types";
|
||||
import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
doubaoAdapter,
|
||||
@@ -22,10 +22,17 @@ import {
|
||||
type PublishAdapter,
|
||||
} from "./adapters";
|
||||
import {
|
||||
ensurePublishAccountSessionHandle,
|
||||
inspectPublishAccountLocalSession,
|
||||
type PublishAccountIdentity,
|
||||
type PublishAccountProfile,
|
||||
} from "./account-binder";
|
||||
import {
|
||||
ensureAccountReady,
|
||||
forgetTrackedAccountHealth,
|
||||
getProjectedAccountHealth,
|
||||
probeTrackedAccount,
|
||||
reportAccountFailure,
|
||||
syncTrackedAccounts,
|
||||
} from "./account-health";
|
||||
import {
|
||||
buildAccountIdentityKey,
|
||||
normalizeAccountPlatformUid,
|
||||
@@ -33,6 +40,21 @@ import {
|
||||
} from "../shared/account-identity";
|
||||
import { releaseHotView, retainHotView } from "./view-pool";
|
||||
import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager";
|
||||
import {
|
||||
enqueueMonitorTaskFromEvent,
|
||||
getMonitorSchedulerSnapshot,
|
||||
initMonitorScheduler,
|
||||
noteMonitorTaskCanceled,
|
||||
noteMonitorTaskCompleted,
|
||||
noteMonitorTaskLeaseMiss,
|
||||
noteMonitorTaskLeased,
|
||||
selectNextMonitorTask,
|
||||
} from "./monitor-scheduler";
|
||||
import {
|
||||
retainHiddenPlaywrightPage,
|
||||
startHiddenPlaywrightReaper,
|
||||
} from "./playwright-cdp";
|
||||
import { getProcessMetricsSnapshot } from "./process-metrics";
|
||||
import {
|
||||
initScheduler,
|
||||
noteSchedulerAccountsSync,
|
||||
@@ -47,7 +69,7 @@ import {
|
||||
setSchedulerPhase,
|
||||
setSchedulerQueueDepth,
|
||||
} from "./scheduler";
|
||||
import { clearSessionHandle, createSessionHandle, getSessionHandle } from "./session-registry";
|
||||
import { clearSessionHandle, createSessionHandle } from "./session-registry";
|
||||
import {
|
||||
completeDesktopTask,
|
||||
configureTransport,
|
||||
@@ -136,19 +158,26 @@ interface PendingTaskRequest {
|
||||
routing: RuntimeTaskRouting;
|
||||
}
|
||||
|
||||
interface ActiveRuntimeExecution {
|
||||
taskId: string;
|
||||
kind: "publish" | "monitor";
|
||||
platform: string;
|
||||
accountId: string;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
session: DesktopRuntimeSessionSyncRequest | null;
|
||||
client: DesktopClientInfo | null;
|
||||
running: boolean;
|
||||
sseConnected: boolean;
|
||||
queue: PendingTaskRequest[];
|
||||
queuedTaskIds: Set<string>;
|
||||
publishQueue: PendingTaskRequest[];
|
||||
queuedPublishTaskIds: Set<string>;
|
||||
tasks: Map<string, RuntimeTaskRecord>;
|
||||
accounts: DesktopAccountInfo[];
|
||||
accountProfiles: Map<string, PublishAccountProfile>;
|
||||
activity: RuntimeControllerActivity[];
|
||||
currentTaskId: string | null;
|
||||
currentExecutionAbort: AbortController | null;
|
||||
activeExecutions: Map<string, ActiveRuntimeExecution>;
|
||||
leaseInFlight: boolean;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
@@ -168,14 +197,13 @@ const state: RuntimeState = {
|
||||
client: null,
|
||||
running: false,
|
||||
sseConnected: false,
|
||||
queue: [],
|
||||
queuedTaskIds: new Set<string>(),
|
||||
publishQueue: [],
|
||||
queuedPublishTaskIds: new Set<string>(),
|
||||
tasks: new Map<string, RuntimeTaskRecord>(),
|
||||
accounts: [],
|
||||
accountProfiles: new Map<string, PublishAccountProfile>(),
|
||||
activity: [],
|
||||
currentTaskId: null,
|
||||
currentExecutionAbort: null,
|
||||
activeExecutions: new Map<string, ActiveRuntimeExecution>(),
|
||||
leaseInFlight: false,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
@@ -190,6 +218,41 @@ const state: RuntimeState = {
|
||||
activitySeq: 0,
|
||||
};
|
||||
|
||||
function accountIdentityFromDesktopAccount(account: DesktopAccountInfo): PublishAccountIdentity {
|
||||
return {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
displayName: account.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function accountIdentityFromTask(task: RuntimeTaskRecord): PublishAccountIdentity | null {
|
||||
if (!task.accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = state.accounts.find((account) => account.id === task.accountId) ?? null;
|
||||
if (matched) {
|
||||
return accountIdentityFromDesktopAccount(matched);
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.accountId,
|
||||
platform: task.platform,
|
||||
platformUid: "",
|
||||
displayName: task.accountName || "待同步账号",
|
||||
};
|
||||
}
|
||||
|
||||
function isDefinitiveAuthState(authState: string): boolean {
|
||||
return ["active", "expired", "challenge_required", "revoked"].includes(authState);
|
||||
}
|
||||
|
||||
function isoFromTimestamp(value: number | null): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null): void {
|
||||
const normalized = normalizeSession(next);
|
||||
const changed = !sameSession(state.session, normalized);
|
||||
@@ -256,7 +319,7 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
||||
client: state.client ? { ...state.client } : null,
|
||||
running: state.running,
|
||||
sseConnected: state.sseConnected,
|
||||
queueDepth: state.queue.length,
|
||||
queueDepth: localQueueDepth(),
|
||||
accounts: state.accounts.map((item) => ({ ...item })),
|
||||
accountProfiles: Object.fromEntries(
|
||||
[...state.accountProfiles.entries()].map(([accountId, profile]) => [accountId, { ...profile }]),
|
||||
@@ -281,6 +344,8 @@ function startRuntime(): void {
|
||||
}
|
||||
|
||||
initScheduler();
|
||||
initMonitorScheduler();
|
||||
startHiddenPlaywrightReaper();
|
||||
state.running = true;
|
||||
setSchedulerPhase("running");
|
||||
setSchedulerError(null);
|
||||
@@ -292,16 +357,17 @@ function startRuntime(): void {
|
||||
|
||||
void sendHeartbeat("startup");
|
||||
void syncAccounts("startup");
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
|
||||
function stopRuntime(): void {
|
||||
state.running = false;
|
||||
state.sseConnected = false;
|
||||
state.leaseInFlight = false;
|
||||
state.currentExecutionAbort?.abort();
|
||||
state.currentExecutionAbort = null;
|
||||
state.currentTaskId = null;
|
||||
for (const execution of state.activeExecutions.values()) {
|
||||
execution.abortController.abort();
|
||||
}
|
||||
state.activeExecutions.clear();
|
||||
|
||||
if (state.heartbeatTimer) {
|
||||
clearInterval(state.heartbeatTimer);
|
||||
@@ -319,7 +385,7 @@ function stopRuntime(): void {
|
||||
setTransportSseState("idle");
|
||||
setSchedulerPhase("idle");
|
||||
setSchedulerCurrentTask(null);
|
||||
setSchedulerQueueDepth(0);
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
setSchedulerNextHeartbeat(null);
|
||||
setSchedulerNextPull(null);
|
||||
|
||||
@@ -329,12 +395,12 @@ function stopRuntime(): void {
|
||||
}
|
||||
|
||||
function clearRuntimeState(): void {
|
||||
state.queue = [];
|
||||
state.queuedTaskIds.clear();
|
||||
state.publishQueue = [];
|
||||
state.queuedPublishTaskIds.clear();
|
||||
state.tasks.clear();
|
||||
state.accounts = [];
|
||||
state.accountProfiles.clear();
|
||||
state.currentTaskId = null;
|
||||
state.activeExecutions.clear();
|
||||
state.lastHeartbeatAt = 0;
|
||||
state.lastHeartbeatStatus = "idle";
|
||||
state.lastPullAt = 0;
|
||||
@@ -342,7 +408,7 @@ function clearRuntimeState(): void {
|
||||
state.lastAccountsSyncAt = 0;
|
||||
state.lastServerTime = null;
|
||||
state.lastError = null;
|
||||
setSchedulerQueueDepth(0);
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
}
|
||||
|
||||
function scheduleHeartbeatLoop(): void {
|
||||
@@ -365,7 +431,7 @@ function schedulePullLoop(): void {
|
||||
setSchedulerNextPull(Date.now() + pullIntervalMs);
|
||||
state.pullTimer = setInterval(() => {
|
||||
setSchedulerNextPull(Date.now() + pullIntervalMs);
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}, pullIntervalMs);
|
||||
}
|
||||
|
||||
@@ -478,9 +544,20 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
|
||||
state.tasks.set(event.task_id, next);
|
||||
|
||||
if (event.kind === "monitor") {
|
||||
if (event.type === "task_available") {
|
||||
enqueueMonitorTaskFromEvent(event, "rabbitmq-primary");
|
||||
}
|
||||
if (event.type === "task_completed" || event.type === "task_reconciled" || event.type === "task_canceled") {
|
||||
noteMonitorTaskCanceled(event.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "task_available") {
|
||||
enqueueTask(event.task_id, "rabbitmq-primary");
|
||||
processQueue();
|
||||
if (event.kind === "publish") {
|
||||
enqueuePublishTask(event.task_id, "rabbitmq-primary");
|
||||
}
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +575,16 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
|
||||
cpu_arch: process.arch,
|
||||
client_version: state.client?.client_version ?? "0.1.0-dev",
|
||||
channel: state.client?.channel ?? "dev",
|
||||
account_ids: state.accounts.filter((account) => account.health === "live").map((account) => account.id),
|
||||
account_ids: state.accounts
|
||||
.filter((account) =>
|
||||
getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
}).health === "live",
|
||||
)
|
||||
.map((account) => account.id),
|
||||
});
|
||||
|
||||
state.client = response.client;
|
||||
@@ -547,28 +633,41 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
noteSchedulerAccountsSync();
|
||||
setSchedulerError(null);
|
||||
|
||||
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
|
||||
syncTrackedAccounts(identities);
|
||||
if (source === "manual") {
|
||||
await Promise.allSettled(
|
||||
identities.map((account) =>
|
||||
probeTrackedAccount(account, {
|
||||
trigger: "manual",
|
||||
allowSilentRefresh: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
state.accountProfiles.clear();
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const isAIPlatform = isAIPlatformId(account.platform);
|
||||
const identity = {
|
||||
id: account.id,
|
||||
const identity = accountIdentityFromDesktopAccount(account);
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: normalizedPlatformUid,
|
||||
displayName: account.display_name,
|
||||
} as const;
|
||||
const local = await inspectPublishAccountLocalSession(identity);
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
|
||||
if (local.availability === "missing") {
|
||||
return null;
|
||||
if (projected.profile?.subjectUid && projected.profile.displayName) {
|
||||
state.accountProfiles.set(account.id, {
|
||||
platformUid: projected.profile.subjectUid,
|
||||
displayName: projected.profile.displayName,
|
||||
avatarUrl: projected.profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (local.profile) {
|
||||
state.accountProfiles.set(account.id, local.profile);
|
||||
}
|
||||
|
||||
const normalizedLocalPlatformUid = local.profile
|
||||
? (normalizeAccountPlatformUid(local.profile.platformUid) || local.profile.platformUid)
|
||||
const normalizedLocalPlatformUid = projected.profile?.subjectUid
|
||||
? (normalizeAccountPlatformUid(projected.profile.subjectUid) || projected.profile.subjectUid)
|
||||
: null;
|
||||
const hasPlatformUidMismatch = Boolean(
|
||||
normalizedLocalPlatformUid && !sameAccountPlatformUid(normalizedLocalPlatformUid, normalizedPlatformUid),
|
||||
@@ -585,22 +684,27 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
let resolvedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? account.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? account.avatar_url,
|
||||
health: local.availability === "expired" ? ("expired" as const) : ("live" as const),
|
||||
display_name: projected.profile?.displayName ?? account.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||||
health: projected.health,
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
};
|
||||
|
||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
||||
if (shouldRelinkClient) {
|
||||
const shouldMirrorHealth =
|
||||
resolvedAccount.health !== account.health
|
||||
&& isDefinitiveAuthState(projected.authState);
|
||||
|
||||
if (shouldRelinkClient || shouldMirrorHealth) {
|
||||
try {
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: resolvedAccount.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
display_name: projected.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: resolvedAccount.health,
|
||||
verified_at: new Date().toISOString(),
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at,
|
||||
tags: resolvedAccount.tags,
|
||||
});
|
||||
|
||||
@@ -694,6 +798,7 @@ export async function unbindRuntimeAccount(accountId: string, syncVersion: numbe
|
||||
|
||||
await deleteDesktopAccount(accountId, syncVersion);
|
||||
await clearSessionHandle(accountId);
|
||||
forgetTrackedAccountHealth(accountId);
|
||||
|
||||
state.accounts = state.accounts.filter((item) => item.id !== accountId);
|
||||
state.accountProfiles.delete(accountId);
|
||||
@@ -710,41 +815,74 @@ export async function unbindRuntimeAccount(accountId: string, syncVersion: numbe
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void {
|
||||
if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) {
|
||||
function enqueuePublishTask(taskId: string, routing: RuntimeTaskRouting): void {
|
||||
if (!taskId || state.activeExecutions.has(taskId) || state.queuedPublishTaskIds.has(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.queue.push({ taskId, routing });
|
||||
state.queuedTaskIds.add(taskId);
|
||||
setSchedulerQueueDepth(state.queue.length);
|
||||
state.publishQueue.push({ taskId, routing });
|
||||
state.queuedPublishTaskIds.add(taskId);
|
||||
syncSchedulerSurface();
|
||||
}
|
||||
|
||||
function dequeueTask(): PendingTaskRequest | null {
|
||||
const next = state.queue.shift() ?? null;
|
||||
function dequeuePublishTask(): PendingTaskRequest | null {
|
||||
const next = state.publishQueue.shift() ?? null;
|
||||
if (next) {
|
||||
state.queuedTaskIds.delete(next.taskId);
|
||||
state.queuedPublishTaskIds.delete(next.taskId);
|
||||
}
|
||||
setSchedulerQueueDepth(state.queue.length);
|
||||
syncSchedulerSurface();
|
||||
return next;
|
||||
}
|
||||
|
||||
function processQueue(): void {
|
||||
if (!state.running || state.currentTaskId || state.leaseInFlight) {
|
||||
function pumpExecutionLoop(): void {
|
||||
if (!state.running || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queued = dequeueTask();
|
||||
if (queued) {
|
||||
void leaseSpecificTask(queued);
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (hasActivePublishExecution()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void pullNextTask();
|
||||
if (state.publishQueue.length > 0) {
|
||||
if (state.activeExecutions.size === 0) {
|
||||
const queuedPublish = dequeuePublishTask();
|
||||
if (queuedPublish) {
|
||||
void leaseSpecificTask(queuedPublish, "publish");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
const selected = selectNextMonitorTask({
|
||||
maxConcurrency: resolveAdaptiveMonitorConcurrency(),
|
||||
activePlatforms: activeMonitorPlatforms(),
|
||||
activeQuestionKeys: activeMonitorQuestionKeys(),
|
||||
activeCount: activeMonitorCount(),
|
||||
});
|
||||
if (selected) {
|
||||
void leaseSpecificTask(selected, "monitor");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.activeExecutions.size === 0) {
|
||||
void pullNextTask("publish");
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
|
||||
async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) {
|
||||
async function leaseSpecificTask(
|
||||
request: PendingTaskRequest,
|
||||
expectedKind: "publish" | "monitor",
|
||||
): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -753,11 +891,18 @@ async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
try {
|
||||
const leased = await leaseDesktopTask({ task_id: request.taskId });
|
||||
if (!leased.task) {
|
||||
if (expectedKind === "monitor") {
|
||||
noteMonitorTaskLeaseMiss(request.taskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.leaseInFlight = false;
|
||||
await executeLeasedTask(leased, request.routing);
|
||||
} catch (error) {
|
||||
if (expectedKind === "monitor") {
|
||||
noteMonitorTaskLeaseMiss(request.taskId);
|
||||
}
|
||||
|
||||
if (isApiClientError(error, 401)) {
|
||||
handleAuthExpired(error);
|
||||
return;
|
||||
@@ -769,22 +914,24 @@ async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
} finally {
|
||||
if (state.leaseInFlight) {
|
||||
state.leaseInFlight = false;
|
||||
if (state.queue.length > 0) {
|
||||
processQueue();
|
||||
syncSchedulerSurface();
|
||||
if (state.publishQueue.length > 0 || localQueueDepth() > 0) {
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pullNextTask(): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) {
|
||||
async function pullNextTask(kind: "publish" | "monitor"): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.leaseInFlight = true;
|
||||
let leasedTask = false;
|
||||
|
||||
try {
|
||||
const leased = await leaseDesktopTask({});
|
||||
const leased = await leaseDesktopTask({ kind });
|
||||
state.lastPullAt = Date.now();
|
||||
state.lastPullStatus = "success";
|
||||
noteTransportPull(true);
|
||||
@@ -794,6 +941,7 @@ async function pullNextTask(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
leasedTask = true;
|
||||
state.leaseInFlight = false;
|
||||
await executeLeasedTask(leased, "db-recovery");
|
||||
} catch (error) {
|
||||
@@ -812,6 +960,11 @@ async function pullNextTask(): Promise<void> {
|
||||
recordActivity("warn", "兜底拉取失败", state.lastError);
|
||||
} finally {
|
||||
state.leaseInFlight = false;
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (!leasedTask && kind === "publish" && canStartAnotherMonitorExecution()) {
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,8 +978,6 @@ async function executeLeasedTask(
|
||||
|
||||
const task = leased.task;
|
||||
|
||||
state.currentTaskId = task.id;
|
||||
setSchedulerCurrentTask(task.id);
|
||||
setSchedulerPhase("running");
|
||||
|
||||
setActiveLease({
|
||||
@@ -849,7 +1000,18 @@ async function executeLeasedTask(
|
||||
);
|
||||
|
||||
const abortController = new AbortController();
|
||||
state.currentExecutionAbort = abortController;
|
||||
state.activeExecutions.set(taskRecord.id, {
|
||||
taskId: taskRecord.id,
|
||||
kind: taskRecord.kind,
|
||||
platform: taskRecord.platform,
|
||||
accountId: taskRecord.accountId,
|
||||
abortController,
|
||||
});
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskLeased(task, routing);
|
||||
}
|
||||
|
||||
const extendHandle = setInterval(() => {
|
||||
void extendActiveLease(taskRecord.id, leased.lease_token as string);
|
||||
@@ -864,7 +1026,7 @@ async function executeLeasedTask(
|
||||
error: execution.error,
|
||||
});
|
||||
|
||||
noteLeaseReleased(execution.status === "failed" ? "failed" : "completed");
|
||||
noteLeaseReleased(execution.status === "failed" ? "failed" : "completed", taskRecord.id);
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing,
|
||||
summary: execution.summary,
|
||||
@@ -881,9 +1043,28 @@ async function executeLeasedTask(
|
||||
: "任务执行成功",
|
||||
`${taskRecord.title} ${execution.summary}`,
|
||||
);
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskCompleted(taskRecord.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const failureMessage = errorMessage(error);
|
||||
const structuredError = toStructuredError(error);
|
||||
const accountIdentity = accountIdentityFromTask(taskRecord);
|
||||
|
||||
if (accountIdentity) {
|
||||
await reportAccountFailure(accountIdentity, {
|
||||
summary: failureMessage,
|
||||
error: structuredError,
|
||||
}).catch((reportError) => {
|
||||
console.warn("[desktop-runtime] thrown failure report failed", {
|
||||
taskId: taskRecord.id,
|
||||
accountId: accountIdentity.id,
|
||||
platform: accountIdentity.platform,
|
||||
message: reportError instanceof Error ? reportError.message : String(reportError),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const completed = await completeDesktopTask(taskRecord.id, {
|
||||
@@ -909,15 +1090,18 @@ async function executeLeasedTask(
|
||||
}
|
||||
}
|
||||
|
||||
noteLeaseReleased("failed");
|
||||
noteLeaseReleased("failed", taskRecord.id);
|
||||
recordActivity("danger", "任务执行失败", `${taskRecord.title} 执行失败:${failureMessage || "未知错误"}`);
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskCompleted(taskRecord.id);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(extendHandle);
|
||||
state.currentExecutionAbort = null;
|
||||
state.currentTaskId = null;
|
||||
setSchedulerCurrentTask(null);
|
||||
state.activeExecutions.delete(taskRecord.id);
|
||||
syncSchedulerSurface();
|
||||
noteSchedulerTaskFinished();
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,7 +1109,7 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
|
||||
try {
|
||||
const task = await extendDesktopTask(taskId, { lease_token: leaseToken });
|
||||
|
||||
noteLeaseExtended(task.lease_expires_at ?? null);
|
||||
noteLeaseExtended(task.lease_expires_at ?? null, taskId);
|
||||
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (existing) {
|
||||
@@ -946,6 +1130,14 @@ async function executeTaskAdapter(
|
||||
task: RuntimeTaskRecord,
|
||||
signal: AbortSignal,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const accountIdentity = accountIdentityFromTask(task);
|
||||
if (accountIdentity) {
|
||||
const readiness = await ensureAccountReady(accountIdentity);
|
||||
if (readiness.authState !== "active") {
|
||||
return buildAccountBlockedResult(task, readiness);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionHandle = createSessionHandle(task.accountId || task.id);
|
||||
const payload = task.payload;
|
||||
|
||||
@@ -959,14 +1151,29 @@ async function executeTaskAdapter(
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: retainHotView(task.accountId || task.id);
|
||||
const playwrightLease =
|
||||
adapter.executionMode === "playwright"
|
||||
? await retainHiddenPlaywrightPage({
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||||
title: `${task.title} · Hidden Playwright`,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
return await adapter.publish(
|
||||
const result = await adapter.publish(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle?.view ?? null,
|
||||
playwright: playwrightLease
|
||||
? {
|
||||
browser: playwrightLease.browser,
|
||||
page: playwrightLease.page,
|
||||
}
|
||||
: null,
|
||||
signal,
|
||||
phase: "initial",
|
||||
article: await loadPublishArticle(task),
|
||||
@@ -976,7 +1183,10 @@ async function executeTaskAdapter(
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await maybeReportTaskAuthFailure(task, result, accountIdentity);
|
||||
return result;
|
||||
} finally {
|
||||
await playwrightLease?.release();
|
||||
if (viewHandle) {
|
||||
releaseHotView(viewHandle.accountId);
|
||||
}
|
||||
@@ -1010,14 +1220,29 @@ async function executeTaskAdapter(
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: retainHotView(task.accountId || task.id);
|
||||
const playwrightLease =
|
||||
adapter.executionMode === "playwright"
|
||||
? await retainHiddenPlaywrightPage({
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||||
title: `${task.title} · Hidden Playwright`,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
return await adapter.query(
|
||||
const result = await adapter.query(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle?.view ?? null,
|
||||
playwright: playwrightLease
|
||||
? {
|
||||
browser: playwrightLease.browser,
|
||||
page: playwrightLease.page,
|
||||
}
|
||||
: null,
|
||||
signal,
|
||||
phase: "initial",
|
||||
reportProgress(stage: string) {
|
||||
@@ -1026,13 +1251,91 @@ async function executeTaskAdapter(
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await maybeReportTaskAuthFailure(task, result, accountIdentity);
|
||||
return result;
|
||||
} finally {
|
||||
await playwrightLease?.release();
|
||||
if (viewHandle) {
|
||||
releaseHotView(viewHandle.accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccountBlockedResult(
|
||||
task: RuntimeTaskRecord,
|
||||
readiness: Awaited<ReturnType<typeof ensureAccountReady>>,
|
||||
): AdapterExecutionResult {
|
||||
if (readiness.authState === "challenge_required") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `${task.accountName} 需要完成人机验证后才能继续执行。`,
|
||||
error: {
|
||||
code: "desktop_account_challenge_required",
|
||||
message: "desktop account challenge required",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (readiness.authState === "expired" || readiness.authState === "revoked") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `${task.accountName} 登录态已失效,请重新授权后再试。`,
|
||||
error: {
|
||||
code: "desktop_account_auth_expired",
|
||||
message: "desktop account auth expired",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "unknown",
|
||||
summary: `${task.accountName} 最近校验失败,已暂缓执行任务。`,
|
||||
error: {
|
||||
code: "desktop_account_probe_pending",
|
||||
message: "desktop account readiness pending",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeReportTaskAuthFailure(
|
||||
task: RuntimeTaskRecord,
|
||||
result: AdapterExecutionResult,
|
||||
accountIdentity: PublishAccountIdentity | null,
|
||||
): Promise<void> {
|
||||
if (!accountIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reportAccountFailure(accountIdentity, {
|
||||
summary: result.summary,
|
||||
error: result.error ?? null,
|
||||
}).catch((error) => {
|
||||
console.warn("[desktop-runtime] account failure report failed", {
|
||||
taskId: task.id,
|
||||
accountId: accountIdentity.id,
|
||||
platform: accountIdentity.platform,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlaywrightTargetURL(
|
||||
platform: string,
|
||||
payload: Record<string, JsonValue>,
|
||||
): string {
|
||||
const payloadURL = resolveStringField(payload, ["target_url", "targetUrl", "console_url", "consoleUrl", "url"]);
|
||||
if (payloadURL) {
|
||||
return payloadURL;
|
||||
}
|
||||
|
||||
const aiPlatform = getAIPlatformCatalogItem(platform);
|
||||
if (aiPlatform?.consoleUrl) {
|
||||
return aiPlatform.consoleUrl;
|
||||
}
|
||||
|
||||
return "about:blank";
|
||||
}
|
||||
|
||||
function buildScaffoldResult(
|
||||
task: RuntimeTaskRecord,
|
||||
adapterPayload: Record<string, JsonValue>,
|
||||
@@ -1096,6 +1399,23 @@ function toPositiveInt(value: JsonValue | null): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStringField(
|
||||
payload: Record<string, JsonValue>,
|
||||
keys: string[],
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStaleMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||||
const businessDate = resolveMonitoringBusinessDate(payload);
|
||||
if (!businessDate) {
|
||||
@@ -1219,6 +1539,90 @@ function recordActivity(
|
||||
}
|
||||
}
|
||||
|
||||
function syncSchedulerSurface(): void {
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
setSchedulerCurrentTask(primaryActiveTaskId());
|
||||
}
|
||||
|
||||
function localQueueDepth(): number {
|
||||
const snapshot = getMonitorSchedulerSnapshot();
|
||||
return state.publishQueue.length + snapshot.queuedCount + snapshot.leasingCount;
|
||||
}
|
||||
|
||||
function primaryActiveTaskId(): string | null {
|
||||
const active = [...state.activeExecutions.values()];
|
||||
const publish = active.find((item) => item.kind === "publish") ?? null;
|
||||
return publish?.taskId ?? active[0]?.taskId ?? null;
|
||||
}
|
||||
|
||||
function hasActivePublishExecution(): boolean {
|
||||
return [...state.activeExecutions.values()].some((item) => item.kind === "publish");
|
||||
}
|
||||
|
||||
function activeMonitorCount(): number {
|
||||
return [...state.activeExecutions.values()].filter((item) => item.kind === "monitor").length;
|
||||
}
|
||||
|
||||
function activeMonitorPlatforms(): Set<string> {
|
||||
return new Set(
|
||||
[...state.activeExecutions.values()]
|
||||
.filter((item) => item.kind === "monitor")
|
||||
.map((item) => item.platform),
|
||||
);
|
||||
}
|
||||
|
||||
function activeMonitorQuestionKeys(): Set<string> {
|
||||
return new Set(
|
||||
getMonitorSchedulerSnapshot().tasks
|
||||
.filter((task) => task.state === "active" && Boolean(task.questionKey))
|
||||
.map((task) => task.questionKey as string),
|
||||
);
|
||||
}
|
||||
|
||||
function canStartAnotherMonitorExecution(): boolean {
|
||||
if (state.publishQueue.length > 0 || hasActivePublishExecution()) {
|
||||
return false;
|
||||
}
|
||||
const limit = resolveAdaptiveMonitorConcurrency();
|
||||
return limit > 0 && activeMonitorCount() < limit;
|
||||
}
|
||||
|
||||
function resolveAdaptiveMonitorConcurrency(): number {
|
||||
const monitorAccountCount = state.accounts.filter((account) =>
|
||||
isAIPlatformId(account.platform)
|
||||
&& getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
}).health === "live",
|
||||
).length;
|
||||
if (monitorAccountCount <= 1) {
|
||||
return monitorAccountCount;
|
||||
}
|
||||
|
||||
const metrics = getProcessMetricsSnapshot().latestSample;
|
||||
if (!metrics) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0);
|
||||
const totalPrivateBytesKB = metrics.totals.privateBytes;
|
||||
const processCount = metrics.totals.processCount;
|
||||
const mainPrivateBytesKB = metrics.mainProcess.memory?.private ?? 0;
|
||||
|
||||
if (
|
||||
totalCPUUsage >= 180
|
||||
|| totalPrivateBytesKB >= 2_500_000
|
||||
|| mainPrivateBytesKB >= 1_000_000
|
||||
|| processCount >= 40
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
function handleAuthExpired(error: unknown): void {
|
||||
const message = errorMessage(error);
|
||||
state.lastError = message;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
type RuntimeInvalidationReason = "account-health";
|
||||
|
||||
type RuntimeInvalidationListener = (event: {
|
||||
reason: RuntimeInvalidationReason;
|
||||
at: number;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RuntimeInvalidationListener>();
|
||||
|
||||
export function emitRuntimeInvalidated(reason: RuntimeInvalidationReason): void {
|
||||
const event = {
|
||||
reason,
|
||||
at: Date.now(),
|
||||
} as const;
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] invalidation listener failed", {
|
||||
reason,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { app } from "electron/main";
|
||||
|
||||
import { getProjectedAccountHealth } from "./account-health";
|
||||
import { getProcessMetricsSnapshot } from "./process-metrics";
|
||||
import { getCircuitBreakerState } from "./circuit-breaker";
|
||||
import { collectRecoverySnapshot } from "./crash-recovery";
|
||||
import { getKeepAlivePlan } from "./keep-alive";
|
||||
import { getLeaseManagerSnapshot } from "./lease-manager";
|
||||
import { getMonitorSchedulerSnapshot } from "./monitor-scheduler";
|
||||
import { getHiddenPlaywrightSnapshot } from "./playwright-cdp";
|
||||
import { getRateLimiterSnapshot } from "./rate-limiter";
|
||||
import { getRuntimeControllerSnapshot } from "./runtime-controller";
|
||||
import { getSchedulerState } from "./scheduler";
|
||||
@@ -71,23 +74,35 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
const accountQueueDepth = controller.tasks.filter((task) =>
|
||||
task.accountId === account.id && ["queued", "in_progress"].includes(task.status),
|
||||
).length;
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
displayName: account.display_name,
|
||||
displayName: projected.profile?.displayName ?? account.display_name,
|
||||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
avatarUrl: account.avatar_url ?? null,
|
||||
health: account.health,
|
||||
avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null,
|
||||
health: projected.health,
|
||||
authState: projected.authState,
|
||||
probeState: projected.probeState,
|
||||
authReason: projected.authReason,
|
||||
tags: account.tags,
|
||||
syncVersion: account.sync_version,
|
||||
clientId: account.client_id ?? primaryClientId,
|
||||
partition: sessionHandle?.partition ?? `persist:acc-${account.id}`,
|
||||
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
||||
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
|
||||
lastSyncAt:
|
||||
controller.lastAccountsSyncAt
|
||||
|| parseTimestamp(account.verified_at)
|
||||
|| now,
|
||||
lastVerifiedAt: projected.lastVerifiedAt,
|
||||
lastProbeAt: projected.lastProbeAt,
|
||||
nextProbeAt: projected.nextProbeAt,
|
||||
queueDepth: accountQueueDepth,
|
||||
online: clientOnline,
|
||||
};
|
||||
@@ -150,9 +165,11 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
lastError: controller.lastError,
|
||||
},
|
||||
scheduler,
|
||||
monitorScheduler: getMonitorSchedulerSnapshot(),
|
||||
sessions,
|
||||
hotViews,
|
||||
hotViewPool: getHotViewPoolPolicy(),
|
||||
playwright: getHiddenPlaywrightSnapshot(),
|
||||
processMetrics: getProcessMetricsSnapshot(),
|
||||
vault: describeVaultBackend(),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron/renderer";
|
||||
import type { IpcRendererEvent } from "electron";
|
||||
import type {
|
||||
CreatePublishJobResponse,
|
||||
DesktopAccountInfo,
|
||||
@@ -41,6 +42,17 @@ const desktopBridge = {
|
||||
ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise<null>,
|
||||
releaseRuntimeSession: (revoke?: boolean) =>
|
||||
ipcRenderer.invoke("desktop:runtime-session-release", revoke) as Promise<null>,
|
||||
onRuntimeInvalidated: (
|
||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
||||
) => {
|
||||
const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number }) => {
|
||||
listener(payload);
|
||||
};
|
||||
ipcRenderer.on("desktop:runtime-invalidated", wrapped);
|
||||
return () => {
|
||||
ipcRenderer.off("desktop:runtime-invalidated", wrapped);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const error = shallowRef<string | null>(null);
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscribers = 0;
|
||||
let visibilityListenerBound = false;
|
||||
let runtimeInvalidationUnsubscribe: (() => void) | null = null;
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
@@ -95,6 +96,18 @@ function bindVisibilityListener() {
|
||||
visibilityListenerBound = true;
|
||||
}
|
||||
|
||||
function bindRuntimeInvalidationListener() {
|
||||
if (runtimeInvalidationUnsubscribe || !window.desktopBridge?.app?.onRuntimeInvalidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated(() => {
|
||||
if (isPageVisible()) {
|
||||
void refreshRuntimeSnapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unbindVisibilityListener() {
|
||||
if (!visibilityListenerBound || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -104,10 +117,16 @@ function unbindVisibilityListener() {
|
||||
visibilityListenerBound = false;
|
||||
}
|
||||
|
||||
function unbindRuntimeInvalidationListener() {
|
||||
runtimeInvalidationUnsubscribe?.();
|
||||
runtimeInvalidationUnsubscribe = null;
|
||||
}
|
||||
|
||||
export function useDesktopRuntime() {
|
||||
onMounted(() => {
|
||||
subscribers += 1;
|
||||
bindVisibilityListener();
|
||||
bindRuntimeInvalidationListener();
|
||||
if (!snapshot.value && !loading.value) {
|
||||
void refreshRuntimeSnapshot();
|
||||
}
|
||||
@@ -119,6 +138,7 @@ export function useDesktopRuntime() {
|
||||
if (subscribers === 0) {
|
||||
stopPolling();
|
||||
unbindVisibilityListener();
|
||||
unbindRuntimeInvalidationListener();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ declare global {
|
||||
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
|
||||
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
|
||||
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
||||
onRuntimeInvalidated(
|
||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
||||
): () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,12 +19,29 @@ export interface RuntimeAccount {
|
||||
platformUid: string;
|
||||
avatarUrl: string | null;
|
||||
health: "live" | "expired" | "captcha" | "risk";
|
||||
authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
|
||||
probeState: "idle" | "probing" | "network_error";
|
||||
authReason:
|
||||
| "bind_success"
|
||||
| "probe_success"
|
||||
| "login_redirect"
|
||||
| "http_401"
|
||||
| "captcha_gate"
|
||||
| "missing_partition"
|
||||
| "silent_refresh_failed"
|
||||
| "manual_unbind"
|
||||
| "too_many_failures"
|
||||
| "network_error"
|
||||
| null;
|
||||
tags: string[];
|
||||
syncVersion: number;
|
||||
clientId: string;
|
||||
partition: string;
|
||||
sessionState: "hot" | "warm" | "cold";
|
||||
lastSyncAt: number;
|
||||
lastVerifiedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
nextProbeAt: number | null;
|
||||
queueDepth: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import StatusBadge from "../components/StatusBadge.vue";
|
||||
import SurfaceCard from "../components/SurfaceCard.vue";
|
||||
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
|
||||
import { showClientActionError } from "../lib/client-errors";
|
||||
import { formatDateTime, titleCaseToken } from "../lib/formatters";
|
||||
import { formatDateTime, formatRelativeTime, titleCaseToken } from "../lib/formatters";
|
||||
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
|
||||
import type { RuntimeAccount } from "../types";
|
||||
|
||||
@@ -70,12 +70,13 @@ function platformMeta(platform: string) {
|
||||
}
|
||||
|
||||
function authState(account: AccountRow): AccountAuthState {
|
||||
switch (account.health) {
|
||||
case "live":
|
||||
switch (account.authState) {
|
||||
case "active":
|
||||
return "authorized";
|
||||
case "expired":
|
||||
case "revoked":
|
||||
return "expired";
|
||||
case "captcha":
|
||||
case "challenge_required":
|
||||
return "attention";
|
||||
default:
|
||||
return "risk";
|
||||
@@ -89,9 +90,9 @@ function authStateLabel(account: AccountRow): string {
|
||||
case "expired":
|
||||
return "授权过期";
|
||||
case "attention":
|
||||
return "待补登";
|
||||
return "需人工验证";
|
||||
default:
|
||||
return "风险观察";
|
||||
return account.probeState === "network_error" ? "最近校验失败" : "待重新校验";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +124,16 @@ function sessionStateLabel(account: AccountRow): string {
|
||||
}
|
||||
}
|
||||
|
||||
function verificationTimeLabel(account: AccountRow): string {
|
||||
if (account.lastVerifiedAt) {
|
||||
return `${formatRelativeTime(account.lastVerifiedAt)}校验通过`;
|
||||
}
|
||||
if (account.probeState === "probing") {
|
||||
return "正在校验";
|
||||
}
|
||||
return "尚未校验";
|
||||
}
|
||||
|
||||
const filteredAccounts = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase();
|
||||
|
||||
@@ -382,7 +393,7 @@ const tableColumns = [
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.health === 'live' ? 'success' : record.health === 'expired' ? 'error' : record.health === 'captcha' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
||||
<a-tag :color="authState(record) === 'authorized' ? 'success' : authState(record) === 'expired' ? 'error' : authState(record) === 'attention' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
||||
{{ authStateLabel(record) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
@@ -399,8 +410,8 @@ const tableColumns = [
|
||||
<template v-else-if="column.key === 'syncTime'">
|
||||
<div class="cell-primary-secondary">
|
||||
<div class="info-stack">
|
||||
<span class="title">{{ formatDateTime(record.lastSyncAt) }}</span>
|
||||
<span class="subtitle">{{ record.partition }}</span>
|
||||
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
|
||||
<span class="subtitle">{{ verificationTimeLabel(record) }} · {{ record.partition }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { computed, ref } from "vue";
|
||||
|
||||
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
|
||||
import { showClientActionError } from "../lib/client-errors";
|
||||
import { formatDateTime } from "../lib/formatters";
|
||||
import { formatDateTime, formatRelativeTime } from "../lib/formatters";
|
||||
import { desktopMonitoringMediaCatalog } from "../lib/media-catalog";
|
||||
import type { RuntimeAccount } from "../types";
|
||||
|
||||
@@ -52,15 +52,19 @@ function authLabel(account: AccountRow | null) {
|
||||
return "未绑定";
|
||||
}
|
||||
|
||||
switch (account.health) {
|
||||
case "live":
|
||||
switch (account.authState) {
|
||||
case "active":
|
||||
return "授权正常";
|
||||
case "captcha":
|
||||
return "待处理";
|
||||
case "challenge_required":
|
||||
return "需人工验证";
|
||||
case "expired":
|
||||
return "授权过期";
|
||||
case "expiring_soon":
|
||||
return account.probeState === "network_error" ? "最近校验失败" : "待重新校验";
|
||||
case "revoked":
|
||||
return "已停用";
|
||||
default:
|
||||
return "风险观察";
|
||||
return account.probeState === "probing" ? "校验中" : "待校验";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +73,38 @@ function authColor(account: AccountRow | null) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
switch (account.health) {
|
||||
case "live":
|
||||
switch (account.authState) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "captcha":
|
||||
case "challenge_required":
|
||||
return "warning";
|
||||
case "expired":
|
||||
case "revoked":
|
||||
return "error";
|
||||
case "expiring_soon":
|
||||
return account.probeState === "network_error" ? "warning" : "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function verificationLabel(account: AccountRow): string {
|
||||
if (account.lastVerifiedAt) {
|
||||
return `${formatRelativeTime(account.lastVerifiedAt)}校验通过`;
|
||||
}
|
||||
if (account.probeState === "probing") {
|
||||
return "正在执行首轮校验";
|
||||
}
|
||||
return "尚未完成校验";
|
||||
}
|
||||
|
||||
function nextProbeLabel(account: AccountRow): string {
|
||||
if (!account.nextProbeAt) {
|
||||
return "未排程";
|
||||
}
|
||||
return formatRelativeTime(account.nextProbeAt);
|
||||
}
|
||||
|
||||
function sessionLabel(account: AccountRow): string {
|
||||
switch (account.sessionState) {
|
||||
case "hot":
|
||||
@@ -243,6 +267,14 @@ async function unbindPlatform(account: AccountRow) {
|
||||
<span class="info-label">最近同步</span>
|
||||
<span class="info-value">{{ formatDateTime(platform.account.lastSyncAt) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">校验状态</span>
|
||||
<span class="info-value">{{ verificationLabel(platform.account) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">下次巡检</span>
|
||||
<span class="info-value">{{ nextProbeLabel(platform.account) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
|
||||
@@ -236,7 +236,7 @@ const subsystemCards = computed(() => {
|
||||
/* Metrics Top Grid */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -4,6 +4,7 @@
|
||||
- Verify the repository against the 9-step implementation plan provided by the user.
|
||||
- Use `/Users/liangxu/Documents/test/geo-rankly/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md` as the source of truth for the next development slice.
|
||||
- Continue development immediately after identifying the earliest unfinished step.
|
||||
- For desktop AI monitoring, put scheduling authority on the client, keep scraping silent/background-only, allow stale cross-day tasks to be dropped, and avoid fanning one question to all models at once.
|
||||
|
||||
## Research Findings
|
||||
- The repository already contains core backend scaffolding under `server/`, including `cmd/tenant-api`, `internal/bootstrap`, `shared`, `tenant`, `migrations`, and SQL query files.
|
||||
@@ -62,6 +63,22 @@
|
||||
- `template_prompt.go`, `template_assist.go`, `prompt_generate_service.go`, and `knowledge_service.go` now assemble prompts from the centralized prompt package, so runtime business logic no longer embeds the long prompt text directly.
|
||||
- The prompt centralization also corrected invalid JSON quoting in the seed card config `custom_placeholder` fields, which previously contained unescaped inner quotes.
|
||||
- Targeted verification passed with `go test ./internal/tenant/... ./cmd/dev-seed` from `server/`, confirming the refactor compiles cleanly across the affected runtime and seed paths.
|
||||
- `desktop-client` now includes an AI platform management view with all six target platforms and the existing bind/open/unbind flows wired through the desktop bridge.
|
||||
- `account-binder.ts` already supports all six AI platforms through generic binding definitions, but identity detection is heuristic and not yet platform-specific.
|
||||
- The current desktop runtime executes tasks through a simple in-memory FIFO guarded by a single `currentTaskId`, so all desktop tasks are effectively global-serial today.
|
||||
- Monitor-task dropping by `business_date` is already implemented in the runtime controller; stale tasks are returned as `unknown` with a client-drop reason instead of being retried on the next day.
|
||||
- There is currently no durable local monitor-task queue/cache in `desktop-client`; if the process exits, queued work only survives on the server side.
|
||||
- `process-metrics.ts` already samples Electron process CPU and memory, which gives us enough local telemetry to drive a small adaptive-concurrency policy without introducing a new metrics dependency.
|
||||
- `session-registry.ts` already persists per-account Electron session partitions to `desktop-session-partitions.json`, showing that lightweight file-backed persistence is already accepted in this runtime.
|
||||
- The only implemented monitor adapter is `doubao`, and it still uses the existing hidden `WebContentsView` path rather than Playwright.
|
||||
- `playwright-core` is installed in `apps/desktop-client`, but there is no `connectOverCDP()` runtime integration yet.
|
||||
- Hidden execution primitives already exist in two forms: hidden `BrowserWindow` instances for silent account checks and pooled hidden `WebContentsView` instances for existing adapters.
|
||||
- Runtime diagnostics already expose scheduler and process-metrics state through the desktop snapshot, so new scheduler/CDP fields can be surfaced without inventing a new renderer path.
|
||||
- A new local monitor scheduler now persists queued monitor tasks to `app.getPath("userData")/desktop-monitor-scheduler.json`, recovers same-day tasks after restart, and drops stale cross-day tasks during hydration.
|
||||
- The new scheduler is intentionally conservative when a task has no question-level metadata: unknown-question monitor tasks do not consume the second concurrency slot, which avoids accidental same-question fan-out until server event metadata is available.
|
||||
- Electron process metrics are sufficient to drive a two-tier adaptive concurrency policy: default to `1`, raise to `2` only when the machine looks healthy enough and multiple live AI accounts exist.
|
||||
- Desktop task SSE events now carry optional `business_date`, `scheduler_group_key`, `question_text`, and `title` metadata derived from the task payload, allowing the local scheduler to defer same-question fan-out before leasing.
|
||||
- A new hidden Playwright manager now connects to Electron Chromium through a local CDP endpoint and leases hidden pages bound to the existing account session partition, while leaving the legacy hidden-view path available for current adapters.
|
||||
|
||||
## Technical Decisions
|
||||
| Decision | Rationale |
|
||||
@@ -78,6 +95,9 @@
|
||||
| Introduce a minimal `vue-i18n` foundation now instead of leaving hard-coded strings in new pages | The user explicitly called out the current i18n approach as non-standard |
|
||||
| Keep `AppShell.vue` on the user-preferred version and avoid structural/style rewrites there | The user said that file is managed elsewhere and only allowed inline-style cleanup |
|
||||
| Create a dedicated `internal/tenant/prompts` package for prompt text and seed definitions | This keeps prompt tuning in one place while minimizing refactor risk in the current dirty worktree |
|
||||
| Add the local scheduler before wiring more monitor adapters | This aligns with the user's requirement that the client, not the server, decides actual execution timing |
|
||||
| Keep publish-task execution behavior intact while evolving monitor-task scheduling | Publish is already working and should not be destabilized by monitor-specific policy changes |
|
||||
| Build the hidden Playwright layer as reusable infrastructure instead of burying it inside one adapter | Qwen is the immediate motivation, but the same layer will likely be needed by other complex AI platforms |
|
||||
|
||||
## Issues Encountered
|
||||
| Issue | Resolution |
|
||||
@@ -86,6 +106,7 @@
|
||||
| `make sqlc-generate` fails before any code is generated | Repair the `sqlc.yaml` schema path and then build repository wrappers around generated queries |
|
||||
| `go run` migrate initially failed with `unknown driver postgres` | Added `-tags postgres` to the Makefile-managed migrate command |
|
||||
| Playwright MCP could not open a browser session locally because it tried to create `/.playwright-mcp` | Use `vite preview` plus `curl` verification for this turn instead of spending time on browser tool plumbing |
|
||||
| `desktop-client` has no existing SQLite/local DB layer despite `better-sqlite3` being installed | Decide between a tiny file-backed queue or adding a new DB-backed task store during implementation |
|
||||
|
||||
## Resources
|
||||
- `/Users/liangxu/Documents/test/geo-rankly/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md`
|
||||
|
||||
@@ -240,6 +240,10 @@ export interface DesktopTaskEventMessage {
|
||||
target_account_id: string;
|
||||
target_client_id: string;
|
||||
platform: string;
|
||||
title?: string;
|
||||
business_date?: string;
|
||||
scheduler_group_key?: string;
|
||||
question_text?: string;
|
||||
status:
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
|
||||
+49
@@ -1,5 +1,54 @@
|
||||
# Progress Log
|
||||
|
||||
## Session: 2026-04-20
|
||||
|
||||
### Phase 15: Desktop Scheduler Design & Integration
|
||||
- **Status:** complete
|
||||
- Actions taken:
|
||||
- Re-read the planning files and recovered the current desktop AI monitoring scope from the existing repo changes.
|
||||
- Audited the desktop runtime controller, process metrics sampler, runtime snapshot, session registry, and monitor adapter surface.
|
||||
- Confirmed the current desktop task executor is global-serial, monitor-task staleness dropping is already implemented, and no durable local queue/cache exists yet.
|
||||
- Confirmed `playwright-core` is installed but `connectOverCDP()` is not wired, while hidden Electron execution already exists via `BrowserWindow` and `WebContentsView`.
|
||||
- Added `monitor-scheduler.ts` as a durable file-backed local scheduler for monitor tasks, including same-day persistence, stale cross-day pruning, per-platform mutual exclusion, question-level cooldowns, and lease-miss backoff.
|
||||
- Refactored `runtime-controller.ts` so publish tasks keep a conservative explicit queue while monitor tasks are selected through the local scheduler with adaptive max concurrency from Electron process metrics.
|
||||
- Extended runtime diagnostics to surface the monitor scheduler snapshot and made the lease manager track multiple active leases instead of a single global lease placeholder.
|
||||
- Files created/modified:
|
||||
- `task_plan.md` (updated)
|
||||
- `findings.md` (updated)
|
||||
- `progress.md` (updated)
|
||||
- `apps/desktop-client/src/main/monitor-scheduler.ts` (created)
|
||||
- `apps/desktop-client/src/main/runtime-controller.ts` (updated)
|
||||
- `apps/desktop-client/src/main/lease-manager.ts` (updated)
|
||||
- `apps/desktop-client/src/main/runtime-snapshot.ts` (updated)
|
||||
|
||||
### Phase 16: Hidden Playwright CDP Execution Layer
|
||||
- **Status:** complete
|
||||
- Actions taken:
|
||||
- Added `playwright-cdp.ts` as a reusable hidden-browser manager that launches hidden Electron `BrowserWindow` instances on account-bound sessions and attaches Playwright via `chromium.connectOverCDP()`.
|
||||
- Enabled Electron remote debugging in bootstrap so the app exposes a CDP endpoint for local Playwright attachment.
|
||||
- Extended adapter context with an optional Playwright `{ browser, page }` handle and wired the runtime controller to acquire/release hidden Playwright pages when an adapter declares `executionMode: "playwright"`.
|
||||
- Kept the existing hidden `WebContentsView` path intact so current adapters like Doubao continue to work unchanged.
|
||||
- Files created/modified:
|
||||
- `apps/desktop-client/src/main/playwright-cdp.ts` (created)
|
||||
- `apps/desktop-client/src/main/bootstrap.ts` (updated)
|
||||
- `apps/desktop-client/src/main/adapters/base.ts` (updated)
|
||||
- `apps/desktop-client/src/main/runtime-controller.ts` (updated)
|
||||
|
||||
### Phase 17: Verification & Delivery
|
||||
- **Status:** in_progress
|
||||
- Actions taken:
|
||||
- Added optional scheduler metadata to desktop task events in shared types and server publishers so the local scheduler can make safer fan-out decisions before leasing.
|
||||
- Ran `gofmt` on the modified Go files after the event metadata changes.
|
||||
- Verified `pnpm --filter @geo/desktop-client typecheck` passes.
|
||||
- Verified `pnpm --filter @geo/desktop-client build` passes.
|
||||
- Verified `pnpm --filter admin-web typecheck` still passes after the shared-type event additions.
|
||||
- Verified `go test ./internal/tenant/app/...` passes from `server/`.
|
||||
- Files created/modified:
|
||||
- `packages/shared-types/src/index.ts` (updated)
|
||||
- `server/internal/tenant/app/desktop_task_events.go` (updated)
|
||||
- `server/internal/tenant/app/desktop_task_service.go` (updated)
|
||||
- `server/internal/tenant/app/publish_job_service.go` (updated)
|
||||
|
||||
## Session: 2026-04-04
|
||||
|
||||
### Phase 13: Prompt Centralization
|
||||
|
||||
@@ -2,23 +2,29 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
)
|
||||
|
||||
type DesktopTaskEvent struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id"`
|
||||
JobID string `json:"job_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetAccountID string `json:"target_account_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id"`
|
||||
JobID string `json:"job_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetAccountID string `json:"target_account_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Platform string `json:"platform"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
BusinessDate *string `json:"business_date,omitempty"`
|
||||
SchedulerGroupKey *string `json:"scheduler_group_key,omitempty"`
|
||||
QuestionText *string `json:"question_text,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func publishDesktopTaskEvent(ctx context.Context, client *rabbitmq.Client, event DesktopTaskEvent) error {
|
||||
@@ -33,3 +39,88 @@ func publishDesktopTaskEvent(ctx context.Context, client *rabbitmq.Client, event
|
||||
|
||||
return client.PublishDesktopTaskEvent(ctx, payload)
|
||||
}
|
||||
|
||||
func deriveDesktopTaskEventMetadataFromPayload(
|
||||
kind string,
|
||||
payload []byte,
|
||||
) (title *string, businessDate *string, schedulerGroupKey *string, questionText *string) {
|
||||
if len(payload) == 0 {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
title = firstDesktopTaskEventString(envelope,
|
||||
"title",
|
||||
"question_title",
|
||||
"questionTitle",
|
||||
)
|
||||
businessDate = firstDesktopTaskEventString(envelope,
|
||||
"business_date",
|
||||
"businessDate",
|
||||
"metric_date",
|
||||
"date",
|
||||
)
|
||||
questionText = firstDesktopTaskEventString(envelope,
|
||||
"question_text",
|
||||
"questionText",
|
||||
"query",
|
||||
"question",
|
||||
"prompt",
|
||||
"content",
|
||||
)
|
||||
|
||||
if explicit := firstDesktopTaskEventString(envelope,
|
||||
"scheduler_group_key",
|
||||
"schedulerGroupKey",
|
||||
); explicit != nil {
|
||||
return title, businessDate, explicit, questionText
|
||||
}
|
||||
|
||||
if questionHash := firstDesktopTaskEventString(envelope, "question_hash", "questionHash"); questionHash != nil {
|
||||
return title, businessDate, questionHash, questionText
|
||||
}
|
||||
|
||||
if questionID := firstDesktopTaskEventString(envelope, "question_id", "questionId"); questionID != nil {
|
||||
value := fmt.Sprintf("qid:%s", *questionID)
|
||||
return title, businessDate, &value, questionText
|
||||
}
|
||||
|
||||
if kind == "monitor" && questionText != nil {
|
||||
sum := sha1.Sum([]byte(*questionText))
|
||||
value := fmt.Sprintf("qtxt:%x", sum)
|
||||
return title, businessDate, &value, questionText
|
||||
}
|
||||
|
||||
return title, businessDate, nil, questionText
|
||||
}
|
||||
|
||||
func firstDesktopTaskEventString(payload map[string]any, keys ...string) *string {
|
||||
for _, key := range keys {
|
||||
value, exists := payload[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
return &typed
|
||||
}
|
||||
case float64:
|
||||
text := fmt.Sprintf("%.0f", typed)
|
||||
return &text
|
||||
case int64:
|
||||
text := fmt.Sprintf("%d", typed)
|
||||
return &text
|
||||
case int:
|
||||
text := fmt.Sprintf("%d", typed)
|
||||
return &text
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -725,17 +725,22 @@ func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *reposit
|
||||
return
|
||||
}
|
||||
|
||||
title, businessDate, schedulerGroupKey, questionText := deriveDesktopTaskEventMetadataFromPayload(task.Kind, task.Payload)
|
||||
err := publishDesktopTaskEvent(ctx, s.messaging, DesktopTaskEvent{
|
||||
Type: eventType,
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetAccountID: task.TargetAccountID.String(),
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Platform: task.Platform,
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Type: eventType,
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetAccountID: task.TargetAccountID.String(),
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Platform: task.Platform,
|
||||
Title: title,
|
||||
BusinessDate: businessDate,
|
||||
SchedulerGroupKey: schedulerGroupKey,
|
||||
QuestionText: questionText,
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
s.logWarn("desktop task event publish failed", err, zap.String("task_id", task.DesktopID.String()), zap.String("event_type", eventType))
|
||||
|
||||
@@ -213,17 +213,22 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
|
||||
for _, task := range createdTasks {
|
||||
title, businessDate, schedulerGroupKey, questionText := deriveDesktopTaskEventMetadataFromPayload(task.Kind, task.Payload)
|
||||
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
|
||||
Type: "task_available",
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetAccountID: task.TargetAccountID.String(),
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Platform: task.Platform,
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Type: "task_available",
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetAccountID: task.TargetAccountID.String(),
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Platform: task.Platform,
|
||||
Title: title,
|
||||
BusinessDate: businessDate,
|
||||
SchedulerGroupKey: schedulerGroupKey,
|
||||
QuestionText: questionText,
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}); publishErr != nil {
|
||||
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
|
||||
+31
-5
@@ -1,10 +1,10 @@
|
||||
# Task Plan: GEO admin-web backend completion and frontend foundation
|
||||
|
||||
## Goal
|
||||
Finish the completed backend verification/implementation chain, then resume the interrupted work by establishing the missing frontend foundation for `admin-web` so it can authenticate against `tenant-api` and render the first dashboard slice.
|
||||
Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions.
|
||||
|
||||
## Current Phase
|
||||
Phase 14
|
||||
Phase 17
|
||||
|
||||
## Phases
|
||||
### Phase 1: Progress Verification
|
||||
@@ -94,10 +94,30 @@ Phase 14
|
||||
- [x] Deliver the extraction result and any remaining gaps
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 15: Desktop Scheduler Design & Integration
|
||||
- [x] Audit the current desktop runtime queue/task execution path and lock the scheduler constraints
|
||||
- [x] Implement a durable local monitor-task queue/cache with stale-task cleanup
|
||||
- [x] Implement platform-level mutual exclusion and adaptive global concurrency policy
|
||||
- [x] Integrate the scheduler into runtime leasing/execution without regressing publish tasks
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 16: Hidden Playwright CDP Execution Layer
|
||||
- [x] Add a reusable hidden-browser manager that attaches Playwright to Electron Chromium over CDP
|
||||
- [x] Reuse desktop account partition/session state for hidden pages
|
||||
- [x] Expose the hidden Playwright context/page lifecycle to future monitor adapters
|
||||
- [x] Keep the current hidden-view path working for existing adapters during the transition
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 17: Verification & Delivery
|
||||
- [x] Run targeted desktop type/build verification
|
||||
- [ ] Inspect runtime snapshot output for new scheduler/CDP state
|
||||
- [ ] Summarize what is production-ready versus still scaffolded
|
||||
- **Status:** in_progress
|
||||
|
||||
## Key Questions
|
||||
1. Which of the 9 steps are already implemented versus partially complete?
|
||||
2. What is the earliest step whose acceptance criteria are not yet met?
|
||||
3. What concrete code changes are required now to advance that step safely?
|
||||
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
|
||||
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?
|
||||
3. How can Playwright CDP attach cleanly to Electron Chromium while reusing existing account session partitions?
|
||||
|
||||
## Decisions Made
|
||||
| Decision | Rationale |
|
||||
@@ -115,6 +135,9 @@ Phase 14
|
||||
| Reconstruct the screenshot layouts from visual position data, not OCR text alone | The user explicitly asked to follow image design including element placement |
|
||||
| Move the quota card into the left sidebar footer and use page-level hero sections | This matches the reference screenshots more closely than the previous generic topbar layout |
|
||||
| Centralize prompt text in a dedicated package instead of leaving it inside business logic functions | The user explicitly asked to extract hard-coded prompts for easier future optimization |
|
||||
| Move scheduling authority fully into `desktop-client` | The user explicitly wants the server to dispatch tasks while the client decides when to execute them |
|
||||
| Allow stale cross-day monitor tasks to be dropped on the client | The user explicitly allows漏采 and does not want next-day catch-up for unfinished tasks |
|
||||
| Implement hidden browser infrastructure before expanding more adapters | Qwen and similar platforms cannot reliably use direct API calls and need browser-native execution |
|
||||
|
||||
## Errors Encountered
|
||||
| Error | Attempt | Resolution |
|
||||
@@ -125,9 +148,12 @@ Phase 14
|
||||
| Gemini CLI `gemini-3.1-pro-preview` returned `429 MODEL_CAPACITY_EXHAUSTED` repeatedly | 1 | Stopped the Gemini path after the user requested not to use Gemini anymore |
|
||||
| Playwright MCP navigation failed locally because it attempted to create `/.playwright-mcp` | 1 | Fell back to preview HTTP checks instead of blocking the turn on Playwright environment setup |
|
||||
| Login button click did not trigger the sign-in flow in browser testing even though the backend endpoint worked | 1 | Bound the primary login button directly to `handleSubmit` and re-ran browser verification until the route change succeeded |
|
||||
| Desktop monitor execution is currently global-serial and lacks durable local queueing | 1 | Replace the ad-hoc in-memory FIFO with a scheduler module that owns persistence, concurrency, and stale-task cleanup |
|
||||
| `connectOverCDP()` is not wired yet even though `playwright-core` is installed | 1 | Add a dedicated hidden-browser manager instead of pushing CDP code directly into adapters or the runtime controller |
|
||||
|
||||
## Notes
|
||||
- Re-check task_plan.md before major implementation decisions.
|
||||
- Record concrete evidence for each claimed completed step.
|
||||
- Frontend scope for this turn is limited to the tenant-facing `admin-web` shell, not the platform ops console.
|
||||
- Visual references for this turn must be treated as layout guides, not just copy decks.
|
||||
- Desktop AI monitoring scope for this turn is infrastructure-first: scheduler plus hidden browser layer, not all six adapters.
|
||||
|
||||
Reference in New Issue
Block a user