diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx index 76a9dfe0c5a..dc5796ef330 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx @@ -31,6 +31,7 @@ import type { AccountCredentialKind } from "../../utils/apiBilling"; import { type FoundLogin, findCompletedLogin, + nextBaseline, } from "../../utils/findCompletedLogin"; import { switchSignInCommand } from "../../utils/switchSignInCommand"; import type { ManagedAgent } from "../../utils/visibleQuotaAgents"; @@ -115,8 +116,8 @@ export function AddAccountDialog({ const loginsQuery = useHostUsageLogins(hostUrl, open && !found); const setDefault = useSetDefaultUsageAccount(hostUrl); - // The first poll result after opening is the baseline; anything beyond it - // is the sign-in we are waiting for. + // The first poll result after opening is the baseline (see nextBaseline); + // anything beyond it is the sign-in we are waiting for. useEffect(() => { if (!open) { baselineRef.current = null; @@ -162,6 +163,8 @@ export function AddAccountDialog({ setFound(fresh); onAccountAdded(); if (fresh.selection) provisionProfile(fresh.selection); + } else { + baselineRef.current = nextBaseline(baselineRef.current, logins); } }, [ open, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.test.ts index d83b6e9503f..d6d72012271 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { UsageLogins } from "../../../../hooks/useHostUsageLogins"; -import { findCompletedLogin } from "./findCompletedLogin"; +import { findCompletedLogin, nextBaseline } from "./findCompletedLogin"; const selection = "/home/test/.claude-work"; const empty: UsageLogins = { homeDir: "/home/test", claude: [], + claudePartial: false, codex: [], claudeDefaultEmail: null, claudeDefaultFingerprint: null, @@ -16,6 +17,8 @@ const profile = { credentialKind: "subscription" as const, fingerprint: "token-one", }; +// Discovery stopped at its time budget, before it reached `selection`. +const partial: UsageLogins = { ...empty, claudePartial: true }; const check = ( current: UsageLogins, baseline = empty, @@ -47,6 +50,39 @@ describe("findCompletedLogin", () => { const snapshot = { ...empty, claude: [profile] }; expect(check(snapshot, snapshot)).toBeNull(); }); + it("does not take a profile a partial baseline left out as signed in", () => + expect(check({ ...empty, claude: [profile] }, partial)).toBeNull()); + it("still sees a re-login of a profile a partial baseline has", () => + expect( + check( + { ...empty, claude: [profile] }, + { ...partial, claude: [{ ...profile, fingerprint: "old-token" }] }, + ), + ).not.toBeNull()); + it("still accepts a finished CLI login after a partial baseline", () => + expect( + check({ ...empty, claude: [profile] }, partial, true), + ).not.toBeNull()); + it("does not apply the Claude scan's budget to Codex homes", () => + expect( + findCompletedLogin({ + agent: "codex", + credentialKind: "subscription", + selection: "/home/test/.codex-work", + baseline: partial, + current: { + ...empty, + codex: [ + { + home: "/home/test/.codex-work", + credentialKind: "subscription", + fingerprint: "auth-hash", + }, + ], + }, + commandSucceeded: false, + }), + ).not.toBeNull()); it("detects re-login to the same email by credentials", () => expect( check( @@ -140,3 +176,13 @@ describe("findCompletedLogin", () => { }), ).toBeNull()); }); + +describe("nextBaseline", () => { + it("swaps a partial baseline for the first complete poll", () => { + const complete = { ...empty, claude: [profile] }; + expect(nextBaseline(partial, { ...partial })).toBe(partial); + expect(nextBaseline(partial, complete)).toBe(complete); + expect(nextBaseline(complete, partial)).toBe(complete); + expect(nextBaseline(complete, { ...complete })).toBe(complete); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.ts index cf094fa9afe..7558d1761d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/findCompletedLogin.ts @@ -48,5 +48,21 @@ export function findCompletedLogin({ return null; if (!commandSucceeded && after.fingerprint === before?.fingerprint) return null; + // A profile left out of a partial scan may have been signed in all along. + if ( + !commandSucceeded && + !before && + agent === "claude" && + baseline.claudePartial + ) + return null; return { selection, label: after.label, credentialKind }; } + +/** A partial Claude scan's baseline gives way to the first complete poll. */ +export function nextBaseline( + baseline: UsageLogins, + current: UsageLogins, +): UsageLogins { + return baseline.claudePartial && !current.claudePartial ? current : baseline; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/index.ts index e259c738651..3153b8dfa5d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/findCompletedLogin/index.ts @@ -1 +1,5 @@ -export { type FoundLogin, findCompletedLogin } from "./findCompletedLogin"; +export { + type FoundLogin, + findCompletedLogin, + nextBaseline, +} from "./findCompletedLogin"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.test.ts new file mode 100644 index 00000000000..2e00ef2a6d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { TRPCClientError } from "@trpc/client"; +import { hostServiceQueryRetry } from "renderer/lib/host-service-client"; +import { loginsQueryRetry } from "./useHostUsageLogins"; + +function hostError(code: string) { + return TRPCClientError.from({ + error: { message: `usage.logins: ${code}`, code: -32603, data: { code } }, + }); +} + +describe("loginsQueryRetry", () => { + it("retries one timed-out poll before it shows as an error", () => { + expect(loginsQueryRetry(0, hostError("TIMEOUT"))).toBe(true); + expect(loginsQueryRetry(1, hostError("TIMEOUT"))).toBe(false); + }); + + it("keeps the connection retries of every host-service query", () => { + const errors = [ + TRPCClientError.from(new TypeError("Failed to fetch")), + hostError("SERVICE_UNAVAILABLE"), + ]; + for (const error of errors) { + for (const failureCount of [0, 1, 2, 3]) { + expect(loginsQueryRetry(failureCount, error)).toBe( + hostServiceQueryRetry(failureCount, error), + ); + } + } + }); + + it("does not retry other host errors", () => { + for (const code of [ + "UNAUTHORIZED", + "BAD_REQUEST", + "INTERNAL_SERVER_ERROR", + ]) { + expect(loginsQueryRetry(0, hostError(code))).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.ts index 6bb4f5c9771..00b5fdcd8f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/hooks/useHostUsageLogins/useHostUsageLogins.ts @@ -1,11 +1,28 @@ import type { AppRouter } from "@superset/host-service"; import { useQuery } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; import type { inferRouterOutputs } from "@trpc/server"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + getHostServiceClientByUrl, + hostServiceQueryRetry, + hostServiceQueryRetryDelay, +} from "renderer/lib/host-service-client"; export type UsageLogins = inferRouterOutputs["usage"]["logins"]; const LOGIN_POLL_INTERVAL_MS = 3_000; +const LOGIN_TIMEOUT_RETRIES = 1; + +/** + * A timed-out scan keeps running on the host and the retry joins it, so one + * slow poll is retried before it shows as an error. + */ +export function loginsQueryRetry(failureCount: number, error: unknown) { + if (error instanceof TRPCClientError && error.data?.code === "TIMEOUT") { + return failureCount < LOGIN_TIMEOUT_RETRIES; + } + return hostServiceQueryRetry(failureCount, error); +} /** * Local-only login discovery on the host (no provider network calls), polled @@ -24,6 +41,8 @@ export function useHostUsageLogins(hostUrl: string | null, enabled: boolean) { }, refetchInterval: enabled ? LOGIN_POLL_INTERVAL_MS : false, refetchIntervalInBackground: true, + retry: loginsQueryRetry, + retryDelay: hostServiceQueryRetryDelay, gcTime: 0, }); } diff --git a/packages/host-service/QUERY_TIMEOUTS.md b/packages/host-service/QUERY_TIMEOUTS.md index 7f1b304b7e5..c71a2645432 100644 --- a/packages/host-service/QUERY_TIMEOUTS.md +++ b/packages/host-service/QUERY_TIMEOUTS.md @@ -2,9 +2,10 @@ Every host-service query procedure has a server-side timeout. If a query takes longer than its budget, the procedure rejects with -`TRPCError({ code: "TIMEOUT" })`. The renderer's `QueryClient` retries -those errors a couple of times with linear backoff, so transient slow -ops self-recover without leaving the UI spinning forever. +`TRPCError({ code: "TIMEOUT" })`. The `QueryClient` in +`WorkspaceClientProvider` retries those errors a couple of times with +linear backoff, so transient slow ops self-recover without leaving the +UI spinning forever. ## Where it lives @@ -16,7 +17,10 @@ ops self-recover without leaving the UI spinning forever. builder. Without `meta`, the default is 5000 ms. - **Client retry**: `WorkspaceClientProvider` in `packages/workspace-client` retries `TIMEOUT` errors up to 2 times with `300 ms × attempt` backoff. - Other errors keep the previous single retry. + Other errors keep the previous single retry. The desktop's shared + `QueryClient` (`ElectronTRPCProvider`) retries connection errors only + (`hostServiceQueryRetry`), so a `TIMEOUT` there fails at once unless + the hook retries it, as `useHostUsageLogins` does once. Mutations stay on `protectedProcedure` because their latency is highly variable (file writes, network calls) and a blanket budget would do more @@ -54,6 +58,7 @@ tight and healthy queries time out under load. | `git.getStatus`, `git.getCommitFiles` | 15s | Slow on big working trees | | `git.listCommits`, `git.getDiff`, `git.getBranchSyncStatus`, `git.getPullRequestThreads` | 30s | Long history, big diffs, GitHub API | | `linkPreview.get` | 20s | A page fetch, then an icon fetch, 7s each | +| `usage.logins` | 15s | Reads every Claude profile's Keychain items; polled while a sign-in is pending | ## What the timeout does *not* do @@ -73,7 +78,9 @@ between operations. ## What a timeout looks like to the client `TRPCClientError` with `error.data.code === "TIMEOUT"`. The -`WorkspaceClientProvider` retry predicate keys on this. Bespoke per-hook -retry logic should not be necessary — if it is, the procedure's budget -is probably wrong, or the underlying work isn't really a single query -and should be split. +`WorkspaceClientProvider` retry predicate keys on this, and so does +`useHostUsageLogins`: a poll that times out leaves its scan running, and +the retry joins that scan instead of starting a new one. Bespoke per-hook +retry logic should not otherwise be necessary — if it is, the procedure's +budget is probably wrong, or the underlying work isn't really a single +query and should be split. diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 54d44edd813..c4520fdb1bd 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -206,7 +206,8 @@ const timeoutMiddleware = t.middleware(async ({ next, type, path, meta }) => { /** * Query procedures with a server-side timeout. Hung filesystem/git work * rejects after `meta.timeoutMs` (default 5s) so the renderer doesn't - * spin forever. React Query is configured to retry on `TIMEOUT` errors. + * spin forever. `WorkspaceClientProvider` retries `TIMEOUT` errors; the + * desktop's shared `QueryClient` does not (see QUERY_TIMEOUTS.md). * * Use this for `.query` procedures only — mutations have variable * latency and shouldn't share a blanket budget. diff --git a/packages/host-service/src/trpc/router/usage/logins.test.ts b/packages/host-service/src/trpc/router/usage/logins.test.ts new file mode 100644 index 00000000000..3d687a962b8 --- /dev/null +++ b/packages/host-service/src/trpc/router/usage/logins.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "bun:test"; +import { + FINGERPRINT_READ_CONCURRENCY, + readClaudeFingerprints, + scanLogins, + shareInFlight, +} from "./logins"; +import type { ClaudeProfile } from "./profiles"; +import { usageRouter } from "./usage"; + +function profile( + configDir: string, + credentialKind: ClaudeProfile["credentialKind"] = "subscription", +): ClaudeProfile { + return { + configDir, + sourceLabel: configDir, + email: null, + credentialKind, + loginFingerprint: + credentialKind === "api_key" ? `marker:${configDir}` : null, + credentialsPath: `${configDir}/.credentials.json`, + keychainServices: [], + }; +} + +describe("usage.logins", () => { + it("has the 15 s budget of usage.quota, not the 5 s default", () => { + expect(usageRouter._def.procedures.logins._def.meta).toEqual({ + timeoutMs: 15_000, + }); + }); +}); + +describe("scanLogins", () => { + it("says when the Claude profile scan stopped at its time budget", async () => { + for (const partial of [true, false]) { + const logins = await scanLogins({ + scanClaudeProfiles: async () => ({ + profiles: [profile("/profiles/a")], + partial, + }), + discoverCodexHomes: async () => [], + readDefaultLoginEmail: async () => null, + readClaudeLoginFingerprint: async () => null, + }); + expect(logins.claude.map((login) => login.configDir)).toEqual([ + "/profiles/a", + ]); + expect(logins.claudePartial).toBe(partial); + } + }); +}); + +describe("shareInFlight", () => { + it("gives a call made while a scan runs that scan's result", async () => { + const scans: Array<(value: string) => void> = []; + const read = shareInFlight( + () => new Promise((resolve) => scans.push(resolve)), + ); + const first = read(); + const second = read(); + expect(scans).toHaveLength(1); + scans[0]?.("scan 1"); + expect(await first).toBe("scan 1"); + expect(await second).toBe("scan 1"); + }); + + it("starts a new scan once the last one has settled", async () => { + let scans = 0; + const read = shareInFlight(async () => ++scans); + expect(await read()).toBe(1); + expect(await read()).toBe(2); + }); + + it("starts a new scan after a failed one", async () => { + let scans = 0; + const read = shareInFlight(async () => { + scans++; + if (scans === 1) throw new Error("scan failed"); + return scans; + }); + await expect(read()).rejects.toThrow("scan failed"); + expect(await read()).toBe(2); + }); +}); + +describe("readClaudeFingerprints", () => { + it("reads a bounded number of profiles at once and keeps their order", async () => { + const profiles = Array.from({ length: 10 }, (_, index) => + profile(`/profiles/${index}`, index === 3 ? "api_key" : "subscription"), + ); + const waiting: Array<() => void> = []; + const read: string[] = []; + let inFlight = 0; + let peak = 0; + let settled = false; + const result = readClaudeFingerprints(profiles, async (configDir) => { + read.push(configDir); + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => waiting.push(resolve)); + inFlight--; + return `token:${configDir}`; + }).finally(() => { + settled = true; + }); + while (!settled) { + await new Promise((resolve) => setTimeout(resolve, 0)); + for (const resolve of waiting.splice(0).reverse()) resolve(); + } + + expect(peak).toBe(FINGERPRINT_READ_CONCURRENCY); + expect(read).not.toContain("/profiles/3"); + expect((await result).map((login) => login.fingerprint)).toEqual( + profiles.map(({ configDir, credentialKind }) => + credentialKind === "api_key" + ? `marker:${configDir}` + : `token:${configDir}`, + ), + ); + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/logins.ts b/packages/host-service/src/trpc/router/usage/logins.ts new file mode 100644 index 00000000000..bed88f125c7 --- /dev/null +++ b/packages/host-service/src/trpc/router/usage/logins.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mapWithConcurrency } from "../git/utils/git-helpers"; +import { readClaudeLoginFingerprint, readDefaultLoginEmail } from "./claude"; +import { + type ClaudeProfile, + discoverCodexHomes, + readCodexProfileKind, + scanClaudeProfiles, +} from "./profiles"; + +// Each read runs `security` up to a dozen times in a row (readKeychainSecrets). +export const FINGERPRINT_READ_CONCURRENCY = 4; + +export function readClaudeFingerprints( + profiles: ClaudeProfile[], + readFingerprint: ( + configDir: string, + ) => Promise = readClaudeLoginFingerprint, +) { + return mapWithConcurrency( + profiles, + FINGERPRINT_READ_CONCURRENCY, + async (profile) => ({ + configDir: profile.configDir, + email: profile.email, + credentialKind: profile.credentialKind, + fingerprint: + profile.credentialKind === "api_key" + ? profile.loginFingerprint + : await readFingerprint(profile.configDir), + }), + ); +} + +const loginReaders = { + scanClaudeProfiles, + discoverCodexHomes, + readDefaultLoginEmail, + readClaudeLoginFingerprint, +}; + +export async function scanLogins(readers = loginReaders) { + const [claudeScan, codexHomes, claudeDefaultEmail, claudeDefaultFingerprint] = + await Promise.all([ + readers.scanClaudeProfiles(), + readers.discoverCodexHomes(), + readers.readDefaultLoginEmail(), + readers.readClaudeLoginFingerprint(null), + ]); + // auth.json fingerprints let the UI notice a re-login on any Codex home + // (its email is only knowable via the network). The first home is the + // system default. + const codex = await Promise.all( + codexHomes.map(async ({ home, credentialKind, loginFingerprint }) => { + // An API-billed home's auth.json holds the raw key and is never + // opened; its marker mtime is the fingerprint instead. + let fingerprint = loginFingerprint; + if ( + credentialKind === "subscription" && + (await readCodexProfileKind(home))?.credentialKind === "subscription" + ) { + try { + fingerprint = createHash("sha256") + .update(await readFile(join(home, "auth.json"))) + .digest("hex"); + } catch { + // No readable auth.json — fingerprint stays null. + } + } + return { home, fingerprint, credentialKind }; + }), + ); + return { + homeDir: homedir(), + claudeDefaultFingerprint, + claude: await readClaudeFingerprints( + claudeScan.profiles, + readers.readClaudeLoginFingerprint, + ), + // Discovery stopped at its time budget: `claude` can lack real profiles. + claudePartial: claudeScan.partial, + codex, + claudeDefaultEmail, + }; +} + +/** + * Calls made while `run` is pending get its promise; the first call after it + * settles starts a new run. Not a cache: a timed-out query does not stop its + * run, so this keeps the next poll from starting a second one beside it. + */ +export function shareInFlight(run: () => Promise): () => Promise { + let pending: Promise | null = null; + return () => { + pending ??= run().finally(() => { + pending = null; + }); + return pending; + }; +} + +export const readLogins = shareInFlight(scanLogins); diff --git a/packages/host-service/src/trpc/router/usage/profiles.test.ts b/packages/host-service/src/trpc/router/usage/profiles.test.ts index c10eba510f2..ca5a4f9ade1 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.test.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.test.ts @@ -13,6 +13,7 @@ import { claudeKeychainAccounts, discoverClaudeProfiles, readCodexProfileKind, + scanClaudeProfiles, } from "./profiles"; // Claude Code keys its Keychain items on these names; a miss reads a sibling @@ -158,3 +159,29 @@ describe("discoverClaudeProfiles", () => { }); }); }); + +describe("scanClaudeProfiles", () => { + it("says when it stopped at its time budget", async () => { + const dirs = [tempProfile(), tempProfile()]; + for (const dir of dirs) { + writeFileSync( + join(dir, ".claude.json"), + JSON.stringify({ oauthAccount: { emailAddress: "a@b.c" } }), + ); + } + // Clock reads: the start, before the first dir, then past the budget. + const clock = [0, 0]; + const stopped = await scanClaudeProfiles( + dirs, + () => clock.shift() ?? 60_000, + ); + expect(stopped.profiles.map((profile) => profile.configDir)).toEqual( + dirs.slice(0, 1), + ); + expect(stopped.partial).toBe(true); + + const complete = await scanClaudeProfiles(dirs, () => 0); + expect(complete.profiles.map((profile) => profile.configDir)).toEqual(dirs); + expect(complete.partial).toBe(false); + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/profiles.ts b/packages/host-service/src/trpc/router/usage/profiles.ts index a7c19384a54..84f8e05323d 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.ts @@ -152,24 +152,33 @@ export async function readApiBillingFingerprint( } } +export interface ClaudeProfileScan { + profiles: ClaudeProfile[]; + /** Stopped at the time budget, so it can lack profiles that exist. */ + partial: boolean; +} + /** * Extra Claude profile dirs beyond the defaults. Default homes are excluded — - * callers already cover `~/.claude` and `~/.config/claude`. `candidates` - * overrides the home-dir scan for tests. + * callers already cover `~/.claude` and `~/.config/claude`. `candidates` and + * `now` override the home-dir scan and the clock for tests. */ -export async function discoverClaudeProfiles( +export async function scanClaudeProfiles( candidates?: string[], -): Promise { + now: () => number = Date.now, +): Promise { const home = homedir(); const excluded = new Set([ join(home, ".claude"), join(home, ".config", "claude"), ]); - const started = Date.now(); + const started = now(); const profiles: ClaudeProfile[] = []; for (const candidate of candidates ?? (await candidateDirectories())) { - if (Date.now() - started > SCAN_TIME_BUDGET_MS) break; + if (now() - started > SCAN_TIME_BUDGET_MS) { + return { profiles, partial: true }; + } if (excluded.has(candidate)) continue; const [identity, apiFingerprint] = await Promise.all([ readClaudeIdentity(candidate), @@ -186,7 +195,13 @@ export async function discoverClaudeProfiles( keychainServices: keychainServicesForConfigDir(candidate), }); } - return profiles; + return { profiles, partial: false }; +} + +export async function discoverClaudeProfiles( + candidates?: string[], +): Promise { + return (await scanClaudeProfiles(candidates)).profiles; } const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; diff --git a/packages/host-service/src/trpc/router/usage/usage.ts b/packages/host-service/src/trpc/router/usage/usage.ts index 4e4debac5df..9b62a9f3e38 100644 --- a/packages/host-service/src/trpc/router/usage/usage.ts +++ b/packages/host-service/src/trpc/router/usage/usage.ts @@ -1,7 +1,4 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import { MAX_BACKFILL_DAYS } from "@superset/trpc/leaderboard-periods"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -17,11 +14,7 @@ import { provisionCodexAccount, } from "./account-provisioning"; import { fetchAgyAccounts } from "./agy-quota"; -import { - fetchClaudeAccounts, - readClaudeLoginFingerprint, - readDefaultLoginEmail, -} from "./claude"; +import { fetchClaudeAccounts } from "./claude"; import { fetchCodexAccounts } from "./codex"; import { getDefaultAccountSelections, @@ -29,13 +22,10 @@ import { } from "./default-account"; import { fetchGrokAccounts } from "./grok-quota"; import { countAgentPrsByDay } from "./history/agent-prs"; +import { readLogins } from "./logins"; import { fetchOpencodeAccounts } from "./opencode-quota"; import { removeClaudeProfile, removeCodexHome } from "./profile-remove"; -import { - discoverClaudeProfiles, - discoverCodexHomes, - readCodexProfileKind, -} from "./profiles"; +import { discoverClaudeProfiles, discoverCodexHomes } from "./profiles"; import { validateSessionAccount } from "./session-account/session-account"; import type { UsageAccount } from "./types"; @@ -140,55 +130,7 @@ export const usageRouter = router({ * system-default login by its credential fingerprint, including re-login * to the same account. */ - logins: queryProcedure.query(async () => { - const [profiles, codexHomes, claudeDefaultEmail, claudeDefaultFingerprint] = - await Promise.all([ - discoverClaudeProfiles(), - discoverCodexHomes(), - readDefaultLoginEmail(), - readClaudeLoginFingerprint(null), - ]); - // auth.json fingerprints let the UI notice a re-login on any Codex home - // (its email is only knowable via the network). The first home is the - // system default. - const codex = await Promise.all( - codexHomes.map(async ({ home, credentialKind, loginFingerprint }) => { - // An API-billed home's auth.json holds the raw key and is never - // opened; its marker mtime is the fingerprint instead. - let fingerprint = loginFingerprint; - if ( - credentialKind === "subscription" && - (await readCodexProfileKind(home))?.credentialKind === "subscription" - ) { - try { - fingerprint = createHash("sha256") - .update(await readFile(join(home, "auth.json"))) - .digest("hex"); - } catch { - // No readable auth.json — fingerprint stays null. - } - } - return { home, fingerprint, credentialKind }; - }), - ); - return { - homeDir: homedir(), - claudeDefaultFingerprint, - claude: await Promise.all( - profiles.map(async (profile) => ({ - configDir: profile.configDir, - email: profile.email, - credentialKind: profile.credentialKind, - fingerprint: - profile.credentialKind === "api_key" - ? profile.loginFingerprint - : await readClaudeLoginFingerprint(profile.configDir), - })), - ), - codex, - claudeDefaultEmail, - }; - }), + logins: queryProcedure.meta({ timeoutMs: 15_000 }).query(() => readLogins()), /** * Point new agent launches at one of the discovered logins (null = the