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

View File

@@ -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<number> {
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<void> }
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<void>((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)
})
})

View File

@@ -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<number> {
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<void> {
await execFileAsync('git', args, { cwd })
}
async function gitAvailable(): Promise<boolean> {
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<void> }
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<void>((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)
})
})

View File

@@ -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<string, unknown>
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 })
})
})