From fca36f907510dcffdca167a699c2d02ca2d80fd9 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 23:18:47 -0400 Subject: [PATCH 1/2] =?UTF-8?q?perf:=20main-process=20reconcile=20cache=20?= =?UTF-8?q?=E2=80=94=20stop=20refetching=20the=20repo=20on=20every=20tool?= =?UTF-8?q?=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full reconcile is ~2N gitea calls (deps + timelines per issue). Every agent tool call (query_project) was doing a fresh one; the UI reconcile and the agent didn't share anything. Now a single in-memory snapshot cache backs both. - gitea.ts: getSnapshot(client, { maxAgeMs }) — reads within the window reuse the cache; maxAgeMs:0 forces fresh. invalidateSnapshot() drops it. The explicit UI reconcile forces fresh (and warms the cache); agent tool calls tolerate a 30s TTL to stay responsive; applyChange + createIssues invalidate so the board and forecast reflect the write immediately. - model.ts: query_project reads getSnapshot (30s TTL) instead of reconciling live. This is the SQLite mirror's cache semantics in memory — rebuildable, the durable truth stays in gitea (purity split, D4). Persistent SQLite (offline + instant boot) is a separate slice: Electron 34's Node 20 has no node:sqlite, so it needs better-sqlite3 + electron-rebuild or sql.js/WASM — deferred as its own decision. Verified: desktop typecheck clean, 14 fixture e2e green, live Reginald still answers correctly from the cache (writes invalidate → board stays correct). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/gitea.ts | 35 +++++++++++++++++++++++++++++++++- apps/desktop/src/main/model.ts | 11 +++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index eaf5670..1ea869e 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -126,6 +126,36 @@ export async function reconcileSnapshot( return { issues, milestones, deps, timelines } } +type Snapshot = Awaited> + +/** + * A single in-memory reconcile cache shared across the app. A full reconcile is + * ~2N gitea calls (deps + timelines per issue); without this, every agent tool + * call refetched the whole repo. Reads within `maxAgeMs` reuse the cache; + * `getSnapshot({ maxAgeMs: 0 })` forces a fresh pull (the explicit UI reconcile), + * and any write calls `invalidateSnapshot()` so the next read sees it. The cache + * is rebuildable — the durable truth stays in gitea (the purity split, D4). + */ +let snapshotCache: { snap: Snapshot; at: number } | null = null + +export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: number }): Promise { + const maxAgeMs = opts?.maxAgeMs ?? 0 + if (snapshotCache && maxAgeMs > 0 && Date.now() - snapshotCache.at <= maxAgeMs) { + return snapshotCache.snap + } + const snap = await reconcileSnapshot(client) + snapshotCache = { snap, at: Date.now() } + return snap +} + +/** Drop the cache so the next read reflects a just-made write. */ +export function invalidateSnapshot(): void { + snapshotCache = null +} + +/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */ +export const AGENT_SNAPSHOT_TTL_MS = 30_000 + export function registerGiteaIpc(): void { const client = getGiteaClient() const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null @@ -134,7 +164,8 @@ export function registerGiteaIpc(): void { ipcMain.handle('gitea:reconcile', async () => { if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } - const snap = await reconcileSnapshot(client) + // explicit UI sync — force fresh, and warm the cache for agent tool calls + const snap = await getSnapshot(client, { maxAgeMs: 0 }) return { configured: true, ...snap } }) @@ -167,6 +198,7 @@ export function registerGiteaIpc(): void { const ids = await resolveLabelIds(plan.labels) await client.setIssueLabels(change.issue, ids) const issue = await client.getIssue(change.issue) + invalidateSnapshot() // the board + forecast must reflect the label change return { ok: true as const, plan, issue } }) @@ -183,6 +215,7 @@ export function registerGiteaIpc(): void { const issue = await client.createIssue({ title: it.title, body: it.body, labelIds }) created.push({ number: issue.number, title: issue.title }) } + if (created.length) invalidateSnapshot() // new issues enter the board/scope return { ok: true as const, created } }, ) diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index 686a8a3..2859397 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -26,7 +26,13 @@ import { } from '@commitea/core' import { ipcMain } from 'electron' -import { appendDirectiveEntry, getGiteaClient, getPmStateClient, reconcileSnapshot } from './gitea.js' +import { + AGENT_SNAPSHOT_TTL_MS, + appendDirectiveEntry, + getGiteaClient, + getPmStateClient, + getSnapshot, +} from './gitea.js' /** Small local model for prose + the read tool; big model reserved for later decomposition. */ function resolveModelRouter(): ModelRouter | null { @@ -93,7 +99,8 @@ export function registerModelIpc(): void { const execute = async (name: string, args: unknown) => { if (!client) return { error: 'gitea is not configured' } if (name === 'query_project') { - const snap = await reconcileSnapshot(client) + // reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call + const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS }) const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } return buildProjectView(a.view, a.filters, snap, new Date()) } -- 2.49.1 From 2f6636684e928bd6fadfe93a874b4b623be41611 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 23:43:44 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20persist=20the=20reconcile=20cache?= =?UTF-8?q?=20to=20disk=20=E2=80=94=20instant=20boot=20+=20offline=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/live-persistence.spec.ts | 37 +++++++++++++++ apps/desktop/src/main/gitea.ts | 42 +++++++++++++++-- apps/desktop/src/main/snapshot-store.ts | 47 +++++++++++++++++++ apps/desktop/src/preload/index.ts | 2 + apps/desktop/src/renderer/src/global.d.ts | 30 ++++++++---- .../src/renderer/src/lib/use-backlog.ts | 46 ++++++++++++++++-- 6 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/e2e/live-persistence.spec.ts create mode 100644 apps/desktop/src/main/snapshot-store.ts diff --git a/apps/desktop/e2e/live-persistence.spec.ts b/apps/desktop/e2e/live-persistence.spec.ts new file mode 100644 index 0000000..6f0a68c --- /dev/null +++ b/apps/desktop/e2e/live-persistence.spec.ts @@ -0,0 +1,37 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { _electron as electron, expect, test } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +const MAIN = join(here, '..', 'out', 'main', 'index.js') + +// Opt-in (GITEA_LIVE=1). A real launch persists the snapshot; a second launch with +// gitea unreachable must still show the board + real scheduler output from the +// persisted cache (offline reads). No model needed. +test.describe('live persistence', () => { + test('offline: serves the persisted snapshot', async () => { + test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') + test.setTimeout(120_000) + + // launch 1 — real reconcile writes the snapshot to disk + const app1 = await electron.launch({ args: [MAIN], env: { ...process.env } }) + const w1 = await app1.firstWindow() + await w1.waitForLoadState('domcontentloaded') + // a scheduler-only phrase confirms real data reconciled (never emitted by fixtures) + await expect(w1.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible({ timeout: 30000 }) + await app1.close() + + // launch 2 — gitea unreachable; the reconcile must fall back to the persisted snapshot + const app2 = await electron.launch({ + args: [MAIN], + env: { ...process.env, GITEA_BASE_URL: 'http://127.0.0.1:9' }, + }) + const w2 = await app2.firstWindow() + await w2.waitForLoadState('domcontentloaded') + // Focus still renders real scheduler output — proving it came from the cache, offline + await expect(w2.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible({ timeout: 20000 }) + await w2.screenshot({ path: join(here, '.artifacts', 'screens', 'live-offline.png'), fullPage: true, animations: 'disabled' }) + await app2.close() + }) +}) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 1ea869e..ccead88 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -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) => { diff --git a/apps/desktop/src/main/snapshot-store.ts b/apps/desktop/src/main/snapshot-store.ts new file mode 100644 index 0000000..2f79e39 --- /dev/null +++ b/apps/desktop/src/main/snapshot-store.ts @@ -0,0 +1,47 @@ +/** + * Durable snapshot store — the reconcile cache, persisted to disk. On boot the + * app shows the last snapshot instantly (stale-while-revalidate) instead of a + * blank board while ~2N gitea calls run; if gitea is unreachable, reads fall + * back to it (offline). It's a rebuildable mirror — the durable truth stays in + * gitea (the purity split, D4). A plain JSON file: the whole snapshot fits in + * memory at this scale, so indexed SQL buys nothing yet (see the PR). + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { app } from 'electron' + +/** The shape we persist — kept loose so a schema drift degrades to "no cache", not a crash. */ +export interface PersistedSnapshot { + issues: unknown[] + milestones: unknown[] + deps: unknown[] + timelines: Record + /** ISO time the snapshot was reconciled — shown as "cached since". */ + savedAt: string +} + +function snapshotPath(): string { + return join(app.getPath('userData'), 'commitea-snapshot.json') +} + +/** Load the last persisted snapshot, or null if absent/corrupt. Never throws. */ +export function loadSnapshot(): PersistedSnapshot | null { + try { + const parsed = JSON.parse(readFileSync(snapshotPath(), 'utf8')) as PersistedSnapshot + if (parsed && Array.isArray(parsed.issues)) return parsed + return null + } catch { + return null // missing file, bad JSON, or drift — treat as no cache + } +} + +/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */ +export function saveSnapshot(snap: Omit, savedAt: string): void { + try { + writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8') + } catch { + // disk full / permissions — the in-memory cache still works this session + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6dee376..a253128 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -5,6 +5,8 @@ const api = { gitea: { /** Whether the main process has a gitea token + target repo configured. */ status: () => ipcRenderer.invoke('gitea:status'), + /** The last persisted snapshot, for instant boot before the fresh reconcile. */ + boot: () => ipcRenderer.invoke('gitea:boot'), /** Full read of the managed repo — every issue + milestone. */ reconcile: () => ipcRenderer.invoke('gitea:reconcile'), /** One issue by index, normalized (or null if unconfigured). */ diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 151675c..3235a0a 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -28,17 +28,31 @@ export type CaptureResult = | { ok: false; reason: 'unconfigured' | 'error'; message?: string } | ({ ok: true } & CaptureProposal) +/** A reconciled snapshot as it crosses the bridge. */ +export interface SnapshotPayload { + configured: boolean + issues: GiteaIssue[] + milestones: GiteaMilestone[] + deps: DependencyEdge[] + /** Normalized lifecycle events keyed by issue number. */ + timelines: Record + /** true when served from the persisted cache (offline / instant boot). */ + stale?: boolean + /** ISO time the persisted snapshot was reconciled (present on cached reads). */ + savedAt?: string +} + +/** Boot payload — the persisted snapshot, or a marker that there's none yet. */ +export type BootPayload = + | { configured: false } + | { configured: true; cached: false } + | ({ configured: true; cached: true } & Omit) + /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { status(): Promise<{ configured: boolean; repo: string | null }> - reconcile(): Promise<{ - configured: boolean - issues: GiteaIssue[] - milestones: GiteaMilestone[] - deps: DependencyEdge[] - /** Normalized lifecycle events keyed by issue number. */ - timelines: Record - }> + boot(): Promise + reconcile(): Promise getIssue(index: number): Promise applyChange(change: IssueChange): Promise createIssues(issues: ProposedIssue[]): Promise diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index 27e0fc9..48419c8 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -12,13 +12,18 @@ export type BacklogState = milestones: GiteaMilestone[] deps: DependencyEdge[] timelines: Record + /** true while showing the persisted snapshot (instant boot / offline). */ + stale: boolean + /** ISO time the shown snapshot was reconciled, when stale. */ + savedAt?: string } /** - * Reconcile the managed repo through the main-process bridge. Runs once on - * mount; the returned `refetch` re-reconciles after a write so the board and - * forecast reflect the change. `unconfigured` means no token — the UI falls - * back to demo fixtures. Errors (network, bad token) surface as `error`. + * Reconcile the managed repo through the main-process bridge, stale-while- + * revalidate: on mount it shows the persisted snapshot instantly (marked stale), + * then a fresh reconcile supersedes it. If gitea is unreachable, the fresh + * reconcile falls back to the persisted snapshot (offline). `refetch` re-syncs + * after a write. `unconfigured` means no token — the UI uses demo fixtures. */ export function useBacklog(): [BacklogState, () => void] { const [state, setState] = useState({ status: 'loading' }) @@ -27,6 +32,30 @@ export function useBacklog(): [BacklogState, () => void] { useEffect(() => { let alive = true + + // instant boot from the persisted snapshot (only on first mount, not refetch) + if (nonce === 0) { + window.commitea.gitea + .boot() + .then((b) => { + if (!alive || !('cached' in b) || !b.cached) return + setState((prev) => + prev.status === 'ready' && !prev.stale + ? prev // a fresh reconcile already won the race + : { + status: 'ready', + issues: b.issues, + milestones: b.milestones, + deps: b.deps, + timelines: b.timelines, + stale: true, + savedAt: b.savedAt, + }, + ) + }) + .catch(() => {}) + } + window.commitea.gitea .reconcile() .then((r) => { @@ -39,12 +68,19 @@ export function useBacklog(): [BacklogState, () => void] { milestones: r.milestones, deps: r.deps, timelines: r.timelines, + stale: r.stale ?? false, + savedAt: r.savedAt, } : { status: 'unconfigured' }, ) }) .catch((e: unknown) => { - if (alive) setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }) + if (alive) { + // keep a shown boot snapshot rather than clobbering it with an error + setState((prev) => + prev.status === 'ready' ? prev : { status: 'error', message: e instanceof Error ? e.message : String(e) }, + ) + } }) return () => { alive = false -- 2.49.1