Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions scripts/service-state-backup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,58 @@ function create(repoPath, requestedOutput) {
}
}

function restore(repoPath, inputPath) {
const verified = verify(repoPath, inputPath)
const repoRoot = realpathSync(resolve(repoPath))
const manifest = validateManifest(JSON.parse(readFileSync(join(verified.input, MANIFEST), "utf8")))
const roots = [...RECORD_ROOTS, OUTPUT_ROOT].filter(root => verified.roots.includes(root))
const release = acquireLock(queuePaths(repoRoot).lockDir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the clean target when restore is rejected

When a verified restore later fails—for example because clients/ already exists—acquireLock has already created runs/service-engine/ for the queue lock, and releasing the lock removes only .lock. The command therefore mutates a target it reports as rejected, violating the zero-write/fail-closed restore behavior; the new tests miss this because snap() records files but ignores empty directories. Acquire the lock without leaving new parents behind, or remove parents created solely for the failed restore.

AGENTS.md reference: AGENTS.md:L2-L3

Useful? React with 👍 / 👎.

let staging = ""
let swapped = []
let committed = false
try {
for (const root of roots) {
assert(!entryExists(join(repoRoot, root)), `restore target already exists: ${root}`)
}
staging = join(dirname(repoRoot), `.service-restore-staging-${randomUUID()}`)
mkdirSync(staging, {mode: 0o700})
chmodSync(staging, 0o700)
for (const root of roots) {
const destination = join(staging, root)
privateDirectory(destination, staging)
walk(join(verified.input, root), {copyTo: destination})
}
assert.deepEqual(walk(staging, {verifyModes: true}), manifest.entries, "staged restore does not match the backup manifest")
for (const root of roots) {
const target = join(repoRoot, root)
mkdirSync(dirname(target), {recursive: true})
renameSync(join(staging, root), target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the multi-root swap recoverable after process termination

If the restore process is killed or the machine loses power after one renameSync but before all roots are swapped, the catch block never runs, leaving a partial mix of restored and absent canonical roots. A subsequent restore then refuses to run because the first restored root already exists, so recovery requires unsafe manual cleanup; the injected exception test does not cover this termination scenario. Use a durable restore journal/recovery path or a genuinely atomic commit boundary for the multi-root swap.

AGENTS.md reference: AGENTS.md:L2-L3

Useful? React with 👍 / 👎.

swapped.push(root)
if (process.env.SERVICE_BACKUP_TEST_INTERRUPT_SWAP === "1") {
throw new Error("service state restore interrupted mid-swap (test injection)")
}
}
committed = true
for (const root of swapped) {
let current = dirname(join(repoRoot, root))
while (current !== repoRoot && containedBy(repoRoot, current)) {
chmodSync(current, 0o700)
current = dirname(current)
}
}
if (staging) rmSync(staging, {recursive: true, force: true})
return {status: "restored", input: verified.input, target: repoRoot, roots, files: verified.files}
} catch (error) {
if (!committed) {
for (const root of swapped) rmSync(join(repoRoot, root), {recursive: true, force: true})
}
if (staging) rmSync(staging, {recursive: true, force: true})
throw error
} finally {
release()
}
}

function option(args, name) {
const index = args.findIndex(value => value === `--${name}` || value.startsWith(`--${name}=`))
if (index < 0) return ""
Expand All @@ -214,9 +266,10 @@ function option(args, name) {

try {
const [mode, ...args] = process.argv.slice(2)
assert(["create", "verify"].includes(mode), "usage: service-state-backup.mjs create --output /absolute/path | verify --input /absolute/path")
assert(["create", "verify", "restore"].includes(mode), "usage: service-state-backup.mjs create --output /absolute/path | verify --input /absolute/path | restore --input /absolute/path")
const repoRoot = process.env.SERVICE_REPO_ROOT || process.cwd()
console.log(JSON.stringify(mode === "create" ? create(repoRoot, option(args, "output")) : verify(repoRoot, option(args, "input")), null, 2))
const result = mode === "create" ? create(repoRoot, option(args, "output")) : mode === "verify" ? verify(repoRoot, option(args, "input")) : restore(repoRoot, option(args, "input"))
console.log(JSON.stringify(result, null, 2))
} catch (error) {
console.error(`service state backup failed: ${error.message}`)
process.exit(1)
Expand Down
80 changes: 76 additions & 4 deletions scripts/test-service-engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1120,11 +1120,13 @@ try {
const restoredRoot = join(backupRoundtripParent, "restored")
md(restoredRoot)
for (const root of ["contracts", GROWTH]) cpSync(rp(root), join(restoredRoot, root), {recursive: true})
for (const root of ["clients", "prospects", DECISIONS, "runs"]) {
const source = join(snapshot, root)
if (ex(source)) cpSync(source, join(restoredRoot, root), {recursive: true})
}
const restoredEnv = {...E(QUEUE_TEST_NOW), SERVICE_REPO_ROOT: restoredRoot}
const productRestored = run(BACKUP, ["restore", "--input", snapshot], restoredEnv)
eq(productRestored.status, 0, productRestored.stderr)
const productRestoredOutput = JSON.parse(productRestored.stdout)
eq(productRestoredOutput.status, "restored")
deq(productRestoredOutput.roots, ["clients", "prospects", DECISIONS, "runs/service-engine/outputs"])
assert(productRestoredOutput.files > 0)
for (const [mode, extra] of [
["prepare", []],
["check", []],
Expand Down Expand Up @@ -2195,6 +2197,76 @@ try {
const inRepoBackup = rp("runtime", "in-repo-backup")
neq(run(BACKUP, ["create", "--output", inRepoBackup]).status, 0)
eq(ex(inRepoBackup), false)

// ——— restore: product command into isolated clean-clone targets, fail closed ———
const restoreTarget = join(backupParent, "restore-target")
const prepareRestoreTarget = () => {
rm(restoreTarget, {recursive: true, force: true})
md(restoreTarget)
for (const root of ["contracts", GROWTH]) cpSync(rp(root), join(restoreTarget, root), {recursive: true})
}
const restoreEnv = {...E(), SERVICE_REPO_ROOT: restoreTarget}
prepareRestoreTarget()
const missingRestoreInputBefore = snap(restoreTarget)
const missingRestoreInput = run(BACKUP, ["restore", "--input", join(backupParent, "missing-snapshot")], restoreEnv)
neq(missingRestoreInput.status, 0)
mat(missingRestoreInput.stderr, /no such file or directory/)
deq(snap(restoreTarget), missingRestoreInputBefore)
const missingManifestBackup = join(backupParent, "missing-manifest-snapshot")
cpSync(backupPath, missingManifestBackup, {recursive: true})
un(join(missingManifestBackup, "manifest.json"))
const missingManifestBefore = snap(restoreTarget)
const missingManifestRestore = run(BACKUP, ["restore", "--input", missingManifestBackup], restoreEnv)
neq(missingManifestRestore.status, 0)
mat(missingManifestRestore.stderr, /no such file or directory/)
deq(snap(restoreTarget), missingManifestBefore)
rm(missingManifestBackup, {recursive: true, force: true})
const corruptedBackup = join(backupParent, "corrupted-snapshot")
cpSync(backupPath, corruptedBackup, {recursive: true})
const corruptedBackupDecision = backedUpFiles.find(entry => entry.path.startsWith("service-decisions/"))
const corruptedBackupDecisionPath = join(corruptedBackup, corruptedBackupDecision.path)
wf(corruptedBackupDecisionPath, Buffer.concat([rf(corruptedBackupDecisionPath), Buffer.from(" ")]))
const corruptedRestoreBefore = snap(restoreTarget)
const corruptedRestore = run(BACKUP, ["restore", "--input", corruptedBackup], restoreEnv)
neq(corruptedRestore.status, 0)
mat(corruptedRestore.stderr, /file manifest mismatch/)
deq(snap(restoreTarget), corruptedRestoreBefore)
rm(corruptedBackup, {recursive: true, force: true})
md(join(restoreTarget, "clients", "existing"), {recursive: true})
aw(join(restoreTarget, "clients", "existing", "note.json"), {kept: true})
const existingTargetBefore = snap(restoreTarget)
const existingTargetRestore = run(BACKUP, ["restore", "--input", backupPath], restoreEnv)
neq(existingTargetRestore.status, 0)
mat(existingTargetRestore.stderr, /restore target already exists: clients/)
deq(snap(restoreTarget), existingTargetBefore)
prepareRestoreTarget()
const successfulRestore = run(BACKUP, ["restore", "--input", backupPath], restoreEnv)
eq(successfulRestore.status, 0, successfulRestore.stderr)
const successfulRestoreOutput = JSON.parse(successfulRestore.stdout)
eq(successfulRestoreOutput.status, "restored")
deq(successfulRestoreOutput.roots, ["clients", "prospects", "service-decisions", "runs/service-engine/outputs"])
for (const [relativePath, hash] of Object.entries(snap(backupPath))) {
if (relativePath === "manifest.json") continue
eq(sha256(rf(join(restoreTarget, relativePath))), hash, `restore changed bytes: ${relativePath}`)
}
for (const entry of manifest.entries) {
const restoredEntryPath = join(restoreTarget, entry.path)
eq(statSync(restoredEntryPath).mode & 0o777, entry.type === "directory" ? 0o700 : 0o600, `restore permission mismatch: ${entry.path}`)
}
const reBackupPath = join(backupParent, "re-backup")
eq(run(BACKUP, ["create", "--output", reBackupPath], {...E(), SERVICE_REPO_ROOT: restoreTarget}).status, 0)
deq(rj(join(reBackupPath, "manifest.json")).entries, manifest.entries)
eq(run(BACKUP, ["verify", "--input", reBackupPath]).status, 0)
prepareRestoreTarget()
const interruptedBefore = snap(restoreTarget)
const interruptedRestore = run(BACKUP, ["restore", "--input", backupPath], {...restoreEnv, SERVICE_BACKUP_TEST_INTERRUPT_SWAP: "1"})
neq(interruptedRestore.status, 0)
mat(interruptedRestore.stderr, /interrupted mid-swap/)
deq(snap(restoreTarget), interruptedBefore)
for (const root of ["clients", "prospects", "service-decisions", "runs/service-engine/outputs"]) eq(ex(join(restoreTarget, root)), false)
assert(!readdirSync(backupParent).some(name => name.startsWith(".service-restore-staging-")))
eq(run(BACKUP, ["verify", "--input", backupPath]).status, 0)
rm(restoreTarget, {recursive: true, force: true})
} finally {
rm(backupParent, {recursive: true, force: true})
}
Expand Down