codeburn/app/electron/quota/index.ts
iamtoruk 300e949951 feat(app): refresh cadence, instant switching, cache coherence, telemetry v1
Performance and coherence:
- Settings > General 'Refresh every' (Manual/30s/1m/3m/5m/10m), live via
  RefreshCadenceContext; Manual polls only on demand
- usePolled memoKey LRU: provider/period switches paint the last-good
  result instantly with a switching hairline while refreshing quietly
- quota TTL 5min + honest rate-limited copy on 429 backoff
- version-suffixed cache files (session-cache.v5.json, daily-cache.v12,
  auto-minted on future bumps); legacy files never written or deleted,
  adopted once when versions match: old and new binaries coexist
  without clobbering (field-observed menubar-vs-desktop ping-pong)
- advisory hydration lock: concurrent cold starts share one scan
  (wait-then-read-warm), stale/dead locks self-heal, never a
  correctness gate

Telemetry v1 + onboarding (desktop only, per owner decisions):
- first-launch onboarding (3 feature screens + consent); region-split
  default (EU/EEA/UK/CH off, elsewhere on, unknown off); nothing sends
  before consent completion or while off; dev builds never send
- anonymous install UUID, rotated on opt-out; day-granularity events,
  cost buckets only; whitelisted names; 200-event queue, 5min flush
- Settings > Privacy live toggle replaces the static claim

Zero computed-number changes. App 316/316, root 1805.

Wire contract targets api.codeburn.app/v1/telemetry (Worker follows).
2026-07-16 11:47:05 -07:00

132 lines
6.2 KiB
TypeScript

import os from 'node:os'
import path from 'node:path'
import { fetchClaudeQuota } from './claude'
import { fetchCodexQuota } from './codex'
import { atomicWriteSecureFile, readSecureFile, sanitizeError } from './security'
import type { ProviderName, QuotaProvider } from './types'
export type { QuotaProvider, QuotaWindow } from './types'
export { sanitizeError } from './security'
type Blocked = Partial<Record<ProviderName, string>>
type FetchResult = { quota: QuotaProvider; retryAfterSeconds?: number }
type QuotaDeps = {
claude: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise<FetchResult>
codex: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise<FetchResult>
statePath: string
readFile: typeof readSecureFile
writeFile: typeof atomicWriteSecureFile
now: () => number
refreshMs: number
}
const defaultDeps: QuotaDeps = {
claude: fetchClaudeQuota,
codex: fetchCodexQuota,
statePath: path.join(os.homedir(), '.codeburn', 'quota-backoff.json'),
readFile: readSecureFile,
writeFile: atomicWriteSecureFile,
now: Date.now,
// Politeness floor: quota stays gentle regardless of the app refresh cadence.
// A user-initiated force refresh still bypasses this (invalidate()).
refreshMs: 5 * 60_000,
}
function unavailable(provider: ProviderName, connection: QuotaProvider['connection']): QuotaProvider {
return { provider, connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
export class QuotaService {
private readonly deps: QuotaDeps
private cache: { at: number; value: QuotaProvider[] } | null = null
private flight: Promise<QuotaProvider[]> | null = null
private generations: Record<ProviderName, number> = { claude: 0, codex: 0 }
private controllers: Partial<Record<ProviderName, AbortController>> = {}
constructor(deps: Partial<QuotaDeps> = {}) { this.deps = { ...defaultDeps, ...deps } }
invalidate(provider?: ProviderName): void {
const providers: ProviderName[] = provider ? [provider] : ['claude', 'codex']
for (const p of providers) {
this.generations[p] += 1
this.controllers[p]?.abort()
this.controllers[p] = undefined
}
this.cache = null
}
async getQuota(options: { force?: boolean; allowKeychain?: boolean } = {}): Promise<QuotaProvider[]> {
if (options.force) this.invalidate()
if (!options.force && this.cache && this.deps.now() - this.cache.at < this.deps.refreshMs) return this.cache.value
if (this.flight) return this.flight
this.flight = this.fetchAll(Boolean(options.allowKeychain)).finally(() => { this.flight = null })
return this.flight
}
private async readBlocked(): Promise<Blocked> {
try {
const raw = await this.deps.readFile(this.deps.statePath, 16 * 1024)
return raw ? JSON.parse(raw) as Blocked : {}
} catch (error) {
console.warn(`Quota backoff state unavailable: ${sanitizeError(error)}`)
return {}
}
}
private async writeBlocked(blocked: Blocked): Promise<void> {
try { await this.deps.writeFile(this.deps.statePath, `${JSON.stringify(blocked, null, 2)}\n`) }
catch (error) { console.warn(`Quota backoff state not saved: ${sanitizeError(error)}`) }
}
private async fetchAll(allowKeychain: boolean): Promise<QuotaProvider[]> {
const startingGenerations = { ...this.generations }
const prior = this.cache?.value ?? []
const blocked = await this.readBlocked()
const run = async (provider: ProviderName): Promise<QuotaProvider> => {
const retainOnFailure = (next: QuotaProvider): QuotaProvider => {
const previous = prior.find(item => item.provider === provider)
if (previous?.connection !== 'connected') return next
// Keychain-only credentials are invisible to a background (keychain-less)
// poll; keep showing the live connection rather than flapping to
// disconnected. A forced refresh re-reads the keychain and reveals truth.
if (!allowKeychain && (next.connection === 'disconnected' || next.connection === 'accessDenied')) return previous
if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure', rateLimited: next.rateLimited }
return next
}
const until = blocked[provider] ? Date.parse(blocked[provider]!) : NaN
if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure({ ...unavailable(provider, 'transientFailure'), rateLimited: true })
const generation = this.generations[provider]
const controller = new AbortController()
this.controllers[provider] = controller
const result = provider === 'claude'
? await this.deps.claude({ signal: controller.signal, allowKeychain })
: await this.deps.codex({ signal: controller.signal, allowKeychain })
if (generation !== this.generations[provider] || controller.signal.aborted) return unavailable(provider, 'disconnected')
if (result.retryAfterSeconds !== undefined) {
blocked[provider] = new Date(this.deps.now() + result.retryAfterSeconds * 1000).toISOString()
await this.writeBlocked(blocked)
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
return retainOnFailure({ ...result.quota, rateLimited: true })
} else if (blocked[provider]) {
delete blocked[provider]
await this.writeBlocked(blocked)
}
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
return retainOnFailure(result.quota)
}
const value = await Promise.all([run('claude'), run('codex')])
if (startingGenerations.claude === this.generations.claude && startingGenerations.codex === this.generations.codex) {
this.cache = { at: this.deps.now(), value }
}
return value
}
}
export const quotaService = new QuotaService()
// Keychain reads can raise a one-time macOS permission dialog, so only attempt
// them on a user-initiated forced refresh (the Connect / Refresh affordance).
// Background polls skip the keychain and lean on retainOnFailure to hold a
// live connection steady between forced refreshes.
export const getQuota = (options: { force?: boolean } = {}): Promise<QuotaProvider[]> =>
quotaService.getQuota({ force: options.force, allowKeychain: Boolean(options.force) })