174 lines
17 KiB
Markdown
174 lines
17 KiB
Markdown
# Quick wins: sync chip + cost guard + reconnect digest + recent commits
|
||
|
||
Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established `/projects/*` / `/hook/*` patterns. Read-only routes get no Origin guard (same threat model as `/projects` — `src/server.ts:281`); the cost guard rides the already-injected `NotifyService` DI seam and the `stuckNotified` one-shot-latch pattern (`src/session/manager.ts:271-287`).
|
||
|
||
Shared coordination edit up front: **`src/types.ts`** gains `ProjectInfo.ahead/behind/lastCommitMs` (§a), `NotifyClass` `'budget'` + `Session.budgetNotified` + `Config.costBudgetUsd` + `UiConfig.costBudgetUsd` (§b), `DigestResult`/`DigestSession` (§c), `CommitLogEntry`/`GitLogResult` (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
|
||
|
||
---
|
||
|
||
## Contract
|
||
|
||
### (a) Sync chip — no new route
|
||
Folded into the existing `GET /projects` payload. `ProjectInfo` gains three optional fields, populated in the cached per-repo metadata pass:
|
||
|
||
```ts
|
||
// src/types.ts — extend ProjectInfo (currently line 279-287)
|
||
export interface ProjectInfo {
|
||
name: string
|
||
path: string
|
||
isGit: boolean
|
||
branch?: string
|
||
dirty?: boolean
|
||
lastActiveMs?: number
|
||
ahead?: number // commits on HEAD not on @{u} (git rev-list, right count)
|
||
behind?: number // commits on @{u} not on HEAD (git rev-list, left count)
|
||
lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
|
||
sessions: ProjectSessionRef[]
|
||
}
|
||
```
|
||
`@{u}` with no upstream / non-git → all three `undefined` (best-effort, never throws). No new env var: gated by the existing `projectDirtyCheck` (which already means "spend git subprocess time per repo").
|
||
|
||
### (b) Cost budget guard + push alert
|
||
- **New env** `COST_BUDGET_USD` (dollars, float ≥ 0; default `0` = disabled) → `Config.costBudgetUsd: number` (`src/types.ts` Config, alongside B2 `statuslineTtlMs` at line 65).
|
||
- **New `NotifyClass` member** `'budget'` (`src/types.ts:376`): `'needs-input' | 'done' | 'stuck' | 'budget'`.
|
||
- **New `Session` field** `budgetNotified: boolean` (`src/types.ts` Session, next to `stuckNotified` at line 227-228) — one-shot latch, **never re-armed** (cost is monotonic).
|
||
- **`GET /config/ui`** payload gains `costBudgetUsd?: number` so the FE can derive warn-styling client-side:
|
||
```ts
|
||
export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }
|
||
```
|
||
- **No new ServerMessage variant.** The "warning broadcast" is the *existing* `{type:'telemetry', telemetry}` frame (already broadcast on every statusLine, `manager.ts:260`); the warn is derived on the client (`costUsd >= costBudgetUsd`). The distinct new server action on threshold crossing is a single `notifyService.notify(session, 'budget')` push. (Rationale: keeps the frozen `ServerMessage` contract in `types.ts:109-120` untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not a `ClaudeStatus`.)
|
||
- `renderTelemetryGauge` (`public/preview-grid.ts:197`) gains a 4th param `costBudgetUsd?: number`; the cost chip (line 224-227) gets class `tg-cost-warn` when `telemetry.costUsd >= budget`, mirroring the ctx>80% path at line 219.
|
||
|
||
### (c) While-you-were-away reconnect digest
|
||
**New read-only route** `GET /digest?since=<epochMs>` (no Origin guard; same as `/live-sessions`). Aggregates `manager.list()`:
|
||
|
||
```ts
|
||
export interface DigestSession {
|
||
id: string
|
||
title?: string // last cwd segment
|
||
status: ClaudeStatus
|
||
costUsd?: number // telemetry.costUsd
|
||
lastOutputAt?: number
|
||
finished: boolean // status==='idle' && lastOutputAt > since
|
||
needsInput: boolean // status==='waiting'
|
||
stuck: boolean // status==='stuck'
|
||
}
|
||
export interface DigestResult {
|
||
since: number
|
||
generatedAt: number
|
||
total: number
|
||
finished: number
|
||
needsInput: number
|
||
stuck: number
|
||
working: number
|
||
totalCostUsd: number
|
||
sessions: DigestSession[]
|
||
}
|
||
```
|
||
Response: `200` + `DigestResult` (empty aggregate when no sessions). `since` clamped to a finite non-negative number (bad/absent → `0`, i.e. "everything is new").
|
||
|
||
### (d) Recent-commits log per project
|
||
**New read-only route** `GET /projects/log?path=<abs>&n=<int>` (no Origin guard; guarded by `isValidGitDir`, `src/server.ts:123`, exactly like `/projects/diff` at line 661-678).
|
||
|
||
```ts
|
||
export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
|
||
export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
|
||
```
|
||
- `path` missing/empty → `400`; not a valid git dir → `404` (three-prong `isValidGitDir`); git failure → `500`.
|
||
- `n` parsed to int, clamped to `[1, GIT_LOG_MAX]` (const `50`; default `20`).
|
||
- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
|
||
```
|
||
git log --no-color -z -n <n> --format=%h%x1f%ct%x1f%s (cwd: repoPath, timeout, maxBuffer)
|
||
```
|
||
|
||
---
|
||
|
||
## Files to change
|
||
|
||
| Path | Change |
|
||
|---|---|
|
||
| `src/types.ts` | **Coordination edit.** Add `ProjectInfo.ahead/behind/lastCommitMs`; `NotifyClass` `'budget'`; `Session.budgetNotified`; `Config.costBudgetUsd`; `UiConfig.costBudgetUsd`; new `DigestResult`/`DigestSession`, `CommitLogEntry`/`GitLogResult`. |
|
||
| `src/config.ts` | Add `parseNonNegativeFloat(raw,label,fallback)` helper; parse `COST_BUDGET_USD`→`costBudgetUsd` (near B2 block line 365-369); add `costBudgetUsd` to the frozen object (spread block line 415-437). |
|
||
| `src/http/projects.ts` | Add `readSync(repoPath)` helper (2 execFile calls); extend `MakeProjectArgs` (line 121) + `makeProject` (line 129) with `ahead/behind/lastCommitMs`; call `readSync` in `runDiscovery`'s per-repo `mapWithConcurrency` (line 276-280), gated by `cfg.projectDirtyCheck`. |
|
||
| `src/http/git-log.ts` | **New.** `parseGitLog(stdout,max)` (pure) + `getGitLog(repoPath,{n,timeoutMs})` (async, execFile, no shell). Modelled on `src/http/diff.ts` / `readDirty` (`projects.ts:98`). |
|
||
| `src/http/digest.ts` | **New.** `buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult` — pure, injected list (mirrors `buildProjects` injection, `projects.ts:387`). |
|
||
| `src/session/manager.ts` | In `handleStatusLine` (line 256): after storing/broadcasting telemetry, run the budget-latch check → `notifyService?.notify(session,'budget')`. |
|
||
| `src/session/session.ts` | Init `budgetNotified: false` in `createSession` object literal (~line 138). **Do not** re-arm (leave line 150 as-is). |
|
||
| `src/server.ts` | Add `GET /projects/log` (after `/projects/diff`, line 678); add `GET /digest` (after `/live-sessions`, line 279); extend `GET /config/ui` (line 728-731) with `costBudgetUsd`. Import `getGitLog`, `buildDigest`. |
|
||
| `public/preview-grid.ts` | `renderTelemetryGauge` gains `costBudgetUsd?` param; add `tg-cost-warn` class on the cost chip (line 224-227). |
|
||
| `public/projects.ts` | `normalizeProject` (line 236): pass through numeric `ahead/behind/lastCommitMs`. `makeProjectCard` (line 454): add sync chip after branch chip. `renderProjectDetail` (line 709 area, git repos only): mount recent-commits section. |
|
||
| `public/git-log.ts` | **New.** `mountGitLog(container, repoPath)` — fetch `GET /projects/log`, render inert rows via `textContent` only. |
|
||
| `public/digest.ts` | **New.** `mountDigest(host)` — read `localStorage` last-seen, fetch `GET /digest?since=`, render dismissible banner, update last-seen. |
|
||
| `public/tabs.ts` | In `loadUiConfig` (line 248-261): also read `costBudgetUsd`; store on the instance; pass to `renderTelemetryGauge` call (line 1391). |
|
||
| `public/main.ts` | `mountDigest(...)` in the toolbar/app wiring block (~line 51-119). |
|
||
| `public/style.css` | Add `.tg-cost-warn`, `.proj-sync`, `.proj-commitlog` rows, `.wya-banner` (mirror `.tg-ctx-warn`, `.proj-branch`). |
|
||
|
||
---
|
||
|
||
## TDD steps (ordered)
|
||
|
||
Repo style: backend unit tests are **node** env (default), FE tests declare `// @vitest-environment jsdom` at the top (`test/projects-panel.test.ts:1`) and mock `@xterm/xterm`; route tests spin a real server on a free port (`test/integration/projects-endpoint.test.ts`). `test/manager.test.ts` uses a mock `NotifyService` recording `notify(session,cls,token)` (line 100-107) and a `parseSent(ws)` helper.
|
||
|
||
**0. Types (RED→GREEN, compile-only)**
|
||
- [ ] Edit `src/types.ts` with all shapes above. `npx tsc --noEmit` fails where callers/mocks lack new required fields → gives the worklist. `Session.budgetNotified` is required → `test/manager.test.ts` + `src/session/session.ts` must init it.
|
||
|
||
**(b) Cost guard — highest security value, do first**
|
||
- [ ] `test/config.test.ts`: add defaults block — `loadConfig({}).costBudgetUsd === 0`; override `COST_BUDGET_USD:'5.50'`→`5.5`; `throw` for `'abc'` and `'-1'` (mirror line 156-181). Implement `parseNonNegativeFloat` + wire in `src/config.ts`.
|
||
- [ ] `test/manager.test.ts` (extend `describe('handleStatusLine')` line 803): with `cfg.costBudgetUsd=1`, feed telemetry `costUsd=0.5` → `notify` **not** called, `session.budgetNotified===false`; feed `costUsd=1.2` → `notify` called once with `(s,'budget')`, latch `true`; feed `costUsd=2` again → **not** called again. With `costBudgetUsd=0` → never called. Implement the latch in `manager.handleStatusLine`.
|
||
- [ ] `test/session.test.ts`: assert `createSession(...).budgetNotified === false`. Implement init in `session.ts`.
|
||
- [ ] `test/preview-grid.test.ts` (jsdom): `renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)` → cost chip has class `tg-cost-warn`; with budget `0`/`undefined` or `costUsd<budget` → no warn class. Implement param.
|
||
- [ ] Route: extend `test/integration` (or `test/http`) — `GET /config/ui` returns `costBudgetUsd`. Implement in `server.ts:728`.
|
||
|
||
**(d) Recent commits**
|
||
- [ ] `test/http/git-log.test.ts` (new): `parseGitLog` — NUL-record + US-field splitting; empty stdout→`[]`; malformed record (missing field) skipped; subject truncated at cap; `truncated` flag when records===max. (Pure, no spawn.)
|
||
- [ ] `test/http/git-log.test.ts`: `getGitLog` against a real temp repo made with `git init` + 3 commits (pattern from `test/http/diff.test.ts` / worktrees test) → 3 entries newest-first, `n=2`→2 + `truncated:true`.
|
||
- [ ] `test/integration/projects-log-endpoint.test.ts` (new, model on `projects-endpoint.test.ts`): `GET /projects/log?path=<repo>` → 200 array; missing `path`→400; non-git temp dir→404; `?n=999`→clamped. Implement route in `server.ts`.
|
||
- [ ] FE `test/git-log.test.ts` (jsdom): `mountGitLog` renders rows via `textContent`; a commit subject containing `<img onerror>` appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implement `public/git-log.ts` + detail-section wiring in `public/projects.ts`.
|
||
|
||
**(a) Sync chip**
|
||
- [ ] `test/projects.test.ts` (extend, node): real temp repo (uses `execFileP` already imported line ~20) with an upstream branch ahead/behind → `buildProjects` yields `ahead`/`behind`/`lastCommitMs`; repo with **no** upstream → those `undefined`, no throw; `projectDirtyCheck:false` → sync skipped (undefined). Implement `readSync` + fold into `runDiscovery`.
|
||
- [ ] `test/projects-panel.test.ts` (jsdom): `normalizeProject` passes numeric `ahead/behind/lastCommitMs`, drops non-numbers; `makeProjectCard` renders `↑2 ↓1` chip when set, omits when `undefined`/`0`. Implement FE.
|
||
|
||
**(c) Reconnect digest**
|
||
- [ ] `test/http/digest.test.ts` (new, node): `buildDigest([...LiveSessionInfo], since)` — counts finished (`idle` & `lastOutputAt>since`), needsInput (`waiting`), stuck, working; `totalCostUsd` sums `telemetry.costUsd`; empty list → zeroes; `since` in the future → 0 finished.
|
||
- [ ] `test/integration/digest-endpoint.test.ts` (new): server up, `GET /digest?since=0` → 200 `DigestResult`; malformed `since` → treated as 0. Implement route.
|
||
- [ ] FE `test/digest.test.ts` (jsdom): `mountDigest` fetches with the stored last-seen; renders banner only when `finished+needsInput+stuck>0`; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implement `public/digest.ts` + `main.ts` mount.
|
||
|
||
**Coverage:** every new pure function (`parseGitLog`, `buildDigest`, `readSync` via `buildProjects`, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin `server.ts` wiring is exercised by the integration tests.
|
||
|
||
---
|
||
|
||
## Edge cases & failure modes
|
||
|
||
- **(a)** No upstream (`@{u}` fatal) → catch → `ahead/behind` undefined; chip hidden. Detached HEAD → `git log -1 --format=%ct` still works (lastCommitMs set), `@{u}` fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuse `GIT_STATUS_TIMEOUT_MS`, `projects.ts:36`). Adds ≤2 spawns/repo bounded by `GIT_CONCURRENCY=8`; gated off entirely when `projectDirtyCheck=false`. `ahead=behind=0` (in sync) → chip omitted (only render when >0).
|
||
- **(b)** `costUsd` undefined in a telemetry frame → skip guard (no crossing). Budget `0` → disabled. Latch persists across statusLine frames; a **new** session gets its own latch (per-`Session`). DND/`notifyDone` interplay: `'budget'` is neither `'done'` nor gated, so `shouldSend` sends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry via `manager.ts:139-140`, derives warn from `/config/ui` budget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck).
|
||
- **(c)** `since` absent/NaN/negative → `0`. No sessions → all-zero `DigestResult` (banner suppressed client-side). Clock skew / `lastOutputAt` in future → still counted as finished if `idle` (acceptable; coarse "what happened" view). First-ever visit (no localStorage) → `since=0`, banner may list everything → set last-seen to `generatedAt` after first render so it doesn't re-nag.
|
||
- **(d)** Binary/huge subjects: `maxBuffer` cap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits → `[]`. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace. `n` non-numeric → clamp to default.
|
||
|
||
---
|
||
|
||
## Security
|
||
|
||
- **Path containment:** `/projects/log` reuses `isValidGitDir` (`server.ts:123`) — absolute + isDirectory + has `.git` (SEC-H7 three-prong), identical to `/projects/diff`. `readSync` runs only against paths already discovered by the bounded BFS scan (`scanRepos`, symlink/dotdir/`node_modules`-skipping, `projects.ts:148`).
|
||
- **No shell, ever:** all git calls use `execFile('git', [...])` with `timeout` + `maxBuffer` (mirrors `readDirty` line 100). `repoPath` is passed as `cwd`, never interpolated into argv. `n` is coerced to an int and clamped before reaching argv.
|
||
- **Output is untrusted:** commit subjects and digest labels rendered via `textContent` only (SEC-H5, as in `preview-grid.ts` and `projects.ts` worktree/CLAUDE.md rendering) — zero `innerHTML`. FE `normalizeProject`-style narrowing for the new numeric fields (drop non-numbers) and for `/digest`/`/projects/log` responses (never trust the API shape).
|
||
- **Origin/CSRF:** all four routes are **read-only GETs** → no Origin guard, consistent with `/projects`, `/live-sessions`, `/projects/diff`. `GET /config/ui` stays read-only. No state-changing surface is added, so no new `requireAllowedOrigin` / rate-limit needed. (The budget **push** goes out the existing `pushService` seam — no new inbound route.)
|
||
- **Secrets:** cost budget is a non-secret number; `costBudgetUsd` is safe to expose in `/config/ui`. Push payload for `'budget'` carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existing `buildPayload` (`push-service.ts:88`) — SEC-C5 byte-shuttle boundary preserved.
|
||
- **DoS:** sync adds bounded spawns (concurrency 8, 2s timeout, gated by `projectDirtyCheck`); `/projects/log` `n` clamped ≤50; `/digest` is O(sessions) over an already-capped table.
|
||
|
||
---
|
||
|
||
## Effort & dependencies
|
||
|
||
**~3–4 dev-days total** (four independent slices; can be parceled to parallel builders after the shared `src/types.ts` edit lands):
|
||
|
||
| Sub | Effort | Notes |
|
||
|---|---|---|
|
||
| (a) sync chip | ~0.5–0.75d | Backend `readSync` + fold-in + 2 FE renders. |
|
||
| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, `/config/ui` + tabs.ts wiring. Highest test surface. |
|
||
| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
|
||
| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
|
||
|
||
**Dependencies (into these):** all four build only on already-shipped infra — `buildProjects` cache pass (v0.6), `NotifyService` DI + `stuckNotified` latch (A5/A1), `renderTelemetryGauge` (B2), `isValidGitDir` + `getDiff` pattern (B1), `/config/ui` seam (review #4). No dependency on other roadmap items.
|
||
|
||
**Unlocks / synergy:** (d)'s `getGitLog` + NUL-parsing helper and (a)'s upstream detection are reusable by **#9 "diff against a base branch"** (upstream/`@{u}` resolution) and **#10 "PR + CI status chip"** (a per-repo git/`gh` metadata pass can fold into the same `runDiscovery` concurrency slot as the sync chip). (c)'s `buildDigest` gives **#8 idle-queued follow-up** a ready read-side "which sessions are idle/waiting" aggregate. `NotifyClass 'budget'` establishes the pattern for any future threshold alerts. |