From 9370c04ac52a4b9d04e7bd662580cc6684024dae Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Sun, 12 Jul 2026 19:55:45 -0400 Subject: [PATCH] Wire the offline write-queue into the desktop write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline write-queue (packages/core/src/queue/write-queue-v0.ts, #33) was a tested pure module that nothing imported. Approving a change while gitea was unreachable made gitea:applyChange call the client directly and throw, losing the write. Now it's wired end to end: - queue-store.ts persists the queue next to the snapshot store (same degrade-to-empty-on-corruption discipline; injectable path for tests). - applyChange extracts the guarded write into applyChangeLive and, on unreachability (any error that is NOT a GiteaApiError rejection), enqueues the intent — coalesced by (issue, axis) — instead of throwing, returning { ok, queued, pending }. A genuine GiteaApiError still surfaces (a doomed write must not replay forever). - reconcile drains the queue once a successful read proves gitea is reachable, re-reading so the board reflects the replays; replays are idempotent (label plan.noop, assignee/milestone re-set). boot + stale reconcile report the pending count so the badge shows immediately offline. - Renderer: use-backlog threads `pending`; the OfflineBanner shows "N queued"; the chat approve message distinguishes a queued (offline) approval from an applied one. Also wires vitest into the desktop workspace (was missing, so the main-process suite couldn't run via `yarn test`) and fixes a stale Capture copy assertion left by the earlier posh-copy pass. Tests: queue-store.test.ts (persist/reload/coalesce/replay-drain, real core fns); 9 main-process + 169 core green; 12 demo e2e green. A one-off GITEA_LIVE smoke verified an online write lands+reverts and an offline approve queues+drains against the real repo (not committed, per the repo's no-mutating-test convention). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/smoke.spec.ts | 2 +- apps/desktop/package.json | 3 +- apps/desktop/src/main/gitea.ts | 106 ++++++++++++++---- apps/desktop/src/main/queue-store.test.ts | 87 ++++++++++++++ apps/desktop/src/main/queue-store.ts | 39 +++++++ .../src/components/shell/app-shell.tsx | 9 +- .../renderer/src/components/shell/states.tsx | 11 +- apps/desktop/src/renderer/src/global.d.ts | 6 +- .../src/renderer/src/lib/use-backlog.ts | 4 + apps/desktop/src/renderer/src/lib/use-chat.ts | 8 +- yarn.lock | 1 + 11 files changed, 239 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/main/queue-store.test.ts create mode 100644 apps/desktop/src/main/queue-store.ts diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 896f029..c1213e3 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -66,7 +66,7 @@ test('Inbox renders', async ({ app, window }) => { test('Capture: braindump prompt', async ({ app, window }) => { await app.nav('Capture').click() - await expect(window.getByText("Tell me what you're planning")).toBeVisible() + await expect(window.getByText('Do tell me what you are planning')).toBeVisible() await app.screenshot('capture') }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a484f91..ec88038 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,6 +37,7 @@ "tailwindcss": "^3.4.17", "tsx": "^4", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.0.5" } } diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index f66216c..47d4466 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -15,8 +15,10 @@ import { applySchemaLabels, createGiteaClient, discoverRepos, + enqueueWrite, ensurePmStateRepo, GiteaApiError, + replayQueue, type DirectiveEntry, type GiteaClient, type GiteaConfig, @@ -46,6 +48,7 @@ import { const isDemo = (): boolean => process.env.COMMITEA_E2E === '1' import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js' +import { loadQueue, saveQueue } from './queue-store.js' import { loadSnapshot, saveSnapshot } from './snapshot-store.js' /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ @@ -241,6 +244,59 @@ async function resolveLabelIds(client: GiteaClient, names: string[]): Promise byName.get(n)).filter((id): id is number => id != null) } +/** The result of a live write — a label swap returns its plan, a field write the fresh issue. */ +type LiveApplyResult = { ok: true; plan?: ReturnType; issue: Awaited> } + +/** + * Apply one approved change against gitea (the guarded write path, used both + * online and when draining the offline queue). Label swaps (est/p) read → plan → + * set; field writes (assign, milestone) write directly and return the fresh + * issue. A replay is idempotent — a change already reflected server-side no-ops + * (label plan.noop; assignee/milestone re-set to the same value). Throws on a + * network failure (queued upstream) or a GiteaApiError (surfaced, never queued). + */ +async function applyChangeLive(client: GiteaClient, change: IssueChange): Promise { + if (isLabelChange(change)) { + const current = await client.getIssue(change.issue) + const plan = planIssueChange(current.labels, change) + if (plan.noop) return { ok: true, plan, issue: current } + const ids = await resolveLabelIds(client, plan.labels) + await client.setIssueLabels(change.issue, ids) + const issue = await client.getIssue(change.issue) + return { ok: true, plan, issue } + } + const issue = + change.kind === 'assign' + ? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : []) + : await client.setIssueMilestone(change.issue, change.milestone) + return { ok: true, issue } +} + +/** Queue a write made while offline, coalescing by (issue, axis). Returns the new pending count. */ +function queueOffline(change: IssueChange): number { + const next = enqueueWrite(loadQueue(), change, new Date().toISOString()) + saveQueue(next) + return next.length +} + +/** How many writes are waiting to replay (shown as a queued badge). */ +function pendingCount(): number { + return loadQueue().length +} + +/** + * Replay the offline queue against gitea once we know it's reachable. Drained + * writes are removed; any that still fail stay queued. Returns how many drained + * (so the caller can invalidate + re-read the snapshot) and what remains. + */ +async function drainQueue(client: GiteaClient): Promise<{ drainedCount: number; remainingCount: number }> { + const queue = loadQueue() + if (queue.length === 0) return { drainedCount: 0, remainingCount: 0 } + const { drained, remaining } = await replayQueue(queue, (change) => applyChangeLive(client, change)) + saveQueue(remaining) + return { drainedCount: drained.length, remainingCount: remaining.length } +} + export function registerGiteaIpc(): void { ipcMain.handle('gitea:status', () => { const cfg = resolveConfig() @@ -253,24 +309,33 @@ export function registerGiteaIpc(): void { // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands. ipcMain.handle('gitea:boot', () => { - if (isDemo()) return { configured: true, cached: true, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY } + if (isDemo()) return { configured: true, cached: true, pending: 0, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY } if (!getGiteaClient()) return { configured: false } const persisted = bootSnapshot() - return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false } + return persisted + ? { configured: true, cached: true, pending: pendingCount(), ...persisted } + : { configured: true, cached: false } }) ipcMain.handle('gitea:reconcile', async () => { - if (isDemo()) return { configured: true, stale: false, ...DEMO_SNAPSHOT } + if (isDemo()) return { configured: true, stale: false, pending: 0, ...DEMO_SNAPSHOT } const client = getGiteaClient() if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } 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 } + let snap = await getSnapshot(client, { maxAgeMs: 0 }) + // A successful reconcile proves gitea is reachable: drain any writes queued + // while offline. If any drained, re-read so the board reflects the replays. + const { drainedCount, remainingCount } = await drainQueue(client) + if (drainedCount > 0) { + invalidateSnapshot() + snap = await getSnapshot(client, { maxAgeMs: 0 }) + } + return { configured: true, stale: false, pending: remainingCount, ...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 } + if (persisted) return { configured: true, stale: true, pending: pendingCount(), ...persisted } throw e } }) @@ -288,25 +353,18 @@ export function registerGiteaIpc(): void { ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { const client = getGiteaClient() if (!client) return { ok: false as const, reason: 'unconfigured' as const } - - if (isLabelChange(change)) { - const current = await client.getIssue(change.issue) - const plan = planIssueChange(current.labels, change) - if (plan.noop) return { ok: true as const, plan, issue: current } - const ids = await resolveLabelIds(client, plan.labels) - await client.setIssueLabels(change.issue, ids) - const issue = await client.getIssue(change.issue) - invalidateSnapshot() - return { ok: true as const, plan, issue } + try { + const result = await applyChangeLive(client, change) + invalidateSnapshot() // the board + forecast must reflect the write + return result + } catch (e) { + // A GiteaApiError is a genuine rejection (bad label, gone issue) — surface + // it; queueing would only replay a doomed write forever. Anything else is + // unreachability: queue the intent so it replays on reconnect (#33). + if (e instanceof GiteaApiError) throw e + const pending = queueOffline(change) + return { ok: true as const, queued: true as const, pending } } - - // Field writes — the client returns the updated issue. - const issue = - change.kind === 'assign' - ? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : []) - : await client.setIssueMilestone(change.issue, change.milestone) - invalidateSnapshot() - return { ok: true as const, issue } }) // capture_work filing: open each approved issue with its est/* + p/* labels. diff --git a/apps/desktop/src/main/queue-store.test.ts b/apps/desktop/src/main/queue-store.test.ts new file mode 100644 index 0000000..449b48b --- /dev/null +++ b/apps/desktop/src/main/queue-store.test.ts @@ -0,0 +1,87 @@ +/** + * Durable offline write-queue store (#33). The invariant mirrors the snapshot + * store's purity: the queue file is a convenience mirror, and a missing or corrupt + * file must degrade to "empty queue", never a crash — losing a queued write is + * bad, but crashing the whole write path is worse. These tests also exercise the + * store together with core's coalescing/replay so the round-trip an offline edit + * takes (enqueue → persist → reload → replay → drain) is covered end to end. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { enqueueWrite, replayQueue, type IssueChange } from '@commitea/core' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// electron can't be imported outside the Electron runtime; the store only needs +// app.getPath for its default path, which every test overrides with an injected path. +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) + +import { loadQueue, saveQueue } from './queue-store.js' + +const estChange = (issue: number, estimate = 'est/5d'): IssueChange => + ({ kind: 'reestimate', issue, estimate }) as IssueChange +const assignChange = (issue: number, assignee: string | null): IssueChange => + ({ kind: 'assign', issue, assignee }) as IssueChange + +describe('queue-store (#33)', () => { + let dir: string | null = null + const path = () => { + if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-queue-')) + return join(dir, 'commitea-write-queue.json') + } + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = null + }) + + it('round-trips a persisted queue', () => { + const p = path() + const queue = enqueueWrite([], estChange(1), '2026-07-11T00:00:00Z') + saveQueue(queue, p) + expect(loadQueue(p)).toEqual(queue) + }) + + it('a missing file degrades to an empty queue (never a crash)', () => { + // nothing written yet — load must return [] so the write path stays alive + expect(loadQueue(path())).toEqual([]) + }) + + it('a corrupt or wrong-shape file is treated as an empty queue', () => { + const p = path() + writeFileSync(p, '{ not json', 'utf8') + expect(loadQueue(p)).toEqual([]) + // valid JSON but not an array (drift) is also rejected + writeFileSync(p, JSON.stringify({ nope: true }), 'utf8') + expect(loadQueue(p)).toEqual([]) + }) + + it('persisted queue coalesces by (issue, axis) across saves — the later intent wins', () => { + const p = path() + // two estimates for the same issue, queued while offline: only the last survives + saveQueue(enqueueWrite(loadQueue(p), estChange(1, 'est/2d'), '2026-07-11T00:00:00Z'), p) + saveQueue(enqueueWrite(loadQueue(p), estChange(1, 'est/8d'), '2026-07-11T00:01:00Z'), p) + // a different axis on the same issue coexists + saveQueue(enqueueWrite(loadQueue(p), assignChange(1, 'ana'), '2026-07-11T00:02:00Z'), p) + + const queue = loadQueue(p) + expect(queue).toHaveLength(2) + expect(queue.map((w) => w.change)).toEqual([estChange(1, 'est/8d'), assignChange(1, 'ana')]) + }) + + it('reloads and replays the queue, draining what applies and keeping what fails', async () => { + const p = path() + saveQueue(enqueueWrite(loadQueue(p), estChange(1), '2026-07-11T00:00:00Z'), p) + saveQueue(enqueueWrite(loadQueue(p), assignChange(2, 'ana'), '2026-07-11T00:01:00Z'), p) + + // Simulate reconnect: replay through an apply that fails only for issue #2 + // (still unreachable / rejected). The successful write drains; the other stays. + const { drained, remaining } = await replayQueue(loadQueue(p), async (c) => + c.issue === 2 ? { ok: false } : { ok: true }, + ) + saveQueue(remaining, p) + + expect(drained.map((w) => w.change.issue)).toEqual([1]) + expect(loadQueue(p).map((w) => w.change.issue)).toEqual([2]) + }) +}) diff --git a/apps/desktop/src/main/queue-store.ts b/apps/desktop/src/main/queue-store.ts new file mode 100644 index 0000000..fb24829 --- /dev/null +++ b/apps/desktop/src/main/queue-store.ts @@ -0,0 +1,39 @@ +/** + * Durable write-queue store — persists propose-approved writes made while gitea + * is unreachable, so they survive a restart and replay on reconnect (#33). A + * plain JSON array of QueuedWrite, mirroring the snapshot store: the queue is + * tiny (one entry per (issue, axis)), so a file is plenty. Never throws — a + * missing or corrupt file degrades to "empty queue", never a crash. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import type { QueuedWrite } from '@commitea/core' +import { app } from 'electron' + +function queuePath(): string { + return join(app.getPath('userData'), 'commitea-write-queue.json') +} + +/** + * Load the persisted write-queue, or [] if absent/corrupt. `path` is injectable + * for tests; production uses the userData file. + */ +export function loadQueue(path: string = queuePath()): QueuedWrite[] { + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown + return Array.isArray(parsed) ? (parsed as QueuedWrite[]) : [] + } catch { + return [] // missing file, bad JSON, or drift — treat as empty + } +} + +/** Persist the write-queue. Best-effort — a write failure never breaks the caller. */ +export function saveQueue(queue: readonly QueuedWrite[], path: string = queuePath()): void { + try { + writeFileSync(path, JSON.stringify(queue), 'utf8') + } catch { + // disk full / permissions — the in-memory result of this call still holds + } +} diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index aba59b1..b268559 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -74,6 +74,7 @@ export function AppShell() { // cache) only when gitea is unreachable. No manual toggle — this is the truth. const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale) const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined + const pending = backlog.status === 'ready' ? backlog.pending : 0 const capacityMembers = useCapacity() const workers = capacityWorkers(capacityMembers) const boardColumns = @@ -182,10 +183,12 @@ export function AppShell() { // issue immediately, and re-reconcile so the board + forecast catch up. const applyChange = async (change: IssueChange) => { const res = await window.commitea.gitea.applyChange(change) - if (res.ok) { + if (res.ok && !res.queued) { setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur)) - refetchBacklog() } + // Re-sync either way: an applied write to refresh the board, a queued one to + // pick up the new pending count (the reconcile reports it while offline). + if (res.ok) refetchBacklog() return res } @@ -445,7 +448,7 @@ export function AppShell() { gap: 14, }} > - {offline ? : null} + {offline ? : null} {renderScreen()} diff --git a/apps/desktop/src/renderer/src/components/shell/states.tsx b/apps/desktop/src/renderer/src/components/shell/states.tsx index 63bf97d..bcf9e9a 100644 --- a/apps/desktop/src/renderer/src/components/shell/states.tsx +++ b/apps/desktop/src/renderer/src/components/shell/states.tsx @@ -55,10 +55,11 @@ export function EmptyState({ icon, title, line, action, onAction, compact }: Emp } export interface OfflineBannerProps { - retryIn?: string + /** Writes approved while offline, held for replay on reconnect. */ + queued?: number } -export function OfflineBanner({ retryIn }: OfflineBannerProps) { +export function OfflineBanner({ queued = 0 }: OfflineBannerProps) { return (
- Gitea isn’t answering. I’ll keep trying and say nothing more about it. + {queued > 0 + ? `Gitea isn’t answering. I’m holding ${queued} ${queued === 1 ? 'change' : 'changes'} and shall apply ${queued === 1 ? 'it' : 'them'} on reconnect.` + : 'Gitea isn’t answering. I’ll keep trying and say nothing more about it.'} - retry in {retryIn || '0:12'} · reads from cache + {queued > 0 ? `${queued} queued · reads from cache` : 'reads from cache'}
) diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 76d26a0..40aff34 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -17,8 +17,10 @@ import type { /** The result of a write through the bridge. */ export type ApplyChangeResult = | { ok: false; reason: 'unconfigured' } + // Offline: the write was queued for replay on reconnect. `pending` is the queue depth. + | { ok: true; queued: true; pending: number } // `plan` is present for label swaps (est/p); absent for field writes (assign, milestone). - | { ok: true; plan?: LabelPlan; issue: GiteaIssue } + | { ok: true; queued?: false; plan?: LabelPlan; issue: GiteaIssue } /** The result of filing captured issues. */ export type CreateIssuesResult = @@ -42,6 +44,8 @@ export interface SnapshotPayload { stale?: boolean /** ISO time the persisted snapshot was reconciled (present on cached reads). */ savedAt?: string + /** Writes queued while offline, awaiting replay on reconnect. */ + pending?: number } /** Boot payload — the persisted snapshot, or a marker that there's none yet. */ diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index 48419c8..a6858e7 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -16,6 +16,8 @@ export type BacklogState = stale: boolean /** ISO time the shown snapshot was reconciled, when stale. */ savedAt?: string + /** Writes queued while offline, awaiting replay on reconnect. */ + pending: number } /** @@ -50,6 +52,7 @@ export function useBacklog(): [BacklogState, () => void] { timelines: b.timelines, stale: true, savedAt: b.savedAt, + pending: b.pending ?? 0, }, ) }) @@ -70,6 +73,7 @@ export function useBacklog(): [BacklogState, () => void] { timelines: r.timelines, stale: r.stale ?? false, savedAt: r.savedAt, + pending: r.pending ?? 0, } : { status: 'unconfigured' }, ) diff --git a/apps/desktop/src/renderer/src/lib/use-chat.ts b/apps/desktop/src/renderer/src/lib/use-chat.ts index 6aa0dfb..9125bcb 100644 --- a/apps/desktop/src/renderer/src/lib/use-chat.ts +++ b/apps/desktop/src/renderer/src/lib/use-chat.ts @@ -141,9 +141,11 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b ...c, { from: 'agent', - text: res.ok - ? `Very good. #${p.change.issue}: ${p.summary}. The plan has been re-run.` - : `Regrettably that did not take; #${p.change.issue} remains unchanged.`, + text: !res.ok + ? `Regrettably that did not take; #${p.change.issue} remains unchanged.` + : 'queued' in res && res.queued + ? `We are offline, so I have set #${p.change.issue}: ${p.summary} aside; I shall apply it the moment gitea is within reach.` + : `Very good. #${p.change.issue}: ${p.summary}. The plan has been re-run.`, }, ]) }) diff --git a/yarn.lock b/yarn.lock index 9428d12..67014e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -264,6 +264,7 @@ __metadata: tsx: "npm:^4" typescript: "npm:^5.7.3" vite: "npm:^6.1.0" + vitest: "npm:^3.0.5" languageName: unknown linkType: soft