feat(desktop-client): queue account probes and report health to server
Replace serial probe-on-lock with a bounded probe queue (concurrency 3) that adds a "queued" probe state, manual cooldown, and retry backoff. After each probe the runtime debounces a batched POST to the new /desktop/accounts/health-reports endpoint so the server learns about live, expired, and risk transitions without waiting for a re-bind. Adds an account-scoped IPC (probeRuntimeAccount + runtimeAccountSnapshot) so the renderer can refresh a single row instead of replaying the full snapshot, and exposes the resulting "重新校验" action in AccountsView/AiPlatformsView. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,12 @@ 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;
|
||||
const MAX_CONCURRENT_ACCOUNT_PROBES = 3;
|
||||
const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000;
|
||||
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000;
|
||||
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const;
|
||||
|
||||
type ProbeTrigger = "scheduled" | "manual" | "preflight" | "confirm";
|
||||
|
||||
interface PersistedAccountHealthRecord {
|
||||
accountId: string;
|
||||
@@ -56,11 +62,27 @@ interface AccountHealthRecord {
|
||||
confirmFailureCount: number;
|
||||
}
|
||||
|
||||
interface AccountProbeJob {
|
||||
account: PublishAccountIdentity;
|
||||
trigger: ProbeTrigger;
|
||||
allowSilentRefresh: boolean;
|
||||
force: boolean;
|
||||
status: "queued" | "running";
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
resolve: (snapshot: AccountHealthSnapshot) => void;
|
||||
reject: (error: unknown) => void;
|
||||
promise: Promise<AccountHealthSnapshot>;
|
||||
}
|
||||
|
||||
const trackedAccounts = new Map<string, PublishAccountIdentity>();
|
||||
const records = new Map<string, AccountHealthRecord>();
|
||||
const accountLocks = new Map<string, Promise<void>>();
|
||||
const probeJobs = new Map<string, AccountProbeJob>();
|
||||
const probeQueue: string[] = [];
|
||||
let initialized = false;
|
||||
let probeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let activeProbeCount = 0;
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
if (max <= min) {
|
||||
@@ -193,7 +215,7 @@ function commitRecord(record: AccountHealthRecord, previousSignature: string | n
|
||||
|
||||
const nextSignature = snapshotSignature(record);
|
||||
if (previousSignature !== nextSignature) {
|
||||
emitRuntimeInvalidated("account-health");
|
||||
emitRuntimeInvalidated("account-health", { accountId: record.accountId });
|
||||
}
|
||||
|
||||
return toPublicSnapshot(record);
|
||||
@@ -342,7 +364,7 @@ function applyNetworkResult(
|
||||
async function performProbeLocked(
|
||||
account: PublishAccountIdentity,
|
||||
options: {
|
||||
trigger: "scheduled" | "manual" | "preflight" | "confirm";
|
||||
trigger: ProbeTrigger;
|
||||
allowSilentRefresh: boolean;
|
||||
},
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
@@ -384,6 +406,165 @@ async function performProbeLocked(
|
||||
}
|
||||
}
|
||||
|
||||
function canUseCachedProbe(
|
||||
record: AccountHealthRecord,
|
||||
options: { trigger: ProbeTrigger; force: boolean },
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (options.force) {
|
||||
return Boolean(record.lastProbeAt && now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (
|
||||
options.trigger === "manual"
|
||||
&& record.lastProbeAt
|
||||
&& now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(options.trigger === "manual" || options.trigger === "scheduled")
|
||||
&& record.authState === "active"
|
||||
&& record.lastVerifiedAt
|
||||
&& now - record.lastVerifiedAt < MANUAL_PROBE_CACHE_TTL_MS
|
||||
&& record.probeState !== "network_error"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function markProbeQueued(record: AccountHealthRecord): AccountHealthSnapshot {
|
||||
const previousSignature = snapshotSignature(record);
|
||||
record.probeState = "queued";
|
||||
return commitRecord(record, previousSignature);
|
||||
}
|
||||
|
||||
function maxAttemptsForTrigger(trigger: ProbeTrigger): number {
|
||||
return trigger === "preflight" ? 1 : ACCOUNT_PROBE_RETRY_BACKOFF_MS.length + 1;
|
||||
}
|
||||
|
||||
function enqueueProbeJob(job: AccountProbeJob): void {
|
||||
if (!probeQueue.includes(job.account.id)) {
|
||||
probeQueue.push(job.account.id);
|
||||
}
|
||||
drainProbeQueue();
|
||||
}
|
||||
|
||||
function scheduleProbeRetry(job: AccountProbeJob): void {
|
||||
const retryIndex = Math.min(Math.max(job.attempt - 2, 0), ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1);
|
||||
const backoff = ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex]
|
||||
?? ACCOUNT_PROBE_RETRY_BACKOFF_MS[ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1];
|
||||
job.status = "queued";
|
||||
setTimeout(() => enqueueProbeJob(job), backoff);
|
||||
}
|
||||
|
||||
function drainProbeQueue(): void {
|
||||
while (activeProbeCount < MAX_CONCURRENT_ACCOUNT_PROBES && probeQueue.length > 0) {
|
||||
const accountId = probeQueue.shift();
|
||||
if (!accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = probeJobs.get(accountId);
|
||||
if (!job || job.status !== "queued") {
|
||||
continue;
|
||||
}
|
||||
|
||||
job.status = "running";
|
||||
activeProbeCount += 1;
|
||||
void runProbeJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProbeJob(job: AccountProbeJob): Promise<void> {
|
||||
try {
|
||||
const snapshot = await withAccountLock(job.account.id, async () => {
|
||||
const record = ensureRecord(job.account);
|
||||
if (canUseCachedProbe(record, job)) {
|
||||
return toPublicSnapshot(record);
|
||||
}
|
||||
return await performProbeLocked(job.account, {
|
||||
trigger: job.trigger,
|
||||
allowSilentRefresh: job.allowSilentRefresh,
|
||||
});
|
||||
});
|
||||
|
||||
if (snapshot.probeState === "network_error" && job.attempt < job.maxAttempts) {
|
||||
job.attempt += 1;
|
||||
scheduleProbeRetry(job);
|
||||
return;
|
||||
}
|
||||
|
||||
probeJobs.delete(job.account.id);
|
||||
job.resolve(snapshot);
|
||||
} catch (error) {
|
||||
probeJobs.delete(job.account.id);
|
||||
job.reject(error);
|
||||
} finally {
|
||||
activeProbeCount = Math.max(0, activeProbeCount - 1);
|
||||
drainProbeQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueAccountProbe(
|
||||
account: PublishAccountIdentity,
|
||||
options: {
|
||||
trigger?: ProbeTrigger;
|
||||
allowSilentRefresh?: boolean;
|
||||
force?: boolean;
|
||||
} = {},
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
syncTrackedAccountsInternal([account], false);
|
||||
|
||||
const trigger = options.trigger ?? "manual";
|
||||
const force = Boolean(options.force);
|
||||
const record = ensureRecord(account);
|
||||
if (canUseCachedProbe(record, { trigger, force })) {
|
||||
return Promise.resolve(toPublicSnapshot(record));
|
||||
}
|
||||
|
||||
const existing = probeJobs.get(account.id);
|
||||
if (existing) {
|
||||
if (force) {
|
||||
existing.force = true;
|
||||
}
|
||||
existing.allowSilentRefresh = existing.allowSilentRefresh || Boolean(options.allowSilentRefresh);
|
||||
if (trigger === "manual") {
|
||||
existing.trigger = "manual";
|
||||
}
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
markProbeQueued(record);
|
||||
|
||||
let resolve!: (snapshot: AccountHealthSnapshot) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<AccountHealthSnapshot>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
|
||||
const job: AccountProbeJob = {
|
||||
account,
|
||||
trigger,
|
||||
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||
force,
|
||||
status: "queued",
|
||||
attempt: 1,
|
||||
maxAttempts: maxAttemptsForTrigger(trigger),
|
||||
resolve,
|
||||
reject,
|
||||
promise,
|
||||
};
|
||||
|
||||
probeJobs.set(account.id, job);
|
||||
enqueueProbeJob(job);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function runDueProbes(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
||||
@@ -398,10 +579,14 @@ async function runDueProbes(): Promise<void> {
|
||||
}
|
||||
|
||||
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
|
||||
void withAccountLock(account.id, async () => {
|
||||
await performProbeLocked(account, {
|
||||
trigger,
|
||||
allowSilentRefresh: false,
|
||||
void enqueueAccountProbe(account, {
|
||||
trigger,
|
||||
allowSilentRefresh: false,
|
||||
}).catch((error) => {
|
||||
console.warn("[desktop-account-health] scheduled probe failed", {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -454,6 +639,7 @@ function syncTrackedAccountsInternal(
|
||||
trackedAccounts.delete(accountId);
|
||||
records.delete(accountId);
|
||||
removed = true;
|
||||
emitRuntimeInvalidated("account-health", { accountId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,7 +661,7 @@ export function forgetTrackedAccountHealth(
|
||||
trackedAccounts.delete(accountId);
|
||||
if (records.delete(accountId)) {
|
||||
persistRecords();
|
||||
emitRuntimeInvalidated("account-health");
|
||||
emitRuntimeInvalidated("account-health", { accountId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,16 +712,15 @@ export function getProjectedAccountHealth(input: {
|
||||
export async function probeTrackedAccount(
|
||||
account: PublishAccountIdentity,
|
||||
options: {
|
||||
trigger?: "scheduled" | "manual" | "preflight" | "confirm";
|
||||
trigger?: ProbeTrigger;
|
||||
allowSilentRefresh?: boolean;
|
||||
force?: boolean;
|
||||
} = {},
|
||||
): Promise<AccountHealthSnapshot> {
|
||||
syncTrackedAccountsInternal([account], false);
|
||||
return await withAccountLock(account.id, async () => {
|
||||
return await performProbeLocked(account, {
|
||||
trigger: options.trigger ?? "manual",
|
||||
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||
});
|
||||
return await enqueueAccountProbe(account, {
|
||||
trigger: options.trigger ?? "manual",
|
||||
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||
force: Boolean(options.force),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AccountAuthState =
|
||||
| "revoked";
|
||||
|
||||
export type AccountProbeState =
|
||||
| "queued"
|
||||
| "idle"
|
||||
| "probing"
|
||||
| "network_error";
|
||||
|
||||
@@ -24,12 +24,13 @@ import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
|
||||
import { onRuntimeInvalidated } from "./runtime-events";
|
||||
import {
|
||||
noteRuntimeAccountBound,
|
||||
requestRuntimeAccountProbe,
|
||||
refreshRuntimeAccounts,
|
||||
releaseRuntimeSession,
|
||||
syncRuntimeSession,
|
||||
unbindRuntimeAccount,
|
||||
} from "./runtime-controller";
|
||||
import { createRuntimeSnapshot } from "./runtime-snapshot";
|
||||
import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from "./runtime-snapshot";
|
||||
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
|
||||
import { initScheduler } from "./scheduler";
|
||||
import { initSessionRegistry } from "./session-registry";
|
||||
@@ -154,6 +155,10 @@ function captureRuntimeSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function captureRuntimeAccountSnapshot(accountId: string) {
|
||||
return createRuntimeAccountSnapshot(accountId);
|
||||
}
|
||||
|
||||
async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
|
||||
const existing = currentMainWindow();
|
||||
if (existing) {
|
||||
@@ -314,6 +319,9 @@ function registerBridgeHandlers(): void {
|
||||
}),
|
||||
);
|
||||
ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
|
||||
ipcMain.handle("desktop:runtime-account-snapshot", (_event, accountId: string) =>
|
||||
captureRuntimeAccountSnapshot(accountId),
|
||||
);
|
||||
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
|
||||
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
|
||||
noteRuntimeAccountBound(account);
|
||||
@@ -328,6 +336,10 @@ function registerBridgeHandlers(): void {
|
||||
await refreshRuntimeAccounts();
|
||||
return null;
|
||||
});
|
||||
safeHandle("desktop:probe-runtime-account", async (_event, accountId: string) => {
|
||||
requestRuntimeAccountProbe(accountId);
|
||||
return captureRuntimeAccountSnapshot(accountId);
|
||||
});
|
||||
safeHandle(
|
||||
"desktop:open-publish-account-console",
|
||||
async (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiClientError } from "@geo/http-client";
|
||||
import type {
|
||||
DesktopArticleContent,
|
||||
DesktopAccountInfo,
|
||||
DesktopAccountHealthReport,
|
||||
DesktopClientInfo,
|
||||
MonitoringLeaseTask,
|
||||
MonitoringSourceItem,
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
noteTransportHeartbeat,
|
||||
noteTransportPull,
|
||||
offlineDesktopClient,
|
||||
reportDesktopAccountHealth,
|
||||
resumeMonitoringTasks,
|
||||
revokeDesktopClient,
|
||||
setTransportAuthState,
|
||||
@@ -151,6 +153,8 @@ const publishReservedSlots = 1;
|
||||
const minimumForegroundTotalConcurrency = 2;
|
||||
const maximumRuntimeTotalConcurrency = 4;
|
||||
const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用";
|
||||
const accountHealthReportDebounceMs = 1_000;
|
||||
const accountHealthReportMaxBatchSize = 100;
|
||||
|
||||
interface RuntimeTaskRecord {
|
||||
id: string;
|
||||
@@ -233,6 +237,8 @@ interface RuntimeState {
|
||||
publishFallbackBackoffUntil: number;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null;
|
||||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>;
|
||||
dispatchWsClient: DispatchWsClient | null;
|
||||
dispatchWsConnected: boolean;
|
||||
lastHeartbeatAt: number;
|
||||
@@ -258,6 +264,8 @@ const state: RuntimeState = {
|
||||
publishFallbackBackoffUntil: 0,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
accountHealthReportTimer: null,
|
||||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||
dispatchWsClient: null,
|
||||
dispatchWsConnected: false,
|
||||
lastHeartbeatAt: 0,
|
||||
@@ -496,6 +504,10 @@ function stopRuntime(): void {
|
||||
clearInterval(state.pullTimer);
|
||||
state.pullTimer = null;
|
||||
}
|
||||
if (state.accountHealthReportTimer) {
|
||||
clearTimeout(state.accountHealthReportTimer);
|
||||
state.accountHealthReportTimer = null;
|
||||
}
|
||||
if (state.dispatchWsClient) {
|
||||
state.dispatchWsClient.stop();
|
||||
state.dispatchWsClient = null;
|
||||
@@ -519,6 +531,7 @@ function clearRuntimeState(): void {
|
||||
state.tasks.clear();
|
||||
state.accounts = [];
|
||||
state.accountProfiles.clear();
|
||||
state.accountHealthReportQueue.clear();
|
||||
state.activeExecutions.clear();
|
||||
state.lastHeartbeatAt = 0;
|
||||
state.lastHeartbeatStatus = "idle";
|
||||
@@ -897,7 +910,6 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
|
||||
try {
|
||||
const accounts = await listDesktopAccounts();
|
||||
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
|
||||
state.lastAccountsSyncAt = Date.now();
|
||||
state.lastError = null;
|
||||
|
||||
@@ -906,22 +918,11 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
|
||||
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 syncedAccounts: Array<DesktopAccountInfo | null> = accounts.map((account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const isAIPlatform = isAIPlatformId(account.platform);
|
||||
const identity = accountIdentityFromDesktopAccount(account);
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
@@ -961,41 +962,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
};
|
||||
|
||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
||||
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: 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: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at,
|
||||
tags: resolvedAccount.tags,
|
||||
});
|
||||
|
||||
resolvedAccount = {
|
||||
...updated,
|
||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||
health: resolvedAccount.health,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] account relink failed", {
|
||||
accountId: resolvedAccount.id,
|
||||
platform: resolvedAccount.platform,
|
||||
platformUid: resolvedAccount.platform_uid,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAccount;
|
||||
}));
|
||||
});
|
||||
const duplicateAccounts: DesktopAccountInfo[] = [];
|
||||
const dedupedAccounts: DesktopAccountInfo[] = [];
|
||||
const seenAccountKeys = new Set<string>();
|
||||
@@ -1061,10 +1029,161 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]):
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccountHealthReport(account: DesktopAccountInfo): DesktopAccountHealthReport {
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
return {
|
||||
account_id: account.id,
|
||||
platform: account.platform,
|
||||
platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
health: projected.health,
|
||||
auth_state: projected.authState,
|
||||
probe_state: projected.probeState,
|
||||
auth_reason: projected.authReason,
|
||||
display_name: projected.profile?.displayName ?? account.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function enqueueAccountHealthReport(accountId: string): void {
|
||||
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.accountHealthReportQueue.set(account.id, buildAccountHealthReport(account));
|
||||
if (state.accountHealthReportTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.accountHealthReportTimer = setTimeout(() => {
|
||||
state.accountHealthReportTimer = null;
|
||||
void flushAccountHealthReports();
|
||||
}, accountHealthReportDebounceMs);
|
||||
}
|
||||
|
||||
async function flushAccountHealthReports(): Promise<void> {
|
||||
if (state.accountHealthReportQueue.size === 0 || !canRunLive(state.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reports = [...state.accountHealthReportQueue.values()].slice(0, accountHealthReportMaxBatchSize);
|
||||
for (const report of reports) {
|
||||
state.accountHealthReportQueue.delete(report.account_id);
|
||||
}
|
||||
|
||||
try {
|
||||
await reportDesktopAccountHealth({ reports });
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] account health report failed", {
|
||||
count: reports.length,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
for (const report of reports) {
|
||||
state.accountHealthReportQueue.set(report.account_id, report);
|
||||
}
|
||||
} finally {
|
||||
if (state.accountHealthReportQueue.size > 0 && !state.accountHealthReportTimer) {
|
||||
state.accountHealthReportTimer = setTimeout(() => {
|
||||
state.accountHealthReportTimer = null;
|
||||
void flushAccountHealthReports();
|
||||
}, accountHealthReportDebounceMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mirrorProjectedAccountProfile(accountId: string): Promise<void> {
|
||||
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
if (!isDefinitiveAuthState(projected.authState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDisplayName = projected.profile?.displayName ?? account.display_name;
|
||||
const nextAvatarUrl = projected.profile?.avatarUrl ?? account.avatar_url;
|
||||
const shouldMirrorProfile =
|
||||
account.display_name !== nextDisplayName
|
||||
|| account.avatar_url !== nextAvatarUrl;
|
||||
if (!shouldMirrorProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: account.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: nextDisplayName,
|
||||
avatar_url: nextAvatarUrl,
|
||||
account_fingerprint: account.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: account.health,
|
||||
verified_at: account.verified_at,
|
||||
tags: account.tags,
|
||||
});
|
||||
|
||||
state.accounts = state.accounts.map((item) =>
|
||||
item.id === account.id
|
||||
? {
|
||||
...updated,
|
||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||
health: account.health,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
state.accountProfiles.set(account.id, {
|
||||
platformUid: normalizedPlatformUid,
|
||||
displayName: nextDisplayName,
|
||||
avatarUrl: nextAvatarUrl,
|
||||
});
|
||||
refreshAccountNames();
|
||||
}
|
||||
|
||||
export async function refreshRuntimeAccounts(): Promise<void> {
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
export function requestRuntimeAccountProbe(
|
||||
accountId: string,
|
||||
options: { account?: PublishAccountIdentity; force?: boolean } = {},
|
||||
): void {
|
||||
const existingAccount = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
const account = options.account ?? (existingAccount ? accountIdentityFromDesktopAccount(existingAccount) : null);
|
||||
if (!account) {
|
||||
throw new Error("runtime_account_not_found");
|
||||
}
|
||||
|
||||
void probeTrackedAccount(account, {
|
||||
trigger: "manual",
|
||||
allowSilentRefresh: true,
|
||||
force: options.force ?? true,
|
||||
})
|
||||
.then(async () => {
|
||||
enqueueAccountHealthReport(account.id);
|
||||
await mirrorProjectedAccountProfile(account.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[desktop-runtime] account probe failed", {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
||||
const normalizedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
|
||||
@@ -3,14 +3,19 @@ type RuntimeInvalidationReason = "account-health";
|
||||
type RuntimeInvalidationListener = (event: {
|
||||
reason: RuntimeInvalidationReason;
|
||||
at: number;
|
||||
accountId?: string;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RuntimeInvalidationListener>();
|
||||
|
||||
export function emitRuntimeInvalidated(reason: RuntimeInvalidationReason): void {
|
||||
export function emitRuntimeInvalidated(
|
||||
reason: RuntimeInvalidationReason,
|
||||
details: { accountId?: string } = {},
|
||||
): void {
|
||||
const event = {
|
||||
reason,
|
||||
at: Date.now(),
|
||||
...details,
|
||||
} as const;
|
||||
|
||||
for (const listener of listeners) {
|
||||
@@ -31,4 +36,3 @@ export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): ()
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,83 @@ function parseTimestamp(value: string | null | undefined): number | null {
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
type RuntimeControllerSnapshot = ReturnType<typeof getRuntimeControllerSnapshot>;
|
||||
type RuntimeControllerAccount = RuntimeControllerSnapshot["accounts"][number];
|
||||
|
||||
function createRuntimeAccountView(
|
||||
controller: RuntimeControllerSnapshot,
|
||||
account: RuntimeControllerAccount,
|
||||
context: {
|
||||
hotViews: ReturnType<typeof listHotViews>;
|
||||
primaryClientId: string;
|
||||
clientOnline: boolean;
|
||||
lastAccountsSyncAt: number;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
const sessionHandle = getSessionHandle(account.id);
|
||||
const isHot = context.hotViews.some((item) => item.accountId === account.id);
|
||||
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: projected.profile?.displayName ?? account.display_name,
|
||||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
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 ?? context.primaryClientId,
|
||||
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
||||
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
|
||||
lastSyncAt:
|
||||
context.lastAccountsSyncAt
|
||||
|| parseTimestamp(account.verified_at)
|
||||
|| context.now,
|
||||
lastVerifiedAt: projected.lastVerifiedAt,
|
||||
lastProbeAt: projected.lastProbeAt,
|
||||
nextProbeAt: projected.nextProbeAt,
|
||||
queueDepth: accountQueueDepth,
|
||||
online: context.clientOnline,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeSnapshot() {
|
||||
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
|
||||
}
|
||||
|
||||
function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeControllerSnapshot>) {
|
||||
export function createRuntimeAccountSnapshot(accountId: string) {
|
||||
const controller = getRuntimeControllerSnapshot();
|
||||
const now = Date.now();
|
||||
const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self";
|
||||
const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed";
|
||||
const account = controller.accounts.find((item) => item.id === accountId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createRuntimeAccountView(controller, account, {
|
||||
hotViews: listHotViews(),
|
||||
primaryClientId,
|
||||
clientOnline,
|
||||
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
||||
const now = Date.now();
|
||||
// Electron Session instances are not safe to ship across IPC to the renderer.
|
||||
const sessions = listSessionHandleSnapshots();
|
||||
@@ -70,45 +142,15 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
},
|
||||
] as const;
|
||||
|
||||
const accounts = controller.accounts.map((account) => {
|
||||
const sessionHandle = getSessionHandle(account.id);
|
||||
const isHot = hotViews.some((item) => item.accountId === account.id);
|
||||
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: projected.profile?.displayName ?? account.display_name,
|
||||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
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: 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,
|
||||
};
|
||||
});
|
||||
const accounts = controller.accounts.map((account) =>
|
||||
createRuntimeAccountView(controller, account, {
|
||||
hotViews,
|
||||
primaryClientId,
|
||||
clientOnline,
|
||||
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
||||
now,
|
||||
}),
|
||||
);
|
||||
|
||||
const tasks = controller.tasks.map((task) => ({ ...task }));
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
LeaseDesktopTaskRequest,
|
||||
LeaseDesktopTaskResponse,
|
||||
ListDesktopPublishTasksParams,
|
||||
ReportDesktopAccountHealthRequest,
|
||||
ReportDesktopAccountHealthResponse,
|
||||
MonitoringLeaseTasksPayload,
|
||||
MonitoringLeaseTasksResponse,
|
||||
MonitoringResumeTasksPayload,
|
||||
@@ -65,6 +67,16 @@ let transportSession: DesktopTransportSession = {
|
||||
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
|
||||
clientToken: null,
|
||||
};
|
||||
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
interface AccountUpsertCacheEntry {
|
||||
signature: string;
|
||||
expiresAt: number;
|
||||
response: DesktopAccountInfo;
|
||||
inFlight: Promise<DesktopAccountInfo> | null;
|
||||
}
|
||||
|
||||
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>();
|
||||
|
||||
const transportState = {
|
||||
baseURL: transportSession.baseURL,
|
||||
@@ -135,6 +147,43 @@ function createDesktopClient(baseURL: string): ApiClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string {
|
||||
return [
|
||||
payload.platform.trim(),
|
||||
payload.platform_uid.trim(),
|
||||
].join("::");
|
||||
}
|
||||
|
||||
function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
|
||||
return stableJson({
|
||||
platform: payload.platform.trim(),
|
||||
platform_uid: payload.platform_uid.trim(),
|
||||
account_fingerprint: payload.account_fingerprint ?? null,
|
||||
display_name: payload.display_name.trim(),
|
||||
avatar_url: payload.avatar_url ?? null,
|
||||
health: payload.health,
|
||||
tags: payload.tags ?? [],
|
||||
if_sync_version: payload.if_sync_version ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function clearAccountUpsertCache(): void {
|
||||
accountUpsertCache.clear();
|
||||
}
|
||||
|
||||
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
|
||||
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
|
||||
}
|
||||
@@ -319,6 +368,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
||||
transportState.auth = "pending";
|
||||
transportState.dispatchState = "idle";
|
||||
desktopApiClient = null;
|
||||
clearAccountUpsertCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -332,6 +382,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
||||
transportState.dispatchState = session.client_token ? "connecting" : "idle";
|
||||
transportState.initializedAt = Date.now();
|
||||
desktopApiClient = createDesktopClient(transportSession.baseURL);
|
||||
clearAccountUpsertCache();
|
||||
return desktopApiClient;
|
||||
}
|
||||
|
||||
@@ -425,10 +476,52 @@ export async function retryDesktopPublishTask(taskId: string): Promise<CreatePub
|
||||
export async function upsertDesktopAccount(
|
||||
payload: UpsertDesktopAccountRequest,
|
||||
): Promise<DesktopAccountInfo> {
|
||||
return desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
|
||||
const now = Date.now();
|
||||
const cacheKey = accountUpsertCacheKey(payload);
|
||||
const signature = accountUpsertSignature(payload);
|
||||
const cached = accountUpsertCache.get(cacheKey);
|
||||
if (cached && cached.signature === signature && cached.expiresAt > now) {
|
||||
if (cached.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
return cached.response;
|
||||
}
|
||||
|
||||
const request = desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
|
||||
"/api/desktop/accounts",
|
||||
payload,
|
||||
);
|
||||
accountUpsertCache.set(cacheKey, {
|
||||
signature,
|
||||
expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
||||
response: cached?.response ?? ({} as DesktopAccountInfo),
|
||||
inFlight: request,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request;
|
||||
accountUpsertCache.set(cacheKey, {
|
||||
signature,
|
||||
expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
||||
response,
|
||||
inFlight: null,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (accountUpsertCache.get(cacheKey)?.inFlight === request) {
|
||||
accountUpsertCache.delete(cacheKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportDesktopAccountHealth(
|
||||
payload: ReportDesktopAccountHealthRequest,
|
||||
): Promise<ReportDesktopAccountHealthResponse> {
|
||||
return desktopPost<ReportDesktopAccountHealthResponse, ReportDesktopAccountHealthRequest>(
|
||||
"/api/desktop/accounts/health-reports",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteDesktopAccount(
|
||||
@@ -436,9 +529,11 @@ export async function deleteDesktopAccount(
|
||||
ifSyncVersion: number,
|
||||
): Promise<DesktopAccountInfo> {
|
||||
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
|
||||
return desktopDelete<DesktopAccountInfo>(
|
||||
const response = await desktopDelete<DesktopAccountInfo>(
|
||||
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
|
||||
);
|
||||
clearAccountUpsertCache();
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function leaseDesktopTask(
|
||||
|
||||
Reference in New Issue
Block a user