codeburn/tests/act-journal.test.ts
AgentSeal be2470d0e5 fix(guard): dedupe streaming message copies in the incremental cost fold
Claude Code rewrites each assistant message several times as it streams,
every copy carrying the full final usage; the shipped parser dedupes these
last-wins (dedupeStreamingMessageIds) but the guard fold summed every line,
measuring real sessions at 2.5-2.8x their true cost and false-blocking the
hard cap at roughly 40% of the configured spend.

- The session cache now maps message id -> that id's cost contribution and
  each id-carrying line replaces its previous contribution; id-less lines
  keep plain adds. Validated against two real transcripts (90MB and 116MB):
  guard totals now equal the shipped deduped totals exactly.
- Replace semantics also self-heal the trailing-line case: a complete final
  line without its newline is folded but byteOffset stops before it, so the
  next invocation re-reads it as a replace, not a double add.
- editCount becomes a set-once sawEdit boolean so duplicate copies of an
  edit tool_use cannot inflate it; cache schema bumped to v2 (old caches
  cold-reparse once).
- Per-session state moves to guard/sessions/ so a session id can never
  collide with the shared flags.json, dropping the doAllow special case.
- The git-commit detector now requires commit as the git subcommand at a
  command boundary (start of string or line, or ; & |), with intra-command
  gaps that never cross newlines: 'git log --grep commit' and
  'git diff && echo commit' no longer match, while newline-separated
  'git add ...\ngit commit' in multi-line Bash calls now does (verified as
  a real false negative on a live transcript).
- Corrected the statusline protocol note: each stdout line renders as its
  own row; we emit exactly one.
- New tests: streaming-duplicate fixtures (3x identical, growing last-wins,
  incremental replace) asserted equal to a cold shipped-parser computation,
  the trailing-partial-line scenario, the commit-detector matrix, and a
  stale-plan test proving guard-install plans carry expectedHash (a
  concurrent settings edit aborts the apply and survives). The act list CLI
  spawn test now anchors to the repo root from the test file location.
2026-07-03 12:32:46 +02:00

215 lines
8.3 KiB
TypeScript

