codeburn/tests/act-undo.test.ts
AgentSeal 2eec2fdc31 fix(act): support directory moves for archive actions
The archive actions in the acting epic move whole skill/agent directories,
which the framework could not handle: snapshotFile used copyFile (EISDIR on
a directory) and the afterHash pass used readFile. Snapshots now branch on
lstat, copying directory trees with fs.cp recursive. Directories get an
empty afterHash ('' means no content hash) and drift detection skips the
hash comparison for them; the occupied-original-path check still applies and
a missing movedTo still falls back to the backup. Backup restore likewise
branches: a directory snapshot replaces the target (rm then cp recursive).

Apply-side rename cannot replace a directory destination (ENOTEMPTY), so a
move retries once after clearing the already-snapshotted destination,
rethrowing other codes before any destination damage.

Tests: archive a directory tree and restore it byte-identical, move a
directory onto an existing destination directory (destBackup taken, both
trees restored), and dir-move undo with an occupied original path refusing
without --force and overwriting with it.
2026-07-03 10:14:48 +02:00

307 lines
12 KiB
TypeScript

import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runAction } from '../src/act/apply.js'
import { appendRecord, readRecords } from '../src/act/journal.js'
import { DriftError, undoAction } from '../src/act/undo.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-undo-'))
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('undoAction', () => {
it('restores byte-identical content for edit, create, and move, then refuses a second undo', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.bin')
const createPath = join(files, 'created.bin')
const movePath = join(files, 'move.bin')
const moveDest = join(files, 'sub', 'move.bin')
const editOriginal = Buffer.from([0, 1, 2, 3, 255, 254])
const moveOriginal = Buffer.from([10, 20, 30, 40])
await writeFile(editPath, editOriginal)
await writeFile(movePath, moveOriginal)
const rec = await runAction({
kind: 'archive-skill',
description: 'undo test',
changes: [
{ op: 'edit', path: editPath, content: Buffer.from([9, 9, 9]) },
{ op: 'create', path: createPath, content: Buffer.from([7, 7]) },
{ op: 'move', path: movePath, movedTo: moveDest },
],
}, actionsDir)
// 8-char prefix is accepted
await undoAction({ id: rec.id.slice(0, 8) }, { actionsDir })
expect(Buffer.compare(await readFile(editPath), editOriginal)).toBe(0)
expect(existsSync(createPath)).toBe(false)
expect(existsSync(moveDest)).toBe(false)
expect(Buffer.compare(await readFile(movePath), moveOriginal)).toBe(0)
const records = await readRecords(actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.status).toBe('undone')
expect(records[0]!.undoneAt).toBeTruthy()
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toThrow(/already undone/)
})
it('undoes the most recent action with --last and leaves earlier actions applied', async () => {
const { actionsDir, files } = await makeRoot()
const first = join(files, 'first.txt')
const second = join(files, 'second.txt')
await writeFile(first, 'first-old')
await writeFile(second, 'second-old')
const recFirst = await runAction({
kind: 'claude-md-rule', description: 'first', changes: [{ op: 'edit', path: first, content: 'first-new' }],
}, actionsDir)
await runAction({
kind: 'claude-md-rule', description: 'second', changes: [{ op: 'edit', path: second, content: 'second-new' }],
}, actionsDir)
const undone = await undoAction({ last: true }, { actionsDir })
expect(undone.description).toBe('second')
expect(await readFile(second, 'utf-8')).toBe('second-old')
expect(await readFile(first, 'utf-8')).toBe('first-new')
const records = await readRecords(actionsDir)
expect(records.find(r => r.id === recFirst.id)!.status).toBe('applied')
})
it('refuses to undo a drifted file, but --force proceeds', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'drift.txt')
await writeFile(p, 'original')
const rec = await runAction({
kind: 'claude-md-rule', description: 'drift', changes: [{ op: 'edit', path: p, content: 'applied' }],
}, actionsDir)
await writeFile(p, 'user-modified')
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
expect((await readRecords(actionsDir))[0]!.status).toBe('applied')
expect(await readFile(p, 'utf-8')).toBe('user-modified')
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(p, 'utf-8')).toBe('original')
expect((await readRecords(actionsDir))[0]!.status).toBe('undone')
})
it('reverts changes newest-first so overlapping changes restore the original state', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'orig')
const rec = await runAction({
kind: 'shell-config',
description: 'move then edit',
changes: [
{ op: 'move', path: src, movedTo: dest },
{ op: 'edit', path: dest, content: 'edited' },
],
}, actionsDir)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('orig')
expect(existsSync(dest)).toBe(false)
})
it('refuses to undo a move when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'moved-bytes')
const rec = await runAction({
kind: 'archive-skill', description: 'occupied', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await writeFile(src, 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(src, 'utf-8')).toBe('moved-bytes')
expect(existsSync(dest)).toBe(false)
})
it('snapshots an existing move destination and restores both files on undo', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'src-bytes')
await writeFile(dest, 'dest-bytes')
const rec = await runAction({
kind: 'archive-agent', description: 'move onto dest', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(dest, 'utf-8')).toBe('src-bytes')
expect(await readFile(join(actionsDir, rec.changes[0]!.destBackup!), 'utf-8')).toBe('dest-bytes')
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('src-bytes')
expect(await readFile(dest, 'utf-8')).toBe('dest-bytes')
})
it('force-undo of a move whose moved file is gone restores from backup and flips status', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'precious')
const rec = await runAction({
kind: 'archive-command', description: 'gone', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await rm(dest)
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
const undone = await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(undone.status).toBe('undone')
expect(await readFile(src, 'utf-8')).toBe('precious')
})
it('undoing a create that overwrote an existing file restores the prior bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'exists.txt')
await writeFile(p, 'prior')
const rec = await runAction({
kind: 'guard-install', description: 'create over existing', changes: [{ op: 'create', path: p, content: 'new' }],
}, actionsDir)
expect(rec.changes[0]!.backup).not.toBeNull()
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('prior')
})
it('undoes a plan that touches the same path twice back to the original bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'twice.txt')
await writeFile(p, 'v0')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'same path twice',
changes: [
{ op: 'edit', path: p, content: 'v1' },
{ op: 'edit', path: p, content: 'v2' },
],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe(rec.changes[1]!.afterHash)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('v0')
})
it('rejects an ambiguous id prefix with the match count', async () => {
const { actionsDir } = await makeRoot()
await appendRecord(actionsDir, bareRecord('aaaaaaaa-1111-4111-8111-111111111111', 'one'))
await appendRecord(actionsDir, bareRecord('aaaaaaaa-2222-4222-8222-222222222222', 'two'))
await expect(undoAction({ id: 'aaaaaaaa' }, { actionsDir })).rejects.toThrow(/matches 2 actions/)
})
it('archives a directory and undo restores the tree with nested content byte-identical', async () => {
const { actionsDir, files } = await makeRoot()
const dir = join(files, 'skill')
const dest = join(files, '.archived', 'skill')
const nestedBytes = Buffer.from([1, 2, 3, 250, 0])
await mkdir(join(dir, 'nested'), { recursive: true })
await writeFile(join(dir, 'SKILL.md'), 'skill body')
await writeFile(join(dir, 'nested', 'data.bin'), nestedBytes)
const rec = await runAction({
kind: 'archive-skill',
description: 'archive dir',
changes: [{ op: 'move', path: dir, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe('')
expect(rec.changes[0]!.backup).not.toBeNull()
expect(existsSync(dir)).toBe(false)
expect(await readFile(join(dest, 'SKILL.md'), 'utf-8')).toBe('skill body')
await undoAction({ id: rec.id }, { actionsDir })
expect(existsSync(dest)).toBe(false)
expect(await readFile(join(dir, 'SKILL.md'), 'utf-8')).toBe('skill body')
expect(Buffer.compare(await readFile(join(dir, 'nested', 'data.bin')), nestedBytes)).toBe(0)
})
it('moves a directory onto an existing destination directory and undo restores both trees', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'agent')
const dest = join(files, 'agent-archived')
await mkdir(src, { recursive: true })
await mkdir(dest, { recursive: true })
await writeFile(join(src, 'agent.md'), 'src tree')
await writeFile(join(dest, 'old.md'), 'dest tree')
const rec = await runAction({
kind: 'archive-agent',
description: 'dir onto dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(join(dest, 'agent.md'), 'utf-8')).toBe('src tree')
expect(existsSync(join(dest, 'old.md'))).toBe(false)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(join(src, 'agent.md'), 'utf-8')).toBe('src tree')
expect(await readFile(join(dest, 'old.md'), 'utf-8')).toBe('dest tree')
expect(existsSync(join(dest, 'agent.md'))).toBe(false)
})
it('refuses dir-move undo when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'cmd')
const dest = join(files, 'cmd-archived')
await mkdir(src, { recursive: true })
await writeFile(join(src, 'cmd.md'), 'body')
const rec = await runAction({
kind: 'archive-command',
description: 'occupied dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await mkdir(src, { recursive: true })
await writeFile(join(src, 'squatter.md'), 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(join(src, 'cmd.md'), 'utf-8')).toBe('body')
expect(existsSync(join(src, 'squatter.md'))).toBe(false)
expect(existsSync(dest)).toBe(false)
})
})
function bareRecord(id: string, description: string): ActionRecord {
return {
id,
at: new Date().toISOString(),
kind: 'mcp-remove',
findingId: null,
description,
changes: [],
status: 'applied',
}
}