feat(install): roll back interrupted syncs from a journal
Plan each sync before writing, record the plan in .ai-config-transaction.json, and roll an interrupted destination back from its backups on the next run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fc6dc8038
commit
dec926fee3
4 files changed
+305
-86
No files matched your search
@@ -150,7 +150,47 @@ try {
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'stale-lock'
|
||||
if (Test-Path -LiteralPath $lockPath) { throw 'A stale lock from an exited process must be replaced and released.' }
|
||||
|
||||
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration' }
|
||||
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config-transaction.json')) { throw 'A completed sync must delete its transaction journal.' }
|
||||
if ([System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination)).Contains('.ai-config-transaction')) { throw 'The transaction journal must never be listed in the manifest.' }
|
||||
|
||||
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
|
||||
$txSource = Join-Path $work 'tx-source'
|
||||
$txDestination = Join-Path $work 'tx-destination'
|
||||
New-Item $txSource -ItemType Directory -Force | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $txSource 'keep.md'), 'v1')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txSource 'gone.md'), 'G')
|
||||
$null = Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx1'
|
||||
New-Item (Join-Path $txDestination 'backups/tx2') -ItemType Directory -Force | Out-Null
|
||||
Copy-Item (Join-Path $txDestination 'keep.md') (Join-Path $txDestination 'backups/tx2/keep.md')
|
||||
Move-Item (Join-Path $txDestination 'gone.md') (Join-Path $txDestination 'backups/tx2/gone.md')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'keep.md'), 'v2')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'new.md'), 'N')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'edited.md'), 'edited by the user')
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$journalPath = Join-Path $txDestination '.ai-config-transaction.json'
|
||||
$journal = [ordered]@{ stamp = 'tx2'; files = @(
|
||||
[ordered]@{ path = 'keep.md'; action = 'update'; backup = 'tx2/keep.md'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('v2')) },
|
||||
[ordered]@{ path = 'gone.md'; action = 'remove'; backup = 'tx2/gone.md' },
|
||||
[ordered]@{ path = 'new.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('N')) },
|
||||
[ordered]@{ path = 'edited.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('E')) },
|
||||
[ordered]@{ path = 'unreached.md'; action = 'update'; backup = 'tx2/unreached.md'; sha256 = ('0' * 64) }
|
||||
) }
|
||||
[System.IO.File]::WriteAllText($journalPath, (ConvertTo-Json -InputObject $journal -Depth 4))
|
||||
$outTxDry = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx-dry' -DryRun)
|
||||
if (@($outTxDry | Where-Object { $_ -eq "DRYRUN ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Dry run must report the pending rollback: $($outTxDry -join '; ')" }
|
||||
if (-not (Test-Path -LiteralPath $journalPath) -or [System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v2') { throw 'Dry run must not roll back.' }
|
||||
$outTx = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx3')
|
||||
if (@($outTx | Where-Object { $_ -eq "ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Expected a ROLLBACK line: $($outTx -join '; ')" }
|
||||
if (@($outTx | Where-Object { $_ -match '^WARN .*edited\.md' }).Count -ne 1) { throw "A created file changed since the interruption must be reported: $($outTx -join '; ')" }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v1') { throw 'Rollback must restore an updated file from its backup.' }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'gone.md')) -ne 'G') { throw 'Rollback must restore a removed file from its backup.' }
|
||||
if (Test-Path -LiteralPath (Join-Path $txDestination 'new.md')) { throw 'Rollback must delete a file the interrupted run created.' }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'edited.md')) -ne 'edited by the user') { throw 'Rollback must keep a created file that changed since.' }
|
||||
if (Test-Path -LiteralPath (Join-Path $txDestination 'unreached.md')) { throw 'Rollback must skip entries the interrupted run never reached.' }
|
||||
if (Test-Path -LiteralPath $journalPath) { throw 'Rollback must delete the journal.' }
|
||||
if (@($outTx | Where-Object { $_ -notlike 'UNCHANGED*' -and $_ -notlike 'SOURCE *' -and $_ -notlike 'ROLLBACK *' -and $_ -notlike 'WARN *' }).Count -ne 0) { throw "The sync after a rollback must find the prior state: $($outTx -join '; ')" }
|
||||
|
||||
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback' }
|
||||
} finally {
|
||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -155,5 +155,44 @@ wait "$exited_pid"
|
||||
printf 'bash %s %s\n' "$(uname -n)" "$exited_pid" > "$destination/.ai-config.lock"
|
||||
sync_managed_destination "$source_dir" "$destination" stale-lock "" "" "" false > /dev/null
|
||||
[ ! -e "$destination/.ai-config.lock" ] || { printf 'A stale lock from an exited process must be replaced and released.\n' >&2; exit 1; }
|
||||
[ ! -e "$destination/.ai-config-transaction.json" ] || { printf 'A completed sync must delete its transaction journal.\n' >&2; exit 1; }
|
||||
! grep -q 'ai-config-transaction' "$destination/.ai-config-manifest.tsv" || { printf 'The transaction journal must never be listed in the manifest.\n' >&2; exit 1; }
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration\n'; fi
|
||||
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
|
||||
tx_source="$work/tx-source"
|
||||
tx_destination="$work/tx-destination"
|
||||
mkdir -p "$tx_source"
|
||||
printf 'v1' > "$tx_source/keep.md"
|
||||
printf 'G' > "$tx_source/gone.md"
|
||||
sync_managed_destination "$tx_source" "$tx_destination" tx1 "" "" "" false > /dev/null
|
||||
mkdir -p "$tx_destination/backups/tx2"
|
||||
cp "$tx_destination/keep.md" "$tx_destination/backups/tx2/keep.md"
|
||||
mv "$tx_destination/gone.md" "$tx_destination/backups/tx2/gone.md"
|
||||
printf 'v2' > "$tx_destination/keep.md"
|
||||
printf 'N' > "$tx_destination/new.md"
|
||||
printf 'edited by the user' > "$tx_destination/edited.md"
|
||||
journal="$tx_destination/.ai-config-transaction.json"
|
||||
cat > "$journal" <<EOF
|
||||
{"stamp": "tx2", "files": [
|
||||
{"path": "keep.md", "action": "update", "backup": "tx2/keep.md", "sha256": "$(sha256_of_string v2)"},
|
||||
{"path": "gone.md", "action": "remove", "backup": "tx2/gone.md"},
|
||||
{"path": "new.md", "action": "create", "sha256": "$(sha256_of_string N)"},
|
||||
{"path": "edited.md", "action": "create", "sha256": "$(sha256_of_string E)"},
|
||||
{"path": "unreached.md", "action": "update", "backup": "tx2/unreached.md", "sha256": "$(printf '0%.0s' {1..64})"}
|
||||
]}
|
||||
EOF
|
||||
out_tx_dry=$(sync_managed_destination "$tx_source" "$tx_destination" tx-dry "" "" "" true)
|
||||
printf '%s\n' "$out_tx_dry" | grep -qx "DRYRUN ROLLBACK $tx_destination tx2" || { printf 'Dry run must report the pending rollback:\n%s\n' "$out_tx_dry" >&2; exit 1; }
|
||||
[ -f "$journal" ] && [ "$(cat "$tx_destination/keep.md")" = v2 ] || { printf 'Dry run must not roll back.\n' >&2; exit 1; }
|
||||
out_tx=$(sync_managed_destination "$tx_source" "$tx_destination" tx3 "" "" "" false)
|
||||
printf '%s\n' "$out_tx" | grep -qx "ROLLBACK $tx_destination tx2" || { printf 'Expected a ROLLBACK line:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
[ "$(printf '%s\n' "$out_tx" | grep -c '^WARN .*edited\.md')" -eq 1 ] || { printf 'A created file changed since the interruption must be reported:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/keep.md")" = v1 ] || { printf 'Rollback must restore an updated file from its backup.\n' >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/gone.md")" = G ] || { printf 'Rollback must restore a removed file from its backup.\n' >&2; exit 1; }
|
||||
[ ! -e "$tx_destination/new.md" ] || { printf 'Rollback must delete a file the interrupted run created.\n' >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/edited.md")" = 'edited by the user' ] || { printf 'Rollback must keep a created file that changed since.\n' >&2; exit 1; }
|
||||
[ ! -e "$tx_destination/unreached.md" ] || { printf 'Rollback must skip entries the interrupted run never reached.\n' >&2; exit 1; }
|
||||
[ ! -e "$journal" ] || { printf 'Rollback must delete the journal.\n' >&2; exit 1; }
|
||||
[ -z "$(printf '%s\n' "$out_tx" | grep -Ev '^(UNCHANGED|SOURCE|ROLLBACK|WARN) ')" ] || { printf 'The sync after a rollback must find the prior state:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback\n'; fi
|
||||
Reference in new issue
Block a user