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

79
src/http/digest.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* src/http/digest.ts (W3 quick-wins c) — "while you were away" reconnect digest.
*
* A PURE read-side aggregate over the live-session list (injected, like
* buildProjects) plus each session's in-memory telemetry/status. No new state:
* it only projects what the manager already tracks into a compact summary the FE
* shows as a banner on (re)connect.
*
* `since` is a client's last-seen epoch-ms watermark: a session counts as
* `finished` when it is idle AND produced output after `since`. A bad/absent
* `since` clamps to 0 ("everything is new").
*/
import path from 'node:path'
import type { DigestResult, DigestSession, LiveSessionInfo } from '../types.js'
/** Last path segment of a cwd (the session "title"), or undefined. */
function lastSegment(cwd: string | null): string | undefined {
if (cwd === null || cwd === '') return undefined
return cwd.split(path.sep).filter(Boolean).pop()
}
/** Clamp `since` to a finite, non-negative number (bad/absent → 0). */
export function clampSince(since: unknown): number {
const n = typeof since === 'number' ? since : Number(since)
return Number.isFinite(n) && n >= 0 ? n : 0
}
/** Project one live session into its digest row. */
function toDigestSession(s: LiveSessionInfo, since: number): DigestSession {
const title = lastSegment(s.cwd)
const costUsd = s.telemetry?.costUsd
const lastOutputAt = s.lastOutputAt
const finished = s.status === 'idle' && lastOutputAt !== undefined && lastOutputAt > since
return {
id: s.id,
...(title !== undefined ? { title } : {}),
status: s.status,
...(costUsd !== undefined ? { costUsd } : {}),
...(lastOutputAt !== undefined ? { lastOutputAt } : {}),
finished,
needsInput: s.status === 'waiting',
stuck: s.status === 'stuck',
}
}
/**
* Build the reconnect digest from the live-session list. Pure — the list is
* injected (e.g. `manager.list()`). Empty list → all-zero aggregate. Never throws.
*/
export function buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult {
const clampedSince = clampSince(since)
const sessions = live.map((s) => toDigestSession(s, clampedSince))
let finished = 0
let needsInput = 0
let stuck = 0
let working = 0
let totalCostUsd = 0
for (const d of sessions) {
if (d.finished) finished += 1
if (d.needsInput) needsInput += 1
if (d.stuck) stuck += 1
if (d.status === 'working') working += 1
if (d.costUsd !== undefined) totalCostUsd += d.costUsd
}
return {
since: clampedSince,
generatedAt: Date.now(),
total: sessions.length,
finished,
needsInput,
stuck,
working,
totalCostUsd,
sessions,
}
}

