From 184646f9fea20c94cd646e89110aacd1493eea83 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 22 Jul 2026 23:56:06 +0800 Subject: [PATCH] fix(desktop): persist account challenge state and clear it on real execution success Previously a challenge auth state was lost on client restart (rehydrated as active/unknown from lastVerifiedAt alone) and never cleared once a subsequent monitor execution actually succeeded. Persist authState and authReason so a challenge survives a restart, and add markTrackedAccountExecutionSucceeded to clear a stale challenge when a real task run succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/account-health.test.ts | 45 ++++++++++++++++++- .../desktop-client/src/main/account-health.ts | 38 +++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/desktop-client/src/main/account-health.test.ts b/apps/desktop-client/src/main/account-health.test.ts index 5951d2e..79ac8d8 100644 --- a/apps/desktop-client/src/main/account-health.test.ts +++ b/apps/desktop-client/src/main/account-health.test.ts @@ -1,3 +1,4 @@ +import { rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,10 +13,11 @@ const silentRefreshPublishAccountSession = vi.fn() const createSessionHandleForPartition = vi.fn() const resolveStoredSessionPartition = vi.fn() const emitRuntimeInvalidated = vi.fn() +const accountHealthTestDirectory = join(tmpdir(), 'geo-rankly-account-health-test') vi.mock('electron/main', () => ({ app: { - getPath: () => join(tmpdir(), 'geo-rankly-account-health-test'), + getPath: () => accountHealthTestDirectory, }, })) @@ -68,6 +70,7 @@ describe('account health challenge recheck', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + rmSync(join(accountHealthTestDirectory, 'desktop-account-health.json'), { force: true }) resolveStoredSessionPartition.mockReturnValue('persist:test') createSessionHandleForPartition.mockReturnValue({ partition: 'persist:test' }) silentRefreshAIAccountSession.mockResolvedValue(true) @@ -124,4 +127,44 @@ describe('account health challenge recheck', () => { expect(probeAIAccountSession).toHaveBeenCalledWith(account, 'persist:acc-account-1') expect(snapshot.authState).toBe('active') }) + + it('clears a stale challenge after a real monitor execution succeeds', async () => { + probeAIAccountSession.mockResolvedValueOnce(challenge()).mockResolvedValueOnce(challenge()) + const { getAccountHealthSnapshot, markTrackedAccountExecutionSucceeded, probeTrackedAccount } = + await import('./account-health') + + const challenged = await probeTrackedAccount(account, { + trigger: 'manual', + allowSilentRefresh: true, + force: true, + }) + expect(challenged.authState).toBe('challenge_required') + + const recovered = markTrackedAccountExecutionSucceeded(account) + + expect(recovered.authState).toBe('active') + expect(recovered.authReason).toBe('probe_success') + expect(recovered.lastAuthFailureAt).toBeNull() + expect(getAccountHealthSnapshot(account.id)?.authState).toBe('active') + }) + + it('preserves a challenge across a client restart', async () => { + probeAIAccountSession.mockResolvedValueOnce(challenge()).mockResolvedValueOnce(challenge()) + const firstRuntime = await import('./account-health') + + const challenged = await firstRuntime.probeTrackedAccount(account, { + trigger: 'manual', + allowSilentRefresh: true, + force: true, + }) + expect(challenged.authState).toBe('challenge_required') + + vi.resetModules() + const restartedRuntime = await import('./account-health') + + expect(restartedRuntime.getAccountHealthSnapshot(account.id)?.authState).toBe( + 'challenge_required', + ) + expect(restartedRuntime.getAccountHealthSnapshot(account.id)?.lastAuthFailureAt).toBeTruthy() + }) }) diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 08116e6..ec512e9 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -46,6 +46,8 @@ type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm' interface PersistedAccountHealthRecord { accountId: string platform: string + authState?: AccountAuthState + authReason?: AccountAuthReason lastVerifiedAt: number | null lastAuthFailureAt: number | null profile: AccountHealthProfile | null @@ -138,13 +140,26 @@ function nextRegularProbeAt(now = Date.now()): number { } function normalizePersisted(record: PersistedAccountHealthRecord): AccountHealthRecord { + const hasNewerAuthFailure = Boolean( + record.lastAuthFailureAt && + (!record.lastVerifiedAt || record.lastAuthFailureAt >= record.lastVerifiedAt), + ) + const authState = record.authState ?? (hasNewerAuthFailure ? 'challenge_required' : null) + const normalizedAuthState = authState ?? (record.lastVerifiedAt ? 'active' : 'unknown') + return { accountId: record.accountId, platform: record.platform, authRevision: 0, - authState: record.lastVerifiedAt ? 'active' : 'unknown', + authState: normalizedAuthState, probeState: 'idle', - authReason: record.lastVerifiedAt ? 'probe_success' : null, + authReason: + record.authReason ?? + (normalizedAuthState === 'active' + ? 'probe_success' + : normalizedAuthState === 'challenge_required' + ? 'captcha_gate' + : null), lastVerifiedAt: record.lastVerifiedAt ?? null, lastProbeAt: null, nextProbeAt: nextInitialProbeAt(), @@ -176,6 +191,8 @@ function persistRecords(): void { const payload: PersistedAccountHealthRecord[] = [...records.values()].map((record) => ({ accountId: record.accountId, platform: record.platform, + authState: record.authState, + authReason: record.authReason, lastVerifiedAt: record.lastVerifiedAt, lastAuthFailureAt: record.lastAuthFailureAt, profile: record.profile, @@ -891,6 +908,23 @@ export function markTrackedAccountBound( return snapshot } +export function markTrackedAccountExecutionSucceeded( + account: PublishAccountIdentity, +): AccountHealthSnapshot { + const record = ensureRecord(account) + trackedAccounts.set(account.id, account) + return applyActiveResult( + record, + { + verdict: 'active', + reason: 'probe_success', + profile: record.profile, + sessionFingerprint: record.sessionFingerprint, + }, + Date.now(), + ) +} + export function markTrackedAccountMissingPartition( account: PublishAccountIdentity, ): AccountHealthSnapshot {