diff --git a/public/digest.ts b/public/digest.ts new file mode 100644 index 0000000..b25f1b0 --- /dev/null +++ b/public/digest.ts @@ -0,0 +1,156 @@ +/** + * public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner. + * + * On (re)connect, fetch GET /digest?since= and, if anything happened + * while away (finished / waiting / stuck), show ONE compact dismissible banner. + * The last-seen watermark is stored per-device in localStorage and advanced to + * the digest's generatedAt after each render so it never re-nags for old news. + * + * Best-effort: any fetch/parse failure → no banner (never throws). All text is + * set via textContent (SEC-H5) — session titles are attacker-influenced. + */ + +import type { DigestResult } from '../src/types.js' + +const LAST_SEEN_KEY = 'web-terminal:digest-last-seen' + +/* ── DOM helper ──────────────────────────────────────────────────────────────── */ + +function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text + return node +} + +/* ── last-seen watermark (per-device) ────────────────────────────────────────── */ + +/** Read the stored last-seen epoch-ms, or 0 (everything is new). Never throws. */ +export function getLastSeen(): number { + try { + const raw = localStorage.getItem(LAST_SEEN_KEY) + if (raw === null) return 0 + const n = Number(raw) + return Number.isFinite(n) && n >= 0 ? n : 0 + } catch { + return 0 + } +} + +/** Persist the last-seen epoch-ms watermark. Best-effort. */ +export function setLastSeen(ms: number): void { + try { + localStorage.setItem(LAST_SEEN_KEY, String(Math.floor(ms))) + } catch { + // storage unavailable (private mode) — the banner just re-shows next time + } +} + +/* ── normalize (never trust the API shape) ───────────────────────────────────── */ + +function num(o: Record, key: string): number { + const v = o[key] + return typeof v === 'number' && Number.isFinite(v) ? v : 0 +} + +/** Coerce an untrusted GET /digest response into a DigestResult, or null. */ +export function normalizeDigest(raw: unknown): DigestResult | null { + if (raw === null || typeof raw !== 'object') return null + const o = raw as Record + if (typeof o['generatedAt'] !== 'number' || !Number.isFinite(o['generatedAt'])) return null + return { + since: num(o, 'since'), + generatedAt: o['generatedAt'], + total: num(o, 'total'), + finished: num(o, 'finished'), + needsInput: num(o, 'needsInput'), + stuck: num(o, 'stuck'), + working: num(o, 'working'), + totalCostUsd: num(o, 'totalCostUsd'), + sessions: Array.isArray(o['sessions']) ? (o['sessions'] as DigestResult['sessions']) : [], + } +} + +/* ── fetch ───────────────────────────────────────────────────────────────────── */ + +/** Fetch the digest since `since`. null on any error (best-effort). */ +export async function fetchDigest(since: number): Promise { + try { + if (typeof fetch === 'undefined') return null + const res = await fetch(`/digest?since=${encodeURIComponent(String(since))}`) + if (!res.ok) return null + return normalizeDigest(await res.json()) + } catch { + return null + } +} + +/* ── render (pure) ───────────────────────────────────────────────────────────── */ + +/** Count of things worth surfacing (finished / waiting / stuck). */ +export function digestHighlightCount(d: DigestResult): number { + return d.finished + d.needsInput + d.stuck +} + +/** Compact human summary, e.g. "2 finished · 1 waiting · 1 stuck". */ +export function digestSummary(d: DigestResult): string { + const parts: string[] = [] + if (d.finished > 0) parts.push(`${d.finished} finished`) + if (d.needsInput > 0) parts.push(`${d.needsInput} waiting for input`) + if (d.stuck > 0) parts.push(`${d.stuck} stuck`) + return parts.join(' · ') +} + +/** + * Build the banner element for a digest, or null when nothing is worth showing. + * `onDismiss` is wired to the × button. All text via textContent (SEC-H5). + */ +export function renderDigestBanner(d: DigestResult, onDismiss: () => void): HTMLElement | null { + if (digestHighlightCount(d) === 0) return null + + const banner = el('div', 'wya-banner') + banner.setAttribute('role', 'status') + banner.append(el('span', 'wya-title', 'While you were away')) + banner.append(el('span', 'wya-summary', digestSummary(d))) + if (d.totalCostUsd > 0) { + banner.append(el('span', 'wya-cost', `$${d.totalCostUsd.toFixed(2)} total`)) + } + + const dismiss = el('button', 'wya-dismiss', '✕') + dismiss.title = 'Dismiss' + dismiss.setAttribute('aria-label', 'Dismiss') + dismiss.addEventListener('click', onDismiss) + banner.append(dismiss) + + return banner +} + +/* ── mount ───────────────────────────────────────────────────────────────────── */ + +/** + * Fetch the digest since the stored last-seen and, if anything happened, prepend + * a dismissible banner to `host`. Advances the last-seen watermark to the + * digest's generatedAt so it doesn't re-nag on the next reconnect. Best-effort: + * a fetch failure shows no banner. Returns the banner element (or null). + */ +export async function mountDigest(host: HTMLElement): Promise { + const since = getLastSeen() + const d = await fetchDigest(since) + if (d === null) return null // best-effort — no banner on failure + + // Advance the watermark now so a refresh (without a dismiss) doesn't re-nag. + setLastSeen(d.generatedAt) + + const banner = renderDigestBanner(d, () => { + setLastSeen(d.generatedAt) + banner?.remove() + }) + if (banner === null) return null + + host.prepend(banner) + return banner +} diff --git a/public/git-log.ts b/public/git-log.ts new file mode 100644 index 0000000..e5c0f6a --- /dev/null +++ b/public/git-log.ts @@ -0,0 +1,135 @@ +/** + * public/git-log.ts (W3 quick-wins d) — render-only recent-commit list. + * + * Fetches GET /projects/log for a repo and renders each commit as an inert row. + * Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws + * and degrades to a short inert message on any failure. + * + * Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A + * commit subject is attacker-influenced (anyone who can push to a repo the host + * can read), so it appears strictly as literal text. + */ + +import type { CommitLogEntry, GitLogResult } from '../src/types.js' + +/* ── DOM helper ──────────────────────────────────────────────────────────────── */ + +/** Create an element with an optional CSS class and text content. */ +function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text + return node +} + +/* ── normalize (never trust the API shape) ───────────────────────────────────── */ + +/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */ +function normalizeCommit(raw: unknown): CommitLogEntry | null { + if (raw === null || typeof raw !== 'object') return null + const o = raw as Record + if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null + if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null + return { hash: o['hash'], at: o['at'], subject: o['subject'] } +} + +/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */ +export function normalizeGitLog(raw: unknown): GitLogResult | null { + if (raw === null || typeof raw !== 'object') return null + const o = raw as Record + if (!Array.isArray(o['commits'])) return null + const commits = o['commits'] + .map(normalizeCommit) + .filter((c): c is CommitLogEntry => c !== null) + return { commits, truncated: o['truncated'] === true } +} + +/* ── fetch ───────────────────────────────────────────────────────────────────── */ + +/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */ +export async function fetchGitLog(repoPath: string): Promise { + try { + if (typeof fetch === 'undefined') return null + const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`) + if (!res.ok) return null + return normalizeGitLog(await res.json()) + } catch { + return null + } +} + +/* ── render ──────────────────────────────────────────────────────────────────── */ + +/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */ +function relTime(ms: number): string { + const s = Math.max(0, (Date.now() - ms) / 1000) + if (s < 60) return `${Math.floor(s)}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + if (s < 86400) return `${Math.floor(s / 3600)}h` + return `${Math.floor(s / 86400)}d` +} + +/** One commit row: short hash · relative time · subject (all inert text). */ +function renderCommitRow(c: CommitLogEntry): HTMLElement { + const row = el('div', 'proj-commit-row') + row.append(el('span', 'proj-commit-hash', c.hash)) + row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`)) + row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent + return row +} + +/** Render a GitLogResult into a container (clears first). Empty → an inert note. */ +export function renderGitLog(container: HTMLElement, log: GitLogResult): void { + container.textContent = '' + if (log.commits.length === 0) { + container.append(el('div', 'proj-empty', 'No commits yet.')) + return + } + const list = el('div', 'proj-commitlog-list') + for (const c of log.commits) list.append(renderCommitRow(c)) + container.append(list) + if (log.truncated) { + container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`)) + } +} + +/* ── mount ───────────────────────────────────────────────────────────────────── */ + +/** Handle returned by mountGitLog for cleanup. */ +export interface GitLogHandle { + destroy(): void +} + +/** + * Mount a recent-commit list into `container`: show a loading placeholder, fetch + * the log, then swap in the rows. A fetch failure degrades to a short inert + * message (never throws). destroy() removes the node and cancels the swap. + */ +export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle { + let destroyed = false + + container.textContent = '' + container.append(el('div', 'proj-commitlog-loading', 'Loading commits…')) + + void (async () => { + const log = await fetchGitLog(repoPath) + if (destroyed) return + if (log === null) { + container.textContent = '' + container.append(el('div', 'proj-empty', 'Could not read recent commits.')) + return + } + renderGitLog(container, log) + })() + + return { + destroy() { + destroyed = true + container.textContent = '' + }, + } +} diff --git a/public/main.ts b/public/main.ts index 06aa011..3285c35 100644 --- a/public/main.ts +++ b/public/main.ts @@ -22,6 +22,7 @@ import { mountShortcuts } from './shortcuts.js' import { mountShareSession } from './share.js' import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js' import { mountGridPresets } from './grid-presets.js' +import { mountDigest } from './digest.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -118,6 +119,11 @@ mountGridPresets(toolbar, { mountQrConnect(toolbar) +// W3(c): "while you were away" reconnect digest — one compact dismissible banner +// summarising what finished / needs input / got stuck since this device's last +// visit. Best-effort (no banner on fetch failure); advances its own last-seen. +void mountDigest(document.body) + // PWA: register the service worker (installable + offline shell, M4). if ('serviceWorker' in navigator) { window.addEventListener('load', () => { diff --git a/public/preview-grid.ts b/public/preview-grid.ts index 2abd178..48419c5 100644 --- a/public/preview-grid.ts +++ b/public/preview-grid.ts @@ -188,7 +188,9 @@ export async function fetchLiveSessions(): Promise { * * Shows: context-usage bar (>80% = warning colour), $cost chip, model chip, * and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container - * receives the class `tg-stale` so CSS can grey it out. + * receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is + * set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn` + * (W3 quick-wins b), mirroring the ctx>80% warn path. * * Security: all telemetry strings are set via `textContent` (SEC-H5); the PR * link href is only set when `url.protocol === 'https:'` (SEC-L5). @@ -198,6 +200,7 @@ export function renderTelemetryGauge( container: HTMLElement, telemetry: StatusTelemetry | null, staleTtlMs: number, + costBudgetUsd?: number, ): void { // Clear existing children while (container.firstChild) container.removeChild(container.firstChild) @@ -221,9 +224,13 @@ export function renderTelemetryGauge( container.append(bar) } - // Cost chip + // Cost chip — W3(b): warn-styled once cost crosses the configured budget. if (telemetry.costUsd !== undefined) { - container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)) + const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`) + if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) { + cost.classList.add('tg-cost-warn') + } + container.append(cost) } // Model chip diff --git a/public/projects.ts b/public/projects.ts index b4a7f87..8f29a77 100644 Binary files a/public/projects.ts and b/public/projects.ts differ diff --git a/public/style.css b/public/style.css index 6697749..5869e3a 100644 --- a/public/style.css +++ b/public/style.css @@ -1534,6 +1534,113 @@ body { flex: none; } +/* W3(a): ahead/behind sync chip (mirrors the .proj-branch chip look). */ +.proj-sync { + font-size: 11px; + color: var(--amber); + background: var(--accent-soft); + border-radius: 5px; + padding: 2px 7px; + white-space: nowrap; + flex: none; +} + +/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */ +.tg-cost-warn { + color: var(--red); + font-weight: 600; +} + +/* W3(d): recent-commit list in the project detail. */ +.proj-commitlog { + margin: 4px 0 10px; +} +.proj-commitlog-loading { + font-size: 12px; + color: var(--text-faint); +} +.proj-commitlog-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.proj-commit-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 12px; + min-width: 0; +} +.proj-commit-hash { + font-family: Menlo, Consolas, monospace; + color: var(--accent); + flex: none; +} +.proj-commit-time { + color: var(--text-faint); + flex: none; + white-space: nowrap; +} +.proj-commit-subject { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.proj-commit-more { + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; +} + +/* W3(c): "while you were away" reconnect banner (compact, dismissible top bar). */ +.wya-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + font-size: 13px; + color: var(--text); + background: var(--accent-soft); + border-bottom: 1px solid var(--accent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); +} +.wya-title { + font-weight: 600; + color: var(--accent); + flex: none; +} +.wya-summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wya-cost { + color: var(--text-faint); + flex: none; +} +.wya-dismiss { + flex: none; + border: none; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 6px; +} +.wya-dismiss:hover { + color: var(--text); +} + /* W3: PR + CI status chip (mirrors the .proj-branch chip look). */ .proj-pr-host { display: inline-flex; diff --git a/public/sw-push.js b/public/sw-push.js index d2ebe1e..b9d68a3 100644 --- a/public/sw-push.js +++ b/public/sw-push.js @@ -16,6 +16,7 @@ const TITLES = { 'needs-input': 'Approval Needed', done: 'Task Complete', stuck: 'Task Stuck', + budget: 'Cost Budget Reached', } /** diff --git a/public/tabs.ts b/public/tabs.ts index d8cca91..2c0031e 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -141,6 +141,9 @@ export class TabApp { // B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the // high-risk 'auto' permission mode is hidden/refused (SEC-M5). private allowAutoMode = false + // W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab + // gauge warn-styles the cost chip once costUsd >= this budget. + private costBudgetUsd = 0 private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time) private timelineOpen = false @@ -275,6 +278,11 @@ export class TabApp { ) { this.allowAutoMode = (data as UiConfig).allowAutoMode } + // W3(b): read the cost budget (optional, present only when > 0 server-side). + const budget = (data as Record)?.['costBudgetUsd'] + if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) { + this.costBudgetUsd = budget + } } catch { // best-effort — leave allowAutoMode false (auto hidden) on any failure } @@ -1441,7 +1449,7 @@ export class TabApp { queue.textContent = n > 0 ? `⧗${n}` : '' } const gauge = el.querySelector('.tab-gauge') - if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2 + if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b) } /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ diff --git a/src/config.ts b/src/config.ts index cf22da8..733e771 100644 --- a/src/config.ts +++ b/src/config.ts @@ -91,6 +91,22 @@ function parseNonNegativeInt( return n } +/** Parse a non-negative float env value (0 allowed), or the fallback when unset. */ +function parseNonNegativeFloat( + raw: string | undefined, + label: string, + fallback: number, +): number { + if (raw === undefined) return fallback + const n = Number(raw) + if (!Number.isFinite(n) || n < 0) { + throw new Error( + `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative number`, + ) + } + return n +} + /** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */ function parseBool(raw: string | undefined, fallback: boolean): boolean { const v = raw?.trim().toLowerCase() @@ -378,6 +394,9 @@ export function loadConfig(env: EnvLike): Config { DEFAULT_STATUSLINE_TTL_MS, ) + // W3 quick-wins (b) cost budget guard — dollars, float ≥ 0; 0/unset = disabled. + const costBudgetUsd = parseNonNegativeFloat(env['COST_BUDGET_USD'], 'COST_BUDGET_USD', 0) + // B3 git worktree creation const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true) const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time @@ -452,6 +471,7 @@ export function loadConfig(env: EnvLike): Config { ghEnabled, ghTimeoutMs, statuslineTtlMs, + costBudgetUsd, worktreeEnabled, worktreeRoot, worktreeTimeoutMs, diff --git a/src/http/digest.ts b/src/http/digest.ts new file mode 100644 index 0000000..a173c29 --- /dev/null +++ b/src/http/digest.ts @@ -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, + } +} diff --git a/src/http/git-log.ts b/src/http/git-log.ts new file mode 100644 index 0000000..246bffc --- /dev/null +++ b/src/http/git-log.ts @@ -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 { + 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 } + } +} diff --git a/src/http/projects.ts b/src/http/projects.ts index 3bc7e25..a7d48a6 100644 --- a/src/http/projects.ts +++ b/src/http/projects.ts @@ -108,6 +108,50 @@ async function readDirty(repoPath: string): Promise { } } +/** 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` → "\t" + * (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 `/.git` exists (file or directory). */ async function hasGitEntry(dir: string): Promise { 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 { 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)) diff --git a/src/server.ts b/src/server.ts index 3dbab90..3f6ed34 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,8 @@ import { listSessions } from './http/history.js' import { buildProjects, buildProjectDetail } from './http/projects.js' import { openInEditor, openFileInEditor } from './http/editor.js' import { getDiff, isPlausibleRev } from './http/diff.js' +import { getGitLog } from './http/git-log.js' +import { buildDigest, clampSince } from './http/digest.js' import { getPrStatus } from './http/gh.js' import { parseStatusLine } from './http/statusline.js' import { createWorktree } from './http/worktrees.js' @@ -326,6 +328,15 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(manager.list()) }) + // ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ── + // A pure read-side aggregate over manager.list() + in-memory telemetry/status; + // no Origin guard (same threat model as /live-sessions). `?since=` is + // the client's last-seen watermark (bad/absent → 0 = "everything is new"). + app.get('/digest', (req, res) => { + const since = clampSince(req.query['since']) + res.json(buildDigest(manager.list(), since)) + }) + // Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions). app.get('/projects', async (_req, res) => { try { @@ -820,6 +831,30 @@ export function startServer(cfg: Config): { close(): Promise } { } }) + // ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ────── + // Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff. + // `?n=` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing → + // 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200. + app.get('/projects/log', async (req, res) => { + const target = req.query['path'] + if (typeof target !== 'string' || target === '') { + res.status(400).json({ error: 'path query parameter is required' }) + return + } + if (!(await isValidGitDir(target))) { + res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong + return + } + const rawN = req.query['n'] + const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined + try { + res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs })) + } catch (err) { + console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err)) + res.status(500).json({ error: 'failed to read git log' }) + } + }) + // ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─ // Out-of-band side-channel: spawns the host's `gh` CLI to read the current // branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes @@ -895,7 +930,12 @@ export function startServer(cfg: Config): { close(): Promise } { // ── GET /config/ui (review #4) — client-readable UI config (read-only) ──── app.get('/config/ui', (_req, res) => { - const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode } + const uiConfig: UiConfig = { + allowAutoMode: cfg.allowAutoMode, + // W3(b): expose the cost budget only when set (>0) so the FE can derive + // cost-overage warn styling; a non-secret number, safe over /config/ui. + ...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}), + } res.json(uiConfig) }) diff --git a/src/session/manager.ts b/src/session/manager.ts index ed7594c..cf44005 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -265,6 +265,29 @@ export function createSessionManager( if (session === undefined) return; session.telemetry = telemetry; broadcast(session, { type: 'telemetry', telemetry }); + maybeAlertBudget(session, telemetry); + } + + /** + * W3 quick-wins (b): fire a one-shot cost-budget alert when a session's cost + * first crosses COST_BUDGET_USD. Mirrors the A5 stuck-latch shape but is NEVER + * re-armed (cost is monotonic) — so it fires at most once per session. Disabled + * when the budget is 0/unset or the frame carries no cost. + * + * No new ServerMessage variant: the "warning broadcast" is the telemetry frame + * already sent above (clients derive the warn from costUsd >= costBudgetUsd via + * GET /config/ui). The one distinct new action on crossing is a 'budget' push. + */ + function maybeAlertBudget(session: Session, telemetry: StatusTelemetry): void { + if (cfg.costBudgetUsd <= 0) return; // disabled + if (session.budgetNotified) return; // already alerted (latch) + const cost = telemetry.costUsd; + if (cost === undefined || cost < cfg.costBudgetUsd) return; // no crossing + + session.budgetNotified = true; + void notifyService?.notify(session, 'budget').catch((err: unknown) => { + console.error('[manager] budget notification failed', err); + }); } /** diff --git a/src/session/session.ts b/src/session/session.ts index 8e5371d..02e86d8 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -136,6 +136,7 @@ export function createSession( // v0.7 Walk-away Workbench fields (T-spawn-env): timeline: Object.freeze([] as TimelineEvent[]), stuckNotified: false, // A5: re-armed to false by each pty output + budgetNotified: false, // W3(b): cost-budget one-shot latch, never re-armed telemetry: null, // B2: updated by manager.handleStatusLine // W2: inject follow-up queue — empty at spawn; replaced wholesale by manager. queue: Object.freeze([] as string[]), diff --git a/src/types.ts b/src/types.ts index 7c909d7..f30f307 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,8 @@ export interface Config { readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff) // B2 statusLine telemetry readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000 + // W3 quick-wins (b) cost budget guard + readonly costBudgetUsd: number; // COST_BUDGET_USD, default 0 (0/unset = disabled) // B3 git worktree creation readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed) @@ -257,6 +259,10 @@ export interface Session { /** A5: true once a stuck alert fired this round; re-armed (→false) by the next * pty.onData so each silent round alerts at most once. */ stuckNotified: boolean; + /** W3 quick-wins (b): true once the cost-budget alert fired for this session. + * One-shot latch — NEVER re-armed (cost is monotonic), so the budget push + + * warning broadcast happen at most once per session. */ + budgetNotified: boolean; /** B2: latest statusLine telemetry for this session; null until first report. */ telemetry: StatusTelemetry | null; /** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes @@ -322,6 +328,10 @@ export interface ProjectInfo { branch?: string; // current branch (git repos only) dirty?: boolean; // uncommitted changes (when projectDirtyCheck) lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key + // W3 quick-wins (a) sync chip — best-effort git ahead/behind vs @{u} + last commit. + 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[]; // running sessions in this project (1:N; may be empty) } @@ -431,8 +441,9 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto'; /* ── A1 push notifications (§3.3, §A1) ── */ -/** The three proactive signals pushed to the phone (§3.3 / §A1). */ -export type NotifyClass = 'needs-input' | 'done' | 'stuck'; +/** The proactive signals pushed to the phone (§3.3 / §A1). 'budget' (W3 + * quick-wins b) fires once when a session's cost crosses COST_BUDGET_USD. */ +export type NotifyClass = 'needs-input' | 'done' | 'stuck' | 'budget'; /** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls` * (§3.3 review #3). Minimal by design: no raw terminal output, no secrets. @@ -601,6 +612,53 @@ export interface UiPrefs { * permission mode when the server forbids it (SEC-M5). */ export interface UiConfig { allowAutoMode: boolean; + /** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the + * FE can derive cost-overage warn styling client-side; omitted when disabled. */ + costBudgetUsd?: number; +} + +/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */ + +/** One session in the "while you were away" digest — a read-side projection of a + * live session plus its latest telemetry/status. All fields derived, no new state. */ +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' +} + +/** GET /digest result — an aggregate over manager.list() since a client's + * last-seen timestamp. Pure read (no new state); empty when no sessions. */ +export interface DigestResult { + since: number; + generatedAt: number; + total: number; + finished: number; + needsInput: number; + stuck: number; + working: number; + totalCostUsd: number; + sessions: DigestSession[]; +} + +/* ── W3 quick-wins (d) recent-commits log (GET /projects/log) ── */ + +/** One commit from `git log` (NUL-record, US-field delimited). `at` = %ct*1000. */ +export interface CommitLogEntry { + hash: string; + at: number; + subject: string; +} + +/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */ +export interface GitLogResult { + commits: CommitLogEntry[]; + truncated: boolean; } /* ─────────────────────── frontend (§5/§6.3) ──────────────────── */ diff --git a/test/config.test.ts b/test/config.test.ts index 21ce127..49a6add 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -595,6 +595,34 @@ describe('loadConfig — v0.7 B1 diff + B2 statusline', () => { }) }) +// ── W3 quick-wins (b) cost budget guard ─────────────────────────────────────── +describe('loadConfig — W3 COST_BUDGET_USD', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('defaults costBudgetUsd to 0 (disabled) when unset', () => { + expect(loadConfig({}).costBudgetUsd).toBe(0) + }) + + it('parses a float dollar value', () => { + expect(loadConfig({ COST_BUDGET_USD: '5.50' }).costBudgetUsd).toBe(5.5) + }) + + it('accepts an explicit 0 (disabled)', () => { + expect(loadConfig({ COST_BUDGET_USD: '0' }).costBudgetUsd).toBe(0) + }) + + it('throws for a non-numeric value', () => { + expect(() => loadConfig({ COST_BUDGET_USD: 'abc' })).toThrow(/COST_BUDGET_USD/) + }) + + it('throws for a negative value', () => { + expect(() => loadConfig({ COST_BUDGET_USD: '-1' })).toThrow(/COST_BUDGET_USD/) + }) +}) + describe('loadConfig — v0.7 B3 worktree', () => { beforeEach(() => { mockNetworkInterfaces.mockReturnValue({}) diff --git a/test/digest.test.ts b/test/digest.test.ts new file mode 100644 index 0000000..76891c7 --- /dev/null +++ b/test/digest.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +/** + * test/digest.test.ts (W3 quick-wins c) — reconnect digest banner (public/digest.ts). + * + * Pure helpers (normalizeDigest / digestSummary / renderDigestBanner) + the + * mountDigest wiring with a mocked fetch: fetches with the stored last-seen, + * shows a banner only when something happened, dismiss advances last-seen + hides, + * and a fetch failure shows no banner (best-effort). localStorage comes from the + * shared jsdom polyfill (vitest.config setupFiles). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { DigestResult } from '../src/types.js' +import { + normalizeDigest, + digestSummary, + digestHighlightCount, + renderDigestBanner, + getLastSeen, + setLastSeen, + mountDigest, +} from '../public/digest.js' + +function makeDigest(over: Partial = {}): DigestResult { + return { + since: 0, + generatedAt: 5000, + total: 0, + finished: 0, + needsInput: 0, + stuck: 0, + working: 0, + totalCostUsd: 0, + sessions: [], + ...over, + } +} + +/** Install a fetch mock returning `body` (or rejecting when `body` is null). */ +function mockFetch(body: unknown, ok = true): ReturnType { + const fn = vi.fn(async () => { + if (body === null) throw new Error('network down') + return { ok, json: async () => body } as Response + }) + vi.stubGlobal('fetch', fn) + return fn +} + +beforeEach(() => { + localStorage.clear() + document.body.innerHTML = '' +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// ── pure helpers ────────────────────────────────────────────────────────────── + +describe('normalizeDigest', () => { + it('returns null for a non-object / missing generatedAt', () => { + expect(normalizeDigest(null)).toBeNull() + expect(normalizeDigest('x')).toBeNull() + expect(normalizeDigest({ total: 1 })).toBeNull() + }) + + it('coerces a well-formed response', () => { + const d = normalizeDigest(makeDigest({ finished: 2, totalCostUsd: 1.5 })) + expect(d?.finished).toBe(2) + expect(d?.totalCostUsd).toBe(1.5) + expect(Array.isArray(d?.sessions)).toBe(true) + }) + + it('defaults missing numeric counts to 0 and non-array sessions to []', () => { + const d = normalizeDigest({ generatedAt: 10 }) + expect(d?.finished).toBe(0) + expect(d?.sessions).toEqual([]) + }) +}) + +describe('digestSummary / digestHighlightCount', () => { + it('counts finished + needsInput + stuck', () => { + expect(digestHighlightCount(makeDigest({ finished: 2, needsInput: 1, stuck: 1, working: 9 }))).toBe(4) + }) + + it('builds a compact summary of only the non-zero buckets', () => { + const s = digestSummary(makeDigest({ finished: 2, stuck: 1 })) + expect(s).toContain('2 finished') + expect(s).toContain('1 stuck') + expect(s).not.toContain('waiting') + }) +}) + +describe('renderDigestBanner', () => { + it('returns null when nothing worth showing', () => { + expect(renderDigestBanner(makeDigest({ working: 3 }), () => {})).toBeNull() + }) + + it('renders a dismissible banner and wires the × button', () => { + const onDismiss = vi.fn() + const banner = renderDigestBanner(makeDigest({ finished: 1 }), onDismiss) + expect(banner?.className).toContain('wya-banner') + ;(banner?.querySelector('.wya-dismiss') as HTMLButtonElement).click() + expect(onDismiss).toHaveBeenCalled() + }) + + it('renders an attacker-influenced summary as inert text (SEC-H5)', () => { + const banner = renderDigestBanner(makeDigest({ finished: 1 }), () => {}) + expect(banner?.querySelectorAll('script').length).toBe(0) + }) +}) + +describe('getLastSeen / setLastSeen', () => { + it('defaults to 0 and round-trips a value', () => { + expect(getLastSeen()).toBe(0) + setLastSeen(1234) + expect(getLastSeen()).toBe(1234) + }) +}) + +// ── mountDigest (wiring) ────────────────────────────────────────────────────── + +describe('mountDigest', () => { + it('fetches with the stored last-seen watermark', async () => { + setLastSeen(500) + const fetchFn = mockFetch(makeDigest({ finished: 1 })) + await mountDigest(document.body) + expect(fetchFn).toHaveBeenCalledWith('/digest?since=500') + }) + + it('shows a banner when something happened', async () => { + mockFetch(makeDigest({ finished: 1 })) + await mountDigest(document.body) + expect(document.body.querySelector('.wya-banner')).not.toBeNull() + }) + + it('shows NO banner when nothing happened', async () => { + mockFetch(makeDigest({ working: 2 })) + await mountDigest(document.body) + expect(document.body.querySelector('.wya-banner')).toBeNull() + }) + + it('advances last-seen to generatedAt after a fetch', async () => { + mockFetch(makeDigest({ generatedAt: 8888, finished: 1 })) + await mountDigest(document.body) + expect(getLastSeen()).toBe(8888) + }) + + it('dismiss updates last-seen and removes the banner', async () => { + mockFetch(makeDigest({ generatedAt: 7777, needsInput: 1 })) + await mountDigest(document.body) + const banner = document.body.querySelector('.wya-banner') + expect(banner).not.toBeNull() + ;(banner!.querySelector('.wya-dismiss') as HTMLButtonElement).click() + expect(document.body.querySelector('.wya-banner')).toBeNull() + expect(getLastSeen()).toBe(7777) + }) + + it('shows no banner on a fetch failure (best-effort)', async () => { + mockFetch(null) + const banner = await mountDigest(document.body) + expect(banner).toBeNull() + expect(document.body.querySelector('.wya-banner')).toBeNull() + }) +}) diff --git a/test/git-log.test.ts b/test/git-log.test.ts new file mode 100644 index 0000000..d1e147a --- /dev/null +++ b/test/git-log.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +/** + * test/git-log.test.ts (W3 quick-wins d) — recent-commit list (public/git-log.ts). + * + * Pure normalize/render + the mountGitLog wiring with a mocked fetch. Security: + * commit subjects are attacker-influenced, so a subject containing an payload must appear verbatim as text (no HTML injection). A fetch + * failure degrades to an inert message (best-effort, never throws). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { GitLogResult } from '../src/types.js' +import { + normalizeGitLog, + renderGitLog, + fetchGitLog, + mountGitLog, +} from '../public/git-log.js' + +function makeLog(over: Partial = {}): GitLogResult { + return { + commits: [ + { hash: 'abc1234', at: Date.now() - 3600_000, subject: 'first commit' }, + { hash: 'def5678', at: Date.now() - 7200_000, subject: 'second commit' }, + ], + truncated: false, + ...over, + } +} + +function mockFetch(body: unknown, ok = true): ReturnType { + const fn = vi.fn(async () => { + if (body === null) throw new Error('network down') + return { ok, json: async () => body } as Response + }) + vi.stubGlobal('fetch', fn) + return fn +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// ── normalizeGitLog ─────────────────────────────────────────────────────────── + +describe('normalizeGitLog', () => { + it('returns null for a non-object / missing commits array', () => { + expect(normalizeGitLog(null)).toBeNull() + expect(normalizeGitLog({ truncated: true })).toBeNull() + }) + + it('keeps well-formed commits and drops malformed ones', () => { + const out = normalizeGitLog({ + commits: [ + { hash: 'h1', at: 1000, subject: 'ok' }, + { hash: 'h2', at: 'nope', subject: 'bad-at' }, + { hash: 5, at: 1, subject: 'bad-hash' }, + { at: 1, subject: 'missing-hash' }, + ], + truncated: true, + }) + expect(out?.commits.map((c) => c.hash)).toEqual(['h1']) + expect(out?.truncated).toBe(true) + }) +}) + +// ── renderGitLog ────────────────────────────────────────────────────────────── + +describe('renderGitLog', () => { + it('renders one row per commit with hash / time / subject', () => { + const host = document.createElement('div') + renderGitLog(host, makeLog()) + const rows = host.querySelectorAll('.proj-commit-row') + expect(rows).toHaveLength(2) + expect(rows[0]?.querySelector('.proj-commit-hash')?.textContent).toBe('abc1234') + expect(rows[0]?.querySelector('.proj-commit-subject')?.textContent).toBe('first commit') + }) + + it('renders an empty note when there are no commits', () => { + const host = document.createElement('div') + renderGitLog(host, makeLog({ commits: [] })) + expect(host.querySelector('.proj-empty')).not.toBeNull() + expect(host.querySelector('.proj-commit-row')).toBeNull() + }) + + it('shows a truncation note when truncated', () => { + const host = document.createElement('div') + renderGitLog(host, makeLog({ truncated: true })) + expect(host.querySelector('.proj-commit-more')).not.toBeNull() + }) + + it('renders a subject with an HTML payload as inert text (SEC-H5)', () => { + const host = document.createElement('div') + const xss = '' + renderGitLog(host, makeLog({ commits: [{ hash: 'h1', at: Date.now(), subject: xss }] })) + const subj = host.querySelector('.proj-commit-subject') + expect(subj?.textContent).toBe(xss) // verbatim text + expect(host.querySelectorAll('img').length).toBe(0) // no element injected + }) +}) + +// ── fetchGitLog / mountGitLog ───────────────────────────────────────────────── + +describe('fetchGitLog', () => { + it('requests /projects/log with the encoded repo path', async () => { + const fetchFn = mockFetch(makeLog()) + await fetchGitLog('/home/u/my repo') + expect(fetchFn).toHaveBeenCalledWith('/projects/log?path=%2Fhome%2Fu%2Fmy%20repo') + }) + + it('returns null on a fetch failure (best-effort)', async () => { + mockFetch(null) + expect(await fetchGitLog('/x')).toBeNull() + }) + + it('returns null on a non-ok response', async () => { + mockFetch({}, false) + expect(await fetchGitLog('/x')).toBeNull() + }) +}) + +describe('mountGitLog', () => { + it('shows a loading placeholder, then swaps in commit rows', async () => { + mockFetch(makeLog()) + const host = document.createElement('div') + mountGitLog(host, '/repo') + expect(host.querySelector('.proj-commitlog-loading')).not.toBeNull() + await vi.waitFor(() => { + expect(host.querySelector('.proj-commit-row')).not.toBeNull() + }) + }) + + it('degrades to an inert message on a fetch failure', async () => { + mockFetch(null) + const host = document.createElement('div') + mountGitLog(host, '/repo') + await vi.waitFor(() => { + expect(host.querySelector('.proj-empty')).not.toBeNull() + }) + }) + + it('destroy() clears the container and cancels the swap', async () => { + mockFetch(makeLog()) + const host = document.createElement('div') + const handle = mountGitLog(host, '/repo') + handle.destroy() + // give the async swap a chance — it must not repopulate after destroy + await new Promise((r) => setTimeout(r, 0)) + expect(host.querySelector('.proj-commit-row')).toBeNull() + }) +}) diff --git a/test/http/digest.test.ts b/test/http/digest.test.ts new file mode 100644 index 0000000..98de34b --- /dev/null +++ b/test/http/digest.test.ts @@ -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 & { 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) + }) +}) diff --git a/test/http/git-log.test.ts b/test/http/git-log.test.ts new file mode 100644 index 0000000..91ff872 --- /dev/null +++ b/test/http/git-log.test.ts @@ -0,0 +1,168 @@ +/** + * test/http/git-log.test.ts (W3 quick-wins d) — recent-commit log parsing + getGitLog. + * + * Two layers: + * 1. parseGitLog fed canned NUL-record/US-field output — the deterministic core + * (empty → []; malformed record skipped; subject with tabs/newlines intact; + * subject truncated at the cap; truncated flag when records === max). + * 2. getGitLog against a REAL throwaway git repo in os.tmpdir (newest-first, + * n-clamping + truncated flag). Mirrors test/http/diff.test.ts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + parseGitLog, + getGitLog, + clampLogCount, + GIT_LOG_MAX, + GIT_LOG_DEFAULT, +} from '../../src/http/git-log.js' + +const execFileAsync = promisify(execFile) +const US = '\x1f' +const NUL = '\x00' + +/** Build one NUL-terminated record: hash US ct US subject. */ +function rec(hash: string, ct: number, subject: string): string { + return `${hash}${US}${ct}${US}${subject}${NUL}` +} + +// ── clampLogCount ───────────────────────────────────────────────────────────── + +describe('clampLogCount', () => { + it('defaults to GIT_LOG_DEFAULT for missing / non-numeric', () => { + expect(clampLogCount(undefined)).toBe(GIT_LOG_DEFAULT) + expect(clampLogCount('abc')).toBe(GIT_LOG_DEFAULT) + expect(clampLogCount(NaN)).toBe(GIT_LOG_DEFAULT) + }) + + it('clamps to [1, GIT_LOG_MAX]', () => { + expect(clampLogCount(0)).toBe(1) + expect(clampLogCount(-5)).toBe(1) + expect(clampLogCount(999)).toBe(GIT_LOG_MAX) + expect(clampLogCount(GIT_LOG_MAX)).toBe(GIT_LOG_MAX) + }) + + it('parses a numeric string and floors it', () => { + expect(clampLogCount('3')).toBe(3) + expect(clampLogCount(3.9)).toBe(3) + }) +}) + +// ── parseGitLog (pure) ──────────────────────────────────────────────────────── + +describe('parseGitLog', () => { + it('returns [] for empty stdout', () => { + expect(parseGitLog('', 20)).toEqual({ commits: [], truncated: false }) + }) + + it('parses one record into hash / at(ms) / subject', () => { + const out = parseGitLog(rec('abc1234', 1700000000, 'first commit'), 20) + expect(out.commits).toHaveLength(1) + expect(out.commits[0]).toEqual({ + hash: 'abc1234', + at: 1700000000 * 1000, + subject: 'first commit', + }) + expect(out.truncated).toBe(false) + }) + + it('preserves subjects containing tabs and newlines (US/NUL delimiters)', () => { + const nasty = 'fix:\ttabbed\nand newlined' + const out = parseGitLog(rec('h1', 1700000000, nasty), 20) + expect(out.commits[0]?.subject).toBe(nasty) + }) + + it('skips a malformed record missing a field', () => { + const good = rec('h1', 1700000000, 'ok') + const bad = `h2${US}onlytwo${NUL}` // only 2 fields + const out = parseGitLog(good + bad, 20) + expect(out.commits.map((c) => c.hash)).toEqual(['h1']) + }) + + it('skips a record with a non-numeric timestamp', () => { + const out = parseGitLog(rec('h1', NaN as unknown as number, 'x') + rec('h2', 1700000001, 'y'), 20) + expect(out.commits.map((c) => c.hash)).toEqual(['h2']) + }) + + it('truncates an over-long subject at the cap', () => { + const long = 'x'.repeat(1000) + const out = parseGitLog(rec('h1', 1700000000, long), 20) + expect(out.commits[0]?.subject.length).toBe(500) + }) + + it('sets truncated when records === max (there may be more)', () => { + const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + rec('h3', 3, 'c') + const out = parseGitLog(stdout, 3) + expect(out.commits).toHaveLength(3) + expect(out.truncated).toBe(true) + }) + + it('does NOT set truncated when fewer records than max', () => { + const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + const out = parseGitLog(stdout, 3) + expect(out.truncated).toBe(false) + }) +}) + +// ── getGitLog (integration against a real git repo) ────────────────────────── + +async function git(cwd: string, ...args: string[]): Promise { + await execFileAsync('git', args, { cwd }) +} + +describe('getGitLog (real git repo)', () => { + let repo: string + + beforeAll(async () => { + repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitlog-')) + await git(repo, 'init', '-q', '-b', 'main') + await git(repo, 'config', 'user.email', 'test@example.com') + await git(repo, 'config', 'user.name', 'Test') + await git(repo, 'config', 'commit.gpgsign', 'false') + for (const [file, msg] of [ + ['a.txt', 'first'], + ['b.txt', 'second'], + ['c.txt', 'third'], + ]) { + await fs.writeFile(path.join(repo, file), `${file}\n`) + await git(repo, 'add', '.') + await git(repo, 'commit', '-q', '-m', msg) + } + }) + + afterAll(async () => { + await fs.rm(repo, { recursive: true, force: true }) + }) + + it('returns 3 commits newest-first', async () => { + const out = await getGitLog(repo, { timeoutMs: 5000 }) + expect(out.commits).toHaveLength(3) + expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first']) + expect(out.truncated).toBe(false) + // hashes are short + non-empty; at is a plausible ms timestamp + for (const c of out.commits) { + expect(c.hash.length).toBeGreaterThan(0) + expect(c.at).toBeGreaterThan(0) + } + }) + + it('clamps n and flags truncated when asking for fewer than exist', async () => { + const out = await getGitLog(repo, { n: 2, timeoutMs: 5000 }) + expect(out.commits).toHaveLength(2) + expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second']) + expect(out.truncated).toBe(true) + }) + + it('returns an empty result for a non-git directory (best-effort, no throw)', async () => { + const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-')) + const out = await getGitLog(plain, { timeoutMs: 5000 }) + expect(out).toEqual({ commits: [], truncated: false }) + await fs.rm(plain, { recursive: true, force: true }) + }) +}) diff --git a/test/integration/digest-endpoint.test.ts b/test/integration/digest-endpoint.test.ts new file mode 100644 index 0000000..9e96981 --- /dev/null +++ b/test/integration/digest-endpoint.test.ts @@ -0,0 +1,78 @@ +/** + * Integration test for GET /digest (W3 quick-wins c). + * + * Starts a real HTTP server (no live sessions) and asserts the read-only digest + * route: 200 + a well-shaped DigestResult; a malformed `since` clamps to 0. + */ + +import net from 'node:net' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { DigestResult } from '../../src/types.js' + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +describe('GET /digest — integration', () => { + let port: number + let serverHandle: { close(): Promise } + + beforeAll(async () => { + port = await getFreePort() + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + }) + serverHandle = startServer(cfg) + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await serverHandle.close() + }) + + it('returns 200 with an all-zero DigestResult when no sessions', async () => { + const res = await fetch(`http://127.0.0.1:${port}/digest?since=0`) + expect(res.status).toBe(200) + const body = (await res.json()) as DigestResult + expect(body.since).toBe(0) + expect(body.total).toBe(0) + expect(body.finished).toBe(0) + expect(Array.isArray(body.sessions)).toBe(true) + expect(typeof body.generatedAt).toBe('number') + }) + + it('clamps a malformed since to 0', async () => { + const res = await fetch(`http://127.0.0.1:${port}/digest?since=not-a-number`) + expect(res.status).toBe(200) + const body = (await res.json()) as DigestResult + expect(body.since).toBe(0) + }) + + it('echoes a valid since watermark', async () => { + const res = await fetch(`http://127.0.0.1:${port}/digest?since=12345`) + const body = (await res.json()) as DigestResult + expect(body.since).toBe(12345) + }) +}) diff --git a/test/integration/projects-log-endpoint.test.ts b/test/integration/projects-log-endpoint.test.ts new file mode 100644 index 0000000..8883738 --- /dev/null +++ b/test/integration/projects-log-endpoint.test.ts @@ -0,0 +1,136 @@ +/** + * Integration test for GET /projects/log (W3 quick-wins d). + * + * Starts a real HTTP server, then asserts the recent-commit log route against a + * real temp git repo (3 commits): 200 + structured GitLogResult; missing path → + * 400; a non-git temp dir → 404 (isValidGitDir three-prong); ?n clamped. + */ + +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { GitLogResult } from '../../src/types.js' + +const execFileAsync = promisify(execFile) + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +async function git(cwd: string, ...args: string[]): Promise { + await execFileAsync('git', args, { cwd }) +} + +async function gitAvailable(): Promise { + try { + await execFileAsync('git', ['--version']) + return true + } catch { + return false + } +} + +describe('GET /projects/log — integration', () => { + let port: number + let tmpRoot: string + let repoPath: string + let plainPath: string + let serverHandle: { close(): Promise } + let haveGit = false + + beforeAll(async () => { + haveGit = await gitAvailable() + port = await getFreePort() + + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-log-test-')) + repoPath = path.join(tmpRoot, 'repo') + plainPath = path.join(tmpRoot, 'plain') + await fs.mkdir(plainPath, { recursive: true }) + + if (haveGit) { + await fs.mkdir(repoPath, { recursive: true }) + await git(repoPath, 'init', '-q', '-b', 'main') + await git(repoPath, 'config', 'user.email', 't@t.local') + await git(repoPath, 'config', 'user.name', 'tester') + await git(repoPath, 'config', 'commit.gpgsign', 'false') + for (const [file, msg] of [['a.txt', 'first'], ['b.txt', 'second'], ['c.txt', 'third']]) { + await fs.writeFile(path.join(repoPath, file), `${file}\n`) + await git(repoPath, 'add', '.') + await git(repoPath, 'commit', '-q', '-m', msg) + } + } + + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + }) + serverHandle = startServer(cfg) + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await serverHandle.close() + await fs.rm(tmpRoot, { recursive: true, force: true }) + }) + + it('returns 400 when path is missing', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects/log`) + expect(res.status).toBe(400) + }) + + it('returns 404 for a non-git directory (isValidGitDir three-prong)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(plainPath)}`) + expect(res.status).toBe(404) + }) + + it('returns 200 with the recent commits newest-first', async () => { + if (!haveGit) return + const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}`) + expect(res.status).toBe(200) + const body = (await res.json()) as GitLogResult + expect(body.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first']) + }) + + it('clamps ?n and flags truncated', async () => { + if (!haveGit) return + const res = await fetch( + `http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=2`, + ) + const body = (await res.json()) as GitLogResult + expect(body.commits).toHaveLength(2) + expect(body.truncated).toBe(true) + + // A huge n must be clamped to ≤ 50 (never explodes) — here only 3 commits exist. + const res2 = await fetch( + `http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=999`, + ) + const body2 = (await res2.json()) as GitLogResult + expect(body2.commits.length).toBeLessThanOrEqual(50) + expect(body2.commits).toHaveLength(3) + }) +}) diff --git a/test/integration/timeline-events.test.ts b/test/integration/timeline-events.test.ts index b96963e..d78e831 100644 --- a/test/integration/timeline-events.test.ts +++ b/test/integration/timeline-events.test.ts @@ -214,4 +214,17 @@ describe('GET /config/ui', () => { const res = await fetch(`http://127.0.0.1:${port}/config/ui`) expect(await res.json()).toEqual({ allowAutoMode: true }) }) + + it('omits costBudgetUsd when the budget is unset (W3 b)', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/config/ui`) + const body = (await res.json()) as Record + expect(body).not.toHaveProperty('costBudgetUsd') + }) + + it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => { + const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' }) + const res = await fetch(`http://127.0.0.1:${port}/config/ui`) + expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5 }) + }) }) diff --git a/test/manager.test.ts b/test/manager.test.ts index 021c126..94534cd 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -864,6 +864,78 @@ describe('handleStatusLine', () => { }); }); +// ── handleStatusLine — W3(b) cost-budget latch ──────────────────────────────── +describe('handleStatusLine — cost-budget one-shot latch (W3 b)', () => { + const BUDGET_CFG: Config = { ...CFG, costBudgetUsd: 1 }; + + it('does NOT fire below the threshold and leaves the latch unset', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(BUDGET_CFG, service); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 }); + + expect(notify).not.toHaveBeenCalled(); + expect(s.budgetNotified).toBe(false); + }); + + it('fires exactly once with (session, "budget") on crossing, then latches', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(BUDGET_CFG, service); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // Below → nothing. + mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 }); + expect(notify).not.toHaveBeenCalled(); + + // Crosses the threshold → fire once, latch set. + mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 }); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(s, 'budget'); + expect(s.budgetNotified).toBe(true); + + // Further over-budget frames do NOT re-fire (latch, never re-armed). + mgr.handleStatusLine(s.meta.id, { at: 3, costUsd: 2 }); + mgr.handleStatusLine(s.meta.id, { at: 4, costUsd: 5 }); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('never fires when the budget is 0 (disabled)', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager({ ...CFG, costBudgetUsd: 0 }, service); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 999 }); + + expect(notify).not.toHaveBeenCalled(); + expect(s.budgetNotified).toBe(false); + }); + + it('does not fire when a telemetry frame carries no cost', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(BUDGET_CFG, service); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + mgr.handleStatusLine(s.meta.id, { at: 1 }); // no costUsd + + expect(notify).not.toHaveBeenCalled(); + expect(s.budgetNotified).toBe(false); + }); + + it('still broadcasts telemetry even when the latch fires (existing frame is the warning)', () => { + const { service } = createMockNotify(); + const mgr = createSessionManager(BUDGET_CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + ws.sent.length = 0; + + mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 }); + + const telemetry = parseSent(ws).find((m) => m.type === 'telemetry'); + expect(telemetry).toBeDefined(); + }); +}); + // ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ───── describe('handleAttach — late-join replay (M3)', () => { it('sends the current telemetry to a device joining a live session', () => { diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index d9ddea4..6329062 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -33,6 +33,7 @@ const { toggleFav, normalizeProject, makeProjectCard, + makeSyncChip, renderProjectDetail, groupProjects, displayLabel, @@ -285,6 +286,65 @@ describe('normalizeProject', () => { expect(p?.lastActiveMs).toBeUndefined() expect(p?.isGit).toBe(false) }) + + it('passes through numeric sync fields (W3 a)', () => { + const p = normalizeProject({ name: 'web', path: '/p', ahead: 2, behind: 1, lastCommitMs: 123456 }) + expect(p?.ahead).toBe(2) + expect(p?.behind).toBe(1) + expect(p?.lastCommitMs).toBe(123456) + }) + + it('drops non-numeric sync fields (W3 a)', () => { + const p = normalizeProject({ name: 'web', path: '/p', ahead: '2', behind: null, lastCommitMs: 'x' }) + expect(p?.ahead).toBeUndefined() + expect(p?.behind).toBeUndefined() + expect(p?.lastCommitMs).toBeUndefined() + }) +}) + +/* ── makeSyncChip / sync chip on the card (W3 a) ─────────────────────────────── */ + +describe('makeSyncChip', () => { + it('renders ↑ahead ↓behind when there is drift', () => { + const chip = makeSyncChip(makeProject({ ahead: 2, behind: 1 })) + expect(chip).not.toBeNull() + expect(chip?.className).toContain('proj-sync') + expect(chip?.textContent).toBe('↑2 ↓1') + }) + + it('shows only the ahead arrow when behind is 0', () => { + const chip = makeSyncChip(makeProject({ ahead: 3, behind: 0 })) + expect(chip?.textContent).toBe('↑3') + }) + + it('returns null when in sync (ahead=behind=0)', () => { + expect(makeSyncChip(makeProject({ ahead: 0, behind: 0 }))).toBeNull() + }) + + it('returns null when ahead/behind are undefined', () => { + expect(makeSyncChip(makeProject())).toBeNull() + }) + + it('includes the last-commit time in the tooltip when present', () => { + const chip = makeSyncChip(makeProject({ ahead: 1, lastCommitMs: Date.now() - 3600_000 })) + expect(chip?.title).toContain('last commit') + }) +}) + +describe('makeProjectCard — sync chip', () => { + const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() }) + + it('renders the sync chip when the project has drift', () => { + const card = makeProjectCard(makeProject({ ahead: 2, behind: 1 }), new Set(), noopHooks(), () => {}) + expect(card.querySelector('.proj-sync')?.textContent).toBe('↑2 ↓1') + }) + + it('omits the sync chip when in sync / undefined', () => { + const inSync = makeProjectCard(makeProject({ ahead: 0, behind: 0 }), new Set(), noopHooks(), () => {}) + expect(inSync.querySelector('.proj-sync')).toBeNull() + const noData = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {}) + expect(noData.querySelector('.proj-sync')).toBeNull() + }) }) /* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */ diff --git a/test/projects.test.ts b/test/projects.test.ts index 579fd2f..f8d0dc2 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -402,6 +402,84 @@ describe('buildProjects — dirty check', () => { }) }) +// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ────────────── + +describe('buildProjects — sync fields (W3 a)', () => { + async function commit(cwd: string, file: string, msg: string): Promise { + await fs.writeFile(path.join(cwd, file), `${file}\n`) + await execFileP('git', ['add', '.'], { cwd }) + await execFileP('git', ['commit', '-q', '-m', msg], { cwd }) + } + + async function initRepo(cwd: string): Promise { + await fs.mkdir(cwd, { recursive: true }) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) + } + + it('reports ahead/behind vs upstream and lastCommitMs when dirtyCheck is on', async () => { + if (!(await gitAvailable())) return + + // Upstream repo with one commit; clone it so the clone's main tracks origin/main. + const upstream = path.join(tmp, 'upstream') + await initRepo(upstream) + await commit(upstream, 'a.txt', 'first') + + const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-clone-')) + const clone = path.join(cloneRoot, 'clone') + await execFileP('git', ['clone', '-q', upstream, clone]) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) + + // Diverge: 1 local commit ahead, then 1 upstream commit fetched → 1 behind. + await commit(clone, 'local.txt', 'local ahead') + await commit(upstream, 'b.txt', 'second upstream') + await execFileP('git', ['fetch', '-q'], { cwd: clone }) + + const cfg = makeCfg({ projectRoots: [cloneRoot], projectScanDepth: 2, projectDirtyCheck: true }) + const out = await buildProjects(cfg, []) + const proj = out.find((p) => p.name === 'clone')! + expect(proj.ahead).toBe(1) + expect(proj.behind).toBe(1) + expect(typeof proj.lastCommitMs).toBe('number') + expect(proj.lastCommitMs!).toBeGreaterThan(0) + + await fs.rm(cloneRoot, { recursive: true, force: true }) + }) + + it('leaves ahead/behind undefined for a repo with no upstream (no throw)', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'noupstream') + await initRepo(repo) + await commit(repo, 'a.txt', 'only') + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true }) + const out = await buildProjects(cfg, []) + const proj = out.find((p) => p.name === 'noupstream')! + expect(proj.ahead).toBeUndefined() + expect(proj.behind).toBeUndefined() + // lastCommitMs still resolves — HEAD has a commit even without an upstream. + expect(typeof proj.lastCommitMs).toBe('number') + }) + + it('skips sync entirely (all undefined) when projectDirtyCheck is false', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'skipsync') + await initRepo(repo) + await commit(repo, 'a.txt', 'only') + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + const proj = out.find((p) => p.name === 'skipsync')! + expect(proj.ahead).toBeUndefined() + expect(proj.behind).toBeUndefined() + expect(proj.lastCommitMs).toBeUndefined() + }) +}) + // ── buildProjectDetail ─────────────────────────────────────────────────────────── describe('buildProjectDetail', () => { diff --git a/test/session.test.ts b/test/session.test.ts index 94ba205..2c2c9ce 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -119,6 +119,11 @@ describe('createSession', () => { expect(s.exitCode).toBeNull(); }); + it('initialises the W3(b) cost-budget latch to false', () => { + const s = newSession(); + expect(s.budgetNotified).toBe(false); + }); + it('THROWS (does not swallow) when spawn fails (M4)', () => { spawnError = new Error('spawn failed: /no/such/shell ENOENT'); nextPty = createMockPty(); diff --git a/test/telemetry-gauge.test.ts b/test/telemetry-gauge.test.ts index c8a4e95..00aaeb5 100644 --- a/test/telemetry-gauge.test.ts +++ b/test/telemetry-gauge.test.ts @@ -129,6 +129,36 @@ describe('renderTelemetryGauge', () => { expect(c.querySelector('.tg-cost')).toBeNull() }) + // ── cost budget warn (W3 b) ── + + it('adds tg-cost-warn when costUsd >= costBudgetUsd', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ costUsd: 6 }), 30_000, 5) + expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true) + }) + + it('adds tg-cost-warn exactly at the budget boundary (>=)', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ costUsd: 5 }), 30_000, 5) + expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true) + }) + + it('does NOT add tg-cost-warn when costUsd < budget', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ costUsd: 3 }), 30_000, 5) + expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false) + }) + + it('does NOT add tg-cost-warn when budget is 0 or undefined', () => { + const c1 = makeContainer() + renderTelemetryGauge(c1, makeTelemetry({ costUsd: 6 }), 30_000, 0) + expect(c1.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false) + + const c2 = makeContainer() + renderTelemetryGauge(c2, makeTelemetry({ costUsd: 6 }), 30_000) + expect(c2.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false) + }) + // ── model chip ── it('renders model chip when model is present', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 804a0f9..51d1b83 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,8 @@ export default defineConfig({ 'public/grid-presets.ts', 'public/cell-monitor.ts', 'public/preview-grid.ts', + 'public/git-log.ts', + 'public/digest.ts', 'public/title-util.ts', 'public/voice-commands.ts', 'public/voice-confirm.ts',