From f26bf69f886baeb29bdb36d1c4e808852c90d6cf Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 18 Jul 2026 16:28:30 -0700 Subject: [PATCH] perf(app): cap CLI spawn concurrency, background-priority prefetch, default to today --- app/electron/cli.test.ts | 127 ++++++++++++++++++++++++++++++++++++++ app/electron/cli.ts | 95 +++++++++++++++++++++++++--- app/electron/main.test.ts | 14 +++++ app/electron/main.ts | 13 ++-- app/electron/preload.ts | 2 +- app/renderer/App.test.tsx | 22 ++++++- app/renderer/App.tsx | 33 +++++++--- app/renderer/lib/types.ts | 4 +- 8 files changed, 282 insertions(+), 28 deletions(-) diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index faf8544d..72fe2348 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -384,6 +384,133 @@ describe('killAll', () => { }) }) +describe('spawnCli concurrency scheduler', () => { + // A fake CLI that records each spawn (by subcommand) and then blocks until a + // release file named after that subcommand appears, so the test controls + // exactly when each child exits and can observe how many run at once. + function schedulerBin(startedFile: string, releaseDir: string): void { + fakeBin( + 'sched.js', + `const fs = require('fs'); const path = require('path'); + const cmd = process.argv[2]; + fs.appendFileSync(${JSON.stringify(startedFile)}, cmd + '\\n'); + const rel = path.join(${JSON.stringify(releaseDir)}, cmd); + const t = setInterval(() => { + if (fs.existsSync(rel)) { clearInterval(t); process.stdout.write('{}'); process.exit(0); } + }, 5);`, + ) + } + function startedList(startedFile: string): string[] { + try { return readFileSync(startedFile, 'utf8').split('\n').filter(Boolean) } catch { return [] } + } + function release(releaseDir: string, cmd: string): void { writeFileSync(join(releaseDir, cmd), '') } + async function waitUntil(cond: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs + while (!cond()) { + if (Date.now() > deadline) throw new Error('waitUntil timed out') + await new Promise(r => setTimeout(r, 10)) + } + } + const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) + + // Reset scheduler state so a leaked running slot can never starve the next test. + afterEach(() => { killAll() }) + + it('runs at most two children at once; a third waits for a freed slot', async () => { + const startedFile = join(dir, 'started') + const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) + schedulerBin(startedFile, releaseDir) + + const p1 = spawnCli(['status']) + const p2 = spawnCli(['models']) + const p3 = spawnCli(['sessions']) + await waitUntil(() => startedList(startedFile).length === 2) + await delay(100) // the cap must keep the third from sneaking in + expect(startedList(startedFile).sort()).toEqual(['models', 'status']) + + release(releaseDir, 'status') // free one slot + await waitUntil(() => startedList(startedFile).includes('sessions')) + + release(releaseDir, 'models'); release(releaseDir, 'sessions') + await Promise.all([p1, p2, p3]) + }) + + it('lets a later interactive spawn preempt an earlier queued background one', async () => { + const startedFile = join(dir, 'started') + const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) + schedulerBin(startedFile, releaseDir) + + // Fill both slots so the next two calls must queue. + const p1 = spawnCli(['fill1'], { priority: 'background' }) + const p2 = spawnCli(['fill2'], { priority: 'background' }) + await waitUntil(() => startedList(startedFile).length === 2) + + // Queue a background first, then an interactive. + const pbg = spawnCli(['bg'], { priority: 'background' }) + const pint = spawnCli(['inter'], { priority: 'interactive' }) + await delay(50) + expect(startedList(startedFile)).not.toContain('bg') + expect(startedList(startedFile)).not.toContain('inter') + + // Free exactly one slot: the interactive must take it despite queueing later. + release(releaseDir, 'fill1') + await waitUntil(() => startedList(startedFile).length === 3) + expect(startedList(startedFile)).toContain('inter') + expect(startedList(startedFile)).not.toContain('bg') + + release(releaseDir, 'fill2'); release(releaseDir, 'inter'); release(releaseDir, 'bg') + await Promise.all([p1, p2, pbg, pint]) + }) + + it('does not spend a slot on a coalesced (same-argv) call', async () => { + const startedFile = join(dir, 'started') + const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) + schedulerBin(startedFile, releaseDir) + + const p1 = spawnCli(['status']) + const p2 = spawnCli(['models']) + await waitUntil(() => startedList(startedFile).length === 2) + + const p1b = spawnCli(['status']) // coalesces onto p1's child — no new slot + const p3 = spawnCli(['sessions']) // genuinely new — queued behind the cap + await delay(100) + expect(startedList(startedFile).length).toBe(2) // still just the two originals + + release(releaseDir, 'status') // frees the slot the coalesced pair shared + await waitUntil(() => startedList(startedFile).includes('sessions')) + + release(releaseDir, 'models'); release(releaseDir, 'sessions') + const [a, b] = await Promise.all([p1, p1b]) + expect(a).toEqual(b) // one child served both callers + await Promise.all([p2, p3]) + }) + + it('cancels a queued spawn on killAll instead of letting it spawn later', async () => { + const startedFile = join(dir, 'started') + const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) + schedulerBin(startedFile, releaseDir) + + const p1 = spawnCli(['status']) + const p2 = spawnCli(['models']) + await waitUntil(() => startedList(startedFile).length === 2) + const p3 = spawnCli(['sessions']) // queued behind the cap, no child yet + await delay(50) + expect(startedList(startedFile)).not.toContain('sessions') + + killAll() // reaps the two running AND cancels the queued third + // Attach all three rejection handlers synchronously: the queued spawn rejects + // at once, so it must not sit unhandled while the killed children settle. + await Promise.all([ + expect(p1).rejects.toMatchObject({ kind: 'nonzero' }), + expect(p2).rejects.toMatchObject({ kind: 'nonzero' }), + expect(p3).rejects.toMatchObject({ kind: 'nonzero' }), + ]) + + await delay(50) + expect(startedList(startedFile)).not.toContain('sessions') // never spawned + }) +}) + describe('spawnCliAction', () => { it('returns stdout and ok:true on success', async () => { fakeBin('action-ok.js', 'process.stdout.write("currency updated")') diff --git a/app/electron/cli.ts b/app/electron/cli.ts index c6a6fcfd..e7e1caca 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -9,6 +9,14 @@ import { delimiter, dirname, isAbsolute, join } from 'node:path' export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args' export type ActionResult = { ok: boolean; stdout: string; stderr: string; code: number | null } +/** + * Scheduling class for a CLI spawn. 'interactive' is what a user is waiting on + * (the visible poll, a Settings mutation); 'background' is speculative work + * (the overview prefetch). Interactive always dequeues first, so a burst of + * background warms can never delay the fetch behind a click. + */ +export type SpawnPriority = 'interactive' | 'background' + /** * A resolved CLI target. `external` is a standalone `codeburn` executable (the * dev repo build, a persisted path, or one found on PATH) spawned directly. @@ -51,16 +59,60 @@ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024 // Same-cadence pollers fire near-identical read spawns; share one child and hold // its result briefly so six overview hooks don't launch six processes at once. const COALESCE_TTL_MS = 5_000 +// A cold-cache CLI spawn costs seconds at ~120% CPU; letting every poll + +// prefetch launch at once saturates the machine. Cap how many children run +// concurrently — the rest queue and drain as slots free (interactive first). +const MAX_CONCURRENT_CLI = 2 // Every live child so `before-quit` can reap them (Electron does not on macOS). const activeChildren = new Set() const readInflight = new Map>() const readCache = new Map() -/** SIGKILL every in-flight child. Wired to Electron's `before-quit`. */ +// Concurrency scheduler. `running` counts spawned (not queued) children; waiters +// hold the slot-grant resolver for a queued spawn. Two queues so interactive +// work jumps ahead of any already-queued background warm the moment a slot frees. +type SlotWaiter = { resolve: () => void; reject: (err: unknown) => void } +let running = 0 +const interactiveQueue: SlotWaiter[] = [] +const backgroundQueue: SlotWaiter[] = [] + +/** Grant free slots to queued waiters, interactive first, up to the cap. */ +function pumpSlots(): void { + while (running < MAX_CONCURRENT_CLI) { + const waiter = interactiveQueue.shift() ?? backgroundQueue.shift() + if (!waiter) return + running += 1 + waiter.resolve() + } +} + +/** Resolve once a run slot is free. The per-call timeout starts only after this + * resolves (i.e. at real spawn time), never while queued. */ +function acquireSlot(priority: SpawnPriority): Promise { + return new Promise((resolve, reject) => { + ;(priority === 'background' ? backgroundQueue : interactiveQueue).push({ resolve, reject }) + pumpSlots() + }) +} + +function releaseSlot(): void { + running = Math.max(0, running - 1) + pumpSlots() +} + +/** SIGKILL every in-flight child and cancel anything still queued for a slot. + * Wired to Electron's `before-quit`. */ export function killAll(): void { for (const child of activeChildren) child.kill('SIGKILL') activeChildren.clear() + // A queued waiter has no child to reap, so releaseSlot never fires for it; + // reject it explicitly so its caller settles instead of hanging past quit. + const waiting = [...interactiveQueue, ...backgroundQueue] + interactiveQueue.length = 0 + backgroundQueue.length = 0 + running = 0 + for (const waiter of waiting) waiter.reject(new CliError('nonzero', 'codeburn cancelled')) } // Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a @@ -331,7 +383,7 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: */ export function spawnCli( args: string[], - opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv } = {}, + opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {}, ): Promise { const target = resolveTarget() if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found', notFoundStage())) @@ -344,26 +396,49 @@ export function spawnCli( const existing = readInflight.get(key) // A same-cadence re-poll during a slow cold warmup coalesces onto the one // in-flight child (which already carries onStderr); no second cold parse. + // Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot. if (existing) return existing - const flight = runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr) + const priority = opts.priority ?? 'interactive' + const flight = (async () => { + await acquireSlot(priority) + try { + return await runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr) + } finally { + releaseSlot() + } + })() .then(value => { readCache.set(key, { at: Date.now(), value }); return value }) .finally(() => { readInflight.delete(key) }) readInflight.set(key, flight) return flight } -/** Spawn a config-mutating CLI command and return its text output verbatim. */ +/** Spawn a config-mutating CLI command and return its text output verbatim. + * Mutations count as interactive, so they take a run slot ahead of any queued + * background warm — a Settings save is never stuck behind speculative prefetch. */ export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS - return new Promise(resolve => { - const target = resolveTarget() - if (!target) { - resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null }) - return + const target = resolveTarget() + if (!target) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null }) + const spec = spawnSpecFor(target, args) + return (async () => { + try { + await acquireSlot('interactive') + } catch { + // Slot grant was cancelled (killAll during quit); never reached a spawn. + return { ok: false, stdout: '', stderr: 'codeburn cancelled', code: null } + } + try { + return await runAction(spec, args, timeoutMs) + } finally { + releaseSlot() } - const spec = spawnSpecFor(target, args) + })() +} +function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise { + return new Promise(resolve => { const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env }) activeChildren.add(child) let stdout = '' diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index c05635ab..30dfe17e 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -429,6 +429,20 @@ describe('createBridgeHandlers (cold-start warmup)', () => { expect(opts[1]?.extraEnv).toBeUndefined() }) + it('drops a warmed overview to background priority only when the prefetch flag is set', async () => { + const opts: Array | undefined> = [] + const spawnCli = vi.fn(async (_args: string[], o?: Record) => { opts.push(o); return { current: { cost: 1 } } }) + const handlers = createBridgeHandlers(base({ spawnCli, emitProgress: vi.fn() })) + + await handlers['codeburn:getOverview']!('30days', 'all') // cold warmup → interactive + await handlers['codeburn:getOverview']!('30days', 'claude') // warmed, no flag → interactive + await handlers['codeburn:getOverview']!('30days', 'grok', undefined, null, true) // prefetch → background + + expect(opts[0]?.priority).toBeUndefined() + expect(opts[1]?.priority).toBeUndefined() + expect(opts[2]?.priority).toBe('background') + }) + it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => { const opts: Array<{ timeoutMs?: number } | undefined> = [] let n = 0 diff --git a/app/electron/main.ts b/app/electron/main.ts index ffd43f6b..92426256 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron' import path from 'node:path' -import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli' +import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult, type SpawnPriority } from './cli' import { getQuota, sanitizeError } from './quota' import { Telemetry } from './telemetry' import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates' @@ -214,7 +214,7 @@ function cliErrorProps(err: unknown, cmd: string | undefined): Record void; extraEnv?: NodeJS.ProcessEnv }) => Promise + spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority }) => Promise spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise resolveCodeburnPath: () => string | null getQuota: typeof getQuota @@ -274,15 +274,20 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), ] - const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null) => { + // `background` (renderer prefetch only) drops this fetch to background priority + // so it yields the CLI's run slots to any interactive poll or click. Optional + // and defaulting to interactive, so an older preload that omits it is unchanged. + const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => { coldStartBegan ??= Date.now() + const priority: SpawnPriority | undefined = background ? 'background' : undefined try { const args = buildOverviewArgs(period, provider, range, configSource) - if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args) } + if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) } const value = await deps.spawnCli(args, { timeoutMs: WARMUP_TIMEOUT_MS, extraEnv: { CODEBURN_PROGRESS: '1' }, onStderr: makeProgressReader(emitProgress), + ...(priority ? { priority } : {}), }) overviewWarmed = true emitProgress({ kind: 'done' }) diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 71a22d45..c0dc4647 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -20,7 +20,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // renderer-side where `window.codeburn` is declared as CodeburnBridge. const bridge = { getQuota: (force?: boolean) => invoke('codeburn:getQuota', force), - getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null) => invoke('codeburn:getOverview', period, provider, range, configSource), + getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background), getPlans: (period: string) => invoke('codeburn:getPlans', period), getActReport: () => invoke('codeburn:getActReport'), getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 7b1381e7..ef3fd993 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', { }) const mocks = vi.hoisted(() => ({ - getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null) => Promise>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), @@ -177,6 +177,9 @@ describe('App shortcuts', () => { beforeEach(() => { installDefaultMocks() localStorage.clear() + // Pin the boot period so the provider/config tests below are independent of + // the app-wide default ('today'); tests that exercise the default set it. + localStorage.setItem('codeburn.defaultPeriod', '30days') document.documentElement.removeAttribute('data-theme') }) @@ -188,7 +191,13 @@ describe('App shortcuts', () => { }) it('boots with the persisted default period from Settings', async () => { - localStorage.setItem('codeburn.defaultPeriod', 'today') + localStorage.setItem('codeburn.defaultPeriod', 'week') + render() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all')) + }) + + it('boots to today when no default period is persisted', async () => { + localStorage.removeItem('codeburn.defaultPeriod') render() await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')) }) @@ -482,6 +491,9 @@ describe('provider prefetch storm', () => { // Pin the cadence to 30s so the fake-timer soak math below is independent of // the app-wide default (bumped to 60s for energy). localStorage.setItem('codeburn.refreshInterval', '30s') + // Pin the boot period so these prefetch assertions are independent of the + // app-wide default ('today'). + localStorage.setItem('codeburn.defaultPeriod', '30days') __resetPolledMemo() }) @@ -502,7 +514,8 @@ describe('provider prefetch storm', () => { for (const id of PROVIDERS) { const spawns = mocks.getOverview.mock.calls.filter( - c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined, + // Prefetch warms carry the background-priority flag (5th arg). + c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined && c[4] === true, ) expect(spawns.length, `prefetch spawns for ${id}`).toBe(1) } @@ -579,6 +592,9 @@ describe('currency correctness', () => { // Reset the module-level display currency so a prior test never bleeds in. setActiveCurrency(USD) localStorage.clear() + // Pin the boot period so the memo keys below match the app's boot fetch, + // independent of the app-wide default ('today'). + localStorage.setItem('codeburn.defaultPeriod', '30days') __resetPolledMemo() }) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 099091aa..41898a9a 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -135,7 +135,9 @@ export function overviewMemoKey(provider: string, period: Period, range: DateRan // provider at a time at low priority so the background scan never competes with // the interaction the user is actually having. const PREFETCH_START_DELAY_MS = 1500 -const PREFETCH_STAGGER_MS = 400 +// A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set +// almost at once; pace it wide enough that each warm genuinely trails the last. +const PREFETCH_STAGGER_MS = 2000 // Base instant-switch memo keys live during overview polling besides the per- // provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`, // plus one slot of headroom for section navigation. The memo cap is sized to @@ -148,11 +150,11 @@ function isPeriod(value: string): value is Period { return (STANDARD_PERIODS as string[]).includes(value) } -/** Boot period = the persisted "Default period" Settings writes, else 30 days. */ +/** Boot period = the persisted "Default period" Settings writes, else today. */ function initialPeriod(): Period { let saved: string | null = null try { saved = globalThis.localStorage?.getItem('codeburn.defaultPeriod') ?? null } catch { /* storage can be unavailable */ } - return saved && isPeriod(saved) ? saved : '30days' + return saved && isPeriod(saved) ? saved : 'today' } /** Persisted Claude config override (empty/absent = aggregate all configs). */ @@ -349,6 +351,10 @@ function AppMain() { // already warmed. New keys (a new provider id, or a period switch) still warm // exactly once. Without this the prefetch re-fired every poll: 12 redundant // full-history CLI parses every 30s, forever. + // Mirror the visible overview's fetch state into a ref so the prefetch can hold + // for a user-triggered fetch without re-arming the whole loop on each toggle. + const overviewBusyRef = useRef(false) + overviewBusyRef.current = overview.loading const warmedKeys = useRef>(new Set()) useEffect(() => { if (!ready || overview.data == null || customRange || claudeConfigSource) return @@ -356,16 +362,25 @@ function AppMain() { if (targets.length === 0) return let cancelled = false const warm = async () => { - for (const id of targets) { - if (cancelled) return - const key = overviewMemoKey(id, period, null, null) - if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue + for (let i = 0; i < targets.length && !cancelled; ) { + const key = overviewMemoKey(targets[i]!, period, null, null) + if (warmedKeys.current.has(key) || hasPolledMemo(key)) { i++; continue } + // Only warm while the visible overview is idle: a user fetch in flight + // takes priority (background-classed CLI spawns yield their slot to it), + // so hold and retry this provider after the stagger rather than race it. + if (overviewBusyRef.current) { + await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS)) + continue + } warmedKeys.current.add(key) try { - const value = await codeburn.getOverview(period, id) + // background priority (5th arg) so this never delays an interactive + // poll; ignored by an older preload, degrading to current behavior. + const value = await codeburn.getOverview(period, targets[i]!, undefined, undefined, true) if (!cancelled) primePolledMemo(key, value) } catch { /* best-effort warm; a real switch will fetch and surface any error */ } - if (!cancelled) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS)) + i++ + if (!cancelled && i < targets.length) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS)) } } const start = setTimeout(() => { void warm() }, PREFETCH_START_DELAY_MS) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index bc393d56..c003a0e9 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -586,7 +586,9 @@ export interface CodeburnBridge { /** Subscribe to pushed update-availability status; returns an unsubscribe fn. */ onUpdateStatus(cb: (status: UpdateStatus) => void): () => void getQuota(force?: boolean): Promise - getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise + // `background` (prefetch only) requests background CLI-spawn priority; optional + // so an older preload that ignores it degrades to interactive priority. + getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string