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
@@ -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(