mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 06:54:26 +00:00
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.
This commit is contained in:
parent
d756eacae5
commit
2eec2fdc31
4 changed files with 104 additions and 8 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, rename, rm, writeFile } from 'fs/promises'
|
||||
import { lstat, mkdir, rename, rm, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { ActionPlan, ActionRecord, FileChange } from './types.js'
|
||||
|
|
@ -45,7 +45,17 @@ export async function runAction(plan: ActionPlan, actionsDir: string = defaultAc
|
|||
const pc = plan.changes[i]!
|
||||
if (pc.op === 'move') {
|
||||
await mkdir(dirname(pc.movedTo), { recursive: true })
|
||||
await rename(pc.path, pc.movedTo)
|
||||
try {
|
||||
await rename(pc.path, pc.movedTo)
|
||||
} catch (err) {
|
||||
// rename cannot replace a directory destination. It is already
|
||||
// snapshotted (destBackup), so clear it and retry. Other codes
|
||||
// (e.g. a missing source) rethrow before any destination damage.
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EISDIR' && code !== 'ENOTDIR') throw err
|
||||
await rm(pc.movedTo, { recursive: true, force: true })
|
||||
await rename(pc.path, pc.movedTo)
|
||||
}
|
||||
} else {
|
||||
await mkdir(dirname(pc.path), { recursive: true })
|
||||
await writeFile(pc.path, pc.content)
|
||||
|
|
@ -53,8 +63,11 @@ export async function runAction(plan: ActionPlan, actionsDir: string = defaultAc
|
|||
done.push(i)
|
||||
}
|
||||
// Hash after ALL mutations so overlapping changes carry the final state.
|
||||
// Directories get '' (no content hash); drift detection skips them.
|
||||
for (const change of changes) {
|
||||
change.afterHash = (await sha256File(change.op === 'move' ? change.movedTo! : change.path)) ?? ''
|
||||
const p = change.op === 'move' ? change.movedTo! : change.path
|
||||
const st = await lstat(p).catch(() => null)
|
||||
change.afterHash = st && !st.isDirectory() ? (await sha256File(p)) ?? '' : ''
|
||||
}
|
||||
const record: ActionRecord = {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { copyFile, lstat, mkdir, readFile, rename, rm } from 'fs/promises'
|
||||
import { copyFile, cp, lstat, mkdir, readFile, rename, rm } from 'fs/promises'
|
||||
import { createHash } from 'crypto'
|
||||
import { dirname, join } from 'path'
|
||||
import type { FileChange } from './types.js'
|
||||
|
|
@ -11,11 +11,13 @@ export function relBackupPath(id: string, index: number): string {
|
|||
return `backups/${id}/${index}.bak`
|
||||
}
|
||||
|
||||
// Copy src to dest if src exists; return whether it existed so the caller can
|
||||
// record backup: null for a create.
|
||||
// Snapshot src (file or directory tree) to dest if it exists; return whether
|
||||
// it existed so the caller can record backup: null for a create.
|
||||
export async function snapshotFile(src: string, dest: string): Promise<boolean> {
|
||||
try {
|
||||
await copyFile(src, dest)
|
||||
const st = await lstat(src)
|
||||
if (st.isDirectory()) await cp(src, dest, { recursive: true })
|
||||
else await copyFile(src, dest)
|
||||
return true
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
|
|
@ -50,8 +52,14 @@ export async function pathExists(path: string): Promise<boolean> {
|
|||
// overwrote an existing file and an edit of a missing file restore correctly.
|
||||
export async function revertChange(actionsDir: string, change: FileChange): Promise<void> {
|
||||
const restore = async (backup: string, to: string): Promise<void> => {
|
||||
const src = join(actionsDir, backup)
|
||||
await mkdir(dirname(to), { recursive: true })
|
||||
await copyFile(join(actionsDir, backup), to)
|
||||
if ((await lstat(src)).isDirectory()) {
|
||||
await rm(to, { recursive: true, force: true })
|
||||
await cp(src, to, { recursive: true })
|
||||
} else {
|
||||
await copyFile(src, to)
|
||||
}
|
||||
}
|
||||
if (change.op === 'move') {
|
||||
if (await pathExists(change.movedTo!)) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ async function driftedFiles(record: ActionRecord): Promise<string[]> {
|
|||
if (change.op === 'move' && await pathExists(change.path)) {
|
||||
drifted.push(`${change.path} (occupied, undo would overwrite it)`)
|
||||
}
|
||||
if (change.afterHash === '') continue // no content hash (directories)
|
||||
const p = currentPath(change)
|
||||
try {
|
||||
if ((await sha256File(p)) !== change.afterHash) drifted.push(p)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue