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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user