import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runAction } from '../src/act/apply.js'
import { journalPath, readRecords } from '../src/act/journal.js'
import { sha256 } from '../src/act/backup.js'
import type { ActionRecord } from '../src/act/types.js'
const roots: string[] = []
async function makeRoot(): Promise<{ actionsDir: string; files: string }> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-act-journal-'))
roots.push(root)
const files = join(root, 'files')
await mkdir(files, { recursive: true })
return { actionsDir: join(root, 'actions'), files }
}
afterAll(async () => {
for (const root of roots) await rm(root, { recursive: true, force: true })
})
describe('runAction journaling', () => {
it('journals backups and afterHash for edit, create, and move ops', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.txt')
const createPath = join(files, 'new.txt')
const movePath = join(files, 'move.txt')
const moveDest = join(files, 'archive', 'move.txt')
await writeFile(editPath, 'old-edit')
await writeFile(movePath, 'move-body')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'test action',
changes: [
{ op: 'edit', path: editPath, content: 'new-edit' },
{ op: 'create', path: createPath, content: 'created' },
{ op: 'move', path: movePath, movedTo: moveDest },
],
}, actionsDir)
expect(rec.status).toBe('applied')
expect(rec.findingId).toBeNull()
expect(rec.changes).toHaveLength(3)
const [edit, create, move] = rec.changes
expect(edit!.backup).not.toBeNull()
expect(await readFile(join(actionsDir, edit!.backup!), 'utf-8')).toBe('old-edit')
expect(edit!.afterHash).toBe(sha256(Buffer.from('new-edit')))
expect(await readFile(editPath, 'utf-8')).toBe('new-edit')
expect(create!.backup).toBeNull()
expect(create!.afterHash).toBe(sha256(Buffer.from('created')))
expect(await readFile(createPath, 'utf-8')).toBe('created')
expect(move!.backup).not.toBeNull()
expect(await readFile(join(actionsDir, move!.backup!), 'utf-8')).toBe('move-body')
expect(move!.movedTo).toBe(moveDest)
expect(move!.afterHash).toBe(sha256(Buffer.from('move-body')))
expect(await readFile(moveDest, 'utf-8')).toBe('move-body')
expect(existsSync(movePath)).toBe(false)
const records = await readRecords(actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.id).toBe(rec.id)
})
it('rolls back completed mutations and writes no record when a mutation fails', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.txt')
await writeFile(editPath, 'original')
const missingSrc = join(files, 'does-not-exist.txt')
await expect(runAction({
kind: 'shell-config',
description: 'failing action',
changes: [
{ op: 'edit', path: editPath, content: 'changed' },
{ op: 'move', path: missingSrc, movedTo: join(files, 'dest.txt') },
],
}, actionsDir)).rejects.toThrow()
expect(await readFile(editPath, 'utf-8')).toBe('original')
expect(existsSync(journalPath(actionsDir))).toBe(false)
})
it('skips corrupt journal lines and still loads valid records', async () => {
const { actionsDir } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
const recA = sampleRecord('11111111-1111-1111-1111-111111111111', 'first')
const recB = sampleRecord('22222222-2222-2222-2222-222222222222', 'second')
await writeFile(
journalPath(actionsDir),
JSON.stringify(recA) + '\n' + '{ this is not json\n' + JSON.stringify(recB) + '\n',
)
const records = await readRecords(actionsDir)
expect(records.map(r => r.id)).toEqual([recA.id, recB.id])
expect(records.map(r => r.description)).toEqual(['first', 'second'])
})
it('round-trips a record through JSON (act list --json shape)', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'f.txt')
await writeFile(p, 'before')
const rec = await runAction({
kind: 'model-default',
description: 'json shape',
changes: [{ op: 'edit', path: p, content: 'after' }],
}, actionsDir)
const records = await readRecords(actionsDir)
const roundTripped = JSON.parse(JSON.stringify(records)) as ActionRecord[]
expect(roundTripped).toEqual(records)
const only = roundTripped[0]!
expect(only).toMatchObject({ id: rec.id, kind: 'model-default', description: 'json shape', status: 'applied' })
expect(typeof only.at).toBe('string')
expect(only.findingId).toBeNull()
expect(only.changes[0]).toMatchObject({ path: p, op: 'edit' })
expect(only.changes[0]!.backup).not.toBeNull()
expect(typeof only.changes[0]!.afterHash).toBe('string')
})
})
describe('action lock', () => {
it('refuses to run while a fresh foreign lock is held', async () => {
const { actionsDir, files } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
await writeFile(join(actionsDir, '.lock'), '')
const target = join(files, 'blocked.txt')
await expect(runAction({
kind: 'shell-config',
description: 'blocked by lock',
changes: [{ op: 'create', path: target, content: 'x' }],
}, actionsDir)).rejects.toThrow(/in progress/)
expect(existsSync(target)).toBe(false)
})
it('takes over a lock whose mtime is older than 60s', async () => {
const { actionsDir, files } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
const lock = join(actionsDir, '.lock')
await writeFile(lock, JSON.stringify({ pid: 1, at: 0 }))
const past = new Date(Date.now() - 61_000)
await utimes(lock, past, past)
const target = join(files, 'takeover.txt')
const rec = await runAction({
kind: 'shell-config',
description: 'stale takeover',
changes: [{ op: 'create', path: target, content: 'x' }],
}, actionsDir)
expect(rec.status).toBe('applied')
expect(await readFile(target, 'utf-8')).toBe('x')
expect(existsSync(lock)).toBe(false)
})
})
describe('act list --json (CLI)', () => {
it('prints full records as JSON, newest first', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-act-cli-'))
roots.push(home)
const actionsDir = join(home, '.config', 'codeburn', 'actions')
const files = join(home, 'work')
await mkdir(files, { recursive: true })
const p1 = join(files, 'a.txt')
const p2 = join(files, 'b.txt')
await writeFile(p1, 'a')
await writeFile(p2, 'b')
const recOlder = await runAction({
kind: 'mcp-remove', description: 'older', changes: [{ op: 'edit', path: p1, content: 'a2' }],
}, actionsDir)
const recNewer = await runAction({
kind: 'mcp-remove', description: 'newer', changes: [{ op: 'edit', path: p2, content: 'b2' }],
}, actionsDir)
// Anchor the spawn to this checkout so running vitest from another cwd
// cannot execute a different checkout's cli.ts.
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const res = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'act', 'list', '--json'], {
cwd: repoRoot,
env: { ...process.env, HOME: home, USERPROFILE: home, HOMEPATH: home, HOMEDRIVE: '' },
encoding: 'utf-8',
})
expect(res.status).toBe(0)
const parsed = JSON.parse(res.stdout) as ActionRecord[]
expect(parsed.map(r => r.id)).toEqual([recNewer.id, recOlder.id])
expect(parsed[0]).toMatchObject({ kind: 'mcp-remove', description: 'newer', status: 'applied', findingId: null })
expect(parsed[0]!.changes[0]).toMatchObject({ path: p2, op: 'edit' })
expect(typeof parsed[0]!.changes[0]!.afterHash).toBe('string')
expect(parsed[0]!.changes[0]!.backup).not.toBeNull()
}, 20_000)
})
function sampleRecord(id: string, description: string): ActionRecord {
return {
id,
at: new Date().toISOString(),
kind: 'mcp-remove',
findingId: null,
description,
changes: [],
status: 'applied',
}
}