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
+80
-26
@@ -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) }
|
||||
}
|
||||
Reference in new issue
Block a user