From dec926fee310940618a05685bb882b99308aec63 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Thu, 1 Oct 2026 13:59:10 +0200 Subject: [PATCH] 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 --- scripts/lib/manifest.ps1 | 106 +++++++++---- scripts/lib/manifest.sh | 202 ++++++++++++++++++------- scripts/tests/test-managed-install.ps1 | 42 ++++- scripts/tests/test-managed-install.sh | 41 ++++- 4 files changed, 305 insertions(+), 86 deletions(-) diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index 4ead13e..2b49182 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -180,6 +180,34 @@ function Invoke-ManagedSync { $oldManifest = Read-ManagedManifest $manifestPath $newManifest = @{} $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' if ($backupRoot -ne $oldBackupRoot -and (Test-Path -LiteralPath $oldBackupRoot)) { if ($DryRun) { @@ -189,8 +217,9 @@ function Invoke-ManagedSync { 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 { $relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/') if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return } @@ -224,42 +253,67 @@ function Invoke-ManagedSync { $newManifest[$relative] = $newHash $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 ($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++ } + $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 }) } - foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) { $target = Resolve-ManagedPath -Destination $Destination -Relative $relative if (-not (Test-Path -LiteralPath $target)) { continue } - if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; continue } - $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 ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) { + # A stale file changed locally is backed up but kept; only an unmodified one is removed. + $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 + # 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 ($item.Action -eq 'REMOVE') { Remove-Item -LiteralPath $target -Force if (-not $Summary) { Write-Output "REMOVE $target" } $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." $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) } } diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index 6255cc5..74b51cf 100644 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -76,6 +76,12 @@ write_managed_file() { { cat -- "$content_file" > "$temporary" && chmod "$mode" "$temporary" && mv -f -- "$temporary" "$target"; } || { rm -f -- "$temporary"; return 1; } } +# json_string : 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 write_managed_manifest() { local path=$1 content @@ -133,6 +139,52 @@ run_managed_sync() { local created=0 updated=0 unchanged=0 removed=0 warned=0 local backup_root old_backup_root 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 if [ "$backup_root" != "$old_backup_root" ] && [ -e "$old_backup_root" ]; then if [ "$dry_run" = true ]; then @@ -146,6 +198,8 @@ run_managed_sync() { 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 local relative target content needs_sub new_hash exists current_hash action backup relative=${source_file#"$source/"} @@ -186,47 +240,15 @@ run_managed_sync() { fi 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=CREATE + if [ -f "$target" ]; then action=UPDATE - else - action=CREATE + current_hash=$(sha256_of_file "$target") + [ "$current_hash" != "$new_hash" ] || action=UNCHANGED fi - - if [ "$dry_run" = true ]; then - [ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target" - [ "$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)) + [ "$needs_sub" = true ] || content='' + plan_relative+=("$relative"); plan_target+=("$target"); plan_action+=("$action"); plan_hash+=("$new_hash") + plan_source+=("$source_file"); plan_sub+=("$needs_sub"); plan_content+=("$content") done < <(find "$source" -type f -print0) local relative target current_hash backup @@ -234,27 +256,91 @@ run_managed_sync() { [ -z "${new_manifest[$relative]+x}" ] || continue target=$(managed_target "$destination" "$relative") || return 1 [ -f "$target" ] || continue - if [ "$dry_run" = true ]; then - [ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target" - removed=$((removed + 1)) - continue - fi - 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" - current_hash=$(sha256_of_file "$target") - if [ "$current_hash" = "${old_manifest[$relative]}" ]; then - rm -f -- "$target" - [ "$summary" = true ] || printf 'REMOVE %s\n' "$target" - removed=$((removed + 1)) - else - 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)) - fi + # A stale file changed locally is backed up but kept; only an unmodified one is removed. + action=KEEP + [ "$(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 - if [ "$dry_run" != true ]; then write_managed_manifest "$manifest" new_manifest || return 1; fi + 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 + 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 + mkdir -p "$(dirname -- "$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" + fi + case "$action" in + REMOVE) + rm -f -- "$target" + [ "$summary" = true ] || printf 'REMOVE %s\n' "$target" + removed=$((removed + 1)) + 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" + 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 + ;; + 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 + + if [ "$dry_run" != true ]; then + write_managed_manifest "$manifest" new_manifest || return 1 + rm -f -- "$journal" + fi if [ "$summary" = true ]; then printf 'SYNC %s: %s created, %s updated, %s unchanged, %s removed, %s warnings\n' "$destination" "$created" "$updated" "$unchanged" "$removed" "$warned" fi diff --git a/scripts/tests/test-managed-install.ps1 b/scripts/tests/test-managed-install.ps1 index 0d8c89e..c2fb157 100644 --- a/scripts/tests/test-managed-install.ps1 +++ b/scripts/tests/test-managed-install.ps1 @@ -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 } diff --git a/scripts/tests/test-managed-install.sh b/scripts/tests/test-managed-install.sh index 1d552a3..ab27371 100644 --- a/scripts/tests/test-managed-install.sh +++ b/scripts/tests/test-managed-install.sh @@ -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" <&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