feat: persist the reconcile cache to disk — instant boot + offline reads

Extends the in-memory cache into a durable mirror. The reconcile snapshot is
written to disk on every successful reconcile; on boot the app shows it instantly
(stale-while-revalidate) instead of a blank board, and if gitea is unreachable,
reads fall back to it (offline). Rebuildable — the durable truth stays in gitea.

- snapshot-store.ts: load/save the snapshot as JSON in app userData (never throws;
  corrupt/absent → "no cache"). At this scale (~34 issues, 37KB) the whole snapshot
  fits in memory, so a JSON file beats indexed SQL — no query benefit yet, no
  native-module (better-sqlite3/electron-rebuild) or WASM dependency. That's the
  next step if the mirror ever needs indexed queries over larger data.
- gitea.ts: getSnapshot persists on a fresh pull; bootSnapshot() returns the
  persisted snapshot (without seeding the cache — agents still reconcile fresh);
  gitea:boot serves it; gitea:reconcile falls back to it on failure (stale:true).
- useBacklog: stale-while-revalidate — boot instantly, then a fresh reconcile
  supersedes; a reconcile error keeps the shown snapshot instead of erroring.

Verified: desktop typecheck clean, 14 fixture e2e green. Live: the snapshot
persists (34 issues / 44 deps / 34 timelines / 5 milestones written to disk); a
second launch with gitea unreachable renders the full real board — NOW/NEXT/LATER
+ the Monte Carlo cone — entirely from the cache (new live-persistence e2e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 23:43:44 -04:00
parent fca36f9075
commit 2f6636684e
6 changed files with 188 additions and 16 deletions

View File

@@ -27,6 +27,8 @@ import {
} from '@commitea/core'
import { ipcMain } from 'electron'
import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
function loadEnvLocalToken(): string | undefined {
let dir = process.cwd()
@@ -145,6 +147,7 @@ export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: numbe
}
const snap = await reconcileSnapshot(client)
snapshotCache = { snap, at: Date.now() }
saveSnapshot(snap, new Date().toISOString()) // persist for instant boot + offline
return snap
}
@@ -153,6 +156,25 @@ export function invalidateSnapshot(): void {
snapshotCache = null
}
/**
* The last persisted snapshot (from a previous session), for instant boot. The
* renderer shows it immediately, then a real reconcile supersedes it
* (stale-while-revalidate). Returns null when there's nothing on disk; its
* `savedAt` marks staleness. It does NOT seed the cache — agent tool calls
* always reconcile fresh so they never reason over stale data.
*/
export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
const persisted = loadSnapshot()
if (!persisted) return null
return {
issues: persisted.issues,
milestones: persisted.milestones,
deps: persisted.deps,
timelines: persisted.timelines,
savedAt: persisted.savedAt,
} as unknown as Snapshot & { savedAt: string }
}
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000
@@ -162,11 +184,25 @@ export function registerGiteaIpc(): void {
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
ipcMain.handle('gitea:boot', () => {
if (!client) return { configured: false }
const persisted = bootSnapshot()
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
})
ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
// explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, ...snap }
try {
// explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, stale: false, ...snap }
} catch (e) {
// offline / gitea down — serve the last persisted snapshot rather than error out
const persisted = bootSnapshot()
if (persisted) return { configured: true, stale: true, ...persisted }
throw e
}
})
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {