diff --git a/bin/cli.js b/bin/cli.js index 0d36b99..ec1c572 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -16,7 +16,31 @@ if (process.argv[1] && process.argv[1].includes('codedash') && !process.argv[1]. } const { loadSessions, searchFullText, getSessionPreview, computeSessionCost } = require('../src/data'); -const { startServer } = require('../src/server'); +const { startServer, getKnownGitRoots } = require('../src/server'); +const { repoRefreshManager } = require('../src/repo-refresh'); + +function bootRepoRefresh() { + // Wire the known-roots gate so initOnStartup can't fetch arbitrary paths + // injected into the settings file by another process. + let gateWired = false; + try { + repoRefreshManager.setKnownGitRootsProvider(getKnownGitRoots); + gateWired = true; + } catch (err) { + console.error('[repo-refresh] failed to wire known-roots provider:', err.message); + } + // If the gate is not wired we MUST NOT run initOnStartup — it would fetch + // arbitrary paths from the settings file without validation. + if (!gateWired) { + console.error('[repo-refresh] skipping initOnStartup because gate is not wired'); + return; + } + // Fire-and-forget — initOnStartup never blocks; errors land in per-repo state. + process.nextTick(() => { + try { repoRefreshManager.initOnStartup(); } + catch (err) { console.error('[repo-refresh] init failed:', err.message); } + }); +} const { exportArchive, importArchive } = require('../src/migrate'); const { convertSession } = require('../src/convert'); const { generateHandoff, quickHandoff } = require('../src/handoff'); @@ -66,6 +90,7 @@ switch (command) { const host = hostArg ? hostArg.split('=')[1] : (process.env.CODEDASH_HOST || DEFAULT_HOST); const noBrowser = args.includes('--no-browser'); startServer(host, port, !noBrowser); + bootRepoRefresh(); break; } @@ -321,6 +346,7 @@ switch (command) { console.log(' Starting...\n'); const noBrowser = args.includes('--no-browser'); startServer(host, port, !noBrowser); + bootRepoRefresh(); }, 500); break; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1d70008..30d14d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -360,6 +360,113 @@ GET /api/changelog Changelog entries GET /api/terminals Available terminal apps ``` +### Repo Auto-Refresh +``` +GET /api/repo-refresh/state Per-repo state + current settings +POST /api/repo-refresh/trigger Start `git fetch --all --prune` for one repo +POST /api/repo-refresh/wait Long-poll until a fetch finishes (or timeoutMs, default 2000, max 10000) +GET /api/repo-refresh/settings Read settings +POST /api/repo-refresh/settings Update settings (partial; merged + atomically persisted) +``` + +--- + +## Repo Auto-Refresh + +Keeps local clones of connected repositories in sync with their remote so the +LLM in a fresh session sees current `origin/` and doesn't drift into +branch divergence. The work runs in the background, never touches the working +tree, and never blocks the HTTP server. + +### Triggers (v1) + +1. **Manual** — click the `↻` button on a project card. +2. **New chat** — when a project has its "Auto-refresh on new chat" toggle on, + `launchNewProjectSession` / `resumeLastProjectSession` issue a fetch and + wait up to 2 s before opening the terminal session. +3. **Service start** — when the global "Refresh on startup" toggle is on, + `bin/cli.js` calls `repoRefreshManager.initOnStartup()` after the HTTP + server has bound. + +Deferred to a future PR: periodic scheduler (5/10/15/30/60 min), page-refresh +trigger, and "behind by N commits" indicators. + +### Per-repo state machine + +``` + ┌───────────┐ + │ idle │ (lastSuccessAt: null | epoch) + └─────┬─────┘ + │ trigger() + ▼ + ┌───────────┐ + │ fetching │ (startedAt: epoch; single-flight per gitRoot) + └─┬───────┬─┘ + ok │ │ error / 60 s timeout + ▼ ▼ + ┌───────┐ ┌────────┐ + │ idle │ │ error │ (lastError truncated to ≤200 chars) + └───────┘ └────────┘ +``` + +### Backend (`src/repo-refresh.js`) + +- Singleton manager built via `createRepoRefreshManager(opts)` — opts allow + test-time DI of `execFile`, timers, `atomicWriteJson`, `resolveGitRoot`, + `existsSync`, and `logger`. +- **Single-flight** per `gitRoot` through an `inflight` Map — concurrent + triggers return the existing promise; no second child process is spawned. +- **Concurrency cap** = 4 parallel `git fetch` processes; the 5th waits in a + FIFO queue. Sync fast path when capacity is available so the child process + starts before `triggerRefresh()` returns. +- **Timeout** = 60 s. On expiry the manager sends `SIGTERM`, waits a 2 s + grace, then sends `SIGKILL`. State transitions to `error` with + `lastError = "timeout after 60000ms"`. +- **Settings** live at `~/.codedash/refresh-settings.json`. Loaded on + construction; saved through `atomicWriteJson` with a 500 ms debounce so + rapid toggle clicks coalesce into one disk write. +- **Orphan GC** — on every `initOnStartup`, perProject keys with + `!existsSync(gitRoot) || resolveGitRoot(gitRoot) === ''` are dropped and the + cleaned settings are persisted. + +### HTTP routes (`src/repo-refresh-routes.js`) + +Pure dispatcher function `handleRepoRefreshRoute(req, res, deps)` that returns +`true` if it handled the request — mounted in `src/server.js` before the +Sessions API. Validation rejects: + +- `POST /trigger` / `POST /settings` referencing a `gitRoot` not in the known + set (`loadProjects()` ∪ `loadSessions().git_root`, cached 5 s) → 404 / 400. +- `POST /settings` body with the wrong shape → 400 `invalid_payload`. +- `POST /trigger` body > 1 MiB → 400 `invalid_payload`. +- `POST /wait timeoutMs` clamped to `[0, 10000]`. + +### Frontend (`src/frontend/app.js`) + +- Module-level `repoRefreshState` mirrors what the backend serves. +- `loadRepoRefreshState()` runs once at init and after manual triggers; a + `setInterval(2000)` polls only while at least one visible repo is in + `fetching` and the user is on the Projects view. +- Project card markup uses `data-rr-badge=""` and + `data-rr-toggle=""` attributes so `refreshRepoRefreshUI()` can + update badges and toggles in place without re-rendering the whole view + (preserves scroll position and group collapse state). +- Toggle clicks are **optimistic**: the visual flips immediately, the POST + fires, and a failure rolls the visual back with a toast. +- `maybeRefreshBeforeLaunch(gitRoot)` issues `/trigger` + `/wait` (timeoutMs: + 2000) before invoking `/api/launch`. If the wait times out the session + opens with whatever refs are currently on disk; if the fetch errors the + user sees a toast but the launch proceeds. + +### Atomic JSON writes (`src/atomic.js`) + +`atomicWriteJson(filePath, obj)` is the canonical write helper for every +codbash JSON cache. Steps: ensure parent dir, write to `.tmp`, fsync, +rename. On rename failure the temp file is unlinked and the original target +is left untouched. The legacy disk caches (`_saveParsedDiskCache`, +`_saveGitRootDiskCache`, `_saveCostDiskCache`, `_saveDailyStatsDiskCache` in +`src/data.js`) all flow through this helper. + --- ## Contributing diff --git a/docs/design/repo-auto-refresh.md b/docs/design/repo-auto-refresh.md new file mode 100644 index 0000000..7bbdb20 --- /dev/null +++ b/docs/design/repo-auto-refresh.md @@ -0,0 +1,308 @@ +# Repo Auto-Refresh (v1) + +## Goal + +Keep local clones of connected repositories in sync with their remotes so that when a session starts, the LLM works against current history and doesn't drift into branch divergence. Done in the background, without blocking the UI, without touching the working tree. + +## Context + +Codbash shows "projects" = git roots bound to a remote. Sessions are created by Claude/Codex/etc. inside these repos. When the remote moves forward (PR merged on GitHub), the local clone doesn't know until the user runs `git fetch` manually. The result: +- A new session sees a stale `git log` / `origin/main`. +- Branches created from `origin/main` start from an outdated base. +- Continuing an old session can produce commits on top of stale state. + +## In scope (v1 — minimum useful core) + +1. **Per-project toggle** "Auto-refresh on new chat" in the Projects view. +2. **Manual "Refresh" button** on the project card — fetch now. +3. **Global toggle** "Refresh enabled repos on service start" (default off). +4. Background worker on the backend running `git fetch --all --prune`. +5. **Triggers**: + - Manual refresh button click → fetch this repo. + - New-chat click on a project with the toggle on → fetch + wait up to 2s before opening the session. + - Service start → fetch all enabled repos if the global toggle is on. +6. **Status indicator** on the card: idle / fetching (spinner) / error (red dot + tooltip) / last-success timestamp. + +## Out of scope (deferred to v2) + +- Periodic scheduler (5/10/15/30/60 min). +- Page-refresh trigger from the browser. +- Full settings modal with extended options. +- Notifications when `origin/` has moved ahead. +- Discovery of repos that have no existing sessions — a project shows up only after the first session in it. + +## Never in scope + +- `git pull`, `merge`, `rebase` — fetch only. Working tree is never touched. +- `push` to the remote. +- Branch management. +- Authentication — private repos via SSH agent already work; we don't add new credential flows. + +## Decisions + +| # | Decision | Why | +|---|----------|-----| +| D1 | `git fetch --all --prune` | Safe — doesn't touch the working tree. User decides when to merge. | +| D2 | Persistence: backend file `~/.codedash/refresh-settings.json` only (atomic write). Frontend reads via API on every load. | Single source of truth, no sync logic. Cost: one extra API call on page load. | +| D3 | New-chat trigger: wait for fetch up to 2 s (with UI indicator), then open the session | Half of the value is the LLM seeing fresh refs in its first turn. 2 s balances "fresh" vs "responsive". | +| D4 | Subtle inline spinner instead of modal-style warning banner | Fetch can't diverge the working copy (see R3). A banner would frighten the user without cause. | +| D5 | Max concurrency = 4 parallel fetches | Doesn't hammer the system, doesn't block the event loop. | +| D6 | Single-flight per gitRoot | A second trigger while a fetch is running returns the existing promise. Simple semantics. | +| D7 | Per-fetch timeout = 60 s | Covers slow remotes/networks. Kill subprocess on timeout → state=error. | +| D8 | Frontend polling = 2 s, only while at least one visible repo is `fetching` | Zero-dep, acceptable latency. Otherwise 0 requests. | +| D9 | `atomicWriteJson(path, obj)` shared helper. Used for new settings **and** retrofitted into existing disk caches (`codedash-gitroot-cache-v2.json` and other `_save*DiskCache` callers) | Closes the deferred MEDIUM from PR #212. Adds ~15 LoC to scope, saves a separate PR. | + +## Architecture + +### Backend + +#### New module `src/repo-refresh.js` + +``` +RepoRefreshManager (singleton) + state: Map + settings: RefreshSettings + inflight: Map> + semaphore: { running, queue, max: 4 } + + triggerRefresh(gitRoot): Promise + triggerAllEnabled(): Promise + waitForRefreshOrTimeout(gitRoot, timeoutMs): Promise + getState(): { repos, settings } + updateSettings(partial): RefreshSettings + loadSettings(): void + initOnStartup(): void +``` + +#### Per-repo state machine + +``` + ┌───────────┐ + │ idle │ (lastSuccessAt: null | epoch) + └─────┬─────┘ + │ trigger() + ▼ + ┌───────────┐ + │ fetching │ (startedAt: epoch; single-flight) + └─┬───────┬─┘ + ok │ │ error / timeout + ▼ ▼ + ┌───────┐ ┌────────┐ + │ idle │ │ error │ (lastError, lastErrorAt) + └───────┘ └────────┘ +``` + +#### Concurrency + +- `child_process.execFile` (async) with `timeout: 60_000`. +- Semaphore: max 4 in flight. The 5th call queues. +- Single-flight: if `inflight.has(gitRoot)`, return the existing promise. +- The main thread is never blocked — fetches run in a child process via libuv. + +#### Shared helper in `src/atomic.js` (new) + +``` +atomicWriteJson(filePath, obj): void + // Write to .tmp, fsync, rename to . Throws on failure. +``` + +Used by `_saveGitRootDiskCache`, any other `_save*DiskCache` callers, and the new `RepoRefreshManager.saveSettings`. + +### Frontend (`src/frontend/app.js` + `styles.css`) + +- On each project card: + - Inline spinner badge when `status === 'fetching'`. + - Red dot + tooltip when `status === 'error'`. + - Subtle check-mark + relative time when `lastSuccessAt` is set. + - "↻ Refresh" button (visible on hover, focusable). + - Per-project toggle "Auto-refresh on new chat". +- In the Projects view header: global toggle "Refresh on startup". +- Polling: `setInterval(2000)` while any visible repo is `fetching`. Cleared otherwise. +- New-chat click handler: + ``` + if (project.autoRefresh) { + showInlineSpinner(project) + await fetch('/api/repo-refresh/trigger', { gitRoot }) + await waitForIdleOrTimeout(gitRoot, 2000) + hideInlineSpinner(project) + } + openSession(...) + ``` + +### Persistence + +`~/.codedash/refresh-settings.json`: + +```json +{ + "version": 1, + "refreshOnStartup": false, + "perProject": { + "/Users/pavelnovak/code/codbash": { "autoRefreshOnNewChat": true }, + "/Users/pavelnovak/code/Flow-Universe": { "autoRefreshOnNewChat": false } + } +} +``` + +- Corrupt JSON → warning log, defaults (everything off). +- Writes go through `atomicWriteJson` (tmp + fsync + rename). +- Debounced 500 ms — settings change frequently when the user clicks toggles. + +## API contract + +### GET /api/repo-refresh/state + +```typescript +interface RepoState { + status: 'idle' | 'fetching' | 'error'; + startedAt: number | null; // epoch ms + lastSuccessAt: number | null; + lastError: string | null; // truncated message + lastErrorAt: number | null; +} + +interface StateResponse { + repos: Record; + settings: RefreshSettings; +} +``` + +### POST /api/repo-refresh/trigger + +```typescript +// request +{ gitRoot: string } + +// response (immediate) +{ status: 'fetching' | 'idle' | 'error', state: RepoState } +``` + +Unknown `gitRoot` → 404. Already `fetching` → returns the current state without starting a second process. + +### POST /api/repo-refresh/wait + +```typescript +// request +{ gitRoot: string, timeoutMs?: number /* default 2000 */ } + +// response (returns when fetch finishes or timeout) +{ state: RepoState, timedOut: boolean } +``` + +Long-poll style. Convenient for the new-chat handler on the frontend. + +### GET /api/repo-refresh/settings, POST /api/repo-refresh/settings + +```typescript +interface RefreshSettings { + refreshOnStartup: boolean; + perProject: Record; +} +``` + +POST accepts a partial, merges, validates (every gitRoot must resolve via `resolveGitRoot`), saves. + +### Error format + +```typescript +{ error: string, code?: 'not_found' | 'invalid_payload' | 'git_unavailable' } +``` + +## Component map + +| File | Change | +|------|--------| +| `src/repo-refresh.js` | **new** — manager + fetch worker + settings I/O | +| `src/atomic.js` | **new** — `atomicWriteJson` helper | +| `src/data.js` | replace direct `writeFileSync` in `_saveGitRootDiskCache` (and any other `_save*DiskCache`) with `atomicWriteJson` | +| `src/server.js` | +4 routes under `/api/repo-refresh/*` | +| `bin/cli.js` | call `RepoRefreshManager.initOnStartup()` after the server boots | +| `src/frontend/app.js` | UI: toggle, spinner, refresh button, polling, new-chat hook | +| `src/frontend/styles.css` | status styles | +| `docs/ARCHITECTURE.md` | new section "Repo Auto-Refresh" | + +## Touchpoints with existing code + +- `resolveGitRoot(projectPath)` (from PR #212) → Map key. +- `getProjectGitInfo` → remote URL for the UI. +- New-chat button — wrap `POST /api/launch` in a helper `maybeRefreshBeforeLaunch(gitRoot)`. + +## Risks + +| # | Risk | Mitigation | +|---|------|------------| +| R1 | Slow fetch (slow remote, large repo) | 60 s timeout, max-concurrency 4, fire-and-forget in UI with a 2 s wait for new-chat | +| R2 | SSH agent not running | state=error, badge with tooltip, user sees what's wrong | +| R3 | Divergence during active fetch + concurrent user work | `fetch` doesn't touch the working tree → impossible. Divergence only happens on a subsequent `merge`/`rebase` — that's the user's responsibility. | +| R4 | Memory leak in `inflight` Map | `try/finally` always clears the entry | +| R5 | `atomicWriteJson` breaks existing cache files during retrofit | Tests on rename semantics + smoke test that the cache is readable after write/restart | +| R6 | 2 s wait on new-chat feels slow | Spinner + "Updating…" text gives visible feedback. If fetch completes in <100 ms, the session opens immediately. | + +## UX & Accessibility + +**Target WCAG level**: AA. + +**Affected surfaces**: +1. Projects view — project card with status indicator, refresh button, per-project toggle. +2. Projects view header — global "Refresh on startup" toggle. + +### Required UI states (per project card) + +- [x] **Loading** — until the first `GET /state` returns: toggle disabled+dim, refresh button hidden. +- [x] **Empty** — N/A (a card appears only when a git root + sessions exist). +- [x] **Error** — `status === 'error'`. Red dot badge, tooltip `Last fetch failed: `. "Retry" button (= same `/trigger` endpoint). +- [x] **Success/Confirmation** — `lastSuccessAt` set, `status === 'idle'`. Subtle check-mark with relative time `Updated 2 min ago`. No toast — we don't distract. +- [x] **Disabled** — toggle off. No status badges, clean card. +- [x] **Partial/Stale** — N/A in v1 (no scheduler → no "expected interval" concept). +- [x] **Optimistic/Pending** — clicking a toggle flips the visual immediately; rollback if POST fails + toast "Failed to save setting". + +### Inline spinner while fetching + +- `role="status"` + `aria-live="polite"` — screen reader announces `Fetching ` → `Updated`. +- Visible spinner + "Updating…" text near the name. +- Doesn't block card clicks; the session can still be opened. + +### Keyboard + +- Per-project toggle: Tab → focus → Space toggles. `aria-checked`. +- Refresh button: Tab → focus → Enter triggers. `aria-label="Refresh "`. +- Visible focus ring on every control (never strip `outline:none` without a replacement). +- Header global toggle — same behavior. + +### Screen reader + +- Toggle: `` paired with a `