Compare commits
4 Commits
p4/directi
...
403aef9d16
| Author | SHA1 | Date | |
|---|---|---|---|
| 403aef9d16 | |||
|
|
2f6636684e | ||
|
|
fca36f9075 | ||
| aab7fd6eeb |
37
apps/desktop/e2e/live-persistence.spec.ts
Normal file
37
apps/desktop/e2e/live-persistence.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/desktop/src/main/snapshot-store.ts
Normal file
47
apps/desktop/src/main/snapshot-store.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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). */
|
||||||
|
|||||||
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: 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user