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:
Julian lechnerandClaude Opus 5.5 committed 2026-10-01 13:59:10 +02:00
1 parent 9fc6dc8038
commit dec926fee3
4 files changed
+305 -86

No files matched your search

+80 -26
View File
@@ -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) }
}