Merge pull request 'perf+persistence: the durable reconcile mirror (cache + disk)' (#47) from infra/reconcile-cache into main

Reviewed-on: #47
This commit is contained in:
2026-07-09 03:53:29 +00:00
7 changed files with 229 additions and 17 deletions

View File

@@ -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()
})
})

View File

@@ -27,6 +27,8 @@ import {
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' 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). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
function loadEnvLocalToken(): string | undefined { function loadEnvLocalToken(): string | undefined {
let dir = process.cwd() let dir = process.cwd()
@@ -126,16 +128,81 @@ export async function reconcileSnapshot(
return { issues, milestones, deps, timelines } return { issues, milestones, deps, timelines }
} }
type Snapshot = Awaited<ReturnType<typeof reconcileSnapshot>>
/**
* 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<Snapshot> {
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() }
saveSnapshot(snap, new Date().toISOString()) // persist for instant boot + offline
return snap
}
/** Drop the cache so the next read reflects a just-made write. */
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
export function registerGiteaIpc(): void { export function registerGiteaIpc(): void {
const client = getGiteaClient() const client = getGiteaClient()
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo })) 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 () => { ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const snap = await reconcileSnapshot(client) try {
return { configured: true, ...snap } // 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) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
@@ -167,6 +234,7 @@ export function registerGiteaIpc(): void {
const ids = await resolveLabelIds(plan.labels) const ids = await resolveLabelIds(plan.labels)
await client.setIssueLabels(change.issue, ids) await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue) const issue = await client.getIssue(change.issue)
invalidateSnapshot() // the board + forecast must reflect the label change
return { ok: true as const, plan, issue } return { ok: true as const, plan, issue }
}) })
@@ -183,6 +251,7 @@ export function registerGiteaIpc(): void {
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds }) const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title }) created.push({ number: issue.number, title: issue.title })
} }
if (created.length) invalidateSnapshot() // new issues enter the board/scope
return { ok: true as const, created } return { ok: true as const, created }
}, },
) )

View File

@@ -26,7 +26,13 @@ import {
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' 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. */ /** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null { function resolveModelRouter(): ModelRouter | null {
@@ -93,7 +99,8 @@ export function registerModelIpc(): void {
const execute = async (name: string, args: unknown) => { const execute = async (name: string, args: unknown) => {
if (!client) return { error: 'gitea is not configured' } if (!client) return { error: 'gitea is not configured' }
if (name === 'query_project') { 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 } const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date()) return buildProjectView(a.view, a.filters, snap, new Date())
} }

View File

@@ -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<number, unknown[]>
/** 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<PersistedSnapshot, 'savedAt'>, savedAt: string): void {
try {
writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8')
} catch {
// disk full / permissions — the in-memory cache still works this session
}
}

View File

@@ -5,6 +5,8 @@ const api = {
gitea: { gitea: {
/** Whether the main process has a gitea token + target repo configured. */ /** Whether the main process has a gitea token + target repo configured. */
status: () => ipcRenderer.invoke('gitea:status'), 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. */ /** Full read of the managed repo — every issue + milestone. */
reconcile: () => ipcRenderer.invoke('gitea:reconcile'), reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
/** One issue by index, normalized (or null if unconfigured). */ /** One issue by index, normalized (or null if unconfigured). */

View File

@@ -28,17 +28,31 @@ export type CaptureResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string } | { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| ({ ok: true } & CaptureProposal) | ({ 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<number, LifecycleEvent[]>
/** 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<SnapshotPayload, 'configured'>)
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge { export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }> status(): Promise<{ configured: boolean; repo: string | null }>
reconcile(): Promise<{ boot(): Promise<BootPayload>
configured: boolean reconcile(): Promise<SnapshotPayload>
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]>
}>
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult> applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult> createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>

View File

@@ -12,13 +12,18 @@ export type BacklogState =
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]> timelines: Record<number, LifecycleEvent[]>
/** 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 * Reconcile the managed repo through the main-process bridge, stale-while-
* mount; the returned `refetch` re-reconciles after a write so the board and * revalidate: on mount it shows the persisted snapshot instantly (marked stale),
* forecast reflect the change. `unconfigured` means no token — the UI falls * then a fresh reconcile supersedes it. If gitea is unreachable, the fresh
* back to demo fixtures. Errors (network, bad token) surface as `error`. * 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] { export function useBacklog(): [BacklogState, () => void] {
const [state, setState] = useState<BacklogState>({ status: 'loading' }) const [state, setState] = useState<BacklogState>({ status: 'loading' })
@@ -27,6 +32,30 @@ export function useBacklog(): [BacklogState, () => void] {
useEffect(() => { useEffect(() => {
let alive = true 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 window.commitea.gitea
.reconcile() .reconcile()
.then((r) => { .then((r) => {
@@ -39,12 +68,19 @@ export function useBacklog(): [BacklogState, () => void] {
milestones: r.milestones, milestones: r.milestones,
deps: r.deps, deps: r.deps,
timelines: r.timelines, timelines: r.timelines,
stale: r.stale ?? false,
savedAt: r.savedAt,
} }
: { status: 'unconfigured' }, : { status: 'unconfigured' },
) )
}) })
.catch((e: unknown) => { .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 () => { return () => {
alive = false alive = false