perf+persistence: the durable reconcile mirror (cache + disk) #47
@@ -126,6 +126,36 @@ 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() }
|
||||||
|
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 {
|
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
|
||||||
@@ -134,7 +164,8 @@ export function registerGiteaIpc(): void {
|
|||||||
|
|
||||||
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)
|
// explicit UI sync — force fresh, and warm the cache for agent tool calls
|
||||||
|
const snap = await getSnapshot(client, { maxAgeMs: 0 })
|
||||||
return { configured: true, ...snap }
|
return { configured: true, ...snap }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,6 +198,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 +215,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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user