Files
web-terminal/docs/plans/w3-quick-wins.md

17 KiB
Raw Permalink Blame History

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 /projectssrc/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:

// 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:
    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():

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).

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_USDcostBudgetUsd (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.5notify not called, session.budgetNotified===false; feed costUsd=1.2notify 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

~34 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.50.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.