109
src/http/git-log.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
*
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
* `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
* lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
*
* Delimiter design (robust against nasty subjects): the format is
* %h %x1f %ct %x1f %s with -z (records separated by NUL)
* so a US (0x1f) field separator + a NUL (0x00) record separator can never be
* corrupted by a subject containing tabs, spaces or newlines. We never parse the
* fragile `--oneline` shape.
*
* Security (mirrors diff.ts):
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
* - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
* int and clamped BEFORE reaching argv. Path→repo validation is the route's
* job (isValidGitDir, SEC-H7), exactly like /projects/diff.
* - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
* best-effort and returns an empty result rather than rejecting.
* - subjects are carried verbatim and rendered as inert text (textContent) in
* the FE — never HTML.
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { CommitLogEntry, GitLogResult } from '../types.js'
const execFileAsync = promisify(execFile)
/** Hard cap on the number of commits a single request may return (DoS bound). */
export const GIT_LOG_MAX = 50
/** Default number of commits when the caller does not specify `n`. */
export const GIT_LOG_DEFAULT = 20
/** Cap a single commit subject so a pathological message can't bloat the payload. */
const SUBJECT_MAX_LEN = 500
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
const GIT_LOG_MAX_BUFFER = 1024 * 1024
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
const FIELD_SEP = '\x1f'
const RECORD_SEP = '\x00'
/**
* Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
* missing → GIT_LOG_DEFAULT. Never throws.
*/
export function clampLogCount(n: unknown): number {
const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
const floored = Math.floor(parsed)
if (floored < 1) return 1
if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
return floored
}
/**
* Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
* Records are NUL-separated; fields are US-separated. A record missing a field
* or with a non-numeric timestamp is skipped (never throws). `max` caps the
* returned entries and drives the `truncated` flag (records === max ⇒ there may
* be more). Empty stdout → an empty, non-truncated result.
*/
export function parseGitLog(stdout: string, max: number): GitLogResult {
if (typeof stdout !== 'string' || stdout.length === 0) {
return { commits: [], truncated: false }
}
const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
const commits: CommitLogEntry[] = []
for (const record of records) {
const parts = record.split(FIELD_SEP)
if (parts.length < 3) continue // malformed — missing a field
const hash = (parts[0] ?? '').trim()
const secs = Number.parseInt(parts[1] ?? '', 10)
// Subject may (in theory) contain a US char; re-join the tail so it is intact.
const subject = parts.slice(2).join(FIELD_SEP)
if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
commits.push({
hash,
at: secs * 1000,
subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
})
}
const truncated = commits.length >= max && max > 0
return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
}
export interface GetGitLogOptions {
n?: number
timeoutMs: number
}
/**
* Read a repo's recent commits (newest first) as a structured GitLogResult.
* `repoPath` must already be a validated absolute git directory (route layer,
* SEC-H7). Best-effort: any git failure yields an empty result, never throws.
*/
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
const n = clampLogCount(opts.n)
try {
const { stdout } = await execFileAsync(
'git',
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
return parseGitLog(stdout, n)
} catch {
return { commits: [], truncated: false }
}
}

View File

@@ -108,6 +108,50 @@ async function readDirty(repoPath: string): Promise<boolean | undefined> {
}
}
/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
* Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
* 1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
* (left = commits on @{u} not HEAD = behind; right = HEAD not @{u} = ahead).
* No upstream (`@{u}` fatal) / detached / empty repo → ahead/behind undefined.
* 2. `git log -1 --format=%ct` → HEAD commit unix seconds → lastCommitMs (×1000).
* Every field degrades to undefined independently; never throws. */
async function readSync(repoPath: string): Promise<{
ahead?: number
behind?: number
lastCommitMs?: number
}> {
const out: { ahead?: number; behind?: number; lastCommitMs?: number } = {}
try {
const { stdout } = await execFileAsync(
'git',
['rev-list', '--count', '--left-right', '@{u}...HEAD'],
{ cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
)
const parts = stdout.trim().split(/\s+/)
const behind = Number.parseInt(parts[0] ?? '', 10)
const ahead = Number.parseInt(parts[1] ?? '', 10)
if (Number.isFinite(behind) && behind >= 0) out.behind = behind
if (Number.isFinite(ahead) && ahead >= 0) out.ahead = ahead
} catch {
// no upstream / detached / empty repo → leave ahead/behind undefined
}
try {
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%ct'], {
cwd: repoPath,
timeout: GIT_STATUS_TIMEOUT_MS,
maxBuffer: GIT_STATUS_MAX_BUFFER,
})
const secs = Number.parseInt(stdout.trim(), 10)
if (Number.isFinite(secs) && secs >= 0) out.lastCommitMs = secs * 1000
} catch {
// empty repo (no commits) → leave lastCommitMs undefined
}
return out
}
/** True iff `<dir>/.git` exists (file or directory). */
async function hasGitEntry(dir: string): Promise<boolean> {
try {
@@ -124,6 +168,9 @@ interface MakeProjectArgs {
readonly branch?: string
readonly dirty?: boolean
readonly lastActiveMs?: number
readonly ahead?: number
readonly behind?: number
readonly lastCommitMs?: number
}
function makeProject(args: MakeProjectArgs): ProjectInfo {
@@ -134,6 +181,9 @@ function makeProject(args: MakeProjectArgs): ProjectInfo {
branch: args.branch,
dirty: args.dirty,
lastActiveMs: args.lastActiveMs,
ahead: args.ahead,
behind: args.behind,
lastCommitMs: args.lastCommitMs,
sessions: [],
}
}
@@ -275,8 +325,11 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
const branch = await readBranch(repoPath)
// W3(a): the sync chip (ahead/behind + last-commit) rides the same per-repo
// git budget as the dirty check — gated by projectDirtyCheck, best-effort.
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
return makeProject({ path: repoPath, isGit: true, branch, dirty })
const sync = cfg.projectDirtyCheck ? await readSync(repoPath) : {}
return makeProject({ path: repoPath, isGit: true, branch, dirty, ...sync })
})
const merged = await mergeHistory(repos)
return dropParentFolders(dedupByPath(merged))