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:
2026-04-27 21:33:55 +08:00
parent 051976e4a9
commit c3feb7477a
15 changed files with 817 additions and 129 deletions
@@ -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,