From caf3bfb0a5394eb78dc2c7f715d97f41701db3ae Mon Sep 17 00:00:00 2001 From: prakersh Date: Wed, 21 Oct 2026 17:36:21 +0530 Subject: [PATCH 01/56] fix(menubar): never blank - keep last-known data and show staleness by age A single slow /api/menubar/summary request blanked the tray title to "--" and left the popover empty or black. Every client now keeps its last good data and shows staleness only through each card's age label. Daemon: - Cache the built snapshot with singleflight; serve it immediately when fresh (12s) or when a rebuild exceeds a 3s budget, and let the build finish in the background. Settings and provider writes invalidate it - Keep the last good card per provider/account and serve it with its original capture time on storage read errors; never fabricate fresh zero cards; staleness no longer raises severity - Add latest_at and per-card updated_at_time so ages count from real capture time Tray (macOS/Windows/Linux): - Keep the last title, menu and preferences on failure; only the icon tint and "last updated" tooltip change - Single refresh worker, so requests never block, overlap or get lost - 15s timeout with one retry Popover: - Ages count up from each provider's capture time - Connection issues move to the status dot (ring + hover tooltip) - Render the last snapshot from localStorage instantly on open; ignore out-of-order responses and corrupt cache entries - Host background follows the page theme to avoid the black flash GNOME extension: keep last good state, 15s timeout with one retry, cancel in-flight requests on disable. --- cmd/onwatch/menubar_runtime.go | 52 +- cmd/onwatch/menubar_snapshot_test.go | 73 + .../onwatch-menubar@onllm.dev/api.js | 76 +- .../onwatch-menubar@onllm.dev/indicator.js | 298 ++++- .../onwatch-menubar@onllm.dev/stylesheet.css | 7 +- internal/menubar/companion.go | 107 +- internal/menubar/companion_stale_test.go | 155 +++ internal/menubar/config.go | 17 +- internal/menubar/menu.go | 76 +- internal/menubar/popover_bridge.go | 8 + internal/menubar/popover_bridge_test.go | 22 + internal/menubar/popover_darwin.m | 100 +- .../menubar/quickview_webview2_windows.go | 24 +- internal/menubar/tray_refresh.go | 220 +++ internal/menubar/tray_refresh_test.go | 457 +++++++ internal/web/codex_stale_paused_test.go | 11 +- internal/web/commandcode_handlers.go | 25 +- internal/web/cursor_handlers.go | 26 +- internal/web/dashboard_tabs.go | 7 +- internal/web/gemini_handlers.go | 17 +- internal/web/handlers.go | 484 +++++-- internal/web/kimi_handlers.go | 62 +- internal/web/menubar.go | 618 ++++++--- internal/web/menubar_age_label_test.go | 77 ++ internal/web/menubar_cache.go | 349 +++++ internal/web/menubar_cache_test.go | 1178 +++++++++++++++++ internal/web/menubar_hover_cache_test.go | 325 +++++ internal/web/menubar_lastgood_test.go | 956 +++++++++++++ internal/web/menubar_popover_r2_test.go | 299 +++++ internal/web/menubar_test.go | 141 ++ internal/web/menubar_theme_test.go | 81 ++ internal/web/menubar_tray_title_test.go | 111 ++ internal/web/mistral_handlers.go | 23 +- internal/web/mistral_stale_test.go | 4 +- internal/web/muse_handlers.go | 25 +- internal/web/ollama_handlers.go | 25 +- internal/web/opencode_handlers.go | 25 +- internal/web/static/menubar.html | 444 ++++++- 38 files changed, 6489 insertions(+), 516 deletions(-) create mode 100644 cmd/onwatch/menubar_snapshot_test.go create mode 100644 internal/menubar/companion_stale_test.go create mode 100644 internal/menubar/tray_refresh.go create mode 100644 internal/menubar/tray_refresh_test.go create mode 100644 internal/web/menubar_age_label_test.go create mode 100644 internal/web/menubar_cache.go create mode 100644 internal/web/menubar_cache_test.go create mode 100644 internal/web/menubar_hover_cache_test.go create mode 100644 internal/web/menubar_lastgood_test.go create mode 100644 internal/web/menubar_popover_r2_test.go create mode 100644 internal/web/menubar_theme_test.go create mode 100644 internal/web/menubar_tray_title_test.go diff --git a/cmd/onwatch/menubar_runtime.go b/cmd/onwatch/menubar_runtime.go index 3e15e5b4..bd94a71b 100644 --- a/cmd/onwatch/menubar_runtime.go +++ b/cmd/onwatch/menubar_runtime.go @@ -267,22 +267,50 @@ func runMenubarCommand() error { func httpSnapshotProvider(port int) menubar.SnapshotProvider { url := fmt.Sprintf("http://localhost:%d/api/menubar/summary", port) - client := &http.Client{Timeout: 5 * time.Second} + // The companion tick is 60 s. The old 5 s cap tripped whenever a provider + // poll held the single SQLite connection while the snapshot was built; 15 s + // leaves headroom and still fails well inside the tick. + const attemptTimeout = 15 * time.Second + client := &http.Client{Timeout: attemptTimeout} + attempt := func() (*menubar.Snapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), attemptTimeout) + defer cancel() + return fetchMenubarSnapshot(ctx, client, url) + } return func() (*menubar.Snapshot, error) { - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("menubar snapshot fetch failed: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("menubar snapshot request returned %s", resp.Status) + snapshot, err := attempt() + if err == nil { + return snapshot, nil } - var snapshot menubar.Snapshot - if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil { - return nil, fmt.Errorf("menubar snapshot decode failed: %w", err) + // One retry: a timeout is often a transient write burst that clears + // within a moment, and a single miss must not be treated as failure. + snapshot, retryErr := attempt() + if retryErr != nil { + return nil, fmt.Errorf("%w (retry: %v)", err, retryErr) } - return &snapshot, nil + return snapshot, nil + } +} + +func fetchMenubarSnapshot(ctx context.Context, client *http.Client, url string) (*menubar.Snapshot, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("menubar snapshot request failed: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("menubar snapshot fetch failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("menubar snapshot request returned %s", resp.Status) + } + var snapshot menubar.Snapshot + if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil { + return nil, fmt.Errorf("menubar snapshot decode failed: %w", err) } + return &snapshot, nil } func httpMistralRetry(port int, basePath string) func() error { diff --git a/cmd/onwatch/menubar_snapshot_test.go b/cmd/onwatch/menubar_snapshot_test.go new file mode 100644 index 00000000..87570a85 --- /dev/null +++ b/cmd/onwatch/menubar_snapshot_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" + "testing" + + "github.com/onllm-dev/onwatch/v2/internal/menubar" +) + +func snapshotTestPort(t *testing.T, rawURL string) int { + t.Helper() + _, port, err := net.SplitHostPort(rawURL[len("http://"):]) + if err != nil { + t.Fatalf("split host port: %v", err) + } + n, err := strconv.Atoi(port) + if err != nil { + t.Fatalf("atoi port: %v", err) + } + return n +} + +// A transient failure (one bad response) must not surface as a refresh failure: +// the provider retries once before giving up. +func TestHTTPSnapshotProviderRetriesOnce(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(menubar.Snapshot{Aggregate: menubar.Aggregate{ProviderCount: 1}}) + })) + defer srv.Close() + + snapshot, err := httpSnapshotProvider(snapshotTestPort(t, srv.URL))() + if err != nil { + t.Fatalf("expected retry to recover, got error: %v", err) + } + if snapshot == nil || snapshot.Aggregate.ProviderCount != 1 { + t.Fatalf("unexpected snapshot: %#v", snapshot) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("expected exactly 2 attempts, got %d", got) + } +} + +// When both attempts fail the provider reports an error so the caller can fall +// back to its cached snapshot. +func TestHTTPSnapshotProviderFailsAfterRetry(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + snapshot, err := httpSnapshotProvider(snapshotTestPort(t, srv.URL))() + if err == nil { + t.Fatal("expected an error after both attempts failed") + } + if snapshot != nil { + t.Fatalf("expected nil snapshot on failure, got %#v", snapshot) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("expected exactly 2 attempts, got %d", got) + } +} diff --git a/gnome-extension/onwatch-menubar@onllm.dev/api.js b/gnome-extension/onwatch-menubar@onllm.dev/api.js index 9ab2e145..184739fa 100644 --- a/gnome-extension/onwatch-menubar@onllm.dev/api.js +++ b/gnome-extension/onwatch-menubar@onllm.dev/api.js @@ -6,7 +6,14 @@ import GLib from 'gi://GLib'; import Soup from 'gi://Soup'; const DEFAULT_PORT = 9211; -const SESSION = new Soup.Session({timeout: 3}); + +/** + * Per-attempt timeout for daemon requests, matching the Go tray client + * (`cmd/onwatch/menubar_runtime.go`): 15 s, with one retry driven by the + * caller. libsoup sets this per session, so it lives here next to the session. + */ +export const SESSION_TIMEOUT_SECONDS = 15; +const SESSION = new Soup.Session({timeout: SESSION_TIMEOUT_SECONDS}); /** * @returns {number} @@ -44,30 +51,43 @@ export function baseURL(port = resolvePort()) { } /** + * GET a daemon JSON endpoint. + * + * Resolves with the decoded body on success and rejects on any failure + * (network error, non-2xx status, empty body, malformed JSON). Callers must + * tell success from failure by catching; there is no fake-offline payload, so + * a caller can keep showing its last good data instead of wiping the screen. + * * @param {string} path * @param {number} [port] - * @returns {Promise} + * @param {?Gio.Cancellable} [cancellable] cancels the request when the caller + * is torn down + * @returns {Promise} */ -export async function fetchJSON(path, port = resolvePort()) { +export async function fetchJSON(path, port = resolvePort(), cancellable = null) { const url = `${baseURL(port)}${path}`; const message = Soup.Message.new('GET', url); if (!message) - return null; + throw new Error(`onWatch: invalid request URL ${url}`); + + const bytes = await SESSION.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + cancellable); + const status = message.get_status(); + if (status !== Soup.Status.OK) + throw new Error(`onWatch: ${path} returned ${status}`); + + const data = bytes.get_data(); + if (!data || data.length === 0) + throw new Error(`onWatch: ${path} returned an empty body`); + + const text = new TextDecoder().decode(data); try { - const bytes = await SESSION.send_and_read_async( - message, - GLib.PRIORITY_DEFAULT, - null); - if (message.get_status() !== Soup.Status.OK) - return null; - const data = bytes.get_data(); - if (!data || data.length === 0) - return null; - const text = new TextDecoder().decode(data); return JSON.parse(text); - } catch (_e) { - return null; + } catch (e) { + throw new Error(`onWatch: ${path} returned invalid JSON: ${e.message}`); } } @@ -75,24 +95,24 @@ export async function fetchJSON(path, port = resolvePort()) { * Compact tray title matching macOS TrayTitle (server-side), plus segments * with icon stems for the GNOME top bar. * + * Rejects when the daemon cannot be reached, so the indicator can retain its + * last good render. `latestAt` is passed through when the payload carries a + * `latest_at` timestamp (used for the stale "last updated Xm ago" label). + * * @param {number} [port] - * @returns {Promise<{title: string, tooltip: string, online: boolean, segments: object[]}>} + * @param {?Gio.Cancellable} [cancellable] + * @returns {Promise<{title: string, tooltip: string, online: boolean, segments: object[], latestAt: ?string}>} */ -export async function fetchTrayTitle(port = resolvePort()) { - const body = await fetchJSON('/api/menubar/tray-title', port); - if (!body) { - return { - title: '—', - tooltip: 'onWatch daemon offline', - online: false, - segments: [], - }; - } +export async function fetchTrayTitle(port = resolvePort(), cancellable = null) { + const body = await fetchJSON('/api/menubar/tray-title', port, cancellable); + if (!body || typeof body !== 'object') + throw new Error('onWatch: tray-title returned no payload'); return { - title: body.title || '—', + title: body.title || '', tooltip: body.tooltip || 'onWatch', online: body.online !== false, segments: Array.isArray(body.segments) ? body.segments : [], + latestAt: typeof body.latest_at === 'string' ? body.latest_at : null, }; } diff --git a/gnome-extension/onwatch-menubar@onllm.dev/indicator.js b/gnome-extension/onwatch-menubar@onllm.dev/indicator.js index 8111dae2..386815da 100644 --- a/gnome-extension/onwatch-menubar@onllm.dev/indicator.js +++ b/gnome-extension/onwatch-menubar@onllm.dev/indicator.js @@ -27,6 +27,26 @@ const ICON_SIZE = 14; /** Delay before opening the quota panel on tray hover (avoids accidental triggers). */ const HOVER_OPEN_DELAY_MS = 750; +/** + * Format an age in ms the same way the Go tray does + * (`internal/menubar/menu.go` formatAgeAgo): "just now", "12m ago", + * "3h ago", "2d ago". + * + * @param {number} ageMs + * @returns {string} + */ +function formatAgeAgo(ageMs) { + const minutes = Math.floor(Math.max(0, ageMs) / 60_000); + if (minutes < 1) + return 'just now'; + if (minutes < 60) + return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + /** * Build a native color for ColorizeEffect.tint. * Prefer Clutter.Color.from_string; fall back to Cogl if needed. @@ -77,10 +97,30 @@ class OnWatchIndicator extends PanelMenu.Button { this._host = new PanelHostController(extensionDir); this._refreshId = 0; this._hoverOpenId = 0; + // Set first thing in destroy(). Every continuation after an await and + // every timer creation checks it, so a request that outlives the + // indicator cannot touch destroyed actors or start new timers. + this._destroyed = false; + // Cancels the in-flight soup request on destroy, where libsoup allows + // it. One cancellable per indicator, reused by every attempt. + this._cancellable = new Gio.Cancellable(); this._giconCache = new Map(); this._icons = []; - // Native color object (from theme node or from_string) — never a plain {} + // Native color object (from theme node or from_string) - never a plain {} this._tintColor = colorFromCss('#f2f2f2'); + // Last good render, kept so a failed refresh can change only the tint + // and tooltip instead of wiping the numbers. Null until the first + // success, which is the only case that shows the offline placeholder. + this._lastGood = null; + // True while a refresh is in flight, so overlapping ticks coalesce + // instead of stacking requests against a slow daemon. + this._refreshInFlight = false; + // Set when a refresh is requested while one is already running; the + // in-flight run loops once more so the request is not dropped. + this._refreshPending = false; + this._staleAgeId = 0; + // True while the tray is showing retained (stale) data. + this._staleMode = false; console.log('onWatch menubar: colorize-v4 starting'); @@ -129,17 +169,36 @@ class OnWatchIndicator extends PanelMenu.Button { this._refreshTitle().catch(e => console.error('onWatch: initial refresh failed', e)); this._refreshId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, REFRESH_MS_DEFAULT, () => { + if (this._destroyed) { + this._refreshId = 0; + return GLib.SOURCE_REMOVE; + } this._refreshTitle().catch(e => console.error('onWatch: refresh failed', e)); return GLib.SOURCE_CONTINUE; }); } destroy() { + // Mark disposed before any teardown: a refresh that is already past + // its await must see this and return without touching actors or + // creating timers. + this._destroyed = true; if (this._refreshId) { GLib.source_remove(this._refreshId); this._refreshId = 0; } + this._stopStaleAgeTimer(); this._cancelHoverOpen(); + // Abort the in-flight request (libsoup reports it as a cancelled + // error, which _refreshTitle swallows for a destroyed instance). + try { + this._cancellable?.cancel(); + } catch (e) { + console.debug?.('onWatch: cancel on destroy failed', e); + } + this._cancellable = null; + this._lastGood = null; + this._staleMode = false; this._host?.destroy(); this._host = null; this._giconCache?.clear(); @@ -184,7 +243,7 @@ class OnWatchIndicator extends PanelMenu.Button { return Math.max(0, Math.min(255, Math.round(v))); }; try { - // Prefer this button's fg (same as tray text) — light text ⇒ dark panel. + // Prefer this button's fg (same as tray text) - light text ⇒ dark panel. const node = this.get_theme_node() || Main.panel?.get_theme_node?.(); if (!node) return true; @@ -202,14 +261,20 @@ class OnWatchIndicator extends PanelMenu.Button { * @param {string} _reason */ async _openPanel(_reason) { + if (this._destroyed) + return; try { const url = menubarPageURL(this._port, { systemPrefersDark: this._panelPrefersDark(), }); await this._host.show(url); } catch (e) { + if (this._destroyed) + return; console.error('onWatch: failed to open panel', e); - this._setOffline('!'); + // A panel-host failure must not wipe the tray numbers either. + if (!this._lastGood) + this._setOffline('!'); } } @@ -221,13 +286,20 @@ class OnWatchIndicator extends PanelMenu.Button { } /** + * Show the offline placeholder. Only reached when there has never been a + * successful response, so there is no last good render to keep. + * * @param {string} text * @param {boolean} [offline] */ _setOffline(text, offline = true) { + if (this._destroyed) + return; + this._stopStaleAgeTimer(); + this._staleMode = false; this._clearBox(); const label = new St.Label({ - text: text || '—', + text: text || '--', y_align: Clutter.ActorAlign.CENTER, style_class: offline ? 'onwatch-menubar-label onwatch-menubar-offline' @@ -236,6 +308,131 @@ class OnWatchIndicator extends PanelMenu.Button { this._box.add_child(label); } + _stopStaleAgeTimer() { + if (this._staleAgeId) { + GLib.source_remove(this._staleAgeId); + this._staleAgeId = 0; + } + } + + /** + * Age to display for retained data: from the payload's `latest_at` when it + * carries one, otherwise from when the last successful fetch landed. Keeps + * counting up while the daemon is unreachable, so it never freezes at the + * build-time value the Go tray also avoids. + * + * @param {?string} latestAt ISO timestamp from the response, if any + * @returns {number} age in ms, or 0 when nothing is known + */ + _staleAgeMs(latestAt) { + const parse = value => { + if (!value) + return 0; + const t = Date.parse(value); + return Number.isFinite(t) ? t : 0; + }; + let from = parse(latestAt); + if (!from && this._lastGood) + from = parse(this._lastGood.latestAt) || this._lastGood.fetchedAtMs; + if (!from) + return 0; + return Math.max(0, Date.now() - from); + } + + /** + * Tick the stale tooltip while retained data is on screen, so "last + * updated 12m ago" advances instead of sticking at the first failure. + */ + _startStaleAgeTimer() { + if (this._destroyed) + return; + this._stopStaleAgeTimer(); + this._staleAgeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 60_000, () => { + if (this._destroyed || !this._staleMode || !this._lastGood) { + this._staleAgeId = 0; + return GLib.SOURCE_REMOVE; + } + this._applyStaleChrome(); + return GLib.SOURCE_CONTINUE; + }); + } + + /** + * Update only the tint and accessible tooltip for retained data, leaving + * the rendered title/segments untouched. + */ + _applyStaleChrome() { + if (this._destroyed || !this._lastGood) + return; + const age = this._staleAgeMs(this._lastGood.latestAt); + this._setName(`onWatch: offline, last updated ${formatAgeAgo(age)}`); + } + + /** + * Mark the tray stale: keep the last good numbers, change only the tint + * and the accessible tooltip/label. + * + * @param {string} reason + */ + _markStale(reason) { + if (this._destroyed) + return; + if (!this._lastGood) { + this._setOffline('--', true); + this._setName('onWatch daemon offline'); + return; + } + this._staleMode = true; + // Offline tint on the retained segments without rebuilding them. + // Remove first so repeated failures cannot stack duplicate classes. + this._box.remove_style_class_name('onwatch-menubar-stale'); + this._box.add_style_class_name('onwatch-menubar-stale'); + this._applyStaleChrome(); + this._startStaleAgeTimer(); + console.debug?.(`onWatch: retaining last good data (${reason})`); + } + + /** + * Clear the stale tint after a successful refresh. + */ + _clearStale() { + if (this._destroyed) + return; + this._stopStaleAgeTimer(); + this._staleMode = false; + this._box.remove_style_class_name('onwatch-menubar-stale'); + } + + /** + * Render a successful payload and remember it as the last good render. + * + * @param {{title: string, tooltip: string, online: boolean, segments: object[], latestAt: ?string}} payload + */ + _applySuccess(payload) { + if (this._destroyed) + return; + const {title, tooltip, online, segments, latestAt} = payload; + // A response that reports itself offline is not a success: retain the + // last good numbers rather than replacing them with a placeholder. + if (!online) { + this._markStale('daemon reported offline'); + return; + } + this._lastGood = { + title, + tooltip, + online, + segments, + latestAt: latestAt || null, + fetchedAtMs: Date.now(), + }; + this._clearStale(); + this._renderSegments(segments, title, online); + this.set_reactive(true); + // Must not throw after a successful render (old bug wiped UI to "-"). + this._setName(tooltip || title || 'onWatch'); + } + /** * @param {string} name */ @@ -249,14 +446,14 @@ class OnWatchIndicator extends PanelMenu.Button { else if (typeof this.setAccessibleName === 'function') this.setAccessibleName(name); } catch (e) { - // Non-fatal — never wipe the tray for a11y failures. + // Non-fatal - never wipe the tray for a11y failures. console.debug?.('onWatch: set accessible name skipped', e); } } /** * Sample native foreground color from a live label (inherits panel theme). - * Keep the GObject color struct — ColorizeEffect rejects plain objects. + * Keep the GObject color struct - ColorizeEffect rejects plain objects. */ _updateTintFromTheme() { let sample = null; @@ -392,10 +589,12 @@ class OnWatchIndicator extends PanelMenu.Button { * @param {boolean} online */ _renderSegments(segments, fallbackTitle, online) { + if (this._destroyed) + return; this._clearBox(); if (!online) { - this._setOffline('—', true); + this._setOffline('--', true); return; } @@ -435,30 +634,93 @@ class OnWatchIndicator extends PanelMenu.Button { }); GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (this._destroyed) + return GLib.SOURCE_REMOVE; this._updateTintFromTheme(); this._applyTintToIcons(); return GLib.SOURCE_REMOVE; }); GLib.timeout_add(GLib.PRIORITY_DEFAULT, 200, () => { + if (this._destroyed) + return GLib.SOURCE_REMOVE; this._updateTintFromTheme(); this._applyTintToIcons(); return GLib.SOURCE_REMOVE; }); } - async _refreshTitle() { + /** + * One fetch attempt. Kept separate so the retry loop reads clearly. + * + * @returns {Promise} + */ + _fetchOnce() { this._port = resolvePort(); + return fetchTrayTitle(this._port, this._cancellable); + } + + /** + * Fetch with one retry, matching the Go tray client: a timeout is often a + * transient write burst that clears within a moment, and a single miss + * must not be treated as failure. Fully async; never blocks the Shell. + * + * @returns {Promise} + */ + async _fetchWithRetry() { try { - const {title, tooltip, online, segments} = await fetchTrayTitle(this._port); - this._renderSegments(segments, title, online); - this.set_reactive(true); - // Must not throw after a successful render (old bug wiped UI to "—"). - this._setName(tooltip || title || 'onWatch'); - } catch (e) { - console.error('onWatch: _refreshTitle failed', e); - // Only show offline if we have nothing useful on screen. - if (!this._box.get_n_children || this._box.get_n_children() === 0) - this._setOffline('—', true); + return await this._fetchOnce(); + } catch (firstErr) { + if (this._destroyed) + throw firstErr; + try { + return await this._fetchOnce(); + } catch (retryErr) { + throw new Error(`${firstErr.message} (retry: ${retryErr.message})`); + } + } + } + + /** + * Refresh the tray. Coalesces overlapping calls: if one is already in + * flight, mark a pending request and return; the in-flight run loops once + * more so the request is not dropped but also does not stack requests + * against a slow daemon. + * + * Every continuation after an await checks `_destroyed`. destroy() cancels + * the soup request, so a late failure or success lands here with the flag + * set and returns without touching actors or arming a timer. + */ + async _refreshTitle() { + if (this._destroyed) + return; + if (this._refreshInFlight) { + this._refreshPending = true; + return; + } + this._refreshInFlight = true; + try { + do { + this._refreshPending = false; + if (this._destroyed) + return; + try { + const payload = await this._fetchWithRetry(); + // The await is the suspension point: the indicator may + // have been destroyed while the request was in flight. + if (this._destroyed) + return; + this._applySuccess(payload); + } catch (e) { + if (this._destroyed) + return; + console.error('onWatch: _refreshTitle failed', e); + // Keep the last good numbers; only the tint and tooltip + // change. Offline placeholder only before any success. + this._markStale(e.message || 'fetch failed'); + } + } while (this._refreshPending && !this._destroyed); + } finally { + this._refreshInFlight = false; } } }); diff --git a/gnome-extension/onwatch-menubar@onllm.dev/stylesheet.css b/gnome-extension/onwatch-menubar@onllm.dev/stylesheet.css index 47d752b6..4c2aab86 100644 --- a/gnome-extension/onwatch-menubar@onllm.dev/stylesheet.css +++ b/gnome-extension/onwatch-menubar@onllm.dev/stylesheet.css @@ -19,7 +19,7 @@ width: 14px; height: 14px; margin-right: 1px; - /* regular — ColorizeEffect handles tint; symbolic FileIcon path was unreliable */ + /* regular - ColorizeEffect handles tint; symbolic FileIcon path was unreliable */ -st-icon-style: regular; } @@ -40,3 +40,8 @@ opacity: 0.55; font-weight: 500; } + +/* Retained data after a failed refresh: numbers stay, dimmed like offline. */ +.onwatch-menubar-stale { + opacity: 0.55; +} diff --git a/internal/menubar/companion.go b/internal/menubar/companion.go index 8d9d289d..72422682 100644 --- a/internal/menubar/companion.go +++ b/internal/menubar/companion.go @@ -37,6 +37,19 @@ type trayController struct { mistralMu sync.RWMutex mistralEnabled bool grantFlow browserGrantFlow + + // refreshWorker serializes snapshot fetches, caches the last good snapshot + // and runs every refresh on one goroutine. See tray_refresh.go. + refresh *refreshWorker + + // cachedSettings and lastVisual are the last successfully applied tray + // preferences and visual. They are read and written only by the refresh + // worker's apply callback, which is the only goroutine that runs a refresh, + // so they need no separate lock. On a failed preferences fetch the + // controller reuses them instead of DefaultSettings, so a daemon outage + // cannot blank the title or badge. + cachedSettings *Settings + lastVisual trayVisual } func (c *trayController) setMistralEnabled(enabled bool) { @@ -59,9 +72,14 @@ func runCompanion(cfg *Config) error { quitFn = nil controller := &trayController{cfg: cfg} + controller.refresh = newRefreshWorker(controller.fetchSnapshot, controller.applyRefresh) stop := make(chan struct{}) defer close(stop) - go watchRefreshRequests(stop, cfg.TestMode, controller.refreshStatus) + // One worker serves every refresh for the process lifetime: the startup + // fetch, menu clicks, daemon refresh requests and the periodic tick all + // publish to the same channel, so they run one at a time in order. + go controller.refresh.RunWorker(stop) + go watchRefreshRequests(stop, cfg.TestMode, controller.refresh.Request) prepareTrayProcess() slog.Default().Debug("Initializing systray") @@ -127,7 +145,10 @@ func (c *trayController) onReady() { systray.Quit() } - c.refreshStatus() + // The first refresh can block up to the fetch timeout; the worker runs it + // off the systray thread so the menu lays out immediately (a blocking first + // refresh delayed the first menu layout on Linux). + c.refresh.Request() logger.Info("Menubar ready and visible") go c.watchMenu(quickViewItem, dashboardItem, refreshItem, quitItem) @@ -216,7 +237,9 @@ func (c *trayController) watchMenu(quickViewItem, dashboardItem, refreshItem, qu case <-dashboardItem.ClickedCh: _ = browser.OpenURL(c.dashboardURL()) case <-refreshItem.ClickedCh: - c.refreshStatus() + // The worker runs the fetch on its own goroutine, so a slow daemon + // cannot block the other menu clicks systray hands over. + c.refresh.Request() case <-quitItem.ClickedCh: _ = stopCompanion() return @@ -280,17 +303,33 @@ func (c *trayController) showMenubar() { _ = browser.OpenURL(url) } +// refreshLoop publishes a periodic refresh. The tick and the on-demand +// requests share the worker's buffer-1 channel, so a tick that lands during a +// run coalesces into the same follow-up run instead of stacking. func (c *trayController) refreshLoop() { interval := time.Duration(normalizeRefreshSeconds(c.cfg.RefreshSeconds)) * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { - c.refreshStatus() + c.refresh.Request() + } +} + +// fetchSnapshot is the worker's fetch step. A controller without a provider +// (tests, or a partial config) yields no data, and applyRefresh renders the +// offline state, so a nil function is never called. +func (c *trayController) fetchSnapshot() (*Snapshot, error) { + if c == nil || c.cfg == nil || c.cfg.SnapshotProvider == nil { + return nil, nil } + return c.cfg.SnapshotProvider() } -func (c *trayController) refreshStatus() { +// applyRefresh is the worker's apply step: it fetches preferences, builds the +// plan and updates the tray. It runs on the single refresh worker goroutine, +// so it can touch the cached settings and last visual without extra locking. +func (c *trayController) applyRefresh(snapshot *Snapshot, stale bool, age time.Duration, fetchErr error) { logger := slog.Default() if c == nil || c.cfg == nil || c.cfg.SnapshotProvider == nil { updateTrayVisual(trayVisual{Title: "onWatch", Text: "-", Status: trayStatusOffline}) @@ -298,36 +337,44 @@ func (c *trayController) refreshStatus() { return } - snapshot, err := c.cfg.SnapshotProvider() - if err != nil { - logger.Error("failed to refresh menubar snapshot", "error", err) - snapshot = nil + if stale { + // Keep the last known numbers. A failure may recolor the icon and + // update the tooltip, but must not wipe the title or the menu. + logger.Warn("failed to refresh menubar snapshot, keeping last known values", "error", fetchErr) + } else if fetchErr != nil { + logger.Error("failed to refresh menubar snapshot", "error", fetchErr) } - if snapshot == nil { - updateTrayVisual(trayVisual{Title: "--", Text: "-", Status: trayStatusOffline}) - setTrayTooltip(TrayTooltipText(nil, trayTooltipLimit)) - c.updateMenu(nil) - return + + // Preferences are fetched even on a stale summary so a recovered daemon + // picks up changed selections. A failed fetch returns nil and the plan + // falls back to the last good preferences, never to DefaultSettings while + // numbers are cached. + var freshSettings *Settings + if snapshot != nil { + settings, err := c.fetchPreferences() + if err != nil { + logger.Debug("failed to refresh menubar preferences, keeping last known", "error", err) + } else { + freshSettings = settings + } } - c.setMistralEnabled(snapshot.MistralEnabled) - settings, err := c.fetchPreferences() - if err != nil { - logger.Debug("failed to refresh menubar preferences, using defaults", "error", err) - settings = DefaultSettings() + plan := planTrayRefresh(snapshot, stale, age, freshSettings, c.cachedSettings, c.lastVisual, trayTooltipLimit) + if plan.FreshSettings { + c.cachedSettings = plan.Settings } - segments := TraySegments(snapshot, settings) - visual := trayVisual{ - Title: TrayTitle(snapshot, settings), - Text: TrayIconText(segments, true), - Status: TrayBadgeStatus(snapshot, segments), - Online: true, - IconOnly: settings.StatusDisplay.Mode == StatusDisplayIconOnly, + if plan.Offline { + updateTrayVisual(plan.Visual) + setTrayTooltip(plan.Tooltip) + c.updateMenu(nil) + return } - updateTrayVisual(visual) - setTrayTooltip(TrayTooltipText(snapshot, trayTooltipLimit)) - c.updateMenu(BuildMenuLines(snapshot)) - logger.Debug("Tray icon set successfully", "title", visual.Title, "badge", visual.Text, "status", visual.Status) + c.setMistralEnabled(plan.Snapshot.MistralEnabled) + updateTrayVisual(plan.Visual) + c.lastVisual = plan.Visual + setTrayTooltip(plan.Tooltip) + c.updateMenu(plan.MenuLines) + logger.Debug("Tray icon set successfully", "title", plan.Visual.Title, "badge", plan.Visual.Text, "status", plan.Visual.Status, "stale", plan.Stale) } func (c *trayController) menubarURL() string { diff --git a/internal/menubar/companion_stale_test.go b/internal/menubar/companion_stale_test.go new file mode 100644 index 00000000..8e745569 --- /dev/null +++ b/internal/menubar/companion_stale_test.go @@ -0,0 +1,155 @@ +package menubar + +import ( + "errors" + "strings" + "testing" + "time" +) + +// traySnapshotWithNumbers is a snapshot with two providers the custom +// selections below can point at. +func traySnapshotWithNumbers() *Snapshot { + return &Snapshot{ + LatestAt: time.Unix(1_700_000_000, 0), + Aggregate: Aggregate{ + ProviderCount: 2, + Status: "healthy", + }, + Providers: []ProviderCard{ + {ID: "anthropic", BaseProvider: "anthropic", Label: "Claude", Status: "healthy", HighestPercent: 10}, + {ID: "zai", BaseProvider: "zai", Label: "GLM", Status: "healthy", HighestPercent: 20}, + }, + } +} + +// customTraySettings selects two quotas for the tray title, so a regression to +// DefaultSettings (multi_provider with no selection) is visible as an empty +// title. +func customTraySettings() *Settings { + settings := DefaultSettings() + settings.StatusDisplay = StatusDisplay{ + Mode: StatusDisplayMultiProvider, + SelectedQuotas: []StatusDisplaySelection{ + {ProviderID: "anthropic"}, + {ProviderID: "zai"}, + }, + } + return settings +} + +// Finding 1 regression: after a successful refresh with custom tray +// selections, a refresh where both the summary and the preferences fail must +// keep the title and badge text unchanged, change only the status/icon tint, +// and switch the tooltip to the stale age. Before the fix the controller fell +// back to DefaultSettings (no selection), which emptied the title and badge. +func TestPlanTrayRefreshKeepsTitleWhenPreferencesFail(t *testing.T) { + t.Parallel() + snapshot := traySnapshotWithNumbers() + settings := customTraySettings() + + // First refresh: summary and preferences both succeed. + first := planTrayRefresh(snapshot, false, 0, settings, nil, trayVisual{}, 0) + if first.Visual.Title != "10%·20%" { + t.Fatalf("first title = %q, want %q", first.Visual.Title, "10%·20%") + } + if first.Visual.Text != "10" { + t.Fatalf("first badge text = %q, want %q", first.Visual.Text, "10") + } + if !first.FreshSettings || first.Settings != settings { + t.Fatalf("expected the fresh settings to be adopted, got fresh=%v", first.FreshSettings) + } + + // Second refresh: summary succeeds but preferences fail (freshSettings nil). + stale := planTrayRefresh(snapshot, true, 3*time.Minute, nil, settings, first.Visual, 0) + if stale.Visual.Title != first.Visual.Title { + t.Fatalf("stale title = %q, want unchanged %q", stale.Visual.Title, first.Visual.Title) + } + if stale.Visual.Text != first.Visual.Text { + t.Fatalf("stale badge text = %q, want unchanged %q", stale.Visual.Text, first.Visual.Text) + } + if stale.Visual.IconOnly != first.Visual.IconOnly { + t.Fatalf("stale IconOnly = %v, want unchanged %v", stale.Visual.IconOnly, first.Visual.IconOnly) + } + if stale.Visual.Status != trayStatusOffline { + t.Fatalf("stale status = %q, want %q", stale.Visual.Status, trayStatusOffline) + } + if stale.FreshSettings { + t.Fatal("a failed preferences fetch must not be adopted as fresh settings") + } + if !strings.Contains(stale.Tooltip, "last updated 3m ago") { + t.Fatalf("stale tooltip = %q, want a last-updated age", stale.Tooltip) + } + if stale.Visual.Title == "" { + t.Fatal("stale title must never be empty while numbers are cached") + } +} + +// The last-good visual is the final fallback: if preferences have never been +// fetched successfully and the current fetch fails, a stale refresh still keeps +// the previously applied title/badge rather than blanking the tray. +func TestPlanTrayRefreshFallsBackToLastVisual(t *testing.T) { + t.Parallel() + snapshot := traySnapshotWithNumbers() + last := trayVisual{Title: "10%·20%", Text: "10", Status: "healthy", Online: true} + + stale := planTrayRefresh(snapshot, true, time.Minute, nil, nil, last, 0) + if stale.Visual.Title != last.Title || stale.Visual.Text != last.Text { + t.Fatalf("stale visual = %+v, want the last-good title/text %+v", stale.Visual, last) + } + if stale.Visual.Status != trayStatusOffline { + t.Fatalf("stale status = %q, want %q", stale.Visual.Status, trayStatusOffline) + } +} + +// A successful refresh still adopts fresh preferences, so the cache cannot pin +// the tray to an old selection once the daemon is reachable again. +func TestPlanTrayRefreshAdoptsFreshSettings(t *testing.T) { + t.Parallel() + snapshot := traySnapshotWithNumbers() + old := customTraySettings() + fresh := DefaultSettings() + fresh.StatusDisplay = StatusDisplay{ + Mode: StatusDisplayMultiProvider, + SelectedQuotas: []StatusDisplaySelection{{ProviderID: "zai"}}, + } + + plan := planTrayRefresh(snapshot, false, 0, fresh, old, trayVisual{Title: "10%·20%", Text: "10", Online: true}, 0) + if plan.Visual.Title != "20%" { + t.Fatalf("title = %q, want %q (fresh settings must win)", plan.Visual.Title, "20%") + } + if !plan.FreshSettings || plan.Settings != fresh { + t.Fatal("fresh settings must be adopted on a successful refresh") + } +} + +// Before any snapshot has ever succeeded the tray is allowed to show the +// offline placeholder; that is the only case that blanks the numbers. +func TestPlanTrayRefreshOfflineBeforeAnySuccess(t *testing.T) { + t.Parallel() + plan := planTrayRefresh(nil, false, 0, nil, nil, trayVisual{}, 0) + if !plan.Offline { + t.Fatal("expected Offline=true before any success") + } + if plan.Visual.Title != "--" || plan.Visual.Text != "-" { + t.Fatalf("offline visual = %+v, want --/- placeholder", plan.Visual) + } + if !strings.Contains(plan.Tooltip, "daemon") { + t.Fatalf("offline tooltip = %q, want an unreachable hint", plan.Tooltip) + } +} + +// A stale plan must never surface a fetch error as a success: the stale flag +// stays set so the caller logs a warning rather than an error. +func TestPlanTrayRefreshStaleFlagSurvives(t *testing.T) { + t.Parallel() + snapshot := traySnapshotWithNumbers() + plan := planTrayRefresh(snapshot, true, time.Minute, customTraySettings(), nil, trayVisual{}, 0) + if !plan.Stale { + t.Fatal("expected Stale=true") + } + // Sanity: the pure helper feeding the plan still reports the error path. + if _, stale := nextTraySnapshot(snapshot, nil, errors.New("boom")); !stale { + t.Fatal("nextTraySnapshot must report stale on a failed fetch") + } +} diff --git a/internal/menubar/config.go b/internal/menubar/config.go index c6ae0d23..befad913 100644 --- a/internal/menubar/config.go +++ b/internal/menubar/config.go @@ -85,10 +85,15 @@ const ( // Snapshot is the normalized UI contract shared by the desktop app and the // browser-testable menubar page. type Snapshot struct { - GeneratedAt time.Time `json:"generated_at"` - UpdatedAgo string `json:"updated_ago"` - Aggregate Aggregate `json:"aggregate"` - Providers []ProviderCard `json:"providers"` + GeneratedAt time.Time `json:"generated_at"` + UpdatedAgo string `json:"updated_ago"` + // LatestAt is the newest provider capturedAt behind UpdatedAgo. Clients use + // it for the header age so it reflects data freshness, not build time. + // omitzero (not omitempty) drops the zero time.Time; omitempty would emit + // "0001-01-01T00:00:00Z", which a client renders as about 739000 days. + LatestAt time.Time `json:"latest_at,omitzero"` + Aggregate Aggregate `json:"aggregate"` + Providers []ProviderCard `json:"providers"` // MistralEnabled reflects config, not poll state: a provider card only // appears in Providers once it has quota data, which would hide anything // gated on it until after the first successful poll. @@ -119,6 +124,10 @@ type ProviderCard struct { Quotas []QuotaMeter `json:"quotas"` Trends []TrendSeries `json:"trends,omitempty"` Promo *ProviderPromo `json:"promo,omitempty"` + // UpdatedAtTime is the provider's last successful data time as RFC3339. + // Clients compute the age label from it against their own wall clock, so + // the label keeps counting up even when the daemon cannot be reached. + UpdatedAtTime string `json:"updated_at_time,omitempty"` } // ProviderPromo carries promo metadata for a provider card. diff --git a/internal/menubar/menu.go b/internal/menubar/menu.go index 34519363..78ad8fe4 100644 --- a/internal/menubar/menu.go +++ b/internal/menubar/menu.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "strings" + "time" ) // MenuLine is one per-provider row shown in the tray context menu. @@ -56,28 +57,65 @@ func TrayTooltipText(snapshot *Snapshot, maxRunes int) string { if snapshot == nil { return "onWatch: daemon unreachable" } - var text string - if len(snapshot.Providers) == 0 { - text = "onWatch: no provider data" - } else { - parts := make([]string, 0, len(snapshot.Providers)) - for _, provider := range snapshot.Providers { - label := strings.TrimSpace(provider.Label) - if label == "" { - label = provider.ID - } - parts = append(parts, fmt.Sprintf("%s %d%%", label, int(math.Round(provider.HighestPercent)))) - } - text = strings.Join(parts, " · ") - } + text := trayProviderLines(snapshot) if updated := strings.TrimSpace(snapshot.UpdatedAgo); updated != "" { text += "\nupdated " + updated } - if maxRunes > 0 { - runes := []rune(text) - if len(runes) > maxRunes { - text = string(runes[:maxRunes-3]) + "..." + return truncateTooltip(text, maxRunes) +} + +// TrayTooltipStaleText renders the hover text when the last refresh failed and +// the tray is showing a cached snapshot. The provider lines are identical; only +// the freshness line differs, and it is computed from age (client wall-clock +// time since the last successful fetch) so it keeps counting up while the +// daemon is unreachable. The snapshot's own UpdatedAgo is frozen at build time +// and would stall on the stale value. +func TrayTooltipStaleText(snapshot *Snapshot, maxRunes int, age time.Duration) string { + if snapshot == nil { + return "onWatch: daemon unreachable" + } + return truncateTooltip(trayProviderLines(snapshot)+"\nlast updated "+formatAgeAgo(age), maxRunes) +} + +// trayProviderLines is the shared "Label NN%" body of both tooltips. +func trayProviderLines(snapshot *Snapshot) string { + if len(snapshot.Providers) == 0 { + return "onWatch: no provider data" + } + parts := make([]string, 0, len(snapshot.Providers)) + for _, provider := range snapshot.Providers { + label := strings.TrimSpace(provider.Label) + if label == "" { + label = provider.ID } + parts = append(parts, fmt.Sprintf("%s %d%%", label, int(math.Round(provider.HighestPercent)))) + } + return strings.Join(parts, " · ") +} + +func truncateTooltip(text string, maxRunes int) string { + if maxRunes <= 0 { + return text + } + runes := []rune(text) + if len(runes) <= maxRunes { + return text + } + return string(runes[:maxRunes-3]) + "..." +} + +// formatAgeAgo renders a duration as a compact age string. It is computed from +// exact timestamps so it grows smoothly on its scale (no integer-day step +// jumps): "just now", "2m ago", "1h ago", "2d ago". +func formatAgeAgo(age time.Duration) string { + if age < time.Minute { + return "just now" + } + if age < time.Hour { + return fmt.Sprintf("%dm ago", int(age.Minutes())) + } + if age < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(age.Hours())) } - return text + return fmt.Sprintf("%dd ago", int(age.Hours()/24)) } diff --git a/internal/menubar/popover_bridge.go b/internal/menubar/popover_bridge.go index 1490492b..8bb13bfd 100644 --- a/internal/menubar/popover_bridge.go +++ b/internal/menubar/popover_bridge.go @@ -23,12 +23,15 @@ const ( hostMessageClose hostMessageOpenDashboard hostMessageOpenURL + hostMessageTheme ) type hostMessage struct { Kind hostMessageKind Height float64 URL string + // Theme is the page's effective theme ("dark" or "light") after applyTheme. + Theme string } // parseHostMessage decodes one page message. Anything malformed or not meant @@ -57,6 +60,7 @@ func parseHostMessage(raw string) hostMessage { var action struct { Action string `json:"action"` URL string `json:"url"` + Theme string `json:"theme"` } if err := json.Unmarshal(envelope.Body, &action.Action); err != nil { if err := json.Unmarshal(envelope.Body, &action); err != nil { @@ -70,6 +74,10 @@ func parseHostMessage(raw string) hostMessage { return hostMessage{Kind: hostMessageOpenDashboard} case "open_url": return hostMessage{Kind: hostMessageOpenURL, URL: action.URL} + case "theme": + if action.Theme == "dark" || action.Theme == "light" { + return hostMessage{Kind: hostMessageTheme, Theme: action.Theme} + } } } return hostMessage{} diff --git a/internal/menubar/popover_bridge_test.go b/internal/menubar/popover_bridge_test.go index 4bae21ea..f096a4a7 100644 --- a/internal/menubar/popover_bridge_test.go +++ b/internal/menubar/popover_bridge_test.go @@ -48,6 +48,28 @@ func TestParseHostMessageActions(t *testing.T) { } } +// The page reports its effective theme so a native host can match its own +// background layers to the page, even when onWatch's theme differs from the OS. +func TestParseHostMessageTheme(t *testing.T) { + cases := []struct { + raw string + kind hostMessageKind + theme string + }{ + {`{"channel":"onwatchAction","body":{"action":"theme","theme":"dark"}}`, hostMessageTheme, "dark"}, + {`{"channel":"onwatchAction","body":{"action":"theme","theme":"light"}}`, hostMessageTheme, "light"}, + {`{"channel":"onwatchAction","body":{"action":"theme","theme":"neon"}}`, hostMessageUnknown, ""}, + {`{"channel":"onwatchAction","body":{"action":"theme"}}`, hostMessageUnknown, ""}, + {`{"channel":"onwatchAction","body":{"action":"close"}}`, hostMessageClose, ""}, + } + for _, tc := range cases { + msg := parseHostMessage(tc.raw) + if msg.Kind != tc.kind || msg.Theme != tc.theme { + t.Fatalf("%q: got %+v, want kind %v theme %q", tc.raw, msg, tc.kind, tc.theme) + } + } +} + func TestDashboardURLForKeepsRuntimeOrigin(t *testing.T) { cases := map[string]string{ "http://localhost:9211/menubar": "http://localhost:9211", diff --git a/internal/menubar/popover_darwin.m b/internal/menubar/popover_darwin.m index 2b7aa6b4..0b0f1392 100644 --- a/internal/menubar/popover_darwin.m +++ b/internal/menubar/popover_darwin.m @@ -28,6 +28,34 @@ static void onwatch_run_on_main_sync(dispatch_block_t block) { dispatch_sync(dispatch_get_main_queue(), block); } +// Unique context so observeValueForKeyPath: knows the effectiveAppearance +// notification is ours; anything else must go to super. Without this, NSObject's +// default implementation throws NSInternalInconsistencyException. +static void *onwatchPopoverAppearanceContext = &onwatchPopoverAppearanceContext; + +// onwatch_popover_background_color returns the layer color for the page's +// --bg-body. Dark: #0d0d0d, light: #f3f5f8. The page reports its effective +// theme (which can differ from the OS, or change at runtime), so prefer that; +// otherwise fall back to the OS appearance using the dark/light names only, so +// high-contrast variants (which are also dark) resolve correctly. +static NSColor *onwatch_popover_background_color(NSString *theme) { + BOOL dark; + if ([theme isEqualToString:@"dark"]) { + dark = YES; + } else if ([theme isEqualToString:@"light"]) { + dark = NO; + } else { + NSAppearanceName match = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:@[ + NSAppearanceNameAqua, NSAppearanceNameDarkAqua + ]]; + dark = [match isEqualToString:NSAppearanceNameDarkAqua]; + } + if (dark) { + return [NSColor colorWithSRGBRed:0x0d / 255.0 green:0x0d / 255.0 blue:0x0d / 255.0 alpha:1.0]; + } + return [NSColor colorWithSRGBRed:0xf3 / 255.0 green:0xf5 / 255.0 blue:0xf8 / 255.0 alpha:1.0]; +} + extern void onwatchGoGrantBrowserAccess(uint64_t token); // Implemented in Go (resize_darwin.go); applies the tested floor/ceiling. extern int onwatchGoClampPopoverHeight(int requested, int maxAllowed); @@ -65,10 +93,17 @@ @interface OnWatchPopoverController : NSObject *)change + context:(void *)context { + if (context != onwatchPopoverAppearanceContext) { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + return; + } + // Our own observation: apply the OS theme only until the page reports its + // own. Always return; never fall through to super for our context. + if (!self.pageTheme) { + [self applyPageTheme:nil]; + } +} + +// teardown removes the appearance observer exactly once. It is called from the +// destroy path instead of dealloc: WKUserContentController retains the +// controller strongly, so dealloc does not run while the popover is alive. +- (void)teardown { + if (self.appearanceObserving) { + self.appearanceObserving = NO; + [NSApp removeObserver:self forKeyPath:@"effectiveAppearance" context:onwatchPopoverAppearanceContext]; + } +} + - (void)dealloc { + [self teardown]; [self stopTransientCloseMonitoring]; [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"onwatchResize"]; [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"onwatchAction"]; @@ -470,6 +553,7 @@ - (void)userContentController:(WKUserContentController *)userContentController } NSString *action = nil; + NSString *theme = nil; id body = message.body; if ([body isKindOfClass:[NSString class]]) { action = (NSString *)body; @@ -478,12 +562,23 @@ - (void)userContentController:(WKUserContentController *)userContentController if ([value isKindOfClass:[NSString class]]) { action = (NSString *)value; } + id themeValue = [(NSDictionary *)body objectForKey:@"theme"]; + if ([themeValue isKindOfClass:[NSString class]]) { + theme = (NSString *)themeValue; + } } if (![action isKindOfClass:[NSString class]]) { return; } + if ([action isEqualToString:@"theme"]) { + if ([theme isEqualToString:@"dark"] || [theme isEqualToString:@"light"]) { + [self applyPageTheme:theme]; + } + return; + } + if ([action isEqualToString:@"grant_browser_access"] || [action isEqualToString:@"browser_access_status"]) { WKSecurityOrigin *origin = message.frameInfo.securityOrigin; BOOL allowed = self.grantToken && onwatch_grant_origin_allowed(self.loadedURLString.UTF8String, @@ -557,6 +652,7 @@ void onwatch_popover_destroy(void *handle) { onwatch_run_on_main_sync(^{ OnWatchPopoverController *controller = (__bridge_transfer OnWatchPopoverController *)handle; [onwatchGrantHosts removeObjectForKey:@(controller.grantToken)]; + [controller teardown]; [controller close]; }); } diff --git a/internal/menubar/quickview_webview2_windows.go b/internal/menubar/quickview_webview2_windows.go index 12cc63d2..51612c49 100644 --- a/internal/menubar/quickview_webview2_windows.go +++ b/internal/menubar/quickview_webview2_windows.go @@ -415,11 +415,7 @@ func (p *webView2Popover) configure() { } // Paint the page background before the document does, so opening the // flyout never flashes white. - if controller := p.chromium.GetController(); controller != nil { - if controller2 := controller.GetICoreWebView2Controller2(); controller2 != nil { - _ = controller2.PutDefaultBackgroundColor(p.background()) - } - } + p.applyBackground() p.chromium.Init(webView2HostScript) } @@ -522,6 +518,24 @@ func (p *webView2Popover) onMessage(raw string) { p.hide(false) go openInBrowser(msg.URL) } + case hostMessageTheme: + // The page's effective theme can differ from the OS; match the default + // background so a repaint never flashes the wrong color. + p.dark = msg.Theme == "dark" + p.applyBackground() + } +} + +// applyBackground repaints the WebView default background for the current +// theme. It is a no-op before the controller exists. +func (p *webView2Popover) applyBackground() { + if p.chromium == nil { + return + } + if controller := p.chromium.GetController(); controller != nil { + if controller2 := controller.GetICoreWebView2Controller2(); controller2 != nil { + _ = controller2.PutDefaultBackgroundColor(p.background()) + } } } diff --git a/internal/menubar/tray_refresh.go b/internal/menubar/tray_refresh.go new file mode 100644 index 00000000..1c58893d --- /dev/null +++ b/internal/menubar/tray_refresh.go @@ -0,0 +1,220 @@ +package menubar + +import ( + "time" +) + +// This file holds the refresh-fallback logic with no build tag so it is +// compiled and tested by the default `go test ./...` build (the companion +// itself is behind the `menubar` tag). + +// nextTraySnapshot decides what a refresh should display. A successful fetch +// replaces the cache. A failed fetch (an error, or a nil snapshot) reuses the +// last good snapshot and reports stale=true, so the caller keeps the numbers +// and only marks the tooltip/status. Only the very first refresh, with nothing +// cached yet, returns nil. +func nextTraySnapshot(cached, fetched *Snapshot, err error) (*Snapshot, bool) { + if err == nil && fetched != nil { + return fetched, false + } + if cached != nil { + return cached, true + } + return nil, false +} + +// trayPlan is the outcome of one refresh as the companion should apply it. It +// is a pure value so the controller's decisions are testable without systray. +type trayPlan struct { + // Snapshot is the snapshot to display, nil only before any success. + Snapshot *Snapshot + // Settings are the preferences the visual was built from. Nil when the + // visual came from the cached last-good state rather than this run. + Settings *Settings + // Visual is what to hand the platform layer. + Visual trayVisual + // Tooltip is the hover text. + Tooltip string + // MenuLines are the tray menu rows. + MenuLines []MenuLine + // Stale is true when the fetch failed and cached data is being shown. + Stale bool + // Offline is true when nothing has ever been fetched. + Offline bool + // FreshSettings is true when this run fetched preferences successfully and + // they should replace the cached ones. + FreshSettings bool +} + +// planTrayRefresh turns a snapshot refresh plus the cached preferences and +// last-good visual into the plan the companion applies. It is the fix for the +// outage regression: a failed refresh keeps the last-good title, badge text +// and display mode, and changes only the status/icon tint and the tooltip. Only +// the very first refresh, with nothing ever fetched, blanks the tray. +// +// freshSettings is this run's preferences (nil when the preferences fetch +// failed). cachedSettings and lastVisual are the last successfully applied +// values. age is the stale age to show, tooltipLimit caps the tooltip (0 = no +// cap; the platform passes its own trayTooltipLimit). +func planTrayRefresh(snapshot *Snapshot, stale bool, age time.Duration, freshSettings *Settings, cachedSettings *Settings, lastVisual trayVisual, tooltipLimit int) trayPlan { + if snapshot == nil { + // Only reached before any snapshot has ever succeeded. + return trayPlan{ + Tooltip: TrayTooltipText(nil, tooltipLimit), + Offline: true, + Visual: trayVisual{Title: "--", Text: "-", Status: trayStatusOffline}, + } + } + + settings := freshSettings + if settings == nil { + // Preferences failed. Fall back to the last good preferences so the + // title, badge and display mode survive a daemon outage. DefaultSettings + // is only correct before preferences have ever been fetched. + settings = cachedSettings + if settings == nil { + settings = DefaultSettings() + } + } + segments := TraySegments(snapshot, settings) + visual := trayVisual{ + Title: TrayTitle(snapshot, settings), + Text: TrayIconText(segments, true), + Status: TrayBadgeStatus(snapshot, segments), + Online: true, + IconOnly: settings.StatusDisplay.Mode == StatusDisplayIconOnly, + } + tooltip := TrayTooltipText(snapshot, tooltipLimit) + if stale { + // Numbers stay; only the status/icon tint and the tooltip change. If a + // last-good visual exists and this run could not build one (no cached + // settings and the preferences fetch failed), keep it rather than + // blanking the tray. + visual.Status = trayStatusOffline + if lastVisual.Online && visual.Title == "" && visual.Text == "" { + visual.Title = lastVisual.Title + visual.Text = lastVisual.Text + visual.IconOnly = lastVisual.IconOnly + } + tooltip = TrayTooltipStaleText(snapshot, tooltipLimit, age) + } + return trayPlan{ + Snapshot: snapshot, + Settings: settings, + Visual: visual, + Tooltip: tooltip, + MenuLines: BuildMenuLines(snapshot), + Stale: stale, + FreshSettings: freshSettings != nil, + } +} + +// applyFunc receives the result of one refresh run: the snapshot to display +// (nil only before any success), whether it is stale, the age to show for a +// stale snapshot, and the fetch error (nil on success) for the caller to log. +type applyFunc func(snapshot *Snapshot, stale bool, age time.Duration, err error) + +// refreshWorker owns the cached snapshot and runs every refresh serially on a +// single goroutine. Fetch and apply are never concurrent, so an older failure +// can never overwrite a newer success and no lock is needed around them. +// +// Requests are a non-blocking send on a buffer-1 channel. A request that +// arrives while a run is in flight leaves one token queued, so the worker runs +// exactly once more; any further requests during that run collapse into the +// same token. There is no pending flag that a descheduled caller could set +// after the worker has already decided to stop, so a request can never be +// lost. +type refreshWorker struct { + // requests is the worker's inbox. Buffer 1 is the coalescing point: a + // non-blocking send succeeds for the first request and is dropped for the + // rest, which are already covered by the queued run. + requests chan struct{} + + fetch SnapshotProvider + apply applyFunc + + snapshot *Snapshot + fetchedAt time.Time + + // Test hooks. Each runs on the worker goroutine at a fixed point in a run + // (both nil in production), so tests can publish requests at those points + // without timing sleeps. + testHookDuringFetch func() + testHookDuringApply func() +} + +func newRefreshWorker(fetch SnapshotProvider, apply applyFunc) *refreshWorker { + return &refreshWorker{ + requests: make(chan struct{}, 1), + fetch: fetch, + apply: apply, + } +} + +// Request asks the worker for a refresh. It never blocks, so a click on the +// systray goroutine cannot stall behind a slow daemon. If a run is in flight +// (or already queued), the request is covered by the follow-up run and the +// send is a no-op. +func (w *refreshWorker) Request() { + select { + case w.requests <- struct{}{}: + default: + } +} + +// RunWorker is the worker loop. It starts once and runs for the process +// lifetime, or until stop is closed. All fetch and apply calls happen on this +// goroutine, which is what guarantees ordering and the absence of overlap. +func (w *refreshWorker) RunWorker(stop <-chan struct{}) { + for { + select { + case <-stop: + return + case <-w.requests: + w.runOnce() + } + } +} + +// runOnce is a single fetch+merge+apply pass. The clock is read when the run +// starts, not when the request was published, so the recorded fetch time +// reflects the data actually fetched. +func (w *refreshWorker) runOnce() { + now := time.Now() + if hook := w.testHookDuringFetch; hook != nil { + hook() + } + + fetched, err := w.fetch() + snapshot, stale := nextTraySnapshot(w.snapshot, fetched, err) + if snapshot == nil { + w.apply(nil, false, 0, err) + return + } + if !stale { + w.snapshot = snapshot + w.fetchedAt = now + } + if hook := w.testHookDuringApply; hook != nil { + hook() + } + w.apply(snapshot, stale, staleSnapshotAge(snapshot, w.fetchedAt, now), err) +} + +// staleSnapshotAge reports the age to show for a stale snapshot. It counts from +// the snapshot's newest provider data (latest_at) so a failure does not make +// "updated 2h ago" snap back to "just now". It falls back to when the snapshot +// was fetched, and returns 0 when neither is known. +func staleSnapshotAge(snapshot *Snapshot, fetchedAt, now time.Time) time.Duration { + var from time.Time + if snapshot != nil { + from = snapshot.LatestAt + } + if from.IsZero() { + from = fetchedAt + } + if from.IsZero() { + return 0 + } + return now.Sub(from) +} diff --git a/internal/menubar/tray_refresh_test.go b/internal/menubar/tray_refresh_test.go new file mode 100644 index 00000000..9430aabe --- /dev/null +++ b/internal/menubar/tray_refresh_test.go @@ -0,0 +1,457 @@ +package menubar + +import ( + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// A failed fetch must never blank the tray. The last good snapshot is reused +// and flagged stale; only a refresh that has never succeeded returns nil. +func TestNextTraySnapshotKeepsCacheOnFailure(t *testing.T) { + t.Parallel() + cached := &Snapshot{Aggregate: Aggregate{ProviderCount: 1}} + + got, stale := nextTraySnapshot(cached, nil, errors.New("timeout")) + if got != cached { + t.Fatalf("expected cached snapshot on failure, got %#v", got) + } + if !stale { + t.Fatal("expected stale=true when the fetch failed") + } +} + +// A nil fetch result (no error) is still a failure: keep the cache. +func TestNextTraySnapshotNilFetchKeepsCache(t *testing.T) { + t.Parallel() + cached := &Snapshot{Aggregate: Aggregate{ProviderCount: 1}} + + got, stale := nextTraySnapshot(cached, nil, nil) + if got != cached || !stale { + t.Fatalf("expected cached snapshot marked stale, got %#v stale=%v", got, stale) + } +} + +func TestNextTraySnapshotUsesFreshData(t *testing.T) { + t.Parallel() + cached := &Snapshot{Aggregate: Aggregate{ProviderCount: 1}} + fresh := &Snapshot{Aggregate: Aggregate{ProviderCount: 2}} + + got, stale := nextTraySnapshot(cached, fresh, nil) + if got != fresh { + t.Fatalf("expected fresh snapshot, got %#v", got) + } + if stale { + t.Fatal("expected stale=false for a successful fetch") + } +} + +// The very first refresh, before any snapshot has ever succeeded, is the only +// case allowed to render "--"/offline. +func TestNextTraySnapshotNilWithoutHistory(t *testing.T) { + t.Parallel() + got, stale := nextTraySnapshot(nil, nil, errors.New("timeout")) + if got != nil { + t.Fatalf("expected nil snapshot with no history, got %#v", got) + } + if stale { + t.Fatal("expected stale=false when there is nothing cached") + } +} + +// The worker serializes refresh runs: fetch and apply never overlap, and many +// concurrent request publishers (the systray click goroutine, the SIGUSR1 / +// marker watcher, the ticker) cannot re-enter either. Publishers are funneled +// in during the first fetch, so the second run cannot start until the first +// apply has finished by construction of the worker loop. +func TestRefreshWorkerNeverOverlaps(t *testing.T) { + t.Parallel() + var active, overlap int32 + guard := func() { + if atomic.AddInt32(&active, 1) > 1 { + atomic.StoreInt32(&overlap, 1) + } + } + releaseGuard := func() { atomic.AddInt32(&active, -1) } + + var runs int32 + applied := make(chan struct{}, 4) + var publishOnce sync.Once + var publishers sync.WaitGroup + var worker *refreshWorker + worker = newRefreshWorker( + func() (*Snapshot, error) { + guard() + defer releaseGuard() + publishOnce.Do(func() { + for i := 0; i < 32; i++ { + publishers.Add(1) + go func() { + defer publishers.Done() + worker.Request() + }() + } + publishers.Wait() + }) + return &Snapshot{Aggregate: Aggregate{ProviderCount: int(atomic.AddInt32(&runs, 1))}}, nil + }, + func(*Snapshot, bool, time.Duration, error) { + guard() + defer releaseGuard() + applied <- struct{}{} + }) + + stop, wait := startTestWorker(t, worker) + worker.Request() + <-applied // first run + <-applied // coalesced follow-up + stop() + wait() + + if atomic.LoadInt32(&runs) != 2 { + t.Fatalf("runs = %d, want 2 (32 requests must coalesce into one follow-up)", atomic.LoadInt32(&runs)) + } + if atomic.LoadInt32(&overlap) == 1 { + t.Fatal("fetch and apply overlapped: the worker must run them serially") + } +} + +// A request published at any point during a run causes exactly one more run. +// The three phases are injected through hooks and the fetch/apply closures, so +// no sleep decides the ordering. +func TestRefreshWorkerRequestDuringRunRunsOnceMore(t *testing.T) { + t.Parallel() + phases := []struct { + name string + set func(w *refreshWorker, publish func()) + }{ + { + name: "before fetch", + set: func(w *refreshWorker, publish func()) { + var once sync.Once + w.testHookDuringFetch = func() { once.Do(publish) } + }, + }, + { + name: "during fetch", + set: func(w *refreshWorker, publish func()) { + var once sync.Once + base := w.fetch + w.fetch = func() (*Snapshot, error) { + once.Do(publish) + return base() + } + }, + }, + { + name: "before apply", + set: func(w *refreshWorker, publish func()) { + var once sync.Once + w.testHookDuringApply = func() { once.Do(publish) } + }, + }, + } + for _, phase := range phases { + phase := phase + t.Run(phase.name, func(t *testing.T) { + t.Parallel() + var fetches int32 + applied := make(chan struct{}, 4) + worker := newRefreshWorker( + func() (*Snapshot, error) { + atomic.AddInt32(&fetches, 1) + return &Snapshot{Aggregate: Aggregate{ProviderCount: 1}}, nil + }, + func(*Snapshot, bool, time.Duration, error) { applied <- struct{}{} }) + + phase.set(worker, worker.Request) + stop, wait := startTestWorker(t, worker) + worker.Request() + + <-applied // first run + <-applied // follow-up run for the request published mid-run + + if got := atomic.LoadInt32(&fetches); got != 2 { + t.Fatalf("fetches = %d, want 2", got) + } + + // No third run can happen: the inbox is empty. Stop and confirm. + stop() + wait() + if got := atomic.LoadInt32(&fetches); got != 2 { + t.Fatalf("fetches after stop = %d, want 2", got) + } + }) + } +} + +// Several requests that arrive during one run collapse into exactly one +// follow-up run, because the buffer-1 inbox holds a single token. +func TestRefreshWorkerRequestsCoalesce(t *testing.T) { + t.Parallel() + var fetches int32 + applied := make(chan struct{}, 4) + worker := newRefreshWorker( + func() (*Snapshot, error) { + atomic.AddInt32(&fetches, 1) + return &Snapshot{Aggregate: Aggregate{ProviderCount: 1}}, nil + }, + func(*Snapshot, bool, time.Duration, error) { applied <- struct{}{} }) + + var once sync.Once + worker.testHookDuringFetch = func() { + once.Do(func() { + for i := 0; i < 32; i++ { + worker.Request() + } + }) + } + stop, wait := startTestWorker(t, worker) + worker.Request() + + <-applied // first run + <-applied // the single coalesced follow-up + + stop() + wait() + if got := atomic.LoadInt32(&fetches); got != 2 { + t.Fatalf("fetches = %d, want 2 (requests must coalesce into one extra run)", got) + } +} + +// Requests sent while the worker is idle each cause their own run, in the +// order they were published. The channel is the only synchronization. +func TestRefreshWorkerIdleRequestsEachRun(t *testing.T) { + t.Parallel() + var fetches int32 + applied := make(chan *Snapshot, 4) + worker := newRefreshWorker( + func() (*Snapshot, error) { + n := atomic.AddInt32(&fetches, 1) + return &Snapshot{Aggregate: Aggregate{ProviderCount: int(n)}}, nil + }, + func(snapshot *Snapshot, _ bool, _ time.Duration, _ error) { applied <- snapshot }) + + stop, wait := startTestWorker(t, worker) + for i := int32(1); i <= 3; i++ { + worker.Request() + snapshot := <-applied + if got := snapshot.Aggregate.ProviderCount; got != int(i) { + t.Fatalf("run %d applied provider count %d, want %d", i, got, i) + } + } + stop() + wait() + if got := atomic.LoadInt32(&fetches); got != 3 { + t.Fatalf("fetches = %d, want 3", got) + } +} + +// Request must never block, even while a run is in flight, so the systray +// goroutine cannot stall behind a slow daemon. +func TestRefreshWorkerRequestNeverBlocks(t *testing.T) { + t.Parallel() + inFetch := make(chan struct{}) + release := make(chan struct{}) + var signalOnce sync.Once + worker := newRefreshWorker( + func() (*Snapshot, error) { + signalOnce.Do(func() { close(inFetch) }) + <-release + return &Snapshot{Aggregate: Aggregate{ProviderCount: 1}}, nil + }, + func(*Snapshot, bool, time.Duration, error) {}) + + stop, wait := startTestWorker(t, worker) + worker.Request() + <-inFetch + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 4; i++ { + worker.Request() + } + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Request blocked while a run was in flight") + } + + close(release) + stop() + wait() +} + +// A failure before any success yields a nil snapshot so the caller shows "--", +// and the error is surfaced for logging. +func TestRefreshWorkerFirstFailureReturnsNil(t *testing.T) { + t.Parallel() + type outcome struct { + snapshot *Snapshot + stale bool + age time.Duration + err error + } + got := make(chan outcome, 1) + worker := newRefreshWorker( + func() (*Snapshot, error) { return nil, errors.New("boom") }, + func(s *Snapshot, st bool, a time.Duration, e error) { + got <- outcome{s, st, a, e} + }) + + stop, wait := startTestWorker(t, worker) + worker.Request() + out := <-got + stop() + wait() + + if out.snapshot != nil || out.stale { + t.Fatalf("expected nil, not stale; got %#v stale=%v", out.snapshot, out.stale) + } + if out.age != 0 { + t.Fatalf("expected zero age, got %s", out.age) + } + if out.err == nil { + t.Fatal("expected the fetch error to be surfaced for logging") + } +} + +// A failure after a success reuses the cached snapshot and marks it stale. +func TestRefreshWorkerFailureKeepsLastGood(t *testing.T) { + t.Parallel() + var fetches int32 + applied := make(chan struct{}, 2) + var gotSnapshot *Snapshot + var gotStale bool + worker := newRefreshWorker( + func() (*Snapshot, error) { + if atomic.AddInt32(&fetches, 1) == 1 { + return &Snapshot{LatestAt: time.Unix(1_700_000_000, 0), Aggregate: Aggregate{ProviderCount: 1}}, nil + } + return nil, errors.New("boom") + }, + func(s *Snapshot, stale bool, _ time.Duration, _ error) { + gotSnapshot, gotStale = s, stale + applied <- struct{}{} + }) + + stop, wait := startTestWorker(t, worker) + worker.Request() + <-applied + worker.Request() + <-applied + stop() + wait() + + if gotSnapshot == nil { + t.Fatal("cache lost the successful snapshot") + } + if !gotStale { + t.Fatal("expected the cached snapshot to be marked stale") + } +} + +// startTestWorker runs the worker loop and returns idempotent stop and wait +// helpers. Cleanup stops it if a test exits early. +func startTestWorker(t *testing.T, worker *refreshWorker) (stop func(), wait func()) { + t.Helper() + stopCh := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + worker.RunWorker(stopCh) + }() + var once sync.Once + stop = func() { once.Do(func() { close(stopCh) }) } + wait = func() { <-done } + t.Cleanup(func() { + stop() + wait() + }) + return stop, wait +} + +// The stale age counts from the newest provider data (latest_at), not the +// fetch time, so "updated 2h ago" does not snap back to "just now" on the +// first failure. +func TestStaleSnapshotAgeUsesLatestAt(t *testing.T) { + t.Parallel() + now := time.Unix(1_700_000_000, 0) + snapshot := &Snapshot{GeneratedAt: now, LatestAt: now.Add(-2 * time.Hour)} + if got := staleSnapshotAge(snapshot, now, now); got != 2*time.Hour { + t.Fatalf("staleSnapshotAge = %s, want 2h (from LatestAt)", got) + } + // No latest_at: fall back to when the snapshot was fetched. + noLatest := &Snapshot{GeneratedAt: now} + if got := staleSnapshotAge(noLatest, now.Add(-5*time.Minute), now); got != 5*time.Minute { + t.Fatalf("staleSnapshotAge = %s, want 5m (from fetch time)", got) + } + if got := staleSnapshotAge(&Snapshot{}, time.Time{}, now); got != 0 { + t.Fatalf("staleSnapshotAge with no source = %s, want 0", got) + } +} + +func TestFormatAgeAgoGrowsSmoothly(t *testing.T) { + t.Parallel() + cases := []struct { + age time.Duration + want string + }{ + {0, "just now"}, + {59 * time.Second, "just now"}, + {60 * time.Second, "1m ago"}, + {90 * time.Second, "1m ago"}, + {2 * time.Minute, "2m ago"}, + {59 * time.Minute, "59m ago"}, + {60 * time.Minute, "1h ago"}, + {90 * time.Minute, "1h ago"}, + {23 * time.Hour, "23h ago"}, + {24 * time.Hour, "1d ago"}, + {49 * time.Hour, "2d ago"}, + } + for _, tc := range cases { + if got := formatAgeAgo(tc.age); got != tc.want { + t.Errorf("formatAgeAgo(%s) = %q, want %q", tc.age, got, tc.want) + } + } +} + +func TestTrayTooltipStaleTextCountsUp(t *testing.T) { + t.Parallel() + snapshot := sampleSnapshot() + got := TrayTooltipStaleText(snapshot, 0, 3*time.Minute) + for _, want := range []string{"Claude 82%", "Codex 18%", "last updated 3m ago"} { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in stale tooltip %q", want, got) + } + } + // A stale tooltip must not repeat the frozen server-side "updated ..." line. + if strings.Contains(got, "updated 12s ago") { + t.Fatalf("stale tooltip must use the client-computed age, got %q", got) + } +} + +func TestTrayTooltipStaleTextRespectsLimit(t *testing.T) { + t.Parallel() + snapshot := sampleSnapshot() + for i := 0; i < 20; i++ { + snapshot.Providers = append(snapshot.Providers, ProviderCard{ID: "p", Label: "Provider With A Long Name", HighestPercent: 50}) + } + got := TrayTooltipStaleText(snapshot, 127, time.Minute) + if len([]rune(got)) > 127 { + t.Fatalf("stale tooltip exceeds limit: %d runes", len([]rune(got))) + } +} + +func TestTrayTooltipStaleTextNilSnapshot(t *testing.T) { + t.Parallel() + got := TrayTooltipStaleText(nil, 0, time.Minute) + if !strings.Contains(got, "daemon") { + t.Fatalf("expected offline hint for nil snapshot, got %q", got) + } +} diff --git a/internal/web/codex_stale_paused_test.go b/internal/web/codex_stale_paused_test.go index ec03b06b..016f6283 100644 --- a/internal/web/codex_stale_paused_test.go +++ b/internal/web/codex_stale_paused_test.go @@ -151,8 +151,9 @@ func TestBuildMenubarSnapshot_PausedCodexLowUsageIsWarning(t *testing.T) { } // A polling-disabled (not paused) account serves a very old snapshot: the -// menubar card must flag it stale so the dot tooltip says "Updates unavailable" -// instead of showing a live-looking card (#147 review, item 3). +// menubar card must flag it stale in connection_status, but staleness is shown +// only through the age label. Severity must come from usage alone, so a +// low-usage card stays healthy rather than turning amber just because of age. func TestBuildMenubarSnapshot_CodexStaleAccountFlagged(t *testing.T) { t.Parallel() s, err := store.New(":memory:") @@ -183,8 +184,10 @@ func TestBuildMenubarSnapshot_CodexStaleAccountFlagged(t *testing.T) { if card.ConnectionStatus != "stale" { t.Errorf("stale codex card connection_status = %q, want stale", card.ConnectionStatus) } - if card.Status != "warning" { - t.Errorf("stale codex card status = %q, want warning", card.Status) + // Severity comes from usage alone: 5% stays healthy even when the snapshot + // is a month old. Staleness is carried by the age label. + if card.Status != "healthy" { + t.Errorf("stale codex card status = %q, want healthy (age alone must not raise severity)", card.Status) } } diff --git a/internal/web/commandcode_handlers.go b/internal/web/commandcode_handlers.go index 64901f19..d7684ade 100644 --- a/internal/web/commandcode_handlers.go +++ b/internal/web/commandcode_handlers.go @@ -172,20 +172,33 @@ func buildCommandCodeBurnRateInsight(quota api.CommandCodeQuota, rate commandCod } func (h *Handler) buildCommandCodeCurrent() map[string]interface{} { + response, _, _ := h.buildCommandCodeCurrentOK() + return response +} + +// buildCommandCodeCurrentOK is buildCommandCodeCurrent with an explicit storage +// outcome. readOK is false only when the read that supplied the payload failed. +// The per-quota read is a refinement, so its failure still leaves the latest +// snapshot payload intact. +func (h *Handler) buildCommandCodeCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestCommandCode() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) if latest.AccountName != "" { @@ -280,7 +293,7 @@ func (h *Handler) buildCommandCodeCurrent() map[string]interface{} { response["quotas"] = append(response["quotas"].([]interface{}), quotaFromLatest(q, latest.CapturedAt)) } applyDisplayModeToResponse(response, h.getDisplayMode("commandcode")) - return response + return response, true, hasData } sort.SliceStable(latestPerQuota, func(i, j int) bool { @@ -306,7 +319,7 @@ func (h *Handler) buildCommandCodeCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("commandcode")) - return response + return response, true, hasData } func (h *Handler) historyCommandCode(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/cursor_handlers.go b/internal/web/cursor_handlers.go index fd0ba906..1ed8c84d 100644 --- a/internal/web/cursor_handlers.go +++ b/internal/web/cursor_handlers.go @@ -189,20 +189,34 @@ func buildCursorBurnRateInsight(quota api.CursorQuota, rate cursorQuotaRate) ins } func (h *Handler) buildCursorCurrent() map[string]interface{} { + response, _, _ := h.buildCursorCurrentOK() + return response +} + +// buildCursorCurrentOK is buildCursorCurrent with an explicit storage outcome. +// readOK is false only when the read that supplied the payload failed. The +// per-quota read is only a refinement of the latest snapshot, so its failure +// still leaves a real payload behind; it falls back to the snapshot quotas just +// as buildCursorCurrent always has. +func (h *Handler) buildCursorCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestCursor() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) response["accountType"] = string(latest.AccountType) @@ -237,7 +251,7 @@ func (h *Handler) buildCursorCurrent() map[string]interface{} { response["quotas"] = append(response["quotas"].([]interface{}), quotaMap) } applyDisplayModeToResponse(response, h.getDisplayMode("cursor")) - return response + return response, true, hasData } sort.SliceStable(latestPerQuota, func(i, j int) bool { @@ -280,7 +294,7 @@ func (h *Handler) buildCursorCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("cursor")) - return response + return response, true, hasData } func (h *Handler) historyCursor(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/dashboard_tabs.go b/internal/web/dashboard_tabs.go index c0089470..58adfd54 100644 --- a/internal/web/dashboard_tabs.go +++ b/internal/web/dashboard_tabs.go @@ -286,7 +286,12 @@ func (h *Handler) saveDashboardProviderLabels(labels map[string]string) error { if err != nil { return err } - return h.store.SetSetting(settingDashboardProviderLabels, string(data)) + if err := h.store.SetSetting(settingDashboardProviderLabels, string(data)); err != nil { + return err + } + // Card labels come from the dashboard tab names. + h.invalidateMenubarSnapshot() + return nil } // filterDashboardProviders applies dashboard visibility, optional tools tab, and "both". diff --git a/internal/web/gemini_handlers.go b/internal/web/gemini_handlers.go index 3edd0fe5..cc78953f 100644 --- a/internal/web/gemini_handlers.go +++ b/internal/web/gemini_handlers.go @@ -17,22 +17,29 @@ func (h *Handler) currentGemini(w http.ResponseWriter, r *http.Request) { // buildGeminiCurrent builds current Gemini response. func (h *Handler) buildGeminiCurrent() map[string]interface{} { + response, _, _ := h.buildGeminiCurrentOK() + return response +} + +// buildGeminiCurrentOK is buildGeminiCurrent with an explicit storage outcome. +// readOK is false only when the store read itself failed. +func (h *Handler) buildGeminiCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestGemini() if err != nil { h.logger.Error("failed to query latest Gemini snapshot", "error", err) - return response + return response, false, false } if latest == nil { - return response + return response, true, false } response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) @@ -71,7 +78,7 @@ func (h *Handler) buildGeminiCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("gemini")) - return response + return response, true, true } // historyGemini returns Gemini usage history as a flat array. diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 864a228b..49045b18 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -101,46 +101,80 @@ type CodexPauseReporter interface { // Handler handles HTTP requests for the web dashboard type Handler struct { - store *store.Store - tracker *tracker.Tracker - zaiTracker *tracker.ZaiTracker - anthropicTracker *tracker.AnthropicTracker - copilotTracker *tracker.CopilotTracker - codexTracker *tracker.CodexTracker - antigravityTracker *tracker.AntigravityTracker - minimaxTracker *tracker.MiniMaxTracker - geminiTracker *tracker.GeminiTracker - openrouterTracker *tracker.OpenRouterTracker - moonshotTracker *tracker.MoonshotTracker - deepseekTracker *tracker.DeepSeekTracker - cursorTracker *tracker.CursorTracker - grokTracker *tracker.GrokTracker - kimiTracker *tracker.KimiTracker - opencodeTracker *tracker.OpenCodeTracker - ollamaTracker *tracker.OllamaTracker - museTracker *tracker.MuseTracker - commandCodeTracker *tracker.CommandCodeTracker - updater *update.Updater - notifier Notifier - agentManager ProviderAgentController - minimaxAgentMgr MiniMaxAccountReloader - logger *slog.Logger - dashboardTmpl *template.Template - loginTmpl *template.Template - settingsTmpl *template.Template - sessions *SessionStore - config *config.Config - metrics *metrics.Metrics - version string - codexPauseReporter CodexPauseReporter - menubarSettingsMu sync.Mutex - smtpTestMu sync.Mutex - smtpTestLastSent time.Time - webhookTestMu sync.Mutex - webhookTestLastSent time.Time - pushTestMu sync.Mutex - pushTestLastSent time.Time - rateLimiter *LoginRateLimiter // Per-IP rate limiting for login attempts + store *store.Store + tracker *tracker.Tracker + zaiTracker *tracker.ZaiTracker + anthropicTracker *tracker.AnthropicTracker + copilotTracker *tracker.CopilotTracker + codexTracker *tracker.CodexTracker + antigravityTracker *tracker.AntigravityTracker + minimaxTracker *tracker.MiniMaxTracker + geminiTracker *tracker.GeminiTracker + openrouterTracker *tracker.OpenRouterTracker + moonshotTracker *tracker.MoonshotTracker + deepseekTracker *tracker.DeepSeekTracker + cursorTracker *tracker.CursorTracker + grokTracker *tracker.GrokTracker + kimiTracker *tracker.KimiTracker + opencodeTracker *tracker.OpenCodeTracker + ollamaTracker *tracker.OllamaTracker + museTracker *tracker.MuseTracker + commandCodeTracker *tracker.CommandCodeTracker + updater *update.Updater + notifier Notifier + agentManager ProviderAgentController + minimaxAgentMgr MiniMaxAccountReloader + logger *slog.Logger + dashboardTmpl *template.Template + loginTmpl *template.Template + settingsTmpl *template.Template + sessions *SessionStore + config *config.Config + metrics *metrics.Metrics + version string + codexPauseReporter CodexPauseReporter + menubarSettingsMu sync.Mutex + // menubarLastGood holds the last successfully built card per menubar card + // ID (one entry per provider, or per account for Codex/MiniMax). When a + // provider's storage read fails, the snapshot serves this card unchanged - + // including its original updated_at_time - so a client's age keeps counting + // up instead of the card vanishing or turning into a fake-fresh zero. + // Bounded by the number of provider cards. + menubarLastGoodMu sync.Mutex + menubarLastGood map[string]menubar.ProviderCard + // menubarSnapshotData caches the last successfully built data so the + // summary, tray-title and preferences endpoints can answer without + // rebuilding roughly 60-90 SQL statements on every request. Only one build + // runs at a time. + menubarSnapshotMu sync.Mutex + menubarSnapshotData menubarBuiltData + menubarSnapshotAt time.Time + menubarSnapshotGen uint64 + menubarSnapshotBuild *menubarSnapshotBuild + menubarBuildMu sync.Mutex + // menubarLastSettings is the last settings read that succeeded. When a + // later read fails, a snapshot can still be built from it (gap A) instead + // of answering 500. + menubarLastSettingsMu sync.Mutex + menubarLastSettings *menubar.Settings + // Test and diagnostics hooks. Nil in production. + menubarNow func() time.Time + menubarBuildHook func(context.Context) (menubarBuiltData, error) + menubarJoinHook func() + menubarAfterBuildHook func(*menubar.Snapshot, error) + // menubarProvidersBuildHook counts entries into the provider build, so a + // test can prove the preferences endpoint never starts its own. + menubarProvidersBuildHook func() + menubarFreshFor time.Duration + menubarWaitFor time.Duration + menubarNoCacheFor time.Duration + smtpTestMu sync.Mutex + smtpTestLastSent time.Time + webhookTestMu sync.Mutex + webhookTestLastSent time.Time + pushTestMu sync.Mutex + pushTestLastSent time.Time + rateLimiter *LoginRateLimiter // Per-IP rate limiting for login attempts } // DefaultCodexAccountID is the default account ID for single-account setups. @@ -457,6 +491,7 @@ func (h *Handler) codexProfileSave(w http.ResponseWriter, r *http.Request) { externalID = profile.AccountID } h.store.GetOrCreateProviderAccountByExternalID("codex", req.Name, externalID) + h.invalidateMenubarSnapshot() } respondJSON(w, http.StatusCreated, map[string]interface{}{ @@ -498,6 +533,7 @@ func (h *Handler) codexProfileDelete(w http.ResponseWriter, r *http.Request) { // Mark profile as deleted in database if h.store != nil { h.store.MarkProviderAccountDeleted("codex", name) + h.invalidateMenubarSnapshot() } respondJSON(w, http.StatusOK, map[string]interface{}{ @@ -592,6 +628,7 @@ func (h *Handler) codexProfileRefresh(w http.ResponseWriter, r *http.Request) { externalID = profile.AccountID } h.store.GetOrCreateProviderAccountByExternalID("codex", name, externalID) + h.invalidateMenubarSnapshot() } respondJSON(w, http.StatusOK, map[string]interface{}{ @@ -601,33 +638,55 @@ func (h *Handler) codexProfileRefresh(w http.ResponseWriter, r *http.Request) { }) } +// menubarAccountUsage pairs one account's current payload with the outcome of +// the storage read that built it, so the menubar can serve its last good card +// instead of an empty payload when that read failed. +type menubarAccountUsage struct { + Usage map[string]interface{} + ReadOK bool + HasData bool +} + func (h *Handler) codexUsageAccounts() []map[string]interface{} { + usages, _ := h.codexUsageAccountsOK() + accounts := make([]map[string]interface{}, 0, len(usages)) + for _, usage := range usages { + accounts = append(accounts, usage.Usage) + } + return accounts +} + +// codexUsageAccountsOK is codexUsageAccounts with an explicit enumeration +// outcome and the read outcome of each account's builder. enumerated is false +// only when the account list itself could not be read; callers that cache +// per-account data must then keep their known accounts and prune nothing. +func (h *Handler) codexUsageAccountsOK() (accounts []menubarAccountUsage, enumerated bool) { if h.store == nil { - return []map[string]interface{}{} + return nil, true } // Only return active (non-deleted) accounts for dashboard rendering - accounts, err := h.store.QueryActiveProviderAccounts("codex") + rows, err := h.store.QueryActiveProviderAccounts("codex") if err != nil { h.logger.Error("failed to query Codex accounts", "error", err) - return []map[string]interface{}{} + return nil, false } - if len(accounts) == 0 { - accounts = []store.ProviderAccount{ + if len(rows) == 0 { + rows = []store.ProviderAccount{ {ID: DefaultCodexAccountID, Name: "default"}, } } - usages := make([]map[string]interface{}, 0, len(accounts)) - for _, acc := range accounts { - usage := h.buildCodexCurrent(acc.ID) + accounts = make([]menubarAccountUsage, 0, len(rows)) + for _, acc := range rows { + usage, readOK, hasData := h.buildCodexCurrentOK(acc.ID) usage["accountId"] = acc.ID usage["accountName"] = acc.Name usage["id"] = acc.ID usage["name"] = acc.Name - usages = append(usages, usage) + accounts = append(accounts, menubarAccountUsage{Usage: usage, ReadOK: readOK, HasData: hasData}) } - return usages + return accounts, true } func codexUsageAccountID(usage map[string]interface{}) int64 { @@ -889,6 +948,10 @@ func (h *Handler) triggerMenubarRefresh() { if h == nil { return } + // The companion re-fetches the summary; make sure that fetch rebuilds + // instead of serving the snapshot from before the change that triggered + // this refresh. + h.invalidateMenubarSnapshot() testMode := false if h.config != nil { testMode = h.config.TestMode @@ -1118,7 +1181,12 @@ func (h *Handler) saveAPIIntegrationsVisibility(vis map[string]bool) error { if err != nil { return err } - return h.store.SetSetting("api_integrations_visibility", string(data)) + if err := h.store.SetSetting("api_integrations_visibility", string(data)); err != nil { + return err + } + // Dashboard visibility gates provider card selection. + h.invalidateMenubarSnapshot() + return nil } func providerPollingValue(entry interface{}) (bool, bool) { @@ -1350,7 +1418,13 @@ func (h *Handler) saveProviderVisibility(vis map[string]map[string]bool) error { if err != nil { return err } - return h.store.SetSetting("provider_visibility", string(data)) + if err := h.store.SetSetting("provider_visibility", string(data)); err != nil { + return err + } + // Dashboard visibility and polling state select which cards the menubar + // shows. + h.invalidateMenubarSnapshot() + return nil } func (h *Handler) setProviderVisibility(provider string, polling, dashboard *bool) error { @@ -1378,6 +1452,16 @@ func (h *Handler) tryAutoDetect(provider string) bool { if h.config == nil { return false } + if h.autoDetectProvider(provider) { + // Credentials discovered here can enable a provider, which changes the + // menubar's card set. + h.invalidateMenubarSnapshot() + return true + } + return false +} + +func (h *Handler) autoDetectProvider(provider string) bool { switch provider { case "mistral": // Enabling schedules discovery in the agent; never read browsers in HTTP handlers. @@ -2336,6 +2420,7 @@ func (h *Handler) ReloadProviders(w http.ResponseWriter, r *http.Request) { applyProviderSettingsFromDBLocked(h.store, h.config, h.logger) } }) + h.invalidateMenubarSnapshot() if h.agentManager != nil { vis := h.providerVisibilityMap() @@ -2546,40 +2631,74 @@ func (h *Handler) currentGrok(w http.ResponseWriter, r *http.Request) { // quota values. Some polls store an empty snapshot (transient API response), so // falling back keeps the card/insights populated instead of flickering empty. func (h *Handler) latestGrokSnapshot() *api.GrokSnapshot { + latest, _, _ := h.latestGrokSnapshotOK() + return latest +} + +// latestGrokSnapshotOK is latestGrokSnapshot plus the outcome of the reads it +// actually performed. ok is false when the latest read, or the 7-day fallback +// read it needed, failed. A latest snapshot with quotas needs no fallback read, +// so only that first read decides success then. +// +// A failed latest read still tries the 7-day fallback (the dashboard builder +// behaved that way before the menubar refactor). When the fallback supplies +// data, the snapshot is returned with ok=false so the menubar keeps serving its +// last good card while the dashboard still renders the older rows. +func (h *Handler) latestGrokSnapshotOK() (latest *api.GrokSnapshot, ok bool, hasData bool) { if h.store == nil { - return nil + return nil, true, false } latest, err := h.store.QueryLatestGrok(store.DefaultGrokAccountID) + if err != nil { + latest = nil + } if err == nil && latest != nil && len(latest.Quotas) > 0 { - return latest + return latest, true, true } now := time.Now().UTC() - if snaps, rerr := h.store.QueryGrokRange(store.DefaultGrokAccountID, now.Add(-7*24*time.Hour), now); rerr == nil { - for i := len(snaps) - 1; i >= 0; i-- { - if len(snaps[i].Quotas) > 0 { - return snaps[i] - } + snaps, rerr := h.store.QueryGrokRange(store.DefaultGrokAccountID, now.Add(-7*24*time.Hour), now) + if rerr != nil { + return latest, false, false + } + for i := len(snaps) - 1; i >= 0; i-- { + if len(snaps[i].Quotas) > 0 { + return snaps[i], err == nil, true } } - return latest + return latest, err == nil, latest != nil } // buildGrokCurrent builds the response for the Grok provider using the dedicated store // and tracker. Returns a structure with "quotas" array (for the custom Grok card renderer) // plus identity info from the auth.json snapshot. func (h *Handler) buildGrokCurrent() map[string]interface{} { + response, _, _ := h.buildGrokCurrentOK() + // Main's dashboard builder reports the build time here; the stored time is + // used only by the menubar age label. + response["capturedAt"] = time.Now().UTC().Format(time.RFC3339) + return response +} + +// buildGrokCurrentOK is buildGrokCurrent with an explicit storage outcome. +// readOK reports the outcome of the very read that supplied the payload, +// including the fallback range read, so the menubar never treats a failed read +// as a successful empty one. +func (h *Handler) buildGrokCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "provider": "grok", "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } - latest := h.latestGrokSnapshot() + latest, readOK, hasData := h.latestGrokSnapshotOK() + if !readOK && !hasData { + return response, false, false + } if latest == nil { // No data yet (agent may not have run, or first poll pending). Return empty quotas but identity if we can detect. if creds := api.DetectGrokCredentials(h.logger); creds != nil { @@ -2587,9 +2706,14 @@ func (h *Handler) buildGrokCurrent() map[string]interface{} { response["team_id"] = creds.TeamID response["login_method"] = creds.LoginMethod() } - return response + return response, true, false } + // The menubar age label reads capturedAt, so it must be the stored snapshot + // time, not the build time: a provider whose polls have stopped must show a + // growing age even while the daemon keeps serving snapshots. + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + // Build quotas array matching what renderGrokQuotaCards expects. // Also provide displayName/label so menubar normalizeQuotas shows nice "Credits". quotas := make([]map[string]interface{}, 0, len(latest.Quotas)) @@ -2651,7 +2775,7 @@ func (h *Handler) buildGrokCurrent() map[string]interface{} { } } - return response + return response, readOK, hasData } // historyGrok serves /api/history?provider=grok . Returns a flat array of points @@ -3005,22 +3129,43 @@ func (h *Handler) currentSynthetic(w http.ResponseWriter, r *http.Request) { // buildSyntheticCurrent builds the Synthetic current quota response map. func (h *Handler) buildSyntheticCurrent() map[string]interface{} { + response, _, _ := h.buildSyntheticCurrentOK() + return response +} + +// buildSyntheticCurrentOK is buildSyntheticCurrent with an explicit storage +// outcome. The menubar needs to tell a read failure apart from a genuine +// first run: the fallback map is stamped time.Now(), so serving it after a +// failure would show a fake-fresh zero card. readOK is false only when the +// store read itself failed; hasData reports whether a stored snapshot backed +// the payload. +func (h *Handler) buildSyntheticCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "subscription": buildEmptyQuotaResponse("Subscription", "Main API request quota for your plan"), "search": buildEmptyQuotaResponse("Search (Hourly)", "Search endpoint calls, resets every hour"), "toolCalls": buildEmptyQuotaResponse("Tool Call Discounts", "Discounted tool call requests"), } + // Without a tracker the builder cannot read the store, so it can only + // produce the fabricated fallback. Report that as a failure: the menubar + // keeps the last good card instead of showing a fake-fresh zero. + if h.store != nil && h.tracker == nil { + applyDisplayModeToResponse(response, h.getDisplayMode("synthetic")) + return response, false, false + } + if h.store != nil && h.tracker != nil { latest, err := h.store.QueryLatest() if err != nil { h.logger.Error("failed to query latest snapshot", "error", err) - return response + applyDisplayModeToResponse(response, h.getDisplayMode("synthetic")) + return response, false, false } if latest != nil { + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) response["subscription"] = buildQuotaResponse("Subscription", "Main API request quota for your plan", latest.Sub, h.tracker, "subscription") response["search"] = buildQuotaResponse("Search (Hourly)", "Search endpoint calls, resets every hour", latest.Search, h.tracker, "search") @@ -3029,7 +3174,7 @@ func (h *Handler) buildSyntheticCurrent() map[string]interface{} { } applyDisplayModeToResponse(response, h.getDisplayMode("synthetic")) - return response + return response, true, hasData } // currentZai returns Z.ai quota status @@ -3039,8 +3184,17 @@ func (h *Handler) currentZai(w http.ResponseWriter, r *http.Request) { // buildZaiCurrent builds the Z.ai current quota response map. func (h *Handler) buildZaiCurrent() map[string]interface{} { + response, _, _ := h.buildZaiCurrentOK() + return response +} + +// buildZaiCurrentOK is buildZaiCurrent with an explicit storage outcome. readOK +// is false only when the store read itself failed; the menubar then serves the +// last good card instead of this time.Now()-stamped fallback. hasData reports +// whether a stored snapshot backed the payload. +func (h *Handler) buildZaiCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "tokensLimit": buildEmptyZaiQuotaResponse("Tokens Limit", "Token consumption budget"), "timeLimit": buildEmptyZaiQuotaResponse("Time Limit", "Tool call time budget"), @@ -3051,10 +3205,12 @@ func (h *Handler) buildZaiCurrent() map[string]interface{} { latest, err := h.store.QueryLatestZai() if err != nil { h.logger.Error("failed to query latest Z.ai snapshot", "error", err) - return response + applyDisplayModeToResponse(response, h.getDisplayMode("zai")) + return response, false, false } if latest != nil { + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) tokensResp := buildZaiTokensQuotaResponse(latest) timeResp := buildZaiTimeQuotaResponse(latest) @@ -3078,7 +3234,7 @@ func (h *Handler) buildZaiCurrent() map[string]interface{} { } applyDisplayModeToResponse(response, h.getDisplayMode("zai")) - return response + return response, true, hasData } func buildEmptyQuotaResponse(name, description string) map[string]interface{} { @@ -4010,8 +4166,17 @@ func (h *Handler) currentOpenRouter(w http.ResponseWriter, r *http.Request) { // buildOpenRouterCurrent builds the OpenRouter current credits response map. func (h *Handler) buildOpenRouterCurrent() map[string]interface{} { + response, _, _ := h.buildOpenRouterCurrentOK() + return response +} + +// buildOpenRouterCurrentOK is buildOpenRouterCurrent with an explicit storage +// outcome. readOK is false only when the store read itself failed; the menubar +// then serves the last good card instead of this time.Now()-stamped fallback. +// hasData reports whether a stored snapshot backed the payload. +func (h *Handler) buildOpenRouterCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "credits": map[string]interface{}{ "name": "Credits", @@ -4030,10 +4195,11 @@ func (h *Handler) buildOpenRouterCurrent() map[string]interface{} { latest, err := h.store.QueryLatestOpenRouter() if err != nil { h.logger.Error("failed to query latest OpenRouter snapshot", "error", err) - return response + return response, false, false } if latest != nil { + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) credits := map[string]interface{}{ "name": "Credits", @@ -4074,7 +4240,7 @@ func (h *Handler) buildOpenRouterCurrent() map[string]interface{} { } } - return response + return response, true, hasData } // historyOpenRouter returns OpenRouter usage history @@ -6395,8 +6561,16 @@ func (h *Handler) currentAnthropic(w http.ResponseWriter, r *http.Request) { // Merges data from the latest snapshot (statusline or API) with per-quota // freshness information so the UI can show all quotas with age indicators. func (h *Handler) buildAnthropicCurrent() map[string]interface{} { + response, _, _ := h.buildAnthropicCurrentOK() + return response +} + +// buildAnthropicCurrentOK is buildAnthropicCurrent with an explicit storage +// outcome. The builder prefers the per-quota merge and falls back to the single +// latest snapshot, so only a failure of both reads is a storage failure. +func (h *Handler) buildAnthropicCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } @@ -6405,7 +6579,7 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { } if h.store == nil { - return response + return response, true, false } // Get per-quota latest values (merges statusline + API snapshots). @@ -6415,11 +6589,21 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { if err != nil { h.logger.Error("failed to query latest per-quota Anthropic data", "error", err) // Fall back to single-snapshot approach - return h.buildAnthropicCurrentFallback(response) + response, snapOK, snapData := h.buildAnthropicCurrentFallbackOK(response) + if !snapOK { + return response, false, false + } + return response, true, snapData } if len(latestPerQuota) == 0 { - return response + // The per-quota table is empty; the single-snapshot read can still hold + // an older API poll, and its outcome decides success. + response, snapOK, snapData := h.buildAnthropicCurrentFallbackOK(response) + if !snapOK { + return response, false, false + } + return response, true, snapData } // Find the most recent capturedAt across all quotas @@ -6441,7 +6625,9 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { return latestPerQuota[i].Name < latestPerQuota[j].Name }) - var quotas []map[string]interface{} + // Initialise so an empty result serialises as [] like the public builder + // always did, not null. + quotas := make([]map[string]interface{}, 0, len(latestPerQuota)) for _, q := range latestPerQuota { age := now.Sub(q.CapturedAt) qMap := map[string]interface{}{ @@ -6471,15 +6657,25 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("anthropic")) - return response + return response, true, true } // buildAnthropicCurrentFallback uses the single latest snapshot when per-quota // merge fails. This preserves the original behavior as a safety net. func (h *Handler) buildAnthropicCurrentFallback(response map[string]interface{}) map[string]interface{} { + response, _, _ = h.buildAnthropicCurrentFallbackOK(response) + return response +} + +// buildAnthropicCurrentFallbackOK is buildAnthropicCurrentFallback with an +// explicit storage outcome. +func (h *Handler) buildAnthropicCurrentFallbackOK(response map[string]interface{}) (result map[string]interface{}, readOK bool, hasData bool) { latest, err := h.store.QueryLatestAnthropic() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } now := time.Now().UTC() @@ -6494,7 +6690,9 @@ func (h *Handler) buildAnthropicCurrentFallback(response map[string]interface{}) } return orderedQuotas[i].Name < orderedQuotas[j].Name }) - var quotas []map[string]interface{} + // Initialise so an empty result serialises as [] rather than null, as the + // public builder always did. + quotas := make([]map[string]interface{}, 0, len(orderedQuotas)) age := now.Sub(latest.CapturedAt) for _, q := range orderedQuotas { qMap := map[string]interface{}{ @@ -6523,7 +6721,7 @@ func (h *Handler) buildAnthropicCurrentFallback(response map[string]interface{}) } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("anthropic")) - return response + return response, true, true } // anthropicUtilStatus returns a status string based on utilization percentage. @@ -7736,6 +7934,7 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to save provider visibility settings") return } + h.invalidateMenubarSnapshot() if h.agentManager != nil { for _, p := range providerCatalog() { enabled := h.providerPollingEnabled(p.Key, vis) @@ -7912,6 +8111,11 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { // UI resends every field, so a no-op or display-only save must not // disrupt a healthy agent (nor re-run Mistral's browser cookie import). h.reconcileProviderAgents(updatedProviders, providerSettingsNeedRestart(provSettings, storedSettings, clearedFields)) + // Invalidate only after the live config reflects the save. A build + // that starts in between would otherwise carry the new generation but + // read the old provider/credential state, and cache it for the whole + // freshness window. + h.invalidateMenubarSnapshot() // Strip sensitive fields before returning to client stripProviderSecrets(existing) result["provider_settings"] = existing @@ -8804,24 +9008,31 @@ func (h *Handler) currentCopilot(w http.ResponseWriter, r *http.Request) { // buildCopilotCurrent builds the Copilot current quota response map. func (h *Handler) buildCopilotCurrent() map[string]interface{} { + response, _, _ := h.buildCopilotCurrentOK() + return response +} + +// buildCopilotCurrentOK is buildCopilotCurrent with an explicit storage outcome. +// readOK is false only when the store read itself failed. +func (h *Handler) buildCopilotCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestCopilot() if err != nil { h.logger.Error("failed to query latest Copilot snapshot", "error", err) - return response + return response, false, false } if latest == nil { - return response + return response, true, false } response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) @@ -8862,7 +9073,7 @@ func (h *Handler) buildCopilotCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("copilot")) - return response + return response, true, true } // copilotUsageStatus returns a status string based on usage percentage. @@ -9348,8 +9559,17 @@ func (h *Handler) getCodexPaceMode() string { } func (h *Handler) buildCodexCurrent(accountID int64) map[string]interface{} { + response, _, _ := h.buildCodexCurrentOK(accountID) + return response +} + +// buildCodexCurrentOK is buildCodexCurrent with an explicit storage outcome. +// readOK is false only when the store read itself failed; the menubar then +// serves the last good card instead of the fallback payload stamped now. +// hasData reports whether a stored snapshot backed the payload. +func (h *Handler) buildCodexCurrentOK(accountID int64) (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } @@ -9370,17 +9590,18 @@ func (h *Handler) buildCodexCurrent(accountID int64) map[string]interface{} { } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestCodex(accountID) if err != nil { h.logger.Error("failed to query latest Codex snapshot", "error", err) - return response + return response, false, false } if latest == nil { - return response + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) // Snapshot age drives the dashboard's stale-card treatment: a polling-disabled @@ -9483,7 +9704,7 @@ func (h *Handler) buildCodexCurrent(accountID int64) map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, displayMode) - return response + return response, true, hasData } func codexUtilStatus(util float64) string { @@ -9519,25 +9740,32 @@ func (h *Handler) currentAntigravity(w http.ResponseWriter, r *http.Request) { // buildAntigravityCurrent builds the Antigravity current quota response map. func (h *Handler) buildAntigravityCurrent() map[string]interface{} { + response, _, _ := h.buildAntigravityCurrentOK() + return response +} + +// buildAntigravityCurrentOK is buildAntigravityCurrent with an explicit storage +// outcome. readOK is false only when the store read itself failed. +func (h *Handler) buildAntigravityCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, "pools": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestAntigravity() if err != nil { h.logger.Error("failed to query latest Antigravity snapshot", "error", err) - return response + return response, false, false } if latest == nil { - return response + return response, true, false } response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) @@ -9565,7 +9793,7 @@ func (h *Handler) buildAntigravityCurrent() map[string]interface{} { response["lowestPool"] = lowest } applyDisplayModeToResponse(response, h.getDisplayMode("antigravity")) - return response + return response, true, true } groups := api.GroupAntigravityModelsByLogicalQuota(latest.Models) @@ -9618,7 +9846,7 @@ func (h *Handler) buildAntigravityCurrent() map[string]interface{} { } applyDisplayModeToResponse(response, h.getDisplayMode("antigravity")) - return response + return response, true, true } // buildAntigravityCLIQuotas renders agy CLI bucket rows (one card per bucket, @@ -10176,34 +10404,47 @@ func (h *Handler) defaultMiniMaxAccountID() int64 { // minimaxUsageAccounts returns current usage for all active MiniMax accounts. // Mirrors codexUsageAccounts() pattern. func (h *Handler) minimaxUsageAccounts() []map[string]interface{} { + usages, _ := h.minimaxUsageAccountsOK() + accounts := make([]map[string]interface{}, 0, len(usages)) + for _, usage := range usages { + accounts = append(accounts, usage.Usage) + } + return accounts +} + +// minimaxUsageAccountsOK is minimaxUsageAccounts with an explicit enumeration +// outcome and the read outcome of each account's builder. enumerated is false +// only when the account list itself could not be read; callers that cache +// per-account data must then keep their known accounts and prune nothing. +func (h *Handler) minimaxUsageAccountsOK() (accounts []menubarAccountUsage, enumerated bool) { if h.store == nil { - return []map[string]interface{}{} + return nil, true } - accounts, err := h.store.QueryActiveProviderAccounts("minimax") + rows, err := h.store.QueryActiveProviderAccounts("minimax") if err != nil { h.logger.Error("failed to query MiniMax accounts", "error", err) - return []map[string]interface{}{} + return nil, false } - if len(accounts) == 0 { + if len(rows) == 0 { defID := h.defaultMiniMaxAccountID() if defID > 0 { - accounts = []store.ProviderAccount{ + rows = []store.ProviderAccount{ {ID: defID, Name: "default"}, } } } - usages := make([]map[string]interface{}, 0, len(accounts)) - for _, acc := range accounts { - usage := h.buildMiniMaxCurrent(acc.ID) + accounts = make([]menubarAccountUsage, 0, len(rows)) + for _, acc := range rows { + usage, readOK, hasData := h.buildMiniMaxCurrentOK(acc.ID) usage["accountId"] = acc.ID usage["accountName"] = acc.Name usage["id"] = acc.ID usage["name"] = acc.Name - usages = append(usages, usage) + accounts = append(accounts, menubarAccountUsage{Usage: usage, ReadOK: readOK, HasData: hasData}) } - return usages + return accounts, true } func minimaxUsageAccountID(usage map[string]interface{}) int64 { @@ -10352,6 +10593,7 @@ func (h *Handler) minimaxAccountCreate(w http.ResponseWriter, r *http.Request) { if h.minimaxAgentMgr != nil { h.minimaxAgentMgr.Reload() } + h.invalidateMenubarSnapshot() respondJSON(w, http.StatusCreated, map[string]interface{}{ "message": "account created", @@ -10439,6 +10681,7 @@ func (h *Handler) minimaxAccountUpdate(w http.ResponseWriter, r *http.Request) { if h.minimaxAgentMgr != nil { h.minimaxAgentMgr.Reload() } + h.invalidateMenubarSnapshot() respondJSON(w, http.StatusOK, map[string]interface{}{"message": "account updated", "id": id}) } @@ -10470,6 +10713,7 @@ func (h *Handler) minimaxAccountDelete(w http.ResponseWriter, r *http.Request) { if h.minimaxAgentMgr != nil { h.minimaxAgentMgr.Reload() } + h.invalidateMenubarSnapshot() respondJSON(w, http.StatusOK, map[string]interface{}{"message": "account deleted", "id": id}) } @@ -10523,24 +10767,34 @@ func (h *Handler) currentMiniMax(w http.ResponseWriter, r *http.Request) { // buildMiniMaxCurrent builds current MiniMax response. func (h *Handler) buildMiniMaxCurrent(accountID int64) map[string]interface{} { + response, _, _ := h.buildMiniMaxCurrentOK(accountID) + return response +} + +// buildMiniMaxCurrentOK is buildMiniMaxCurrent with an explicit storage +// outcome. readOK is false only when the store read itself failed; the menubar +// then serves the last good card instead of the fallback payload stamped now. +// hasData reports whether a stored snapshot backed the payload. +func (h *Handler) buildMiniMaxCurrentOK(accountID int64) (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, "sharedQuota": false, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestMiniMax(accountID) if err != nil { h.logger.Error("failed to query latest MiniMax snapshot", "error", err) - return response + return response, false, false } if latest == nil { - return response + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) @@ -10638,7 +10892,7 @@ func (h *Handler) buildMiniMaxCurrent(accountID int64) map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("minimax")) - return response + return response, true, hasData } func minimaxSharedModelSummary(models []string) string { diff --git a/internal/web/kimi_handlers.go b/internal/web/kimi_handlers.go index 25898bd4..a4a24f0c 100644 --- a/internal/web/kimi_handlers.go +++ b/internal/web/kimi_handlers.go @@ -20,45 +20,81 @@ func (h *Handler) currentKimi(w http.ResponseWriter, r *http.Request) { } func (h *Handler) latestKimiSnapshot() *api.KimiSnapshot { + latest, _, _ := h.latestKimiSnapshotOK() + return latest +} + +// latestKimiSnapshotOK is latestKimiSnapshot plus the outcome of the reads it +// actually performed. ok is false when the latest read, or the 7-day fallback +// read it needed, failed. +// +// A failed latest read still tries the 7-day fallback (the dashboard builder +// behaved that way before the menubar refactor). When the fallback supplies +// data, the snapshot is returned with ok=false so the menubar keeps serving its +// last good card while the dashboard still renders the older rows. +func (h *Handler) latestKimiSnapshotOK() (latest *api.KimiSnapshot, ok bool, hasData bool) { if h.store == nil { - return nil + return nil, true, false } latest, err := h.store.QueryLatestKimi(store.DefaultKimiAccountID) + if err != nil { + latest = nil + } if err == nil && latest != nil && len(latest.Quotas) > 0 { - return latest + return latest, true, true } now := time.Now().UTC() - if snaps, rerr := h.store.QueryKimiRange(store.DefaultKimiAccountID, now.Add(-7*24*time.Hour), now); rerr == nil { - for i := len(snaps) - 1; i >= 0; i-- { - if len(snaps[i].Quotas) > 0 { - return snaps[i] - } + snaps, rerr := h.store.QueryKimiRange(store.DefaultKimiAccountID, now.Add(-7*24*time.Hour), now) + if rerr != nil { + return latest, false, false + } + for i := len(snaps) - 1; i >= 0; i-- { + if len(snaps[i].Quotas) > 0 { + return snaps[i], err == nil, true } } - return latest + return latest, err == nil, latest != nil } // buildKimiCurrent builds the /api/current?provider=kimi payload. func (h *Handler) buildKimiCurrent() map[string]interface{} { + response, _, _ := h.buildKimiCurrentOK() + // Main's dashboard builder reports the build time here; the stored + // snapshot time is only for the menubar age label. + response["capturedAt"] = time.Now().UTC().Format(time.RFC3339) + return response +} + +// buildKimiCurrentOK is buildKimiCurrent with an explicit storage outcome. +// readOK reports the outcome of the read that supplied the payload, including +// the fallback range read. +func (h *Handler) buildKimiCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "provider": "kimi", "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } - latest := h.latestKimiSnapshot() + latest, readOK, hasData := h.latestKimiSnapshotOK() + if !readOK && !hasData { + return response, false, false + } if latest == nil { if creds := api.DetectKimiCredentials(h.logger); creds != nil { response["login_method"] = "oauth" response["configured"] = true } - return response + return response, true, false } + // capturedAt drives the menubar age label; it must be the stored snapshot + // time so the age grows when this provider's polls stop. + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + quotas := make([]map[string]interface{}, 0, len(latest.Quotas)) for _, q := range latest.Quotas { display := api.KimiDisplayName(q.Name) @@ -116,7 +152,7 @@ func (h *Handler) buildKimiCurrent() map[string]interface{} { response["summary"] = sm } } - return response + return response, readOK, hasData } func (h *Handler) historyKimi(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/menubar.go b/internal/web/menubar.go index e5d50211..b2552a65 100644 --- a/internal/web/menubar.go +++ b/internal/web/menubar.go @@ -49,10 +49,10 @@ func (h *Handler) Capabilities(w http.ResponseWriter, r *http.Request) { // no longer gates on the remote address. // Does not require a native companion build (menubar.IsSupported). func (h *Handler) MenubarSummary(w http.ResponseWriter, r *http.Request) { - snapshot, err := h.BuildMenubarSnapshot() + snapshot, err := h.menubarSnapshotForRequest(r.Context()) if err != nil { h.logger.Error("failed to build menubar snapshot", "error", err) - respondError(w, http.StatusInternalServerError, "failed to build menubar snapshot") + respondMenubarSnapshotError(w, err, "failed to build menubar snapshot") return } respondJSON(w, http.StatusOK, snapshot) @@ -62,16 +62,15 @@ func (h *Handler) MenubarSummary(w http.ResponseWriter, r *http.Request) { // same rules as the macOS menubar companion, so GNOME can stay in sync without // reimplementing formatting. func (h *Handler) MenubarTrayTitle(w http.ResponseWriter, r *http.Request) { - snapshot, err := h.BuildMenubarSnapshot() + snapshot, err := h.menubarSnapshotForRequest(r.Context()) if err != nil { h.logger.Error("failed to build menubar snapshot for tray title", "error", err) - respondError(w, http.StatusInternalServerError, "failed to build menubar tray title") + respondMenubarSnapshotError(w, err, "failed to build menubar tray title") return } - settings, err := h.menubarSettings() - if err != nil { - settings = menubar.DefaultSettings() - } + // Never fail the tray title on a settings read error: the last settings + // that were read successfully (or defaults) still format a usable title. + settings := h.menubarSettingsWithFallback() title := menubar.TrayTitle(snapshot, settings) segments := menubar.TraySegments(snapshot, settings) tooltip := "" @@ -95,12 +94,20 @@ func (h *Handler) MenubarTrayTitle(w http.ResponseWriter, r *http.Request) { } } } - respondJSON(w, http.StatusOK, map[string]interface{}{ + response := map[string]interface{}{ "title": title, "tooltip": tooltip, "online": snapshot != nil, "segments": segments, - }) + } + // Surface LatestAt so tray companions can keep counting up even when the + // daemon cannot be reached. Mirror the snapshot's omitzero behaviour: + // never emit the zero time as "0001-01-01T00:00:00Z", which clients render + // as hundreds of thousands of days. + if snapshot != nil && !snapshot.LatestAt.IsZero() { + response["latest_at"] = snapshot.LatestAt.UTC().Format(time.RFC3339) + } + respondJSON(w, http.StatusOK, response) } // MenubarPage renders the compact browser UI used by the tray companions and @@ -161,19 +168,21 @@ func (h *Handler) MenubarTest(w http.ResponseWriter, r *http.Request) { func (h *Handler) MenubarPreferences(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - settings, err := h.reconcileMenubarSettings() + // Preferences are derived from the same cached build as the summary: + // the one build supplies the unfiltered card list, so opening the + // popover never starts a second set of queries next to a summary + // refresh. If the build fails and there is no cached data, fall back to + // the last good settings with no options rather than 500. + data, err := h.menubarDataForRequest(r.Context()) if err != nil { h.logger.Error("failed to load menubar preferences", "error", err) - respondError(w, http.StatusInternalServerError, "failed to load menubar preferences") + respondMenubarSnapshotError(w, err, "failed to load menubar preferences") return } - providers, err := h.buildMenubarProviderOptions(settings) - if err != nil { - h.logger.Error("failed to build menubar provider options", "error", err) - respondError(w, http.StatusInternalServerError, "failed to load menubar preferences") - return - } - respondJSON(w, http.StatusOK, menubarPreferencesResponse(settings, providers)) + // Settings are read fresh: one cheap primary-key lookup, and it keeps + // the response correct even when the cached build predates a save. + effective := h.menubarSettingsWithFallback() + respondJSON(w, http.StatusOK, menubarPreferencesResponse(effective, h.buildMenubarProviderOptions(effective, data.AllProviders))) case http.MethodPut: if h.store == nil { respondError(w, http.StatusInternalServerError, "store not available") @@ -197,13 +206,18 @@ func (h *Handler) MenubarPreferences(w http.ResponseWriter, r *http.Request) { return } h.triggerMenubarRefresh() - providers, err := h.buildMenubarProviderOptions(normalized) + // Derive only the provider options from the shared build; the settings + // in the response are always the ones just saved. When the rebuild + // exceeds the wait budget the build data still carries the pre-save + // settings, and echoing them here would make the popover persist them + // into localStorage and revert the user's change. + data, err := h.menubarDataForRequest(r.Context()) if err != nil { h.logger.Error("failed to rebuild menubar provider options", "error", err) - respondError(w, http.StatusInternalServerError, "failed to save menubar preferences") + respondJSON(w, http.StatusOK, menubarPreferencesResponse(normalized, nil)) return } - respondJSON(w, http.StatusOK, menubarPreferencesResponse(normalized, providers)) + respondJSON(w, http.StatusOK, menubarPreferencesResponse(normalized, h.buildMenubarProviderOptions(normalized, data.AllProviders))) default: respondError(w, http.StatusMethodNotAllowed, "method not allowed") } @@ -214,12 +228,27 @@ func (h *Handler) MenubarRefresh(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusMethodNotAllowed, "method not allowed") return } - if _, err := h.BuildMenubarSnapshot(); err != nil { + // A manual refresh must actually rebuild, even inside the fresh window, + // but it keeps the last snapshot as the fallback: if the rebuild stalls + // past the wait budget the request still answers with the previous data. + h.markMenubarSnapshotStale() + if _, err := h.menubarSnapshotForRequest(r.Context()); err != nil { h.logger.Debug("menubar refresh snapshot rebuild failed", "error", err) } respondJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +// respondMenubarSnapshotError maps a snapshot failure to a status. A wedged +// first build is temporary, so it answers 503 (retry later) rather than 500; +// the client keeps its last data either way. +func respondMenubarSnapshotError(w http.ResponseWriter, err error, message string) { + if err == errMenubarSnapshotTimeout { + respondError(w, http.StatusServiceUnavailable, message) + return + } + respondError(w, http.StatusInternalServerError, message) +} + func isLoopbackRequest(r *http.Request) bool { // A reverse proxy on the same host connects from loopback on behalf of // remote clients. Forwarded requests are never treated as local. @@ -244,25 +273,60 @@ func isLocalMenubarPublicPath(path string) bool { } // BuildMenubarSnapshot constructs the shared menubar UI contract. +// +// When the stored settings cannot be read, the last settings that were read +// successfully stand in, so a transient store failure does not turn the whole +// menubar into a 500. Only when no settings have ever been read does it fail. func (h *Handler) BuildMenubarSnapshot() (*menubar.Snapshot, error) { + built, err := h.buildMenubarSnapshotWithAllProviders() + return built.Snapshot, err +} + +// buildMenubarSnapshotWithAllProviders builds the filtered snapshot the +// clients see plus the unfiltered card list the preferences endpoint derives +// its options from. Building both in one pass keeps preferences off a second +// set of queries. +func (h *Handler) buildMenubarSnapshotWithAllProviders() (menubarBuiltData, error) { settings, err := h.reconcileMenubarSettings() if err != nil { - return nil, err + settings = h.menubarLastSettingsSnapshot() + if settings == nil { + return menubarBuiltData{}, err + } + h.logger.Warn("menubar settings read failed; building from the last good settings", "error", err) } - providers, latest := h.buildMenubarProviders(settings, false) + // Build with hidden cards included, then filter, so one pass supplies both + // the snapshot and the options list. + allProviders, latest := h.buildMenubarProviders(settings, true) + normalized := settings.Normalize() + providers := filterMenubarProviders(allProviders, normalized.VisibleProviders) + if providers == nil { + providers = []menubar.ProviderCard{} + } aggregate := buildAggregate(providers) - return &menubar.Snapshot{ - GeneratedAt: time.Now().UTC(), - UpdatedAgo: timeAgo(latest), - Aggregate: aggregate, - Providers: providers, - MistralEnabled: h.config != nil && h.config.HasProvider("mistral"), + return menubarBuiltData{ + Snapshot: &menubar.Snapshot{ + GeneratedAt: time.Now().UTC(), + // UpdatedAgo and LatestAt both describe the newest provider data, not + // this build: a header age computed from them keeps growing when every + // provider has stopped polling. + UpdatedAgo: timeAgo(latest), + LatestAt: latest.UTC(), + Aggregate: aggregate, + Providers: providers, + MistralEnabled: h.config != nil && h.config.HasProvider("mistral"), + }, + AllProviders: allProviders, + Settings: normalized, }, nil } func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidden bool) ([]menubar.ProviderCard, time.Time) { normalized := settings.Normalize() + if h.menubarProvidersBuildHook != nil { + h.menubarProvidersBuildHook() + } // Reuse dashboard tab renames (Settings → Tab name) for menubar cards. labels := h.loadDashboardProviderLabels() @@ -270,216 +334,344 @@ func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidde providers := make([]menubar.ProviderCard, 0, 10) latest := time.Time{} - if h.config != nil && h.config.HasProvider("synthetic") && h.providerDashboardVisible("synthetic", visibility) { - payload := h.buildSyntheticCurrent() - if card := normalizeProviderCard("synthetic", resolveProviderTabLabel("synthetic", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } + // attempted holds every card ID this build considered (configured and + // dashboard-visible). After the build, cached cards outside this set are + // evicted, so a removed provider or deleted account does not linger. + attempted := make(map[string]struct{}, 10) + defer h.pruneMenubarLastGood(attempted) + + // add appends a resolved card and folds its data time into latest. A cached + // card keeps its original timestamp, which is a real data time, so LatestAt + // still comes only from real data. + add := func(card *menubar.ProviderCard, captured time.Time) { + if card == nil { + return + } + providers = append(providers, *card) + if captured.After(latest) { + latest = captured } } + + if h.config != nil && h.config.HasProvider("synthetic") && h.providerDashboardVisible("synthetic", visibility) { + attempted["synthetic"] = struct{}{} + payload, readOK, hasData := h.buildSyntheticCurrentOK() + add(h.menubarResolveCard("synthetic", resolveProviderTabLabel("synthetic", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) + } if h.config != nil && h.config.HasProvider("zai") && h.providerDashboardVisible("zai", visibility) { - payload := h.buildZaiCurrent() - if card := normalizeProviderCard("zai", resolveProviderTabLabel("zai", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["zai"] = struct{}{} + payload, readOK, hasData := h.buildZaiCurrentOK() + add(h.menubarResolveCard("zai", resolveProviderTabLabel("zai", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("anthropic") && h.providerDashboardVisible("anthropic", visibility) { - payload := h.buildAnthropicCurrent() - if card := normalizeProviderCard("anthropic", resolveProviderTabLabel("anthropic", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - if promoData, ok := payload["promo"]; ok && promoData != nil { - if p, ok := promoData.(*anthropicPromo); ok { - compactText := "Off-peak hours" - if isAnthropicPeakHours(p, time.Now()) { - compactText = "Peak hours" - } - card.Promo = &menubar.ProviderPromo{ - ID: p.ID, - Title: p.Title, - CompactText: compactText, - PeakStartHourET: p.PeakStartHourET, - PeakEndHourET: p.PeakEndHourET, - PeakWeekdaysOnly: p.PeakWeekdaysOnly, - EndsAt: p.EndsAt, - } - } + attempted["anthropic"] = struct{}{} + payload, readOK, hasData := h.buildAnthropicCurrentOK() + add(h.menubarResolveCard("anthropic", resolveProviderTabLabel("anthropic", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, func(card *menubar.ProviderCard) { + promoData, ok := payload["promo"] + if !ok || promoData == nil { + return } - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured + p, ok := promoData.(*anthropicPromo) + if !ok { + return } - } + compactText := "Off-peak hours" + if isAnthropicPeakHours(p, time.Now()) { + compactText = "Peak hours" + } + card.Promo = &menubar.ProviderPromo{ + ID: p.ID, + Title: p.Title, + CompactText: compactText, + PeakStartHourET: p.PeakStartHourET, + PeakEndHourET: p.PeakEndHourET, + PeakWeekdaysOnly: p.PeakWeekdaysOnly, + EndsAt: p.EndsAt, + } + })) } if h.config != nil && h.config.HasProvider("copilot") && h.providerDashboardVisible("copilot", visibility) { - payload := h.buildCopilotCurrent() - if card := normalizeProviderCard("copilot", resolveProviderTabLabel("copilot", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["copilot"] = struct{}{} + payload, readOK, hasData := h.buildCopilotCurrentOK() + add(h.menubarResolveCard("copilot", resolveProviderTabLabel("copilot", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("codex") && h.providerDashboardVisible("codex", visibility) { - codexAccounts := h.codexUsageAccounts() - multiCodex := len(codexAccounts) > 1 - for _, usage := range codexAccounts { - accountID := codexUsageAccountID(usage) - providerKey := fmt.Sprintf("codex:%d", accountID) - if !providerDashboardVisibleForKey(visibility, providerKey, "codex") { - continue - } - name := stringValue(usage, "accountName") - if name == "" { - name = "default" - } - subtitle := "ChatGPT account" - label := menubarAccountLabel("codex", labels, name, multiCodex) - if card := normalizeProviderCard(providerKey, label, subtitle, usage, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(usage); captured.After(latest) { - latest = captured + codexAccounts, enumerated := h.codexUsageAccountsOK() + if enumerated { + multiCodex := len(codexAccounts) > 1 + for _, account := range codexAccounts { + usage := account.Usage + accountID := codexUsageAccountID(usage) + providerKey := fmt.Sprintf("codex:%d", accountID) + if !providerDashboardVisibleForKey(visibility, providerKey, "codex") { + continue + } + attempted[providerKey] = struct{}{} + name := stringValue(usage, "accountName") + if name == "" { + name = "default" } + subtitle := "ChatGPT account" + label := menubarAccountLabel("codex", labels, name, multiCodex) + add(h.menubarResolveCard(providerKey, label, subtitle, usage, account.ReadOK, account.HasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) + } + } else { + // The account list could not be read. Keep every known account card + // and leave the cache untouched: an enumeration failure says nothing + // about which accounts still exist. + for _, entry := range h.menubarCachedAccountCards("codex", attempted, visibility) { + add(entry.card, entry.captured) } } } if h.config != nil && h.config.HasProvider("antigravity") && h.providerDashboardVisible("antigravity", visibility) { - payload := h.buildAntigravityCurrent() - if card := normalizeProviderCard("antigravity", resolveProviderTabLabel("antigravity", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["antigravity"] = struct{}{} + payload, readOK, hasData := h.buildAntigravityCurrentOK() + add(h.menubarResolveCard("antigravity", resolveProviderTabLabel("antigravity", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("minimax") && h.providerDashboardVisible("minimax", visibility) { - minimaxAccounts := h.minimaxUsageAccounts() - multiMiniMax := len(minimaxAccounts) > 1 - for _, usage := range minimaxAccounts { - accountID := minimaxUsageAccountID(usage) - providerKey := fmt.Sprintf("minimax:%d", accountID) - if !providerDashboardVisibleForKey(visibility, providerKey, "minimax") { - continue - } - name := stringValue(usage, "accountName") - if name == "" { - name = "default" - } - subtitle := "MiniMax account" - label := menubarAccountLabel("minimax", labels, name, multiMiniMax) - if card := normalizeProviderCard(providerKey, label, subtitle, usage, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(usage); captured.After(latest) { - latest = captured + minimaxAccounts, enumerated := h.minimaxUsageAccountsOK() + if enumerated { + multiMiniMax := len(minimaxAccounts) > 1 + for _, account := range minimaxAccounts { + usage := account.Usage + accountID := minimaxUsageAccountID(usage) + providerKey := fmt.Sprintf("minimax:%d", accountID) + if !providerDashboardVisibleForKey(visibility, providerKey, "minimax") { + continue } + attempted[providerKey] = struct{}{} + name := stringValue(usage, "accountName") + if name == "" { + name = "default" + } + subtitle := "MiniMax account" + label := menubarAccountLabel("minimax", labels, name, multiMiniMax) + add(h.menubarResolveCard(providerKey, label, subtitle, usage, account.ReadOK, account.HasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) + } + } else { + // The account list could not be read: keep the known account cards + // and do not prune them. + for _, entry := range h.menubarCachedAccountCards("minimax", attempted, visibility) { + add(entry.card, entry.captured) } } } if h.config != nil && h.config.HasProvider("openrouter") && h.providerDashboardVisible("openrouter", visibility) { - payload := h.buildOpenRouterCurrent() - if card := normalizeProviderCard("openrouter", resolveProviderTabLabel("openrouter", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["openrouter"] = struct{}{} + payload, readOK, hasData := h.buildOpenRouterCurrentOK() + add(h.menubarResolveCard("openrouter", resolveProviderTabLabel("openrouter", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("gemini") && h.providerDashboardVisible("gemini", visibility) { - payload := h.buildGeminiCurrent() - if card := normalizeProviderCard("gemini", resolveProviderTabLabel("gemini", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["gemini"] = struct{}{} + payload, readOK, hasData := h.buildGeminiCurrentOK() + add(h.menubarResolveCard("gemini", resolveProviderTabLabel("gemini", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("grok") && h.providerDashboardVisible("grok", visibility) { - payload := h.buildGrokCurrent() - if card := normalizeProviderCard("grok", resolveProviderTabLabel("grok", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["grok"] = struct{}{} + payload, readOK, hasData := h.buildGrokCurrentOK() + add(h.menubarResolveCard("grok", resolveProviderTabLabel("grok", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("kimi") && h.providerDashboardVisible("kimi", visibility) { - payload := h.buildKimiCurrent() - if card := normalizeProviderCard("kimi", "Kimi Code", "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["kimi"] = struct{}{} + payload, readOK, hasData := h.buildKimiCurrentOK() + add(h.menubarResolveCard("kimi", "Kimi Code", "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("mistral") && h.providerDashboardVisible("mistral", visibility) { - payload := h.buildMistralCurrent() - if card := normalizeProviderCard("mistral", resolveProviderTabLabel("mistral", labels), mistralBillingSubtitle(payload), payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["mistral"] = struct{}{} + payload, readOK, hasData := h.buildMistralCurrentOK() + add(h.menubarResolveCard("mistral", resolveProviderTabLabel("mistral", labels), mistralBillingSubtitle(payload), payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("opencode") && h.providerDashboardVisible("opencode", visibility) { - payload := h.buildOpenCodeCurrent() - if card := normalizeProviderCard("opencode", resolveProviderTabLabel("opencode", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["opencode"] = struct{}{} + payload, readOK, hasData := h.buildOpenCodeCurrentOK() + add(h.menubarResolveCard("opencode", resolveProviderTabLabel("opencode", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("ollama") && h.providerDashboardVisible("ollama", visibility) { - payload := h.buildOllamaCurrent() - if card := normalizeProviderCard("ollama", resolveProviderTabLabel("ollama", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["ollama"] = struct{}{} + payload, readOK, hasData := h.buildOllamaCurrentOK() + add(h.menubarResolveCard("ollama", resolveProviderTabLabel("ollama", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("muse") && h.providerDashboardVisible("muse", visibility) { - payload := h.buildMuseCurrent() - if card := normalizeProviderCard("muse", resolveProviderTabLabel("muse", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["muse"] = struct{}{} + payload, readOK, hasData := h.buildMuseCurrentOK() + add(h.menubarResolveCard("muse", resolveProviderTabLabel("muse", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("commandcode") && h.providerDashboardVisible("commandcode", visibility) { - payload := h.buildCommandCodeCurrent() - if card := normalizeProviderCard("commandcode", resolveProviderTabLabel("commandcode", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["commandcode"] = struct{}{} + payload, readOK, hasData := h.buildCommandCodeCurrentOK() + add(h.menubarResolveCard("commandcode", resolveProviderTabLabel("commandcode", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } if h.config != nil && h.config.HasProvider("cursor") && h.providerDashboardVisible("cursor", visibility) { - payload := h.buildCursorCurrent() - if card := normalizeProviderCard("cursor", resolveProviderTabLabel("cursor", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { - providers = append(providers, *card) - if captured := parseCapturedAt(payload); captured.After(latest) { - latest = captured - } - } + attempted["cursor"] = struct{}{} + payload, readOK, hasData := h.buildCursorCurrentOK() + add(h.menubarResolveCard("cursor", resolveProviderTabLabel("cursor", labels), "", payload, readOK, hasData, normalized.WarningPercent, normalized.CriticalPercent, nil)) } sortProviderCards(providers, normalized.ProvidersOrder) if !includeHidden { providers = filterMenubarProviders(providers, normalized.VisibleProviders) } + if providers == nil { + // An empty build still serialises as [] rather than null. + providers = []menubar.ProviderCard{} + } return providers, latest } +// menubarResolveCard normalizes one provider payload into a card and applies +// the last-good cache. decorate, when non-nil, runs on a freshly built card +// before it is cached. +// +// readOK reports whether the provider's storage read succeeded; hasData reports +// whether it returned stored rows. A read failure is distinct from a genuine +// "no data yet": on failure the cached card is served unchanged (or the +// provider is omitted when nothing was cached). A successful read with no rows +// means the history is genuinely gone, so any cached card is evicted; the +// provider is omitted, except for Mistral's connection-only placeholder, which +// carries retry state but must never claim a data time. +func (h *Handler) menubarResolveCard(id, label, subtitle string, payload map[string]interface{}, readOK, hasData bool, warningPercent, criticalPercent int, decorate func(*menubar.ProviderCard)) (*menubar.ProviderCard, time.Time) { + if !readOK { + return h.menubarCachedCard(id) + } + if !hasData { + h.forgetMenubarCard(id) + if providerKeyBase(id) != "mistral" { + return nil, time.Time{} + } + // Mistral still shows a connection-only placeholder so the user can see + // the reconnect/browser-access state and retry. It must not carry a + // capturedAt: that would fabricate a fresh age and feed LatestAt. + timeless := make(map[string]interface{}, len(payload)) + for key, value := range payload { + if key == "capturedAt" { + continue + } + timeless[key] = value + } + card := normalizeProviderCard(id, label, subtitle, timeless, warningPercent, criticalPercent) + if card == nil { + return nil, time.Time{} + } + if decorate != nil { + decorate(card) + } + return card, time.Time{} + } + card := normalizeProviderCard(id, label, subtitle, payload, warningPercent, criticalPercent) + if card == nil { + // Stored rows that produce nothing recognizable are not evidence that + // the history is gone (some polls store a snapshot with no quota + // values). Serve the last good card instead of dropping it: with + // return nil here the card vanished now and reappeared later with + // older numbers. + return h.menubarCachedCard(id) + } + if decorate != nil { + decorate(card) + } + captured := parseCapturedAt(payload) + h.storeMenubarCard(id, card) + return card, captured +} + +// menubarCachedCard serves the last good card for a provider whose storage read +// failed. With nothing cached the provider is omitted rather than fabricated +// with a zero-value card stamped now. +func (h *Handler) menubarCachedCard(id string) (*menubar.ProviderCard, time.Time) { + h.menubarLastGoodMu.Lock() + cached, ok := h.menubarLastGood[id] + h.menubarLastGoodMu.Unlock() + if !ok { + return nil, time.Time{} + } + card := cached + captured, _ := time.Parse(time.RFC3339, card.UpdatedAtTime) + return &card, captured +} + +func (h *Handler) forgetMenubarCard(id string) { + h.menubarLastGoodMu.Lock() + defer h.menubarLastGoodMu.Unlock() + delete(h.menubarLastGood, id) +} + +func (h *Handler) storeMenubarCard(id string, card *menubar.ProviderCard) { + h.menubarLastGoodMu.Lock() + defer h.menubarLastGoodMu.Unlock() + if h.menubarLastGood == nil { + h.menubarLastGood = make(map[string]menubar.ProviderCard, 8) + } + h.menubarLastGood[id] = *card +} + +// pruneMenubarLastGood drops cached cards for providers this build did not +// consider, so a removed provider, a deleted account or a disabled provider +// does not linger in the menubar. +func (h *Handler) pruneMenubarLastGood(attempted map[string]struct{}) { + h.menubarLastGoodMu.Lock() + defer h.menubarLastGoodMu.Unlock() + for id := range h.menubarLastGood { + if _, ok := attempted[id]; !ok { + delete(h.menubarLastGood, id) + } + } +} + +// menubarCachedAccountEntry is one cached card handed back while a +// multi-account provider's account enumeration is failing. +type menubarCachedAccountEntry struct { + card *menubar.ProviderCard + captured time.Time +} + +// menubarCachedAccountCards serves every cached card for a multi-account +// provider whose account list could not be read. The entries are marked +// attempted so deferred pruning keeps them; pruning waits for a successful +// enumeration, which is the only evidence that an account was deleted. +func (h *Handler) menubarCachedAccountCards(base string, attempted map[string]struct{}, visibility map[string]map[string]bool) []menubarCachedAccountEntry { + h.menubarLastGoodMu.Lock() + defer h.menubarLastGoodMu.Unlock() + entries := make([]menubarCachedAccountEntry, 0, len(h.menubarLastGood)) + for id, card := range h.menubarLastGood { + if providerKeyBase(id) != base { + continue + } + if !providerDashboardVisibleForKey(visibility, id, base) { + continue + } + attempted[id] = struct{}{} + stored := card + captured, _ := time.Parse(time.RFC3339, stored.UpdatedAtTime) + entries = append(entries, menubarCachedAccountEntry{card: &stored, captured: captured}) + } + // Stable order so tests and clients see the same account order even though + // the cache is a map. + sort.Slice(entries, func(i, j int) bool { return entries[i].card.ID < entries[j].card.ID }) + return entries +} + +// menubarCardAccountID extracts the account ID from an account-scoped card ID +// ("codex:2", "minimax:3"). Providers without a scope return 0. +func menubarCardAccountID(cardID string) int64 { + idx := strings.IndexByte(cardID, ':') + if idx < 0 { + return 0 + } + id, err := strconv.ParseInt(cardID[idx+1:], 10, 64) + if err != nil { + return 0 + } + return id +} + // updateMenubarProviderHistory changes one settings value so provider history // and automatic visibility are saved together. A nil history marks settings // written before provider discovery was tracked. func updateMenubarProviderHistory(settings *menubar.Settings, configured []string) bool { + if settings == nil { + return false + } changed := settings.KnownProviders == nil if settings.KnownProviders == nil { settings.KnownProviders = []string{} @@ -519,12 +711,19 @@ func (h *Handler) reconcileMenubarSettings() (*menubar.Settings, error) { h.menubarSettingsMu.Lock() defer h.menubarSettingsMu.Unlock() settings, err := h.menubarSettings() - if err != nil || h.store == nil { + if err != nil { return settings, err } + // Record the read that succeeded before any write below can fail; the + // values themselves are valid even if persisting the discovery history + // does not work. + h.recordMenubarLastSettings(settings) + if h.store == nil { + return settings, nil + } if updateMenubarProviderHistory(settings, h.configuredMenubarProviders()) { if err := h.store.SetMenubarSettings(settings); err != nil { - return nil, err + return settings, err } } return settings, nil @@ -541,7 +740,13 @@ func (h *Handler) saveMenubarSettings(settings *menubar.Settings) error { } updateMenubarProviderHistory(current, h.configuredMenubarProviders()) settings.KnownProviders = append([]string{}, current.KnownProviders...) - return h.store.SetMenubarSettings(settings) + if err := h.store.SetMenubarSettings(settings); err != nil { + return err + } + // Menubar output just changed: the next summary/tray-title request must + // rebuild rather than serve the previous settings' snapshot. + h.invalidateMenubarSnapshot() + return nil } // menubarAccountLabel builds multi-account menubar titles using the dashboard @@ -597,9 +802,8 @@ func (h *Handler) renderMenubarHTML(view menubar.ViewType, settings *menubar.Set return strings.Replace(content, "__ONWATCH_MENUBAR_VERSION__", version, 1), nil } -func (h *Handler) buildMenubarProviderOptions(settings *menubar.Settings) ([]menubarProviderOption, error) { +func (h *Handler) buildMenubarProviderOptions(settings *menubar.Settings, providers []menubar.ProviderCard) []menubarProviderOption { normalized := settings.Normalize() - providers, _ := h.buildMenubarProviders(normalized, true) visibleKeys := normalized.VisibleProviders options := make([]menubarProviderOption, 0, len(providers)) for _, provider := range providers { @@ -620,7 +824,7 @@ func (h *Handler) buildMenubarProviderOptions(settings *menubar.Settings) ([]men Quotas: quotaOptions, }) } - return options, nil + return options } func menubarPreferencesResponse(settings *menubar.Settings, providers []menubarProviderOption) map[string]interface{} { @@ -768,7 +972,10 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf } status := "healthy" connectionStatus := stringValue(payload, "status") - if connectionStatus == "reconnect" || connectionStatus == "stale" || connectionStatus == "paused" { + // reconnect and partial describe a connection that needs action (some or + // all data unavailable), so they stay a warning floor. Plain staleness does + // not: see the isStale branch below. + if connectionStatus == "reconnect" || connectionStatus == "partial" || connectionStatus == "paused" { status = "warning" } // A paused account needs re-authentication. That is a warning floor, not a @@ -780,9 +987,10 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf status = worsenStatus(status, "warning") connectionStatus = "paused" } else if stale, _ := payload["isStale"].(bool); stale { - // A polling-disabled account keeps serving its last snapshot: flag it as - // stale so the dot tooltip shows "Updates unavailable" (#147 review). - status = worsenStatus(status, "warning") + // Staleness is shown only through the per-card age label. It must not + // raise the dot severity: a healthy card that is merely old stays + // healthy. The connection_status still marks the state for the clients + // that need it (Mistral recovery text); it does not drive color here. if connectionStatus == "" { connectionStatus = "stale" } @@ -797,9 +1005,6 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf highest = quota.Percent } status = worsenStatus(status, quota.Status) - if quota.IsStale { - status = worsenStatus(status, "warning") - } points := quota.SparklinePoints if len(points) == 0 { points = []float64{quota.Percent, quota.Percent, quota.Percent, quota.Percent} @@ -811,6 +1016,16 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf Points: points, }) } + // Age is computed from the provider's own last stored snapshot time + // (capturedAt), not the summary build time. That way it keeps growing when + // this provider's polls stop, even though the daemon is still serving + // snapshots for other providers. + updatedAt := "" + updatedAtTime := "" + if captured := parseCapturedAt(payload); !captured.IsZero() { + updatedAt = timeAgo(captured) + updatedAtTime = captured.UTC().Format(time.RFC3339) + } return &menubar.ProviderCard{ ID: id, BaseProvider: providerKeyBase(id), @@ -820,7 +1035,8 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf Connection: connection, Status: status, HighestPercent: highest, - UpdatedAt: timeAgo(parseCapturedAt(payload)), + UpdatedAt: updatedAt, + UpdatedAtTime: updatedAtTime, Quotas: quotas, Trends: trends, } diff --git a/internal/web/menubar_age_label_test.go b/internal/web/menubar_age_label_test.go new file mode 100644 index 00000000..db36d266 --- /dev/null +++ b/internal/web/menubar_age_label_test.go @@ -0,0 +1,77 @@ +package web + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +// The per-card age label must count from the provider's own last successful +// data time against the client wall clock, so it keeps growing both when the +// client cannot reach the daemon and when the daemon is up but that provider +// is not syncing. It must not jump in steps on its scale. +func TestMenubarProviderAgeLabelCountsFromTimestamp(t *testing.T) { + page, err := staticFS.ReadFile("static/menubar.html") + if err != nil { + t.Fatal(err) + } + html := string(page) + + start := strings.Index(html, "function compactUpdatedLabel(") + end := strings.Index(html, "// quotaLabelSemantic maps") + if start < 0 || end < start { + t.Fatal("age label helpers not found in menubar.html") + } + slice := html[start:end] + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node required for menubar age label test") + } + + test := ` +const assert = require('node:assert/strict'); +` + slice + ` +const nowMs = 1_700_000_000_000; +const realNow = Date.now; +Date.now = () => nowMs; +const iso = (msAgo) => new Date(nowMs - msAgo).toISOString(); +const min = 60 * 1000, hour = 60 * min, day = 24 * hour; + +assert.equal(providerAgeLabel({updated_at_time: iso(0)}), "now"); +assert.equal(providerAgeLabel({updated_at_time: iso(90 * 1000)}), "1m"); +assert.equal(providerAgeLabel({updated_at_time: iso(2 * min)}), "2m"); +assert.equal(providerAgeLabel({updated_at_time: iso(59 * min)}), "59m"); +assert.equal(providerAgeLabel({updated_at_time: iso(hour)}), "1h"); +assert.equal(providerAgeLabel({updated_at_time: iso(23 * hour)}), "23h"); +assert.equal(providerAgeLabel({updated_at_time: iso(day)}), "1d"); +assert.equal(providerAgeLabel({updated_at_time: iso(2 * day)}), "2d"); + +// The label keeps growing as the wall clock advances, with no API call. +const provider = {updated_at_time: iso(0)}; +assert.equal(providerAgeLabel(provider), "now"); +Date.now = () => nowMs + 5 * min; +assert.equal(providerAgeLabel(provider), "5m"); +Date.now = () => nowMs + 3 * hour; +assert.equal(providerAgeLabel(provider), "3h"); + +// No timestamp: fall back to the server's pre-rendered string. +assert.equal(providerAgeLabel({updated_at: "7m ago"}), "7m"); +assert.equal(providerAgeLabel(null), "--"); +// A zero Go time.Time ("0001-01-01T00:00:00Z") and other absurd ages must fall +// back rather than render hundreds of thousands of days. +assert.equal(ageLabelFromTimestamp("0001-01-01T00:00:00Z", "5m ago"), "5m"); +assert.equal(ageLabelFromTimestamp("1999-12-31T23:59:59Z", "5m ago"), "5m"); +assert.equal(ageLabelFromTimestamp("not-a-date", "5m ago"), "5m"); +Date.now = realNow; +` + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node) + cmd.Stdin = strings.NewReader(test) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, output) + } +} diff --git a/internal/web/menubar_cache.go b/internal/web/menubar_cache.go new file mode 100644 index 00000000..d01a294c --- /dev/null +++ b/internal/web/menubar_cache.go @@ -0,0 +1,349 @@ +package web + +import ( + "context" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/menubar" +) + +// Snapshot caching for the menubar endpoints. +// +// Building a snapshot runs roughly 60-90 SQL statements (about 16 provider +// cards plus tracker usage-summary enrichment) against a pool capped at two +// connections. Under load those statements queue behind the provider agents' +// writes, and several independent consumers (the companion, the popover and +// the GNOME extension) each used to start their own build. The result was a +// 30s+ /api/menubar/summary hang while every other endpoint answered in about +// a second. +// +// The daemon now keeps the last successfully built data in memory and only ever +// runs one build at a time. Concurrent requests share the in-flight build +// (singleflight). A request with fresh cached data is answered with no database +// work at all; otherwise it waits a short, bounded time for a build and falls +// back to the cached data if that build is still running, letting it finish in +// the background. The cached snapshot keeps its original generated_at/latest_at, +// so the popover's ordering guard still accepts a newer build after a cached +// response, and staleness shows only through each card's age label (which +// counts up from updated_at_time). +const ( + // menubarSnapshotFreshWindow is how long built data is served without + // touching the database. 12s is shorter than the default 60s refresh and + // short enough that a popover re-open after the user has been away shows + // fresh-enough numbers, while the companion, the popover and GNOME hitting + // within the same second share one build. + menubarSnapshotFreshWindow = 12 * time.Second + + // menubarSnapshotWaitBudget is how long a request waits for a build once + // the cached data is stale. On an idle database the full query set takes + // about 0.2s, so 3s tolerates heavy contention and still leaves the client + // far inside its 15s deadline. Past it, the stale data is served + // immediately and the build continues in the background. + menubarSnapshotWaitBudget = 3 * time.Second + + // menubarSnapshotNoCacheBudget bounds the wait when there is no cached + // snapshot at all (the first request after start). Nothing can be served + // before the first build finishes, but a wedged build must not pin the + // request forever (nor past the client's 15s deadline); the handler + // answers 503 so the client keeps its last data. + menubarSnapshotNoCacheBudget = 15 * time.Second +) + +// errMenubarSnapshotTimeout is returned when the first build does not finish +// inside menubarSnapshotNoCacheBudget. Handlers map it to 503. +var errMenubarSnapshotTimeout = fmt.Errorf("menubar snapshot build timed out") + +// menubarBuiltData is the product of one build: the filtered snapshot the +// clients see, the unfiltered card list (menubar-hidden providers included) +// that the preferences endpoint needs, and the settings the build used. One +// build supplies all three, so preferences never issue a second set of +// queries. +type menubarBuiltData struct { + Snapshot *menubar.Snapshot + AllProviders []menubar.ProviderCard + Settings *menubar.Settings +} + +// menubarSnapshotBuild is one in-flight build. Every request that arrives while +// it runs waits on done instead of starting another build. +type menubarSnapshotBuild struct { + done chan struct{} + generation uint64 + data menubarBuiltData + err error +} + +// menubarFreshWindow returns the configured freshness window. Tests set a +// negative value to make every cached snapshot stale. +func (h *Handler) menubarFreshWindow() time.Duration { + if h.menubarFreshFor != 0 { + return h.menubarFreshFor + } + return menubarSnapshotFreshWindow +} + +// menubarWaitBudget returns the configured build wait budget. +func (h *Handler) menubarWaitBudget() time.Duration { + if h.menubarWaitFor > 0 { + return h.menubarWaitFor + } + return menubarSnapshotWaitBudget +} + +// menubarNoCacheBudget bounds the no-cache wait. Tests shorten it. +func (h *Handler) menubarNoCacheBudget() time.Duration { + if h.menubarNoCacheFor > 0 { + return h.menubarNoCacheFor + } + return menubarSnapshotNoCacheBudget +} + +func (h *Handler) menubarNowTime() time.Time { + if h.menubarNow != nil { + return h.menubarNow() + } + return time.Now() +} + +// invalidateMenubarSnapshot bumps the settings generation and marks the cached +// data stale, so the next request rebuilds instead of serving configuration +// from before the write. The data itself is kept and still served as the +// fallback when the new build does not finish inside the wait budget: showing +// the previous cards with their real ages beats blanking the menubar. Only the +// next successful build replaces it. The in-flight build pointer is cleared so +// waiters can start the next build without waiting for the superseded +// goroutine; that goroutine skips its queries or discards its result because +// its generation no longer matches. +func (h *Handler) invalidateMenubarSnapshot() { + h.menubarSnapshotMu.Lock() + h.menubarSnapshotGen++ + h.menubarSnapshotAt = time.Time{} + h.menubarSnapshotBuild = nil + h.menubarSnapshotMu.Unlock() +} + +// markMenubarSnapshotStale forces the next request to rebuild while keeping +// the cached data as the fallback. Used by the manual refresh endpoint: a +// refresh that stalls past the wait budget must still answer with the last +// data. +func (h *Handler) markMenubarSnapshotStale() { + h.menubarSnapshotMu.Lock() + h.menubarSnapshotAt = time.Time{} + h.menubarSnapshotMu.Unlock() +} + +// menubarCachedData returns the cached build payload (immutable once stored), +// its build time, the current generation and any in-flight build. +func (h *Handler) menubarCachedData() (menubarBuiltData, time.Time, uint64, *menubarSnapshotBuild) { + h.menubarSnapshotMu.Lock() + defer h.menubarSnapshotMu.Unlock() + return h.menubarSnapshotData, h.menubarSnapshotAt, h.menubarSnapshotGen, h.menubarSnapshotBuild +} + +// menubarDataForRequest applies the cache policy and returns the built data +// (snapshot, unfiltered providers and settings). It serves fresh cached data +// without building, waits up to the budget for stale data to be rebuilt, and +// otherwise serves the cached data while the rebuild continues in the +// background. +func (h *Handler) menubarDataForRequest(ctx context.Context) (menubarBuiltData, error) { + deadline := time.Now().Add(h.menubarWaitBudget()) + noCacheDeadline := time.Now().Add(h.menubarNoCacheBudget()) + + for { + cached, cachedAt, generation, build := h.menubarCachedData() + + if cached.Snapshot != nil && h.menubarNowTime().Sub(cachedAt) < h.menubarFreshWindow() { + return cached, nil + } + + joined := build != nil && build.generation == generation + if !joined { + h.menubarSnapshotMu.Lock() + if h.menubarSnapshotBuild != nil && h.menubarSnapshotBuild.generation == h.menubarSnapshotGen { + build = h.menubarSnapshotBuild + joined = true + h.menubarSnapshotMu.Unlock() + } else { + build = h.startMenubarSnapshotBuildLocked(h.menubarSnapshotGen) + h.menubarSnapshotMu.Unlock() + } + } + if joined && h.menubarJoinHook != nil { + h.menubarJoinHook() + } + + if cached.Snapshot == nil { + // First request after start: there is nothing to serve yet, so wait + // for this build as the endpoints did before, but never without a + // bound. A wedged build must not pin the request past the client + // deadline. + remaining := time.Until(noCacheDeadline) + if remaining <= 0 { + return menubarBuiltData{}, errMenubarSnapshotTimeout + } + timer := time.NewTimer(remaining) + select { + case <-build.done: + timer.Stop() + if h.menubarGenerationChanged(build.generation) { + continue + } + if build.err != nil { + return menubarBuiltData{}, build.err + } + if build.data.Snapshot == nil { + return menubarBuiltData{}, fmt.Errorf("menubar snapshot build returned no data") + } + return build.data, nil + case <-timer.C: + return menubarBuiltData{}, errMenubarSnapshotTimeout + case <-ctx.Done(): + timer.Stop() + return menubarBuiltData{}, errMenubarSnapshotTimeout + } + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return cached, nil + } + timer := time.NewTimer(remaining) + select { + case <-build.done: + timer.Stop() + if h.menubarGenerationChanged(build.generation) { + continue + } + if build.err == nil && build.data.Snapshot != nil { + return build.data, nil + } + // The build failed (for example the settings read is broken): + // serve the last good data instead of a 500. + return cached, nil + case <-timer.C: + // Still building. Serve what we have; the build finishes in the + // background and updates the cache when it does. + return cached, nil + case <-ctx.Done(): + timer.Stop() + return cached, nil + } + } +} + +// menubarSnapshotForRequest is the summary/tray-title accessor. +func (h *Handler) menubarSnapshotForRequest(ctx context.Context) (*menubar.Snapshot, error) { + data, err := h.menubarDataForRequest(ctx) + if err != nil { + return nil, err + } + return data.Snapshot, nil +} + +// menubarGenerationChanged reports whether the snapshot generation moved past +// the given build's generation (a settings write invalidated its result). +func (h *Handler) menubarGenerationChanged(generation uint64) bool { + h.menubarSnapshotMu.Lock() + defer h.menubarSnapshotMu.Unlock() + return h.menubarSnapshotGen != generation +} + +// startMenubarSnapshotBuildLocked starts a build. The caller must hold +// menubarSnapshotMu. The builder runs in its own goroutine on a background +// context, so a cancelled HTTP request cannot kill a build other requests are +// waiting on. menubarBuildMu serialises builds, keeping at most one set of +// snapshot queries in flight at a time. +// +// A build that is already superseded when it reaches menubarBuildMu skips its +// queries entirely: its generation no longer matches the settings it would +// read. Every waiter on it notices the generation change and joins or starts +// the current build, so a burst of invalidations produces at most one extra +// build rather than one per invalidation. +func (h *Handler) startMenubarSnapshotBuildLocked(generation uint64) *menubarSnapshotBuild { + build := &menubarSnapshotBuild{done: make(chan struct{}), generation: generation} + h.menubarSnapshotBuild = build + + go func() { + var ( + built menubarBuiltData + err error + ) + h.menubarBuildMu.Lock() + if h.menubarCurrentGeneration() == build.generation { + if h.menubarBuildHook != nil { + built, err = h.menubarBuildHook(context.Background()) + } else { + built, err = h.buildMenubarSnapshotWithAllProviders() + } + } + h.menubarBuildMu.Unlock() + + h.menubarSnapshotMu.Lock() + if h.menubarSnapshotBuild == build { + h.menubarSnapshotBuild = nil + } + // Only a build whose generation still matches may be stored as fresh. + // A superseded build may have run its queries against the old settings + // (a just-disabled provider, an old theme); storing it with a fresh + // timestamp would serve that state for the whole freshness window. + // Its result is dropped rather than kept as a fallback, because the + // previously cached data is already the better fallback. + if err == nil && built.Snapshot != nil && h.menubarSnapshotGen == build.generation { + h.menubarSnapshotData = built + h.menubarSnapshotAt = h.menubarNowTime() + } + h.menubarSnapshotMu.Unlock() + + build.data = built + build.err = err + if h.menubarAfterBuildHook != nil { + h.menubarAfterBuildHook(built.Snapshot, err) + } + close(build.done) + }() + + return build +} + +// menubarCurrentGeneration reads the settings generation under the snapshot +// mutex. +func (h *Handler) menubarCurrentGeneration() uint64 { + h.menubarSnapshotMu.Lock() + defer h.menubarSnapshotMu.Unlock() + return h.menubarSnapshotGen +} + +// recordMenubarLastSettings keeps the last settings read that succeeded, so a +// later settings read failure can still build a snapshot (gap A) instead of +// answering 500 while a usable configuration is in memory. +func (h *Handler) recordMenubarLastSettings(settings *menubar.Settings) { + if settings == nil { + return + } + normalized := settings.Normalize() + h.menubarLastSettingsMu.Lock() + h.menubarLastSettings = normalized + h.menubarLastSettingsMu.Unlock() +} + +func (h *Handler) menubarLastSettingsSnapshot() *menubar.Settings { + h.menubarLastSettingsMu.Lock() + defer h.menubarLastSettingsMu.Unlock() + return h.menubarLastSettings +} + +// menubarSettingsWithFallback returns current settings, falling back to the +// last successfully read settings and then defaults. It never fails, so the +// tray-title handler can always format a title. +func (h *Handler) menubarSettingsWithFallback() *menubar.Settings { + settings, err := h.menubarSettings() + if err == nil { + h.recordMenubarLastSettings(settings) + return settings + } + if last := h.menubarLastSettingsSnapshot(); last != nil { + return last + } + return menubar.DefaultSettings() +} diff --git a/internal/web/menubar_cache_test.go b/internal/web/menubar_cache_test.go new file mode 100644 index 00000000..6677b0a1 --- /dev/null +++ b/internal/web/menubar_cache_test.go @@ -0,0 +1,1178 @@ +package web + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/menubar" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// newCacheTestHandler returns a handler backed by a shared-cache in-memory +// database (so a test can break the settings table under it) with a short +// freshness window and wait budget. +func newCacheTestHandler(t *testing.T, cfg *config.Config) (*Handler, *store.Store, string) { + t.Helper() + if cfg == nil { + cfg = createTestConfigWithCursor() + } + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + h.menubarFreshFor = time.Millisecond + h.menubarWaitFor = 250 * time.Millisecond + return h, s, dsn +} + +func waitNotNil(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the build to start") + } +} + +func waitClosed(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the build to finish") + } +} + +func decodeSummary(t *testing.T, rr *httptest.ResponseRecorder) menubar.Snapshot { + t.Helper() + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) + } + var snap menubar.Snapshot + if err := json.Unmarshal(rr.Body.Bytes(), &snap); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + return snap +} + +func getSummary(h *Handler) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + h.MenubarSummary(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil)) + return rr +} + +// Concurrent requests must share one build. A gate holds the build open while +// every request queues on it; the build counter must stay at one. +func TestMenubarCacheConcurrentRequestsTriggerExactlyOneBuild(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + h.menubarFreshFor = -time.Second + h.menubarWaitFor = 30 * time.Second + + gate := make(chan struct{}) + buildStarted := make(chan struct{}, 1) + var builds int32 + var joined int32 + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + atomic.AddInt32(&builds, 1) + buildStarted <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + h.menubarJoinHook = func() { atomic.AddInt32(&joined, 1) } + + const requests = 8 + var wg sync.WaitGroup + codes := make(chan int, requests) + for i := 0; i < requests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rr := httptest.NewRecorder() + h.MenubarSummary(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil)) + codes <- rr.Code + }() + } + + // Let the first request start the build, then wait until every other + // request has joined it before releasing. + waitNotNil(t, buildStarted) + deadline := time.Now().Add(5 * time.Second) + for atomic.LoadInt32(&joined) < requests-1 { + if time.Now().After(deadline) { + t.Fatalf("only %d requests joined the in-flight build, want %d", atomic.LoadInt32(&joined), requests-1) + } + time.Sleep(time.Millisecond) + } + close(gate) + wg.Wait() + close(codes) + + if got := atomic.LoadInt32(&builds); got != 1 { + t.Fatalf("builds = %d, want exactly 1", got) + } + for code := range codes { + if code != http.StatusOK { + t.Fatalf("request status = %d, want 200", code) + } + } +} + +// A build slower than the wait budget must return the cached snapshot within +// the budget, with its original generated_at. When the build finishes the +// cache updates and the next request sees the new generated_at. +func TestMenubarCacheSlowBuildServesCachedThenUpdates(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + cached := decodeSummary(t, getSummary(h)) + + gate := make(chan struct{}) + buildStarted := make(chan struct{}, 1) + backgroundDone := make(chan struct{}, 1) + var builds int32 + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + atomic.AddInt32(&builds, 1) + buildStarted <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + h.menubarAfterBuildHook = func(*menubar.Snapshot, error) { backgroundDone <- struct{}{} } + + time.Sleep(2 * time.Millisecond) // past the 1ms freshness window + + start := time.Now() + stale := decodeSummary(t, getSummary(h)) + elapsed := time.Since(start) + + if elapsed > 2*time.Second { + t.Fatalf("stale request took %s, want about the wait budget", elapsed) + } + if !stale.GeneratedAt.Equal(cached.GeneratedAt) { + t.Fatalf("cached response generated_at = %s, want the original %s", stale.GeneratedAt, cached.GeneratedAt) + } + if !stale.LatestAt.Equal(cached.LatestAt) { + t.Fatalf("cached response latest_at = %s, want the original %s", stale.LatestAt, cached.LatestAt) + } + if got := atomic.LoadInt32(&builds); got != 1 { + t.Fatalf("builds = %d, want 1", got) + } + + // Release the background build and wait for it to update the cache. + waitNotNil(t, buildStarted) + close(gate) + waitClosed(t, backgroundDone) + + time.Sleep(2 * time.Millisecond) + fresh := decodeSummary(t, getSummary(h)) + if !fresh.GeneratedAt.After(cached.GeneratedAt) { + t.Fatalf("post-build snapshot generated_at = %s, want newer than %s", fresh.GeneratedAt, cached.GeneratedAt) + } +} + +// Inside the freshness window a summary request performs no build at all. +func TestMenubarCacheFreshWindowSkipsBuild(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = time.Minute + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + cached := decodeSummary(t, getSummary(h)) + + var builds int32 + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + atomic.AddInt32(&builds, 1) + return h.buildMenubarSnapshotWithAllProviders() + } + + again := decodeSummary(t, getSummary(h)) + if got := atomic.LoadInt32(&builds); got != 0 { + t.Fatalf("builds = %d, want 0 inside the fresh window", got) + } + if !again.GeneratedAt.Equal(cached.GeneratedAt) { + t.Fatalf("fresh-window response generated_at = %s, want %s", again.GeneratedAt, cached.GeneratedAt) + } +} + +// A settings save must invalidate the cache so the next request reflects it. +func TestMenubarCacheSettingsChangeInvalidates(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = time.Minute + now := time.Now().UTC() + reset := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent, ResetsAt: &reset}, + }) + + before := decodeSummary(t, getSummary(h)) + if len(before.Providers) != 1 { + t.Fatalf("providers before = %d, want 1", len(before.Providers)) + } + + settings := menubar.DefaultSettings() + settings.VisibleProviders = []string{"some_other_provider"} + if err := h.saveMenubarSettings(settings); err != nil { + t.Fatalf("saveMenubarSettings: %v", err) + } + + after := decodeSummary(t, getSummary(h)) + if len(after.Providers) != 0 { + t.Fatalf("providers after visibility save = %d, want 0 (cache was not invalidated): %+v", len(after.Providers), after.Providers) + } +} + +// With no cached snapshot the first request must wait for the build. +func TestMenubarCacheFirstRequestWaits(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarWaitFor = 500 * time.Millisecond + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gate := make(chan struct{}) + started := make(chan struct{}, 1) + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + rr := httptest.NewRecorder() + h.MenubarSummary(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil)) + done <- rr + }() + + waitNotNil(t, started) + select { + case rr := <-done: + t.Fatalf("first request returned before the build completed: status %d", rr.Code) + case <-time.After(100 * time.Millisecond): + } + + close(gate) + select { + case rr := <-done: + snap := decodeSummary(t, rr) + if len(snap.Providers) != 1 { + t.Fatalf("providers = %d, want 1", len(snap.Providers)) + } + case <-time.After(5 * time.Second): + t.Fatal("first request never returned after the build completed") + } +} + +// Gap A: a settings read failure after a good snapshot must serve the cached +// snapshot, not 500. +func TestMenubarCacheSettingsReadFailureServesCached(t *testing.T) { + h, s, dsn := newCacheTestHandler(t, nil) + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + first := decodeSummary(t, getSummary(h)) + + breakProviderStorage(t, dsn, "settings") + + // The cache may answer directly, or the build may succeed from the last + // good settings. Either way the request is served and never 500. + snap := decodeSummary(t, getSummary(h)) + if snap.GeneratedAt.Before(first.GeneratedAt) { + t.Fatalf("generated_at went backwards: %s, want at least %s", snap.GeneratedAt, first.GeneratedAt) + } + if len(snap.Providers) != 1 { + t.Fatalf("providers = %d, want 1", len(snap.Providers)) + } + + // Even after the cache is invalidated, the last good settings are still in + // memory, so the handler builds from them rather than answering 500. + h.invalidateMenubarSnapshot() + rr := getSummary(h) + if rr.Code != http.StatusOK { + t.Fatalf("status after cache invalidation with fallback settings = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } +} + +// Finding 1: the preferences endpoint derives its options from the shared +// build. A preferences request concurrent with a summary build must join it, +// not start a second set of queries. +func TestMenubarPreferencesJoinsSnapshotBuild(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = -time.Second + h.menubarWaitFor = 30 * time.Second + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gate := make(chan struct{}) + started := make(chan struct{}, 1) + var providerBuilds int32 + var joined int32 + h.menubarProvidersBuildHook = func() { atomic.AddInt32(&providerBuilds, 1) } + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + h.menubarJoinHook = func() { atomic.AddInt32(&joined, 1) } + + doneSummary := make(chan int, 1) + go func() { + rr := getSummary(h) + doneSummary <- rr.Code + }() + waitNotNil(t, started) + + donePrefs := make(chan *httptest.ResponseRecorder, 1) + go func() { + rr := httptest.NewRecorder() + h.MenubarPreferences(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil)) + donePrefs <- rr + }() + + // The preferences request must join the in-flight build rather than + // starting its own. + deadline := time.Now().Add(5 * time.Second) + for atomic.LoadInt32(&joined) < 1 { + if time.Now().After(deadline) { + t.Fatalf("preferences did not join the summary build; provider builds=%d", atomic.LoadInt32(&providerBuilds)) + } + time.Sleep(time.Millisecond) + } + if got := atomic.LoadInt32(&providerBuilds); got != 0 { + t.Fatalf("provider builds while the summary build is blocked = %d, want 0 (preferences must not build)", got) + } + + close(gate) + select { + case code := <-doneSummary: + if code != http.StatusOK { + t.Fatalf("summary status = %d", code) + } + case <-time.After(5 * time.Second): + t.Fatal("summary never returned") + } + select { + case rr := <-donePrefs: + if rr.Code != http.StatusOK { + t.Fatalf("preferences status = %d body=%s", rr.Code, rr.Body.String()) + } + var body struct { + Providers []struct { + ID string `json:"id"` + } `json:"providers"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if len(body.Providers) != 1 || body.Providers[0].ID != "cursor" { + t.Fatalf("preferences providers = %+v, want the cursor card from the shared build", body.Providers) + } + case <-time.After(5 * time.Second): + t.Fatal("preferences never returned") + } + + if got := atomic.LoadInt32(&providerBuilds); got != 1 { + t.Fatalf("provider builds = %d, want exactly 1 (the shared build, never a preferences build)", got) + } +} + +// Finding 1: preferences must not trigger another provider build even when the +// cache is fresh, and hidden providers must still be selectable. +func TestMenubarPreferencesUsesCachedOptionsWithoutBuilding(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = time.Minute + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + var providerBuilds int32 + h.menubarProvidersBuildHook = func() { atomic.AddInt32(&providerBuilds, 1) } + // Seed the cache with a first build (the counter counts from here). + if rr := getSummary(h); rr.Code != http.StatusOK { + t.Fatalf("summary status = %d", rr.Code) + } + + rr := httptest.NewRecorder() + h.MenubarPreferences(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("preferences status = %d body=%s", rr.Code, rr.Body.String()) + } + if got := atomic.LoadInt32(&providerBuilds); got != 1 { + t.Fatalf("provider builds = %d, want 1 total (preferences reused the cached build)", got) + } +} + +// Finding 5a: a build whose queries already ran when it is superseded must +// never be stored as fresh; waiters end on a build for the current generation. +// The hook tags each result with the generation its build started with, so the +// cache contents reveal which build won. +func TestMenubarCacheSupersededBuildNeverReachesCache(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = -time.Second + h.menubarWaitFor = 5 * time.Second + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gateA := make(chan struct{}) + gateC := make(chan struct{}) + started := make(chan struct{}, 4) + completed := make(chan struct{}, 4) + h.menubarAfterBuildHook = func(*menubar.Snapshot, error) { completed <- struct{}{} } + var runs int32 + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + n := atomic.AddInt32(&runs, 1) + // The hook only runs when the build's generation still matched the + // current one, so this is the build's own generation. + buildGeneration := h.menubarCurrentGeneration() + started <- struct{}{} + switch n { + case 1: + <-gateA + case 2: + <-gateC + } + built, err := h.buildMenubarSnapshotWithAllProviders() + if err != nil { + return built, err + } + // Encode the generation this build reads, never "whatever is current + // now": the superseded build must be identifiable after the fact. + built.Snapshot.LatestAt = time.Date(2000, 1, 1, 0, int(buildGeneration), 0, 0, time.UTC) + return built, nil + } + + doneA := make(chan *httptest.ResponseRecorder, 1) + go func() { doneA <- getSummary(h) }() + waitNotNil(t, started) + + // Generation one: a queued build that must be superseded before it runs. + h.invalidateMenubarSnapshot() + generationOne := h.menubarCurrentGeneration() + doneB := make(chan *httptest.ResponseRecorder, 1) + go func() { doneB <- getSummary(h) }() + h.waitForRegisteredBuild(t, generationOne) + + // Generation two (final): the current build. Generation one's build is + // already queued on the mutex and must skip its queries when it gets there. + h.invalidateMenubarSnapshot() + finalGeneration := h.menubarCurrentGeneration() + doneC := make(chan *httptest.ResponseRecorder, 1) + go func() { doneC <- getSummary(h) }() + h.waitForRegisteredBuild(t, finalGeneration) + + // Release the first build. Its queries have already run by the time it is + // superseded: its result must not be stored as fresh. + close(gateA) + + // A completes, then the generation-one build (run 2, skipped) completes, + // then generation three enters the hook and blocks on gateC. + waitClosed(t, completed) + waitClosed(t, completed) + waitNotNil(t, started) // run 3 (the current generation) is now inside + + cached, cachedAt, _, _ := h.menubarCachedData() + if cached.Snapshot != nil || !cachedAt.IsZero() { + t.Fatalf("a superseded build was stored as fresh (generated_at=%v, has data=%v); it must be dropped", + cachedAt, cached.Snapshot != nil) + } + + close(gateC) + for _, done := range []chan *httptest.ResponseRecorder{doneA, doneB, doneC} { + select { + case rr := <-done: + if rr.Code != http.StatusOK { + t.Fatalf("waiter status = %d body=%s", rr.Code, rr.Body.String()) + } + case <-time.After(5 * time.Second): + t.Fatal("waiter never completed") + } + } + + // Only the blocked build plus the current generation ran queries; the + // superseded queued build skipped them. + if got := atomic.LoadInt32(&runs); got != 2 { + t.Fatalf("build hook runs = %d, want 2 (the queued superseded build must skip)", got) + } + if got := h.mustCachedMarker(t); uint64(got) != finalGeneration { + t.Fatalf("cached marker = %d, want generation %d (the superseded build reached the cache)", got, finalGeneration) + } + if _, cachedAt, _, _ := h.menubarCachedData(); cachedAt.IsZero() { + t.Fatal("the current generation's result was not stored as fresh") + } +} + +// mustCachedMarker reads the minute field of the cached snapshot's LatestAt, +// which the finding-5 tests use as a build sequence number. +func (h *Handler) mustCachedMarker(t *testing.T) int { + t.Helper() + cached, _, _, _ := h.menubarCachedData() + if cached.Snapshot == nil { + t.Fatal("cache is empty") + } + return cached.Snapshot.LatestAt.Minute() +} + +// waitForRegisteredBuild blocks until a build for the given generation is the +// registered in-flight build, so a test can invalidate it while it is queued. +func (h *Handler) waitForRegisteredBuild(t *testing.T, generation uint64) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + _, _, _, inflight := h.menubarCachedData() + if inflight != nil && inflight.generation == generation { + return + } + if time.Now().After(deadline) { + t.Fatalf("no build registered for generation %d", generation) + } + time.Sleep(time.Millisecond) + } +} + +// Finding 5b: a burst of invalidations during a slow build causes at most one +// extra build. The superseded goroutine must skip its queries, so the hook +// runs at most twice regardless of how many invalidations happened. +func TestMenubarCacheInvalidationBurstCausesOneExtraBuild(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = -time.Second + h.menubarWaitFor = 5 * time.Second + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gate := make(chan struct{}) + started := make(chan struct{}, 8) + var builds int32 + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + n := atomic.AddInt32(&builds, 1) + started <- struct{}{} + if n == 1 { + <-gate + } + return h.buildMenubarSnapshotWithAllProviders() + } + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- getSummary(h) }() + waitNotNil(t, started) + + // Several invalidations while the first build is stuck. Each one starts a + // request and is followed until that request's build is registered, so + // every superseded generation really queues behind the stuck build. + for i := 0; i < 5; i++ { + h.invalidateMenubarSnapshot() + go func() { + _ = getSummary(h) + }() + generation := h.menubarCurrentGeneration() + deadline := time.Now().Add(5 * time.Second) + for { + _, _, _, inflight := h.menubarCachedData() + if inflight != nil && inflight.generation == generation { + break + } + if time.Now().After(deadline) { + t.Fatalf("no build registered for generation %d", generation) + } + time.Sleep(time.Millisecond) + } + } + + time.Sleep(20 * time.Millisecond) + close(gate) + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("first waiter never completed") + } + + // Give any stray goroutine a moment to reach the hook, then bound it. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cached, _, _, inflight := h.menubarCachedData(); cached.Snapshot != nil && inflight == nil { + break + } + time.Sleep(time.Millisecond) + } + if got := atomic.LoadInt32(&builds); got > 2 { + t.Fatalf("builds = %d, want at most 2 for a burst of invalidations", got) + } +} + +// Gap A: a failure to persist the discovery history must not hide settings +// that were read successfully. +func TestMenubarReconcileWriteFailureKeepsReadSettings(t *testing.T) { + h, _, dsn := newCacheTestHandler(t, nil) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + // Settings remain readable; every write aborts. The cursor provider is + // configured but not in KnownProviders, so reconcile wants to write. + if _, err := db.Exec(`CREATE TRIGGER reject_settings_write BEFORE INSERT ON settings + BEGIN SELECT RAISE(ABORT, 'read only'); END;`); err != nil { + t.Fatalf("create trigger: %v", err) + } + + snap, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot with a failed settings write: %v", err) + } + if snap == nil { + t.Fatal("BuildMenubarSnapshot returned nil snapshot") + } +} + +// Gap A: 500 only when there is neither a cached snapshot nor any settings +// that were read successfully. +func TestMenubarSettingsFailureWithoutAnyFallbackReturns500(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + rr := getSummary(h) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 when no cache and no last good settings exist (body=%s)", rr.Code, rr.Body.String()) + } + + // Once settings have been read successfully, the same failure builds from + // memory instead of 500. + h.recordMenubarLastSettings(menubar.DefaultSettings()) + rr = getSummary(h) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 with last good settings in memory (body=%s)", rr.Code, rr.Body.String()) + } +} + +// Gap A: the tray title must still be served when settings cannot be read. +func TestMenubarTrayTitleSettingsReadFailureServesCached(t *testing.T) { + h, s, dsn := newCacheTestHandler(t, nil) + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + rr := httptest.NewRecorder() + h.MenubarTrayTitle(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/tray-title", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("first tray-title status = %d, body=%s", rr.Code, rr.Body.String()) + } + + breakProviderStorage(t, dsn, "settings") + + rr = httptest.NewRecorder() + h.MenubarTrayTitle(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/tray-title", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("tray-title status after settings failure = %d, want 200", rr.Code) + } +} + +// Finding 2: with no cache, a build that never finishes must not pin the +// request. The handler answers 503 within the no-cache budget so the client +// keeps its last data. +func TestMenubarCacheNoCacheWaitIsBounded(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarNoCacheFor = 50 * time.Millisecond + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gate := make(chan struct{}) + started := make(chan struct{}, 1) + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- getSummary(h) }() + waitNotNil(t, started) + + select { + case rr := <-done: + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 when the first build exceeds the no-cache budget", rr.Code) + } + case <-time.After(2 * time.Second): + t.Fatal("no-cache request waited past its budget") + } + close(gate) +} + +// Finding 2: a manual refresh marks the cache stale but keeps it as the +// fallback. A refresh against a stalled build answers with the last data +// instead of blanking. +func TestMenubarRefreshFallsBackToCachedSnapshot(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = time.Minute + h.menubarWaitFor = 50 * time.Millisecond + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + // Seed the cache; the refresh below must fall back to it while the + // rebuild is stalled. + if rr := getSummary(h); rr.Code != http.StatusOK { + t.Fatalf("seed summary status = %d", rr.Code) + } + + gate := make(chan struct{}) + started := make(chan struct{}, 1) + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + + refreshed := make(chan *httptest.ResponseRecorder, 1) + go func() { + rr := httptest.NewRecorder() + h.MenubarRefresh(rr, httptest.NewRequest(http.MethodPost, "/api/menubar/refresh", nil)) + refreshed <- rr + }() + waitNotNil(t, started) + + select { + case rr := <-refreshed: + if rr.Code != http.StatusOK { + t.Fatalf("refresh status = %d, want 200 with cached fallback", rr.Code) + } + case <-time.After(2 * time.Second): + t.Fatal("refresh waited past its budget instead of using the cached snapshot") + } + + close(gate) +} + +// Finding 3: the provider-settings save must invalidate only after the live +// config reflects the new values. A build that starts in between would carry +// the new generation but read the old state. +func TestProviderSettingsSaveInvalidatesAfterConfigApplied(t *testing.T) { + h, _, _ := newCacheTestHandler(t, &config.Config{}) + // An agent that observes config at Start time, like a real factory would. + observed := &configObserverController{cfg: h.config, handler: h} + h.SetAgentManager(observed) + + body := strings.NewReader(`{"provider_settings":{"opencode":{"api_key":"oc_sk_after_save"}}}`) + req := httptest.NewRequest(http.MethodPut, "/api/settings", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.UpdateSettings(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("settings status = %d body=%s", rr.Code, rr.Body.String()) + } + if observed.keyAtStart != "oc_sk_after_save" { + t.Fatalf("agent saw key %q at start, want the saved key", observed.keyAtStart) + } + // The generation must still be the pre-save one while the agent starts. + // If it had already advanced, a concurrent build would carry the new + // generation but read the old provider state, then cache it for the whole + // freshness window. + if observed.generationAtStart != 0 { + t.Fatalf("generation at agent start = %d, want 0 (invalidation must follow the config apply)", observed.generationAtStart) + } + if got := h.menubarCurrentGeneration(); got != observed.generationAtStart+1 { + t.Fatalf("generation after the save = %d, want exactly one invalidation", got) + } +} + +// configObserverController records the live config and menubar generation as +// seen by an agent Start, which is where a real factory would read both. +type configObserverController struct { + cfg *config.Config + handler *Handler + keyAtStart string + generationAtStart uint64 +} + +func (c *configObserverController) Start(key string) error { + c.cfg.ReadLock() + c.keyAtStart = c.cfg.OpenCodeGoAPIKey + c.cfg.ReadUnlock() + c.generationAtStart = c.handler.menubarCurrentGeneration() + return nil +} + +func (c *configObserverController) Stop(string) {} +func (c *configObserverController) IsRunning(string) bool { return false } + +// Finding 2: a preferences PUT must respond with the settings it just saved, +// even when the post-save rebuild exceeds the wait budget and the cached data +// still carries the pre-save settings. The popover persists the response into +// state.preferences and localStorage, so a stale response would revert the +// user's change. +func TestMenubarPreferencesPutReturnsSavedSettingsDuringSlowRebuild(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarFreshFor = -time.Second + h.menubarWaitFor = 50 * time.Millisecond + now := time.Now().UTC() + reset := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent, ResetsAt: &reset}, + }) + + // Seed the cache with the default theme. + if rr := getSummary(h); rr.Code != http.StatusOK { + t.Fatalf("seed summary status = %d", rr.Code) + } + + // Post-save builds stall past the budget. + gate := make(chan struct{}) + started := make(chan struct{}, 4) + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + + body := strings.NewReader(`{"enabled":true,"default_view":"detailed","refresh_seconds":60,"theme":"dark"}`) + rr := httptest.NewRecorder() + h.MenubarPreferences(rr, httptest.NewRequest(http.MethodPut, "/api/menubar/preferences", body)) + if rr.Code != http.StatusOK { + t.Fatalf("PUT status = %d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + Theme string `json:"theme"` + DefaultView string `json:"default_view"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if response.Theme != "dark" { + t.Fatalf("PUT response theme = %q, want the saved %q", response.Theme, "dark") + } + if response.DefaultView != "detailed" { + t.Fatalf("PUT response default_view = %q, want the saved %q", response.DefaultView, "detailed") + } + close(gate) +} + +// Finding 3: a cold start whose first build exceeds the no-cache budget must +// answer the preferences GET with 503, not 200 with a null provider list that +// makes the popover empty its options. +func TestMenubarPreferencesColdStartTimeoutIs503(t *testing.T) { + h, s, _ := newCacheTestHandler(t, nil) + h.menubarNoCacheFor = 50 * time.Millisecond + insertTestCursorSnapshot(t, s, time.Now().UTC().Add(-5*time.Minute), api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + gate := make(chan struct{}) + started := make(chan struct{}, 1) + h.menubarBuildHook = func(ctx context.Context) (menubarBuiltData, error) { + started <- struct{}{} + <-gate + return h.buildMenubarSnapshotWithAllProviders() + } + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + rr := httptest.NewRecorder() + h.MenubarPreferences(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil)) + done <- rr + }() + waitNotNil(t, started) + + select { + case rr := <-done: + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("preferences cold-start status = %d, want 503 (body=%s)", rr.Code, rr.Body.String()) + } + case <-time.After(2 * time.Second): + t.Fatal("preferences request waited past its budget") + } + close(gate) +} + +// Gap B: a stored row that normalizes to no card must not drop the last good +// card. +func TestMenubarBuildCardNilServesLastGoodCard(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + capturedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("first build: %v", err) + } + + // hasData true, but the payload normalizes to nothing. + card, captured := h.menubarResolveCard("cursor", "Cursor", "", map[string]interface{}{ + "capturedAt": time.Now().UTC().Format(time.RFC3339), + "quotas": []interface{}{}, + }, true, true, 70, 90, nil) + if card == nil { + t.Fatal("card was dropped instead of serving the last good card") + } + if card.ID != "cursor" { + t.Fatalf("card id = %q, want cursor", card.ID) + } + if !captured.Equal(capturedAt) { + t.Fatalf("captured = %s, want the cached %s", captured, capturedAt) + } +} + +// The public dashboard builders must keep main's behaviour: Anthropic's empty +// quota list serialises as [], and Grok/Kimi still use the 7-day fallback when +// the latest read fails. +func TestDashboardBuildersMatchMainBehaviour(t *testing.T) { + t.Run("anthropic empty quotas is an array", func(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + h := NewHandler(s, nil, nil, nil, createTestConfigWithAnthropic()) + + // The per-quota merge returns rows; make it return a row that the + // quotas loop does not append by breaking the fallback instead: an + // empty store yields the per-quota table is empty and the fallback + // finds no latest snapshot. + response, readOK, _ := h.buildAnthropicCurrentOK() + if !readOK { + t.Fatalf("readOK = false, want true (fallback succeeded with no rows)") + } + data, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), `"quotas":[]`) { + t.Fatalf("anthropic quotas serialised as something other than []: %s", data) + } + }) + + t.Run("anthropic fallback quotas is an array", func(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + h := NewHandler(s, nil, nil, nil, createTestConfigWithAnthropic()) + + response, readOK, hasData := h.buildAnthropicCurrentFallbackOK(map[string]interface{}{ + "capturedAt": time.Now().UTC().Format(time.RFC3339), + "quotas": []interface{}{}, + }) + if !readOK || hasData { + t.Fatalf("fallback with no snapshot = readOK %v hasData %v, want true/false", readOK, hasData) + } + data, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), `"quotas":[]`) { + t.Fatalf("anthropic fallback quotas serialised as something other than []: %s", data) + } + }) + + t.Run("grok falls back after latest read failure", func(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + AccountID: store.DefaultGrokAccountID, + CapturedAt: time.Now().UTC().Add(-30 * time.Minute), + Quotas: []api.GrokQuota{{Name: "credits", Utilization: 42, Status: "ok"}}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot: %v", err) + } + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + response, readOK, hasData := h.buildGrokCurrentOK() + if !readOK || !hasData { + t.Fatalf("buildGrokCurrentOK = readOK %v hasData %v, want both true", readOK, hasData) + } + if got := response["capturedAt"]; got == nil { + t.Fatal("capturedAt missing from the grok dashboard payload") + } + }) + + // Main's dashboard builders stamped capturedAt with the build time. The + // stored snapshot time is reserved for the menubar age label. + t.Run("kimi dashboard capturedAt is the build time", func(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + stored := time.Now().UTC().Add(-90 * time.Minute).Truncate(time.Second) + if _, err := s.InsertKimiSnapshot(&api.KimiSnapshot{ + AccountID: store.DefaultKimiAccountID, + CapturedAt: stored, + Quotas: []api.KimiQuota{{Name: "weekly", Utilization: 42, Status: "ok"}}, + }); err != nil { + t.Fatalf("InsertKimiSnapshot: %v", err) + } + h := NewHandler(s, nil, nil, nil, &config.Config{PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"}) + + response := h.buildKimiCurrent() + raw, _ := response["capturedAt"].(string) + built, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("kimi capturedAt %q: %v", raw, err) + } + if built.Equal(stored) || time.Since(built) > time.Minute { + t.Fatalf("dashboard capturedAt = %s, want the build time (main behaviour)", built) + } + + // The menubar builder keeps the stored time for the age label. + payload, readOK, hasData := h.buildKimiCurrentOK() + if !readOK || !hasData { + t.Fatalf("buildKimiCurrentOK = readOK %v hasData %v, want both true", readOK, hasData) + } + if payload["capturedAt"] != stored.Format(time.RFC3339) { + t.Fatalf("menubar capturedAt = %v, want the stored %q", payload["capturedAt"], stored.Format(time.RFC3339)) + } + }) + + t.Run("grok dashboard capturedAt is the build time", func(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + stored := time.Now().UTC().Add(-90 * time.Minute).Truncate(time.Second) + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + AccountID: store.DefaultGrokAccountID, + CapturedAt: stored, + Quotas: []api.GrokQuota{{Name: "credits", Utilization: 42, Status: "ok"}}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot: %v", err) + } + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + response := h.buildGrokCurrent() + raw, _ := response["capturedAt"].(string) + built, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("grok capturedAt %q: %v", raw, err) + } + if built.Equal(stored) || time.Since(built) > time.Minute { + t.Fatalf("dashboard capturedAt = %s, want the build time (main behaviour)", built) + } + }) +} + +// TestLatestGrokSnapshotFallsBackWhenLatestReadFails proves the fallback path: +// the range read must supply an older row carrying quotas when the latest row +// has none. +func TestLatestGrokSnapshotFallsBackWhenLatestReadFails(t *testing.T) { + dsn := "file:menubar-cache-grok-fallback-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared" + s, err := store.New(dsn) + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + AccountID: store.DefaultGrokAccountID, + CapturedAt: time.Now().UTC().Add(-30 * time.Minute), + Quotas: []api.GrokQuota{{Name: "credits", Utilization: 42, Status: "ok"}}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot: %v", err) + } + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + + // Make the ORDER BY ... LIMIT 1 read fail while the range read on the same + // table still succeeds: a view cannot distinguish them, but a trigger on + // SELECT is not available in SQLite. Drop the table and recreate it with + // the same rows, then install a schema change mid-flight is racy. Instead + // prove the fallback path with the range read by emptying the quota values + // of only the newest row: latest is non-nil but has no quotas, so the + // fallback supplies an older row with quotas. + if _, err := db.Exec(`INSERT INTO grok_snapshots (captured_at, account_id, raw_json, quota_count) + VALUES (?, ?, '', 0)`, time.Now().UTC().Format(time.RFC3339Nano), store.DefaultGrokAccountID); err != nil { + t.Fatalf("insert empty newest snapshot: %v", err) + } + + latest, readOK, hasData := h.latestGrokSnapshotOK() + if !readOK { + t.Fatalf("readOK = false, want true (both reads succeeded)") + } + if !hasData || latest == nil { + t.Fatal("fallback snapshot was not returned") + } + if len(latest.Quotas) == 0 || latest.Quotas[0].Utilization != 42 { + t.Fatalf("fallback quotas = %+v, want the older 42%% row", latest.Quotas) + } +} + +func TestLatestKimiSnapshotFallsBackWhenLatestReadFails(t *testing.T) { + dsn := "file:menubar-cache-kimi-fallback-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared" + s, err := store.New(dsn) + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + if _, err := s.InsertKimiSnapshot(&api.KimiSnapshot{ + AccountID: store.DefaultKimiAccountID, + CapturedAt: time.Now().UTC().Add(-30 * time.Minute), + Quotas: []api.KimiQuota{{Name: "weekly", Utilization: 42, Status: "ok"}}, + }); err != nil { + t.Fatalf("InsertKimiSnapshot: %v", err) + } + h := NewHandler(s, nil, nil, nil, &config.Config{PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"}) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + + if _, err := db.Exec(`INSERT INTO kimi_snapshots (captured_at, account_id, raw_json, quota_count) + VALUES (?, ?, '', 0)`, time.Now().UTC().Format(time.RFC3339Nano), store.DefaultKimiAccountID); err != nil { + t.Fatalf("insert empty newest snapshot: %v", err) + } + + latest, readOK, hasData := h.latestKimiSnapshotOK() + if !readOK { + t.Fatalf("readOK = false, want true (both reads succeeded)") + } + if !hasData || latest == nil { + t.Fatal("fallback snapshot was not returned") + } + if len(latest.Quotas) == 0 || latest.Quotas[0].Utilization != 42 { + t.Fatalf("fallback quotas = %+v, want the older 42%% row", latest.Quotas) + } +} diff --git a/internal/web/menubar_hover_cache_test.go b/internal/web/menubar_hover_cache_test.go new file mode 100644 index 00000000..b614c8cd --- /dev/null +++ b/internal/web/menubar_hover_cache_test.go @@ -0,0 +1,325 @@ +package web + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +// readMenubarHTML loads the popover page and fails the test when it is missing. +func readMenubarHTML(t *testing.T) string { + t.Helper() + page, err := staticFS.ReadFile("static/menubar.html") + if err != nil { + t.Fatal(err) + } + return string(page) +} + +// menubarSlice returns the HTML between two anchors, failing when either is +// missing. The node-driven tests run the page's own JS, so the anchors must +// stay stable. +func menubarSlice(t *testing.T, html, start, end string) string { + t.Helper() + startIndex := strings.Index(html, start) + if startIndex < 0 { + t.Fatalf("menubar.html is missing %q", start) + } + endIndex := strings.Index(html[startIndex:], end) + if endIndex < 0 { + t.Fatalf("menubar.html is missing %q after %q", end, start) + } + return html[startIndex : startIndex+endIndex] +} + +// runMenubarNode executes the given JS with node, skipping when node is absent. +func runMenubarNode(t *testing.T, script string) { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node required for menubar hover/cache test") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node) + cmd.Stdin = strings.NewReader(script) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, output) + } +} + +// A card that needs the user to act (re-auth, reconnect, browser access) must +// not print an inline tag next to the provider name. The reason and the action +// live on the status dot's custom tooltip, and the dot carries a distinct +// attention state. The age label alone shows staleness, so a stale card gets +// no tag and no attention state. +func TestMenubarProviderAttentionDotAndTooltip(t *testing.T) { + html := readMenubarHTML(t) + + // The inline tags are gone from the source, including the stale one. + for _, banned := range []string{ + "Updates unavailable", + `quota-source-tag">Reconnect required`, + `quota-source-tag">Re-auth required`, + `quota-source-tag">Browser access required`, + } { + if strings.Contains(html, banned) { + t.Fatalf("menubar.html still renders the inline tag %q", banned) + } + } + + slice := menubarSlice(t, html, "function renderStandardCard(", "function renderStateCard(") + + script := ` +const assert = require('node:assert/strict'); +` + slice + ` +function escapeHTML(value) { return String(value == null ? "" : value); } +function providerIconClass() { return "provider-icon-codex"; } +function providerAgeLabel() { return "5m"; } +function clampPercent(value) { return Math.max(0, Math.min(100, Number(value || 0))); } +function providerQuotas(provider) { return Array.isArray(provider && provider.quotas) ? provider.quotas : []; } +function usageSummary() { return ""; } +function quotaStatusNote() { return ""; } +function fullQuotaLabel(quota) { return String(quota.label || ""); } +function compactResetLabel() { return "1h"; } +function ringColumns() { return 2; } +const mistralRecovery = { markup: () => "" }; + +const base = { + label: "Codex", + id: "codex", + base_provider: "codex", + status: "warning", + highest_percent: 42, + updated_at_time: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + quotas: [{ key: "weekly", label: "Weekly", percent: 42, status: "warning" }] +}; + +// Paused: attention dot, tooltip carries the reason and the action. +const paused = Object.assign({}, base, { connection_status: "paused" }); +const pausedHeader = standardHeaderMarkup(paused); +assert.ok(pausedHeader.includes("status-attention"), "paused card must use the attention dot"); +assert.ok(!pausedHeader.includes("quota-source-tag"), "paused card must not render an inline tag"); +assert.ok(pausedHeader.includes("Codex auth failed"), "paused Codex tooltip must name the fix"); +assert.ok(pausedHeader.includes("provider-status-tip"), "paused card must render the custom tooltip"); +assert.ok(pausedHeader.includes("aria-label="), "the dot must keep an aria-label"); + +// Reconnect. +const reconnect = Object.assign({}, base, { connection_status: "reconnect" }); +const reconnectHeader = standardHeaderMarkup(reconnect); +assert.ok(reconnectHeader.includes("status-attention")); +assert.ok(!reconnectHeader.includes("quota-source-tag")); +assert.ok(reconnectHeader.includes("Reconnect required")); + +// Browser access denied. +const denied = Object.assign({}, base, { connection_status: "warning", connection: { reason: "browser_access_denied" } }); +const deniedHeader = standardHeaderMarkup(denied); +assert.ok(deniedHeader.includes("status-attention")); +assert.ok(!deniedHeader.includes("quota-source-tag")); +assert.ok(deniedHeader.includes("Browser access required")); + +// Healthy: no attention state, still a dot. +const healthy = Object.assign({}, base, { status: "healthy", connection_status: "" }); +const healthyHeader = standardHeaderMarkup(healthy); +assert.ok(!healthyHeader.includes("status-attention")); +assert.ok(healthyHeader.includes("provider-status-dot")); + +// Stale: the age label already covers it. No tag, no attention state. +const stale = Object.assign({}, base, { connection_status: "stale" }); +const staleHeader = standardHeaderMarkup(stale); +assert.ok(!staleHeader.includes("status-attention")); +assert.ok(!staleHeader.includes("quota-source-tag")); +assert.ok(!staleHeader.includes("Updates unavailable")); + +// The shared header (minimal and detailed views) shows the same dot and never +// an inline tag. +const sharedHeader = providerHeaderMarkup(paused); +assert.ok(sharedHeader.includes("provider-status-dot")); +assert.ok(sharedHeader.includes("status-attention")); +assert.ok(!sharedHeader.includes("quota-source-tag")); + +// The attention class rides on top of the severity class rather than +// replacing it, so the dot keeps its usage-severity meaning. +const pausedDot = pausedHeader.match(/class="provider-status-dot [^"]*"/)[0]; +assert.ok(pausedDot.includes("status-warning"), "attention must not drop the severity class"); + +// A high-usage card that needs no action keeps the plain severity dot and no +// action text in its tooltip. +const hot = Object.assign({}, base, { status: "critical", connection_status: "", highest_percent: 97 }); +const hotHeader = standardHeaderMarkup(hot); +assert.ok(!hotHeader.includes("status-attention")); +assert.ok(hotHeader.includes("status-critical")); +assert.ok(!hotHeader.includes("auth failed")); + +// Card numbers stay visible in every state. +const card = renderStandardCard(paused, 2); +assert.ok(card.includes("quota-tile"), "paused card must still render its quota numbers"); +assert.ok(card.includes("quota-ring-pct"), "paused card must still render its percentages"); +assert.ok(card.includes("Weekly"), "paused card must still render its quota labels"); +assert.ok(!card.includes('class="hidden"'), "paused card must not hide its numbers"); +assert.ok(!card.includes("display:none"), "paused card must not hide its numbers"); +` + runMenubarNode(t, script) +} + +// The attention dot and the tooltip must be defined for both themes: the +// variables the rules read have to exist under the default (dark) root and +// under the light override, and the tooltip needs a light-surface shadow. +func TestMenubarAttentionDotThemeCoverage(t *testing.T) { + html := readMenubarHTML(t) + + darkRoot := menubarSlice(t, html, ":root {", ":root[data-theme=\"light\"] {") + lightRoot := menubarSlice(t, html, ":root[data-theme=\"light\"] {", "* {") + + for _, variable := range []string{"--attention:", "--attention-halo:"} { + if !strings.Contains(darkRoot, variable) { + t.Errorf("dark theme is missing %s", variable) + } + if !strings.Contains(lightRoot, variable) { + t.Errorf("light theme is missing %s", variable) + } + } + + if !strings.Contains(html, ".provider-status-dot.status-attention") { + t.Error("the attention dot rule is missing") + } + if !strings.Contains(html, ".provider-status-tip") { + t.Error("the tooltip rule is missing") + } + if !strings.Contains(html, ":root[data-theme=\"light\"] .provider-status-tip") { + t.Error("the tooltip has no light-theme rule") + } +} + +// The snapshot cache is one versioned localStorage entry: written on every +// successful fetch, read on load for an instant first paint, and cleared when +// corrupt or written by a different version. +func TestMenubarSnapshotCacheRoundTrip(t *testing.T) { + html := readMenubarHTML(t) + + if !strings.Contains(html, "SNAPSHOT_CACHE_KEY") || !strings.Contains(html, "SNAPSHOT_CACHE_VERSION") { + t.Fatal("menubar.html must define a versioned snapshot cache key") + } + + slice := menubarSlice(t, html, "function isRenderableProviderCard(", "function resolveThemeMode(") + + script := ` +const assert = require('node:assert/strict'); +const SNAPSHOT_CACHE_KEY = "menubar_snapshot_cache"; +const SNAPSHOT_CACHE_VERSION = 1; +const backing = new Map(); +const localStorage = { + getItem: (key) => (backing.has(key) ? backing.get(key) : null), + setItem: (key, value) => { backing.set(key, String(value)); }, + removeItem: (key) => { backing.delete(key); } +}; +const state = { snapshot: null, loading: true }; +` + slice + ` +const snapshot = { generated_at: "2026-01-01T00:00:00Z", providers: [{ id: "codex", quotas: [] }] }; + +// Round trip. +writeCachedSnapshot(snapshot); +assert.deepEqual(readCachedSnapshot(), snapshot); +assert.equal(backing.size, 1, "the cache must be a single overwritten entry"); + +// Overwrite, never append. +writeCachedSnapshot({ providers: [{ id: "anthropic", quotas: [] }] }); +assert.equal(backing.size, 1); +assert.equal(readCachedSnapshot().providers[0].id, "anthropic"); + +// Corrupt entry is ignored and cleared. +backing.set(SNAPSHOT_CACHE_KEY, "{not json"); +assert.equal(readCachedSnapshot(), null); +assert.ok(!backing.has(SNAPSHOT_CACHE_KEY), "a corrupt entry must be cleared"); + +// Wrong version is ignored and cleared. +backing.set(SNAPSHOT_CACHE_KEY, JSON.stringify({ version: SNAPSHOT_CACHE_VERSION + 1, snapshot: snapshot })); +assert.equal(readCachedSnapshot(), null); +assert.ok(!backing.has(SNAPSHOT_CACHE_KEY), "a wrong-version entry must be cleared"); + +// A payload without a providers array is ignored and cleared. +backing.set(SNAPSHOT_CACHE_KEY, JSON.stringify({ version: SNAPSHOT_CACHE_VERSION, snapshot: { generated_at: "x" } })); +assert.equal(readCachedSnapshot(), null); +assert.ok(!backing.has(SNAPSHOT_CACHE_KEY)); + +// Nothing stored. +assert.equal(readCachedSnapshot(), null); + +// A cached snapshot is adopted only when nothing newer is in memory. +writeCachedSnapshot(snapshot); +restoreCachedSnapshot(); +assert.deepEqual(state.snapshot, snapshot); +assert.equal(state.loading, false, "a cached render must clear the loading state"); + +// Never render a cached snapshot over a newer in-memory one. +state.snapshot = { providers: [{ id: "fresh", quotas: [] }] }; +restoreCachedSnapshot(); +assert.equal(state.snapshot.providers[0].id, "fresh"); + +// Writing junk must not touch the cache. +const before = backing.get(SNAPSHOT_CACHE_KEY); +writeCachedSnapshot(null); +writeCachedSnapshot({}); +assert.equal(backing.get(SNAPSHOT_CACHE_KEY), before); +` + runMenubarNode(t, script) +} + +// A successful fetch replaces the cache; a failed one leaves the last good +// snapshot in place so the next load still paints instantly. +func TestMenubarSnapshotCacheSavedOnlyOnSuccess(t *testing.T) { + html := readMenubarHTML(t) + + // init restores the cache before the first fetch. + initStart := strings.Index(html, "async function init()") + if initStart < 0 { + t.Fatal("menubar.html is missing init()") + } + initEnd := strings.Index(html[initStart:], "async function loadPreferences(") + if initEnd < 0 { + t.Fatal("menubar.html is missing loadPreferences()") + } + initBody := html[initStart : initStart+initEnd] + // The guarded starter clears an entry whose first render throws, so init + // always reaches the fetch. It calls restoreCachedSnapshot internally. + if !strings.Contains(initBody, "restoreCachedSnapshotSafely()") { + t.Fatal("init must restore the cached snapshot before fetching") + } + if strings.Index(initBody, "restoreCachedSnapshotSafely()") > strings.Index(initBody, "loadSnapshot()") { + t.Fatal("init must restore the cached snapshot before loadSnapshot()") + } + + slice := menubarSlice(t, html, "let snapshotGeneration = 0;", "async function refreshData(") + + script := ` +const assert = require('node:assert/strict'); +const API_BASE = ""; +const state = { snapshot: null, loading: true, error: "" }; +const saved = []; +function writeCachedSnapshot(snapshot) { saved.push(snapshot); } +function render() {} +` + slice + ` +(async () => { + // Success writes the cache. + global.fetch = async () => ({ ok: true, json: async () => ({ providers: [{ id: "codex" }] }) }); + await loadSnapshot(); + assert.equal(saved.length, 1, "a successful fetch must write the cache"); + assert.equal(saved[0].providers[0].id, "codex"); + + // Failure must not overwrite the last good snapshot. + state.snapshot = null; + global.fetch = async () => { throw new Error("offline"); }; + await loadSnapshot(); + assert.equal(saved.length, 1, "a failed fetch must not overwrite the cache"); + assert.ok(state.error, "a failed fetch must record the error"); + + // A non-OK response is a failure too. + global.fetch = async () => ({ ok: false, status: 503 }); + await loadSnapshot(); + assert.equal(saved.length, 1, "a 503 must not overwrite the cache"); +})().catch((error) => { console.error(error); process.exit(1); }); +` + runMenubarNode(t, script) +} diff --git a/internal/web/menubar_lastgood_test.go b/internal/web/menubar_lastgood_test.go new file mode 100644 index 00000000..0474f54b --- /dev/null +++ b/internal/web/menubar_lastgood_test.go @@ -0,0 +1,956 @@ +package web + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/menubar" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// breakProviderStorage makes every subsequent read of one provider's tables +// fail, without touching any other provider. It opens a second handle on the +// same shared-cache in-memory database and drops the tables the menubar builder +// reads. A dropped table turns the builder's read into a hard SQLite error, +// which is exactly the storage failure the last-good cache has to survive. +func breakProviderStorage(t *testing.T, dsn string, tables ...string) { + t.Helper() + + // The store's own pool hides its *sql.DB, so reach the same database + // through a second connection on the shared-cache DSN. + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + + for _, table := range tables { + if _, err := db.Exec("DROP TABLE IF EXISTS " + table); err != nil { + t.Fatalf("drop %s: %v", table, err) + } + } +} + +// execSQLOnDSN runs one statement on a second handle to the shared-cache +// in-memory database. Tests use it to change storage under the handler's feet: +// delete rows for a successful empty read, or rename a table to make one read +// fail while an adjacent read still succeeds. +func execSQLOnDSN(t *testing.T, dsn string, statements ...string) { + t.Helper() + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("%s: %v", statement, err) + } + } +} + +// newSharedMemoryMenubarHandler returns a handler backed by an in-memory +// SQLite database that other connections can reach, so a test can break one +// provider's tables while the handler keeps serving the others. +func newSharedMemoryMenubarHandler(t *testing.T, cfg *config.Config) (*Handler, *store.Store, string) { + t.Helper() + + dsn := "file:menubar-lastgood-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared" + s, err := store.New(dsn) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + + h := NewHandler(s, nil, nil, nil, cfg) + return h, s, dsn +} + +func menubarCardByID(snapshot *menubar.Snapshot, id string) (menubar.ProviderCard, bool) { + for _, card := range snapshot.Providers { + if card.ID == id { + return card, true + } + } + return menubar.ProviderCard{}, false +} + +// A storage read failure for one quota-array provider must not drop its card. +// The daemon keeps serving the last good card, with the original stored +// timestamp, so the client's age label keeps counting up. +func TestMenubarLastGoodServesPreviousCardOnQuotaArrayReadFailure(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-90 * time.Minute).Truncate(time.Second) + reset := capturedAt.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent, ResetsAt: &reset}, + }) + + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + before, ok := menubarCardByID(first, "cursor") + if !ok { + t.Fatalf("expected cursor card in first snapshot, got %+v", first.Providers) + } + if before.UpdatedAtTime == "" { + t.Fatal("expected cursor card to carry updated_at_time") + } + + breakProviderStorage(t, dsn, "cursor_quota_values", "cursor_snapshots") + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(after, "cursor") + if !ok { + t.Fatalf("cursor card vanished after a storage read failure: %+v", after.Providers) + } + if card.UpdatedAtTime != before.UpdatedAtTime { + t.Fatalf("cursor updated_at_time = %q, want the original %q", card.UpdatedAtTime, before.UpdatedAtTime) + } + if len(card.Quotas) != len(before.Quotas) { + t.Fatalf("cursor quotas = %d, want the cached %d", len(card.Quotas), len(before.Quotas)) + } + for _, quota := range card.Quotas { + if quota.Percent == 0 && before.Quotas[0].Percent != 0 { + t.Fatalf("cached cursor card was zeroed: %+v", card.Quotas) + } + } + if after.LatestAt.IsZero() { + t.Fatal("LatestAt went zero after a storage failure; it must keep the last real data time") + } + if !after.LatestAt.Equal(capturedAt) { + t.Fatalf("LatestAt = %s, want %s (the cached provider's real capturedAt)", after.LatestAt, capturedAt) + } +} + +// Mistral's builder returns a connection-only payload after its 3s read +// timeout. That payload must not replace the previous quota card. +func TestMenubarLastGoodServesPreviousCardOnMistralTimeout(t *testing.T) { + cfg := &config.Config{MistralEnabled: true, PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"} + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-45 * time.Minute).Truncate(time.Second) + snap := &api.MistralSnapshot{ + Identity: "test", + CapturedAt: capturedAt, + Status: "ok", + Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 30, Limit: 100, Currency: "EUR", Utilization: 30, CapturedAt: capturedAt}, + {Name: "vibe_included", Used: 50, Limit: 100, Currency: "EUR", Utilization: 50, CapturedAt: capturedAt}, + }, + } + if err := s.SaveMistral(t.Context(), snap); err != nil { + t.Fatalf("SaveMistral: %v", err) + } + + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + before, ok := menubarCardByID(first, "mistral") + if !ok { + t.Fatalf("expected mistral card in first snapshot, got %+v", first.Providers) + } + if len(before.Quotas) != 2 { + t.Fatalf("mistral quotas = %d, want 2", len(before.Quotas)) + } + + breakProviderStorage(t, dsn, "mistral_quota_values", "mistral_snapshots") + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(after, "mistral") + if !ok { + t.Fatalf("mistral card vanished after its read failed: %+v", after.Providers) + } + if len(card.Quotas) != 2 { + t.Fatalf("mistral card dropped to a connection-only placeholder: %+v", card) + } + if card.UpdatedAtTime != before.UpdatedAtTime { + t.Fatalf("mistral updated_at_time = %q, want the original %q", card.UpdatedAtTime, before.UpdatedAtTime) + } +} + +// Synthetic/Z.ai/OpenRouter build initialized quota maps stamped time.Now() on +// a read error. Those must never reach a card: the previous card is served +// unchanged instead, and the timestamp never resets to "now". +func TestMenubarLastGoodServesPreviousCardOnMapShapedProviderReadFailure(t *testing.T) { + for _, tc := range []struct { + name string + provider string + cfg *config.Config + table string + snapshot func(capturedAt time.Time) any + buildCard func(*Handler) (map[string]interface{}, bool, bool) + }{ + { + name: "synthetic", + provider: "synthetic", + cfg: createTestConfigWithSynthetic(), + table: "quota_snapshots", + snapshot: func(capturedAt time.Time) any { + return &api.Snapshot{ + CapturedAt: capturedAt, + Sub: api.QuotaInfo{Limit: 100, Requests: 40, RenewsAt: capturedAt.Add(time.Hour)}, + Search: api.QuotaInfo{Limit: 100, Requests: 20, RenewsAt: capturedAt.Add(time.Hour)}, + ToolCall: api.QuotaInfo{Limit: 100, Requests: 10, RenewsAt: capturedAt.Add(time.Hour)}, + } + }, + buildCard: func(h *Handler) (map[string]interface{}, bool, bool) { return h.buildSyntheticCurrentOK() }, + }, + { + name: "zai", + provider: "zai", + cfg: createTestConfigWithZai(), + table: "zai_snapshots", + snapshot: func(capturedAt time.Time) any { + return &api.ZaiSnapshot{ + CapturedAt: capturedAt, + TokensUsage: 1000, + TokensCurrentValue: 400, + TokensPercentage: 40, + TimeUsage: 100, + TimeCurrentValue: 30, + TimePercentage: 30, + } + }, + buildCard: func(h *Handler) (map[string]interface{}, bool, bool) { return h.buildZaiCurrentOK() }, + }, + { + name: "openrouter", + provider: "openrouter", + cfg: &config.Config{OpenRouterAPIKey: "or_test_key", PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"}, + table: "openrouter_snapshots", + snapshot: func(capturedAt time.Time) any { + return &api.OpenRouterSnapshot{CapturedAt: capturedAt, Usage: 12.5, UsageDaily: 1.5} + }, + buildCard: func(h *Handler) (map[string]interface{}, bool, bool) { return h.buildOpenRouterCurrentOK() }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h, s, dsn := newSharedMemoryMenubarHandler(t, tc.cfg) + // Synthetic's builder only reads the store when a tracker is set. + h.tracker = tracker.New(s, nil) + + capturedAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) + switch snap := tc.snapshot(capturedAt).(type) { + case *api.Snapshot: + if _, err := s.InsertSnapshot(snap); err != nil { + t.Fatalf("InsertSnapshot: %v", err) + } + case *api.ZaiSnapshot: + if _, err := s.InsertZaiSnapshot(snap); err != nil { + t.Fatalf("InsertZaiSnapshot: %v", err) + } + case *api.OpenRouterSnapshot: + if _, err := s.InsertOpenRouterSnapshot(snap); err != nil { + t.Fatalf("InsertOpenRouterSnapshot: %v", err) + } + } + + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + before, ok := menubarCardByID(first, tc.provider) + if !ok { + t.Fatalf("expected %s card in first snapshot, got %+v", tc.provider, first.Providers) + } + + breakProviderStorage(t, dsn, tc.table) + + // The builder's ok flag must report the failure, so the menubar + // knows to keep the cached card rather than this fresh fallback. + payload, readOK, _ := tc.buildCard(h) + if readOK { + t.Fatalf("%s builder reported success after a storage read failure: %v", tc.provider, payload) + } + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(after, tc.provider) + if !ok { + t.Fatalf("%s card vanished after a storage read failure: %+v", tc.provider, after.Providers) + } + if card.UpdatedAtTime != before.UpdatedAtTime { + t.Fatalf("%s updated_at_time = %q, want the original %q", tc.provider, card.UpdatedAtTime, before.UpdatedAtTime) + } + if card.UpdatedAtTime == "" { + t.Fatalf("%s card has no timestamp, so the client cannot show an age", tc.provider) + } + }) + } +} + +// With no cached card there is nothing honest to show, so the provider must be +// omitted rather than filled with a zero-value card stamped now. +func TestMenubarLastGoodOmitsProviderOnFirstEverReadFailure(t *testing.T) { + cfg := createTestConfigWithCursor() + h, _, dsn := newSharedMemoryMenubarHandler(t, cfg) + + breakProviderStorage(t, dsn, "cursor_quota_values", "cursor_snapshots") + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(snapshot, "cursor"); ok { + t.Fatalf("cursor card fabricated on a first-ever read failure: %+v", card) + } + if !snapshot.LatestAt.IsZero() { + t.Fatalf("LatestAt = %s, want zero: no provider supplied real data", snapshot.LatestAt) + } +} + +// Eviction: a provider that is no longer configured must not be served from +// the cache, and its entry must be dropped. +func TestMenubarLastGoodEvictsRemovedProvider(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, _ := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + if _, ok := h.menubarLastGood["cursor"]; !ok { + t.Fatalf("expected cursor entry in the last-good cache, got %v", h.menubarLastGood) + } + + h.config.CursorToken = "" + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(snapshot, "cursor"); ok { + t.Fatalf("disabled provider still served from the cache: %+v", card) + } + if _, ok := h.menubarLastGood["cursor"]; ok { + t.Fatalf("disabled provider entry was not evicted: %v", h.menubarLastGood) + } +} + +// A cached card must not be served for an account the user deleted, and the +// cache is keyed per account for multi-account providers. +func TestMenubarLastGoodEvictsDeletedAccountAndKeepsOthers(t *testing.T) { + cfg := createTestConfigWithCodex() + h, s, _ := newSharedMemoryMenubarHandler(t, cfg) + + work, err := s.GetOrCreateProviderAccount("codex", "work") + if err != nil { + t.Fatalf("GetOrCreateProviderAccount(work): %v", err) + } + personal, err := s.GetOrCreateProviderAccount("codex", "personal") + if err != nil { + t.Fatalf("GetOrCreateProviderAccount(personal): %v", err) + } + capturedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second) + for _, accountID := range []int64{work.ID, personal.ID} { + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: capturedAt, + AccountID: accountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "seven_day", Utilization: 30}}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot(%d): %v", accountID, err) + } + } + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + workKey := fmt.Sprintf("codex:%d", work.ID) + personalKey := fmt.Sprintf("codex:%d", personal.ID) + if _, ok := menubarCardByID(snapshot, workKey); !ok { + t.Fatalf("expected %s card, got %+v", workKey, snapshot.Providers) + } + if _, ok := menubarCardByID(snapshot, personalKey); !ok { + t.Fatalf("expected %s card, got %+v", personalKey, snapshot.Providers) + } + + // Soft-delete the personal account; its cached card must go with it. + if err := s.MarkProviderAccountDeletedByID(personal.ID); err != nil { + t.Fatalf("MarkProviderAccountDeletedByID: %v", err) + } + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(after, personalKey); ok { + t.Fatalf("deleted account still served from the cache: %+v", card) + } + if _, ok := menubarCardByID(after, workKey); !ok { + t.Fatalf("remaining account was evicted too: %+v", after.Providers) + } + if _, ok := h.menubarLastGood[personalKey]; ok { + t.Fatalf("deleted account entry was not evicted: %v", h.menubarLastGood) + } + if _, ok := h.menubarLastGood[workKey]; !ok { + t.Fatalf("remaining account entry was dropped: %v", h.menubarLastGood) + } +} + +// The cache is bounded: one entry per provider card, and repeated failures do +// not accumulate entries. +func TestMenubarLastGoodCacheIsBounded(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + + breakProviderStorage(t, dsn, "cursor_quota_values", "cursor_snapshots") + for i := 0; i < 5; i++ { + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("BuildMenubarSnapshot %d: %v", i, err) + } + } + if len(h.menubarLastGood) != 1 { + t.Fatalf("cache holds %d entries after repeated failures, want 1: %v", len(h.menubarLastGood), h.menubarLastGood) + } +} + +// A cached card must survive a storage failure that happens before any client +// ever saw it: the second request is the one that has to fall back. +func TestMenubarLastGoodServedCardHasNoFabricatedTimestamp(t *testing.T) { + cfg := createTestConfigWithSynthetic() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + h.tracker = tracker.New(s, nil) + + capturedAt := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second) + if _, err := s.InsertSnapshot(&api.Snapshot{ + CapturedAt: capturedAt, + Sub: api.QuotaInfo{Limit: 100, Requests: 40, RenewsAt: capturedAt.Add(time.Hour)}, + Search: api.QuotaInfo{Limit: 100, Requests: 20, RenewsAt: capturedAt.Add(time.Hour)}, + ToolCall: api.QuotaInfo{Limit: 100, Requests: 10, RenewsAt: capturedAt.Add(time.Hour)}, + }); err != nil { + t.Fatalf("InsertSnapshot: %v", err) + } + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + + breakProviderStorage(t, dsn, "quota_snapshots") + + rr := httptest.NewRecorder() + h.MenubarSummary(rr, httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("summary returned %d: %s", rr.Code, rr.Body.String()) + } + var snapshot menubar.Snapshot + if err := json.Unmarshal(rr.Body.Bytes(), &snapshot); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + card, ok := menubarCardByID(&snapshot, "synthetic") + if !ok { + t.Fatalf("synthetic card missing after failure: %+v", snapshot.Providers) + } + want := capturedAt.UTC().Format(time.RFC3339) + if card.UpdatedAtTime != want { + t.Fatalf("synthetic updated_at_time = %q, want %q", card.UpdatedAtTime, want) + } + if snapshot.GeneratedAt.Before(snapshot.LatestAt) { + t.Fatalf("GeneratedAt %s is before LatestAt %s", snapshot.GeneratedAt, snapshot.LatestAt) + } +} + +// A successful read that simply has no rows yet must not seed the cache with a +// fabricated card. Synthetic's builder stamps the empty fallback with now, so +// caching it would resurrect that zero card after a later real read failure. +func TestMenubarLastGoodDoesNotCacheFabricatedEmptyCard(t *testing.T) { + cfg := createTestConfigWithSynthetic() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + h.tracker = tracker.New(s, nil) + + // No snapshot inserted: the read succeeds and returns nothing. + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + if _, ok := h.menubarLastGood["synthetic"]; ok { + t.Fatalf("a no-data read seeded the cache: %+v", h.menubarLastGood) + } + + breakProviderStorage(t, dsn, "quota_snapshots") + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(snapshot, "synthetic"); ok { + t.Fatalf("synthetic card fabricated after a read failure with no history: %+v", card) + } +} + +// Mistral with no stored history must not seed the cache with its +// connection-only placeholder, so a later read failure cannot resurrect it as +// if it were real quota data. +func TestMenubarLastGoodDoesNotCacheMistralPlaceholder(t *testing.T) { + cfg := &config.Config{MistralEnabled: true, PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"} + h, _, dsn := newSharedMemoryMenubarHandler(t, cfg) + + if _, err := h.BuildMenubarSnapshot(); err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + if _, ok := h.menubarLastGood["mistral"]; ok { + t.Fatalf("a no-history Mistral read seeded the cache: %+v", h.menubarLastGood) + } + + breakProviderStorage(t, dsn, "mistral_quota_values", "mistral_snapshots") + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(snapshot, "mistral"); ok { + t.Fatalf("mistral placeholder served as cached data after a read failure: %+v", card) + } +} + +// insertCodexTestSnapshot stores one Codex snapshot for an account. +func insertCodexTestSnapshot(t *testing.T, s *store.Store, accountID int64, capturedAt time.Time, utilization float64) { + t.Helper() + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: capturedAt, + AccountID: accountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "seven_day", Utilization: utilization}}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot(%d): %v", accountID, err) + } +} + +// Finding 1: when the account list cannot be read, the known Codex cards must +// keep being served and must not be pruned. Recovery must bring back the real +// accounts, and only a successful enumeration may prune a genuinely deleted +// one. Before the fix, codexUsageAccounts turned the error into an empty list, +// nothing entered attempted, and deferred pruning deleted every cached card. +func TestMenubarLastGoodKeepsCodexAccountsWhenEnumerationFails(t *testing.T) { + cfg := createTestConfigWithCodex() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + work, err := s.GetOrCreateProviderAccount("codex", "work") + if err != nil { + t.Fatalf("GetOrCreateProviderAccount(work): %v", err) + } + personal, err := s.GetOrCreateProviderAccount("codex", "personal") + if err != nil { + t.Fatalf("GetOrCreateProviderAccount(personal): %v", err) + } + capturedAt := time.Now().UTC().Add(-25 * time.Minute).Truncate(time.Second) + insertCodexTestSnapshot(t, s, work.ID, capturedAt, 30) + insertCodexTestSnapshot(t, s, personal.ID, capturedAt, 55) + + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + workKey := fmt.Sprintf("codex:%d", work.ID) + personalKey := fmt.Sprintf("codex:%d", personal.ID) + workBefore, ok := menubarCardByID(first, workKey) + if !ok { + t.Fatalf("expected %s card, got %+v", workKey, first.Providers) + } + personalBefore, ok := menubarCardByID(first, personalKey) + if !ok { + t.Fatalf("expected %s card, got %+v", personalKey, first.Providers) + } + + // Enumeration fails; the snapshot tables stay readable. + execSQLOnDSN(t, dsn, "ALTER TABLE provider_accounts RENAME TO provider_accounts_broken") + + failed, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + workCard, ok := menubarCardByID(failed, workKey) + if !ok { + t.Fatalf("%s vanished when account enumeration failed: %+v", workKey, failed.Providers) + } + if workCard.UpdatedAtTime != workBefore.UpdatedAtTime { + t.Fatalf("%s updated_at_time = %q, want the cached %q", workKey, workCard.UpdatedAtTime, workBefore.UpdatedAtTime) + } + personalCard, ok := menubarCardByID(failed, personalKey) + if !ok { + t.Fatalf("%s vanished when account enumeration failed: %+v", personalKey, failed.Providers) + } + if personalCard.UpdatedAtTime != personalBefore.UpdatedAtTime { + t.Fatalf("%s updated_at_time = %q, want the cached %q", personalKey, personalCard.UpdatedAtTime, personalBefore.UpdatedAtTime) + } + if _, ok := h.menubarLastGood[workKey]; !ok { + t.Fatalf("enumeration failure pruned %s: %v", workKey, h.menubarLastGood) + } + + // Recovery: the list reads again. + execSQLOnDSN(t, dsn, "ALTER TABLE provider_accounts_broken RENAME TO provider_accounts") + recovered, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("third BuildMenubarSnapshot: %v", err) + } + if _, ok := menubarCardByID(recovered, workKey); !ok { + t.Fatalf("%s missing after recovery: %+v", workKey, recovered.Providers) + } + if _, ok := menubarCardByID(recovered, personalKey); !ok { + t.Fatalf("%s missing after recovery: %+v", personalKey, recovered.Providers) + } + + // A genuine deletion after a successful enumeration is still pruned. + if err := s.MarkProviderAccountDeletedByID(personal.ID); err != nil { + t.Fatalf("MarkProviderAccountDeletedByID: %v", err) + } + deleted, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("fourth BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(deleted, personalKey); ok { + t.Fatalf("deleted account still served: %+v", card) + } + if _, ok := h.menubarLastGood[personalKey]; ok { + t.Fatalf("deleted account was not pruned after a successful enumeration: %v", h.menubarLastGood) + } + if _, ok := menubarCardByID(deleted, workKey); !ok { + t.Fatalf("%s was pruned along with the deleted account: %+v", workKey, deleted.Providers) + } +} + +// Finding 1 (MiniMax half of finding 1): the same rule applies to MiniMax. +func TestMenubarLastGoodKeepsMiniMaxAccountsWhenEnumerationFails(t *testing.T) { + cfg := &config.Config{MiniMaxAPIKey: "minimax_test_key", PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"} + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + account, err := s.GetOrCreateProviderAccount("minimax", "work") + if err != nil { + t.Fatalf("GetOrCreateProviderAccount: %v", err) + } + capturedAt := time.Now().UTC().Add(-15 * time.Minute).Truncate(time.Second) + if _, err := s.InsertMiniMaxSnapshot(&api.MiniMaxSnapshot{ + CapturedAt: capturedAt, + Models: []api.MiniMaxModelQuota{ + {ModelName: "general", Used: 10, Total: 100, Remain: 90, UsedPercent: 10}, + }, + }, account.ID); err != nil { + t.Fatalf("InsertMiniMaxSnapshot: %v", err) + } + + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + key := fmt.Sprintf("minimax:%d", account.ID) + before, ok := menubarCardByID(first, key) + if !ok { + t.Fatalf("expected %s card, got %+v", key, first.Providers) + } + + execSQLOnDSN(t, dsn, "ALTER TABLE provider_accounts RENAME TO provider_accounts_broken") + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(after, key) + if !ok { + t.Fatalf("%s vanished when account enumeration failed: %+v", key, after.Providers) + } + if card.UpdatedAtTime != before.UpdatedAtTime { + t.Fatalf("%s updated_at_time = %q, want the cached %q", key, card.UpdatedAtTime, before.UpdatedAtTime) + } +} + +// Finding 2: the outcome must come from the read that builds the payload, not +// from a separate probe. A broken payload read must report failure, and a +// successful read backed by stored rows must report success. The old probe ran +// QueryLatestCursor and the builder then re-read the same data inside +// buildCursorCurrent, so the two could disagree. +func TestMenubarLastGoodUsesBuilderReadOutcome(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + + payload, readOK, hasData := h.buildCursorCurrentOK() + if !readOK || !hasData { + t.Fatalf("builder outcome = (readOK=%v, hasData=%v), want both true for stored data: %v", readOK, hasData, payload) + } + + // Break the table the builder's payload read depends on. The outcome must + // follow that actual read. + execSQLOnDSN(t, dsn, "ALTER TABLE cursor_snapshots RENAME TO cursor_snapshots_broken") + + payload, readOK, _ = h.buildCursorCurrentOK() + if readOK { + t.Fatalf("builder reported success after its payload read failed: %v", payload) + } +} + +// Finding 2 for a range-fallback provider: when the latest snapshot has no +// quotas and the 7-day range read fails, the builder must report failure +// instead of treating the empty result as a successful no-data read. +func TestMenubarLastGoodSeesGrokFallbackReadFailure(t *testing.T) { + cfg := &config.Config{GrokToken: "grok_test_token", PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"} + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second) + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + CapturedAt: capturedAt, + Quotas: []api.GrokQuota{{Name: "credits", Utilization: 20, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot: %v", err) + } + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + before, ok := menubarCardByID(first, "grok") + if !ok { + t.Fatalf("expected grok card, got %+v", first.Providers) + } + + // An empty newest snapshot forces the range fallback; breaking the quota + // value table makes that fallback read fail. + insertEmptyGrokSnapshot(t, s) + execSQLOnDSN(t, dsn, "ALTER TABLE grok_quota_values RENAME TO grok_quota_values_broken") + + after, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(after, "grok") + if !ok { + t.Fatalf("grok card vanished after its fallback read failed: %+v", after.Providers) + } + if card.UpdatedAtTime != before.UpdatedAtTime { + t.Fatalf("grok updated_at_time = %q, want the cached %q", card.UpdatedAtTime, before.UpdatedAtTime) + } +} + +func insertEmptyGrokSnapshot(t *testing.T, s *store.Store) { + t.Helper() + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + CapturedAt: time.Now().UTC(), + Quotas: []api.GrokQuota{}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot(empty): %v", err) + } +} + +// Finding 3: real data, then a successful empty read, then a storage error. +// The successful empty read means the history is genuinely gone, so the old +// card must be evicted and must NOT come back on the later failure. +func TestMenubarLastGoodDoesNotResurrectAfterSuccessfulEmptyRead(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent}, + }) + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + if _, ok := menubarCardByID(first, "cursor"); !ok { + t.Fatalf("expected cursor card, got %+v", first.Providers) + } + if _, ok := h.menubarLastGood["cursor"]; !ok { + t.Fatalf("expected cursor in the last-good cache") + } + + // Clear the history: the read now succeeds with no rows. + execSQLOnDSN(t, dsn, "DELETE FROM cursor_quota_values", "DELETE FROM cursor_snapshots") + empty, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(empty, "cursor"); ok { + t.Fatalf("cursor card shown after a successful empty read: %+v", card) + } + if _, ok := h.menubarLastGood["cursor"]; ok { + t.Fatalf("successful empty read did not evict the cached card: %v", h.menubarLastGood) + } + if !empty.LatestAt.IsZero() { + t.Fatalf("LatestAt = %s after the only provider's data was cleared, want zero", empty.LatestAt) + } + + // A later storage failure must not bring the old card back. + execSQLOnDSN(t, dsn, "ALTER TABLE cursor_snapshots RENAME TO cursor_snapshots_broken") + failed, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("third BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(failed, "cursor"); ok { + t.Fatalf("cleared card came back after a storage failure: %+v", card) + } +} + +// Finding 3: the same eviction gap for Mistral's connection-only placeholder. +func TestMenubarLastGoodMistralPlaceholderDoesNotResurrect(t *testing.T) { + cfg := &config.Config{MistralEnabled: true, PollInterval: 60 * time.Second, Port: 9211, AdminUser: "admin", AdminPass: "test"} + h, s, dsn := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) + if err := s.SaveMistral(t.Context(), &api.MistralSnapshot{ + Identity: "test", + CapturedAt: capturedAt, + Status: "ok", + Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 30, Limit: 100, Currency: "EUR", Utilization: 30, CapturedAt: capturedAt}, + {Name: "vibe_included", Used: 50, Limit: 100, Currency: "EUR", Utilization: 50, CapturedAt: capturedAt}, + }, + }); err != nil { + t.Fatalf("SaveMistral: %v", err) + } + first, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("first BuildMenubarSnapshot: %v", err) + } + before, ok := menubarCardByID(first, "mistral") + if !ok { + t.Fatalf("expected mistral card, got %+v", first.Providers) + } + + // Clear the history: the read succeeds, Mistral shows only its + // connection-only placeholder, and the cached quota card must go. + execSQLOnDSN(t, dsn, "DELETE FROM mistral_quota_values", "DELETE FROM mistral_snapshots") + empty, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("second BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(empty, "mistral"); ok && card.UpdatedAtTime != "" { + t.Fatalf("mistral placeholder fabricated a data time after a successful empty read: %+v", card) + } + if _, ok := h.menubarLastGood["mistral"]; ok { + t.Fatalf("successful empty read did not evict the cached Mistral card: %v", h.menubarLastGood) + } + + // The later storage failure must not resurrect the cleared quota card. With + // nothing cached the provider may be omitted, or show Mistral's timestamp- + // free connection placeholder; it must never come back with the old data. + execSQLOnDSN(t, dsn, "ALTER TABLE mistral_snapshots RENAME TO mistral_snapshots_broken") + failed, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("third BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(failed, "mistral") + if !ok { + if !failed.LatestAt.IsZero() { + t.Fatalf("LatestAt = %s with no provider data", failed.LatestAt) + } + return + } + if len(card.Quotas) != 0 || card.UpdatedAtTime != "" { + t.Fatalf("cleared Mistral card came back after a storage failure: %+v", card) + } + if card.UpdatedAtTime == before.UpdatedAtTime { + t.Fatal("cleared Mistral card kept its old timestamp") + } +} + +// Finding 3: a provider that is configured but has no stored history must not +// produce a fabricated card or a non-zero LatestAt. Synthetic stamped its empty +// fallback with time.Now(), which produced a fake-fresh zero card. +func TestMenubarLastGoodConfiguredNoHistoryHasNoFabricatedCard(t *testing.T) { + for _, tc := range []struct { + name string + cfg *config.Config + provider string + }{ + {name: "synthetic", cfg: createTestConfigWithSynthetic(), provider: "synthetic"}, + {name: "zai", cfg: createTestConfigWithZai(), provider: "zai"}, + } { + t.Run(tc.name, func(t *testing.T) { + h, _, _ := newSharedMemoryMenubarHandler(t, tc.cfg) + if tc.provider == "synthetic" { + h.tracker = tracker.New(h.store, nil) + } + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + if card, ok := menubarCardByID(snapshot, tc.provider); ok { + t.Fatalf("%s card fabricated with no stored history: %+v", tc.provider, card) + } + if !snapshot.LatestAt.IsZero() { + t.Fatalf("LatestAt = %s with no stored history, want zero", snapshot.LatestAt) + } + }) + } +} + +// Finding 8: severity comes from usage alone. A healthy but very old card keeps +// its usage-based severity; the age label carries staleness. Connection states +// that need action (paused, reconnect) still floor at warning. +func TestMenubarStalenessDoesNotRaiseSeverity(t *testing.T) { + cfg := createTestConfigWithCursor() + h, s, _ := newSharedMemoryMenubarHandler(t, cfg) + + capturedAt := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 20, Limit: 100, Utilization: 20, Format: api.CursorFormatPercent}, + }) + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + card, ok := menubarCardByID(snapshot, "cursor") + if !ok { + t.Fatalf("expected cursor card, got %+v", snapshot.Providers) + } + if card.Status != "healthy" { + t.Fatalf("stale 20%% card status = %q, want healthy (age alone must not raise severity)", card.Status) + } + if card.UpdatedAtTime == "" { + t.Fatal("stale card carries no age label source; staleness must still be visible there") + } + + // A stale quota flagged by the payload must likewise not raise severity. + payload := map[string]interface{}{ + "capturedAt": capturedAt.Format(time.RFC3339), + "quotas": []interface{}{map[string]interface{}{ + "name": "total_usage", + "utilization": 20.0, + "isStale": true, + }}, + } + normalized := normalizeProviderCard("cursor", "Cursor", "", payload, 70, 90) + if normalized == nil { + t.Fatal("normalizeProviderCard returned nil for a stale quota") + } + if normalized.Status != "healthy" { + t.Fatalf("normalizeProviderCard status = %q for a stale 20%% card, want healthy", normalized.Status) + } +} diff --git a/internal/web/menubar_popover_r2_test.go b/internal/web/menubar_popover_r2_test.go new file mode 100644 index 00000000..e5ea3eb4 --- /dev/null +++ b/internal/web/menubar_popover_r2_test.go @@ -0,0 +1,299 @@ +package web + +import ( + "strings" + "testing" +) + +// A version-1 cache entry can be valid JSON and still be structurally corrupt: +// providers:[null] passes the shallow "providers is an array" check, then the +// first sort dereferences item.id and throws before either fetch starts. The +// reader must validate the nested card/quota shapes the renderer needs and +// clear any entry that fails. +func TestMenubarCorruptCacheValidation(t *testing.T) { + html := readMenubarHTML(t) + + slice := menubarSlice(t, html, "function isRenderableProviderCard(", "function resolveThemeMode(") + + script := ` +const assert = require('node:assert/strict'); +const SNAPSHOT_CACHE_KEY = "menubar_snapshot_cache"; +const SNAPSHOT_CACHE_VERSION = 1; +const backing = new Map(); +const localStorage = { + getItem: (key) => (backing.has(key) ? backing.get(key) : null), + setItem: (key, value) => { backing.set(key, String(value)); }, + removeItem: (key) => { backing.delete(key); } +}; +const state = { snapshot: null, loading: true, error: "" }; +` + slice + ` +function put(snapshot) { + backing.set(SNAPSHOT_CACHE_KEY, JSON.stringify({ version: SNAPSHOT_CACHE_VERSION, snapshot })); +} +function rejects(snapshot, label) { + put(snapshot); + assert.equal(readCachedSnapshot(), null, label + " must be rejected"); + assert.ok(!backing.has(SNAPSHOT_CACHE_KEY), label + " must be cleared"); +} + +// Valid-JSON entries with the wrong nested shape. +rejects({ providers: [null] }, "providers:[null]"); +rejects({ providers: [{ id: "" , quotas: [] }] }, "an empty provider id"); +rejects({ providers: [{ id: "codex" }] }, "a provider without quotas"); +rejects({ providers: [{ id: "codex", quotas: null }] }, "null quotas"); +rejects({ providers: [{ id: "codex", quotas: [null] }] }, "a null quota"); +rejects({ providers: ["codex"] }, "a string provider"); +rejects({ providers: "codex" }, "a non-array providers field"); + +// A provider with no quotas is legitimate and must still restore. +put({ providers: [{ id: "codex", quotas: [] }] }); +const empty = readCachedSnapshot(); +assert.ok(empty, "a provider with an empty quota list must restore"); +assert.equal(empty.providers[0].id, "codex"); + +// Quota objects are accepted. +put({ providers: [{ id: "codex", quotas: [{ key: "weekly", label: "Weekly", percent: 40 }] }] }); +const full = readCachedSnapshot(); +assert.ok(full, "a provider with quota objects must restore"); +assert.equal(full.providers[0].quotas[0].percent, 40); +` + runMenubarNode(t, script) +} + +// Even a cache that passes validation must not be able to wedge the popover: +// the cached first paint is guarded, so a render exception clears the entry, +// resets to the empty state and lets initialization fetch normally. +func TestMenubarCorruptCacheRecovery(t *testing.T) { + html := readMenubarHTML(t) + + initSlice := menubarSlice(t, html, "async function init()", "async function loadPreferences(") + cacheSlice := menubarSlice(t, html, "function isRenderableProviderCard(", "function resolveThemeMode(") + + // init must paint through the guarded helper and still fetch. + if !strings.Contains(initSlice, "restoreCachedSnapshotSafely()") { + t.Fatal("init must render the cached snapshot through the guarded helper") + } + if !strings.Contains(initSlice, "loadSnapshot()") { + t.Fatal("init must still fetch after a failed cached render") + } + if strings.Index(initSlice, "restoreCachedSnapshotSafely()") > strings.Index(initSlice, "loadSnapshot()") { + t.Fatal("init must attempt the cached paint before fetching") + } + + script := ` +const assert = require('node:assert/strict'); +const SNAPSHOT_CACHE_KEY = "menubar_snapshot_cache"; +const SNAPSHOT_CACHE_VERSION = 1; +const backing = new Map(); +const localStorage = { + getItem: (key) => (backing.has(key) ? backing.get(key) : null), + setItem: (key, value) => { backing.set(key, String(value)); }, + removeItem: (key) => { backing.delete(key); } +}; +const state = { snapshot: null, loading: true, error: "" }; +let renders = 0; +let renderThrows = 0; +function render() { + renders += 1; + if (renderThrows > 0) { + renderThrows -= 1; + throw new Error("render failed"); + } +} +function applyTheme() {} +let fetches = 0; +async function loadPreferences() {} +async function loadSnapshot() { fetches += 1; } +` + cacheSlice + initSlice + ` +(async () => { + // Case 1: structurally corrupt but valid JSON. The reader clears it and + // init continues to fetch. + backing.set(SNAPSHOT_CACHE_KEY, JSON.stringify({ + version: SNAPSHOT_CACHE_VERSION, + snapshot: { providers: [null] } + })); + await init(); + assert.equal(fetches, 1, "init must fetch even when the cache is corrupt"); + assert.ok(!backing.has(SNAPSHOT_CACHE_KEY), "the corrupt entry must be cleared"); + assert.equal(state.snapshot, null, "a corrupt entry must not become the snapshot"); + + // Case 2: a cache that passes validation but whose first render throws. The + // entry is cleared, state returns to the empty loading view, and init still + // fetches. + fetches = 0; + renders = 0; + renderThrows = 1; + backing.set(SNAPSHOT_CACHE_KEY, JSON.stringify({ + version: SNAPSHOT_CACHE_VERSION, + snapshot: { providers: [{ id: "codex", quotas: [] }] } + })); + await init(); + assert.ok(!backing.has(SNAPSHOT_CACHE_KEY), "an unrenderable entry must be cleared"); + assert.equal(state.snapshot, null, "the failed cached render must be discarded"); + assert.equal(state.loading, true, "the popover must fall back to the loading state"); + assert.equal(fetches, 1, "init must fetch after a failed cached render"); + assert.ok(renders >= 2, "the empty state must be repainted after the failure"); +})().catch((error) => { console.error(error); process.exit(1); }); +` + runMenubarNode(t, script) +} + +// Manual, periodic, native-show and recovery refreshes overlap. A response +// that completes out of order must not replace newer live or persisted state. +func TestMenubarSnapshotResponseOrdering(t *testing.T) { + html := readMenubarHTML(t) + + slice := menubarSlice(t, html, "let snapshotGeneration = 0;", "async function refreshData(") + + script := ` +const assert = require('node:assert/strict'); +const API_BASE = ""; +const state = { snapshot: null, loading: true, error: "" }; +const saved = []; +function writeCachedSnapshot(snapshot) { saved.push(snapshot); } +function render() {} +` + slice + ` +const pending = []; +global.fetch = () => new Promise((resolve) => { pending.push(resolve); }); +const respond = (index, snapshot) => pending[index]({ ok: true, json: async () => snapshot }); + +(async () => { + const older = { + generated_at: "2026-01-01T00:00:00Z", + providers: [{ id: "codex", quotas: [], value: "old" }] + }; + const newer = { + generated_at: "2026-01-01T00:01:00Z", + providers: [{ id: "codex", quotas: [], value: "new" }] + }; + + // Reversed completion: the second request finishes first with newer data, + // then the first finishes with older data. + const first = loadSnapshot(); + const second = loadSnapshot(); + assert.equal(pending.length, 2, "both refreshes must be in flight"); + respond(1, newer); + await second; + assert.equal(state.snapshot.providers[0].value, "new", "the newer response must apply"); + assert.equal(saved.length, 1, "the newer response must be persisted"); + respond(0, older); + await first; + assert.equal(state.snapshot.providers[0].value, "new", "an older response must not roll live state back"); + assert.equal(saved.length, 1, "an older response must not be persisted"); + assert.equal(saved[0].providers[0].value, "new"); + + // A later request whose payload was built earlier must also be rejected. + const stale = loadSnapshot(); + respond(2, { + generated_at: "2026-01-01T00:00:30Z", + providers: [{ id: "codex", quotas: [], value: "time-travel" }] + }); + await stale; + assert.equal(state.snapshot.providers[0].value, "new", "an older build must not replace a newer one"); + assert.equal(saved.length, 1, "an older build must not be persisted"); + + // A genuinely newer build still applies and persists. + const freshest = loadSnapshot(); + respond(3, { + generated_at: "2026-01-01T00:02:00Z", + providers: [{ id: "codex", quotas: [], value: "newest" }] + }); + await freshest; + assert.equal(state.snapshot.providers[0].value, "newest"); + assert.equal(saved.length, 2); + assert.equal(saved[1].providers[0].value, "newest"); + + // A late failure from a superseded request must not surface an error over + // the newer data: the next two requests overlap, the newer one succeeds + // first, then the older one fails. + const failing = loadSnapshot(); + const succeeding = loadSnapshot(); + respond(5, { + generated_at: "2026-01-01T00:03:00Z", + providers: [{ id: "codex", quotas: [], value: "newest" }] + }); + await succeeding; + pending[4]({ ok: false, status: 503 }); + await failing; + assert.equal(state.error, "", "a superseded failure must not set the error"); +})().catch((error) => { console.error(error); process.exit(1); }); +` + runMenubarNode(t, script) +} + +// The attention state must not replace the dot's severity fill. A paused +// account at 100% still reads red, with attention carried by a ring. A card +// that is merely healthy but stale gets no attention treatment at all. +func TestMenubarAttentionDotPreservesSeverity(t *testing.T) { + html := readMenubarHTML(t) + + // Structurally, the attention rule must not set a background; severity + // classes own the fill. It carries an amber ring plus halo. + rule := menubarSlice(t, html, ".provider-status-dot.status-attention {", "}") + if strings.Contains(rule, "background:") { + t.Fatalf("the attention rule must not override the severity background:\n%s", rule) + } + if !strings.Contains(rule, "box-shadow") { + t.Fatalf("the attention rule must add a ring:\n%s", rule) + } + if !strings.Contains(rule, "var(--attention)") || !strings.Contains(rule, "var(--attention-halo)") { + t.Fatalf("the attention ring must use the themed attention variables:\n%s", rule) + } + + slice := menubarSlice(t, html, "function renderStandardCard(", "function renderStateCard(") + + script := ` +const assert = require('node:assert/strict'); +` + slice + ` +function escapeHTML(value) { return String(value == null ? "" : value); } +function providerIconClass() { return "provider-icon-codex"; } +function providerAgeLabel() { return "5m"; } +function clampPercent(value) { return Math.max(0, Math.min(100, Number(value || 0))); } +function providerQuotas(provider) { return Array.isArray(provider && provider.quotas) ? provider.quotas : []; } +function usageSummary() { return ""; } +function quotaStatusNote() { return ""; } +function fullQuotaLabel(quota) { return String(quota.label || ""); } +function compactResetLabel() { return "1h"; } +function ringColumns() { return 2; } +const mistralRecovery = { markup: () => "" }; + +const base = { + label: "Codex", + id: "codex", + base_provider: "codex", + status: "critical", + highest_percent: 100, + updated_at_time: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + quotas: [{ key: "weekly", label: "Weekly", percent: 100, status: "critical" }] +}; + +// Critical plus paused: the dot keeps its critical class (red fill) and adds +// the attention ring. +const paused = Object.assign({}, base, { connection_status: "paused" }); +const pausedHeader = standardHeaderMarkup(paused); +const pausedDot = pausedHeader.match(/class="provider-status-dot [^"]*"/)[0]; +assert.ok(pausedDot.includes("status-critical"), "a paused critical card must keep its critical severity"); +assert.ok(pausedDot.includes("status-attention"), "a paused card must carry the attention ring"); +assert.ok(pausedHeader.includes("provider-status-tip"), "the tooltip stays"); + +// Critical without an action keeps the plain severity dot. +const hot = Object.assign({}, base, { connection_status: "" }); +const hotHeader = standardHeaderMarkup(hot); +assert.ok(hotHeader.includes("status-critical")); +assert.ok(!hotHeader.includes("status-attention")); + +// Healthy but stale: no attention treatment and no severity change. +const stale = Object.assign({}, base, { + status: "healthy", + highest_percent: 20, + connection_status: "stale", + quotas: [{ key: "weekly", label: "Weekly", percent: 20, status: "healthy" }] +}); +const staleHeader = standardHeaderMarkup(stale); +assert.ok(staleHeader.includes("status-healthy")); +assert.ok(!staleHeader.includes("status-attention"), "staleness alone must not add the attention ring"); +assert.ok(!staleHeader.includes("quota-source-tag")); +assert.ok(!staleHeader.includes("status-critical"), "staleness alone must not change severity"); +` + runMenubarNode(t, script) +} diff --git a/internal/web/menubar_test.go b/internal/web/menubar_test.go index 76709140..a01319fe 100644 --- a/internal/web/menubar_test.go +++ b/internal/web/menubar_test.go @@ -992,6 +992,147 @@ func TestBuildMenubarSnapshotKimiResetFields(t *testing.T) { } } +// The per-card age label must count from the provider's own last stored +// snapshot time, not the summary build time. Otherwise a provider whose polls +// have stopped would keep reading "now" as long as the daemon serves snapshots +// for other providers. +func TestBuildMenubarSnapshotProviderAgeUsesStoredCapturedAt(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + capturedAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) + reset := time.Now().UTC().Add(3 * time.Hour) + if _, err := s.InsertGrokSnapshot(&api.GrokSnapshot{ + CapturedAt: capturedAt, + AccountID: 1, + Quotas: []api.GrokQuota{{Name: "credits", Utilization: 10, ResetsAt: &reset, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertGrokSnapshot: %v", err) + } + + cfg := &config.Config{ + GrokEnabled: true, + GrokToken: "g", + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + } + h := NewHandler(s, nil, nil, nil, cfg) + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + grok := findMenubarProviderCard(t, snapshot, "grok") + if grok.UpdatedAt != "2h ago" { + t.Fatalf("grok UpdatedAt = %q, want %q (from capturedAt, not build time)", grok.UpdatedAt, "2h ago") + } + // The raw timestamp rides along so clients can compute the age themselves. + wantTime := capturedAt.UTC().Format(time.RFC3339) + if grok.UpdatedAtTime != wantTime { + t.Fatalf("grok UpdatedAtTime = %q, want %q (stored capturedAt)", grok.UpdatedAtTime, wantTime) + } + // The header age must track the newest provider data, not the build time. + if snapshot.LatestAt.IsZero() { + t.Fatal("snapshot LatestAt is zero; the header age would read the build time") + } + if !snapshot.LatestAt.Equal(capturedAt) { + t.Fatalf("snapshot LatestAt = %s, want %s (newest capturedAt)", snapshot.LatestAt, capturedAt) + } + if snapshot.GeneratedAt.Before(snapshot.LatestAt) { + t.Fatalf("GeneratedAt %s is before LatestAt %s", snapshot.GeneratedAt, snapshot.LatestAt) + } +} + +// With no provider data, latest_at must be omitted entirely, not serialized as +// the zero time ("0001-01-01T00:00:00Z"), which a client would render as +// hundreds of thousands of days. +func TestBuildMenubarSnapshotOmitsZeroLatestAt(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + // No snapshots inserted and no provider enabled: nothing to report. + h := NewHandler(s, nil, nil, nil, &config.Config{ + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + }) + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + if !snapshot.LatestAt.IsZero() { + t.Fatalf("LatestAt = %s, want zero", snapshot.LatestAt) + } + + raw, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if strings.Contains(string(raw), "latest_at") { + t.Fatalf("zero latest_at must be omitted, got %s", raw) + } + if strings.Contains(string(raw), "0001-01-01") { + t.Fatalf("zero time leaked into JSON: %s", raw) + } +} + +// Kimi's capturedAt must be the stored snapshot time, not the build time, so +// the menubar age for a stalled Kimi poll keeps growing. +func TestBuildMenubarSnapshotKimiCapturedAtIsStoredTime(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + capturedAt := time.Now().UTC().Add(-90 * time.Minute).Truncate(time.Second) + reset := time.Now().UTC().Add(3 * time.Hour) + if _, err := s.InsertKimiSnapshot(&api.KimiSnapshot{ + CapturedAt: capturedAt, + AccountID: 1, + UserID: "u1", + Quotas: []api.KimiQuota{{Name: api.KimiQuotaSevenDay, Utilization: 40, ResetsAt: &reset, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertKimiSnapshot: %v", err) + } + + cfg := &config.Config{ + KimiEnabled: true, + KimiToken: "kimi-test", + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + } + h := NewHandler(s, nil, nil, nil, cfg) + + // The menubar builder (buildKimiCurrentOK) carries the stored time. The + // dashboard builder restamps capturedAt with the build time, as main did, + // so check the age-label work on the menubar payload. + payload, _, _ := h.buildKimiCurrentOK() + got, _ := payload["capturedAt"].(string) + if got != capturedAt.Format(time.RFC3339) { + t.Fatalf("kimi capturedAt = %q, want %q (stored snapshot time)", got, capturedAt.Format(time.RFC3339)) + } + + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot: %v", err) + } + kimi := findMenubarProviderCard(t, snapshot, "kimi") + if kimi.UpdatedAt != "1h ago" { + t.Fatalf("kimi UpdatedAt = %q, want %q", kimi.UpdatedAt, "1h ago") + } +} + func TestMenubarPageAllowsEditorFrames(t *testing.T) { h, s := newMenubarTestHandler(t) defer s.Close() diff --git a/internal/web/menubar_theme_test.go b/internal/web/menubar_theme_test.go new file mode 100644 index 00000000..30fc5fe8 --- /dev/null +++ b/internal/web/menubar_theme_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +// The header age must track the newest provider data (latest_at), not the +// snapshot build time, so it keeps growing when every provider has stopped +// polling. The page must also report its effective theme to a native host so +// the host can match its own background layers. +func TestMenubarHeaderAgeAndThemeReport(t *testing.T) { + page, err := staticFS.ReadFile("static/menubar.html") + if err != nil { + t.Fatal(err) + } + html := string(page) + + // The header must read latest_at, not generated_at. + if !strings.Contains(html, "state.snapshot.latest_at") { + t.Fatal("menubar.html header age must use snapshot.latest_at") + } + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node required for menubar header age test") + } + + // applyTheme reports the effective theme through sendNativeAction. + themeStart := strings.Index(html, "function applyTheme()") + themeEnd := strings.Index(html[themeStart:], "function queueResizeReport()") + themeStart + if themeStart < 0 || themeEnd < themeStart { + t.Fatal("applyTheme not found in menubar.html") + } + themeSlice := html[themeStart:themeEnd] + + // ageLabelFromTimestamp is shared with the provider labels. + ageStart := strings.Index(html, "function compactUpdatedLabel(") + ageEnd := strings.Index(html, "// quotaLabelSemantic maps") + if ageStart < 0 || ageEnd < ageStart { + t.Fatal("age label helpers not found in menubar.html") + } + ageSlice := html[ageStart:ageEnd] + + test := ` +const assert = require('node:assert/strict'); +` + ageSlice + ` +// The header age for a stale snapshot counts from latest_at, not generated_at. +const nowMs = 1_700_000_000_000; +Date.now = () => nowMs; +const hour = 60 * 60 * 1000; +const snapshot = { + generated_at: new Date(nowMs).toISOString(), + latest_at: new Date(nowMs - 2 * hour).toISOString(), + updated_ago: "just now" +}; +assert.equal(ageLabelFromTimestamp(snapshot.latest_at, snapshot.updated_ago), "2h"); +// Missing latest_at falls back to the server string. +assert.equal(ageLabelFromTimestamp(undefined, snapshot.updated_ago), "now"); + +// applyTheme posts the effective theme through the native channel. +const posted = []; +function sendNativeAction(action) { posted.push(action); return true; } +const state = { preferences: { theme: "dark" }, settingsOpen: false, draft: null, systemPrefersDark: false }; +function resolveThemeMode() { return "dark"; } +const document = { documentElement: { setAttribute() {}, style: {} } }; +` + themeSlice + ` +applyTheme(); +assert.deepEqual(posted, [{ action: "theme", theme: "dark" }]); +` + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node) + cmd.Stdin = strings.NewReader(test) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, output) + } +} diff --git a/internal/web/menubar_tray_title_test.go b/internal/web/menubar_tray_title_test.go new file mode 100644 index 00000000..c34b5661 --- /dev/null +++ b/internal/web/menubar_tray_title_test.go @@ -0,0 +1,111 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// With one stored provider snapshot the tray-title response must carry +// `latest_at` as RFC3339 so the GNOME extension (and any other tray +// companion) can keep counting up even after the daemon stops responding. +// The header age cannot be derived from a build time when the daemon is gone. +func TestMenubarTrayTitleIncludesLatestAtWhenProviderDataPresent(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + capturedAt := time.Now().UTC().Add(-25 * time.Minute).Truncate(time.Second) + reset := capturedAt.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, capturedAt, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 40, Limit: 100, Utilization: 40, Format: api.CursorFormatPercent, ResetsAt: &reset}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/tray-title", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + h.MenubarTrayTitle(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + raw, ok := body["latest_at"] + if !ok { + t.Fatalf("latest_at missing from tray-title response: %s", rr.Body.String()) + } + got, ok := raw.(string) + if !ok { + t.Fatalf("latest_at = %T (%v), want string", raw, raw) + } + want := capturedAt.UTC().Format(time.RFC3339) + if got != want { + t.Fatalf("latest_at = %q, want %q", got, want) + } +} + +// With no providers configured and no stored data, the tray-title response +// must omit `latest_at` entirely: the zero time would render as +// "0001-01-01T00:00:00Z" on clients that treat it as a real timestamp. +func TestMenubarTrayTitleOmitsLatestAtWhenNoProviderData(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + h := NewHandler(s, nil, nil, nil, &config.Config{ + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/tray-title", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + h.MenubarTrayTitle(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + raw := rr.Body.String() + if _, ok := decodeTrayTitleHasKey(raw, "latest_at"); ok { + t.Fatalf("latest_at must be omitted when no provider has data, got body: %s", raw) + } + if strings.Contains(raw, "0001-01-01") { + t.Fatalf("zero time leaked into JSON: %s", raw) + } +} + +// decodeTrayTitleHasKey reports whether `key` appears in the JSON object's +// own keys (not inside any nested object). It does not parse the value. +func decodeTrayTitleHasKey(raw, key string) (interface{}, bool) { + var body map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &body); err != nil { + return nil, false + } + value, ok := body[key] + if !ok { + return nil, false + } + var out interface{} + if err := json.Unmarshal(value, &out); err != nil { + return raw, true + } + return out, true +} diff --git a/internal/web/mistral_handlers.go b/internal/web/mistral_handlers.go index cc211c46..14f82c48 100644 --- a/internal/web/mistral_handlers.go +++ b/internal/web/mistral_handlers.go @@ -14,11 +14,21 @@ import ( var mistralDisplayNames = map[string]string{"api_included": "Included API usage", "vibe_included": "Included Vibe Code usage"} func (h *Handler) buildMistralCurrent() map[string]interface{} { + result, _, _ := h.buildMistralCurrentOK() + return result +} + +// buildMistralCurrentOK is buildMistralCurrent with an explicit storage +// outcome. readOK is false when the read timed out or failed, which leaves only +// a connection-only placeholder; the menubar serves the last good card in that +// case instead of replacing real quota data with it. hasData reports whether a +// stored snapshot backed the payload. +func (h *Handler) buildMistralCurrentOK() (result map[string]interface{}, readOK bool, hasData bool) { showBilling := h.showMistralBilling() - result := map[string]interface{}{"quotas": []interface{}{}, "status": "waiting", "showBilling": showBilling, "billing": map[string]interface{}{"amount": nil, "status": "unavailable"}} + result = map[string]interface{}{"quotas": []interface{}{}, "status": "waiting", "showBilling": showBilling, "billing": map[string]interface{}{"amount": nil, "status": "unavailable"}} result["connection"] = h.mistralConnection() if h.store == nil { - return result + return result, true, false } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -26,8 +36,11 @@ func (h *Handler) buildMistralCurrent() map[string]interface{} { result["status"] = status } snap, e := h.store.LatestMistral(ctx) - if e != nil || snap == nil { - return result + if e != nil { + return result, false, false + } + if snap == nil { + return result, true, false } if result["status"] == "waiting" { result["status"] = snap.Status @@ -68,7 +81,7 @@ func (h *Handler) buildMistralCurrent() map[string]interface{} { result["status"] = "ok" } applyDisplayModeToResponse(result, h.getDisplayMode("mistral")) - return result + return result, true, true } // This is a display preference, read live so changing it needs no restart. diff --git a/internal/web/mistral_stale_test.go b/internal/web/mistral_stale_test.go index 205364e1..e75eea28 100644 --- a/internal/web/mistral_stale_test.go +++ b/internal/web/mistral_stale_test.go @@ -12,7 +12,9 @@ func TestMistralReconnectCard(t *testing.T) { want string }{ {"reconnect", true, "warning"}, - {"stale", false, "warning"}, + // Staleness is carried by the age label only; the connection_status + // still records "stale" but usage severity alone decides the dot. + {"stale", false, "healthy"}, {"partial", true, "warning"}, {"ok", false, "healthy"}, } { diff --git a/internal/web/muse_handlers.go b/internal/web/muse_handlers.go index df215295..3430096a 100644 --- a/internal/web/muse_handlers.go +++ b/internal/web/muse_handlers.go @@ -196,20 +196,33 @@ func buildMuseBurnRateInsight(quota api.MuseQuota, rate museQuotaRate) insightIt } func (h *Handler) buildMuseCurrent() map[string]interface{} { + response, _, _ := h.buildMuseCurrentOK() + return response +} + +// buildMuseCurrentOK is buildMuseCurrent with an explicit storage outcome. +// readOK is false only when the read that supplied the payload failed. The +// per-quota read is a refinement, so its failure still leaves the latest +// snapshot payload intact. +func (h *Handler) buildMuseCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestMuse() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) if tier := api.MuseDisplayTier(latest.Tier); tier != "" { @@ -287,7 +300,7 @@ func (h *Handler) buildMuseCurrent() map[string]interface{} { response["quotas"] = append(response["quotas"].([]interface{}), quotaFromLatest(q, latest.CapturedAt)) } applyDisplayModeToResponse(response, h.getDisplayMode("muse")) - return response + return response, true, hasData } sort.SliceStable(latestPerQuota, func(i, j int) bool { @@ -312,7 +325,7 @@ func (h *Handler) buildMuseCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("muse")) - return response + return response, true, hasData } func (h *Handler) historyMuse(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/ollama_handlers.go b/internal/web/ollama_handlers.go index c65263e6..70d7f455 100644 --- a/internal/web/ollama_handlers.go +++ b/internal/web/ollama_handlers.go @@ -175,20 +175,33 @@ func buildOllamaBurnRateInsight(quota api.OllamaQuota, rate ollamaQuotaRate) ins } func (h *Handler) buildOllamaCurrent() map[string]interface{} { + response, _, _ := h.buildOllamaCurrentOK() + return response +} + +// buildOllamaCurrentOK is buildOllamaCurrent with an explicit storage outcome. +// readOK is false only when the read that supplied the payload failed. The +// per-quota read is a refinement, so its failure still leaves the latest +// snapshot payload intact. +func (h *Handler) buildOllamaCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestOllama() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) response["plan"] = latest.Plan @@ -245,7 +258,7 @@ func (h *Handler) buildOllamaCurrent() map[string]interface{} { response["quotas"] = append(response["quotas"].([]interface{}), quotaFromLatest(q, latest.CapturedAt)) } applyDisplayModeToResponse(response, h.getDisplayMode("ollama")) - return response + return response, true, hasData } sort.SliceStable(latestPerQuota, func(i, j int) bool { @@ -270,7 +283,7 @@ func (h *Handler) buildOllamaCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("ollama")) - return response + return response, true, hasData } func (h *Handler) historyOllama(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/opencode_handlers.go b/internal/web/opencode_handlers.go index be721571..726a2387 100644 --- a/internal/web/opencode_handlers.go +++ b/internal/web/opencode_handlers.go @@ -191,20 +191,33 @@ func buildOpenCodeBurnRateInsight(quota api.OpenCodeQuota, rate opencodeQuotaRat } func (h *Handler) buildOpenCodeCurrent() map[string]interface{} { + response, _, _ := h.buildOpenCodeCurrentOK() + return response +} + +// buildOpenCodeCurrentOK is buildOpenCodeCurrent with an explicit storage +// outcome. readOK is false only when the read that supplied the payload failed. +// The per-quota read is a refinement, so its failure still leaves the latest +// snapshot payload intact. +func (h *Handler) buildOpenCodeCurrentOK() (response map[string]interface{}, readOK bool, hasData bool) { now := time.Now().UTC() - response := map[string]interface{}{ + response = map[string]interface{}{ "capturedAt": now.Format(time.RFC3339), "quotas": []interface{}{}, } if h.store == nil { - return response + return response, true, false } latest, err := h.store.QueryLatestOpenCode() - if err != nil || latest == nil { - return response + if err != nil { + return response, false, false + } + if latest == nil { + return response, true, false } + hasData = true response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) response["accountType"] = string(latest.AccountType) @@ -241,7 +254,7 @@ func (h *Handler) buildOpenCodeCurrent() map[string]interface{} { response["quotas"] = append(response["quotas"].([]interface{}), quotaMap) } applyDisplayModeToResponse(response, h.getDisplayMode("opencode")) - return response + return response, true, hasData } sort.SliceStable(latestPerQuota, func(i, j int) bool { @@ -286,7 +299,7 @@ func (h *Handler) buildOpenCodeCurrent() map[string]interface{} { } response["quotas"] = quotas applyDisplayModeToResponse(response, h.getDisplayMode("opencode")) - return response + return response, true, hasData } func (h *Handler) historyOpenCode(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/static/menubar.html b/internal/web/static/menubar.html index bf63b5f0..e1c92a6c 100644 --- a/internal/web/static/menubar.html +++ b/internal/web/static/menubar.html @@ -52,6 +52,11 @@ --critical: #ef4444; --track: rgba(255, 255, 255, 0.08); --focus: rgba(96, 165, 250, 0.42); + /* "Needs action" dot: a saturated amber, deliberately brighter and + more orange than --warning so a paused/reconnect card reads as a + call to action, not just high usage. */ + --attention: #fbbf24; + --attention-halo: rgba(251, 191, 36, 0.22); } :root[data-theme="light"] { @@ -80,6 +85,9 @@ --text-secondary: rgba(15, 23, 42, 0.78); --track: rgba(15, 23, 42, 0.12); --focus: rgba(37, 99, 235, 0.45); + /* Darker amber on the light card so the dot keeps contrast. */ + --attention: #d97706; + --attention-halo: rgba(217, 119, 6, 0.2); } * { @@ -507,6 +515,75 @@ background: var(--critical); } + /* Attention state: the card needs a user action (re-auth, reconnect, + browser access). It deliberately does NOT touch background, so the + severity classes keep owning the fill: a paused account at 100% + still reads red. Attention is a separate amber ring plus a soft + halo around the dot, visible on both card surfaces. */ + .provider-status-dot.status-attention { + box-shadow: + 0 0 0 1.5px var(--attention), + 0 0 0 3.5px var(--attention-halo); + } + + /* Hover/focus tooltip for the dot. Native title tooltips are unreliable + inside the non-activating macOS WKWebView panel, so the reason and + the action are rendered here. */ + .provider-status { + position: relative; + display: inline-flex; + align-items: center; + border-radius: 999px; + } + + .provider-status:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + + .provider-status-tip { + display: none; + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 40; + max-width: 220px; + width: max-content; + padding: 5px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card-strong); + color: var(--text-primary); + font-size: var(--font-xs); + font-weight: 500; + line-height: 1.3; + letter-spacing: 0; + text-align: left; + white-space: normal; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32); + pointer-events: none; + } + + /* display, not opacity: a hidden absolutely positioned tip still + contributes scrollable overflow, which would add phantom scroll space + to the popover body. */ + .provider-status:hover .provider-status-tip, + .provider-status:focus-within .provider-status-tip, + .provider-status:focus .provider-status-tip { + display: block; + } + + /* The scrollable body clips a tip that hangs past its bottom edge, so a + card near the bottom flips its tip above the dot. */ + .provider-status.tip-above .provider-status-tip { + top: auto; + bottom: calc(100% + 6px); + } + + :root[data-theme="light"] .provider-status-tip { + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.16); + } + .mistral-recovery { gap: var(--space-sm); margin: var(--space-sm) 0; @@ -1568,6 +1645,11 @@ const VIEW_STORAGE_KEY = "menubar_view_mode"; const ORDER_STORAGE_KEY = "menubar_provider_order"; const THEME_STORAGE_KEY = "menubar_theme_mode"; + // The last successful summary, so the popover paints instantly on + // open instead of flashing a loading card. One versioned entry, + // overwritten on every success. + const SNAPSHOT_CACHE_KEY = "menubar_snapshot_cache"; + const SNAPSHOT_CACHE_VERSION = 1; const VIEW_ORDER = ["minimal", "standard", "detailed"]; const THEME_MODES = ["system", "light", "dark"]; @@ -1653,6 +1735,7 @@ const settingsNote = document.getElementById("settings-note"); const footerVersion = document.getElementById("footer-version"); let refreshTimer = null; + let ageTickTimer = null; let resizeFrame = 0; let resizeTimer = 0; let lastReportedHeight = null; @@ -1735,6 +1818,22 @@ settingsBody.addEventListener("dragover", handleSettingsDragOver); settingsBody.addEventListener("drop", handleSettingsDrop); settingsBody.addEventListener("dragend", handleSettingsDragEnd); + // Flip a status tooltip above its dot when it would be clipped by + // the bottom of the scrollable body. Event delegation keeps this + // working across re-renders, and the tip is shown by CSS hover, so + // mouseover/focus is the first moment it has a measurable box. + document.addEventListener("mouseover", (event) => { + const status = event.target instanceof Element ? event.target.closest(".provider-status") : null; + if (status) { + positionStatusTip(status); + } + }); + document.addEventListener("focusin", (event) => { + const status = event.target instanceof Element ? event.target.closest(".provider-status") : null; + if (status) { + positionStatusTip(status); + } + }); window.addEventListener("resize", queueResizeReport); // A host that missed the first report (bridge or iframe listener not // yet ready) must still get a later one. Re-arm the debounce on @@ -1752,6 +1851,12 @@ async function init() { applyTheme(); + // Paint the last-known snapshot immediately, then fetch. Ages + // are computed from the stored timestamps, so a restored + // snapshot shows its real age rather than looking live. The + // guarded variant clears a cache that cannot render and falls + // back to the empty state, so startup always reaches the fetch. + restoreCachedSnapshotSafely(); render(); await Promise.all([loadPreferences(), loadSnapshot()]); } @@ -1796,8 +1901,54 @@ } } + // Manual, periodic, native-show and recovery refreshes can overlap. + // Each request takes a generation when it starts, and a response + // is applied and persisted only if it is newer than the last one + // applied. The snapshot's generated_at is a second guard, so a + // request that started later but carries an older build cannot + // roll the numbers and ages backwards either. + let snapshotGeneration = 0; + let appliedSnapshotGeneration = 0; + let appliedSnapshotTime = null; + + function snapshotBuildTime(snapshot) { + const raw = snapshot && (snapshot.generated_at || snapshot.generatedAt); + const parsed = raw ? Date.parse(raw) : NaN; + return Number.isFinite(parsed) ? parsed : null; + } + + function isNewerSnapshot(generation, snapshot) { + if (generation <= appliedSnapshotGeneration) { + return false; + } + const built = snapshotBuildTime(snapshot); + if (built !== null && appliedSnapshotTime !== null && built < appliedSnapshotTime) { + return false; + } + return true; + } + + function applySnapshot(generation, snapshot) { + if (!isNewerSnapshot(generation, snapshot)) { + return false; + } + appliedSnapshotGeneration = generation; + const built = snapshotBuildTime(snapshot); + if (built !== null) { + appliedSnapshotTime = built; + } + state.snapshot = snapshot; + state.error = ""; + // Live state and the persisted cache follow the same rule. + writeCachedSnapshot(snapshot); + return true; + } + async function loadSnapshot(signal) { - state.loading = state.snapshot == null; + const generation = ++snapshotGeneration; + if (generation > appliedSnapshotGeneration) { + state.loading = state.snapshot == null; + } render(); try { const response = await fetch(API_BASE + "/api/menubar/summary", { @@ -1808,13 +1959,18 @@ if (!response.ok) { throw new Error(`summary ${response.status}`); } - state.snapshot = await response.json(); - state.error = ""; + applySnapshot(generation, await response.json()); } catch (error) { if (signal) throw error; - state.error = error && error.message ? error.message : "Unable to load menubar data."; + // A superseded request's failure must not replace the error + // state of a newer, already-applied snapshot. + if (generation >= appliedSnapshotGeneration) { + state.error = error && error.message ? error.message : "Unable to load menubar data."; + } } finally { - state.loading = false; + if (generation >= appliedSnapshotGeneration) { + state.loading = false; + } render(); } } @@ -2201,7 +2357,13 @@ headerValue.classList.remove("hidden"); headerValue.setAttribute("aria-hidden", "false"); headerCount.textContent = `${providers.length} ${providers.length === 1 ? "provider" : "providers"}`; - headerUpdated.textContent = compactUpdatedLabel(state.snapshot && state.snapshot.updated_ago); + // Header age tracks the newest provider data (latest_at), not + // the snapshot build time, so it keeps growing when every + // provider has stopped polling. + headerUpdated.textContent = ageLabelFromTimestamp( + state.snapshot && state.snapshot.latest_at, + state.snapshot && state.snapshot.updated_ago + ); } function renderBody() { @@ -2479,38 +2641,92 @@

Provider Order

return "Healthy"; } - // The status dot carries the status; the tooltip explains a paused or - // stale card without inline text (minimal-UI rule, issue #147). - function providerDotTitle(provider) { - const peak = Math.round(clampPercent(provider && provider.highest_percent)); - const updated = compactUpdatedLabel(provider && provider.updated_at); - const connection = String(provider && provider.connection_status || ""); + // providerAttention is the action a card needs from the user, or + // null when there is nothing to do. Staleness is deliberately not + // an attention state: the age label already carries it, and a + // second stale indicator is what the inline tag used to be + // (minimal-UI rule, issue #147). + function providerAttention(provider) { + if (!provider) { + return null; + } + if (provider.connection && provider.connection.reason === "browser_access_denied") { + return "Browser access required - allow access from the menu"; + } + const connection = String(provider.connection_status || ""); + if (connection === "reconnect") { + return "Reconnect required - open the dashboard to reconnect"; + } if (connection === "paused") { - return `Re-authentication required - last updated ${updated}`; + const base = String(provider.base_provider || provider.id || "").split(":")[0]; + if (base === "codex") { + return "Codex auth failed - run `codex auth`"; + } + const label = String(provider.label || "Provider"); + return `${label} auth failed - re-authenticate to resume`; } - if (connection === "stale") { - return `Updates unavailable - last updated ${updated}`; + return null; + } + + // providerDotTitle is the dot's accessible label and tooltip text: + // the action the card needs when it has one, otherwise the usage + // severity the dot has always carried. + function providerDotTitle(provider) { + const attention = providerAttention(provider); + if (attention) { + return attention; } + const peak = Math.round(clampPercent(provider && provider.highest_percent)); return `${standardStatusLabel(provider)} - peak ${peak}%`; } + // providerStatusMarkup is the right-hand status cluster: the dot + // plus its hover/focus tooltip. The tooltip is a custom element + // because native title tooltips do not show reliably inside the + // non-activating macOS WKWebView panel. The dot keeps an + // aria-label so the reason is announced without the tooltip. + function providerStatusMarkup(provider) { + const status = String(provider && provider.status || "healthy"); + const dotTitle = providerDotTitle(provider); + const attentionClass = providerAttention(provider) ? " status-attention" : ""; + return ` + + + + + `; + } + + // positionStatusTip flips a tip that would hang past the bottom of + // the scrollable body up above its dot, so the text is never + // clipped by the popover edge. The tip is display:none until it is + // shown, so this runs on hover/focus, when it has a real box. + function positionStatusTip(el) { + el.classList.remove("tip-above"); + const tip = el.querySelector(".provider-status-tip"); + if (!tip) { + return; + } + const container = body.getBoundingClientRect(); + if (tip.getBoundingClientRect().bottom > container.bottom - 2) { + el.classList.add("tip-above"); + } + } + // standardHeaderMarkup keeps the icon next to a normally colored // name, then a compact right cluster with the freshness label and a // status dot whose tooltip carries the status. function standardHeaderMarkup(provider) { - const status = String(provider && provider.status || "healthy"); - const dotTitle = providerDotTitle(provider); return `
${escapeHTML(provider.label)} - ${provider.connection?.reason === 'browser_access_denied' ? 'Browser access required' : provider.connection_status === 'reconnect' ? 'Reconnect required' : provider.connection_status === 'stale' ? 'Updates unavailable' : ''} ${provider.promo ? `${escapeHTML(promoLabel(provider.promo))}` : ''}
- ${escapeHTML(compactUpdatedLabel(provider.updated_at))} - + ${escapeHTML(providerAgeLabel(provider))} + ${providerStatusMarkup(provider)}
${provider.base_provider === "mistral" && provider.subtitle ? `
${escapeHTML(provider.subtitle)}
` : ""} @@ -2610,10 +2826,12 @@

Provider Order

${escapeHTML(provider.label)} - ${provider.connection?.reason === 'browser_access_denied' ? 'Browser access required' : provider.connection_status === 'reconnect' ? 'Reconnect required' : provider.connection_status === 'stale' ? 'Updates unavailable' : provider.connection_status === 'paused' ? 'Re-auth required' : ''} ${provider.promo ? `${escapeHTML(promoLabel(provider.promo))}` : ''}
- ${escapeHTML(compactUpdatedLabel(provider.updated_at))} + + ${escapeHTML(providerAgeLabel(provider))} + ${providerStatusMarkup(provider)} + ${provider.base_provider === "mistral" && provider.subtitle ? `
${escapeHTML(provider.subtitle)}
` : ""} ${provider.base_provider === 'mistral' ? mistralRecovery.markup(provider.connection, provider.connection_status) : ''} @@ -2842,6 +3060,116 @@

Provider Order

} } + // isRenderableProviderCard checks the nested shape the renderer + // dereferences. A version-1 entry such as providers:[null] is + // valid JSON but throws on item.id during the first sort, so the + // cache must reject it before any render. + function isRenderableProviderCard(provider) { + return provider !== null && typeof provider === "object" && !Array.isArray(provider) + && typeof provider.id === "string" && provider.id !== "" + && Array.isArray(provider.quotas) + && provider.quotas.every((quota) => quota !== null && typeof quota === "object" && !Array.isArray(quota)); + } + + // A provider with an empty quota list is legitimate; a missing or + // null quota list is not (the server always emits an array). + function isRenderableSnapshot(snapshot) { + return snapshot !== null && typeof snapshot === "object" && !Array.isArray(snapshot) + && Array.isArray(snapshot.providers) + && snapshot.providers.every(isRenderableProviderCard); + } + + // readCachedSnapshot returns the last successful summary, or null + // when there is none, it is corrupt, or it was written by a + // different cache version. A bad entry is cleared so it cannot keep + // failing on every open. + function readCachedSnapshot() { + let raw; + try { + raw = localStorage.getItem(SNAPSHOT_CACHE_KEY); + } catch (error) { + return null; + } + if (!raw) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + clearCachedSnapshot(); + return null; + } + const snapshot = parsed && parsed.snapshot; + if (!parsed || parsed.version !== SNAPSHOT_CACHE_VERSION + || !isRenderableSnapshot(snapshot)) { + clearCachedSnapshot(); + return null; + } + return snapshot; + } + + function writeCachedSnapshot(snapshot) { + if (!isRenderableSnapshot(snapshot)) { + return; + } + try { + localStorage.setItem(SNAPSHOT_CACHE_KEY, JSON.stringify({ + version: SNAPSHOT_CACHE_VERSION, + savedAt: new Date().toISOString(), + snapshot + })); + } catch (error) { + // Ignore local storage errors inside the popover. + } + } + + function clearCachedSnapshot() { + try { + localStorage.removeItem(SNAPSHOT_CACHE_KEY); + } catch (error) { + // Ignore local storage errors inside the popover. + } + } + + // restoreCachedSnapshot paints the last-known data before the first + // fetch. It never replaces data already in memory, so a slow open + // cannot roll a fresher render back to an older snapshot. + function restoreCachedSnapshot() { + if (state.snapshot) { + return; + } + const cached = readCachedSnapshot(); + if (!cached) { + return; + } + state.snapshot = cached; + state.loading = false; + } + + // restoreCachedSnapshotSafely is the startup path: a cache entry + // that passes structural validation can still fail to render. If + // it does, drop the entry and reset to the empty loading state so + // initialization can fetch instead of wedging every open. + function restoreCachedSnapshotSafely() { + restoreCachedSnapshot(); + if (!state.snapshot) { + return; + } + try { + render(); + } catch (error) { + clearCachedSnapshot(); + state.snapshot = null; + state.loading = true; + try { + render(); + } catch (ignored) { + // Normal initialization below still fetches. + } + } + } + function resolveThemeMode() { const draft = state.settingsOpen && state.draft ? state.draft : state.preferences; return normalizeTheme(draft && draft.theme); @@ -2853,6 +3181,10 @@

Provider Order

const effective = mode === "system" ? (state.systemPrefersDark ? "dark" : "light") : mode; root.setAttribute("data-theme", effective); root.style.colorScheme = effective; + // Tell a native host the page's effective theme so it can match + // its own background layers, even when onWatch's theme differs + // from the OS. Hosts without the channel ignore this. + sendNativeAction({ action: "theme", theme: effective }); } function queueResizeReport() { @@ -3217,6 +3549,44 @@

Provider Order

return label === "0m" ? "now" : label; } + // ageLabelFromTimestamp renders an age from a raw RFC3339 timestamp + // against this client's wall clock, so it keeps counting up even when + // the daemon is unreachable. Falls back to the server's pre-rendered + // string when no timestamp is available. + function ageLabelFromTimestamp(raw, fallback) { + if (!raw) { + return compactUpdatedLabel(fallback); + } + const then = Date.parse(raw); + // Reject unparseable, non-positive, and absurdly old timestamps. + // A zero time.Time serializes as 0001-01-01, which would read as + // hundreds of thousands of days; fall back instead. + const minValid = Date.UTC(2000, 0, 1); + if (!Number.isFinite(then) || then <= 0 || then < minValid) { + return compactUpdatedLabel(fallback); + } + const seconds = Math.max(0, (Date.now() - then) / 1000); + if (seconds < 60) { + return "now"; + } + if (seconds < 3600) { + return Math.floor(seconds / 60) + "m"; + } + if (seconds < 86400) { + return Math.floor(seconds / 3600) + "h"; + } + return Math.floor(seconds / 86400) + "d"; + } + + // providerAgeLabel counts from the provider's own last successful + // data time (updated_at_time), not the snapshot build time. + function providerAgeLabel(provider) { + if (!provider) { + return "--"; + } + return ageLabelFromTimestamp(provider.updated_at_time || provider.updatedAtTime, provider.updated_at); + } + function compactResetLabel(quota) { return compactTimeLabel(quota.time_until_reset || quota.reset_at); } @@ -3420,6 +3790,27 @@

Provider Order

return Math.max(0, Math.min(100, numeric)); } + // updateAgeLabels refreshes every provider age label from its + // recorded timestamp without rebuilding the DOM, so a long-lived + // popover (or one whose daemon is unreachable) keeps counting up + // without a repaint. + function updateAgeLabels() { + document.querySelectorAll(".provider-updated[data-updated-at]").forEach((el) => { + const raw = el.getAttribute("data-updated-at") || ""; + const fallback = el.getAttribute("data-updated-fallback") || ""; + if (!raw) { + return; + } + el.textContent = ageLabelFromTimestamp(raw, fallback); + }); + if (state.snapshot) { + headerUpdated.textContent = ageLabelFromTimestamp( + state.snapshot.latest_at, + state.snapshot.updated_ago + ); + } + } + function resetRefreshTimer() { if (refreshTimer) { window.clearInterval(refreshTimer); @@ -3427,6 +3818,15 @@

Provider Order

refreshTimer = window.setInterval(() => { void loadSnapshot(); }, Math.max(Number(state.preferences.refresh_seconds) || 60, 10) * 1000); + // Age labels are computed from client wall-clock time, so they + // must be refreshed between fetches: that is what lets them keep + // counting up when the daemon is unreachable and the fetch above + // fails. Update the text in place (no re-render, no flash) and + // make no API calls. + if (ageTickTimer) { + window.clearInterval(ageTickTimer); + } + ageTickTimer = window.setInterval(updateAgeLabels, 30000); } // Promo countdown: update tags every 30s without API calls From da754db67f5f4cfe62be5ffff976b706919963c4 Mon Sep 17 00:00:00 2001 From: prakersh Date: Wed, 21 Oct 2026 15:48:32 +0530 Subject: [PATCH 02/56] fix(cursor): accept numeric fields sent as JSON strings (#151) Cursor now returns spend fields such as spendLimitUsage.pooledLimit as strings ("50000"), which aborted the whole usage decode and dropped the provider on every poll, including free plans. - Tolerant int/float types accept numbers, quoted numbers, "" and null - A field that still cannot be parsed degrades to zero with one warning (count only, no values) instead of failing the response - Team is no longer inferred from pooledLimit; it requires a team or business plan name or limitType "team" --- docs/CURSOR_SETUP.md | 6 +- internal/api/cursor_types.go | 418 +++++++++++++++++++++++--- internal/api/cursor_types_test.go | 476 ++++++++++++++++++++++++++++++ 3 files changed, 860 insertions(+), 40 deletions(-) diff --git a/docs/CURSOR_SETUP.md b/docs/CURSOR_SETUP.md index e7103516..a916b92f 100644 --- a/docs/CURSOR_SETUP.md +++ b/docs/CURSOR_SETUP.md @@ -62,9 +62,13 @@ If the refresh token is invalid (e.g., revoked), onWatch pauses polling and logs onWatch automatically detects your account type: - **Individual**: `planName` is "pro", "ultra", or "free", or `spendLimitUsage.limitType` is "user" -- **Team**: `planName` is "team"/"business", or `limitType` is "team", or `pooledLimit` is present +- **Team**: `planName` is "team"/"business", or `limitType` is "team" - **Enterprise**: `planName` is "enterprise" (uses request-based `/api/usage` instead of Connect RPC) +`spendLimitUsage.pooledLimit` is no longer used to infer a Team account. Free plans also send this field, so only the plan name or `limitType` decides Team status. + +Numeric usage fields are decoded tolerantly: Cursor may send an amount as a JSON number (`50000`) or a quoted string (`"50000"`). Fields that still cannot be parsed are treated as zero and logged once, instead of failing the whole poll. + ## API Endpoints Used | Endpoint | Purpose | diff --git a/internal/api/cursor_types.go b/internal/api/cursor_types.go index 0ce6e599..8864bdb7 100644 --- a/internal/api/cursor_types.go +++ b/internal/api/cursor_types.go @@ -5,11 +5,152 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" + "math" "strconv" "strings" "time" ) +var errInvalidCursorNumber = errors.New("invalid cursor numeric value") + +// cursorFlexInt is an int64 that accepts both JSON numbers and quoted numeric +// strings. The Cursor usage API has drifted to encoding cent amounts such as +// pooledLimit as strings ("50000"), which previously aborted the whole decode. +// An empty string, an empty value or null is treated as zero. +type cursorFlexInt int64 + +func (v *cursorFlexInt) UnmarshalJSON(data []byte) error { + n, err := parseCursorFlexInt(data) + if err != nil { + return err + } + *v = cursorFlexInt(n) + return nil +} + +// cursorFlexFloat is the float64 counterpart, used for percentage fields. +type cursorFlexFloat float64 + +func (v *cursorFlexFloat) UnmarshalJSON(data []byte) error { + f, err := parseCursorFlexFloat(data) + if err != nil { + return err + } + *v = cursorFlexFloat(f) + return nil +} + +// cursorNumberToken normalizes a raw JSON token to the string to parse. Null +// and an empty quoted string both yield "", which callers treat as zero. +func cursorNumberToken(data []byte) (string, error) { + s := strings.TrimSpace(string(data)) + if s == "" || s == "null" { + return "", nil + } + if strings.HasPrefix(s, `"`) { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return "", errInvalidCursorNumber + } + return strings.TrimSpace(str), nil + } + return s, nil +} + +func parseCursorFlexInt(data []byte) (int64, error) { + s, err := cursorNumberToken(data) + if err != nil { + return 0, err + } + if s == "" { + return 0, nil + } + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return n, nil + } + // Cursor occasionally sends decimals in fields that are integral. Round + // rather than fail so a single drifted field does not hide the provider. + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, errInvalidCursorNumber + } + r := math.Round(f) + if r >= float64(math.MaxInt64) || r < float64(math.MinInt64) { + return 0, errInvalidCursorNumber + } + return int64(r), nil +} + +func parseCursorFlexFloat(data []byte) (float64, error) { + s, err := cursorNumberToken(data) + if err != nil { + return 0, err + } + if s == "" { + return 0, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, errInvalidCursorNumber + } + return f, nil +} + +// cursorBadFields collects numeric fields that could not be parsed so a +// response can degrade them to zero with one warning instead of failing. +type cursorBadFields struct { + count int +} + +func (b *cursorBadFields) intField(raw json.RawMessage, dst *cursorFlexInt) { + if len(raw) == 0 { + return + } + n, err := parseCursorFlexInt(raw) + if err != nil { + b.count++ + return + } + *dst = cursorFlexInt(n) +} + +func (b *cursorBadFields) floatField(raw json.RawMessage, dst *cursorFlexFloat) { + if len(raw) == 0 { + return + } + f, err := parseCursorFlexFloat(raw) + if err != nil { + b.count++ + return + } + *dst = cursorFlexFloat(f) +} + +// intStringField decodes a cent amount that is normally a quoted string but +// may arrive as a bare JSON number, keeping the existing string representation +// so downstream ParseIntString call sites are unchanged. +func (b *cursorBadFields) intStringField(raw json.RawMessage, dst *string) { + if len(raw) == 0 { + return + } + s, err := cursorNumberToken(raw) + if err != nil { + b.count++ + return + } + if s == "" { + *dst = "" + return + } + n, err := parseCursorFlexInt(raw) + if err != nil { + b.count++ + return + } + *dst = strconv.FormatInt(n, 10) +} + type CursorAccountType string const ( @@ -50,31 +191,131 @@ type CursorUsageResponse struct { PlanUsage *CursorPlanUsage `json:"planUsage"` SpendLimitUsage *CursorSpendLimitUsage `json:"spendLimitUsage"` Enabled bool `json:"enabled"` - DisplayThreshold int `json:"displayThreshold"` + DisplayThreshold cursorFlexInt `json:"displayThreshold"` DisplayMessage string `json:"displayMessage"` + + badNumericFields int +} + +func (u *CursorUsageResponse) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + decodeCursorString(raw["billingCycleStart"], &u.BillingCycleStart) + decodeCursorString(raw["billingCycleEnd"], &u.BillingCycleEnd) + decodeCursorString(raw["displayMessage"], &u.DisplayMessage) + if v, ok := raw["enabled"]; ok { + if err := json.Unmarshal(v, &u.Enabled); err != nil { + bad.count++ + } + } + bad.intField(raw["displayThreshold"], &u.DisplayThreshold) + + if v, ok := raw["planUsage"]; ok && !isCursorJSONNull(v) { + u.PlanUsage = &CursorPlanUsage{} + if err := json.Unmarshal(v, u.PlanUsage); err != nil { + u.PlanUsage = nil + bad.count++ + } else { + bad.count += u.PlanUsage.badNumericFields + } + } + if v, ok := raw["spendLimitUsage"]; ok && !isCursorJSONNull(v) { + u.SpendLimitUsage = &CursorSpendLimitUsage{} + if err := json.Unmarshal(v, u.SpendLimitUsage); err != nil { + u.SpendLimitUsage = nil + bad.count++ + } else { + bad.count += u.SpendLimitUsage.badNumericFields + } + } + + u.badNumericFields = bad.count + return nil +} + +func isCursorJSONNull(raw json.RawMessage) bool { + return strings.TrimSpace(string(raw)) == "null" +} + +func decodeCursorString(raw json.RawMessage, dst *string) { + if len(raw) == 0 { + return + } + _ = json.Unmarshal(raw, dst) } type CursorPlanUsage struct { - TotalSpend int `json:"totalSpend"` - IncludedSpend int `json:"includedSpend"` - BonusSpend int `json:"bonusSpend"` - Remaining int `json:"remaining"` - Limit int `json:"limit"` - RemainingBonus bool `json:"remainingBonus"` - AutoPercentUsed float64 `json:"autoPercentUsed"` - ApiPercentUsed float64 `json:"apiPercentUsed"` - TotalPercentUsed float64 `json:"totalPercentUsed"` + TotalSpend cursorFlexInt `json:"totalSpend"` + IncludedSpend cursorFlexInt `json:"includedSpend"` + BonusSpend cursorFlexInt `json:"bonusSpend"` + Remaining cursorFlexInt `json:"remaining"` + Limit cursorFlexInt `json:"limit"` + RemainingBonus bool `json:"remainingBonus"` + AutoPercentUsed cursorFlexFloat `json:"autoPercentUsed"` + ApiPercentUsed cursorFlexFloat `json:"apiPercentUsed"` + TotalPercentUsed cursorFlexFloat `json:"totalPercentUsed"` + + badNumericFields int +} + +func (p *CursorPlanUsage) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + bad.intField(raw["totalSpend"], &p.TotalSpend) + bad.intField(raw["includedSpend"], &p.IncludedSpend) + bad.intField(raw["bonusSpend"], &p.BonusSpend) + bad.intField(raw["remaining"], &p.Remaining) + bad.intField(raw["limit"], &p.Limit) + bad.floatField(raw["autoPercentUsed"], &p.AutoPercentUsed) + bad.floatField(raw["apiPercentUsed"], &p.ApiPercentUsed) + bad.floatField(raw["totalPercentUsed"], &p.TotalPercentUsed) + if v, ok := raw["remainingBonus"]; ok { + _ = json.Unmarshal(v, &p.RemainingBonus) + } + + p.badNumericFields = bad.count + return nil } type CursorSpendLimitUsage struct { - TotalSpend int `json:"totalSpend"` - PooledLimit int `json:"pooledLimit"` - PooledUsed int `json:"pooledUsed"` - PooledRemaining int `json:"pooledRemaining"` - IndividualLimit int `json:"individualLimit"` - IndividualUsed int `json:"individualUsed"` - IndividualRemaining int `json:"individualRemaining"` - LimitType string `json:"limitType"` + TotalSpend cursorFlexInt `json:"totalSpend"` + PooledLimit cursorFlexInt `json:"pooledLimit"` + PooledUsed cursorFlexInt `json:"pooledUsed"` + PooledRemaining cursorFlexInt `json:"pooledRemaining"` + IndividualLimit cursorFlexInt `json:"individualLimit"` + IndividualUsed cursorFlexInt `json:"individualUsed"` + IndividualRemaining cursorFlexInt `json:"individualRemaining"` + LimitType string `json:"limitType"` + + badNumericFields int +} + +func (s *CursorSpendLimitUsage) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + bad.intField(raw["totalSpend"], &s.TotalSpend) + bad.intField(raw["pooledLimit"], &s.PooledLimit) + bad.intField(raw["pooledUsed"], &s.PooledUsed) + bad.intField(raw["pooledRemaining"], &s.PooledRemaining) + bad.intField(raw["individualLimit"], &s.IndividualLimit) + bad.intField(raw["individualUsed"], &s.IndividualUsed) + bad.intField(raw["individualRemaining"], &s.IndividualRemaining) + decodeCursorString(raw["limitType"], &s.LimitType) + + s.badNumericFields = bad.count + return nil } type CursorPlanInfoResponse struct { @@ -82,22 +323,76 @@ type CursorPlanInfoResponse struct { } type CursorPlanInfo struct { - PlanName string `json:"planName"` - IncludedAmountCents int `json:"includedAmountCents"` - Price string `json:"price"` - BillingCycleEnd string `json:"billingCycleEnd"` + PlanName string `json:"planName"` + IncludedAmountCents cursorFlexInt `json:"includedAmountCents"` + Price string `json:"price"` + BillingCycleEnd string `json:"billingCycleEnd"` + + badNumericFields int +} + +func (p *CursorPlanInfo) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + decodeCursorString(raw["planName"], &p.PlanName) + decodeCursorString(raw["price"], &p.Price) + decodeCursorString(raw["billingCycleEnd"], &p.BillingCycleEnd) + bad.intField(raw["includedAmountCents"], &p.IncludedAmountCents) + + p.badNumericFields = bad.count + return nil } type CursorCreditGrantsResponse struct { HasCreditGrants bool `json:"hasCreditGrants"` TotalCents string `json:"totalCents"` UsedCents string `json:"usedCents"` + + badNumericFields int +} + +func (c *CursorCreditGrantsResponse) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + if v, ok := raw["hasCreditGrants"]; ok { + _ = json.Unmarshal(v, &c.HasCreditGrants) + } + bad.intStringField(raw["totalCents"], &c.TotalCents) + bad.intStringField(raw["usedCents"], &c.UsedCents) + + c.badNumericFields = bad.count + return nil } type CursorStripeResponse struct { - MembershipType string `json:"membershipType"` - SubscriptionStatus string `json:"subscriptionStatus"` - CustomerBalance int `json:"customerBalance"` + MembershipType string `json:"membershipType"` + SubscriptionStatus string `json:"subscriptionStatus"` + CustomerBalance cursorFlexInt `json:"customerBalance"` + + badNumericFields int +} + +func (s *CursorStripeResponse) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + decodeCursorString(raw["membershipType"], &s.MembershipType) + decodeCursorString(raw["subscriptionStatus"], &s.SubscriptionStatus) + bad.intField(raw["customerBalance"], &s.CustomerBalance) + + s.badNumericFields = bad.count + return nil } type CursorOAuthResponse struct { @@ -113,8 +408,24 @@ type CursorRequestUsageResponse struct { } type CursorModelUsage struct { - NumRequests int `json:"numRequests"` - MaxRequestUsage int `json:"maxRequestUsage"` + NumRequests cursorFlexInt `json:"numRequests"` + MaxRequestUsage cursorFlexInt `json:"maxRequestUsage"` + + badNumericFields int +} + +func (m *CursorModelUsage) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var bad cursorBadFields + bad.intField(raw["numRequests"], &m.NumRequests) + bad.intField(raw["maxRequestUsage"], &m.MaxRequestUsage) + + m.badNumericFields = bad.count + return nil } type CursorCredentials struct { @@ -137,6 +448,9 @@ func ParseCursorUsageResponse(data []byte) (*CursorUsageResponse, error) { if err := json.Unmarshal(data, &resp); err != nil { return nil, err } + if resp.badNumericFields > 0 { + slog.Warn("cursor: usage response had unparseable numeric fields, treating them as zero", "field_count", resp.badNumericFields) + } return &resp, nil } @@ -145,6 +459,9 @@ func ParseCursorPlanInfoResponse(data []byte) (*CursorPlanInfoResponse, error) { if err := json.Unmarshal(data, &resp); err != nil { return nil, err } + if resp.PlanInfo.badNumericFields > 0 { + slog.Warn("cursor: plan info response had unparseable numeric fields, treating them as zero", "field_count", resp.PlanInfo.badNumericFields) + } return &resp, nil } @@ -153,6 +470,9 @@ func ParseCursorCreditGrantsResponse(data []byte) (*CursorCreditGrantsResponse, if err := json.Unmarshal(data, &resp); err != nil { return nil, err } + if resp.badNumericFields > 0 { + slog.Warn("cursor: credit grants response had unparseable numeric fields, treating them as zero", "field_count", resp.badNumericFields) + } return &resp, nil } @@ -161,6 +481,9 @@ func ParseCursorStripeResponse(data []byte) (*CursorStripeResponse, error) { if err := json.Unmarshal(data, &resp); err != nil { return nil, err } + if resp.badNumericFields > 0 { + slog.Warn("cursor: stripe response had unparseable numeric fields, treating them as zero", "field_count", resp.badNumericFields) + } return &resp, nil } @@ -172,6 +495,9 @@ func ParseCursorOAuthResponse(data []byte) (*CursorOAuthResponse, error) { return &resp, nil } +// ParseCursorRequestUsageResponse decodes the per-model request usage payload. +// Each model entry is decoded independently so one malformed model does not +// discard the rest. func ParseCursorRequestUsageResponse(data []byte) (*CursorRequestUsageResponse, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { @@ -183,15 +509,24 @@ func ParseCursorRequestUsageResponse(data []byte) (*CursorRequestUsageResponse, _ = json.Unmarshal(v, &resp.StartOfMonth) } + badFields := 0 + skippedEntries := 0 resp.Models = make(map[string]CursorModelUsage) for key, val := range raw { if key == "startOfMonth" { continue } var usage CursorModelUsage - if err := json.Unmarshal(val, &usage); err == nil { - resp.Models[key] = usage + if err := json.Unmarshal(val, &usage); err != nil { + skippedEntries++ + continue } + badFields += usage.badNumericFields + resp.Models[key] = usage + } + if badFields > 0 || skippedEntries > 0 { + slog.Warn("cursor: request usage had unparseable entries, treating them as zero", + "field_count", badFields, "entry_count", skippedEntries) } return resp, nil @@ -348,8 +683,9 @@ func buildStandardQuotas( switch accountType { case CursorAccountIndividual: - if pu.TotalPercentUsed != 0 || pu.Limit > 0 { - percentUsed := pu.TotalPercentUsed + totalPercent := float64(pu.TotalPercentUsed) + if totalPercent != 0 || pu.Limit > 0 { + percentUsed := totalPercent if percentUsed == 0 && pu.Limit > 0 { planUsed := pu.TotalSpend if planUsed == 0 { @@ -384,8 +720,9 @@ func buildStandardQuotas( ResetsAt: billingCycleEnd, }) default: - if pu.TotalPercentUsed != 0 || pu.Limit > 0 { - percentUsed := pu.TotalPercentUsed + totalPercent := float64(pu.TotalPercentUsed) + if totalPercent != 0 || pu.Limit > 0 { + percentUsed := totalPercent if percentUsed == 0 && pu.Limit > 0 { planUsed := pu.TotalSpend if planUsed == 0 { @@ -406,14 +743,14 @@ func buildStandardQuotas( quotas = append(quotas, CursorQuota{ Name: "auto_usage", - Utilization: pu.AutoPercentUsed, + Utilization: float64(pu.AutoPercentUsed), Format: CursorFormatPercent, ResetsAt: billingCycleEnd, }) quotas = append(quotas, CursorQuota{ Name: "api_usage", - Utilization: pu.ApiPercentUsed, + Utilization: float64(pu.ApiPercentUsed), Format: CursorFormatPercent, ResetsAt: billingCycleEnd, }) @@ -427,7 +764,7 @@ func buildStandardQuotas( } stripeBalanceCents := 0 if stripeResp.CustomerBalance < 0 { - stripeBalanceCents = -stripeResp.CustomerBalance + stripeBalanceCents = int(-stripeResp.CustomerBalance) } combinedTotal := grantTotalCents + stripeBalanceCents if combinedTotal > 0 { @@ -474,9 +811,12 @@ func DetermineCursorAccountType(planName string, usage *CursorUsageResponse, use normalizedPlan := NormalizeCursorPlanName(planName) hasPlanUsageLimit := usage != nil && usage.PlanUsage != nil && usage.PlanUsage.Limit > 0 - if normalizedPlan == "team" || - (usage != nil && usage.SpendLimitUsage != nil && usage.SpendLimitUsage.LimitType == "team") || - (usage != nil && usage.SpendLimitUsage != nil && usage.SpendLimitUsage.PooledLimit > 0) { + limitType := "" + if usage != nil && usage.SpendLimitUsage != nil { + limitType = strings.ToLower(strings.TrimSpace(usage.SpendLimitUsage.LimitType)) + } + + if normalizedPlan == "team" || limitType == "team" { return CursorAccountTeam } diff --git a/internal/api/cursor_types_test.go b/internal/api/cursor_types_test.go index 7ec7559d..37d361a4 100644 --- a/internal/api/cursor_types_test.go +++ b/internal/api/cursor_types_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/base64" "fmt" + "log/slog" "math" "strings" "testing" @@ -837,6 +838,481 @@ func TestCursorQuotaFormat_String(t *testing.T) { } } +// Issue #151: Cursor began returning spend fields as JSON strings. The whole +// decode used to abort on the first string, hiding Cursor from the dashboard. +func TestParseCursorUsageResponse_StringNumbers(t *testing.T) { + raw := `{ + "billingCycleStart": "1768399334000", + "billingCycleEnd": "1771077734000", + "planUsage": { + "totalSpend": "23222", + "includedSpend": "23222", + "bonusSpend": "0", + "remaining": "16778", + "limit": "40000", + "remainingBonus": false, + "autoPercentUsed": "0", + "apiPercentUsed": "46.444", + "totalPercentUsed": "15.48" + }, + "spendLimitUsage": { + "totalSpend": "0", + "pooledLimit": "50000", + "pooledUsed": "0", + "pooledRemaining": "50000", + "individualLimit": "10000", + "individualUsed": "0", + "individualRemaining": "10000", + "limitType": "team" + }, + "enabled": true, + "displayThreshold": "200", + "displayMessage": "You've used 46% of your usage limit" + }` + + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse rejected string numbers: %v", err) + } + + if resp.PlanUsage == nil || resp.SpendLimitUsage == nil { + t.Fatal("planUsage/spendLimitUsage should not be nil") + } + if resp.PlanUsage.TotalSpend != 23222 { + t.Errorf("planUsage.TotalSpend = %d, want 23222", resp.PlanUsage.TotalSpend) + } + if resp.PlanUsage.Limit != 40000 { + t.Errorf("planUsage.Limit = %d, want 40000", resp.PlanUsage.Limit) + } + if resp.PlanUsage.ApiPercentUsed != 46.444 { + t.Errorf("planUsage.ApiPercentUsed = %v, want 46.444", resp.PlanUsage.ApiPercentUsed) + } + if resp.SpendLimitUsage.PooledLimit != 50000 { + t.Errorf("spendLimitUsage.PooledLimit = %d, want 50000", resp.SpendLimitUsage.PooledLimit) + } + if resp.SpendLimitUsage.IndividualRemaining != 10000 { + t.Errorf("spendLimitUsage.IndividualRemaining = %d, want 10000", resp.SpendLimitUsage.IndividualRemaining) + } + if resp.DisplayThreshold != 200 { + t.Errorf("DisplayThreshold = %d, want 200", resp.DisplayThreshold) + } +} + +func TestParseCursorUsageResponse_MixedNumbers(t *testing.T) { + raw := `{ + "planUsage": { + "totalSpend": 23222, + "limit": "40000", + "remaining": 16778, + "totalPercentUsed": "15.48", + "autoPercentUsed": 2.5, + "apiPercentUsed": "46.444" + }, + "spendLimitUsage": { + "totalSpend": "0", + "pooledLimit": 50000, + "pooledRemaining": "50000", + "individualLimit": 10000, + "individualRemaining": "10000" + }, + "enabled": true + }` + + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse rejected mixed numbers: %v", err) + } + if resp.PlanUsage.TotalSpend != 23222 || resp.PlanUsage.Limit != 40000 { + t.Errorf("planUsage spend = %d/%d, want 23222/40000", resp.PlanUsage.TotalSpend, resp.PlanUsage.Limit) + } + if resp.PlanUsage.TotalPercentUsed != 15.48 || resp.PlanUsage.ApiPercentUsed != 46.444 { + t.Errorf("planUsage percents = %v/%v, want 15.48/46.444", resp.PlanUsage.TotalPercentUsed, resp.PlanUsage.ApiPercentUsed) + } + if resp.SpendLimitUsage.PooledLimit != 50000 || resp.SpendLimitUsage.PooledRemaining != 50000 { + t.Errorf("pooled = %d/%d, want 50000/50000", resp.SpendLimitUsage.PooledLimit, resp.SpendLimitUsage.PooledRemaining) + } + if resp.SpendLimitUsage.IndividualLimit != 10000 || resp.SpendLimitUsage.IndividualRemaining != 10000 { + t.Errorf("individual = %d/%d, want 10000/10000", resp.SpendLimitUsage.IndividualLimit, resp.SpendLimitUsage.IndividualRemaining) + } +} + +func TestParseCursorUsageResponse_EmptyAndNullNumbers(t *testing.T) { + raw := `{ + "planUsage": { + "totalSpend": "", + "limit": null, + "remaining": "40000", + "totalPercentUsed": "", + "autoPercentUsed": null, + "apiPercentUsed": 1.5 + }, + "spendLimitUsage": { + "pooledLimit": "", + "pooledRemaining": null, + "individualLimit": "10000", + "individualRemaining": "" + }, + "enabled": true + }` + + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse rejected empty/null numbers: %v", err) + } + if resp.PlanUsage.TotalSpend != 0 || resp.PlanUsage.Limit != 0 { + t.Errorf("empty/null plan spend = %d/%d, want 0/0", resp.PlanUsage.TotalSpend, resp.PlanUsage.Limit) + } + if resp.PlanUsage.Remaining != 40000 { + t.Errorf("Remaining = %d, want 40000", resp.PlanUsage.Remaining) + } + if resp.PlanUsage.TotalPercentUsed != 0 || resp.PlanUsage.AutoPercentUsed != 0 { + t.Errorf("empty/null percents = %v/%v, want 0/0", resp.PlanUsage.TotalPercentUsed, resp.PlanUsage.AutoPercentUsed) + } + if resp.PlanUsage.ApiPercentUsed != 1.5 { + t.Errorf("ApiPercentUsed = %v, want 1.5", resp.PlanUsage.ApiPercentUsed) + } + if resp.SpendLimitUsage.PooledLimit != 0 || resp.SpendLimitUsage.PooledRemaining != 0 { + t.Errorf("empty/null pooled = %d/%d, want 0/0", resp.SpendLimitUsage.PooledLimit, resp.SpendLimitUsage.PooledRemaining) + } + if resp.SpendLimitUsage.IndividualLimit != 10000 { + t.Errorf("IndividualLimit = %d, want 10000", resp.SpendLimitUsage.IndividualLimit) + } +} + +func TestParseCursorUsageResponse_GarbageFieldDegrades(t *testing.T) { + raw := `{ + "planUsage": { + "totalSpend": "not-a-number", + "limit": "40000", + "totalPercentUsed": "also-bad", + "apiPercentUsed": "46.444" + }, + "spendLimitUsage": { + "pooledLimit": {}, + "individualLimit": "10000" + }, + "enabled": true + }` + + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("one bad field must not fail the whole decode: %v", err) + } + if resp.PlanUsage.TotalSpend != 0 { + t.Errorf("garbage TotalSpend = %d, want 0", resp.PlanUsage.TotalSpend) + } + if resp.PlanUsage.Limit != 40000 { + t.Errorf("Limit = %d, want 40000 (other fields must still decode)", resp.PlanUsage.Limit) + } + if resp.PlanUsage.TotalPercentUsed != 0 { + t.Errorf("garbage TotalPercentUsed = %v, want 0", resp.PlanUsage.TotalPercentUsed) + } + if resp.PlanUsage.ApiPercentUsed != 46.444 { + t.Errorf("ApiPercentUsed = %v, want 46.444", resp.PlanUsage.ApiPercentUsed) + } + if resp.SpendLimitUsage.PooledLimit != 0 { + t.Errorf("garbage PooledLimit = %d, want 0", resp.SpendLimitUsage.PooledLimit) + } + if resp.SpendLimitUsage.IndividualLimit != 10000 { + t.Errorf("IndividualLimit = %d, want 10000", resp.SpendLimitUsage.IndividualLimit) + } +} + +func TestParseCursorUsageResponse_GarbageLogsOneWarning(t *testing.T) { + var buf strings.Builder + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + + raw := `{ + "planUsage": {"totalSpend": "secret-token-looking-value"}, + "spendLimitUsage": {"pooledLimit": "also-bad"}, + "enabled": true + }` + if _, err := ParseCursorUsageResponse([]byte(raw)); err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + + logs := buf.String() + if n := strings.Count(logs, "cursor:"); n != 1 { + t.Errorf("got %d cursor log lines, want exactly 1; logs: %q", n, logs) + } + if !strings.Contains(logs, "level=WARN") { + t.Errorf("expected a WARN log, got: %q", logs) + } + if strings.Contains(logs, "secret-token-looking-value") || strings.Contains(logs, "also-bad") { + t.Errorf("log leaked raw field values: %q", logs) + } +} + +// A free plan now sends spendLimitUsage.pooledLimit too, so its presence must +// not promote the account to Team. +func TestDetermineCursorAccountType_FreePlanWithStringPooledLimit(t *testing.T) { + raw := `{ + "planUsage": {"totalSpend": "0", "limit": "2000", "remaining": "2000", "totalPercentUsed": "0"}, + "spendLimitUsage": {"pooledLimit": "50000", "pooledRemaining": "50000", "limitType": "user"}, + "enabled": true + }` + usage, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + + if got := DetermineCursorAccountType("free", usage, false); got != CursorAccountIndividual { + t.Errorf("DetermineCursorAccountType(free) = %q, want %q", got, CursorAccountIndividual) + } + + snapshot := ToCursorSnapshot(usage, &CursorPlanInfoResponse{PlanInfo: CursorPlanInfo{PlanName: "Free"}}, nil, nil, nil, false) + if snapshot.AccountType != CursorAccountIndividual { + t.Errorf("snapshot AccountType = %q, want %q", snapshot.AccountType, CursorAccountIndividual) + } + for _, q := range snapshot.Quotas { + if q.Name == "total_usage" && q.Format != CursorFormatPercent { + t.Errorf("free plan total_usage Format = %q, want %q", q.Format, CursorFormatPercent) + } + } +} + +// Plan info is non-fatal in the client, so a free account must not be +// promoted to Team when only limitType identifies it as individual. +func TestDetermineCursorAccountType_UserLimitTypeWithoutPlanName(t *testing.T) { + raw := `{ + "planUsage": {"limit": "2000", "remaining": "2000"}, + "spendLimitUsage": {"pooledLimit": "50000", "limitType": "user"}, + "enabled": true + }` + usage, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if got := DetermineCursorAccountType("", usage, false); got != CursorAccountIndividual { + t.Errorf("DetermineCursorAccountType with user limitType = %q, want %q", got, CursorAccountIndividual) + } +} + +// Plan info is non-fatal in the client. With no plan name and no limitType, +// a pooledLimit must not promote the account to Team: the issue's free-plan +// user would otherwise be misclassified whenever plan info fails. +func TestDetermineCursorAccountType_EmptyPlanPooledLimitIsIndividual(t *testing.T) { + raw := `{ + "planUsage": {"limit": "2000", "remaining": "2000"}, + "spendLimitUsage": {"pooledLimit": "50000", "pooledRemaining": "50000"}, + "enabled": true + }` + usage, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if got := DetermineCursorAccountType("", usage, false); got != CursorAccountIndividual { + t.Errorf(`DetermineCursorAccountType("") = %q, want %q`, got, CursorAccountIndividual) + } +} + +// A Team payload still resolves to Team after the inference change. +func TestDetermineCursorAccountType_TeamLimitTypeStillWins(t *testing.T) { + raw := `{"spendLimitUsage": {"pooledLimit": "50000", "limitType": "team"}, "enabled": true}` + usage, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if got := DetermineCursorAccountType("", usage, false); got != CursorAccountTeam { + t.Errorf("DetermineCursorAccountType with team limitType = %q, want %q", got, CursorAccountTeam) + } +} + +func TestParseCursorUsageResponse_GarbageValueDoesNotFailPoll(t *testing.T) { + var buf strings.Builder + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + + // Requirement 3: even a value the tolerant type cannot parse must degrade + // to zero rather than fail the poll. + raw := `{ + "planUsage": {"totalSpend": "not-a-number", "limit": 40000}, + "spendLimitUsage": {"pooledLimit": {}, "individualLimit": 10000}, + "enabled": true + }` + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if resp.PlanUsage == nil || resp.SpendLimitUsage == nil { + t.Fatal("sections should still decode") + } + if resp.PlanUsage.TotalSpend != 0 || resp.PlanUsage.Limit != 40000 { + t.Errorf("planUsage = %d/%d, want 0/40000", resp.PlanUsage.TotalSpend, resp.PlanUsage.Limit) + } + if resp.SpendLimitUsage.PooledLimit != 0 || resp.SpendLimitUsage.IndividualLimit != 10000 { + t.Errorf("spendLimitUsage = %d/%d, want 0/10000", resp.SpendLimitUsage.PooledLimit, resp.SpendLimitUsage.IndividualLimit) + } +} + +// A failed enabled decode, or a section that is not an object, must also be +// counted so the single warning reflects silently dropped data. +func TestParseCursorUsageResponse_MalformedSectionsWarn(t *testing.T) { + var buf strings.Builder + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + + raw := `{ + "enabled": "yes", + "planUsage": 5, + "spendLimitUsage": "oops" + }` + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if resp.PlanUsage != nil || resp.SpendLimitUsage != nil { + t.Errorf("malformed sections should be nil, got %+v/%+v", resp.PlanUsage, resp.SpendLimitUsage) + } + + logs := buf.String() + if n := strings.Count(logs, "cursor:"); n != 1 { + t.Errorf("got %d cursor log lines, want exactly 1; logs: %q", n, logs) + } + if !strings.Contains(logs, "field_count=3") { + t.Errorf("expected field_count=3 in warning, got: %q", logs) + } +} + +func TestParseCursorUsageResponse_NullSections(t *testing.T) { + raw := `{"planUsage": null, "spendLimitUsage": null, "enabled": true}` + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + if resp.PlanUsage != nil || resp.SpendLimitUsage != nil { + t.Errorf("null sections should stay nil, got %+v/%+v", resp.PlanUsage, resp.SpendLimitUsage) + } +} + +func TestParseCursorPlanInfoResponse_StringNumbers(t *testing.T) { + raw := `{"planInfo": {"planName": "Pro", "includedAmountCents": "2000"}}` + resp, err := ParseCursorPlanInfoResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorPlanInfoResponse: %v", err) + } + if resp.PlanInfo.IncludedAmountCents != 2000 { + t.Errorf("IncludedAmountCents = %d, want 2000", resp.PlanInfo.IncludedAmountCents) + } +} + +func TestParseCursorCreditGrantsResponse_NumericCents(t *testing.T) { + raw := `{"hasCreditGrants": true, "totalCents": 5000, "usedCents": "2000"}` + resp, err := ParseCursorCreditGrantsResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorCreditGrantsResponse: %v", err) + } + if resp.TotalCents != "5000" { + t.Errorf("TotalCents = %q, want %q", resp.TotalCents, "5000") + } + if resp.UsedCents != "2000" { + t.Errorf("UsedCents = %q, want %q", resp.UsedCents, "2000") + } +} + +func TestParseCursorStripeResponse_StringBalance(t *testing.T) { + raw := `{"membershipType": "pro", "customerBalance": "-123456"}` + resp, err := ParseCursorStripeResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorStripeResponse: %v", err) + } + if resp.CustomerBalance != -123456 { + t.Errorf("CustomerBalance = %d, want -123456", resp.CustomerBalance) + } +} + +func TestParseCursorRequestUsageResponse_StringNumbers(t *testing.T) { + raw := `{ + "startOfMonth": "2026-03-01", + "gpt-4": {"numRequests": "150", "maxRequestUsage": 500}, + "bad-model": {"numRequests": {}, "maxRequestUsage": "200"} + }` + resp, err := ParseCursorRequestUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorRequestUsageResponse: %v", err) + } + if resp.Models["gpt-4"].NumRequests != 150 || resp.Models["gpt-4"].MaxRequestUsage != 500 { + t.Errorf("gpt-4 = %d/%d, want 150/500", resp.Models["gpt-4"].NumRequests, resp.Models["gpt-4"].MaxRequestUsage) + } + if resp.Models["bad-model"].MaxRequestUsage != 200 { + t.Errorf("bad-model MaxRequestUsage = %d, want 200", resp.Models["bad-model"].MaxRequestUsage) + } +} + +func TestCursorFlexInt_Unmarshal(t *testing.T) { + tests := []struct { + name string + input string + want cursorFlexInt + wantErr bool + }{ + {"number", `50000`, 50000, false}, + {"negative number", `-1234`, -1234, false}, + {"string integer", `"50000"`, 50000, false}, + {"string with spaces", `" 50000 "`, 50000, false}, + {"string decimal rounds down", `"12.4"`, 12, false}, + {"string decimal rounds up", `"12.5"`, 13, false}, + {"decimal number rounds", `12.6`, 13, false}, + {"empty string", `""`, 0, false}, + {"null", `null`, 0, false}, + {"garbage string", `"abc"`, 0, true}, + {"array", `[1,2]`, 0, true}, + {"object", `{"v":1}`, 0, true}, + {"bool", `true`, 0, true}, + {"NaN", `"NaN"`, 0, true}, + {"Infinity", `"Inf"`, 0, true}, + {"overflow", `1e300`, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got cursorFlexInt + err := got.UnmarshalJSON([]byte(tt.input)) + if tt.wantErr != (err != nil) { + t.Fatalf("UnmarshalJSON(%s) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("UnmarshalJSON(%s) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestCursorFlexFloat_Unmarshal(t *testing.T) { + tests := []struct { + name string + input string + want cursorFlexFloat + wantErr bool + }{ + {"number", `46.444`, 46.444, false}, + {"string", `"46.444"`, 46.444, false}, + {"string integer", `"0"`, 0, false}, + {"empty string", `""`, 0, false}, + {"null", `null`, 0, false}, + {"garbage", `"nope"`, 0, true}, + {"object", `{}`, 0, true}, + {"bool", `true`, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got cursorFlexFloat + err := got.UnmarshalJSON([]byte(tt.input)) + if tt.wantErr != (err != nil) { + t.Fatalf("UnmarshalJSON(%s) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("UnmarshalJSON(%s) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + func createTestJWT(sub string, exp int64) string { header := `{"alg":"RS256","typ":"JWT"}` payload := `{"sub":"` + sub + `","exp":` + fmt.Sprintf("%d", exp) + `}` From 7efe7fe45acf372926b7b0311ab924ba72e206dd Mon Sep 17 00:00:00 2001 From: prakersh Date: Wed, 21 Oct 2026 18:14:52 +0530 Subject: [PATCH 03/56] feat(codex): show banked reset credits per account (#152, phase 1) Read-only tracking of Codex "reset credits". onWatch never redeems them. - Parse rate_limit_reset_credits (available and applicable counts) from wham/usage; an absent count stays unknown instead of 0 - Fetch the credit list (GET wham/rate-limit-reset-credits) every 30m per account, or when the count changes; failures never touch usage polling, pause state or auth counters, and never refresh tokens - Store credits per account with gone-marking; additive, idempotent migrations; account merges keep notification and live state - Dashboard: "N resets" chip with next-expiry tooltip, plan-unsupported note, and a muted dot when the list is unavailable - One notification per credit 24h before expiry, deduped across restarts; credits with no or nonsensical expiry are never notified - Test guard that no consume/redeem code path exists --- docs/CODEX_SETUP.md | 57 ++ internal/agent/codex_agent.go | 189 +++++ internal/agent/codex_agent_manager.go | 2 + internal/agent/codex_no_consume_test.go | 79 ++ internal/agent/codex_reset_credits_test.go | 457 ++++++++++ internal/api/codex_client.go | 111 +++ internal/api/codex_reset_credits.go | 240 ++++++ .../api/codex_reset_credits_client_test.go | 237 ++++++ .../api/codex_reset_credits_types_test.go | 384 +++++++++ internal/api/codex_types.go | 77 +- internal/notify/notify.go | 123 ++- internal/notify/webhook.go | 30 +- internal/store/codex_reset_credits.go | 373 +++++++++ internal/store/codex_reset_credits_test.go | 790 ++++++++++++++++++ internal/store/codex_store.go | 79 +- internal/store/store.go | 70 ++ internal/web/codex_reset_credits_test.go | 354 ++++++++ internal/web/handlers.go | 141 +++- internal/web/static/app.js | 88 +- internal/web/static/style.css | 46 + internal/web/templates/settings.html | 8 + 21 files changed, 3883 insertions(+), 52 deletions(-) create mode 100644 internal/agent/codex_no_consume_test.go create mode 100644 internal/agent/codex_reset_credits_test.go create mode 100644 internal/api/codex_reset_credits.go create mode 100644 internal/api/codex_reset_credits_client_test.go create mode 100644 internal/api/codex_reset_credits_types_test.go create mode 100644 internal/store/codex_reset_credits.go create mode 100644 internal/store/codex_reset_credits_test.go create mode 100644 internal/web/codex_reset_credits_test.go diff --git a/docs/CODEX_SETUP.md b/docs/CODEX_SETUP.md index 3d70fa11..a884e474 100644 --- a/docs/CODEX_SETUP.md +++ b/docs/CODEX_SETUP.md @@ -395,6 +395,63 @@ Environment overrides (the dashboard toggles take precedence at runtime): --- +## Banked reset credits + +ChatGPT plans can bank full resets ("reset credits") that clear your current +Codex usage limits. onWatch shows each account's banked credits and warns before +one expires. **onWatch never redeems a credit** - this phase is read-only. + +What the dashboard shows, per Codex account: + +- A compact chip with the plan-usable count, e.g. `2 resets`. When the backend + reports `applicable_available_count`, that is the count shown (credits the + current plan cannot use are excluded); otherwise `available_count` is used. +- Hovering the chip shows the nearest usable expiry as a relative time, e.g. + `next expires in 3d 4h`, or `does not expire` when no credit has an expiry. + When some credits are not usable on the current plan the tooltip adds a line + such as `1 not usable on current plan`. +- A muted dot (tooltip "Resets unavailable") sits next to the chip when the + detail endpoint cannot be read - for example with API-key auth, a 404 on an + older backend, or a stale token. The chip keeps showing the count from the + usage response when it is known; without any known count only the dot shows. +- An expiry the backend reports that is not trustworthy (equal to the grant + time, or already past while the credit is still offered) is shown as + `expiry unknown` rather than a past date. + +How it works: + +- The usage poll already returns `rate_limit_reset_credits.available_count` + (and `applicable_available_count` when present); those counts are stored with + each snapshot. An absent count is treated as unknown, never as zero, and the + latest detail-fetch count falls back to the snapshot count when it is absent. +- The credit list comes from `GET /backend-api/wham/rate-limit-reset-credits` + (or `/api/codex/rate-limit-reset-credits` on the alternate backend), using + the same OAuth token and `ChatGPT-Account-Id` header as the usage poll. The + detail URL follows whichever usage path works for that account. +- Details are fetched at most every **30 minutes per account**, plus + immediately when the usage count changes. Never on every poll. +- Credits that disappear from a response are marked gone in the local database + (`codex_reset_credits`) rather than deleted, so notification dedupe is stable. +- A credit with a null `expires_at` never expires. Credits marked + `is_supported_by_plan: false` do not count toward the next expiry or the + expiry warning. + +Notifications: + +- One notification per credit when it is within **24 hours** of expiring. The + message names the account (profile label), the expiry time, and points to the + Codex CLI `/usage` menu, where "Redeem reset" is the entry point. +- Deduplicated persistently with a `notified_at` timestamp on the credit row, so + a restart or a second poller never re-sends. +- Credits that never expire, have an unknown expiry, or are not usable on the + current plan are never notified. +- Silence the type with **Settings -> Notifications -> Reset credit expiry + alerts**; webhook deliveries use the `reset_credit_expiry` event. +- Detail-endpoint failures never affect usage polling, pause state, or auth + failure counters. + +--- + ## See Also - [README](../README.md) — Quick start and provider overview diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index 90bf2b75..660cbe13 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -50,6 +50,17 @@ const ( // Codex tokens expire weekly, so refreshing 6 hours early provides a comfortable buffer. const codexTokenRefreshThreshold = 6 * time.Hour +// codexResetCreditsFetchInterval is how often the reset-credit detail endpoint +// is polled. The Codex CLI itself fetches details sparsely rather than on every +// usage refresh, and the count is already in the usage response, so details +// only need to refresh slowly (issue #152). +const codexResetCreditsFetchInterval = 30 * time.Minute + +// codexResetCreditNotifyLead is how close to expiry a banked reset credit must +// be before onWatch sends its single warning. Fixed constant for Phase 1; the +// existing notification settings can silence the type but not change the lead. +const codexResetCreditNotifyLead = 24 * time.Hour + // CodexTokenRefreshFunc is called before each poll to get a fresh Codex token. type CodexTokenRefreshFunc func() string @@ -265,6 +276,17 @@ type CodexAgent struct { starterFires []time.Time starterGen uint64 starterSeen map[string]codexResetObservation + + // Banked reset credits (issue #152). resetCreditsCheckedAt is the last + // detail fetch, resetCreditsCount the usage count seen at that time (with + // resetCreditsCountKnown distinguishing absent from 0), and + // resetCreditsFetchedOnce forces a baseline fetch on the first poll. + // All three are owned by the poll goroutine. + resetCreditsCheckedAt time.Time + resetCreditsCount int64 + resetCreditsCountKnown bool + resetCreditsFetchedOnce bool + profileLabel string // human account label for reset-credit notifications } // codexResetObservation is a window's reset time as seen by one poll, with @@ -326,6 +348,168 @@ func (a *CodexAgent) SetCodexAccountID(id string) { a.client.SetAccountID(id) } +// SetProfileLabel sets a human-readable account label used in notifications +// (the profile name, never a token). +func (a *CodexAgent) SetProfileLabel(label string) { + a.profileLabel = strings.TrimSpace(label) +} + +// resetCreditsDue reports whether this poll should fetch reset-credit details: +// once for a baseline, then on the slow cadence, or immediately when the usage +// count changed since the last fetch. +func (a *CodexAgent) resetCreditsDue(now time.Time, snapshot *api.CodexSnapshot) bool { + if !a.resetCreditsFetchedOnce { + return true + } + count := snapshot.ResetCreditsAvailableCount + if count.Valid && (!a.resetCreditsCountKnown || count.Value != a.resetCreditsCount) { + return true + } + return now.Sub(a.resetCreditsCheckedAt) >= codexResetCreditsFetchInterval +} + +// maybeFetchResetCredits refreshes banked reset credits when due. It is strictly +// informational: every failure is logged at debug/info and leaves usage +// polling, pause state and the #147 failure counters untouched. It never +// refreshes tokens and never redeems anything. +func (a *CodexAgent) maybeFetchResetCredits(ctx context.Context, snapshot *api.CodexSnapshot, now time.Time) { + if a.store == nil { + return + } + if !a.resetCreditsDue(now, snapshot) { + return + } + + // Mark the attempt before the request so a hanging or failing endpoint is + // retried only after the slow cadence, never on every usage poll. + a.resetCreditsFetchedOnce = true + a.resetCreditsCheckedAt = now + if count := snapshot.ResetCreditsAvailableCount; count.Valid { + a.resetCreditsCount = count.Value + a.resetCreditsCountKnown = true + } + + resp, err := a.client.FetchResetCredits(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + a.recordResetCreditsUnavailable(now) + a.logger.Debug("Codex reset credits unavailable", + "error", err, "account_id", a.accountID) + return + } + + records := make([]store.CodexResetCreditRecord, 0, len(resp.Credits)) + parsed := make([]api.CodexResetCredit, 0, len(resp.Credits)) + for _, c := range resp.Credits { + records = append(records, store.CodexResetCreditRecord{ + CreditID: c.ID, + Status: c.Status.String(), + ResetType: c.ResetType, + Title: c.Title, + Description: c.Description, + GrantedAt: c.GrantedAt, + ExpiresAt: c.ExpiresAt, + UnsupportedByPlan: c.UnsupportedByPlan, + ExpiryUnknown: c.ExpiryUnknown, + }) + parsed = append(parsed, c) + } + if err := a.store.SyncCodexResetCredits(a.accountID, records, now); err != nil { + a.logger.Warn("Failed to store Codex reset credits", "error", err, "account_id", a.accountID) + // Storage failed, so nothing below can be trusted; the next due fetch retries. + a.recordResetCreditsUnavailable(now) + return + } + + // nil when the detail payload omitted the count: unknown, never 0. + if err := a.store.SetCodexResetCreditCheckStatus(a.accountID, "ok", resp.AvailableCount, resp.ApplicableAvailableCount, now); err != nil { + a.logger.Warn("Failed to record Codex reset-credit check", "error", err, "account_id", a.accountID) + } + + a.notifyExpiringResetCredits(ctx, parsed, now) +} + +// recordResetCreditsUnavailable stores the last-check outcome as unavailable. +func (a *CodexAgent) recordResetCreditsUnavailable(now time.Time) { + if err := a.store.SetCodexResetCreditCheckStatus(a.accountID, "unavailable", nil, nil, now); err != nil { + a.logger.Debug("Failed to record Codex reset-credit check status", "error", err, "account_id", a.accountID) + } +} + +// notifyExpiringResetCredits sends at most one warning per credit inside the +// lead window. The notified_at marker is claimed atomically before sending; +// a send that reached no channel releases the claim so a later fetch retries. +// Never-expiring credits are skipped by construction (the store query requires +// a non-null expiry). +func (a *CodexAgent) notifyExpiringResetCredits(ctx context.Context, credits []api.CodexResetCredit, now time.Time) { + if a.notifier == nil { + return + } + pending, err := a.store.QueryCodexResetCreditsNeedingExpiryNotice(a.accountID, now, codexResetCreditNotifyLead) + if err != nil { + a.logger.Warn("Failed to query Codex credits needing expiry notice", "error", err, "account_id", a.accountID) + return + } + if len(pending) == 0 { + return + } + byID := make(map[string]api.CodexResetCredit, len(credits)) + for _, c := range credits { + byID[c.ID] = c + } + accountName := a.resetCreditsAccountName() + for _, row := range pending { + if ctx.Err() != nil { + return + } + if row.ExpiresAt == nil { + continue + } + claimedAt := time.Now().UTC() + claimed, err := a.store.ClaimCodexResetCreditNotice(a.accountID, row.CreditID, claimedAt) + if err != nil { + a.logger.Warn("Failed to claim Codex reset-credit notice", "error", err, "account_id", a.accountID) + continue + } + if !claimed { + continue // another poller or a previous run already sent it + } + + title := row.Title + if credit, ok := byID[row.CreditID]; ok && credit.Title != "" { + title = credit.Title + } + sent := a.notifier.SendResetCreditExpiryNotification(notify.ResetCreditExpiryAlert{ + AccountID: fmt.Sprintf("%d", a.accountID), + AccountName: accountName, + CreditID: row.CreditID, + Title: title, + ExpiresAt: *row.ExpiresAt, + }) + if !sent { + if err := a.store.ReleaseCodexResetCreditNotice(a.accountID, row.CreditID, claimedAt); err != nil { + a.logger.Debug("Failed to release Codex reset-credit notice claim", "error", err) + } + continue + } + a.logger.Info("Sent Codex reset-credit expiry notification", + "credit_id", row.CreditID, + "expires_at", row.ExpiresAt.UTC(), + "account_id", a.accountID) + } +} + +// resetCreditsAccountName returns the human label used in notifications. It +// prefers the configured profile label and never falls back to credentials. +func (a *CodexAgent) resetCreditsAccountName() string { + if a.profileLabel != "" { + return a.profileLabel + } + return fmt.Sprintf("Codex account %d", a.accountID) +} + // SetAutoStartCheck wires a callback that reports, fresh per poll, whether the // auto quota-starter (Beta) is enabled for a given window. Read fresh so a // dashboard toggle takes effect without a daemon restart. @@ -1103,6 +1287,11 @@ func (a *CodexAgent) poll(ctx context.Context) { // unstarted (reset countdown pinned at ~full window length). a.maybeAutoStartWindows(ctx, snapshot.Quotas, now, sp) + // Banked reset credits (issue #152): slow cadence, read-only. Failures are + // contained inside maybeFetchResetCredits and never touch usage state. + // The clock hook keeps the cadence deterministic in tests. + a.maybeFetchResetCredits(ctx, snapshot, a.currentTime().UTC()) + if a.notifier != nil { for _, q := range snapshot.Quotas { a.notifier.Check(notify.QuotaStatus{ diff --git a/internal/agent/codex_agent_manager.go b/internal/agent/codex_agent_manager.go index cb283b71..e2b9d7e6 100644 --- a/internal/agent/codex_agent_manager.go +++ b/internal/agent/codex_agent_manager.go @@ -712,6 +712,8 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error { // Auto quota-starter (Beta): give the agent its Codex account_id (for the // ChatGPT-Account-ID header) and the per-window enablement check. agent.SetCodexAccountID(profile.AccountID) + // Human label for reset-credit notifications (profile name, never a token). + agent.SetProfileLabel(profile.Name) if m.autoStartCheck != nil { agent.SetAutoStartCheck(m.autoStartCheck) } diff --git a/internal/agent/codex_no_consume_test.go b/internal/agent/codex_no_consume_test.go new file mode 100644 index 00000000..bc620d33 --- /dev/null +++ b/internal/agent/codex_no_consume_test.go @@ -0,0 +1,79 @@ +package agent + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// assertNoConsumeReferences guards Phase 1's read-only promise: onWatch must +// never implement, stub, or call the reset-credit redemption endpoint. It walks +// the repository source (including tests) and fails on any reference to the +// consume endpoint path or a consume client method. +func assertNoConsumeReferences(t *testing.T) { + t.Helper() + + root := repoRoot(t) + forbidden := []string{ + "rate-limit-reset-credits/consume", + "ConsumeRateLimitResetCredit", + "ConsumeCodexResetCredit", + "consume_codex", + } + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "vendor", "node_modules", "result": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, ".js") { + return nil + } + // These tests' own sources necessarily contain the forbidden strings. + switch filepath.Base(path) { + case "codex_reset_credits_test.go", "codex_no_consume_test.go": + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + lower := strings.ToLower(string(data)) + for _, needle := range forbidden { + if strings.Contains(lower, strings.ToLower(needle)) { + t.Errorf("%s references %q; onWatch must stay read-only for reset credits", path, needle) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk repo: %v", err) + } +} + +// repoRoot walks up from the test's working directory to the module root. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod not found above the test directory") + } + dir = parent + } +} diff --git a/internal/agent/codex_reset_credits_test.go b/internal/agent/codex_reset_credits_test.go new file mode 100644 index 00000000..b7560831 --- /dev/null +++ b/internal/agent/codex_reset_credits_test.go @@ -0,0 +1,457 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// codexResetCreditsServer serves usage with a configurable available_count and +// counts detail requests separately, so cadence tests can assert exactly when +// the slow detail fetch runs. +type codexResetCreditsServer struct { + server *httptest.Server + usageCalls atomic.Int32 + detailCalls atomic.Int32 + usageCount atomic.Int64 // available_count on the usage response; -1 omits the field + detailStatus atomic.Int32 + detailPayload atomic.Value // string + mu sync.Mutex + detailPaths []string +} + +func newCodexResetCreditsServer(t *testing.T) *codexResetCreditsServer { + t.Helper() + cs := &codexResetCreditsServer{} + cs.usageCount.Store(-1) + cs.detailStatus.Store(http.StatusOK) + cs.detailPayload.Store(`{"credits":[],"available_count":0,"total_earned_count":0}`) + cs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/wham/usage": + cs.usageCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + count := cs.usageCount.Load() + extra := "" + if count >= 0 { + extra = fmt.Sprintf(`,"rate_limit_reset_credits":{"available_count":%d}`, count) + } + fmt.Fprintf(w, `{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":25,"reset_at":1766000000,"limit_window_seconds":18000}}%s}`, extra) + case "/backend-api/wham/rate-limit-reset-credits": + cs.detailCalls.Add(1) + cs.mu.Lock() + cs.detailPaths = append(cs.detailPaths, r.URL.Path) + cs.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(int(cs.detailStatus.Load())) + fmt.Fprint(w, cs.detailPayload.Load().(string)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(cs.server.Close) + return cs +} + +func (cs *codexResetCreditsServer) agent(t *testing.T, interval time.Duration) (*CodexAgent, *store.Store) { + t.Helper() + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { st.Close() }) + return cs.agentWithStore(t, st, interval), st +} + +// agentWithStore builds an agent over an existing store, so a test can model a +// daemon restart against the same persisted state. +func (cs *codexResetCreditsServer) agentWithStore(t *testing.T, st *store.Store, interval time.Duration) *CodexAgent { + t.Helper() + logger := slog.Default() + client := api.NewCodexClient("oauth_token", logger, + api.WithCodexBaseURL(cs.server.URL+"/backend-api/wham/usage")) + tr := tracker.NewCodexTracker(st, logger) + ag := NewCodexAgent(client, st, tr, interval, logger, nil) + ag.SetCodexAccountID("acct-test") + return ag +} + +func TestCodexAgent_ResetCredits_FetchOnFirstPollThenSlowCadence(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(2) + cs.detailPayload.Store(`{ + "credits":[{"id":"credit-1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-10-01T00:00:00Z","expires_at":null}], + "available_count":1,"total_earned_count":1 + }`) + ag, st := cs.agent(t, time.Minute) + + // A manual clock keeps the cadence deterministic. + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + ag.now = func() time.Time { return now } + + ctx := context.Background() + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls after first poll = %d, want 1", got) + } + + // Subsequent usage polls inside the slow window must not fetch details. + for i := 0; i < 3; i++ { + now = now.Add(5 * time.Minute) + ag.poll(ctx) + } + if got := cs.usageCalls.Load(); got != 4 { + t.Fatalf("usage calls = %d, want 4", got) + } + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls inside the slow window = %d, want 1", got) + } + + // Past the slow cadence, one more fetch runs. + now = now.Add(codexResetCreditsFetchInterval) + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 2 { + t.Fatalf("detail calls after the slow window = %d, want 2", got) + } + + rows, err := st.QueryCodexResetCredits(DefaultCodexAccountIDForTest()) + if err != nil { + t.Fatalf("QueryCodexResetCredits: %v", err) + } + if len(rows) != 1 || rows[0].CreditID != "credit-1" { + t.Fatalf("stored rows = %#v", rows) + } +} + +func TestCodexAgent_ResetCredits_CountChangeTriggersImmediateFetch(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(1) + ag, _ := cs.agent(t, time.Minute) + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + ag.now = func() time.Time { return now } + ctx := context.Background() + + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls = %d, want 1", got) + } + + // No change and inside the interval: no fetch. + now = now.Add(time.Minute) + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls after unchanged count = %d, want 1", got) + } + + // The usage count changes: fetch immediately even though the interval has + // not elapsed. + cs.usageCount.Store(2) + now = now.Add(time.Minute) + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 2 { + t.Fatalf("detail calls after count change = %d, want 2", got) + } +} + +func TestCodexAgent_ResetCredits_UnknownCountDoesNotTriggerFetch(t *testing.T) { + cs := newCodexResetCreditsServer(t) + // The usage response omits rate_limit_reset_credits entirely. + ag, _ := cs.agent(t, time.Minute) + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + ag.now = func() time.Time { return now } + ctx := context.Background() + + ag.poll(ctx) + // First poll always fetches once so the account has a baseline. + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls = %d, want 1", got) + } + now = now.Add(time.Minute) + ag.poll(ctx) + if got := cs.detailCalls.Load(); got != 1 { + t.Fatalf("detail calls with unknown count = %d, want 1", got) + } +} + +func TestCodexAgent_ResetCredits_FailureDoesNotAffectUsageOrPause(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(3) + cs.detailStatus.Store(http.StatusUnauthorized) + ag, st := cs.agent(t, time.Minute) + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + ag.now = func() time.Time { return now } + + ag.poll(context.Background()) + + if ag.authFailCount != 0 || ag.AuthPaused() { + t.Fatalf("authFailCount=%d paused=%v, want no effect from a detail 401", ag.authFailCount, ag.AuthPaused()) + } + latest, err := st.QueryLatestCodex(DefaultCodexAccountIDForTest()) + if err != nil || latest == nil { + t.Fatalf("latest snapshot = %#v, err = %v", latest, err) + } + + status, err := st.QueryCodexResetCreditCheckStatus(DefaultCodexAccountIDForTest()) + if err != nil { + t.Fatalf("QueryCodexResetCreditCheckStatus: %v", err) + } + if status == nil || status.Status != "unavailable" { + t.Fatalf("check status = %#v, want unavailable", status) + } +} + +func TestCodexAgent_ResetCredits_NotifiesOnceInsideLeadWindow(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(1) + + // Use the wall clock: the detail parser sanity-checks expiries against it. + now := time.Now().UTC().Truncate(time.Second) + expiry := now.Add(3 * time.Hour).Format(time.RFC3339) + cs.detailPayload.Store(fmt.Sprintf(`{ + "credits":[ + {"id":"credit-soon","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-10-01T00:00:00Z","expires_at":%q}, + {"id":"credit-never","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-10-01T00:00:00Z","expires_at":null} + ], + "available_count":2,"total_earned_count":2 + }`, expiry)) + + ag, st := cs.agent(t, time.Minute) + ag.now = func() time.Time { return now } + ag.SetProfileLabel("work-profile") + + webhookCalls := make(chan notify.WebhookPayload, 4) + webhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload notify.WebhookPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + webhookCalls <- payload + } + w.WriteHeader(http.StatusOK) + })) + defer webhookSrv.Close() + + engine := notify.New(st, slog.Default()) + if err := st.SetSetting("webhook", fmt.Sprintf(`{"url":%q,"timeout_seconds":2,"events":{"reset_credit_expiry":true}}`, webhookSrv.URL)); err != nil { + t.Fatalf("SetSetting: %v", err) + } + // Webhook delivery is off by default; enable the channel the way the + // settings UI does. + if err := st.SetSetting("notifications", `{"channels":{"email":false,"push":false,"webhook":true}}`); err != nil { + t.Fatalf("SetSetting notifications: %v", err) + } + if err := engine.Reload(); err != nil { + t.Fatalf("Reload: %v", err) + } + if err := engine.ConfigureWebhook(); err != nil { + t.Fatalf("ConfigureWebhook: %v", err) + } + ag.SetNotifier(engine) + + ctx := context.Background() + ag.poll(ctx) + + select { + case payload := <-webhookCalls: + if payload.Event != notify.EventResetCreditExpiry { + t.Fatalf("event = %q, want %q", payload.Event, notify.EventResetCreditExpiry) + } + if payload.AccountID != "1" { + t.Fatalf("account id = %q, want the database account ID", payload.AccountID) + } + if payload.Message == "" || payload.Title == "" { + t.Fatalf("payload missing text: %#v", payload) + } + if !strings.Contains(payload.Message, "work-profile") { + t.Fatalf("message %q does not name the profile label", payload.Message) + } + if !strings.Contains(payload.Message, "/usage") { + t.Fatalf("message %q does not mention the Codex CLI /usage entry point", payload.Message) + } + case <-time.After(2 * time.Second): + t.Fatal("expected one reset-credit expiry notification") + } + + // A never-expiring credit is never notified. + now = now.Add(30 * time.Minute) + ag.poll(ctx) + select { + case payload := <-webhookCalls: + t.Fatalf("unexpected second notification: %#v", payload) + case <-time.After(200 * time.Millisecond): + } + + rows, err := st.QueryCodexResetCredits(DefaultCodexAccountIDForTest()) + if err != nil { + t.Fatalf("QueryCodexResetCredits: %v", err) + } + for _, row := range rows { + switch row.CreditID { + case "credit-soon": + if row.NotifiedAt == nil { + t.Fatalf("credit-soon NotifiedAt = nil, want a marker") + } + case "credit-never": + if row.NotifiedAt != nil { + t.Fatalf("credit-never NotifiedAt = %v, want nil", row.NotifiedAt) + } + } + } + + // A fresh agent (restart) must not re-send: the marker is persisted. + ag2 := cs.agentWithStore(t, st, time.Minute) + ag2.now = func() time.Time { return now } + ag2.SetNotifier(engine) + now = now.Add(codexResetCreditsFetchInterval) + ag2.poll(ctx) + select { + case payload := <-webhookCalls: + t.Fatalf("unexpected notification after restart: %#v", payload) + case <-time.After(200 * time.Millisecond): + } +} + +// DefaultCodexAccountIDForTest mirrors store.DefaultCodexAccountID. +func DefaultCodexAccountIDForTest() int64 { + return store.DefaultCodexAccountID +} + +// TestNoConsumeCodePath asserts onWatch only reads reset credits: no onWatch +// source file may reference the redemption endpoint or client call. +func TestNoConsumeCodePath(t *testing.T) { + assertNoConsumeReferences(t) +} + +// The real backend has returned an available credit whose expires_at equals +// granted_at to the microsecond. It must not be stored as expiring, shown with +// a past expiry, or notified. +func TestCodexAgent_ResetCredits_EqualGrantAndExpiryNotNotified(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(1) + + granted := time.Now().UTC().Truncate(time.Second) + stamp := granted.Format(time.RFC3339Nano) + cs.detailPayload.Store(fmt.Sprintf(`{ + "credits":[{"id":"credit-equal","reset_type":"codex_rate_limits","is_supported_by_plan":true,"status":"available","granted_at":%q,"expires_at":%q}], + "available_count":1,"total_earned_count":0 + }`, stamp, stamp)) + + ag, st := cs.agent(t, time.Minute) + ag.now = func() time.Time { return granted } + ag.SetProfileLabel("work-profile") + + webhookCalls := make(chan notify.WebhookPayload, 4) + webhookSrv := newResetCreditWebhook(t, webhookCalls) + engine := newResetCreditNotifier(t, st, webhookSrv.URL) + ag.SetNotifier(engine) + + ag.poll(context.Background()) + + select { + case payload := <-webhookCalls: + t.Fatalf("unexpected notification for an equal-grant/expiry credit: %#v", payload) + case <-time.After(300 * time.Millisecond): + } + + rows, err := st.QueryCodexResetCredits(DefaultCodexAccountIDForTest()) + if err != nil { + t.Fatalf("QueryCodexResetCredits: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].ExpiresAt != nil || !rows[0].ExpiryUnknown { + t.Fatalf("row = %#v, want an unknown expiry, not a past one", rows[0]) + } + if rows[0].NotifiedAt != nil { + t.Fatalf("NotifiedAt = %v, want nil", rows[0].NotifiedAt) + } +} + +// Credits marked is_supported_by_plan=false must not trigger an expiry warning +// and must not drive next_expires_at. +func TestCodexAgent_ResetCredits_UnsupportedCreditNotNotified(t *testing.T) { + cs := newCodexResetCreditsServer(t) + cs.usageCount.Store(1) + + now := time.Now().UTC().Truncate(time.Second) + expiry := now.Add(2 * time.Hour).Format(time.RFC3339) + cs.detailPayload.Store(fmt.Sprintf(`{ + "credits":[ + {"id":"credit-unsupported","reset_type":"codex_rate_limits","is_supported_by_plan":false,"status":"available","granted_at":"2026-10-01T00:00:00Z","expires_at":%q} + ], + "available_count":1,"applicable_available_count":0 + }`, expiry)) + + ag, st := cs.agent(t, time.Minute) + ag.now = func() time.Time { return now } + ag.SetProfileLabel("work-profile") + + webhookCalls := make(chan notify.WebhookPayload, 4) + webhookSrv := newResetCreditWebhook(t, webhookCalls) + ag.SetNotifier(newResetCreditNotifier(t, st, webhookSrv.URL)) + + ag.poll(context.Background()) + + select { + case payload := <-webhookCalls: + t.Fatalf("unexpected notification for an unsupported credit: %#v", payload) + case <-time.After(300 * time.Millisecond): + } + + check, err := st.QueryCodexResetCreditCheckStatus(DefaultCodexAccountIDForTest()) + if err != nil { + t.Fatalf("QueryCodexResetCreditCheckStatus: %v", err) + } + if check == nil || check.ApplicableCount == nil || *check.ApplicableCount != 0 { + t.Fatalf("check = %#v, want applicable count 0", check) + } +} + +func newResetCreditWebhook(t *testing.T, calls chan<- notify.WebhookPayload) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload notify.WebhookPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + select { + case calls <- payload: + default: + } + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv +} + +func newResetCreditNotifier(t *testing.T, st *store.Store, webhookURL string) *notify.NotificationEngine { + t.Helper() + engine := notify.New(st, slog.Default()) + if err := st.SetSetting("webhook", fmt.Sprintf(`{"url":%q,"timeout_seconds":2,"events":{"reset_credit_expiry":true}}`, webhookURL)); err != nil { + t.Fatalf("SetSetting webhook: %v", err) + } + if err := st.SetSetting("notifications", `{"channels":{"email":false,"push":false,"webhook":true}}`); err != nil { + t.Fatalf("SetSetting notifications: %v", err) + } + if err := engine.Reload(); err != nil { + t.Fatalf("Reload: %v", err) + } + if err := engine.ConfigureWebhook(); err != nil { + t.Fatalf("ConfigureWebhook: %v", err) + } + return engine +} diff --git a/internal/api/codex_client.go b/internal/api/codex_client.go index 5aa5a1ad..fd46bf99 100644 --- a/internal/api/codex_client.go +++ b/internal/api/codex_client.go @@ -203,6 +203,117 @@ func codexBodySnippet(body []byte) string { return text } +// buildCodexResetCreditsURL derives the reset-credits detail URL from a working +// usage URL. Codex serves two path styles and the detail path mirrors the one +// the account's usage poll resolved (issue #152). +func buildCodexResetCreditsURL(usageURL string) (string, bool) { + u, err := url.Parse(usageURL) + if err != nil { + return "", false + } + switch { + case strings.Contains(u.Path, "/backend-api/wham/usage"): + u.Path = strings.Replace(u.Path, "/backend-api/wham/usage", "/backend-api/wham/rate-limit-reset-credits", 1) + case strings.Contains(u.Path, "/api/codex/usage"): + u.Path = strings.Replace(u.Path, "/api/codex/usage", "/api/codex/rate-limit-reset-credits", 1) + default: + return "", false + } + u.RawQuery = "" + u.Fragment = "" + return u.String(), true +} + +// FetchResetCredits lists this account's banked reset credits. It is read-only: +// there is deliberately no redemption call in onWatch. +// +// The request reuses the account's current access token - it never refreshes - +// and a failure is reported as ErrCodexResetCreditsUnavailable so callers can +// treat it as informational and leave usage polling, pause state and failure +// counters untouched. No call is retried. +func (c *CodexClient) FetchResetCredits(ctx context.Context) (*CodexResetCreditsResponse, error) { + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + usageURL := c.baseURL + if fallback := c.getFallbackBaseURL(); fallback != "" { + usageURL = fallback + } + detailURL, ok := buildCodexResetCreditsURL(usageURL) + if !ok { + return nil, fmt.Errorf("%w: cannot derive detail URL", ErrCodexResetCreditsUnavailable) + } + + resp, err := c.doUsageRequest(reqCtx, detailURL) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrCodexResetCreditsUnavailable, err) + } + + if resp.StatusCode == http.StatusNotFound { + // The usage poll sometimes needs the alternate path; probe it once here + // too, without caching anything (the usage poll owns endpoint selection). + altURL, altOK := buildCodexFallbackResetCreditsURL(detailURL) + if !altOK { + resp.Body.Close() + return nil, fmt.Errorf("%w: 404", ErrCodexResetCreditsUnavailable) + } + resp.Body.Close() + resp, err = c.doUsageRequest(reqCtx, altURL) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrCodexResetCreditsUnavailable, err) + } + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized, http.StatusForbidden: + // API-key auth and stale tokens are both expected rejections. Never + // refresh here: the normal usage poll owns auth handling. + return nil, fmt.Errorf("%w: status %d", ErrCodexResetCreditsUnavailable, resp.StatusCode) + case http.StatusNotFound: + return nil, fmt.Errorf("%w: 404", ErrCodexResetCreditsUnavailable) + default: + return nil, fmt.Errorf("%w: status %d", ErrCodexResetCreditsUnavailable, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<17)) + if err != nil { + return nil, fmt.Errorf("%w: reading body: %v", ErrCodexResetCreditsUnavailable, err) + } + parsed, err := ParseCodexResetCreditsResponse(body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrCodexResetCreditsUnavailable, err) + } + return parsed, nil +} + +// buildCodexFallbackResetCreditsURL maps a detail URL to the alternate detail +// path. It mirrors buildCodexFallbackBaseURL for the usage endpoints. +func buildCodexFallbackResetCreditsURL(detailURL string) (string, bool) { + u, err := url.Parse(detailURL) + if err != nil { + return "", false + } + switch { + case strings.Contains(u.Path, "/api/codex/rate-limit-reset-credits"): + u.Path = strings.Replace(u.Path, "/api/codex/rate-limit-reset-credits", "/backend-api/wham/rate-limit-reset-credits", 1) + case strings.Contains(u.Path, "/backend-api/wham/rate-limit-reset-credits"): + u.Path = strings.Replace(u.Path, "/backend-api/wham/rate-limit-reset-credits", "/api/codex/rate-limit-reset-credits", 1) + default: + return "", false + } + u.RawQuery = "" + u.Fragment = "" + return u.String(), true +} + func (c *CodexClient) doUsageRequest(ctx context.Context, usageURL string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil) if err != nil { diff --git a/internal/api/codex_reset_credits.go b/internal/api/codex_reset_credits.go new file mode 100644 index 00000000..8b604571 --- /dev/null +++ b/internal/api/codex_reset_credits.go @@ -0,0 +1,240 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// ErrCodexResetCreditsUnavailable marks a detail fetch that cannot produce +// reset-credit data (404, rejected auth, malformed body). It is informational: +// reset-credit failures must never affect usage polling or pause state. +var ErrCodexResetCreditsUnavailable = errors.New("codex: reset credits unavailable") + +// CodexResetCreditStatus is the normalized status of one banked reset credit. +// Unknown wire values map to CodexResetCreditStatusUnknown instead of failing. +type CodexResetCreditStatus int + +const ( + CodexResetCreditStatusUnknown CodexResetCreditStatus = iota + CodexResetCreditStatusAvailable + CodexResetCreditStatusRedeeming + CodexResetCreditStatusRedeemed +) + +// String returns the lowercase wire-style name used in the database and API. +func (s CodexResetCreditStatus) String() string { + switch s { + case CodexResetCreditStatusAvailable: + return "available" + case CodexResetCreditStatusRedeeming: + return "redeeming" + case CodexResetCreditStatusRedeemed: + return "redeemed" + default: + return "unknown" + } +} + +// ParseCodexResetCreditStatus normalizes a wire status. Unknown values are +// tolerated and reported as CodexResetCreditStatusUnknown. +func ParseCodexResetCreditStatus(raw string) CodexResetCreditStatus { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "available": + return CodexResetCreditStatusAvailable + case "redeeming": + return CodexResetCreditStatusRedeeming + case "redeemed": + return CodexResetCreditStatusRedeemed + default: + return CodexResetCreditStatusUnknown + } +} + +// CodexResetTypeUnknown is stored for reset_type values onWatch does not know. +const CodexResetTypeUnknown = "unknown" + +// CodexResetCredit is one banked reset credit from the detail endpoint. +type CodexResetCredit struct { + ID string + ResetType string + Status CodexResetCreditStatus + GrantedAt *time.Time + ExpiresAt *time.Time // nil means no usable expiry (never expires or unknown) + Title string + Description string + // UnsupportedByPlan is true only when the wire is_supported_by_plan flag + // was explicitly false. The zero value means usable, so a credit built + // directly (or a response that omits the flag) is never silently hidden; + // unsupported credits are excluded from plan-usable counts and warnings. + UnsupportedByPlan bool + // ExpiryUnknown marks an expiry the backend reported but that cannot be + // trusted (equal to granted_at, or already past while still available). + ExpiryUnknown bool +} + +// CodexResetCreditsResponse is the parsed reset-credits detail payload. +type CodexResetCreditsResponse struct { + Credits []CodexResetCredit + // AvailableCount is nil when the payload omits the field: unknown, not 0. + AvailableCount *int64 + // ApplicableAvailableCount is the plan-usable subset, nil when absent. + ApplicableAvailableCount *int64 + TotalEarnedCount int64 +} + +// codexResetCreditsWire mirrors the wire shape. Optional fields stay pointers +// so a missing value is never confused with a zero/default. +type codexResetCreditsWire struct { + Credits []struct { + ID string `json:"id"` + ResetType string `json:"reset_type"` + Status string `json:"status"` + GrantedAt *string `json:"granted_at"` + ExpiresAt *string `json:"expires_at"` + Title *string `json:"title"` + Description *string `json:"description"` + IsSupportedByPlan *bool `json:"is_supported_by_plan"` + } `json:"credits"` + AvailableCount *int64 `json:"available_count"` + ApplicableAvailableCount *int64 `json:"applicable_available_count"` + TotalEarnedCount *int64 `json:"total_earned_count"` +} + +// ParseCodexResetCreditsResponse parses a reset-credits detail payload using +// the wall clock to sanity-check expiries. A missing credits array is rejected +// as unavailable. +func ParseCodexResetCreditsResponse(data []byte) (*CodexResetCreditsResponse, error) { + return ParseCodexResetCreditsResponseAt(data, time.Now()) +} + +// ParseCodexResetCreditsResponseAt is ParseCodexResetCreditsResponse with an +// injectable clock for deterministic expiry sanitization. +// +// The backend can report an available credit whose expires_at is already +// reached (the real payload observed has expires_at == granted_at to the +// microsecond). Taken literally that would put a past expiry on the card and +// fire the lead-time warning immediately with a wrong time, so such an expiry +// is reported as unknown and the credit sorts with the non-expiring ones. +func ParseCodexResetCreditsResponseAt(data []byte, now time.Time) (*CodexResetCreditsResponse, error) { + var wire codexResetCreditsWire + if err := json.Unmarshal(data, &wire); err != nil { + return nil, err + } + if wire.Credits == nil { + return nil, fmt.Errorf("%w: credits field missing", ErrCodexResetCreditsUnavailable) + } + + resp := &CodexResetCreditsResponse{ + Credits: make([]CodexResetCredit, 0, len(wire.Credits)), + } + if wire.AvailableCount != nil { + count := *wire.AvailableCount + resp.AvailableCount = &count + } + if wire.ApplicableAvailableCount != nil { + count := *wire.ApplicableAvailableCount + resp.ApplicableAvailableCount = &count + } + if wire.TotalEarnedCount != nil { + resp.TotalEarnedCount = *wire.TotalEarnedCount + } + + now = now.UTC() + for _, c := range wire.Credits { + credit := CodexResetCredit{ + ID: strings.TrimSpace(c.ID), + ResetType: normalizeCodexResetType(c.ResetType), + Status: ParseCodexResetCreditStatus(c.Status), + UnsupportedByPlan: c.IsSupportedByPlan != nil && !*c.IsSupportedByPlan, + } + credit.GrantedAt = parseCodexOptionalRC3339(c.GrantedAt) + credit.ExpiresAt = parseCodexOptionalRC3339(c.ExpiresAt) + if c.Title != nil { + credit.Title = strings.TrimSpace(*c.Title) + } + if c.Description != nil { + credit.Description = strings.TrimSpace(*c.Description) + } + if credit.ID == "" { + continue // a credit without an ID cannot be tracked + } + credit.sanitizeExpiry(now) + resp.Credits = append(resp.Credits, credit) + } + + return resp, nil +} + +// sanitizeExpiry replaces an untrustworthy expiry on an available credit with +// "no usable expiry". Available credits already past expiry are never valid: +// the list would not still offer them. Redeemed/redeeming credits keep their +// real timestamps, since they are not shown as upcoming. +func (c *CodexResetCredit) sanitizeExpiry(now time.Time) { + if c.Status != CodexResetCreditStatusAvailable || c.ExpiresAt == nil { + return + } + notAfterGrant := c.GrantedAt != nil && !c.ExpiresAt.After(*c.GrantedAt) + if notAfterGrant || !c.ExpiresAt.After(now) { + c.ExpiresAt = nil + c.ExpiryUnknown = true + } +} + +func normalizeCodexResetType(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return CodexResetTypeUnknown + } + // The only reset type the CLI defines today. Anything else is kept as an + // opaque string so the database never gains an unconstrained enum. + if trimmed == "codex_rate_limits" { + return trimmed + } + return CodexResetTypeUnknown +} + +func parseCodexOptionalRC3339(raw *string) *time.Time { + if raw == nil { + return nil + } + trimmed := strings.TrimSpace(*raw) + if trimmed == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339, trimmed) + if err != nil { + return nil + } + utc := parsed.UTC() + return &utc +} + +// AvailableCodexResetCredits returns only available, plan-usable credits, +// sorted the way the Codex CLI sorts them: expiry ascending, never-expiring +// credits last. +func AvailableCodexResetCredits(credits []CodexResetCredit) []CodexResetCredit { + available := make([]CodexResetCredit, 0, len(credits)) + for _, c := range credits { + if c.Status == CodexResetCreditStatusAvailable && !c.UnsupportedByPlan { + available = append(available, c) + } + } + sort.SliceStable(available, func(i, j int) bool { + left, right := available[i].ExpiresAt, available[j].ExpiresAt + switch { + case left == nil && right == nil: + return false + case left == nil: + return false // never-expiring sorts last + case right == nil: + return true + default: + return left.Before(*right) + } + }) + return available +} diff --git a/internal/api/codex_reset_credits_client_test.go b/internal/api/codex_reset_credits_client_test.go new file mode 100644 index 00000000..653c175e --- /dev/null +++ b/internal/api/codex_reset_credits_client_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" +) + +const codexResetCreditsJSON = `{ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z" + }, + { + "id": "credit-2", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + } + ], + "available_count": 2, + "total_earned_count": 4 +}` + +func TestBuildCodexResetCreditsURL(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + { + in: "https://chatgpt.com/backend-api/wham/usage", + want: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + ok: true, + }, + { + in: "https://chatgpt.com/api/codex/usage", + want: "https://chatgpt.com/api/codex/rate-limit-reset-credits", + ok: true, + }, + { + in: "https://chatgpt.com/backend-api/wham/usage?x=1", + want: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + ok: true, + }, + {in: "https://example.test/unknown", ok: false}, + } + for _, tc := range cases { + got, ok := buildCodexResetCreditsURL(tc.in) + if ok != tc.ok { + t.Fatalf("buildCodexResetCreditsURL(%q) ok = %v, want %v", tc.in, ok, tc.ok) + } + if ok && got != tc.want { + t.Fatalf("buildCodexResetCreditsURL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCodexClient_FetchResetCredits_DefaultBaseAndHeaders(t *testing.T) { + var gotPath, gotAuth, gotAccount string + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotAccount = r.Header.Get("ChatGPT-Account-Id") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, codexResetCreditsJSON) + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + client.SetAccountID("acct-123") + + resp, err := client.FetchResetCredits(context.Background()) + if err != nil { + t.Fatalf("FetchResetCredits: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want 1", calls.Load()) + } + if gotPath != "/backend-api/wham/rate-limit-reset-credits" { + t.Fatalf("path = %q, want the wham detail path", gotPath) + } + if gotAuth != "Bearer oauth_token" { + t.Fatalf("Authorization = %q", gotAuth) + } + if gotAccount != "acct-123" { + t.Fatalf("ChatGPT-Account-Id = %q", gotAccount) + } + if resp.AvailableCount == nil || *resp.AvailableCount != 2 || len(resp.Credits) != 2 { + t.Fatalf("response = %#v", resp) + } +} + +func TestCodexClient_FetchResetCredits_AlternateBase(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, codexResetCreditsJSON) + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/api/codex/usage")) + + if _, err := client.FetchResetCredits(context.Background()); err != nil { + t.Fatalf("FetchResetCredits: %v", err) + } + if gotPath != "/api/codex/rate-limit-reset-credits" { + t.Fatalf("path = %q, want the codex-api detail path", gotPath) + } +} + +func TestCodexClient_FetchResetCredits_UsesCachedWorkingUsageBase(t *testing.T) { + var gotPath string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPath = r.URL.Path + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, codexResetCreditsJSON) + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + // The usage poll learned that this account is served from the alternate base. + client.setFallbackBaseURL(srv.URL + "/api/codex/usage") + + if _, err := client.FetchResetCredits(context.Background()); err != nil { + t.Fatalf("FetchResetCredits: %v", err) + } + mu.Lock() + defer mu.Unlock() + if gotPath != "/api/codex/rate-limit-reset-credits" { + t.Fatalf("path = %q, want the cached alternate base", gotPath) + } +} + +func TestCodexClient_FetchResetCredits_404FallsBackOnceThenUnavailable(t *testing.T) { + var primaryCalls, fallbackCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/wham/rate-limit-reset-credits": + primaryCalls.Add(1) + w.WriteHeader(http.StatusNotFound) + case "/api/codex/rate-limit-reset-credits": + fallbackCalls.Add(1) + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + + _, err := client.FetchResetCredits(context.Background()) + if !errors.Is(err, ErrCodexResetCreditsUnavailable) { + t.Fatalf("err = %v, want ErrCodexResetCreditsUnavailable", err) + } + if primaryCalls.Load() != 1 || fallbackCalls.Load() != 1 { + t.Fatalf("calls = %d/%d, want exactly one probe each", primaryCalls.Load(), fallbackCalls.Load()) + } +} + +func TestCodexClient_FetchResetCredits_UnauthorizedUnavailable(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + status := status + t.Run(fmt.Sprintf("%d", status), func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(status) + })) + defer srv.Close() + + client := NewCodexClient("api_key_like_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + _, err := client.FetchResetCredits(context.Background()) + if !errors.Is(err, ErrCodexResetCreditsUnavailable) { + t.Fatalf("err = %v, want ErrCodexResetCreditsUnavailable", err) + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want 1 (never retried)", calls.Load()) + } + }) + } +} + +func TestCodexClient_FetchResetCredits_MissingCreditsFieldUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"available_count": 1}`) + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + _, err := client.FetchResetCredits(context.Background()) + if !errors.Is(err, ErrCodexResetCreditsUnavailable) { + t.Fatalf("err = %v, want ErrCodexResetCreditsUnavailable", err) + } +} + +func TestCodexClient_FetchResetCredits_ServerErrorDoesNotRetry(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client := NewCodexClient("oauth_token", discardLoggerClient(), + WithCodexBaseURL(srv.URL+"/backend-api/wham/usage")) + if _, err := client.FetchResetCredits(context.Background()); err == nil { + t.Fatal("expected an error") + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want 1 (no retry loop)", calls.Load()) + } +} diff --git a/internal/api/codex_reset_credits_types_test.go b/internal/api/codex_reset_credits_types_test.go new file mode 100644 index 00000000..0c1ef4a9 --- /dev/null +++ b/internal/api/codex_reset_credits_types_test.go @@ -0,0 +1,384 @@ +package api + +import ( + "testing" + "time" +) + +func TestParseCodexUsageResponse_ResetCreditsCountPresent(t *testing.T) { + payload := []byte(`{ + "plan_type": "plus", + "rate_limit": {"primary_window": {"used_percent": 10, "reset_at": 1766000000, "limit_window_seconds": 18000}}, + "rate_limit_reset_credits": {"available_count": 3} + }`) + + resp, err := ParseCodexUsageResponse(payload) + if err != nil { + t.Fatalf("ParseCodexUsageResponse: %v", err) + } + if resp.RateLimitResetCredits == nil { + t.Fatal("RateLimitResetCredits = nil, want a parsed summary") + } + if !resp.RateLimitResetCredits.AvailableCount.Valid { + t.Fatal("AvailableCount invalid, want known") + } + if got := resp.RateLimitResetCredits.AvailableCount.Value; got != 3 { + t.Fatalf("AvailableCount = %d, want 3", got) + } + + snap := resp.ToSnapshot(time.Unix(1765900000, 0).UTC()) + if !snap.ResetCreditsAvailableCount.Valid || snap.ResetCreditsAvailableCount.Value != 3 { + t.Fatalf("snapshot count = %#v, want known 3", snap.ResetCreditsAvailableCount) + } + // The persisted raw JSON is re-parsed for history; it must carry the count. + reparsed, err := ParseCodexUsageResponse([]byte(snap.RawJSON)) + if err != nil { + t.Fatalf("re-parse snapshot raw JSON: %v (raw=%q)", err, snap.RawJSON) + } + if reparsed.RateLimitResetCredits == nil || + !reparsed.RateLimitResetCredits.AvailableCount.Valid || + reparsed.RateLimitResetCredits.AvailableCount.Value != 3 { + t.Fatalf("raw JSON lost the reset-credit count: %q", snap.RawJSON) + } +} + +func TestParseCodexUsageResponse_ResetCreditsCountAbsentIsUnknownNotZero(t *testing.T) { + payload := []byte(`{ + "plan_type": "plus", + "rate_limit": {"primary_window": {"used_percent": 10, "reset_at": 1766000000, "limit_window_seconds": 18000}} + }`) + + resp, err := ParseCodexUsageResponse(payload) + if err != nil { + t.Fatalf("ParseCodexUsageResponse: %v", err) + } + if resp.RateLimitResetCredits != nil { + t.Fatalf("RateLimitResetCredits = %#v, want nil for an absent field", resp.RateLimitResetCredits) + } + + snap := resp.ToSnapshot(time.Unix(1765900000, 0).UTC()) + if snap.ResetCreditsAvailableCount.Valid { + t.Fatalf("snapshot count = %#v, want unknown", snap.ResetCreditsAvailableCount) + } +} + +func TestParseCodexUsageResponse_ResetCreditsNullCountIsUnknown(t *testing.T) { + payload := []byte(`{ + "plan_type": "plus", + "rate_limit": {"primary_window": {"used_percent": 10, "reset_at": 1766000000, "limit_window_seconds": 18000}}, + "rate_limit_reset_credits": {"available_count": null} + }`) + + resp, err := ParseCodexUsageResponse(payload) + if err != nil { + t.Fatalf("ParseCodexUsageResponse: %v", err) + } + snap := resp.ToSnapshot(time.Unix(1765900000, 0).UTC()) + if snap.ResetCreditsAvailableCount.Valid { + t.Fatalf("snapshot count = %#v, want unknown for a null count", snap.ResetCreditsAvailableCount) + } +} + +func TestParseCodexResetCreditsResponse_AvailableAndUnknownValues(t *testing.T) { + payload := []byte(`{ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z", + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + }, + { + "id": "credit-2", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + }, + { + "id": "credit-3", + "reset_type": "gift_card", + "status": "mystery_status", + "granted_at": "2026-06-19T00:00:00Z", + "expires_at": "2026-08-19T00:00:00Z" + } + ], + "available_count": 2, + "total_earned_count": 4 + }`) + + // Parse at grant time so the fixture's expiries are not "already past". + resp, err := ParseCodexResetCreditsResponseAt(payload, time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponseAt: %v", err) + } + if resp.AvailableCount == nil || *resp.AvailableCount != 2 || resp.TotalEarnedCount != 4 { + t.Fatalf("counts = %v/%d, want 2/4", resp.AvailableCount, resp.TotalEarnedCount) + } + if len(resp.Credits) != 3 { + t.Fatalf("credits len = %d, want 3", len(resp.Credits)) + } + + first := resp.Credits[0] + if first.ID != "credit-1" || first.Status != CodexResetCreditStatusAvailable || first.ResetType != "codex_rate_limits" { + t.Fatalf("first credit = %#v", first) + } + if first.ExpiresAt == nil || !first.ExpiresAt.Equal(time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("first expires_at = %v", first.ExpiresAt) + } + if first.Title != "Full reset (Weekly + 5 hr)" || first.Description != "Ready to redeem" { + t.Fatalf("first text = %q / %q", first.Title, first.Description) + } + + if resp.Credits[1].ExpiresAt != nil { + t.Fatalf("second expires_at = %v, want nil (never expires)", resp.Credits[1].ExpiresAt) + } + if resp.Credits[1].Title != "" || resp.Credits[1].Description != "" { + t.Fatalf("optional text should stay empty, got %q / %q", resp.Credits[1].Title, resp.Credits[1].Description) + } + + // Unknown enum values must be tolerated, not rejected. + if resp.Credits[2].Status != CodexResetCreditStatusUnknown { + t.Fatalf("third status = %q, want unknown", resp.Credits[2].Status) + } + if resp.Credits[2].ResetType != CodexResetTypeUnknown { + t.Fatalf("third reset_type = %q, want unknown", resp.Credits[2].ResetType) + } +} + +func TestParseCodexResetCreditsResponse_EqualGrantAndExpiryIsUnknown(t *testing.T) { + // Real backend shape: granted_at and expires_at identical to the + // microsecond while the credit is available. Taken literally it expired at + // grant time, so the expiry must be treated as unknown. + payload := []byte(`{ + "credits": [ + { + "id": "credit-real", + "reset_type": "codex_rate_limits", + "is_supported_by_plan": true, + "status": "available", + "granted_at": "2026-10-18T10:00:00.897669Z", + "expires_at": "2026-10-18T10:00:00.897669Z", + "redeem_started_at": null, + "redeemed_at": null, + "title": "Full reset", + "description": "Ready to redeem" + } + ], + "available_count": 1, + "total_earned_count": 0, + "immediate_reset_purchase_eligible": false, + "history_enabled": true + }`) + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + resp, err := ParseCodexResetCreditsResponseAt(payload, now) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponseAt: %v", err) + } + if len(resp.Credits) != 1 { + t.Fatalf("credits len = %d, want 1", len(resp.Credits)) + } + credit := resp.Credits[0] + if credit.ExpiresAt != nil { + t.Fatalf("ExpiresAt = %v, want nil (unknown)", credit.ExpiresAt) + } + if !credit.ExpiryUnknown { + t.Fatal("ExpiryUnknown = false, want true for expires_at == granted_at") + } + if resp.AvailableCount == nil || *resp.AvailableCount != 1 { + t.Fatalf("AvailableCount = %v, want 1", resp.AvailableCount) + } + // Never list an unknown-expiry credit as expiring in lead-window queries. + if credits := AvailableCodexResetCredits(resp.Credits); len(credits) != 1 || credits[0].ExpiresAt != nil { + t.Fatalf("available credits = %#v, want one with no expiry", credits) + } +} + +func TestParseCodexResetCreditsResponse_PastExpiryAvailableIsUnknown(t *testing.T) { + payload := []byte(`{ + "credits": [ + { + "id": "credit-past", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-10-01T00:00:00Z", + "expires_at": "2026-10-02T00:00:00Z" + } + ], + "available_count": 1 + }`) + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + resp, err := ParseCodexResetCreditsResponseAt(payload, now) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponseAt: %v", err) + } + if resp.Credits[0].ExpiresAt != nil || !resp.Credits[0].ExpiryUnknown { + t.Fatalf("credit = %#v, want unknown expiry for an available credit already past expiry", resp.Credits[0]) + } +} + +func TestParseCodexResetCreditsResponse_PastExpiryRedeemedIsKept(t *testing.T) { + // Only available credits are sanitized: a redeemed credit legitimately has + // a past expiry and is never shown or notified anyway. + payload := []byte(`{ + "credits": [ + { + "id": "credit-redeemed", + "reset_type": "codex_rate_limits", + "status": "redeemed", + "granted_at": "2026-10-01T00:00:00Z", + "expires_at": "2026-10-02T00:00:00Z" + } + ], + "available_count": 0 + }`) + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + resp, err := ParseCodexResetCreditsResponseAt(payload, now) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponseAt: %v", err) + } + if resp.Credits[0].ExpiresAt == nil || resp.Credits[0].ExpiryUnknown { + t.Fatalf("credit = %#v, want the real past expiry kept for a redeemed credit", resp.Credits[0]) + } +} + +func TestParseCodexResetCreditsResponse_ApplicableCountAndSupportedFlag(t *testing.T) { + payload := []byte(`{ + "credits": [ + { + "id": "credit-supported", + "reset_type": "codex_rate_limits", + "is_supported_by_plan": true, + "status": "available", + "granted_at": "2026-10-01T00:00:00Z", + "expires_at": "2026-11-01T00:00:00Z" + }, + { + "id": "credit-unsupported", + "reset_type": "codex_rate_limits", + "is_supported_by_plan": false, + "status": "available", + "granted_at": "2026-10-02T00:00:00Z", + "expires_at": "2026-11-02T00:00:00Z" + }, + { + "id": "credit-unspecified", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-10-03T00:00:00Z", + "expires_at": "2026-11-03T00:00:00Z" + } + ], + "available_count": 3, + "applicable_available_count": 2, + "total_earned_count": 0, + "immediate_reset_purchase_eligible": false, + "history_enabled": true + }`) + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + resp, err := ParseCodexResetCreditsResponseAt(payload, now) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponseAt: %v", err) + } + if resp.ApplicableAvailableCount == nil || *resp.ApplicableAvailableCount != 2 { + t.Fatalf("ApplicableAvailableCount = %v, want 2", resp.ApplicableAvailableCount) + } + if resp.Credits[0].UnsupportedByPlan { + t.Fatal("first credit should be supported") + } + if !resp.Credits[1].UnsupportedByPlan { + t.Fatal("second credit should be unsupported") + } + // A missing flag defaults to supported. + if resp.Credits[2].UnsupportedByPlan { + t.Fatal("third credit (flag absent) should default to supported") + } +} + +func TestParseCodexResetCreditsResponse_MissingAvailableCountIsNil(t *testing.T) { + payload := []byte(`{"credits": [ + {"id":"credit-1","reset_type":"codex_rate_limits","status":"available","expires_at":null} + ]}`) + resp, err := ParseCodexResetCreditsResponse(payload) + if err != nil { + t.Fatalf("ParseCodexResetCreditsResponse: %v", err) + } + if resp.AvailableCount != nil { + t.Fatalf("AvailableCount = %v, want nil when absent", *resp.AvailableCount) + } + if resp.ApplicableAvailableCount != nil { + t.Fatalf("ApplicableAvailableCount = %v, want nil when absent", *resp.ApplicableAvailableCount) + } +} + +func TestParseCodexUsageResponse_ApplicableCount(t *testing.T) { + payload := []byte(`{ + "plan_type": "plus", + "rate_limit": {"primary_window": {"used_percent": 10, "reset_at": 1766000000, "limit_window_seconds": 18000}}, + "rate_limit_reset_credits": {"available_count": 3, "applicable_available_count": 1} + }`) + resp, err := ParseCodexUsageResponse(payload) + if err != nil { + t.Fatalf("ParseCodexUsageResponse: %v", err) + } + if resp.RateLimitResetCredits == nil { + t.Fatal("RateLimitResetCredits = nil") + } + if !resp.RateLimitResetCredits.ApplicableAvailableCount.Valid || + resp.RateLimitResetCredits.ApplicableAvailableCount.Value != 1 { + t.Fatalf("ApplicableAvailableCount = %#v, want known 1", resp.RateLimitResetCredits.ApplicableAvailableCount) + } + snap := resp.ToSnapshot(time.Unix(1765900000, 0).UTC()) + if !snap.ResetCreditsApplicableCount.Valid || snap.ResetCreditsApplicableCount.Value != 1 { + t.Fatalf("snapshot applicable = %#v, want known 1", snap.ResetCreditsApplicableCount) + } +} + +func TestParseCodexResetCreditsResponse_RejectsMissingCredits(t *testing.T) { + if _, err := ParseCodexResetCreditsResponse([]byte(`{"available_count":1}`)); err == nil { + t.Fatal("expected an error when credits[] is missing") + } +} + +func TestCodexResetCredits_AvailableSortedByExpiryNeverExpiringLast(t *testing.T) { + day := func(d int) *time.Time { + t := time.Date(2026, 7, d, 0, 0, 0, 0, time.UTC) + return &t + } + credits := []CodexResetCredit{ + {ID: "never", Status: CodexResetCreditStatusAvailable}, + {ID: "later", Status: CodexResetCreditStatusAvailable, ExpiresAt: day(20)}, + {ID: "redeemed", Status: CodexResetCreditStatusRedeemed, ExpiresAt: day(1)}, + {ID: "sooner", Status: CodexResetCreditStatusAvailable, ExpiresAt: day(10)}, + } + + available := AvailableCodexResetCredits(credits) + if len(available) != 3 { + t.Fatalf("available len = %d, want 3", len(available)) + } + want := []string{"sooner", "later", "never"} + for i, id := range want { + if available[i].ID != id { + t.Fatalf("available[%d] = %q, want %q", i, available[i].ID, id) + } + } +} + +func TestCodexResetCreditStatus_StringValues(t *testing.T) { + cases := map[CodexResetCreditStatus]string{ + CodexResetCreditStatusAvailable: "available", + CodexResetCreditStatusRedeeming: "redeeming", + CodexResetCreditStatusRedeemed: "redeemed", + CodexResetCreditStatusUnknown: "unknown", + } + for status, want := range cases { + if got := status.String(); got != want { + t.Fatalf("status %d = %q, want %q", status, got, want) + } + } +} diff --git a/internal/api/codex_types.go b/internal/api/codex_types.go index 123160ef..00f0d190 100644 --- a/internal/api/codex_types.go +++ b/internal/api/codex_types.go @@ -11,10 +11,48 @@ import ( // CodexUsageResponse is the OAuth usage payload returned by Codex. type CodexUsageResponse struct { - PlanType string `json:"plan_type"` - RateLimit codexRateLimit `json:"rate_limit"` - CodeReviewRateLimit codexRateLimit `json:"code_review_rate_limit,omitempty"` - Credits *codexCredits `json:"credits,omitempty"` + PlanType string `json:"plan_type"` + RateLimit codexRateLimit `json:"rate_limit"` + CodeReviewRateLimit codexRateLimit `json:"code_review_rate_limit,omitempty"` + Credits *codexCredits `json:"credits,omitempty"` + RateLimitResetCredits *codexResetCreditsCount `json:"rate_limit_reset_credits,omitempty"` +} + +// codexResetCreditsCount is the summary block on the usage response +// (`rate_limit_reset_credits: {"available_count": N}`). The optional integer +// keeps an absent value distinguishable from an explicit zero (issue #152). +type codexResetCreditsCount struct { + AvailableCount CodexOptionalInt64 `json:"available_count"` + ApplicableAvailableCount CodexOptionalInt64 `json:"applicable_available_count"` +} + +// CodexOptionalInt64 distinguishes an absent/null JSON integer from 0. +type CodexOptionalInt64 struct { + Value int64 + Valid bool +} + +func (o *CodexOptionalInt64) UnmarshalJSON(data []byte) error { + s := strings.TrimSpace(string(data)) + if s == "null" || s == "" { + o.Value, o.Valid = 0, false + return nil + } + var n int64 + if err := json.Unmarshal(data, &n); err != nil { + return err + } + o.Value, o.Valid = n, true + return nil +} + +// MarshalJSON emits the original wire shape, so a snapshot's raw JSON keeps an +// unknown count as null rather than the struct's internal fields. +func (o CodexOptionalInt64) MarshalJSON() ([]byte, error) { + if !o.Valid { + return []byte("null"), nil + } + return json.Marshal(o.Value) } type codexRateLimit struct { @@ -83,7 +121,13 @@ type CodexSnapshot struct { Quotas []CodexQuota PlanType string CreditsBalance *float64 - RawJSON string + // ResetCreditsAvailableCount is the banked reset credit count reported by + // the usage response. Valid is false when the field is absent: unknown is + // not the same as zero. ResetCreditsApplicableCount is the plan-usable + // subset reported alongside it. + ResetCreditsAvailableCount CodexOptionalInt64 + ResetCreditsApplicableCount CodexOptionalInt64 + RawJSON string } var codexDisplayNames = map[string]string{ @@ -121,6 +165,17 @@ func (r CodexUsageResponse) ToSnapshot(capturedAt time.Time) *CodexSnapshot { snapshot.CreditsBalance = &balance } + if r.RateLimitResetCredits != nil { + snapshot.ResetCreditsAvailableCount = CodexOptionalInt64{ + Value: r.RateLimitResetCredits.AvailableCount.Value, + Valid: r.RateLimitResetCredits.AvailableCount.Valid, + } + snapshot.ResetCreditsApplicableCount = CodexOptionalInt64{ + Value: r.RateLimitResetCredits.ApplicableAvailableCount.Value, + Valid: r.RateLimitResetCredits.ApplicableAvailableCount.Valid, + } + } + if name := codexPrimaryQuotaName(r.PlanType, r.RateLimit.PrimaryWindow, r.RateLimit.SecondaryWindow); name != "" { snapshot.Quotas = append(snapshot.Quotas, codexQuotaFromWindow(name, r.RateLimit.PrimaryWindow)) } @@ -140,7 +195,17 @@ func (r CodexUsageResponse) ToSnapshot(capturedAt time.Time) *CodexSnapshot { return snapshot.Quotas[i].Name < snapshot.Quotas[j].Name }) - if raw, err := json.Marshal(r); err == nil { + // Re-marshal from a carrier that includes the reset-credit count: the raw + // JSON is persisted and re-parsed for history, so an unknown field dropped + // here would be lost (issue #152). + carrier := struct { + CodexUsageResponse + RateLimitResetCredits *codexResetCreditsCount `json:"rate_limit_reset_credits,omitempty"` + }{CodexUsageResponse: r} + if r.RateLimitResetCredits != nil { + carrier.RateLimitResetCredits = r.RateLimitResetCredits + } + if raw, err := json.Marshal(carrier); err == nil { snapshot.RawJSON = string(raw) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index f968eb1d..4ea52ba3 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -62,6 +62,8 @@ type NotificationTypes struct { Critical bool `json:"critical"` Reset bool `json:"reset"` AuthError bool `json:"auth_error"` // Auth failure notifications + // ResetCreditExpiry warns before a banked Codex reset credit expires. + ResetCreditExpiry bool `json:"reset_credit_expiry"` } // QuotaStatus represents the current state of a quota for notification evaluation. @@ -86,7 +88,7 @@ func New(s *store.Store, logger *slog.Logger) *NotificationEngine { Critical: 95, Overrides: make(map[string]ThresholdOverride), Cooldown: defaultNotificationCooldown, - Types: NotificationTypes{Warning: true, Critical: true, Reset: false}, + Types: NotificationTypes{Warning: true, Critical: true, Reset: false, ResetCreditExpiry: true}, Channels: NotificationChannels{Email: true, Push: true}, }, } @@ -124,16 +126,17 @@ func (e *NotificationEngine) Config() NotificationConfig { // notificationSettingsJSON matches the JSON shape saved by the handler's UpdateSettings. type notificationSettingsJSON struct { - WarningThreshold float64 `json:"warning_threshold"` - CriticalThreshold float64 `json:"critical_threshold"` - NotifyWarning bool `json:"notify_warning"` - NotifyCritical bool `json:"notify_critical"` - NotifyReset bool `json:"notify_reset"` - NotifyAuthError bool `json:"notify_auth_error"` - NotifyRepeat bool `json:"notify_repeat"` - CooldownMinutes int `json:"cooldown_minutes"` - Channels *NotificationChannels `json:"channels,omitempty"` - Overrides []struct { + WarningThreshold float64 `json:"warning_threshold"` + CriticalThreshold float64 `json:"critical_threshold"` + NotifyWarning bool `json:"notify_warning"` + NotifyCritical bool `json:"notify_critical"` + NotifyReset bool `json:"notify_reset"` + NotifyAuthError bool `json:"notify_auth_error"` + NotifyResetCreditExpiry *bool `json:"notify_reset_credit_expiry"` + NotifyRepeat bool `json:"notify_repeat"` + CooldownMinutes int `json:"cooldown_minutes"` + Channels *NotificationChannels `json:"channels,omitempty"` + Overrides []struct { QuotaKey string `json:"quota_key"` Provider string `json:"provider"` Warning float64 `json:"warning"` @@ -178,6 +181,9 @@ func (e *NotificationEngine) Reload() error { Critical: notif.NotifyCritical, Reset: notif.NotifyReset, AuthError: notif.NotifyAuthError, + // Default on when the setting is absent: an expiring banked reset is + // value the user asked to be warned about. + ResetCreditExpiry: notif.NotifyResetCreditExpiry == nil || *notif.NotifyResetCreditExpiry, } overrides := make(map[string]ThresholdOverride, len(notif.Overrides)) @@ -989,7 +995,102 @@ func (e *NotificationEngine) SendStarterEvent(ev StarterEvent) { channels.deliverWebhook(e.logger, payload) } +// ResetCreditExpiryAlert describes a banked Codex reset credit that is about to +// expire. The message names the account (label, never tokens) and when the +// credit expires. +type ResetCreditExpiryAlert struct { + AccountID string + AccountName string + CreditID string + Title string + ExpiresAt time.Time +} + +// SendResetCreditExpiryNotification delivers one expiry warning for a banked +// reset credit (issue #152). Dedupe lives in the credit row (notified_at), so +// this is the transport only: the caller claims the row first. +// +// It returns true when at least one configured channel accepted the message, so +// the caller can release a claim whose delivery reached nothing and retry on a +// later fetch. +func (e *NotificationEngine) SendResetCreditExpiryNotification(alert ResetCreditExpiryAlert) bool { + e.mu.RLock() + cfg := e.cfg + channels := channelSet{ + mailer: e.mailer, + push: e.pushSender, + webhook: e.webhookSender, + enabled: e.cfg.Channels, + } + e.mu.RUnlock() + + // Respect the shared notification type switch: turning operational alerts + // off silences these too. + if !cfg.Types.ResetCreditExpiry { + return false + } + + account := strings.TrimSpace(alert.AccountName) + if account == "" { + account = "Codex account " + alert.AccountID + } + creditTitle := strings.TrimSpace(alert.Title) + if creditTitle == "" { + creditTitle = "Reset credit" + } + expires := alert.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC") + + subject := fmt.Sprintf("[RESET CREDIT] %s credit expires %s", titleCase("codex"), expires) + message := fmt.Sprintf( + "A banked Codex reset credit for %s expires at %s (%s). For reference, the Codex CLI exposes it under /usage, where you can redeem it before it expires.", + account, expires, creditTitle, + ) + + sent := false + if channels.enabled.Email && channels.mailer != nil { + if err := channels.mailer.Send(subject, message); err != nil { + e.logger.Error("failed to send reset-credit expiry email", "error", err, "account", alert.AccountID) + } else { + sent = true + } + } + if channels.enabled.Push && channels.push != nil { + subs, err := e.store.GetPushSubscriptions() + if err != nil { + e.logger.Error("failed to get push subscriptions", "error", err) + } else { + for _, sub := range subs { + ps := PushSubscription{Endpoint: sub.Endpoint} + ps.Keys.P256dh = sub.P256dh + ps.Keys.Auth = sub.Auth + if err := channels.push.Send(ps, subject, message); err != nil { + e.logger.Error("failed to send reset-credit expiry push", "error", err, "endpoint", sub.Endpoint) + if strings.Contains(err.Error(), "410") { + e.store.DeletePushSubscription(sub.Endpoint) + } + } else { + sent = true + } + } + } + } + if channels.deliverWebhook(e.logger, WebhookPayload{ + Event: EventResetCreditExpiry, + Provider: "codex", + QuotaKey: alert.CreditID, + AccountID: alert.AccountID, + ResetAt: alert.ExpiresAt.UTC().Format(time.RFC3339), + Title: subject, + Message: message, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) { + sent = true + } + return sent +} + // SendTestWebhook posts a test payload to verify the webhook configuration. + // It ignores the per-event toggles so the endpoint can be verified during setup. func (e *NotificationEngine) SendTestWebhook() error { e.mu.RLock() diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index ca11ea13..7634692b 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -67,12 +67,13 @@ type WebhookConfig struct { // WebhookEvents controls which events are delivered to the webhook endpoint. type WebhookEvents struct { - Warning bool `json:"warning"` - Critical bool `json:"critical"` - Reset bool `json:"reset"` - AuthError bool `json:"auth_error"` - StarterSuccess bool `json:"starter_success"` - StarterFailure bool `json:"starter_failure"` + Warning bool `json:"warning"` + Critical bool `json:"critical"` + Reset bool `json:"reset"` + AuthError bool `json:"auth_error"` + StarterSuccess bool `json:"starter_success"` + StarterFailure bool `json:"starter_failure"` + ResetCreditExpiry bool `json:"reset_credit_expiry"` } // Enabled reports whether the named event should be delivered. @@ -90,6 +91,8 @@ func (e WebhookEvents) Enabled(event string) bool { return e.StarterSuccess case EventStarterFailure: return e.StarterFailure + case EventResetCreditExpiry: + return e.ResetCreditExpiry default: return false } @@ -97,13 +100,14 @@ func (e WebhookEvents) Enabled(event string) bool { // Event names used in webhook payloads and in the notification dedupe log. const ( - EventWarning = "warning" - EventCritical = "critical" - EventReset = "reset" - EventAuthError = "auth_error" - EventStarterSuccess = "starter_success" - EventStarterFailure = "starter_failure" - EventTest = "test" + EventWarning = "warning" + EventCritical = "critical" + EventReset = "reset" + EventAuthError = "auth_error" + EventStarterSuccess = "starter_success" + EventStarterFailure = "starter_failure" + EventResetCreditExpiry = "reset_credit_expiry" + EventTest = "test" ) // WebhookPayload is the JSON body POSTed to the configured endpoint. diff --git a/internal/store/codex_reset_credits.go b/internal/store/codex_reset_credits.go new file mode 100644 index 00000000..7d1b2838 --- /dev/null +++ b/internal/store/codex_reset_credits.go @@ -0,0 +1,373 @@ +package store + +import ( + "database/sql" + "fmt" + "time" +) + +// CodexResetCreditRecord is one banked reset credit row for an account. +type CodexResetCreditRecord struct { + AccountID int64 + CreditID string + Status string + ResetType string + Title string + Description string + GrantedAt *time.Time + ExpiresAt *time.Time // nil means the credit never expires + // UnsupportedByPlan mirrors an explicit is_supported_by_plan=false. Such + // credits are excluded from the next-expiry display and expiry warnings. + UnsupportedByPlan bool + // ExpiryUnknown marks an expiry the backend reported but that is not + // trustworthy (equal to granted_at, or already past while available). + ExpiryUnknown bool + FirstSeen time.Time + LastSeen time.Time + GoneAt *time.Time + NotifiedAt *time.Time +} + +// SyncCodexResetCredits upserts the credits from one successful detail fetch +// and marks the ones that disappeared as gone (never deletes them, so the +// expiry-notification dedupe survives). first_seen is preserved across +// updates; gone_at is cleared when a credit reappears. +func (s *Store) SyncCodexResetCredits(accountID int64, credits []CodexResetCreditRecord, seenAt time.Time) error { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + seenAt = seenAt.UTC() + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("codex reset credits: begin: %w", err) + } + defer tx.Rollback() + + seen := make(map[string]bool, len(credits)) + for _, c := range credits { + if c.CreditID == "" { + continue + } + seen[c.CreditID] = true + var grantedAt, expiresAt interface{} + if c.GrantedAt != nil { + grantedAt = c.GrantedAt.UTC().Format(time.RFC3339) + } + if c.ExpiresAt != nil { + expiresAt = c.ExpiresAt.UTC().Format(time.RFC3339) + } + if _, err := tx.Exec( + `INSERT INTO codex_reset_credits + (account_id, credit_id, status, reset_type, title, description, granted_at, expires_at, + unsupported_by_plan, expiry_unknown, first_seen, last_seen, gone_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(account_id, credit_id) DO UPDATE SET + status = excluded.status, + reset_type = excluded.reset_type, + title = excluded.title, + description = excluded.description, + granted_at = excluded.granted_at, + expires_at = excluded.expires_at, + unsupported_by_plan = excluded.unsupported_by_plan, + expiry_unknown = excluded.expiry_unknown, + last_seen = excluded.last_seen, + gone_at = NULL`, + accountID, + c.CreditID, + c.Status, + c.ResetType, + c.Title, + c.Description, + grantedAt, + expiresAt, + c.UnsupportedByPlan, + c.ExpiryUnknown, + seenAt.Format(time.RFC3339), + seenAt.Format(time.RFC3339), + ); err != nil { + return fmt.Errorf("codex reset credits: upsert %q: %w", c.CreditID, err) + } + } + + // Mark every credit this account previously had but the fetch did not + // return. A bounded, parameterized update over the account's rows only. + rows, err := tx.Query( + `SELECT credit_id FROM codex_reset_credits WHERE account_id = ? AND gone_at IS NULL LIMIT 500`, + accountID, + ) + if err != nil { + return fmt.Errorf("codex reset credits: list existing: %w", err) + } + var missing []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return fmt.Errorf("codex reset credits: scan existing: %w", err) + } + if !seen[id] { + missing = append(missing, id) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("codex reset credits: iterate existing: %w", err) + } + rows.Close() + + for _, id := range missing { + if _, err := tx.Exec( + `UPDATE codex_reset_credits SET gone_at = ? WHERE account_id = ? AND credit_id = ?`, + seenAt.Format(time.RFC3339), accountID, id, + ); err != nil { + return fmt.Errorf("codex reset credits: mark gone %q: %w", id, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("codex reset credits: commit: %w", err) + } + return nil +} + +// QueryCodexResetCredits returns the account's tracked credits, bounded and +// ordered by expiry (never-expiring last) then ID for a stable display. +func (s *Store) QueryCodexResetCredits(accountID int64) ([]CodexResetCreditRecord, error) { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + rows, err := s.db.Query( + `SELECT account_id, credit_id, status, reset_type, title, description, granted_at, expires_at, + unsupported_by_plan, expiry_unknown, first_seen, last_seen, gone_at, notified_at + FROM codex_reset_credits + WHERE account_id = ? + ORDER BY (expires_at IS NULL), expires_at ASC, credit_id ASC + LIMIT 200`, + accountID, + ) + if err != nil { + return nil, fmt.Errorf("codex reset credits: query: %w", err) + } + defer rows.Close() + + var out []CodexResetCreditRecord + for rows.Next() { + record, err := scanCodexResetCredit(rows) + if err != nil { + return nil, err + } + out = append(out, record) + } + return out, rows.Err() +} + +// QueryCodexResetCreditsNeedingExpiryNotice returns available credits that +// expire within lead time, have not been notified yet, and have not +// disappeared. Never-expiring credits are never returned. +func (s *Store) QueryCodexResetCreditsNeedingExpiryNotice(accountID int64, now time.Time, lead time.Duration) ([]CodexResetCreditRecord, error) { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + now = now.UTC() + cutoff := now.Add(lead).UTC() + + rows, err := s.db.Query( + `SELECT account_id, credit_id, status, reset_type, title, description, granted_at, expires_at, + unsupported_by_plan, expiry_unknown, first_seen, last_seen, gone_at, notified_at + FROM codex_reset_credits + WHERE account_id = ? + AND status = 'available' + AND gone_at IS NULL + AND notified_at IS NULL + AND unsupported_by_plan = 0 + AND expiry_unknown = 0 + AND expires_at IS NOT NULL + AND expires_at > ? + AND expires_at <= ? + ORDER BY expires_at ASC, credit_id ASC + LIMIT 50`, + accountID, + now.Format(time.RFC3339), + cutoff.Format(time.RFC3339), + ) + if err != nil { + return nil, fmt.Errorf("codex reset credits: query expiry notices: %w", err) + } + defer rows.Close() + + var out []CodexResetCreditRecord + for rows.Next() { + record, err := scanCodexResetCredit(rows) + if err != nil { + return nil, err + } + out = append(out, record) + } + return out, rows.Err() +} + +// MarkCodexResetCreditNotified records the expiry-notification timestamp. The +// first marker wins; a repeated call leaves it untouched. +func (s *Store) MarkCodexResetCreditNotified(accountID int64, creditID string, notifiedAt time.Time) error { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + _, err := s.db.Exec( + `UPDATE codex_reset_credits SET notified_at = ? + WHERE account_id = ? AND credit_id = ? AND notified_at IS NULL`, + notifiedAt.UTC().Format(time.RFC3339), accountID, creditID, + ) + if err != nil { + return fmt.Errorf("codex reset credits: mark notified: %w", err) + } + return nil +} + +// ClaimCodexResetCreditNotice atomically claims the one notification slot for a +// credit. It reports true only for the caller that set the marker, so a second +// poller (or a restart racing the first send) never re-sends. +func (s *Store) ClaimCodexResetCreditNotice(accountID int64, creditID string, claimedAt time.Time) (bool, error) { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + result, err := s.db.Exec( + `UPDATE codex_reset_credits SET notified_at = ? + WHERE account_id = ? AND credit_id = ? AND notified_at IS NULL`, + claimedAt.UTC().Format(time.RFC3339), accountID, creditID, + ) + if err != nil { + return false, fmt.Errorf("codex reset credits: claim notice: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("codex reset credits: claim rows: %w", err) + } + return affected > 0, nil +} + +// ReleaseCodexResetCreditNotice clears a claim made by this exact timestamp, +// so a delivery that reached no channel can be retried on the next fetch. +func (s *Store) ReleaseCodexResetCreditNotice(accountID int64, creditID string, claimedAt time.Time) error { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + _, err := s.db.Exec( + `UPDATE codex_reset_credits SET notified_at = NULL + WHERE account_id = ? AND credit_id = ? AND notified_at = ?`, + accountID, creditID, claimedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("codex reset credits: release notice: %w", err) + } + return nil +} + +// CodexResetCreditCheckStatus is the last detail-fetch outcome for an account. +type CodexResetCreditCheckStatus struct { + AccountID int64 + Status string // "ok", "unavailable" + AvailableCount *int64 + // ApplicableCount is the plan-usable subset, nil when the payload omitted it. + ApplicableCount *int64 + LastCheckedAt time.Time +} + +// SetCodexResetCreditCheckStatus upserts the last detail-fetch outcome. A nil +// count is stored as NULL, so "unknown" never becomes 0. +func (s *Store) SetCodexResetCreditCheckStatus(accountID int64, status string, availableCount, applicableCount *int64, checkedAt time.Time) error { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + var count, applicable interface{} + if availableCount != nil { + count = *availableCount + } + if applicableCount != nil { + applicable = *applicableCount + } + _, err := s.db.Exec( + `INSERT INTO codex_reset_credit_checks (account_id, status, available_count, applicable_count, last_checked_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + status = excluded.status, + available_count = excluded.available_count, + applicable_count = excluded.applicable_count, + last_checked_at = excluded.last_checked_at`, + accountID, status, count, applicable, checkedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("codex reset credits: set check status: %w", err) + } + return nil +} + +// QueryCodexResetCreditCheckStatus returns the last detail-fetch outcome, or +// nil when the account has never been checked successfully or otherwise. +func (s *Store) QueryCodexResetCreditCheckStatus(accountID int64) (*CodexResetCreditCheckStatus, error) { + if accountID == 0 { + accountID = DefaultCodexAccountID + } + var status CodexResetCreditCheckStatus + var availableCount, applicableCount sql.NullInt64 + var checkedAt string + err := s.db.QueryRow( + `SELECT account_id, status, available_count, applicable_count, last_checked_at + FROM codex_reset_credit_checks WHERE account_id = ?`, + accountID, + ).Scan(&status.AccountID, &status.Status, &availableCount, &applicableCount, &checkedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("codex reset credits: query check status: %w", err) + } + if availableCount.Valid { + count := availableCount.Int64 + status.AvailableCount = &count + } + if applicableCount.Valid { + count := applicableCount.Int64 + status.ApplicableCount = &count + } + if parsed, parseErr := time.Parse(time.RFC3339, checkedAt); parseErr == nil { + status.LastCheckedAt = parsed + } + return &status, nil +} + +func scanCodexResetCredit(rows *sql.Rows) (CodexResetCreditRecord, error) { + var rec CodexResetCreditRecord + var grantedAt, expiresAt, firstSeen, lastSeen, goneAt, notifiedAt sql.NullString + if err := rows.Scan( + &rec.AccountID, &rec.CreditID, &rec.Status, &rec.ResetType, + &rec.Title, &rec.Description, + &grantedAt, &expiresAt, + &rec.UnsupportedByPlan, &rec.ExpiryUnknown, + &firstSeen, &lastSeen, &goneAt, ¬ifiedAt, + ); err != nil { + return rec, fmt.Errorf("codex reset credits: scan: %w", err) + } + rec.GrantedAt = parseNullableCodexTime(grantedAt) + rec.ExpiresAt = parseNullableCodexTime(expiresAt) + if firstSeen.Valid { + rec.FirstSeen, _ = time.Parse(time.RFC3339Nano, firstSeen.String) + } + if lastSeen.Valid { + rec.LastSeen, _ = time.Parse(time.RFC3339Nano, lastSeen.String) + } + rec.GoneAt = parseNullableCodexTime(goneAt) + rec.NotifiedAt = parseNullableCodexTime(notifiedAt) + return rec, nil +} + +func parseNullableCodexTime(value sql.NullString) *time.Time { + if !value.Valid || value.String == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339Nano, value.String) + if err != nil { + return nil + } + return &parsed +} diff --git a/internal/store/codex_reset_credits_test.go b/internal/store/codex_reset_credits_test.go new file mode 100644 index 00000000..2c6db4a0 --- /dev/null +++ b/internal/store/codex_reset_credits_test.go @@ -0,0 +1,790 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func rcTime(t *testing.T, raw string) *time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return &parsed +} + +func TestSyncCodexResetCredits_UpsertAndGoneMarking(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + credit1Expiry := rcTime(t, "2026-10-19T12:00:00Z") + credit2Expiry := rcTime(t, "2026-10-20T12:00:00Z") + + first := []CodexResetCreditRecord{ + { + CreditID: "credit-1", + Status: "available", + ResetType: "codex_rate_limits", + Title: "Full reset", + GrantedAt: rcTime(t, "2026-10-01T00:00:00Z"), + ExpiresAt: credit1Expiry, + }, + { + CreditID: "credit-2", + Status: "available", + ResetType: "codex_rate_limits", + GrantedAt: rcTime(t, "2026-10-02T00:00:00Z"), + }, + } + if err := s.SyncCodexResetCredits(DefaultCodexAccountID, first, now); err != nil { + t.Fatalf("SyncCodexResetCredits: %v", err) + } + + stored, err := s.QueryCodexResetCredits(DefaultCodexAccountID) + if err != nil { + t.Fatalf("QueryCodexResetCredits: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored len = %d, want 2", len(stored)) + } + if stored[0].CreditID != "credit-1" || stored[0].Status != "available" { + t.Fatalf("first row = %#v", stored[0]) + } + if stored[0].ExpiresAt == nil || !stored[0].ExpiresAt.Equal(*credit1Expiry) { + t.Fatalf("first expiry = %v", stored[0].ExpiresAt) + } + if stored[1].ExpiresAt != nil { + t.Fatalf("second expiry = %v, want nil (never expires)", stored[1].ExpiresAt) + } + if stored[1].GoneAt != nil { + t.Fatalf("second GoneAt = %v, want nil", stored[1].GoneAt) + } + + // Second fetch: credit-1 is still there and redeemed, credit-2 disappeared. + later := now.Add(30 * time.Minute) + second := []CodexResetCreditRecord{ + { + CreditID: "credit-1", + Status: "redeemed", + ResetType: "codex_rate_limits", + Title: "Full reset", + ExpiresAt: credit1Expiry, + }, + { + CreditID: "credit-3", + Status: "available", + ResetType: "codex_rate_limits", + ExpiresAt: credit2Expiry, + }, + } + if err := s.SyncCodexResetCredits(DefaultCodexAccountID, second, later); err != nil { + t.Fatalf("SyncCodexResetCredits second: %v", err) + } + + stored, err = s.QueryCodexResetCredits(DefaultCodexAccountID) + if err != nil { + t.Fatalf("QueryCodexResetCredits second: %v", err) + } + byID := map[string]CodexResetCreditRecord{} + for _, row := range stored { + byID[row.CreditID] = row + } + if got := byID["credit-1"]; got.Status != "redeemed" || got.GoneAt != nil { + t.Fatalf("credit-1 = %#v, want redeemed and not gone", got) + } + if got := byID["credit-2"]; got.GoneAt == nil { + t.Fatalf("credit-2 = %#v, want marked gone", got) + } + if got := byID["credit-3"]; got.Status != "available" || got.GoneAt != nil { + t.Fatalf("credit-3 = %#v, want available and not gone", got) + } + + // A credit that reappears is revived rather than duplicated. + if err := s.SyncCodexResetCredits(DefaultCodexAccountID, first, later.Add(time.Minute)); err != nil { + t.Fatalf("SyncCodexResetCredits revive: %v", err) + } + stored, err = s.QueryCodexResetCredits(DefaultCodexAccountID) + if err != nil { + t.Fatalf("QueryCodexResetCredits revive: %v", err) + } + if len(stored) != 3 { + t.Fatalf("stored len = %d, want 3 (no duplicates)", len(stored)) + } + for _, row := range stored { + if row.CreditID == "credit-2" && row.GoneAt != nil { + t.Fatalf("credit-2 = %#v, want revived", row) + } + } +} + +func TestSyncCodexResetCredits_PerAccountIsolation(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + credit := []CodexResetCreditRecord{{CreditID: "credit-1", Status: "available"}} + if err := s.SyncCodexResetCredits(1, credit, now); err != nil { + t.Fatalf("Sync account 1: %v", err) + } + + rows, err := s.QueryCodexResetCredits(2) + if err != nil { + t.Fatalf("Query account 2: %v", err) + } + if len(rows) != 0 { + t.Fatalf("account 2 rows = %d, want 0", len(rows)) + } + + // Gone-marking for one account must not touch another account's rows. + if err := s.SyncCodexResetCredits(2, nil, now); err != nil { + t.Fatalf("Sync account 2 empty: %v", err) + } + rows, err = s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query account 1: %v", err) + } + if len(rows) != 1 || rows[0].GoneAt != nil { + t.Fatalf("account 1 rows = %#v, want the untouched credit", rows) + } +} + +func TestMarkCodexResetCreditNotified_DedupesByCredit(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{{ + CreditID: "credit-1", + Status: "available", + ExpiresAt: rcTime(t, "2026-10-19T06:00:00Z"), + }}, now); err != nil { + t.Fatalf("Sync: %v", err) + } + + pending, err := s.QueryCodexResetCreditsNeedingExpiryNotice(1, now, 24*time.Hour) + if err != nil { + t.Fatalf("Query needing notice: %v", err) + } + if len(pending) != 1 { + t.Fatalf("pending len = %d, want 1", len(pending)) + } + + marker := now.Add(time.Minute) + if err := s.MarkCodexResetCreditNotified(1, "credit-1", marker); err != nil { + t.Fatalf("MarkCodexResetCreditNotified: %v", err) + } + // A second mark must not move the timestamp. + if err := s.MarkCodexResetCreditNotified(1, "credit-1", marker.Add(time.Hour)); err != nil { + t.Fatalf("MarkCodexResetCreditNotified second: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query: %v", err) + } + if rows[0].NotifiedAt == nil || !rows[0].NotifiedAt.Equal(marker) { + t.Fatalf("NotifiedAt = %v, want %v", rows[0].NotifiedAt, marker) + } + + pending, err = s.QueryCodexResetCreditsNeedingExpiryNotice(1, now.Add(2*time.Hour), 24*time.Hour) + if err != nil { + t.Fatalf("Query needing notice after mark: %v", err) + } + if len(pending) != 0 { + t.Fatalf("pending after mark = %d, want 0", len(pending)) + } +} + +func TestCodexResetCreditCheckStatus_UpsertAndQuery(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + status, err := s.QueryCodexResetCreditCheckStatus(1) + if err != nil { + t.Fatalf("Query empty: %v", err) + } + if status != nil { + t.Fatalf("status = %#v, want nil before any check", status) + } + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + count := int64(2) + if err := s.SetCodexResetCreditCheckStatus(1, "ok", &count, nil, now); err != nil { + t.Fatalf("Set ok: %v", err) + } + status, err = s.QueryCodexResetCreditCheckStatus(1) + if err != nil { + t.Fatalf("Query ok: %v", err) + } + if status.Status != "ok" || status.AvailableCount == nil || *status.AvailableCount != 2 { + t.Fatalf("status = %#v", status) + } + if !status.LastCheckedAt.Equal(now) { + t.Fatalf("LastCheckedAt = %v, want %v", status.LastCheckedAt, now) + } + + if err := s.SetCodexResetCreditCheckStatus(1, "unavailable", nil, nil, now.Add(time.Hour)); err != nil { + t.Fatalf("Set unavailable: %v", err) + } + status, err = s.QueryCodexResetCreditCheckStatus(1) + if err != nil { + t.Fatalf("Query unavailable: %v", err) + } + if status.Status != "unavailable" || status.AvailableCount != nil { + t.Fatalf("status = %#v, want unavailable without a count", status) + } +} + +func TestClaimCodexResetCreditNotice_ExactlyOnce(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{{ + CreditID: "credit-1", + Status: "available", + ExpiresAt: rcTime(t, now.Add(6*time.Hour).Format(time.RFC3339)), + }}, now); err != nil { + t.Fatalf("Sync: %v", err) + } + + claimed, err := s.ClaimCodexResetCreditNotice(1, "credit-1", now) + if err != nil { + t.Fatalf("Claim: %v", err) + } + if !claimed { + t.Fatal("first claim = false, want true") + } + claimed, err = s.ClaimCodexResetCreditNotice(1, "credit-1", now.Add(time.Minute)) + if err != nil { + t.Fatalf("Claim second: %v", err) + } + if claimed { + t.Fatal("second claim = true, want false") + } + + // Releasing the exact claim makes it claimable again; an older timestamp + // does not release a newer claim. + if err := s.ReleaseCodexResetCreditNotice(1, "credit-1", now.Add(time.Minute)); err != nil { + t.Fatalf("Release mismatched: %v", err) + } + claimed, err = s.ClaimCodexResetCreditNotice(1, "credit-1", now.Add(2*time.Minute)) + if err != nil { + t.Fatalf("Claim after mismatched release: %v", err) + } + if claimed { + t.Fatal("claim after mismatched release = true, want false") + } + if err := s.ReleaseCodexResetCreditNotice(1, "credit-1", now); err != nil { + t.Fatalf("Release: %v", err) + } + claimed, err = s.ClaimCodexResetCreditNotice(1, "credit-1", now.Add(3*time.Minute)) + if err != nil { + t.Fatalf("Claim after release: %v", err) + } + if !claimed { + t.Fatal("claim after release = false, want true") + } +} + +func TestMergeCodexAccountData_MovesResetCreditsAndKeepsNotifiedMarker(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + if _, err := s.CreateOrRestoreProviderAccount("codex", "target"); err != nil { + t.Fatalf("create target: %v", err) + } + if _, err := s.CreateOrRestoreProviderAccount("codex", "source"); err != nil { + t.Fatalf("create source: %v", err) + } + + now := time.Now().UTC().Truncate(time.Second) + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{{ + CreditID: "shared", + Status: "available", + ExpiresAt: rcTime(t, now.Add(4*time.Hour).Format(time.RFC3339)), + }}, now); err != nil { + t.Fatalf("Sync target: %v", err) + } + if err := s.SyncCodexResetCredits(2, []CodexResetCreditRecord{{ + CreditID: "shared", + Status: "available", + ExpiresAt: rcTime(t, now.Add(4*time.Hour).Format(time.RFC3339)), + }}, now); err != nil { + t.Fatalf("Sync source: %v", err) + } + if _, err := s.ClaimCodexResetCreditNotice(2, "shared", now); err != nil { + t.Fatalf("Claim source: %v", err) + } + + if err := s.MergeCodexAccountData(1, 2); err != nil { + t.Fatalf("MergeCodexAccountData: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query target: %v", err) + } + if len(rows) != 1 { + t.Fatalf("target rows = %d, want 1 after merge", len(rows)) + } + if rows[0].NotifiedAt == nil { + t.Fatal("notification marker lost in merge; a warning could re-send") + } + other, err := s.QueryCodexResetCredits(2) + if err != nil { + t.Fatalf("Query source: %v", err) + } + if len(other) != 0 { + t.Fatalf("source rows = %d, want 0 after merge", len(other)) + } +} + +func TestMergeCodexAccountData_PreservesTargetMarkerFirstSeenAndGoneState(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + earliest := now.Add(-48 * time.Hour) + expiry := now.Add(4 * time.Hour) + + // Target: live shared credit, notified, first seen earlier. + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{{ + CreditID: "shared", + Status: "available", + ExpiresAt: &expiry, + }}, earliest); err != nil { + t.Fatalf("Sync target: %v", err) + } + targetMarker := now.Add(-time.Hour) + if _, err := s.ClaimCodexResetCreditNotice(1, "shared", targetMarker); err != nil { + t.Fatalf("Claim target: %v", err) + } + + // Source: the same credit id, but gone (a stale disappeared row) and no + // notification marker. UPDATE OR REPLACE would otherwise discard the + // target's marker, first_seen and live state. + if err := s.SyncCodexResetCredits(2, []CodexResetCreditRecord{{ + CreditID: "shared", + Status: "available", + ExpiresAt: &expiry, + }}, now.Add(-24*time.Hour)); err != nil { + t.Fatalf("Sync source: %v", err) + } + if err := s.SyncCodexResetCredits(2, nil, now.Add(-23*time.Hour)); err != nil { + t.Fatalf("Sync source gone: %v", err) + } + + if err := s.MergeCodexAccountData(1, 2); err != nil { + t.Fatalf("MergeCodexAccountData: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query target: %v", err) + } + if len(rows) != 1 { + t.Fatalf("target rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.NotifiedAt == nil || !row.NotifiedAt.Equal(targetMarker) { + t.Fatalf("NotifiedAt = %v, want the target's marker %v (otherwise the warning re-sends)", row.NotifiedAt, targetMarker) + } + if !row.FirstSeen.Equal(earliest) { + t.Fatalf("FirstSeen = %v, want the earliest %v", row.FirstSeen, earliest) + } + if row.GoneAt != nil { + t.Fatalf("GoneAt = %v, want nil: one side was live, so the credit is live", row.GoneAt) + } +} + +// A credit that disappeared on the source, with no target row at all, must +// stay gone after the merge. The target-side subquery is empty, so the stale +// source's gone_at has to survive. +func TestMergeCodexAccountData_SourceOnlyGoneCreditStaysGone(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + expiry := now.Add(4 * time.Hour) + if err := s.SyncCodexResetCredits(2, []CodexResetCreditRecord{{ + CreditID: "source-gone", + Status: "available", + ExpiresAt: &expiry, + }}, now.Add(-2*time.Hour)); err != nil { + t.Fatalf("Sync source: %v", err) + } + goneAt := now.Add(-time.Hour) + if err := s.SyncCodexResetCredits(2, nil, goneAt); err != nil { + t.Fatalf("Sync source gone: %v", err) + } + + if err := s.MergeCodexAccountData(1, 2); err != nil { + t.Fatalf("MergeCodexAccountData: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query target: %v", err) + } + if len(rows) != 1 { + t.Fatalf("target rows = %d, want 1", len(rows)) + } + if rows[0].CreditID != "source-gone" { + t.Fatalf("credit = %q", rows[0].CreditID) + } + if rows[0].GoneAt == nil || !rows[0].GoneAt.Equal(goneAt) { + t.Fatalf("GoneAt = %v, want the source's %v: a gone credit must not revive", rows[0].GoneAt, goneAt) + } +} + +// The earlier first_seen wins regardless of which account holds it. +func TestMergeCodexAccountData_EarliestFirstSeenWinsBothDirections(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + expiry := now.Add(4 * time.Hour) + earlier := now.Add(-72 * time.Hour) + later := now.Add(-24 * time.Hour) + + cases := []struct { + name string + targetSeen time.Time + sourceSeen time.Time + wantFirstSeen time.Time + }{ + {name: "target earlier", targetSeen: earlier, sourceSeen: later, wantFirstSeen: earlier}, + {name: "source earlier", targetSeen: later, sourceSeen: earlier, wantFirstSeen: earlier}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + credit := []CodexResetCreditRecord{{CreditID: "shared", Status: "available", ExpiresAt: &expiry}} + if err := s.SyncCodexResetCredits(1, credit, tc.targetSeen); err != nil { + t.Fatalf("Sync target: %v", err) + } + if err := s.SyncCodexResetCredits(2, credit, tc.sourceSeen); err != nil { + t.Fatalf("Sync source: %v", err) + } + if err := s.MergeCodexAccountData(1, 2); err != nil { + t.Fatalf("MergeCodexAccountData: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query target: %v", err) + } + if len(rows) != 1 { + t.Fatalf("target rows = %d, want 1", len(rows)) + } + if !rows[0].FirstSeen.Equal(tc.wantFirstSeen) { + t.Fatalf("FirstSeen = %v, want the earlier %v", rows[0].FirstSeen, tc.wantFirstSeen) + } + }) + } +} + +func TestSyncCodexResetCredits_StoresSupportedAndExpiryUnknown(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + expiry := rcTime(t, now.Add(3*time.Hour).Format(time.RFC3339)) + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{ + {CreditID: "usable", Status: "available", ExpiresAt: expiry}, + {CreditID: "unsupported", Status: "available", ExpiresAt: expiry, UnsupportedByPlan: true}, + {CreditID: "unknown-expiry", Status: "available", ExpiryUnknown: true}, + }, now); err != nil { + t.Fatalf("Sync: %v", err) + } + + rows, err := s.QueryCodexResetCredits(1) + if err != nil { + t.Fatalf("Query: %v", err) + } + byID := map[string]CodexResetCreditRecord{} + for _, row := range rows { + byID[row.CreditID] = row + } + if byID["unsupported"].UnsupportedByPlan != true { + t.Fatalf("unsupported flag not stored: %#v", byID["unsupported"]) + } + if byID["usable"].UnsupportedByPlan { + t.Fatalf("usable flag = true, want false: %#v", byID["usable"]) + } + if !byID["unknown-expiry"].ExpiryUnknown || byID["unknown-expiry"].ExpiresAt != nil { + t.Fatalf("unknown-expiry row = %#v", byID["unknown-expiry"]) + } + + // Only plan-usable credits may trigger an expiry warning. + pending, err := s.QueryCodexResetCreditsNeedingExpiryNotice(1, now, 24*time.Hour) + if err != nil { + t.Fatalf("Query needing notice: %v", err) + } + if len(pending) != 1 || pending[0].CreditID != "usable" { + t.Fatalf("pending = %#v, want only the usable credit", pending) + } +} + +func TestCodexResetCreditCheckStatus_StoresApplicableCount(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + available, applicable := int64(3), int64(1) + if err := s.SetCodexResetCreditCheckStatus(1, "ok", &available, &applicable, now); err != nil { + t.Fatalf("Set: %v", err) + } + status, err := s.QueryCodexResetCreditCheckStatus(1) + if err != nil { + t.Fatalf("Query: %v", err) + } + if status.ApplicableCount == nil || *status.ApplicableCount != 1 { + t.Fatalf("ApplicableCount = %v, want 1", status.ApplicableCount) + } + + // An absent applicable count must round-trip as unknown, not 0. + if err := s.SetCodexResetCreditCheckStatus(1, "ok", &available, nil, now); err != nil { + t.Fatalf("Set nil applicable: %v", err) + } + status, err = s.QueryCodexResetCreditCheckStatus(1) + if err != nil { + t.Fatalf("Query second: %v", err) + } + if status.ApplicableCount != nil { + t.Fatalf("ApplicableCount = %v, want nil when absent", *status.ApplicableCount) + } +} + +func TestInsertCodexSnapshot_RoundTripsApplicableCount(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: 1, + PlanType: "plus", + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 3, Valid: true}, + ResetCreditsApplicableCount: api.CodexOptionalInt64{Value: 1, Valid: true}, + }); err != nil { + t.Fatalf("Insert: %v", err) + } + latest, err := s.QueryLatestCodex(1) + if err != nil { + t.Fatalf("QueryLatest: %v", err) + } + if !latest.ResetCreditsApplicableCount.Valid || latest.ResetCreditsApplicableCount.Value != 1 { + t.Fatalf("applicable = %#v, want known 1", latest.ResetCreditsApplicableCount) + } +} + +func TestInsertCodexSnapshot_RoundTripsResetCreditsCount(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + known := &api.CodexSnapshot{ + CapturedAt: now.Add(-time.Minute), + AccountID: 1, + PlanType: "plus", + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 0, Valid: true}, + } + if _, err := s.InsertCodexSnapshot(known); err != nil { + t.Fatalf("Insert known: %v", err) + } + // A known zero must come back as known zero, not unknown. + latest, err := s.QueryLatestCodex(1) + if err != nil { + t.Fatalf("QueryLatest: %v", err) + } + if !latest.ResetCreditsAvailableCount.Valid || latest.ResetCreditsAvailableCount.Value != 0 { + t.Fatalf("count = %#v, want known 0", latest.ResetCreditsAvailableCount) + } + + unknown := &api.CodexSnapshot{ + CapturedAt: now, + AccountID: 1, + PlanType: "plus", + } + if _, err := s.InsertCodexSnapshot(unknown); err != nil { + t.Fatalf("Insert unknown: %v", err) + } + latest, err = s.QueryLatestCodex(1) + if err != nil { + t.Fatalf("QueryLatest second: %v", err) + } + if latest.ResetCreditsAvailableCount.Valid { + t.Fatalf("count = %#v, want unknown when absent", latest.ResetCreditsAvailableCount) + } + + rangeRows, err := s.QueryCodexRange(1, now.Add(-time.Hour), now.Add(time.Hour)) + if err != nil { + t.Fatalf("QueryCodexRange: %v", err) + } + if len(rangeRows) != 2 { + t.Fatalf("range rows = %d, want 2", len(rangeRows)) + } + if !rangeRows[0].ResetCreditsAvailableCount.Valid || rangeRows[0].ResetCreditsAvailableCount.Value != 0 { + t.Fatalf("range first count = %#v", rangeRows[0].ResetCreditsAvailableCount) + } + if rangeRows[1].ResetCreditsAvailableCount.Valid { + t.Fatalf("range second count = %#v, want unknown", rangeRows[1].ResetCreditsAvailableCount) + } +} + +func TestQueryCodexResetCreditsNeedingExpiryNotice_SkipsNeverExpiringAndGone(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + expiringAt := now.Add(3 * time.Hour) + tooFar := now.Add(72 * time.Hour) + + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{ + {CreditID: "soon", Status: "available", ExpiresAt: &expiringAt}, + {CreditID: "never", Status: "available"}, + {CreditID: "later", Status: "available", ExpiresAt: &tooFar}, + {CreditID: "redeemed", Status: "redeemed", ExpiresAt: &expiringAt}, + }, now); err != nil { + t.Fatalf("Sync: %v", err) + } + // Mark "gone" as disappeared. + if err := s.SyncCodexResetCredits(1, []CodexResetCreditRecord{ + {CreditID: "soon", Status: "available", ExpiresAt: &expiringAt}, + {CreditID: "never", Status: "available"}, + {CreditID: "later", Status: "available", ExpiresAt: &tooFar}, + }, now.Add(time.Minute)); err != nil { + t.Fatalf("Sync gone: %v", err) + } + + pending, err := s.QueryCodexResetCreditsNeedingExpiryNotice(1, now, 24*time.Hour) + if err != nil { + t.Fatalf("Query needing notice: %v", err) + } + if len(pending) != 1 || pending[0].CreditID != "soon" { + t.Fatalf("pending = %#v, want only 'soon'", pending) + } +} + +// A database created before the applicable/unsupported columns existed gains +// them through the same idempotent migration path, and existing rows keep their +// data with safe defaults. +func TestMigrateSchema_AddsResetCreditColumns(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + // Simulate the pre-follow-up shape. Snapshots have no dependent data here + // beyond the quota values table, which is empty in this test. + for _, stmt := range []string{ + `DROP TABLE codex_quota_values`, + `DROP TABLE codex_snapshots`, + `CREATE TABLE codex_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + account_id INTEGER NOT NULL DEFAULT 1, + plan_type TEXT, + credits_balance REAL, + reset_credits_count INTEGER, + raw_json TEXT, + quota_count INTEGER DEFAULT 0 + )`, + `DROP TABLE codex_reset_credit_checks`, + `CREATE TABLE codex_reset_credit_checks ( + account_id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + available_count INTEGER, + last_checked_at TEXT NOT NULL + )`, + `DROP TABLE codex_reset_credits`, + `CREATE TABLE codex_reset_credits ( + account_id INTEGER NOT NULL DEFAULT 1, + credit_id TEXT NOT NULL, + status TEXT NOT NULL, + reset_type TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + granted_at TEXT, + expires_at TEXT, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + gone_at TEXT, + notified_at TEXT, + PRIMARY KEY (account_id, credit_id) + )`, + } { + if _, err := s.db.Exec(stmt); err != nil { + t.Fatalf("setup %q: %v", stmt, err) + } + } + + for i := 0; i < 2; i++ { + if err := s.migrateSchema(); err != nil { + t.Fatalf("migrateSchema run %d: %v", i+1, err) + } + } + + for _, tc := range []struct{ table, column string }{ + {"codex_reset_credits", "unsupported_by_plan"}, + {"codex_reset_credits", "expiry_unknown"}, + {"codex_reset_credit_checks", "applicable_count"}, + {"codex_snapshots", "applicable_credits_count"}, + } { + has, err := s.tableHasColumn(tc.table, tc.column) + if err != nil { + t.Fatalf("tableHasColumn(%s, %s): %v", tc.table, tc.column, err) + } + if !has { + t.Fatalf("migration did not add %s.%s", tc.table, tc.column) + } + } +} diff --git a/internal/store/codex_store.go b/internal/store/codex_store.go index dd75cdda..0251aaf1 100644 --- a/internal/store/codex_store.go +++ b/internal/store/codex_store.go @@ -43,6 +43,13 @@ func (s *Store) InsertCodexSnapshot(snapshot *api.CodexSnapshot) (int64, error) if snapshot.CreditsBalance != nil { creditsBalance = *snapshot.CreditsBalance } + var resetCreditsCount, applicableCreditsCount interface{} + if snapshot.ResetCreditsAvailableCount.Valid { + resetCreditsCount = snapshot.ResetCreditsAvailableCount.Value + } + if snapshot.ResetCreditsApplicableCount.Valid { + applicableCreditsCount = snapshot.ResetCreditsApplicableCount.Value + } accountID := snapshot.AccountID if accountID == 0 { @@ -50,11 +57,13 @@ func (s *Store) InsertCodexSnapshot(snapshot *api.CodexSnapshot) (int64, error) } result, err := tx.Exec( - `INSERT INTO codex_snapshots (captured_at, account_id, plan_type, credits_balance, raw_json, quota_count) VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO codex_snapshots (captured_at, account_id, plan_type, credits_balance, reset_credits_count, applicable_credits_count, raw_json, quota_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.CapturedAt.Format(time.RFC3339Nano), accountID, snapshot.PlanType, creditsBalance, + resetCreditsCount, + applicableCreditsCount, snapshot.RawJSON, len(snapshot.Quotas), ) @@ -101,11 +110,12 @@ func (s *Store) QueryLatestCodex(accountID int64) (*api.CodexSnapshot, error) { var capturedAt string var planType sql.NullString var creditsBalance sql.NullFloat64 + var resetCreditsCount, applicableCreditsCount sql.NullInt64 err := s.db.QueryRow( - `SELECT id, captured_at, plan_type, credits_balance, quota_count, account_id FROM codex_snapshots WHERE account_id = ? ORDER BY captured_at DESC LIMIT 1`, + `SELECT id, captured_at, plan_type, credits_balance, reset_credits_count, applicable_credits_count, quota_count, account_id FROM codex_snapshots WHERE account_id = ? ORDER BY captured_at DESC LIMIT 1`, accountID, - ).Scan(&snapshot.ID, &capturedAt, &planType, &creditsBalance, new(int), &snapshot.AccountID) + ).Scan(&snapshot.ID, &capturedAt, &planType, &creditsBalance, &resetCreditsCount, &applicableCreditsCount, new(int), &snapshot.AccountID) if err == sql.ErrNoRows { return nil, nil @@ -125,6 +135,12 @@ func (s *Store) QueryLatestCodex(accountID int64) (*api.CodexSnapshot, error) { if creditsBalance.Valid { snapshot.CreditsBalance = &creditsBalance.Float64 } + if resetCreditsCount.Valid { + snapshot.ResetCreditsAvailableCount = api.CodexOptionalInt64{Value: resetCreditsCount.Int64, Valid: true} + } + if applicableCreditsCount.Valid { + snapshot.ResetCreditsApplicableCount = api.CodexOptionalInt64{Value: applicableCreditsCount.Int64, Valid: true} + } rows, err := s.db.Query( `SELECT quota_name, utilization, resets_at, status FROM codex_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, @@ -163,13 +179,13 @@ func (s *Store) QueryCodexRange(accountID int64, start, end time.Time, limit ... if accountID == 0 { accountID = DefaultCodexAccountID } - query := `SELECT id, captured_at, plan_type, credits_balance, quota_count, account_id FROM codex_snapshots + query := `SELECT id, captured_at, plan_type, credits_balance, reset_credits_count, applicable_credits_count, quota_count, account_id FROM codex_snapshots WHERE account_id = ? AND captured_at BETWEEN ? AND ? ORDER BY captured_at ASC` args := []interface{}{accountID, start.Format(time.RFC3339Nano), end.Format(time.RFC3339Nano)} if len(limit) > 0 && limit[0] > 0 { - query = `SELECT id, captured_at, plan_type, credits_balance, quota_count, account_id + query = `SELECT id, captured_at, plan_type, credits_balance, reset_credits_count, applicable_credits_count, quota_count, account_id FROM ( - SELECT id, captured_at, plan_type, credits_balance, quota_count, account_id + SELECT id, captured_at, plan_type, credits_balance, reset_credits_count, applicable_credits_count, quota_count, account_id FROM codex_snapshots WHERE account_id = ? AND captured_at BETWEEN ? AND ? ORDER BY captured_at DESC @@ -191,7 +207,8 @@ func (s *Store) QueryCodexRange(accountID int64, start, end time.Time, limit ... var capturedAt string var planType sql.NullString var creditsBalance sql.NullFloat64 - if err := rows.Scan(&snap.ID, &capturedAt, &planType, &creditsBalance, new(int), &snap.AccountID); err != nil { + var resetCreditsCount, applicableCreditsCount sql.NullInt64 + if err := rows.Scan(&snap.ID, &capturedAt, &planType, &creditsBalance, &resetCreditsCount, &applicableCreditsCount, new(int), &snap.AccountID); err != nil { return nil, fmt.Errorf("failed to scan codex snapshot: %w", err) } parsedCapturedAt, err := parseCodexTime(capturedAt, "codex snapshot captured_at") @@ -205,6 +222,12 @@ func (s *Store) QueryCodexRange(accountID int64, start, end time.Time, limit ... if creditsBalance.Valid { snap.CreditsBalance = &creditsBalance.Float64 } + if resetCreditsCount.Valid { + snap.ResetCreditsAvailableCount = api.CodexOptionalInt64{Value: resetCreditsCount.Int64, Valid: true} + } + if applicableCreditsCount.Valid { + snap.ResetCreditsApplicableCount = api.CodexOptionalInt64{Value: applicableCreditsCount.Int64, Valid: true} + } snapshots = append(snapshots, &snap) } @@ -1036,6 +1059,48 @@ func (s *Store) MergeCodexAccountData(targetID, sourceID int64) error { return fmt.Errorf("failed to merge reset cycles: %w", err) } + // Move tracked reset credits. UPDATE OR REPLACE resolves a credit ID that + // exists on both rows by deleting the target row and inserting the source + // one. The target's notification marker and earliest first_seen must survive + // that, and the merged row must stay live if either side was live - a stale + // gone_at on the source must not hide a live target credit. Copy those + // values onto the source rows first, with parameterized SQL. + if _, err := tx.Exec( + `UPDATE codex_reset_credits AS src SET + notified_at = COALESCE(src.notified_at, ( + SELECT tgt.notified_at FROM codex_reset_credits AS tgt + WHERE tgt.account_id = ? AND tgt.credit_id = src.credit_id + )), + first_seen = MIN(src.first_seen, COALESCE(( + SELECT tgt.first_seen FROM codex_reset_credits AS tgt + WHERE tgt.account_id = ? AND tgt.credit_id = src.credit_id + ), src.first_seen)), + gone_at = CASE + WHEN src.gone_at IS NULL OR EXISTS ( + SELECT 1 FROM codex_reset_credits AS tgt + WHERE tgt.account_id = ? AND tgt.credit_id = src.credit_id AND tgt.gone_at IS NULL + ) THEN NULL + ELSE src.gone_at + END + WHERE src.account_id = ?`, + targetID, targetID, targetID, sourceID, + ); err != nil { + return fmt.Errorf("failed to merge reset-credit metadata: %w", err) + } + if _, err := tx.Exec( + `UPDATE OR REPLACE codex_reset_credits SET account_id = ? WHERE account_id = ?`, + targetID, sourceID, + ); err != nil { + return fmt.Errorf("failed to merge reset credits: %w", err) + } + // The target's last-check row stands; both accounts polled the same identity. + if _, err := tx.Exec( + `DELETE FROM codex_reset_credit_checks WHERE account_id = ?`, + sourceID, + ); err != nil { + return fmt.Errorf("failed to merge reset-credit checks: %w", err) + } + // Mark source account as deleted if _, err := tx.Exec( `UPDATE provider_accounts SET deleted_at = ? WHERE id = ?`, diff --git a/internal/store/store.go b/internal/store/store.go index 62a30b0e..9d47c420 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -482,10 +482,46 @@ func (s *Store) createTables() error { account_id INTEGER NOT NULL DEFAULT 1, plan_type TEXT, credits_balance REAL, + reset_credits_count INTEGER, raw_json TEXT, quota_count INTEGER DEFAULT 0 ); + -- Banked Codex reset credits per account (issue #152). A credit that + -- disappears from the detail response is marked gone, never deleted. + -- notified_at is the expiry-warning dedupe marker: one notification per + -- credit, persisted across restarts. + CREATE TABLE IF NOT EXISTS codex_reset_credits ( + account_id INTEGER NOT NULL DEFAULT 1, + credit_id TEXT NOT NULL, + status TEXT NOT NULL, + reset_type TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + granted_at TEXT, + expires_at TEXT, + unsupported_by_plan INTEGER NOT NULL DEFAULT 0, + expiry_unknown INTEGER NOT NULL DEFAULT 0, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + gone_at TEXT, + notified_at TEXT, + PRIMARY KEY (account_id, credit_id) + ); + CREATE INDEX IF NOT EXISTS idx_codex_reset_credits_expiry ON codex_reset_credits(expires_at); + CREATE INDEX IF NOT EXISTS idx_codex_reset_credits_account ON codex_reset_credits(account_id, status); + + -- Last outcome of the reset-credits detail fetch per account. Kept + -- separate from the credits themselves so "unavailable" is visible even + -- when the account has no tracked rows. + CREATE TABLE IF NOT EXISTS codex_reset_credit_checks ( + account_id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + available_count INTEGER, + applicable_count INTEGER, + last_checked_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS codex_quota_values ( id INTEGER PRIMARY KEY AUTOINCREMENT, snapshot_id INTEGER NOT NULL, @@ -1215,6 +1251,40 @@ func (s *Store) migrateSchema() error { return fmt.Errorf("failed to insert default codex account: %w", err) } + // Additive reset-credit columns for databases created before they existed + // (issue #152 follow-up). Missing optional tables are tolerated like the + // rest of this migration. + for _, stmt := range []string{ + `ALTER TABLE codex_snapshots ADD COLUMN applicable_credits_count INTEGER`, + } { + if _, err := s.db.Exec(stmt); err != nil { + if !strings.Contains(err.Error(), "duplicate column name") && !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("reset-credit migration failed: %w", err) + } + } + } + for _, stmt := range []string{ + `ALTER TABLE codex_reset_credits ADD COLUMN unsupported_by_plan INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE codex_reset_credits ADD COLUMN expiry_unknown INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE codex_reset_credit_checks ADD COLUMN applicable_count INTEGER`, + } { + if _, err := s.db.Exec(stmt); err != nil { + if !strings.Contains(err.Error(), "duplicate column name") && !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("reset-credit migration failed: %w", err) + } + } + } + + // Add reset_credits_count to codex_snapshots (issue #152). NULL means the + // usage response did not report a count - unknown, not zero. + if _, err := s.db.Exec(` + ALTER TABLE codex_snapshots ADD COLUMN reset_credits_count INTEGER + `); err != nil { + if !strings.Contains(err.Error(), "duplicate column name") && !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to add reset_credits_count to codex_snapshots: %w", err) + } + } + // Add account_id column to codex_snapshots for multi-account support // Using INTEGER DEFAULT 1 (references provider_accounts.id) if _, err := s.db.Exec(` diff --git a/internal/web/codex_reset_credits_test.go b/internal/web/codex_reset_credits_test.go new file mode 100644 index 00000000..32ec4979 --- /dev/null +++ b/internal/web/codex_reset_credits_test.go @@ -0,0 +1,354 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func seedCodexResetCredits(t *testing.T, s *store.Store, now time.Time) { + t.Helper() + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{ + {Name: "five_hour", Utilization: 20, Status: "healthy"}, + }, + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 2, Valid: true}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + + sooner := now.Add(2 * time.Hour) + later := now.Add(48 * time.Hour) + credits := []store.CodexResetCreditRecord{ + {CreditID: "credit-later", Status: "available", ResetType: "codex_rate_limits", Title: "Full reset", ExpiresAt: &later}, + {CreditID: "credit-never", Status: "available", ResetType: "codex_rate_limits"}, + {CreditID: "credit-sooner", Status: "available", ResetType: "codex_rate_limits", Title: "Full reset", ExpiresAt: &sooner}, + {CreditID: "credit-redeemed", Status: "redeemed", ResetType: "codex_rate_limits"}, + {CreditID: "credit-unsupported", Status: "available", ResetType: "codex_rate_limits", UnsupportedByPlan: true, ExpiresAt: &sooner}, + {CreditID: "credit-unknown-expiry", Status: "available", ResetType: "codex_rate_limits", ExpiryUnknown: true}, + } + if err := s.SyncCodexResetCredits(DefaultCodexAccountID, credits, now); err != nil { + t.Fatalf("SyncCodexResetCredits: %v", err) + } + count := int64(4) + applicable := int64(3) + if err := s.SetCodexResetCreditCheckStatus(DefaultCodexAccountID, "ok", &count, &applicable, now); err != nil { + t.Fatalf("SetCodexResetCreditCheckStatus: %v", err) + } +} + +func TestHandler_CodexUsage_ResetCreditsShape(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + seedCodexResetCredits(t, s, now) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + + // Existing fields must be unchanged. + if _, ok := response["quotas"]; !ok { + t.Fatal("existing quotas field disappeared") + } + if response["accountId"] != float64(DefaultCodexAccountID) { + t.Fatalf("accountId = %v", response["accountId"]) + } + + rc, ok := response["resetCredits"].(map[string]interface{}) + if !ok { + t.Fatalf("resetCredits = %T, want object", response["resetCredits"]) + } + if rc["status"] != "ok" { + t.Fatalf("status = %v, want ok", rc["status"]) + } + if rc["available_count"] != float64(4) { + t.Fatalf("available_count = %v, want 4", rc["available_count"]) + } + if rc["applicable_count"] != float64(3) { + t.Fatalf("applicable_count = %v, want 3", rc["applicable_count"]) + } + if rc["last_checked_at"] != now.Format(time.RFC3339) { + t.Fatalf("last_checked_at = %v", rc["last_checked_at"]) + } + if rc["next_expires_at"] != now.Add(2*time.Hour).Format(time.RFC3339) { + t.Fatalf("next_expires_at = %v", rc["next_expires_at"]) + } + + credits, ok := rc["credits"].([]interface{}) + if !ok { + t.Fatalf("credits = %T, want array", rc["credits"]) + } + // Available, plan-usable credits first (expiry ascending, no-expiry last), + // then the unsupported ones the tooltip accounts for separately. + if len(credits) != 5 { + t.Fatalf("credits len = %d, want 5 (redeemed excluded)", len(credits)) + } + wantOrder := []string{"credit-sooner", "credit-later", "credit-never", "credit-unknown-expiry", "credit-unsupported"} + for i, want := range wantOrder { + credit := credits[i].(map[string]interface{}) + if credit["id"] != want { + t.Fatalf("credits[%d].id = %v, want %v", i, credit["id"], want) + } + } + for _, entry := range credits { + credit := entry.(map[string]interface{}) + id := credit["id"] + if (id == "credit-never" || id == "credit-unknown-expiry") && credit["expires_at"] != nil { + t.Fatalf("%v expires_at = %v, want null", id, credit["expires_at"]) + } + if id == "credit-unsupported" && credit["unsupported_by_plan"] != true { + t.Fatalf("credit-unsupported flag = %v, want true", credit["unsupported_by_plan"]) + } + } +} + +func TestHandler_CodexUsage_ResetCreditsUnavailable(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + if err := s.SetCodexResetCreditCheckStatus(DefaultCodexAccountID, "unavailable", nil, nil, now); err != nil { + t.Fatalf("SetCodexResetCreditCheckStatus: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["status"] != "unavailable" { + t.Fatalf("status = %v, want unavailable", rc["status"]) + } + // The usage count is still known even when details are unavailable. + if rc["available_count"] != nil { + t.Fatalf("available_count = %v, want null (no usage count reported)", rc["available_count"]) + } + if credits, _ := rc["credits"].([]interface{}); len(credits) != 0 { + t.Fatalf("credits = %v, want empty", credits) + } +} + +func TestHandler_CodexUsage_ResetCreditsUnknownWithoutCheck(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 2, Valid: true}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["status"] != "unknown" { + t.Fatalf("status = %v, want unknown before any detail check", rc["status"]) + } + if rc["available_count"] != float64(2) { + t.Fatalf("available_count = %v, want the usage count 2", rc["available_count"]) + } +} + +func TestHandler_CodexUsage_ResetCreditsAbsentCountIsNull(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["available_count"] != nil { + t.Fatalf("available_count = %v, want null for an unknown count", rc["available_count"]) + } +} + +func TestHandler_CodexUsage_ResetCreditsFallsBackToSnapshotCountWhenCheckCountNil(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 2, Valid: true}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + // A successful detail fetch that omitted available_count: the check row is + // "ok" with a NULL count. That must not override the snapshot's 2 with 0. + if err := s.SetCodexResetCreditCheckStatus(DefaultCodexAccountID, "ok", nil, nil, now); err != nil { + t.Fatalf("SetCodexResetCreditCheckStatus: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["available_count"] != float64(2) { + t.Fatalf("available_count = %v, want the snapshot fallback 2 (not 0/unknown)", rc["available_count"]) + } + if rc["status"] != "ok" { + t.Fatalf("status = %v, want ok", rc["status"]) + } +} + +func TestHandler_CodexUsage_ResetCreditsApplicableCountBackfillsFromSnapshot(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + ResetCreditsAvailableCount: api.CodexOptionalInt64{Value: 3, Valid: true}, + ResetCreditsApplicableCount: api.CodexOptionalInt64{Value: 1, Valid: true}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["applicable_count"] != float64(1) { + t.Fatalf("applicable_count = %v, want the snapshot's 1", rc["applicable_count"]) + } +} + +func TestHandler_CodexUsage_NextExpiryIgnoresUnsupportedCredits(t *testing.T) { + t.Parallel() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + unsupportedExpiry := now.Add(1 * time.Hour) + usableExpiry := now.Add(5 * time.Hour) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: now, + AccountID: DefaultCodexAccountID, + PlanType: "plus", + Quotas: []api.CodexQuota{{Name: "five_hour", Utilization: 20, Status: "healthy"}}, + }); err != nil { + t.Fatalf("InsertCodexSnapshot: %v", err) + } + if err := s.SyncCodexResetCredits(DefaultCodexAccountID, []store.CodexResetCreditRecord{ + {CreditID: "unsupported", Status: "available", ExpiresAt: &unsupportedExpiry, UnsupportedByPlan: true}, + {CreditID: "usable", Status: "available", ExpiresAt: &usableExpiry}, + }, now); err != nil { + t.Fatalf("SyncCodexResetCredits: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + req := httptest.NewRequest(http.MethodGet, "/api/codex/usage?account=1", nil) + rr := httptest.NewRecorder() + h.CodexUsage(rr, req) + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("parse JSON: %v", err) + } + rc := response["resetCredits"].(map[string]interface{}) + if rc["next_expires_at"] != usableExpiry.UTC().Format(time.RFC3339) { + t.Fatalf("next_expires_at = %v, want the usable credit's expiry %v", rc["next_expires_at"], usableExpiry) + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 49045b18..8452eca2 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -7760,14 +7760,15 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { // Handle notification settings if raw, ok := body["notifications"]; ok { var notif struct { - WarningThreshold float64 `json:"warning_threshold"` - CriticalThreshold float64 `json:"critical_threshold"` - NotifyWarning bool `json:"notify_warning"` - NotifyCritical bool `json:"notify_critical"` - NotifyReset bool `json:"notify_reset"` - NotifyAuthError bool `json:"notify_auth_error"` - NotifyRepeat bool `json:"notify_repeat"` - CooldownMinutes int `json:"cooldown_minutes"` + WarningThreshold float64 `json:"warning_threshold"` + CriticalThreshold float64 `json:"critical_threshold"` + NotifyWarning bool `json:"notify_warning"` + NotifyCritical bool `json:"notify_critical"` + NotifyReset bool `json:"notify_reset"` + NotifyAuthError bool `json:"notify_auth_error"` + NotifyResetCreditExpiry *bool `json:"notify_reset_credit_expiry"` + NotifyRepeat bool `json:"notify_repeat"` + CooldownMinutes int `json:"cooldown_minutes"` // Channels is a pointer so an omitted value keeps the stored // selection instead of silently resetting every channel to off. Channels *notify.NotificationChannels `json:"channels,omitempty"` @@ -7817,14 +7818,22 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { } } - // Preserve the stored channel selection when the client omits it. - if notif.Channels == nil { + // Preserve the stored channel selection and the reset-credit toggle + // when the client omits them. The reset-credit warning defaults on when + // it has never been saved. + if notif.Channels == nil || notif.NotifyResetCreditExpiry == nil { if existingJSON, _ := h.store.GetSetting("notifications"); existingJSON != "" { var existing struct { - Channels *notify.NotificationChannels `json:"channels"` + Channels *notify.NotificationChannels `json:"channels"` + NotifyResetCreditExpiry *bool `json:"notify_reset_credit_expiry"` } if json.Unmarshal([]byte(existingJSON), &existing) == nil { - notif.Channels = existing.Channels + if notif.Channels == nil { + notif.Channels = existing.Channels + } + if notif.NotifyResetCreditExpiry == nil { + notif.NotifyResetCreditExpiry = existing.NotifyResetCreditExpiry + } } } } @@ -9703,10 +9712,118 @@ func (h *Handler) buildCodexCurrentOK(accountID int64) (response map[string]inte } } response["quotas"] = quotas + response["resetCredits"] = h.buildCodexResetCredits(accountID, latest) applyDisplayModeToResponse(response, displayMode) return response, true, hasData } +// buildCodexResetCredits shapes the read-only banked reset-credit view for one +// account (issue #152). It only adds fields and never fails the response: a +// missing check row is reported as "unknown", and an absent usage count stays +// null rather than 0. +func (h *Handler) buildCodexResetCredits(accountID int64, latest *api.CodexSnapshot) map[string]interface{} { + out := map[string]interface{}{ + "status": "unknown", + "available_count": nil, + "applicable_count": nil, + "next_expires_at": nil, + "credits": []interface{}{}, + "last_checked_at": nil, + } + if h.store == nil { + return out + } + + // Counts from the latest snapshot are always available, even when the + // detail endpoint cannot be reached. + if latest != nil && latest.ResetCreditsAvailableCount.Valid { + out["available_count"] = latest.ResetCreditsAvailableCount.Value + } + if latest != nil && latest.ResetCreditsApplicableCount.Valid { + out["applicable_count"] = latest.ResetCreditsApplicableCount.Value + } + + check, err := h.store.QueryCodexResetCreditCheckStatus(accountID) + if err != nil { + h.logger.Error("failed to query Codex reset-credit check", "error", err, "account_id", accountID) + return out + } + if check != nil { + switch check.Status { + case "ok": + out["status"] = "ok" + // The detail response's own counts are authoritative once it has + // served data; a NULL count stays unknown and falls back to the + // snapshot value (never 0). + if check.AvailableCount != nil { + out["available_count"] = *check.AvailableCount + } + if check.ApplicableCount != nil { + out["applicable_count"] = *check.ApplicableCount + } + case "unavailable": + out["status"] = "unavailable" + } + if !check.LastCheckedAt.IsZero() { + out["last_checked_at"] = check.LastCheckedAt.UTC().Format(time.RFC3339) + } + } + + rows, err := h.store.QueryCodexResetCredits(accountID) + if err != nil { + h.logger.Error("failed to query Codex reset credits", "error", err, "account_id", accountID) + return out + } + + credits := make([]interface{}, 0, len(rows)) + available := make([]store.CodexResetCreditRecord, 0, len(rows)) + for _, row := range rows { + if row.GoneAt != nil || row.Status != "available" { + continue + } + available = append(available, row) + } + // The store already orders by expiry with never-expiring last. Keep that + // order, but put plan-usable credits first: the soonest usable expiry + // drives next_expires_at and the card tooltip. + sort.SliceStable(available, func(i, j int) bool { + left, right := available[i], available[j] + if left.UnsupportedByPlan != right.UnsupportedByPlan { + return !left.UnsupportedByPlan + } + switch { + case left.ExpiresAt == nil && right.ExpiresAt == nil: + return left.CreditID < right.CreditID + case left.ExpiresAt == nil: + return false + case right.ExpiresAt == nil: + return true + default: + return left.ExpiresAt.Before(*right.ExpiresAt) + } + }) + for _, row := range available { + entry := map[string]interface{}{ + "id": row.CreditID, + "status": row.Status, + "reset_type": row.ResetType, + "title": row.Title, + "expires_at": nil, + "unsupported_by_plan": row.UnsupportedByPlan, + "expiry_unknown": row.ExpiryUnknown, + } + if row.ExpiresAt != nil { + entry["expires_at"] = row.ExpiresAt.UTC().Format(time.RFC3339) + if out["next_expires_at"] == nil && !row.UnsupportedByPlan { + out["next_expires_at"] = row.ExpiresAt.UTC().Format(time.RFC3339) + } + } + credits = append(credits, entry) + } + out["credits"] = credits + return out +} + func codexUtilStatus(util float64) string { switch { case util >= 95: diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 8d01fa8f..5c69964b 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -2823,11 +2823,83 @@ function syncCodexAutoStartBadges() { }); } +// Banked reset credits (issue #152): a compact chip with the plan-usable count +// and a tooltip for the nearest expiry. The detail endpoint can be unavailable +// (API-key auth, older backend): the chip still shows the usage count and a +// muted dot carries the "Resets unavailable" note. A count is never invented - +// unknown stays hidden. +function codexResetCreditsUsableCount(rc) { + if (!rc) return null; + if (typeof rc.applicable_count === 'number') return rc.applicable_count; + if (typeof rc.available_count === 'number') return rc.available_count; + return null; +} + +function codexResetCreditsTooltip(rc) { + const credits = (Array.isArray(rc.credits) ? rc.credits : []) + .filter(c => !c.unsupported_by_plan); + const expiries = credits.map(c => c.expires_at).filter(Boolean); + let tip; + if (expiries.length === 0) { + if (rc.status !== 'ok' || credits.some(c => c.expiry_unknown)) { + tip = 'expiry unknown'; + } else if (codexResetCreditsUsableCount(rc) === 0) { + tip = 'no banked resets'; + } else { + tip = 'does not expire'; + } + } else { + const soonest = expiries.reduce((a, b) => (new Date(a) <= new Date(b) ? a : b)); + const ms = new Date(soonest).getTime() - Date.now(); + tip = (!isFinite(ms) || ms <= 0) ? 'expires now' : `next expires in ${formatDuration(Math.floor(ms / 1000))}`; + } + if (typeof rc.applicable_count === 'number' && typeof rc.available_count === 'number' && + rc.applicable_count < rc.available_count) { + const diff = rc.available_count - rc.applicable_count; + tip += `; ${diff} not usable on current plan`; + } + return tip; +} + +function codexResetCreditsHTML(rc) { + if (!rc || !rc.status) return ''; + const unavailable = rc.status === 'unavailable' + ? '' + : ''; + const count = codexResetCreditsUsableCount(rc); + if (count == null || !isFinite(count) || count < 0) return unavailable; + const label = `${count} ${count === 1 ? 'reset' : 'resets'}`; + const chip = `${label}`; + return unavailable + chip; +} + +// codexResetCreditsKeyFromData renders the chip identity used to decide whether +// a re-render is needed. Only count and status are compared: the tooltip is a +// live relative time and must not force a re-render on every poll. +function codexResetCreditsKeyFromData(rc) { + if (!rc || !rc.status) return ''; + const count = codexResetCreditsUsableCount(rc); + const countKey = count == null ? '' : String(count); + return `${rc.status === 'unavailable' ? 'unavailable' : 'ok'}|${countKey}`; +} + +// codexResetCreditsKey reads the chip identity that is actually rendered in the +// given container, matching codexResetCreditsKeyFromData. The dot and the chip +// can coexist, so both are looked up independently. +function codexResetCreditsKey(container) { + if (!container) return ''; + const status = container.querySelector('.codex-resets-dot') ? 'unavailable' : 'ok'; + const chip = container.querySelector('.codex-reset-credits-chip'); + const count = chip ? parseInt((chip.textContent || '').trim(), 10) : NaN; + return `${status}|${isFinite(count) ? count : ''}`; +} + function renderCodexQuotaCards(quotas, containerId, planType, usageSourceNote, pause) { const container = document.getElementById(containerId); if (!container) return; const visibleQuotas = filterCodexQuotasForPlan(quotas, planType); - const sourceNoteHTML = codexPauseNoticeHTML(pause) + codexUsageSourceNoteHTML(usageSourceNote); + const resetsHTML = codexResetCreditsHTML(pause && pause.resetCredits); + const sourceNoteHTML = codexPauseNoticeHTML(pause) + resetsHTML + codexUsageSourceNoteHTML(usageSourceNote); if (visibleQuotas.length === 0) { container.innerHTML = `${sourceNoteHTML}

No Codex quota data available yet.

`; return; @@ -2919,7 +2991,10 @@ function renderCodexQuotaCardsForAccount(quotas, container, accountName, planTyp header.className = 'codex-account-header'; header.innerHTML = ` - + `; container.appendChild(header); container.insertAdjacentHTML('beforeend', codexPauseNoticeHTML(pause)); @@ -4994,7 +5069,8 @@ async function fetchCodexUsage(options = {}) { const sourceNoteChanged = (container.querySelector('.codex-usage-source-note')?.textContent || '') !== sourceNote; const pausedChanged = (container.querySelector('.codex-paused-dot') != null) !== Boolean(payload.paused); const staleChanged = (container.querySelector('.quota-card.stale-card') != null) !== Boolean(payload.isStale); - if (container.children.length === 0 || renderedCount !== visibleQuotas.length || planChanged || quotaNamesChanged || sourceNoteChanged || pausedChanged || staleChanged) { + const resetsChanged = codexResetCreditsKey(container) !== codexResetCreditsKeyFromData(payload.resetCredits); + if (container.children.length === 0 || renderedCount !== visibleQuotas.length || planChanged || quotaNamesChanged || sourceNoteChanged || pausedChanged || staleChanged || resetsChanged) { renderCodexQuotaCards(visibleQuotas, 'quota-grid-codex', State.codexPlanType, payload.usageSourceNote, payload); } @@ -10289,6 +10365,9 @@ async function loadSettings() { if (resetCheck) resetCheck.checked = n.notify_reset !== false; const authErrorCheck = document.getElementById('notify-auth-error'); if (authErrorCheck) authErrorCheck.checked = !!n.notify_auth_error; + // Absent means the default (on) for the reset-credit expiry warning. + const resetCreditCheck = document.getElementById('notify-reset-credit-expiry'); + if (resetCreditCheck) resetCreditCheck.checked = n.notify_reset_credit_expiry !== false; const repeatCheck = document.getElementById('notify-repeat'); if (repeatCheck) repeatCheck.checked = !!n.notify_repeat; setVal('notify-cooldown', n.cooldown_minutes || 30); @@ -12123,6 +12202,7 @@ function gatherSettings() { notify_critical: document.getElementById('notify-critical')?.checked ?? true, notify_reset: document.getElementById('notify-reset')?.checked ?? true, notify_auth_error: document.getElementById('notify-auth-error')?.checked ?? false, + notify_reset_credit_expiry: document.getElementById('notify-reset-credit-expiry')?.checked ?? true, notify_repeat: document.getElementById('notify-repeat')?.checked ?? false, cooldown_minutes: parseInt(document.getElementById('notify-cooldown')?.value) || 30, channels: { @@ -12380,6 +12460,7 @@ function loadWebhookSettings(webhook) { 'webhook-event-critical': 'critical', 'webhook-event-reset': 'reset', 'webhook-event-auth-error': 'auth_error', + 'webhook-event-reset-credit-expiry': 'reset_credit_expiry', 'webhook-event-starter-success': 'starter_success', 'webhook-event-starter-failure': 'starter_failure', }; @@ -12415,6 +12496,7 @@ function collectWebhookSettings() { critical: document.getElementById('webhook-event-critical')?.checked ?? false, reset: document.getElementById('webhook-event-reset')?.checked ?? false, auth_error: document.getElementById('webhook-event-auth-error')?.checked ?? false, + reset_credit_expiry: document.getElementById('webhook-event-reset-credit-expiry')?.checked ?? false, starter_success: document.getElementById('webhook-event-starter-success')?.checked ?? false, starter_failure: document.getElementById('webhook-event-starter-failure')?.checked ?? false, }, diff --git a/internal/web/static/style.css b/internal/web/static/style.css index cb69d47c..cec7fae4 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -3801,6 +3801,52 @@ select.settings-input { padding: 2px 8px; } +/* Banked reset credits (issue #152): compact chip in the account header, or a + muted dot when the detail endpoint is unavailable. */ +.codex-account-meta { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.codex-reset-credits-chip { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + background: var(--surface-card-alt); + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + padding: 2px 8px; + white-space: nowrap; + cursor: default; +} + +.codex-resets-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + flex: 0 0 auto; + vertical-align: middle; + margin: 0 6px 2px 0; + opacity: 0.6; +} + +#quota-grid-codex > .codex-resets-dot { + grid-column: 1 / -1; +} + +#quota-grid-codex > .codex-reset-credits-chip { + grid-column: 1 / -1; + justify-self: start; +} + +.codex-account-header .codex-resets-dot { + margin: 0; +} + .codex-account-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--layout-card-min), 1fr)); diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index e2a2e5b2..9a7ab054 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -198,6 +198,10 @@

Webhook Events

Auth error alerts + +