feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)

Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
Yaojia Wang
2026-07-12 21:27:20 +02:00
parent 7551f8a4b2
commit 1dd12b035a
30 changed files with 1911 additions and 8 deletions

114
test/http/digest.test.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* test/http/digest.test.ts (W3 quick-wins c) — buildDigest read-side aggregate.
*
* Pure over an injected LiveSessionInfo[] (mirrors buildProjects injection):
* counts finished (idle & lastOutputAt > since), needsInput (waiting), stuck,
* working; sums telemetry.costUsd; empty → zeroes; future `since` → 0 finished;
* bad `since` clamps to 0.
*/
import { describe, it, expect } from 'vitest'
import type { LiveSessionInfo, ClaudeStatus } from '../../src/types.js'
import { buildDigest, clampSince } from '../../src/http/digest.js'
function live(over: Partial<LiveSessionInfo> & { id: string; status: ClaudeStatus }): LiveSessionInfo {
return {
createdAt: 1000,
clientCount: 0,
exited: false,
cwd: null,
cols: 80,
rows: 24,
...over,
}
}
// ── clampSince ────────────────────────────────────────────────────────────────
describe('clampSince', () => {
it('passes through a finite non-negative number', () => {
expect(clampSince(1234)).toBe(1234)
expect(clampSince(0)).toBe(0)
})
it('clamps NaN / negative / non-numeric / undefined to 0', () => {
expect(clampSince(NaN)).toBe(0)
expect(clampSince(-5)).toBe(0)
expect(clampSince('abc')).toBe(0)
expect(clampSince(undefined)).toBe(0)
})
it('parses a numeric string', () => {
expect(clampSince('42')).toBe(42)
})
})
// ── buildDigest ───────────────────────────────────────────────────────────────
describe('buildDigest', () => {
it('returns an all-zero aggregate for an empty list', () => {
const d = buildDigest([], 0)
expect(d).toMatchObject({
since: 0,
total: 0,
finished: 0,
needsInput: 0,
stuck: 0,
working: 0,
totalCostUsd: 0,
sessions: [],
})
expect(typeof d.generatedAt).toBe('number')
})
it('counts finished (idle + output after since), needsInput, stuck, working', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', lastOutputAt: 5000 }), // finished (5000 > 100)
live({ id: 'b', status: 'waiting' }), // needsInput
live({ id: 'c', status: 'stuck' }), // stuck
live({ id: 'd', status: 'working' }), // working
live({ id: 'e', status: 'idle', lastOutputAt: 50 }), // idle but stale (50 < 100) → not finished
]
const d = buildDigest(sessions, 100)
expect(d.total).toBe(5)
expect(d.finished).toBe(1)
expect(d.needsInput).toBe(1)
expect(d.stuck).toBe(1)
expect(d.working).toBe(1)
})
it('does not count an idle session with no lastOutputAt as finished', () => {
const d = buildDigest([live({ id: 'a', status: 'idle' })], 0)
expect(d.finished).toBe(0)
})
it('treats a future `since` as "nothing new" (0 finished)', () => {
const d = buildDigest([live({ id: 'a', status: 'idle', lastOutputAt: 1000 })], 999_999_999_999)
expect(d.finished).toBe(0)
})
it('sums telemetry.costUsd across sessions', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', telemetry: { at: 1, costUsd: 1.5 } }),
live({ id: 'b', status: 'working', telemetry: { at: 1, costUsd: 2.25 } }),
live({ id: 'c', status: 'working' }), // no telemetry → contributes 0
]
const d = buildDigest(sessions, 0)
expect(d.totalCostUsd).toBeCloseTo(3.75)
})
it('projects per-session flags + title (last cwd segment)', () => {
const d = buildDigest([live({ id: 'a', status: 'waiting', cwd: '/home/u/my-repo' })], 0)
const row = d.sessions[0]
expect(row?.id).toBe('a')
expect(row?.title).toBe('my-repo')
expect(row?.needsInput).toBe(true)
expect(row?.finished).toBe(false)
expect(row?.stuck).toBe(false)
})
it('clamps a bad `since` to 0', () => {
const d = buildDigest([], NaN as unknown as number)
expect(d.since).toBe(0)
})
})