diff --git a/CLAUDE.md b/CLAUDE.md index d55c5b6..1daeb8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ After changing GNOME source, re-install and re-login (Wayland) or `Alt+F2 → r` The platform-agnostic core lives in `shared/` (single source of truth, pure ESM + DI). Each platform adds a thin adapter layer and consumes the core at build time. ``` -shared/core/scheduler.js → Polls providers on 180s interval, serial queue per provider +shared/core/scheduler.js → Polls providers on 600s interval, serial queue per provider shared/core/aggregate.js → Computes minRemainingPct across all providers shared/core/state.js → Per-provider state machine (OK/PARTIAL_DATA/AUTH_EXPIRED/RATE_LIMITED/NETWORK_ERROR/SCHEMA_CHANGED) shared/core/backoff.js → Exponential backoff (30s initial, 15m cap, on 2+ consecutive network errors or 429) @@ -89,7 +89,7 @@ kde/plasma{5,6}/contents/code/ → bundled brainusage-app.mjs + runtime.js (buil ## Testing -Tests use `bun:test` (describe/test/expect + vi for fake timers). Tests mock fetch and readTextFile via DI — no network or filesystem calls. Scheduler tests use `vi.useFakeTimers()` and `vi.advanceTimersByTime()` to control polling. Provider tests use deferred promises to simulate async resolution order. +Tests use `bun:test` (describe/test/expect). Tests mock fetch and readTextFile via DI — no network or filesystem calls. Scheduler tests inject `setIntervalFn` to capture the poll callback and drive ticks manually (bun 1.3 removed `vi.advanceTimersByTime`). Provider tests use deferred promises to simulate async resolution order. ## Credentials diff --git a/extension/lib/runtime/fetch.js b/extension/lib/runtime/fetch.js index fa30aea..5626cf6 100644 --- a/extension/lib/runtime/fetch.js +++ b/extension/lib/runtime/fetch.js @@ -61,6 +61,10 @@ function createResponse(status, bytes) { export function createFetch() { const session = new Soup.Session(); + // Without a timeout a hung request never resolves, and the scheduler + // chains polls on a per-provider queue — one hang kills the provider + // until the shell restarts. + session.timeout = 30; function sendMessage(message) { return new Promise((resolve, reject) => { @@ -95,7 +99,10 @@ export function createFetch() { } const bytes = await sendMessage(message); - return createResponse(message.get_status(), bytes); + // Read the GObject property instead of get_status(): GJS validates the + // return value of get_status() against the Soup.Status enum, which has + // no entry for some valid HTTP codes (e.g. 429) and throws. + return createResponse(message.status_code, bytes); } function dispose() { diff --git a/shared/core/normalize.js b/shared/core/normalize.js index 0eb82af..f645fa4 100644 --- a/shared/core/normalize.js +++ b/shared/core/normalize.js @@ -35,19 +35,57 @@ export function normalizeClaudeUsage(payload) { }; } +const DAY_SECONDS = 86_400; + +// The usage API does not guarantee window positions: payloads have been seen +// with the 7-day window as primary_window and secondary_window set to null. +// Classify each window by its duration instead of trusting its position. +function classifyCodexWindows(rateLimit) { + const primary = rateLimit?.primary_window ?? null; + const secondary = rateLimit?.secondary_window ?? null; + + let sessionWindow = null; + let weeklyWindow = null; + + for (const window of [primary, secondary]) { + if (!window || typeof window !== 'object') + continue; + + const seconds = Number(window.limit_window_seconds); + if (!Number.isFinite(seconds)) + continue; + + if (seconds >= DAY_SECONDS) + weeklyWindow ??= window; + else + sessionWindow ??= window; + } + + // Legacy payloads without limit_window_seconds: fall back to positions. + if (!sessionWindow && !weeklyWindow) { + sessionWindow = primary; + weeklyWindow = secondary; + } + + return {sessionWindow, weeklyWindow}; +} + export function normalizeCodexUsage(payload) { - const primaryWindow = payload?.rate_limit?.primary_window; - const secondaryWindow = payload?.rate_limit?.secondary_window; + const {sessionWindow, weeklyWindow} = classifyCodexWindows(payload?.rate_limit); return { data: { - sessionRemainingPct: clampPercent(100 - Number(primaryWindow?.used_percent)), - weeklyRemainingPct: clampPercent(100 - Number(secondaryWindow?.used_percent)), - sessionResetsAtIso: unixSecondsToIso(primaryWindow?.reset_at), - weeklyResetsAtIso: unixSecondsToIso(secondaryWindow?.reset_at), + sessionRemainingPct: sessionWindow + ? clampPercent(100 - Number(sessionWindow.used_percent)) + : null, + weeklyRemainingPct: weeklyWindow + ? clampPercent(100 - Number(weeklyWindow.used_percent)) + : null, + sessionResetsAtIso: unixSecondsToIso(sessionWindow?.reset_at), + weeklyResetsAtIso: unixSecondsToIso(weeklyWindow?.reset_at), }, - hasPrimaryWindow: Boolean(primaryWindow), - hasSecondaryWindow: Boolean(secondaryWindow), - hasPartialData: !primaryWindow || !secondaryWindow, + hasPrimaryWindow: Boolean(sessionWindow), + hasSecondaryWindow: Boolean(weeklyWindow), + hasPartialData: !sessionWindow || !weeklyWindow, }; } diff --git a/shared/core/scheduler.js b/shared/core/scheduler.js index e89bb63..6f76dab 100644 --- a/shared/core/scheduler.js +++ b/shared/core/scheduler.js @@ -2,7 +2,9 @@ import {computeSummary} from './aggregate.js'; import {createBackoffManager} from './backoff.js'; import {applyProviderResult, createProviderState} from './state.js'; -export const DEFAULT_POLL_INTERVAL_MS = 180_000; +// 10 minutes: the Claude usage endpoint rate-limits aggressive polling +// (observed 429 with retry-after ~340s against a 3-minute interval). +export const DEFAULT_POLL_INTERVAL_MS = 600_000; function normalizeProviders(providersInput) { if (Array.isArray(providersInput)) { diff --git a/shared/core/state.js b/shared/core/state.js index d15a7be..ee5baca 100644 --- a/shared/core/state.js +++ b/shared/core/state.js @@ -55,7 +55,9 @@ export function applyProviderResult(state, result, requestId, updatedAtIso) { return true; } - state.data = result?.data ?? null; + // Keep the last known usage on failure so the UI can render stale data + // alongside the warning instead of going blank (e.g. while rate limited). + state.data = result?.data ?? state.data; state.error = { code: state.code, providerCode: result?.error?.code ?? null, diff --git a/shared/providers/codex.js b/shared/providers/codex.js index 252a832..3895709 100644 --- a/shared/providers/codex.js +++ b/shared/providers/codex.js @@ -185,9 +185,8 @@ export function createCodexProvider(options = {}) { if (!normalized.hasPrimaryWindow && !normalized.hasSecondaryWindow) return fail('schema_changed', 'Usage payload is missing expected rate_limit windows'); - if (normalized.hasPartialData) - return fail('partial_data', 'Usage payload is missing primary_window or secondary_window', normalized.data); - + // A single window is a valid payload: the API may report only + // the weekly window, so render what exists instead of failing. return ok(normalized.data); } catch { return fail('network_error', 'Network request failed while calling Codex APIs'); diff --git a/shared/ui/render.js b/shared/ui/render.js index c0e4721..3af2660 100644 --- a/shared/ui/render.js +++ b/shared/ui/render.js @@ -94,6 +94,9 @@ function toWarningText(providerLabel, code) { if (code === 'NETWORK_ERROR') return `${providerLabel}: network error`; + if (code === 'RATE_LIMITED') + return `${providerLabel}: rate limited`; + if (code === 'SCHEMA_CHANGED') return `${providerLabel}: schema changed`; diff --git a/test/unit/codex-provider.test.js b/test/unit/codex-provider.test.js index 605d5f1..f8a2e7d 100644 --- a/test/unit/codex-provider.test.js +++ b/test/unit/codex-provider.test.js @@ -122,7 +122,7 @@ describe('Codex provider', () => { expect(result.data.sessionRemainingPct).toBe(90); }); - test('returns partial_data when secondary_window is missing', async () => { + test('returns ok with null weekly when only the session window exists', async () => { const provider = createCodexProvider({ readTextFile: async () => JSON.stringify({ tokens: { @@ -142,16 +142,66 @@ describe('Codex provider', () => { const result = await provider.getUsage(); - expect(result.ok).toBe(false); - expect(result.error.code).toBe('partial_data'); + expect(result.ok).toBe(true); expect(result.data).toEqual({ sessionRemainingPct: 65, - weeklyRemainingPct: 0, + weeklyRemainingPct: null, sessionResetsAtIso: '2026-02-08T00:00:00.000Z', weeklyResetsAtIso: null, }); }); + test('returns ok when the weekly window arrives as primary_window and secondary_window is null', async () => { + const provider = createCodexProvider({ + readTextFile: async () => JSON.stringify({ + tokens: { + access_token: 'access-token', + refresh_token: 'refresh-token', + }, + }), + fetch: async () => createJsonResponse(200, { + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 2, + limit_window_seconds: 604_800, + reset_after_seconds: 587_758, + reset_at: 1_784_850_454, + }, + secondary_window: null, + }, + }), + }); + + const result = await provider.getUsage(); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + sessionRemainingPct: null, + weeklyRemainingPct: 98, + sessionResetsAtIso: null, + weeklyResetsAtIso: '2026-07-23T23:47:34.000Z', + }); + }); + + test('returns schema_changed when no rate_limit windows exist', async () => { + const provider = createCodexProvider({ + readTextFile: async () => JSON.stringify({ + tokens: { + access_token: 'access-token', + refresh_token: 'refresh-token', + }, + }), + fetch: async () => createJsonResponse(200, {rate_limit: {}}), + }); + + const result = await provider.getUsage(); + + expect(result.ok).toBe(false); + expect(result.error.code).toBe('schema_changed'); + }); + test('returns auth_expired when refresh is rejected', async () => { const provider = createCodexProvider({ readTextFile: async () => JSON.stringify({ diff --git a/test/unit/normalize.test.js b/test/unit/normalize.test.js new file mode 100644 index 0000000..77e35b2 --- /dev/null +++ b/test/unit/normalize.test.js @@ -0,0 +1,71 @@ +import {describe, expect, test} from 'bun:test'; + +import {normalizeCodexUsage} from '../../shared/core/normalize.js'; + +describe('normalizeCodexUsage', () => { + test('classifies windows by duration when the weekly window arrives as primary_window', () => { + const normalized = normalizeCodexUsage({ + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 2, + limit_window_seconds: 604_800, + reset_after_seconds: 587_758, + reset_at: 1_784_850_454, + }, + secondary_window: null, + }, + }); + + expect(normalized.data).toEqual({ + sessionRemainingPct: null, + weeklyRemainingPct: 98, + sessionResetsAtIso: null, + weeklyResetsAtIso: '2026-07-23T23:47:34.000Z', + }); + expect(normalized.hasPartialData).toBe(true); + }); + + test('keeps the classic mapping for both-windows payloads carrying durations', () => { + const normalized = normalizeCodexUsage({ + rate_limit: { + primary_window: { + used_percent: 18, + limit_window_seconds: 18_000, + reset_at: 1_770_508_800, + }, + secondary_window: { + used_percent: 5, + limit_window_seconds: 604_800, + reset_at: 1_770_768_000, + }, + }, + }); + + expect(normalized.data).toEqual({ + sessionRemainingPct: 82, + weeklyRemainingPct: 95, + sessionResetsAtIso: '2026-02-08T00:00:00.000Z', + weeklyResetsAtIso: '2026-02-11T00:00:00.000Z', + }); + expect(normalized.hasPartialData).toBe(false); + }); + + test('falls back to positional mapping for legacy payloads without durations', () => { + const normalized = normalizeCodexUsage({ + rate_limit: { + primary_window: {used_percent: 42, reset_at: 1_770_508_800}, + secondary_window: {used_percent: 64, reset_at: 1_770_768_000}, + }, + }); + + expect(normalized.data).toEqual({ + sessionRemainingPct: 58, + weeklyRemainingPct: 36, + sessionResetsAtIso: '2026-02-08T00:00:00.000Z', + weeklyResetsAtIso: '2026-02-11T00:00:00.000Z', + }); + expect(normalized.hasPartialData).toBe(false); + }); +}); diff --git a/test/unit/scheduler.test.js b/test/unit/scheduler.test.js index abe0847..86ac031 100644 --- a/test/unit/scheduler.test.js +++ b/test/unit/scheduler.test.js @@ -1,4 +1,4 @@ -import {afterEach, describe, expect, setSystemTime, test, vi} from 'bun:test'; +import {describe, expect, test} from 'bun:test'; import {createBackoffManager} from '../../shared/core/backoff.js'; import {createScheduler, DEFAULT_POLL_INTERVAL_MS} from '../../shared/core/scheduler.js'; @@ -20,19 +20,24 @@ function deferred() { return {promise, resolve, reject}; } -afterEach(() => { - vi.useRealTimers(); -}); - describe('scheduler', () => { - test('polls all providers every 180000ms', async () => { - vi.useFakeTimers(); - setSystemTime(new Date('2026-02-08T00:00:00.000Z')); + test('defaults to a 10 minute poll interval', () => { + expect(DEFAULT_POLL_INTERVAL_MS).toBe(600_000); + }); + test('polls all providers once per poll interval', async () => { let claudeCalls = 0; let codexCalls = 0; + let intervalMs = null; + let tick = null; const scheduler = createScheduler({ + setIntervalFn: (callback, ms) => { + tick = callback; + intervalMs = ms; + return 1; + }, + clearIntervalFn: () => {}, providers: { claude: { async getUsage() { @@ -52,16 +57,11 @@ describe('scheduler', () => { scheduler.start(); await flushMicrotasks(); + expect(intervalMs).toBe(DEFAULT_POLL_INTERVAL_MS); expect(claudeCalls).toBe(1); expect(codexCalls).toBe(1); - vi.advanceTimersByTime(DEFAULT_POLL_INTERVAL_MS - 1); - await flushMicrotasks(); - - expect(claudeCalls).toBe(1); - expect(codexCalls).toBe(1); - - vi.advanceTimersByTime(1); + tick(); await flushMicrotasks(); expect(claudeCalls).toBe(2); diff --git a/test/unit/state.test.js b/test/unit/state.test.js new file mode 100644 index 0000000..116632e --- /dev/null +++ b/test/unit/state.test.js @@ -0,0 +1,41 @@ +import {describe, expect, test} from 'bun:test'; + +import {applyProviderResult, createProviderState} from '../../shared/core/state.js'; + +describe('provider state', () => { + test('keeps last known data when a failure result carries no data', () => { + const state = createProviderState('claude'); + + applyProviderResult(state, { + ok: true, + data: {sessionRemainingPct: 80, weeklyRemainingPct: 70}, + }, 1, '2026-02-08T00:00:00.000Z'); + + applyProviderResult(state, { + ok: false, + error: {code: 'rate_limited', message: 'slow down'}, + }, 2, '2026-02-08T00:10:00.000Z'); + + expect(state.code).toBe('RATE_LIMITED'); + expect(state.data).toEqual({sessionRemainingPct: 80, weeklyRemainingPct: 70}); + expect(state.error.providerCode).toBe('rate_limited'); + }); + + test('prefers failure-carried data over the stale snapshot', () => { + const state = createProviderState('codex'); + + applyProviderResult(state, { + ok: true, + data: {sessionRemainingPct: 80, weeklyRemainingPct: 70}, + }, 1, '2026-02-08T00:00:00.000Z'); + + applyProviderResult(state, { + ok: false, + error: {code: 'partial_data', message: 'missing weekly window'}, + data: {sessionRemainingPct: 42, weeklyRemainingPct: null}, + }, 2, '2026-02-08T00:10:00.000Z'); + + expect(state.code).toBe('PARTIAL_DATA'); + expect(state.data).toEqual({sessionRemainingPct: 42, weeklyRemainingPct: null}); + }); +}); diff --git a/test/unit/ui-render.test.js b/test/unit/ui-render.test.js index a30c614..c75b20a 100644 --- a/test/unit/ui-render.test.js +++ b/test/unit/ui-render.test.js @@ -91,6 +91,20 @@ describe('buildUsageViewModel', () => { expect(view.services[0].warning).toBe('Codex: partial usage data'); }); + test('shows rate limited warning while keeping last known data', () => { + const view = buildUsageViewModel({ + providers: { + claude: { + code: 'RATE_LIMITED', + data: {sessionRemainingPct: 85, weeklyRemainingPct: 60}, + }, + }, + }, {now: NOW}); + + expect(view.services[1].warning).toBe('Claude: rate limited'); + expect(view.services[1].windows[0].remainingText).toBe('85% left'); + }); + test('formats next update countdown from last update time', () => { const view = buildUsageViewModel({ lastUpdatedAtIso: '2026-02-09T09:58:00.000Z',