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
+292
-73
No files matched your search
+78
-24
@@ -180,6 +180,34 @@ function Invoke-ManagedSync {
|
|||||||
$oldManifest = Read-ManagedManifest $manifestPath
|
$oldManifest = Read-ManagedManifest $manifestPath
|
||||||
$newManifest = @{}
|
$newManifest = @{}
|
||||||
$backupRoot = Get-ManagedBackupRoot $Destination
|
$backupRoot = Get-ManagedBackupRoot $Destination
|
||||||
|
$tally = @{ Created = 0; Updated = 0; Unchanged = 0; Removed = 0; Warned = 0 }
|
||||||
|
$journalPath = Resolve-ManagedPath -Destination $Destination -Relative '.ai-config-transaction.json'
|
||||||
|
if (Test-Path -LiteralPath $journalPath) {
|
||||||
|
# An earlier run was interrupted after it started writing; undo its changes before syncing.
|
||||||
|
$journal = [System.IO.File]::ReadAllText($journalPath, (New-Object System.Text.UTF8Encoding($false))) | ConvertFrom-Json
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Output "DRYRUN ROLLBACK $Destination $($journal.stamp)"
|
||||||
|
} else {
|
||||||
|
foreach ($entry in @($journal.files)) {
|
||||||
|
$target = Resolve-ManagedPath -Destination $Destination -Relative $entry.path
|
||||||
|
if ($entry.action -eq 'create') {
|
||||||
|
if (-not (Test-Path -LiteralPath $target)) { continue }
|
||||||
|
if ((Get-Sha256Hash $target) -eq "$($entry.sha256)".ToLowerInvariant()) { [System.IO.File]::Delete($target) }
|
||||||
|
else { Write-Output "WARN $target was created by an interrupted run and has changed since; it was left in place."; $tally.Warned++ }
|
||||||
|
} elseif ($entry.action -in @('update', 'remove')) {
|
||||||
|
# A missing backup means the interrupted run never reached this file.
|
||||||
|
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative $entry.backup
|
||||||
|
if (-not (Test-Path -LiteralPath $backup)) { continue }
|
||||||
|
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
||||||
|
Write-ManagedFileAtomically -Path $target -Bytes ([System.IO.File]::ReadAllBytes($backup))
|
||||||
|
} else {
|
||||||
|
throw "Invalid transaction journal action in ${journalPath}: $($entry.action)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Output "ROLLBACK $Destination $($journal.stamp)"
|
||||||
|
[System.IO.File]::Delete($journalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
$oldBackupRoot = Resolve-ManagedPath -Destination $Destination -Relative 'backups'
|
$oldBackupRoot = Resolve-ManagedPath -Destination $Destination -Relative 'backups'
|
||||||
if ($backupRoot -ne $oldBackupRoot -and (Test-Path -LiteralPath $oldBackupRoot)) {
|
if ($backupRoot -ne $oldBackupRoot -and (Test-Path -LiteralPath $oldBackupRoot)) {
|
||||||
if ($DryRun) {
|
if ($DryRun) {
|
||||||
@@ -189,8 +217,9 @@ function Invoke-ManagedSync {
|
|||||||
Move-Item -LiteralPath $oldBackupRoot -Destination (Join-Path $backupRoot ('legacy-' + [Guid]::NewGuid().ToString('N')))
|
Move-Item -LiteralPath $oldBackupRoot -Destination (Join-Path $backupRoot ('legacy-' + [Guid]::NewGuid().ToString('N')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$tally = @{ Created = 0; Updated = 0; Unchanged = 0; Removed = 0; Warned = 0 }
|
|
||||||
|
|
||||||
|
# Plan every change before writing anything, so the journal can describe the whole run.
|
||||||
|
$plan = New-Object System.Collections.ArrayList
|
||||||
Get-ChildItem $Source -File -Recurse | ForEach-Object {
|
Get-ChildItem $Source -File -Recurse | ForEach-Object {
|
||||||
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
|
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
|
||||||
if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return }
|
if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return }
|
||||||
@@ -224,42 +253,67 @@ function Invoke-ManagedSync {
|
|||||||
$newManifest[$relative] = $newHash
|
$newManifest[$relative] = $newHash
|
||||||
|
|
||||||
$exists = Test-Path -LiteralPath $target
|
$exists = Test-Path -LiteralPath $target
|
||||||
if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { $tally.Unchanged++; if (-not $Summary) { Write-Output "UNCHANGED $target" }; return }
|
$action = if (-not $exists) { 'CREATE' } elseif ((Get-Sha256Hash $target) -eq $newHash) { 'UNCHANGED' } else { 'UPDATE' }
|
||||||
|
$null = $plan.Add([pscustomobject]@{ Relative = $relative; Target = $target; Action = $action; Bytes = $newBytes; Hash = $newHash })
|
||||||
$action = if ($exists) { 'UPDATE' } else { 'CREATE' }
|
|
||||||
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $action $target" }; if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }; return }
|
|
||||||
if ($exists) {
|
|
||||||
$wasManaged = $oldManifest.ContainsKey($relative)
|
|
||||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$relative"
|
|
||||||
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
|
||||||
Copy-Item -LiteralPath $target $backup -Force
|
|
||||||
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
|
||||||
if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten."; $tally.Warned++ }
|
|
||||||
}
|
}
|
||||||
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
|
||||||
Write-ManagedFileAtomically -Path $target -Bytes $newBytes
|
|
||||||
if (-not $Summary) { Write-Output "$action $target" }
|
|
||||||
if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
|
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
|
||||||
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
||||||
if (-not (Test-Path -LiteralPath $target)) { continue }
|
if (-not (Test-Path -LiteralPath $target)) { continue }
|
||||||
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; continue }
|
# A stale file changed locally is backed up but kept; only an unmodified one is removed.
|
||||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$relative"
|
$action = if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) { 'REMOVE' } else { 'KEEP' }
|
||||||
|
$null = $plan.Add([pscustomobject]@{ Relative = $relative; Target = $target; Action = $action; Bytes = $null; Hash = $null })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $DryRun) {
|
||||||
|
$journalEntries = @(foreach ($item in $plan) {
|
||||||
|
switch ($item.Action) {
|
||||||
|
'CREATE' { [ordered]@{ path = $item.Relative; action = 'create'; sha256 = $item.Hash } }
|
||||||
|
'UPDATE' { [ordered]@{ path = $item.Relative; action = 'update'; backup = "$Stamp/$($item.Relative)"; sha256 = $item.Hash } }
|
||||||
|
'REMOVE' { [ordered]@{ path = $item.Relative; action = 'remove'; backup = "$Stamp/$($item.Relative)" } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$journalJson = ConvertTo-Json -InputObject ([ordered]@{ stamp = $Stamp; files = $journalEntries }) -Depth 4
|
||||||
|
New-Item $Destination -ItemType Directory -Force | Out-Null
|
||||||
|
Write-ManagedFileAtomically -Path $journalPath -Bytes ((New-Object System.Text.UTF8Encoding($false)).GetBytes($journalJson + "`n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($item in $plan) {
|
||||||
|
$target = $item.Target
|
||||||
|
if ($item.Action -eq 'UNCHANGED') { $tally.Unchanged++; if (-not $Summary) { Write-Output "UNCHANGED $target" }; continue }
|
||||||
|
$isRemoval = $item.Action -in @('REMOVE', 'KEEP')
|
||||||
|
if ($DryRun) {
|
||||||
|
if ($isRemoval) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++ }
|
||||||
|
else { if (-not $Summary) { Write-Output "DRYRUN $($item.Action) $target" }; if ($item.Action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ } }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($item.Action -ne 'CREATE') {
|
||||||
|
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$($item.Relative)"
|
||||||
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
||||||
Copy-Item -LiteralPath $target $backup -Force
|
# Atomic, so a rollback after an interruption never restores a truncated backup.
|
||||||
|
Write-ManagedFileAtomically -Path $backup -Bytes ([System.IO.File]::ReadAllBytes($target))
|
||||||
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
||||||
if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) {
|
}
|
||||||
|
if ($item.Action -eq 'REMOVE') {
|
||||||
Remove-Item -LiteralPath $target -Force
|
Remove-Item -LiteralPath $target -Force
|
||||||
if (-not $Summary) { Write-Output "REMOVE $target" }
|
if (-not $Summary) { Write-Output "REMOVE $target" }
|
||||||
$tally.Removed++
|
$tally.Removed++
|
||||||
} else {
|
continue
|
||||||
|
}
|
||||||
|
if ($item.Action -eq 'KEEP') {
|
||||||
Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed."
|
Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed."
|
||||||
$tally.Warned++
|
$tally.Warned++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if ($item.Action -eq 'UPDATE' -and -not $oldManifest.ContainsKey($item.Relative)) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten."; $tally.Warned++ }
|
||||||
|
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
||||||
|
Write-ManagedFileAtomically -Path $target -Bytes $item.Bytes
|
||||||
|
if (-not $Summary) { Write-Output "$($item.Action) $target" }
|
||||||
|
if ($item.Action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $DryRun) { Write-ManagedManifest $manifestPath $newManifest }
|
if (-not $DryRun) {
|
||||||
|
Write-ManagedManifest $manifestPath $newManifest
|
||||||
|
[System.IO.File]::Delete($journalPath)
|
||||||
|
}
|
||||||
if ($Summary) { Write-Output ("SYNC {0}: {1} created, {2} updated, {3} unchanged, {4} removed, {5} warnings" -f $Destination, $tally.Created, $tally.Updated, $tally.Unchanged, $tally.Removed, $tally.Warned) }
|
if ($Summary) { Write-Output ("SYNC {0}: {1} created, {2} updated, {3} unchanged, {4} removed, {5} warnings" -f $Destination, $tally.Created, $tally.Updated, $tally.Unchanged, $tally.Removed, $tally.Warned) }
|
||||||
}
|
}
|
||||||
+133
-47
@@ -76,6 +76,12 @@ write_managed_file() {
|
|||||||
{ cat -- "$content_file" > "$temporary" && chmod "$mode" "$temporary" && mv -f -- "$temporary" "$target"; } || { rm -f -- "$temporary"; return 1; }
|
{ cat -- "$content_file" > "$temporary" && chmod "$mode" "$temporary" && mv -f -- "$temporary" "$target"; } || { rm -f -- "$temporary"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# json_string <value>: print value as a JSON string. Managed paths and stamps never contain control characters.
|
||||||
|
json_string() {
|
||||||
|
local value=${1//\\/\\\\}
|
||||||
|
printf '"%s"' "${value//\"/\\\"}"
|
||||||
|
}
|
||||||
|
|
||||||
# write_managed_manifest <manifest-file> <assoc-array-name>
|
# write_managed_manifest <manifest-file> <assoc-array-name>
|
||||||
write_managed_manifest() {
|
write_managed_manifest() {
|
||||||
local path=$1 content
|
local path=$1 content
|
||||||
@@ -133,6 +139,52 @@ run_managed_sync() {
|
|||||||
local created=0 updated=0 unchanged=0 removed=0 warned=0
|
local created=0 updated=0 unchanged=0 removed=0 warned=0
|
||||||
local backup_root old_backup_root
|
local backup_root old_backup_root
|
||||||
backup_root=$(managed_backup_root "$destination") || return 1
|
backup_root=$(managed_backup_root "$destination") || return 1
|
||||||
|
local journal
|
||||||
|
journal=$(managed_target "$destination" '.ai-config-transaction.json') || return 1
|
||||||
|
if [ -f "$journal" ]; then
|
||||||
|
# An earlier run was interrupted after it started writing; undo its changes before syncing.
|
||||||
|
local entries entry entry_action entry_path entry_backup entry_hash entry_target
|
||||||
|
local -a journal_lines
|
||||||
|
# One stamp line, then one unit-separated line per file (an empty field must survive read).
|
||||||
|
entries=$(python3 -c '
|
||||||
|
import json, sys
|
||||||
|
journal = json.load(open(sys.argv[1], encoding="utf-8-sig"))
|
||||||
|
lines = [str(journal["stamp"])]
|
||||||
|
for entry in journal.get("files") or []:
|
||||||
|
lines.append("\x1f".join([entry["action"], entry["path"], entry.get("backup", ""), entry.get("sha256", "")]))
|
||||||
|
sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8"))
|
||||||
|
' "$journal") || { printf 'Could not read transaction journal %s\n' "$journal" >&2; return 1; }
|
||||||
|
mapfile -t journal_lines <<< "$entries"
|
||||||
|
if [ "$dry_run" = true ]; then
|
||||||
|
printf 'DRYRUN ROLLBACK %s %s\n' "$destination" "${journal_lines[0]}"
|
||||||
|
else
|
||||||
|
for entry in "${journal_lines[@]:1}"; do
|
||||||
|
IFS=$'\x1f' read -r entry_action entry_path entry_backup entry_hash <<< "$entry"
|
||||||
|
entry_target=$(managed_target "$destination" "$entry_path") || return 1
|
||||||
|
case "$entry_action" in
|
||||||
|
create)
|
||||||
|
[ -f "$entry_target" ] || continue
|
||||||
|
if [ "$(sha256_of_file "$entry_target")" = "$(printf '%s' "$entry_hash" | tr '[:upper:]' '[:lower:]')" ]; then
|
||||||
|
rm -f -- "$entry_target"
|
||||||
|
else
|
||||||
|
printf 'WARN %s was created by an interrupted run and has changed since; it was left in place.\n' "$entry_target"
|
||||||
|
warned=$((warned + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
update|remove)
|
||||||
|
# A missing backup means the interrupted run never reached this file.
|
||||||
|
entry_backup=$(managed_target "$backup_root" "$entry_backup") || return 1
|
||||||
|
[ -f "$entry_backup" ] || continue
|
||||||
|
mkdir -p -- "$(dirname -- "$entry_target")"
|
||||||
|
write_managed_file "$entry_target" "$entry_backup" "$entry_backup" || return 1
|
||||||
|
;;
|
||||||
|
*) printf 'Invalid transaction journal action in %s: %s\n' "$journal" "$entry_action" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'ROLLBACK %s %s\n' "$destination" "${journal_lines[0]}"
|
||||||
|
rm -f -- "$journal"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
old_backup_root=$(managed_target "$destination" backups) || return 1
|
old_backup_root=$(managed_target "$destination" backups) || return 1
|
||||||
if [ "$backup_root" != "$old_backup_root" ] && [ -e "$old_backup_root" ]; then
|
if [ "$backup_root" != "$old_backup_root" ] && [ -e "$old_backup_root" ]; then
|
||||||
if [ "$dry_run" = true ]; then
|
if [ "$dry_run" = true ]; then
|
||||||
@@ -146,6 +198,8 @@ run_managed_sync() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Plan every change before writing anything, so the journal can describe the whole run.
|
||||||
|
local -a plan_relative=() plan_target=() plan_action=() plan_hash=() plan_source=() plan_sub=() plan_content=()
|
||||||
while IFS= read -r -d '' source_file; do
|
while IFS= read -r -d '' source_file; do
|
||||||
local relative target content needs_sub new_hash exists current_hash action backup
|
local relative target content needs_sub new_hash exists current_hash action backup
|
||||||
relative=${source_file#"$source/"}
|
relative=${source_file#"$source/"}
|
||||||
@@ -186,47 +240,15 @@ run_managed_sync() {
|
|||||||
fi
|
fi
|
||||||
new_manifest[$relative]=$new_hash
|
new_manifest[$relative]=$new_hash
|
||||||
|
|
||||||
exists=false
|
|
||||||
[ -f "$target" ] && exists=true
|
|
||||||
if [ "$exists" = true ]; then
|
|
||||||
current_hash=$(sha256_of_file "$target")
|
|
||||||
if [ "$current_hash" = "$new_hash" ]; then
|
|
||||||
unchanged=$((unchanged + 1))
|
|
||||||
[ "$summary" = true ] || printf 'UNCHANGED %s\n' "$target"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
action=UPDATE
|
|
||||||
else
|
|
||||||
action=CREATE
|
action=CREATE
|
||||||
|
if [ -f "$target" ]; then
|
||||||
|
action=UPDATE
|
||||||
|
current_hash=$(sha256_of_file "$target")
|
||||||
|
[ "$current_hash" != "$new_hash" ] || action=UNCHANGED
|
||||||
fi
|
fi
|
||||||
|
[ "$needs_sub" = true ] || content=''
|
||||||
if [ "$dry_run" = true ]; then
|
plan_relative+=("$relative"); plan_target+=("$target"); plan_action+=("$action"); plan_hash+=("$new_hash")
|
||||||
[ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target"
|
plan_source+=("$source_file"); plan_sub+=("$needs_sub"); plan_content+=("$content")
|
||||||
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$exists" = true ]; then
|
|
||||||
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
|
||||||
mkdir -p "$(dirname -- "$backup")"
|
|
||||||
cp -- "$target" "$backup"
|
|
||||||
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
|
||||||
if [ -z "${old_manifest[$relative]+x}" ]; then
|
|
||||||
printf 'WARN %s existed before this installation but was not tracked by a previous run; it was backed up before being overwritten.\n' "$target"
|
|
||||||
warned=$((warned + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
mkdir -p "$(dirname -- "$target")"
|
|
||||||
if [ "$needs_sub" = true ]; then
|
|
||||||
local rendered
|
|
||||||
rendered=$(mktemp) || return 1
|
|
||||||
printf '%s' "$content" > "$rendered" && write_managed_file "$target" "$rendered" "$source_file" || { rm -f -- "$rendered"; return 1; }
|
|
||||||
rm -f -- "$rendered"
|
|
||||||
else
|
|
||||||
write_managed_file "$target" "$source_file" "$source_file" || return 1
|
|
||||||
fi
|
|
||||||
[ "$summary" = true ] || printf '%s %s\n' "$action" "$target"
|
|
||||||
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
|
||||||
done < <(find "$source" -type f -print0)
|
done < <(find "$source" -type f -print0)
|
||||||
|
|
||||||
local relative target current_hash backup
|
local relative target current_hash backup
|
||||||
@@ -234,27 +256,91 @@ run_managed_sync() {
|
|||||||
[ -z "${new_manifest[$relative]+x}" ] || continue
|
[ -z "${new_manifest[$relative]+x}" ] || continue
|
||||||
target=$(managed_target "$destination" "$relative") || return 1
|
target=$(managed_target "$destination" "$relative") || return 1
|
||||||
[ -f "$target" ] || continue
|
[ -f "$target" ] || continue
|
||||||
if [ "$dry_run" = true ]; then
|
# A stale file changed locally is backed up but kept; only an unmodified one is removed.
|
||||||
[ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"
|
action=KEEP
|
||||||
removed=$((removed + 1))
|
[ "$(sha256_of_file "$target")" != "${old_manifest[$relative]}" ] || action=REMOVE
|
||||||
|
plan_relative+=("$relative"); plan_target+=("$target"); plan_action+=("$action"); plan_hash+=('')
|
||||||
|
plan_source+=(''); plan_sub+=(false); plan_content+=('')
|
||||||
|
done
|
||||||
|
|
||||||
|
local index entry journal_content first_entry=true
|
||||||
|
if [ "$dry_run" != true ]; then
|
||||||
|
journal_content=$(mktemp) || return 1
|
||||||
|
{
|
||||||
|
printf '{"stamp": %s, "files": [' "$(json_string "$stamp")"
|
||||||
|
for index in "${!plan_action[@]}"; do
|
||||||
|
case "${plan_action[$index]}" in
|
||||||
|
CREATE) entry=$(printf '{"path": %s, "action": "create", "sha256": "%s"}' "$(json_string "${plan_relative[$index]}")" "${plan_hash[$index]}") ;;
|
||||||
|
UPDATE) entry=$(printf '{"path": %s, "action": "update", "backup": %s, "sha256": "%s"}' "$(json_string "${plan_relative[$index]}")" "$(json_string "$stamp/${plan_relative[$index]}")" "${plan_hash[$index]}") ;;
|
||||||
|
REMOVE) entry=$(printf '{"path": %s, "action": "remove", "backup": %s}' "$(json_string "${plan_relative[$index]}")" "$(json_string "$stamp/${plan_relative[$index]}")") ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
[ "$first_entry" = true ] || printf ', '
|
||||||
|
first_entry=false
|
||||||
|
printf '%s' "$entry"
|
||||||
|
done
|
||||||
|
printf ']}\n'
|
||||||
|
} > "$journal_content" && mkdir -p -- "$destination" && write_managed_file "$journal" "$journal_content" || { rm -f -- "$journal_content"; return 1; }
|
||||||
|
rm -f -- "$journal_content"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for index in "${!plan_action[@]}"; do
|
||||||
|
relative=${plan_relative[$index]} target=${plan_target[$index]} action=${plan_action[$index]}
|
||||||
|
if [ "$action" = UNCHANGED ]; then
|
||||||
|
unchanged=$((unchanged + 1))
|
||||||
|
[ "$summary" = true ] || printf 'UNCHANGED %s\n' "$target"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
if [ "$dry_run" = true ]; then
|
||||||
|
case "$action" in
|
||||||
|
REMOVE|KEEP) [ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"; removed=$((removed + 1)) ;;
|
||||||
|
*) [ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target"; [ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1)) ;;
|
||||||
|
esac
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$action" != CREATE ]; then
|
||||||
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
||||||
mkdir -p "$(dirname -- "$backup")"
|
mkdir -p "$(dirname -- "$backup")"
|
||||||
cp -- "$target" "$backup"
|
# Atomic, so a rollback after an interruption never restores a truncated backup.
|
||||||
|
write_managed_file "$backup" "$target" "$target" || return 1
|
||||||
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
||||||
current_hash=$(sha256_of_file "$target")
|
fi
|
||||||
if [ "$current_hash" = "${old_manifest[$relative]}" ]; then
|
case "$action" in
|
||||||
|
REMOVE)
|
||||||
rm -f -- "$target"
|
rm -f -- "$target"
|
||||||
[ "$summary" = true ] || printf 'REMOVE %s\n' "$target"
|
[ "$summary" = true ] || printf 'REMOVE %s\n' "$target"
|
||||||
removed=$((removed + 1))
|
removed=$((removed + 1))
|
||||||
else
|
continue
|
||||||
|
;;
|
||||||
|
KEEP)
|
||||||
printf 'WARN %s was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed.\n' "$target"
|
printf 'WARN %s was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed.\n' "$target"
|
||||||
warned=$((warned + 1))
|
warned=$((warned + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
UPDATE)
|
||||||
|
if [ -z "${old_manifest[$relative]+x}" ]; then
|
||||||
|
printf 'WARN %s existed before this installation but was not tracked by a previous run; it was backed up before being overwritten.\n' "$target"
|
||||||
|
warned=$((warned + 1))
|
||||||
fi
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
mkdir -p "$(dirname -- "$target")"
|
||||||
|
if [ "${plan_sub[$index]}" = true ]; then
|
||||||
|
local rendered
|
||||||
|
rendered=$(mktemp) || return 1
|
||||||
|
printf '%s' "${plan_content[$index]}" > "$rendered" && write_managed_file "$target" "$rendered" "${plan_source[$index]}" || { rm -f -- "$rendered"; return 1; }
|
||||||
|
rm -f -- "$rendered"
|
||||||
|
else
|
||||||
|
write_managed_file "$target" "${plan_source[$index]}" "${plan_source[$index]}" || return 1
|
||||||
|
fi
|
||||||
|
[ "$summary" = true ] || printf '%s %s\n' "$action" "$target"
|
||||||
|
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$dry_run" != true ]; then write_managed_manifest "$manifest" new_manifest || return 1; fi
|
if [ "$dry_run" != true ]; then
|
||||||
|
write_managed_manifest "$manifest" new_manifest || return 1
|
||||||
|
rm -f -- "$journal"
|
||||||
|
fi
|
||||||
if [ "$summary" = true ]; then
|
if [ "$summary" = true ]; then
|
||||||
printf 'SYNC %s: %s created, %s updated, %s unchanged, %s removed, %s warnings\n' "$destination" "$created" "$updated" "$unchanged" "$removed" "$warned"
|
printf 'SYNC %s: %s created, %s updated, %s unchanged, %s removed, %s warnings\n' "$destination" "$created" "$updated" "$unchanged" "$removed" "$warned"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -150,7 +150,47 @@ try {
|
|||||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'stale-lock'
|
$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 (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 {
|
} finally {
|
||||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
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"
|
printf 'bash %s %s\n' "$(uname -n)" "$exited_pid" > "$destination/.ai-config.lock"
|
||||||
sync_managed_destination "$source_dir" "$destination" stale-lock "" "" "" false > /dev/null
|
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.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