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:
30
apps/desktop/src/renderer/src/global.d.ts
vendored
30
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -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<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). */
|
||||
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<number, LifecycleEvent[]>
|
||||
}>
|
||||
boot(): Promise<BootPayload>
|
||||
reconcile(): Promise<SnapshotPayload>
|
||||
getIssue(index: number): Promise<GiteaIssue | null>
|
||||
applyChange(change: IssueChange): Promise<ApplyChangeResult>
|
||||
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
|
||||
|
||||
@@ -12,13 +12,18 @@ export type BacklogState =
|
||||
milestones: GiteaMilestone[]
|
||||
deps: DependencyEdge[]
|
||||
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
|
||||
* 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<BacklogState>({ 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
|
||||
|
||||
Reference in New Issue
Block a user