From f7915a3a98f4e10dac3480f45b7938ace520ab24 Mon Sep 17 00:00:00 2001 From: Dailin <71995555+Dailin521@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:05:45 -0700 Subject: [PATCH 01/19] refactor(windows): start application controller layer --- .github/workflows/ci.yml | 2 + .github/workflows/publish.yml | 1 + AGENTS.md | 16 + CodexProviderSync.sln | 30 ++ .../CodexProviderSync.App.csproj | 1 + desktop/CodexProviderSync.App/MainForm.cs | 217 ++++++--- .../AppControllerTests.cs | 293 ++++++++++++ ...CodexProviderSync.Application.Tests.csproj | 25 + .../AppController.cs | 451 ++++++++++++++++++ .../AppControllerCommands.cs | 24 + .../AppModels.cs | 108 +++++ .../CodexProviderSync.Application.csproj | 17 + .../CoreApplicationAdapter.cs | 80 ++++ .../ICoreApplicationAdapter.cs | 26 + docs/AUTOMATION_DESIGN_NOTES.md | 79 +++ test/release-version.test.js | 1 + 16 files changed, 1317 insertions(+), 54 deletions(-) create mode 100644 desktop/CodexProviderSync.Application.Tests/AppControllerTests.cs create mode 100644 desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj create mode 100644 desktop/CodexProviderSync.Application/AppController.cs create mode 100644 desktop/CodexProviderSync.Application/AppControllerCommands.cs create mode 100644 desktop/CodexProviderSync.Application/AppModels.cs create mode 100644 desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj create mode 100644 desktop/CodexProviderSync.Application/CoreApplicationAdapter.cs create mode 100644 desktop/CodexProviderSync.Application/ICoreApplicationAdapter.cs create mode 100644 docs/AUTOMATION_DESIGN_NOTES.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7935cf9..09d530f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: with: dotnet-version: "10.0.x" - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj + - run: dotnet test desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj - run: dotnet test desktop/CodexProviderSync.App.Tests/CodexProviderSync.App.Tests.csproj desktop-macos: @@ -48,6 +49,7 @@ jobs: with: dotnet-version: "10.0.x" - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj + - run: dotnet test desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj - run: dotnet build desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj --configuration Release ci-gate: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b22669d..5196d54 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,6 +45,7 @@ jobs: - run: npm ci - run: npm test - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj + - run: dotnet test desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj - run: dotnet test desktop/CodexProviderSync.App.Tests/CodexProviderSync.App.Tests.csproj - name: Build Windows GUI diff --git a/AGENTS.md b/AGENTS.md index 566263f..870da3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,22 @@ The tool works by updating both: - rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions` - SQLite thread metadata in the resolved Codex state database +## Architecture Direction + +`docs/AUTOMATION_DESIGN_NOTES.md` records the experimental 0.x direction. +It is not a public compatibility contract. No Automation executable, stable +JSONL protocol, or UI probe is currently shipped. + +For Windows GUI work: + +- move UI-independent state, validation, and Core request construction into the + Application/controller layer incrementally +- keep Core authoritative for config, rollout, SQLite, backup, restore, and + storage-safety behavior +- keep WinForms handlers focused on presentation and platform interaction +- preserve observable behavior and add controller tests for each migrated slice +- prefer controller tests over adding new reflection-based MainForm business tests + Resolve SQLite Home in this order: explicit CLI/GUI override, root `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `/sqlite`. Only the default layout may fall back to `/state_5.sqlite`. Never fall back when an explicit/config/environment SQLite Home is missing. On Windows, `\\wsl.localhost\...` and `\\wsl$\...` SQLite Homes are diagnostic-only. SQLite operations for these paths run inside WSL and use the corresponding Linux path. diff --git a/CodexProviderSync.sln b/CodexProviderSync.sln index 800e03d..5e5dd0b 100644 --- a/CodexProviderSync.sln +++ b/CodexProviderSync.sln @@ -11,8 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Mac", "de EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Core", "desktop\CodexProviderSync.Core\CodexProviderSync.Core.csproj", "{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Application", "desktop\CodexProviderSync.Application\CodexProviderSync.Application.csproj", "{A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.App.Tests", "desktop\CodexProviderSync.App.Tests\CodexProviderSync.App.Tests.csproj", "{C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Application.Tests", "desktop\CodexProviderSync.Application.Tests\CodexProviderSync.Application.Tests.csproj", "{F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +63,18 @@ Global {0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x64.Build.0 = Release|Any CPU {0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x86.ActiveCfg = Release|Any CPU {0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x86.Build.0 = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|x64.Build.0 = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Debug|x86.Build.0 = Debug|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|Any CPU.Build.0 = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|x64.ActiveCfg = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|x64.Build.0 = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|x86.ActiveCfg = Release|Any CPU + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A}.Release|x86.Build.0 = Release|Any CPU {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -71,6 +87,18 @@ Global {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Release|x64.Build.0 = Release|Any CPU {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Release|x86.ActiveCfg = Release|Any CPU {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914}.Release|x86.Build.0 = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|x64.Build.0 = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Debug|x86.Build.0 = Debug|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|Any CPU.Build.0 = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|x64.ActiveCfg = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|x64.Build.0 = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|x86.ActiveCfg = Release|Any CPU + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -79,6 +107,8 @@ Global {046C07E6-8117-4F19-B2CF-683D62D944C9} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} {2F00CBA5-C5E9-42BC-9F25-01641E0B8901} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} {0BAD4EAB-F18E-405C-AA1F-0D08B03B599B} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} + {A2A9E0B8-D2F3-4E21-AEF0-6DDE4688D80A} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} {C2B35A41-4A58-49EC-A8D1-65F8AA6D9914} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} + {F4F86035-7CC3-4D8E-BA71-1C87E2A7E2E9} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} EndGlobalSection EndGlobal diff --git a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj index 10833eb..89ac8ad 100644 --- a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj +++ b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj @@ -1,6 +1,7 @@  + diff --git a/desktop/CodexProviderSync.App/MainForm.cs b/desktop/CodexProviderSync.App/MainForm.cs index 0abcc1c..30b846a 100644 --- a/desktop/CodexProviderSync.App/MainForm.cs +++ b/desktop/CodexProviderSync.App/MainForm.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using CodexProviderSync.Application; using CodexProviderSync.Core; namespace CodexProviderSync.App; @@ -8,6 +9,7 @@ public sealed class MainForm : Form private const int ActionGroupMinimumWidth = 460; private readonly CodexSyncService _syncService = new(); + private readonly AppController _appController; private readonly SettingsService _settingsService; private readonly UpdateService _updateService; private readonly ExecutionLogService _executionLogService; @@ -126,6 +128,7 @@ public sealed class MainForm : Form private bool _logFailureReported; private bool _busy; private bool _updateCheckInProgress; + private bool _renderingControllerState; private string? _sqliteOverrideCodexHome; public MainForm() : this(new ExecutionLogService()) @@ -142,6 +145,10 @@ internal MainForm( _settingsService = settingsService ?? new SettingsService(); _updateService = updateService ?? new UpdateService(); _localDate = localDate ?? (() => DateOnly.FromDateTime(DateTime.Now)); + _appController = new AppController(new CoreApplicationAdapter( + _syncService, + _settingsService, + new CodexHomeService())); Text = "Codex Provider Sync"; MinimumSize = new Size(1180, 760); StartPosition = FormStartPosition.CenterScreen; @@ -434,17 +441,42 @@ private Control BuildUpdateConfigPanel() modelOptions.Controls.Add(_modelCustomText, 1, 3); panel.Controls.Add(modelOptions, 0, 1); - _modelAutoRadio.CheckedChanged += (_, _) => UpdateModelOptionsEnabled(); - _modelKeepRadio.CheckedChanged += (_, _) => UpdateModelOptionsEnabled(); - _modelCustomRadio.CheckedChanged += (_, _) => UpdateModelOptionsEnabled(); - _updateConfigCheck.CheckedChanged += (_, _) => UpdateModelOptionsEnabled(); + _modelAutoRadio.CheckedChanged += (_, _) => UpdateControllerModelState(); + _modelKeepRadio.CheckedChanged += (_, _) => UpdateControllerModelState(); + _modelCustomRadio.CheckedChanged += (_, _) => UpdateControllerModelState(); + _modelCustomText.TextChanged += (_, _) => UpdateControllerModelState(); + _updateConfigCheck.CheckedChanged += (_, _) => UpdateControllerModelState(); UpdateModelOptionsEnabled(); return panel; } + private void UpdateControllerModelState() + { + if (_renderingControllerState) + { + return; + } + + _appController.SetUpdateConfig(_updateConfigCheck.Checked); + if (_modelCustomRadio.Checked) + { + _appController.SetModelMode(ModelMode.Custom); + } + else if (_modelKeepRadio.Checked) + { + _appController.SetModelMode(ModelMode.KeepRootModel); + } + else + { + _appController.SetModelMode(ModelMode.FollowProvider); + } + _appController.SetCustomModel(_modelCustomText.Text); + UpdateModelOptionsEnabled(); + } + private void UpdateModelOptionsEnabled() { - bool enabled = _updateConfigCheck.Checked; + bool enabled = !_busy && _updateConfigCheck.Checked; _modelAutoRadio.Enabled = enabled; _modelKeepRadio.Enabled = enabled; _modelCustomRadio.Enabled = enabled; @@ -580,11 +612,25 @@ private void WireEvents() _pruneBackupsButton.Click += async (_, _) => await PruneBackupsAsync(); _checkUpdateButton.Click += async (_, _) => await CheckForUpdatesAsync(UpdateCheckTrigger.Manual); _openLogButton.Click += (_, _) => OpenLogFolder(); - _providerList.SelectedIndexChanged += (_, _) => UpdateSelectionLabel(); + _providerList.SelectedIndexChanged += (_, _) => UpdateControllerProviderSelection(); _codexHomeCombo.Leave += async (_, _) => await PersistHomeSelectionAsync(); _sqliteHomeText.Leave += async (_, _) => await PersistSqliteHomeOverrideAsync(CaptureStorageSelection()); } + private void UpdateControllerProviderSelection() + { + if (_renderingControllerState) + { + return; + } + + string? provider = _providerList.SelectedItems.Count == 0 + ? null + : _providerList.SelectedItems[0].Tag as string; + _appController.SetProvider(provider); + UpdateSelectionLabel(); + } + private async Task LoadStateAsync() { _loadingSettings = true; @@ -597,14 +643,18 @@ private async Task LoadStateAsync() AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已加载设置: {_settingsService.SettingsPath}"); AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 执行日志文件: {_executionLogService.CurrentLogPath}"); _loadingSettings = false; - await RefreshStatusAsync(); + await RunBusyAsync("刷新中...", async () => + { + AppSnapshot snapshot = await Task.Run(async () => await _appController.InitializeAsync()); + await ApplyControllerRefreshAsync(snapshot); + }); } - private async Task RefreshStatusAsync() + private async Task RefreshStatusAsync(string? preferredProviderId = null) { (string codexHome, string? sqliteHome) = CaptureStorageSelection(); await PersistSqliteHomeOverrideAsync((codexHome, sqliteHome)); - await RunBusyAsync("刷新中...", () => RefreshStatusCoreAsync(codexHome, sqliteHome)); + await RunBusyAsync("刷新中...", () => RefreshStatusCoreAsync(codexHome, sqliteHome, preferredProviderId)); } private async Task BrowseCodexHomeAsync() @@ -712,8 +762,7 @@ private async Task AddManualProviderAsync() _settings = _settingsService.AddManualProvider(_settings, provider); await _settingsService.SaveAsync(_settings); _manualProviderText.Clear(); - ReloadProviderList(); - SelectProvider(provider); + RefreshControllerProviderOptions(provider); AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已添加手动 Provider: {provider}"); } @@ -728,16 +777,30 @@ private async Task RemoveManualProviderAsync() _settings = _settingsService.RemoveManualProvider(_settings, provider); await _settingsService.SaveAsync(_settings); - ReloadProviderList(); - SelectProvider(_currentStatus?.CurrentProvider.Provider); + RefreshControllerProviderOptions(_currentStatus?.CurrentProvider.Provider); AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已删除手动 Provider: {provider}"); } + private void RefreshControllerProviderOptions(string? preferredProviderId) + { + if (_currentStatus is null) + { + return; + } + + _appController.ApplyProviderOptions( + _syncService.BuildProviderOptions(_currentStatus, _settings), + preferredProviderId); + ReloadProviderList(); + } + private async Task ExecuteSyncOrSwitchAsync() { (string codexHome, string? sqliteHome) = CaptureStorageSelection(); - string? provider = SelectedProvider(); - if (string.IsNullOrWhiteSpace(provider)) + _appController.SetStorage(codexHome, sqliteHome); + UpdateControllerModelState(); + SyncRequestPreparation preparation = _appController.PrepareSyncRequest(); + if (preparation.ValidationIssues.Contains(AppValidationIssue.ProviderRequired)) { MessageBox.Show(this, "请先选择目标 Provider。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); return; @@ -748,35 +811,50 @@ private async Task ExecuteSyncOrSwitchAsync() return; } + if (preparation.ValidationIssues.Contains(AppValidationIssue.CustomModelRequired)) + { + MessageBox.Show(this, "请填写自定义 model 名称,或改成 \"跟随 provider\"。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (!preparation.IsValid) + { + MessageBox.Show(this, "当前状态无法执行同步,请先刷新后重试。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + PreparedSyncRequest request = preparation.Request!; + string provider = request.ProviderId; + int backupRetentionCount = CurrentBackupRetentionCount(); + await RunBusyAsync("执行中...", async () => { - await PersistSqliteHomeOverrideAsync((codexHome, sqliteHome)); - int backupRetentionCount = CurrentBackupRetentionCount(); + await PersistSqliteHomeOverrideAsync((request.CodexHome, request.SqliteHomeOverride)); SyncResult result; - if (_updateConfigCheck.Checked) + if (request is SwitchProviderRequest switchRequest) { - bool keepRootModel = _modelKeepRadio.Checked; - string? explicitModel = _modelCustomRadio.Checked ? _modelCustomText.Text.Trim() : null; - if (_modelCustomRadio.Checked && string.IsNullOrEmpty(explicitModel)) - { - MessageBox.Show(this, "请填写自定义 model 名称,或改成 \"跟随 provider\"。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } + bool keepRootModel = switchRequest.ModelSelection is KeepRootModelSelection; + string? explicitModel = switchRequest.ModelSelection is CustomModelSelection custom + ? custom.Model + : null; result = await Task.Run(async () => await _syncService.RunSwitchAsync( - codexHome, - provider, + switchRequest.CodexHome, + switchRequest.ProviderId, backupRetentionCount, model: explicitModel, keepRootModel: keepRootModel, - explicitSqliteHome: sqliteHome)); + explicitSqliteHome: switchRequest.SqliteHomeOverride)); } - else + else if (request is SyncProviderRequest syncRequest) { result = await Task.Run(async () => await _syncService.RunSyncAsync( - codexHome, - provider: provider, + syncRequest.CodexHome, + provider: syncRequest.ProviderId, keepCount: backupRetentionCount, - explicitSqliteHome: sqliteHome)); + explicitSqliteHome: syncRequest.SqliteHomeOverride)); + } + else + { + throw new InvalidOperationException("Unsupported prepared sync request."); } _settings = _settingsService.UpdateState(_settings, provider, result.BackupDir, CaptureWindowBounds(), backupRetentionCount); @@ -784,12 +862,11 @@ await RunBusyAsync("执行中...", async () => AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 执行完成"); AppendLog(TextFormatter.FormatSyncResult( result, - _updateConfigCheck.Checked ? "已切换并同步" : "已同步", + request is SwitchProviderRequest ? "已切换并同步" : "已同步", TextFormatter.ChineseSimplified)); AppendLog(FormatModelSyncOutcome(result.ModelSync)); AppendLog(string.Empty); - await RefreshStatusCoreAsync(codexHome, sqliteHome); - SelectProvider(provider); + await RefreshStatusCoreAsync(request.CodexHome, request.SqliteHomeOverride, provider); }); } @@ -1165,34 +1242,45 @@ private void ReloadRecentHomes() private void ReloadProviderList() { - _providerList.BeginUpdate(); - _providerList.Items.Clear(); - - if (_currentStatus is not null) + _renderingControllerState = true; + try { - foreach (ProviderOption option in _syncService.BuildProviderOptions(_currentStatus, _settings)) + _providerList.BeginUpdate(); + _providerList.Items.Clear(); + foreach (ProviderOptionState option in _appController.Snapshot.Providers) { ListViewItem item = new(option.Id) { Tag = option.Id }; - item.SubItems.Add(TextFormatter.FormatProviderSources(option, TextFormatter.ChineseSimplified)); + item.SubItems.Add(TextFormatter.FormatProviderSources(new ProviderOption + { + Id = option.Id, + Sources = option.Sources, + IsCurrentProvider = option.IsCurrentProvider, + IsManual = option.IsManual, + IsSaved = option.IsSaved + }, TextFormatter.ChineseSimplified)); item.SubItems.Add(option.IsCurrentProvider ? "是" : string.Empty); item.SubItems.Add(option.IsManual ? "是" : string.Empty); item.SubItems.Add(option.IsSaved ? "是" : string.Empty); _providerList.Items.Add(item); } + _providerList.EndUpdate(); + SelectProviderInList(_appController.Snapshot.SelectedProviderId); + UpdateSelectionLabel(); + } + finally + { + _renderingControllerState = false; } - - _providerList.EndUpdate(); - SelectProvider(_settings.LastSelectedProvider ?? _currentStatus?.CurrentProvider.Provider); - UpdateSelectionLabel(); } - private void SelectProvider(string? provider) + private void SelectProviderInList(string? provider) { if (string.IsNullOrWhiteSpace(provider)) { + _providerList.SelectedItems.Clear(); return; } @@ -1217,7 +1305,7 @@ private void UpdateSelectionLabel() private string? SelectedProvider() { - return _providerList.SelectedItems.Count == 0 ? null : _providerList.SelectedItems[0].Tag as string; + return _appController.Snapshot.SelectedProviderId; } private string CurrentCodexHome() @@ -1266,14 +1354,33 @@ private void PersistUiState() } } - private async Task RefreshStatusCoreAsync(string codexHome, string? sqliteHome) + private async Task RefreshStatusCoreAsync( + string codexHome, + string? sqliteHome, + string? preferredProviderId = null) { - _currentStatus = await Task.Run(async () => await _syncService.GetStatusAsync( + AppSnapshot snapshot = await Task.Run(async () => await _appController.RefreshAsync( codexHome, - sqliteHome)); - _settings = _settingsService.RecordCodexHome(_settings, _currentStatus.CodexHome); - _settings = _settingsService.MergeDetectedProviders(_settings, _syncService.ExtractDetectedProviderIds(_currentStatus)); - _settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount()); + sqliteHome, + preferredProviderId)); + await ApplyControllerRefreshAsync(snapshot); + } + + private async Task ApplyControllerRefreshAsync(AppSnapshot snapshot) + { + if (snapshot.Activity == AppActivity.Faulted || snapshot.Status is null) + { + throw new InvalidOperationException(snapshot.ErrorMessage ?? "Unable to refresh application state."); + } + + _currentStatus = snapshot.Status; + _settings = await _settingsService.LoadAsync(); + _settings = _settingsService.UpdateState( + _settings, + snapshot.SelectedProviderId, + _settings.LastBackupDirectory, + CaptureWindowBounds(), + CurrentBackupRetentionCount()); await _settingsService.SaveAsync(_settings); _statusBox.Text = TextFormatter.FormatStatus(_currentStatus, TextFormatter.ChineseSimplified); @@ -1327,7 +1434,8 @@ private async Task RunBusyAsync(string stateText, Func action) private void SetBusy(bool busy, string stateText) { - bool sqliteActionsSupported = _currentStatus?.SqliteAccess.Supported != false; + bool sqliteActionsSupported = _appController.Snapshot.Activity == AppActivity.Ready + && _currentStatus?.SqliteAccess.Supported != false; _busy = busy; UseWaitCursor = busy; _busyLabel.Text = stateText; @@ -1352,6 +1460,7 @@ private void SetBusy(bool busy, string stateText) _manualProviderText.Enabled = !busy; _codexHomeCombo.Enabled = !busy; _sqliteHomeText.Enabled = !busy; + UpdateModelOptionsEnabled(); } private static bool PathsEqual(string left, string right) diff --git a/desktop/CodexProviderSync.Application.Tests/AppControllerTests.cs b/desktop/CodexProviderSync.Application.Tests/AppControllerTests.cs new file mode 100644 index 0000000..120e34d --- /dev/null +++ b/desktop/CodexProviderSync.Application.Tests/AppControllerTests.cs @@ -0,0 +1,293 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Application.Tests; + +public sealed class AppControllerTests +{ + [Fact] + public async Task InitializeAsync_LoadsStatusAndKeepsThePreferredProvider() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", "/sqlite-override", "relay"), + RefreshState("relay", supported: true, "openai", "relay")); + AppController controller = new(core); + List activities = []; + controller.SnapshotChanged += snapshot => activities.Add(snapshot.Activity); + + AppSnapshot snapshot = await controller.InitializeAsync(); + + Assert.Equal(AppActivity.Ready, snapshot.Activity); + Assert.Equal("/codex", snapshot.CodexHome); + Assert.Equal("/sqlite-override", snapshot.SqliteHomeOverride); + Assert.Equal("relay", snapshot.SelectedProviderId); + Assert.True(snapshot.Providers.Single(option => option.Id == "relay").IsSelected); + Assert.True(snapshot.Controls.RefreshEnabled); + Assert.True(snapshot.Controls.ExecuteEnabled); + Assert.Contains(AppActivity.Initializing, activities); + Assert.Equal("relay", Assert.Single(core.RefreshRequests).SelectedProviderId); + + SyncRequestPreparation preparation = controller.PrepareSyncRequest(); + Assert.True(preparation.IsValid); + SyncProviderRequest request = Assert.IsType(preparation.Request); + Assert.Equal("relay", request.ProviderId); + } + + [Fact] + public async Task ProviderSelection_IsTypedAndMustReferenceAnAvailableOption() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, null), + RefreshState("openai", supported: true, "openai", "relay")); + AppController controller = new(core); + await controller.InitializeAsync(); + + AppSnapshot selected = controller.SetProvider("relay"); + Assert.Equal("relay", selected.SelectedProviderId); + Assert.True(selected.Providers.Single(option => option.Id == "relay").IsSelected); + Assert.False(selected.Providers.Single(option => option.Id == "openai").IsSelected); + + AppSnapshot cleared = controller.SetProvider(null); + Assert.True(cleared.HasIssue(AppValidationIssue.ProviderRequired)); + Assert.True(cleared.Controls.ExecuteEnabled); + Assert.False(controller.PrepareSyncRequest().IsValid); + + Assert.Throws(() => controller.SetProvider("missing")); + } + + [Fact] + public async Task ProviderOptions_CanBeRebuiltAfterManualSettingsChange() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "openai"), + RefreshState("openai", supported: true, "openai")); + AppController controller = new(core); + await controller.InitializeAsync(); + ProviderOption manual = new() + { + Id = "relay", + Sources = [ProviderSource.Manual], + IsManual = true, + IsSaved = true + }; + + AppSnapshot added = controller.ApplyProviderOptions( + [.. core.CurrentRefreshState.Providers, manual], + "relay"); + Assert.Equal("relay", added.SelectedProviderId); + Assert.True(added.SelectedProvider!.IsManual); + + AppSnapshot removed = controller.ApplyProviderOptions( + core.CurrentRefreshState.Providers, + "relay"); + Assert.Equal("openai", removed.SelectedProviderId); + } + + [Fact] + public async Task ModelMode_DrivesValidationControlsAndPreparedSwitchRequest() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "relay"), + RefreshState("openai", supported: true, "openai", "relay")); + AppController controller = new(core); + await controller.InitializeAsync(); + + AppSnapshot switchEnabled = controller.SetUpdateConfig(true); + Assert.True(switchEnabled.Controls.ModelModeEnabled); + Assert.False(switchEnabled.Controls.CustomModelEnabled); + Assert.IsType( + Assert.IsType(controller.PrepareSyncRequest().Request).ModelSelection); + + AppSnapshot custom = controller.SetModelMode(ModelMode.Custom); + Assert.True(custom.Controls.CustomModelEnabled); + Assert.True(custom.HasIssue(AppValidationIssue.CustomModelRequired)); + Assert.True(custom.Controls.ExecuteEnabled); + + controller.SetCustomModel(" gpt-custom "); + SyncRequestPreparation preparation = controller.PrepareSyncRequest(); + Assert.True(preparation.IsValid); + SwitchProviderRequest request = Assert.IsType(preparation.Request); + CustomModelSelection model = Assert.IsType(request.ModelSelection); + Assert.Equal("gpt-custom", model.Model); + + controller.SetCustomModel("changed-after-prepare"); + controller.SetProvider(null); + Assert.Equal("gpt-custom", model.Model); + Assert.Equal("relay", request.ProviderId); + controller.SetProvider("relay"); + + controller.SetModelMode(ModelMode.KeepRootModel); + Assert.IsType( + Assert.IsType(controller.PrepareSyncRequest().Request).ModelSelection); + + AppSnapshot syncOnly = controller.SetUpdateConfig(false); + Assert.False(syncOnly.Controls.ModelModeEnabled); + Assert.False(syncOnly.Controls.CustomModelEnabled); + Assert.IsType(controller.PrepareSyncRequest().Request); + } + + [Fact] + public async Task Refresh_UsesExplicitPreferredProviderAndStorageForTheNextRequest() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "openai"), + RefreshState("openai", supported: true, "openai", "relay")); + AppController controller = new(core); + await controller.InitializeAsync(); + + await controller.RefreshAsync("/other-codex", "/other-sqlite", "relay"); + controller.SetStorage("/edited-codex", "/edited-sqlite"); + + Assert.Equal("relay", controller.Snapshot.SelectedProviderId); + SyncProviderRequest request = Assert.IsType(controller.PrepareSyncRequest().Request); + Assert.Equal("/edited-codex", request.CodexHome); + Assert.Equal("/edited-sqlite", request.SqliteHomeOverride); + } + + [Fact] + public async Task RefreshInProgress_DisablesModelInputsAndRejectsEdits() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "openai"), + RefreshState("openai", supported: true, "openai")); + AppController controller = new(core); + await controller.InitializeAsync(); + controller.SetUpdateConfig(true); + + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + core.RefreshHandler = (_, _) => pending.Task; + Task refresh = controller.RefreshAsync("/codex"); + + Assert.Equal(AppActivity.Refreshing, controller.Snapshot.Activity); + Assert.False(controller.Snapshot.Controls.ModelModeEnabled); + Assert.False(controller.Snapshot.Controls.CustomModelEnabled); + Assert.Throws(() => controller.SetModelMode(ModelMode.Custom)); + + pending.SetResult(RefreshState("openai", supported: true, "openai")); + await refresh; + } + + [Fact] + public async Task ConcurrentRefresh_IsRejectedWithoutReplacingTheActiveOperation() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "openai"), + RefreshState("openai", supported: true, "openai")); + AppController controller = new(core); + await controller.InitializeAsync(); + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + core.RefreshHandler = (_, _) => pending.Task; + + Task active = controller.RefreshAsync("/first"); + await Assert.ThrowsAsync( + () => controller.RefreshAsync("/second")); + Assert.Equal("/first", controller.Snapshot.CodexHome); + + pending.SetResult(RefreshState("openai", supported: true, "openai")); + await active; + } + + [Fact] + public async Task Refresh_FallsBackToCurrentProviderAndDisablesUnsupportedSqliteActions() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, "missing"), + RefreshState("openai", supported: false, "openai", "relay")); + AppController controller = new(core); + + AppSnapshot snapshot = await controller.InitializeAsync(); + + Assert.Equal("openai", snapshot.SelectedProviderId); + Assert.True(snapshot.HasIssue(AppValidationIssue.SqliteUnsupported)); + Assert.False(snapshot.Controls.ExecuteEnabled); + Assert.True(snapshot.Controls.RefreshEnabled); + Assert.False(controller.PrepareSyncRequest().IsValid); + } + + [Fact] + public async Task RefreshFailure_LeavesARecoverableFaultedSnapshot() + { + FakeCoreAdapter core = new( + new CoreInitializationState("/codex", null, null), + RefreshState("openai", supported: true, "openai")); + AppController controller = new(core); + await controller.InitializeAsync(); + core.RefreshHandler = (_, _) => throw new InvalidOperationException("refresh failed"); + + AppSnapshot snapshot = await controller.RefreshAsync("/other-codex", "/other-sqlite"); + + Assert.Equal(AppActivity.Faulted, snapshot.Activity); + Assert.Equal("/other-codex", snapshot.CodexHome); + Assert.Equal("/other-sqlite", snapshot.SqliteHomeOverride); + Assert.Equal("refresh failed", snapshot.ErrorMessage); + Assert.True(snapshot.HasIssue(AppValidationIssue.RefreshFailed)); + Assert.False(snapshot.Controls.ExecuteEnabled); + Assert.True(snapshot.Controls.RefreshEnabled); + Assert.False(controller.PrepareSyncRequest().IsValid); + } + + private static CoreRefreshState RefreshState( + string currentProvider, + bool supported, + params string[] providerIds) + { + StatusSnapshot status = new() + { + CodexHome = "/codex", + SqliteHome = "/codex/sqlite", + SqliteAccess = supported + ? SqliteAccessInfo.Direct + : new SqliteAccessInfo(false, "test-unsupported", "unsupported for test"), + CurrentProvider = new CurrentProviderInfo(currentProvider, false), + ConfiguredProviders = providerIds, + RolloutCounts = new ProviderCounts(), + LockedRolloutFiles = [], + UnreadableRolloutFiles = [], + EncryptedContentCounts = new ProviderCounts(), + SqliteCounts = null, + BackupRoot = "/codex/backups_state/provider-sync", + BackupSummary = new BackupSummary { Count = 0, TotalBytes = 0 } + }; + IReadOnlyList providers = providerIds + .Select(providerId => new ProviderOption + { + Id = providerId, + Sources = [ProviderSource.Config], + IsCurrentProvider = string.Equals(providerId, currentProvider, StringComparison.Ordinal) + }) + .ToArray(); + return new CoreRefreshState(status, providers); + } + + private sealed class FakeCoreAdapter : ICoreApplicationAdapter + { + private readonly CoreInitializationState _initialization; + private readonly CoreRefreshState _refreshState; + + public FakeCoreAdapter(CoreInitializationState initialization, CoreRefreshState refreshState) + { + _initialization = initialization; + _refreshState = refreshState; + RefreshHandler = (_, _) => Task.FromResult(_refreshState); + } + + public List RefreshRequests { get; } = []; + + public CoreRefreshState CurrentRefreshState => _refreshState; + + public Func> RefreshHandler { get; set; } + + public Task InitializeAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_initialization); + } + + public Task RefreshAsync( + CoreRefreshRequest request, + CancellationToken cancellationToken = default) + { + RefreshRequests.Add(request); + return RefreshHandler(request, cancellationToken); + } + } +} diff --git a/desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj b/desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj new file mode 100644 index 0000000..c77e770 --- /dev/null +++ b/desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/desktop/CodexProviderSync.Application/AppController.cs b/desktop/CodexProviderSync.Application/AppController.cs new file mode 100644 index 0000000..c6f4527 --- /dev/null +++ b/desktop/CodexProviderSync.Application/AppController.cs @@ -0,0 +1,451 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Application; + +public sealed class AppController +{ + private readonly ICoreApplicationAdapter _core; + private readonly object _stateGate = new(); + private AppSnapshot _snapshot; + + public AppController(ICoreApplicationAdapter core) + { + _core = core ?? throw new ArgumentNullException(nameof(core)); + _snapshot = Recalculate(new AppSnapshot()); + } + + public AppSnapshot Snapshot + { + get + { + lock (_stateGate) + { + return _snapshot; + } + } + } + + public event Action? SnapshotChanged; + + public Task DispatchAsync( + AppControllerCommand command, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + return command switch + { + InitializeAppCommand => InitializeAsync(cancellationToken), + RefreshStatusCommand refresh => RefreshAsync( + refresh.CodexHome, + refresh.SqliteHomeOverride, + refresh.PreferredProviderId, + cancellationToken), + SetStorageCommand storage => Task.FromResult(SetStorage( + storage.CodexHome, + storage.SqliteHomeOverride)), + SelectProviderCommand select => Task.FromResult(SetProvider(select.ProviderId)), + SetUpdateConfigCommand update => Task.FromResult(SetUpdateConfig(update.Enabled)), + SetModelModeCommand model => Task.FromResult(SetModelMode(model.Mode)), + SetCustomModelCommand custom => Task.FromResult(SetCustomModel(custom.Value)), + _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Unknown controller command.") + }; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + AppSnapshot previous = BeginOperation(AppActivity.Initializing); + try + { + CoreInitializationState initial = await _core.InitializeAsync(cancellationToken); + Publish(Snapshot with + { + CodexHome = initial.CodexHome, + SqliteHomeOverride = NormalizeOptional(initial.SqliteHomeOverride), + SelectedProviderId = NormalizeOptional(initial.PreferredProviderId) + }); + return await RefreshCoreAsync( + initial.CodexHome, + initial.SqliteHomeOverride, + initial.PreferredProviderId, + cancellationToken); + } + catch (OperationCanceledException) + { + Publish(previous); + throw; + } + catch (Exception error) + { + return Publish(Snapshot with + { + Activity = AppActivity.Faulted, + ErrorMessage = error.Message + }); + } + } + + public async Task RefreshAsync( + string codexHome, + string? sqliteHomeOverride = null, + string? preferredProviderId = null, + CancellationToken cancellationToken = default) + { + AppSnapshot previous = BeginOperation( + AppActivity.Refreshing, + codexHome, + sqliteHomeOverride); + try + { + return await RefreshCoreAsync( + codexHome, + sqliteHomeOverride, + NormalizeOptional(preferredProviderId) ?? previous.SelectedProviderId, + cancellationToken); + } + catch (OperationCanceledException) + { + Publish(previous); + throw; + } + catch (Exception error) + { + return Publish(Snapshot with + { + Activity = AppActivity.Faulted, + ErrorMessage = error.Message + }); + } + } + + public AppSnapshot SetStorage(string codexHome, string? sqliteHomeOverride = null) + { + if (string.IsNullOrWhiteSpace(codexHome)) + { + throw new ArgumentException("Codex Home is required.", nameof(codexHome)); + } + + return UpdateEditable(snapshot => snapshot with + { + CodexHome = codexHome.Trim(), + SqliteHomeOverride = NormalizeOptional(sqliteHomeOverride) + }); + } + + public AppSnapshot SetProvider(string? providerId) + { + string? normalizedProviderId = NormalizeOptional(providerId); + return UpdateEditable(snapshot => + { + if (normalizedProviderId is not null + && !snapshot.Providers.Any(option => string.Equals(option.Id, normalizedProviderId, StringComparison.Ordinal))) + { + throw new ArgumentOutOfRangeException( + nameof(providerId), + providerId, + "The provider is not present in the current provider options."); + } + + return snapshot with { SelectedProviderId = normalizedProviderId }; + }); + } + + public AppSnapshot ApplyProviderOptions( + IReadOnlyList providers, + string? preferredProviderId = null) + { + ArgumentNullException.ThrowIfNull(providers); + return UpdateEditable(snapshot => + { + string? selectedProviderId = ResolveSelection( + NormalizeOptional(preferredProviderId) ?? snapshot.SelectedProviderId, + snapshot.Status?.CurrentProvider.Provider, + providers); + IReadOnlyList providerStates = providers + .Select(option => new ProviderOptionState( + option.Id, + Array.AsReadOnly(option.Sources.ToArray()), + option.IsCurrentProvider, + option.IsManual, + option.IsSaved, + string.Equals(option.Id, selectedProviderId, StringComparison.Ordinal))) + .ToList() + .AsReadOnly(); + + return snapshot with + { + Providers = providerStates, + SelectedProviderId = selectedProviderId + }; + }); + } + + public AppSnapshot SetUpdateConfig(bool enabled) + { + return UpdateEditable(snapshot => snapshot with { UpdateConfig = enabled }); + } + + public AppSnapshot SetModelMode(ModelMode mode) + { + if (!Enum.IsDefined(mode)) + { + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown model mode."); + } + + return UpdateEditable(snapshot => snapshot with { ModelMode = mode }); + } + + public AppSnapshot SetCustomModel(string value) + { + return UpdateEditable(snapshot => snapshot with { CustomModel = value ?? string.Empty }); + } + + public SyncRequestPreparation PrepareSyncRequest() + { + AppSnapshot snapshot = Snapshot; + AppValidationIssue[] blockingIssues = snapshot.ValidationIssues + .Where(static issue => issue is + AppValidationIssue.OperationInProgress or + AppValidationIssue.RefreshFailed or + AppValidationIssue.StatusUnavailable or + AppValidationIssue.ProviderRequired or + AppValidationIssue.CustomModelRequired or + AppValidationIssue.SqliteUnsupported) + .ToArray(); + if (blockingIssues.Length > 0) + { + return new SyncRequestPreparation(null, Array.AsReadOnly(blockingIssues)); + } + + string providerId = snapshot.SelectedProviderId!; + PreparedSyncRequest request; + if (!snapshot.UpdateConfig) + { + request = new SyncProviderRequest( + snapshot.CodexHome, + snapshot.SqliteHomeOverride, + providerId); + } + else + { + SwitchModelSelection modelSelection = snapshot.ModelMode switch + { + ModelMode.FollowProvider => new FollowProviderModelSelection(), + ModelMode.KeepRootModel => new KeepRootModelSelection(), + ModelMode.Custom => new CustomModelSelection(snapshot.CustomModel.Trim()), + _ => throw new InvalidOperationException("Unknown model mode.") + }; + request = new SwitchProviderRequest( + snapshot.CodexHome, + snapshot.SqliteHomeOverride, + providerId, + modelSelection); + } + + return new SyncRequestPreparation(request, []); + } + + private async Task RefreshCoreAsync( + string codexHome, + string? sqliteHomeOverride, + string? preferredProviderId, + CancellationToken cancellationToken) + { + CoreRefreshState refreshed = await _core.RefreshAsync( + new CoreRefreshRequest(codexHome, sqliteHomeOverride, preferredProviderId), + cancellationToken); + string? selectedProviderId = ResolveSelection( + preferredProviderId, + refreshed.Status.CurrentProvider.Provider, + refreshed.Providers); + IReadOnlyList providers = refreshed.Providers + .Select(option => new ProviderOptionState( + option.Id, + Array.AsReadOnly(option.Sources.ToArray()), + option.IsCurrentProvider, + option.IsManual, + option.IsSaved, + string.Equals(option.Id, selectedProviderId, StringComparison.Ordinal))) + .ToList() + .AsReadOnly(); + + return Publish(Snapshot with + { + Activity = AppActivity.Ready, + CodexHome = refreshed.Status.CodexHome, + SqliteHomeOverride = NormalizeOptional(sqliteHomeOverride), + Status = refreshed.Status, + Providers = providers, + SelectedProviderId = selectedProviderId, + ErrorMessage = null + }); + } + + private AppSnapshot BeginOperation( + AppActivity activity, + string? codexHome = null, + string? sqliteHomeOverride = null) + { + AppSnapshot previous; + AppSnapshot published; + lock (_stateGate) + { + if (IsBusy(_snapshot.Activity)) + { + throw new InvalidOperationException("Another controller operation is already in progress."); + } + + previous = _snapshot; + published = Recalculate(_snapshot with + { + Activity = activity, + CodexHome = codexHome is null ? _snapshot.CodexHome : codexHome.Trim(), + SqliteHomeOverride = codexHome is null ? _snapshot.SqliteHomeOverride : NormalizeOptional(sqliteHomeOverride), + ErrorMessage = null + }); + _snapshot = published; + } + + NotifySnapshotChanged(published); + return previous; + } + + private AppSnapshot UpdateEditable(Func update) + { + ArgumentNullException.ThrowIfNull(update); + AppSnapshot published; + lock (_stateGate) + { + if (IsBusy(_snapshot.Activity)) + { + throw new InvalidOperationException("Controller state cannot be edited while an operation is in progress."); + } + + published = Recalculate(update(_snapshot)); + _snapshot = published; + } + + NotifySnapshotChanged(published); + return published; + } + + private AppSnapshot Publish(AppSnapshot snapshot) + { + AppSnapshot published; + lock (_stateGate) + { + published = Recalculate(snapshot); + _snapshot = published; + } + + NotifySnapshotChanged(published); + return published; + } + + private void NotifySnapshotChanged(AppSnapshot snapshot) + { + Delegate[] handlers = SnapshotChanged?.GetInvocationList() ?? []; + foreach (Action handler in handlers.Cast>()) + { + try + { + handler(snapshot); + } + catch + { + // Observers must not corrupt controller state or leave an + // operation permanently busy. + } + } + } + + private static AppSnapshot Recalculate(AppSnapshot snapshot) + { + string? selectedProviderId = NormalizeOptional(snapshot.SelectedProviderId); + IReadOnlyList providers = snapshot.Providers + .Select(option => option with + { + IsSelected = selectedProviderId is not null + && string.Equals(option.Id, selectedProviderId, StringComparison.Ordinal) + }) + .ToList() + .AsReadOnly(); + + bool busy = IsBusy(snapshot.Activity); + List issues = []; + if (busy) + { + issues.Add(AppValidationIssue.OperationInProgress); + } + if (snapshot.Activity == AppActivity.Faulted) + { + issues.Add(AppValidationIssue.RefreshFailed); + } + if (snapshot.Status is null) + { + issues.Add(AppValidationIssue.StatusUnavailable); + } + if (selectedProviderId is null) + { + issues.Add(AppValidationIssue.ProviderRequired); + } + if (snapshot.UpdateConfig + && snapshot.ModelMode == ModelMode.Custom + && string.IsNullOrWhiteSpace(snapshot.CustomModel)) + { + issues.Add(AppValidationIssue.CustomModelRequired); + } + if (snapshot.Status?.SqliteAccess.Supported == false) + { + issues.Add(AppValidationIssue.SqliteUnsupported); + } + + bool ready = snapshot.Activity == AppActivity.Ready && snapshot.Status is not null; + bool sqliteSupported = snapshot.Status?.SqliteAccess.Supported != false; + // Keep the existing WinForms behavior: provider/custom-model validation + // is shown when Execute is invoked rather than disabling the button. + AppControlAvailability controls = new( + RefreshEnabled: !busy && !string.IsNullOrWhiteSpace(snapshot.CodexHome), + ExecuteEnabled: ready && sqliteSupported, + ProviderSelectionEnabled: ready, + UpdateConfigEnabled: ready, + ModelModeEnabled: ready && snapshot.UpdateConfig, + CustomModelEnabled: ready && snapshot.UpdateConfig && snapshot.ModelMode == ModelMode.Custom); + + return snapshot with + { + Providers = providers, + SelectedProviderId = selectedProviderId, + ValidationIssues = issues.AsReadOnly(), + Controls = controls + }; + } + + private static string? ResolveSelection( + string? preferredProviderId, + string? currentProviderId, + IReadOnlyList providers) + { + string? preferred = NormalizeOptional(preferredProviderId); + if (preferred is not null + && providers.Any(option => string.Equals(option.Id, preferred, StringComparison.Ordinal))) + { + return preferred; + } + + string? current = NormalizeOptional(currentProviderId); + return current is not null + && providers.Any(option => string.Equals(option.Id, current, StringComparison.Ordinal)) + ? current + : null; + } + + private static bool IsBusy(AppActivity activity) + { + return activity is AppActivity.Initializing or AppActivity.Refreshing; + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/desktop/CodexProviderSync.Application/AppControllerCommands.cs b/desktop/CodexProviderSync.Application/AppControllerCommands.cs new file mode 100644 index 0000000..7e9f6e2 --- /dev/null +++ b/desktop/CodexProviderSync.Application/AppControllerCommands.cs @@ -0,0 +1,24 @@ +namespace CodexProviderSync.Application; + +public abstract record AppControllerCommand; + +public sealed record InitializeAppCommand : AppControllerCommand; + +public sealed record RefreshStatusCommand( + string CodexHome, + string? SqliteHomeOverride = null, + string? PreferredProviderId = null) + : AppControllerCommand; + +public sealed record SetStorageCommand( + string CodexHome, + string? SqliteHomeOverride = null) + : AppControllerCommand; + +public sealed record SelectProviderCommand(string? ProviderId) : AppControllerCommand; + +public sealed record SetUpdateConfigCommand(bool Enabled) : AppControllerCommand; + +public sealed record SetModelModeCommand(ModelMode Mode) : AppControllerCommand; + +public sealed record SetCustomModelCommand(string Value) : AppControllerCommand; diff --git a/desktop/CodexProviderSync.Application/AppModels.cs b/desktop/CodexProviderSync.Application/AppModels.cs new file mode 100644 index 0000000..1434dfe --- /dev/null +++ b/desktop/CodexProviderSync.Application/AppModels.cs @@ -0,0 +1,108 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Application; + +public enum AppActivity +{ + Uninitialized, + Initializing, + Refreshing, + Ready, + Faulted +} + +public enum ModelMode +{ + FollowProvider, + KeepRootModel, + Custom +} + +public enum AppValidationIssue +{ + OperationInProgress, + RefreshFailed, + StatusUnavailable, + ProviderRequired, + CustomModelRequired, + SqliteUnsupported +} + +public sealed record ProviderOptionState( + string Id, + IReadOnlyList Sources, + bool IsCurrentProvider, + bool IsManual, + bool IsSaved, + bool IsSelected); + +public sealed record AppControlAvailability( + bool RefreshEnabled, + bool ExecuteEnabled, + bool ProviderSelectionEnabled, + bool UpdateConfigEnabled, + bool ModelModeEnabled, + bool CustomModelEnabled); + +public sealed record AppSnapshot +{ + internal AppSnapshot() + { + } + + public AppActivity Activity { get; internal init; } = AppActivity.Uninitialized; + public string CodexHome { get; internal init; } = string.Empty; + public string? SqliteHomeOverride { get; internal init; } + public StatusSnapshot? Status { get; internal init; } + public IReadOnlyList Providers { get; internal init; } = []; + public string? SelectedProviderId { get; internal init; } + public bool UpdateConfig { get; internal init; } + public ModelMode ModelMode { get; internal init; } = ModelMode.FollowProvider; + public string CustomModel { get; internal init; } = string.Empty; + public IReadOnlyList ValidationIssues { get; internal init; } = []; + public AppControlAvailability Controls { get; internal init; } = new( + RefreshEnabled: false, + ExecuteEnabled: false, + ProviderSelectionEnabled: false, + UpdateConfigEnabled: false, + ModelModeEnabled: false, + CustomModelEnabled: false); + public string? ErrorMessage { get; internal init; } + + public ProviderOptionState? SelectedProvider => Providers.FirstOrDefault(static option => option.IsSelected); + + public bool HasIssue(AppValidationIssue issue) => ValidationIssues.Contains(issue); +} + +public abstract record PreparedSyncRequest( + string CodexHome, + string? SqliteHomeOverride, + string ProviderId); + +public sealed record SyncProviderRequest( + string CodexHome, + string? SqliteHomeOverride, + string ProviderId) + : PreparedSyncRequest(CodexHome, SqliteHomeOverride, ProviderId); + +public abstract record SwitchModelSelection; + +public sealed record FollowProviderModelSelection : SwitchModelSelection; + +public sealed record KeepRootModelSelection : SwitchModelSelection; + +public sealed record CustomModelSelection(string Model) : SwitchModelSelection; + +public sealed record SwitchProviderRequest( + string CodexHome, + string? SqliteHomeOverride, + string ProviderId, + SwitchModelSelection ModelSelection) + : PreparedSyncRequest(CodexHome, SqliteHomeOverride, ProviderId); + +public sealed record SyncRequestPreparation( + PreparedSyncRequest? Request, + IReadOnlyList ValidationIssues) +{ + public bool IsValid => Request is not null && ValidationIssues.Count == 0; +} diff --git a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj new file mode 100644 index 0000000..604f842 --- /dev/null +++ b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + false + 0.3.2 + 0.3.2.0 + 0.3.2.0 + + + + + + + diff --git a/desktop/CodexProviderSync.Application/CoreApplicationAdapter.cs b/desktop/CodexProviderSync.Application/CoreApplicationAdapter.cs new file mode 100644 index 0000000..0234b32 --- /dev/null +++ b/desktop/CodexProviderSync.Application/CoreApplicationAdapter.cs @@ -0,0 +1,80 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Application; + +public sealed class CoreApplicationAdapter : ICoreApplicationAdapter +{ + private readonly CodexSyncService _syncService; + private readonly SettingsService _settingsService; + private readonly CodexHomeService _codexHomeService; + + public CoreApplicationAdapter() + : this(new CodexSyncService(), new SettingsService(), new CodexHomeService()) + { + } + + public CoreApplicationAdapter( + CodexSyncService syncService, + SettingsService settingsService, + CodexHomeService codexHomeService) + { + _syncService = syncService ?? throw new ArgumentNullException(nameof(syncService)); + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _codexHomeService = codexHomeService ?? throw new ArgumentNullException(nameof(codexHomeService)); + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + AppSettings settings = await _settingsService.LoadAsync(); + cancellationToken.ThrowIfCancellationRequested(); + + string codexHome = _codexHomeService.NormalizeCodexHome(settings.LastCodexHome); + string? sqliteHomeOverride = _settingsService.GetSqliteHomeOverride(settings, codexHome); + return new CoreInitializationState( + codexHome, + sqliteHomeOverride, + NormalizeOptional(settings.LastSelectedProvider)); + } + + public async Task RefreshAsync( + CoreRefreshRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + // Other GUI slices still write through SettingsService during the + // incremental migration. Reload here so a refresh never overwrites + // those newer settings with an adapter-local stale copy. + AppSettings settings = await _settingsService.LoadAsync(); + cancellationToken.ThrowIfCancellationRequested(); + string codexHome = _codexHomeService.NormalizeCodexHome(request.CodexHome); + string? sqliteHomeOverride = NormalizeOptional(request.SqliteHomeOverride); + StatusSnapshot status = await _syncService.GetStatusAsync(codexHome, sqliteHomeOverride); + cancellationToken.ThrowIfCancellationRequested(); + + AppSettings nextSettings = _settingsService.RecordCodexHome(settings, status.CodexHome); + nextSettings = _settingsService.RecordSqliteHomeOverride( + nextSettings, + status.CodexHome, + sqliteHomeOverride); + nextSettings = _settingsService.MergeDetectedProviders( + nextSettings, + _syncService.ExtractDetectedProviderIds(status)); + nextSettings = _settingsService.UpdateState( + nextSettings, + NormalizeOptional(request.SelectedProviderId), + nextSettings.LastBackupDirectory); + + IReadOnlyList providers = _syncService.BuildProviderOptions(status, nextSettings); + await _settingsService.SaveAsync(nextSettings); + cancellationToken.ThrowIfCancellationRequested(); + + return new CoreRefreshState(status, providers); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/desktop/CodexProviderSync.Application/ICoreApplicationAdapter.cs b/desktop/CodexProviderSync.Application/ICoreApplicationAdapter.cs new file mode 100644 index 0000000..f40e992 --- /dev/null +++ b/desktop/CodexProviderSync.Application/ICoreApplicationAdapter.cs @@ -0,0 +1,26 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Application; + +public sealed record CoreInitializationState( + string CodexHome, + string? SqliteHomeOverride, + string? PreferredProviderId); + +public sealed record CoreRefreshRequest( + string CodexHome, + string? SqliteHomeOverride, + string? SelectedProviderId); + +public sealed record CoreRefreshState( + StatusSnapshot Status, + IReadOnlyList Providers); + +public interface ICoreApplicationAdapter +{ + Task InitializeAsync(CancellationToken cancellationToken = default); + + Task RefreshAsync( + CoreRefreshRequest request, + CancellationToken cancellationToken = default); +} diff --git a/docs/AUTOMATION_DESIGN_NOTES.md b/docs/AUTOMATION_DESIGN_NOTES.md new file mode 100644 index 0000000..b02a882 --- /dev/null +++ b/docs/AUTOMATION_DESIGN_NOTES.md @@ -0,0 +1,79 @@ +# Automation Design Notes + +> Status: exploratory and non-normative. +> +> These notes describe a possible 0.x direction. They do not define a shipped +> API, protocol, executable, schema, or compatibility commitment. + +## Motivation + +The Windows GUI currently combines presentation, application state, +validation, request construction, and Core service orchestration in +`MainForm`. Before adding an automation surface, the project needs a +UI-independent seam that is exercised by the real GUI. + +## Current Direction + +- `CodexProviderSync.Core` remains the only owner of config, rollout, SQLite, + backup, restore, storage resolution, locking, and WSL safety behavior. +- A UI-independent Application/controller layer owns migrated application + state, validation, action availability, and Core request construction. +- WinForms remains responsible for rendering, native dialogs, confirmation, + accessibility, and other Windows-specific presentation. +- Existing frontends migrate incrementally. Unmigrated behavior may remain in + the frontend until its own tested vertical slice is moved. + +## Phase 1 + +Phase 1 introduces the Application/controller boundary and migrates one +complete Windows GUI behavior slice through it. + +Requirements: + +- preserve observable GUI behavior +- reuse Core rather than reimplementing data operations +- keep the Application project free of WinForms dependencies +- add controller tests for migrated state, validation, and request mapping +- keep existing Core, CLI, GUI, and packaging tests green + +This phase does not ship an Automation executable. + +## Possible Later Experiment + +After the controller has been exercised by the real GUI, a local automation +host may be prototyped as an experimental 0.x interface. Its transport, +messages, versioning, write opt-in, planning model, and packaging remain open +questions and may change incompatibly during 0.x development. + +## Safety Invariants + +Any future automation path must: + +- use the same Core storage-resolution and WSL safety rules +- never read, write, expose, or manage `auth.json` or credentials +- identify write targets clearly and retain the existing backup-first behavior +- use temporary fixture homes in automated tests, never the runner's real + Codex home +- report partial results such as locked rollout files without presenting them + as complete success + +## Deliberately Undecided + +The project currently makes no commitment to: + +- JSON Lines or any other transport +- public method, field, enum, or error-code names +- a `CodexProviderSync.Automation.exe` binary or published JSON schema +- stable v1 compatibility +- plan IDs, expiration rules, or a particular plan/execute protocol +- stable control IDs, `ui.inspect`, `ui.capture`, or a UI probe +- bundling multiple executables in the Windows release + +A UI probe, if needed, will be designed and reviewed separately from the +Application/controller extraction. + +## Stabilization Criteria + +A stable public automation contract should be proposed only after an +experimental 0.x host has been used against the shared controller, its safety +model has been tested, and the required compatibility surface is understood. diff --git a/test/release-version.test.js b/test/release-version.test.js index 5b6e315..49b1e5c 100644 --- a/test/release-version.test.js +++ b/test/release-version.test.js @@ -71,6 +71,7 @@ test("current repository versions match an explicit tag", () => { assert.deepEqual(result.projects, [ "desktop/CodexProviderSync.App/CodexProviderSync.App.csproj", + "desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj", "desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj", "desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj", ]); From b4308ee73dfb6206a61fb71e6f70b4447656af05 Mon Sep 17 00:00:00 2001 From: Dailin <71995555+Dailin521@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:09:23 -0700 Subject: [PATCH 02/19] fix(windows): qualify WinForms application type --- desktop/CodexProviderSync.App/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/CodexProviderSync.App/Program.cs b/desktop/CodexProviderSync.App/Program.cs index 83d3c18..3dc764a 100644 --- a/desktop/CodexProviderSync.App/Program.cs +++ b/desktop/CodexProviderSync.App/Program.cs @@ -29,7 +29,7 @@ static void Main(string[] args) MainForm mainForm = new(executionLogService); using FocusRequestServer focusServer = new(mainForm.BringToFront); focusServer.Start(); - Application.Run(mainForm); + System.Windows.Forms.Application.Run(mainForm); } catch (Exception error) { From c7ee4d4eef40ea4ed97348d85cabf2915289b6e1 Mon Sep 17 00:00:00 2001 From: Dailin521 <71995555+Dailin521@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:57:44 +0800 Subject: [PATCH 03/19] fix transaction rollback safety for v0.4 --- .../CoreIntegrationTests.cs | 294 +++++++++++++++ desktop/CodexProviderSync.Core/AtomicFile.cs | 89 +++++ .../CodexProviderSync.Core/BackupService.cs | 13 +- .../CodexSyncService.cs | 206 ++++++++-- .../ConfigFileService.cs | 2 +- .../GlobalStateService.cs | 25 +- desktop/CodexProviderSync.Core/Models.cs | 46 +++ .../SessionRolloutService.cs | 98 +---- .../CodexProviderSync.Core/TextFormatter.cs | 20 + .../TransactionJournalService.cs | 237 ++++++++++++ docs/ADR-0001-v0.4-automation-architecture.md | 103 +++++ docs/V0.4_AUTOMATION_PLAN.md | 355 ++++++++++++++++++ src/atomic-file.js | 44 +++ src/backup.js | 7 +- src/config-file.js | 3 +- src/service.js | 225 +++++++++-- src/session-files.js | 23 +- src/transaction-journal.js | 181 +++++++++ src/workspace-roots.js | 9 +- test/sync-service.test.js | 280 ++++++++++++++ 20 files changed, 2103 insertions(+), 157 deletions(-) create mode 100644 desktop/CodexProviderSync.Core/AtomicFile.cs create mode 100644 desktop/CodexProviderSync.Core/TransactionJournalService.cs create mode 100644 docs/ADR-0001-v0.4-automation-architecture.md create mode 100644 docs/V0.4_AUTOMATION_PLAN.md create mode 100644 src/atomic-file.js create mode 100644 src/transaction-journal.js diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index b2cccdd..838d106 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -6,6 +6,300 @@ namespace CodexProviderSync.Core.Tests; public sealed class CoreIntegrationTests { + [Fact] + public async Task RunSync_RollsBackFirstRollout_WhenLaterTargetFails_Issue69() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string firstPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + string secondPath = fixture.RolloutPath("sessions", "rollout-b.jsonl"); + await fixture.WriteRolloutAsync(firstPath, "thread-a", "apigather"); + await fixture.WriteRolloutAsync(secondPath, "thread-b", "apigather"); + await fixture.WriteStateDbAsync([ + ("thread-a", "apigather", false), + ("thread-b", "apigather", false) + ]); + string firstBefore = await File.ReadAllTextAsync(firstPath); + string secondBefore = await File.ReadAllTextAsync(secondPath); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, appliedCount) => + { + if (point == "after_rollout_apply" && appliedCount == 1) + { + throw new IOException("injected second-target failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome, provider: "openai")); + Assert.Contains("injected second-target failure", error.OriginalError.Message); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-a")); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-b")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task RunSync_RestoresGlobalStatePrimary_WhenBackupWriteFails_Issue69() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteGlobalStateAsync(new Dictionary + { + ["electron-saved-workspace-roots"] = new[] { @"\\?\D:\Workspace\sample" }, + ["project-order"] = new[] { @"\\?\D:\Workspace\sample" }, + ["active-workspace-roots"] = new[] { @"\\?\D:\Workspace\sample" } + }); + await fixture.WriteStateDbWithCwdAsync([ + ("thread-global", "openai", false, @"\\?\D:\Workspace\sample") + ]); + string primaryPath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename); + string backupPath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateBackupFileBasename); + string primaryBefore = await File.ReadAllTextAsync(primaryPath); + string backupBefore = await File.ReadAllTextAsync(backupPath); + + CodexSyncService service = new(); + service.FaultInjector = (point, appliedPath, _) => + { + if (point == "after_global_state_apply" + && string.Equals(appliedPath, primaryPath, StringComparison.Ordinal)) + { + throw new IOException("injected global-state backup failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + Assert.Contains("injected global-state backup failure", error.OriginalError.Message); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Equal(primaryBefore, await File.ReadAllTextAsync(primaryPath)); + Assert.Equal(backupBefore, await File.ReadAllTextAsync(backupPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task UnfinishedJournal_BlocksWrites_UntilBoundBackupIsRestored() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([("thread-recovery", "openai", false)]); + string configPath = Path.Combine(fixture.CodexHome, "config.toml"); + BackupService backupService = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backupService.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + configPath); + await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [configPath]); + + CodexSyncService service = new(); + StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome); + Assert.Single(status.PendingTransactions); + Assert.Contains("Recovery required:", TextFormatter.FormatStatus(status)); + Assert.Contains("需要恢复:", TextFormatter.FormatStatus(status, TextFormatter.ChineseSimplified)); + RecoveryRequiredException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + Assert.Single(error.PendingBackupDirectories); + + await service.RunRestoreAsync(fixture.CodexHome, backupDir); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + SyncResult result = await service.RunSyncAsync(fixture.CodexHome); + Assert.Equal("openai", result.TargetProvider); + } + + [Fact] + public async Task RollbackFailure_PreservesBothErrors_AndManualRecoveryEvidence() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-rollback-failure.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-rollback", "apigather"); + await fixture.WriteStateDbAsync([("thread-rollback", "apigather", false)]); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_rollout_apply") + { + throw new IOException("injected original failure"); + } + if (point == "before_rollout_rollback") + { + throw new IOException("injected rollback failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + Assert.Contains("injected original failure", error.OriginalError.Message); + Assert.Contains(error.RollbackErrors, value => value.Contains("injected rollback failure")); + Assert.Equal("incomplete", error.RollbackStatus); + Assert.True(error.RecoveryRequired); + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + + await service.RunRestoreAsync(fixture.CodexHome, error.BackupDirectory); + using JsonDocument restored = JsonDocument.Parse( + (await File.ReadAllTextAsync(sessionPath)).Split('\n', StringSplitOptions.RemoveEmptyEntries)[0]); + Assert.Equal("apigather", restored.RootElement.GetProperty("payload").GetProperty("model_provider").GetString()); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task Cancellation_AfterFirstTarget_RollsBackDiskAndSqlite_WithStructuredEvidence() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string firstPath = fixture.RolloutPath("sessions", "rollout-cancel-a.jsonl"); + string secondPath = fixture.RolloutPath("sessions", "rollout-cancel-b.jsonl"); + await fixture.WriteRolloutAsync(firstPath, "thread-cancel-a", "apigather"); + await fixture.WriteRolloutAsync(secondPath, "thread-cancel-b", "apigather"); + await fixture.WriteStateDbAsync([ + ("thread-cancel-a", "apigather", false), + ("thread-cancel-b", "apigather", false) + ]); + string firstBefore = await File.ReadAllTextAsync(firstPath); + string secondBefore = await File.ReadAllTextAsync(secondPath); + using CancellationTokenSource cancellation = new(); + CodexSyncService service = new(); + service.FaultInjector = (point, _, appliedCount) => + { + if (point == "after_rollout_apply" && appliedCount == 1) + { + cancellation.Cancel(); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync( + fixture.CodexHome, + provider: "openai", + cancellationToken: cancellation.Token)); + + Assert.IsType(error.OriginalError); + Assert.Equal("SYNC_FAILED_ROLLED_BACK", error.Code); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Contains(Path.GetFullPath(firstPath), error.CompletedTargets); + Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-a")); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-b")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task BackupFailure_OccursBeforeJournalOrTargetMutation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-backup-failure.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-backup-failure", "apigather"); + await fixture.WriteStateDbAsync([("thread-backup-failure", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "before_backup") + { + throw new IOException("injected backup creation failure"); + } + return Task.CompletedTask; + }; + + IOException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + Assert.Contains("injected backup creation failure", error.Message); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.False(Directory.Exists(fixture.BackupRoot())); + } + + [Fact] + public async Task AtomicReplacementFailure_PreservesOriginalAndRemovesStaging() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string targetPath = Path.Combine(fixture.Root, "atomic-target.txt"); + await File.WriteAllTextAsync(targetPath, "before"); + await using (FileStream locked = new( + targetPath, + FileMode.Open, + FileAccess.Read, + FileShare.None)) + { + await Assert.ThrowsAnyAsync( + () => AtomicFile.WriteAllTextAsync(targetPath, "after")); + } + Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); + Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); + } + + [Theory] + [InlineData("before_stage_write")] + [InlineData("before_atomic_replace")] + public async Task AtomicWriter_InjectedFailure_PreservesOriginalAndRemovesStaging(string faultPoint) + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string targetPath = Path.Combine(fixture.Root, $"atomic-{faultPoint}.txt"); + await File.WriteAllTextAsync(targetPath, "before"); + + IOException error = await Assert.ThrowsAsync( + () => AtomicFile.WriteAllTextAsync( + targetPath, + "after", + faultInjector: (point, _, _) => + { + if (point == faultPoint) + { + throw new IOException($"injected {faultPoint}"); + } + return Task.CompletedTask; + })); + + Assert.Contains($"injected {faultPoint}", error.Message); + Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); + Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); + } + + [Fact] + public async Task PruneBackups_NeverDeletesBackupReferencedByUnfinishedTransaction() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteBackupAsync("20260319T000000000Z", ("note.txt", "pending")); + await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "terminal")); + string pendingDir = fixture.BackupPath("20260319T000000000Z"); + await FileTransactionJournal.CreateAsync( + pendingDir, + fixture.CodexHome, + "openai", + []); + + BackupPruneResult result = await new BackupService( + new SessionRolloutService(), + new SqliteStateService()).PruneBackupsAsync(fixture.CodexHome, 0); + + Assert.Equal(1, result.DeletedCount); + Assert.Equal(1, result.RemainingCount); + Assert.True(Directory.Exists(pendingDir)); + Assert.False(Directory.Exists(fixture.BackupPath("20260320T000000000Z"))); + } + [Fact] public async Task GetStatus_ReportsWindowsWslUncSqliteHomeWithoutOpeningDatabase() { diff --git a/desktop/CodexProviderSync.Core/AtomicFile.cs b/desktop/CodexProviderSync.Core/AtomicFile.cs new file mode 100644 index 0000000..5c90246 --- /dev/null +++ b/desktop/CodexProviderSync.Core/AtomicFile.cs @@ -0,0 +1,89 @@ +using System.Text; + +namespace CodexProviderSync.Core; + +internal static class AtomicFile +{ + internal static async Task WriteAllTextAsync( + string filePath, + string content, + CancellationToken cancellationToken = default, + Func? faultInjector = null) + { + string fullPath = Path.GetFullPath(filePath); + string? directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directory)) + { + throw new InvalidOperationException($"Cannot resolve the parent directory for {fullPath}."); + } + + Directory.CreateDirectory(directory); + string tempPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.provider-sync.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + try + { + byte[] bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); + await using (FileStream stream = new( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + if (faultInjector is not null) + { + await faultInjector("before_stage_write", fullPath, tempPath); + } + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + if (faultInjector is not null) + { + await faultInjector("before_atomic_replace", fullPath, tempPath); + } + File.Move(tempPath, fullPath, overwrite: true); + } + catch + { + TryDelete(tempPath); + throw; + } + } + + internal static async Task ReplaceOpenFileFromTempAsync(FileStream destination, string tempPath) + { + string destinationPath = destination.Name; + await using (FileStream staged = new( + tempPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await staged.FlushAsync(); + staged.Flush(flushToDisk: true); + } + + await destination.DisposeAsync(); + File.Move(tempPath, destinationPath, overwrite: true); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Cleanup must not hide the original write failure. + } + } +} diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index a6a1e25..85a626b 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -396,7 +396,7 @@ public Task GetBackupSummaryAsync(string codexHome) }); } - public Task PruneBackupsAsync(string codexHome, int keepCount = AppConstants.DefaultBackupRetentionCount) + public async Task PruneBackupsAsync(string codexHome, int keepCount = AppConstants.DefaultBackupRetentionCount) { if (keepCount < 0) { @@ -404,7 +404,11 @@ public Task PruneBackupsAsync(string codexHome, int keepCount } string backupRoot = AppConstants.DefaultBackupRoot(codexHome); - return Task.Run(() => + IReadOnlyList pending = await FileTransactionJournal.FindPendingAsync(codexHome); + HashSet protectedBackups = new( + pending.Select(static transaction => Path.GetFullPath(transaction.BackupDir)), + StringComparer.Ordinal); + return await Task.Run(() => { if (!Directory.Exists(backupRoot)) { @@ -419,7 +423,10 @@ public Task PruneBackupsAsync(string codexHome, int keepCount List entries = GetManagedBackupDirectories(backupRoot); - List toDelete = entries.Skip(keepCount).ToList(); + List toDelete = entries + .Skip(keepCount) + .Where(entry => !protectedBackups.Contains(Path.GetFullPath(entry.FullName))) + .ToList(); long freedBytes = 0; foreach (DirectoryInfo entry in toDelete) { diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs index fbcfe78..cdc4dd7 100644 --- a/desktop/CodexProviderSync.Core/CodexSyncService.cs +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -12,6 +12,8 @@ public sealed class CodexSyncService private readonly ProviderDiscoveryService _providerDiscoveryService; private readonly CodexStorageLayoutService _storageLayoutService; + internal Func? FaultInjector { get; set; } + public CodexSyncService() : this( new CodexHomeService(), @@ -70,6 +72,7 @@ public async Task GetStatusAsync( ? [] : await _globalStateService.ReadProjectThreadVisibilityAsync(storage); BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome); + IReadOnlyList pendingTransactions = await FileTransactionJournal.FindPendingAsync(codexHome); return new StatusSnapshot { @@ -90,7 +93,14 @@ public async Task GetStatusAsync( SqliteRepairStats = sqliteRepairStats, ProjectThreadVisibility = projectThreadVisibility, BackupRoot = _codexHomeService.BackupRoot(codexHome), - BackupSummary = backupSummary + BackupSummary = backupSummary, + PendingTransactions = pendingTransactions + .Select(static item => new TransactionRecoveryInfo( + item.OperationId, + item.State, + item.BackupDir, + item.JournalPath)) + .ToArray() }; } @@ -111,7 +121,8 @@ public Task RunSyncAsync( int keepCount = AppConstants.DefaultBackupRetentionCount, int? sqliteBusyTimeoutMs = null, string? model = null, - string? explicitSqliteHome = null) + string? explicitSqliteHome = null, + CancellationToken cancellationToken = default) { return RunSyncCoreAsync( explicitCodexHome, @@ -121,7 +132,8 @@ public Task RunSyncAsync( sqliteBusyTimeoutMs, model, explicitSqliteHome, - afterBackup: null); + afterBackup: null, + cancellationToken); } private async Task RunSyncCoreAsync( @@ -132,7 +144,8 @@ private async Task RunSyncCoreAsync( int? sqliteBusyTimeoutMs, string? model, string? explicitSqliteHome, - Func? afterBackup) + Func? afterBackup, + CancellationToken cancellationToken = default) { if (keepCount < 1) { @@ -141,6 +154,9 @@ private async Task RunSyncCoreAsync( string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); + await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync"); + await FileTransactionJournal.AssertNoPendingAsync(codexHome); + cancellationToken.ThrowIfCancellationRequested(); string configPath = _codexHomeService.ConfigPath(codexHome); string configText = await _configFileService.ReadConfigTextAsync(configPath); CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); @@ -160,8 +176,6 @@ private async Task RunSyncCoreAsync( targetModel = _configFileService.ReadRootModelFromConfigText(configText); } - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync"); - SessionChangeCollection sessionInfo = await _sessionRolloutService.CollectSessionChangesAsync( codexHome, targetProvider, @@ -179,15 +193,37 @@ private async Task RunSyncCoreAsync( .ToList(); await _sqliteStateService.AssertSqliteWritableAsync(storage, sqliteBusyTimeoutMs); - string backupDir = await _backupService.CreateBackupAsync(storage, targetProvider, writableChanges, configPath, configBackupText); - if (afterBackup is not null) + cancellationToken.ThrowIfCancellationRequested(); + if (FaultInjector is not null) { - await afterBackup(backupDir); + await FaultInjector("before_backup", null, 0); } - + string backupDir = await _backupService.CreateBackupAsync(storage, targetProvider, writableChanges, configPath, configBackupText); bool sessionRestoreNeeded = false; List appliedSessionChanges = []; bool globalStateRestoreNeeded = false; + string globalStatePath = _globalStateService.StatePath(codexHome); + string globalStateBackupPath = _globalStateService.BackupPath(codexHome); + string[] potentialTargets = writableChanges.Select(static change => Path.GetFullPath(change.Path)) + .Append(Path.GetFullPath(globalStatePath)) + .Append(Path.GetFullPath(globalStateBackupPath)) + .Concat(configBackupText is null ? [] : [Path.GetFullPath(configPath)]) + .Concat(storage.StateDbCandidates.Select(static candidate => Path.GetFullPath(candidate.Path))) + .ToArray(); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + codexHome, + targetProvider, + potentialTargets); + List completedTargets = []; + void RecordCompletedTarget(string targetPath) + { + string fullPath = Path.GetFullPath(targetPath); + if (!completedTargets.Contains(fullPath, StringComparer.Ordinal)) + { + completedTargets.Add(fullPath); + } + } WorkspaceRootSyncResult workspaceRootResult = new() { Present = false, @@ -197,7 +233,21 @@ private async Task RunSyncCoreAsync( }; try { + if (afterBackup is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + await journal.ApplyingAsync("config", configPath); + await afterBackup(backupDir); + await journal.AppliedAsync("config", configPath); + RecordCompletedTarget(configPath); + if (FaultInjector is not null) + { + await FaultInjector("after_config_apply", configPath, 1); + } + } + SessionApplyResult? applyResult = null; + await journal.ApplyingAsync("sqlite", storage.StateDbLocation?.Path ?? storage.SqliteHome); (int updatedRows, int providerRowsUpdated, int modelRowsUpdated, int userEventRowsUpdated, int cwdRowsUpdated, bool databasePresent) = await _sqliteStateService.UpdateSqliteProviderAsync( storage, targetProvider, @@ -206,18 +256,58 @@ private async Task RunSyncCoreAsync( { if (writableChanges.Count > 0) { - applyResult = await _sessionRolloutService.ApplySessionChangesAsync(writableChanges, targetModel); - HashSet appliedPathSet = new(applyResult.AppliedPaths, StringComparer.Ordinal); - appliedSessionChanges = writableChanges.Where(change => appliedPathSet.Contains(change.Path)).ToList(); - sessionRestoreNeeded = appliedSessionChanges.Count > 0; - await _backupService.UpdateSessionBackupManifestAsync(backupDir, appliedSessionChanges); + applyResult = await _sessionRolloutService.ApplySessionChangesAsync( + writableChanges, + targetModel, + async change => + { + cancellationToken.ThrowIfCancellationRequested(); + await journal.ApplyingAsync("rollout", change.Path); + if (FaultInjector is not null) + { + await FaultInjector( + "before_rollout_apply", + change.Path, + appliedSessionChanges.Count + 1); + } + }, + async change => + { + appliedSessionChanges.Add(change); + sessionRestoreNeeded = true; + await journal.AppliedAsync("rollout", change.Path); + RecordCompletedTarget(change.Path); + await _backupService.UpdateSessionBackupManifestAsync(backupDir, appliedSessionChanges); + if (FaultInjector is not null) + { + await FaultInjector("after_rollout_apply", change.Path, appliedSessionChanges.Count); + } + }); } - workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync(storage, workspaceCwdStats); - globalStateRestoreNeeded = workspaceRootResult.Updated; + workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync( + storage, + workspaceCwdStats, + async targetPath => + { + cancellationToken.ThrowIfCancellationRequested(); + await journal.ApplyingAsync("globalState", targetPath); + }, + async targetPath => + { + globalStateRestoreNeeded = true; + await journal.AppliedAsync("globalState", targetPath); + RecordCompletedTarget(targetPath); + if (FaultInjector is not null) + { + await FaultInjector("after_global_state_apply", targetPath, 1); + } + }); }, sqliteBusyTimeoutMs, sessionInfo.UserEventThreadIds, sessionInfo.ThreadCwdsById); + await journal.AppliedAsync("sqlite", storage.StateDbLocation?.Path ?? storage.SqliteHome); + RecordCompletedTarget(storage.StateDbLocation?.Path ?? storage.SqliteHome); skippedRolloutFiles.AddRange(applyResult?.SkippedPaths ?? []); skippedRolloutFiles = skippedRolloutFiles.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); @@ -233,7 +323,7 @@ private async Task RunSyncCoreAsync( autoPruneWarning = $"Automatic backup cleanup failed: {error.Message}"; } - return new SyncResult + SyncResult result = new() { CodexHome = codexHome, SqliteHome = storage.SqliteHome, @@ -258,14 +348,28 @@ private async Task RunSyncCoreAsync( AutoPruneResult = autoPruneResult, AutoPruneWarning = autoPruneWarning }; + await journal.CommittedAsync(); + return result; } catch (Exception error) { List restoreFailures = []; + try + { + await journal.RollingBackAsync(error); + } + catch (Exception journalError) + { + restoreFailures.Add($"transaction journal: {journalError.Message}"); + } if (sessionRestoreNeeded) { try { + if (FaultInjector is not null) + { + await FaultInjector("before_rollout_rollback", null, appliedSessionChanges.Count); + } await _sessionRolloutService.RestoreSessionChangesAsync(appliedSessionChanges); } catch (Exception restoreError) @@ -277,6 +381,10 @@ private async Task RunSyncCoreAsync( { try { + if (FaultInjector is not null) + { + await FaultInjector("before_global_state_rollback", null, 1); + } await _backupService.RestoreGlobalStateFilesAsync(backupDir, codexHome); } catch (Exception restoreError) @@ -285,14 +393,63 @@ private async Task RunSyncCoreAsync( } } + if (configBackupText is not null) + { + try + { + await _configFileService.WriteConfigTextAsync(configPath, configBackupText); + } + catch (Exception restoreError) + { + restoreFailures.Add($"config: {restoreError.Message}"); + } + } + + if (restoreFailures.Count == 0) + { + try + { + await journal.RolledBackAsync(); + } + catch (Exception journalError) + { + restoreFailures.Add($"transaction journal: {journalError.Message}"); + } + } if (restoreFailures.Count > 0) { - throw new InvalidOperationException( - $"Failed to restore state after sync error. Original error: {error.Message}. Restore error: {string.Join("; ", restoreFailures)}", - error); + try + { + await journal.RecoveryRequiredAsync(error, restoreFailures); + } + catch + { + // Preserve the original and rollback failures when the + // journal itself is no longer writable. + } + HashSet completedTargetSet = new(completedTargets, StringComparer.Ordinal); + IReadOnlyList uncompletedTargets = potentialTargets + .Where(targetPath => !completedTargetSet.Contains(targetPath)) + .ToArray(); + throw new SyncTransactionException( + error, + restoreFailures, + backupDir, + completedTargets, + uncompletedTargets, + rollbackStatus: "incomplete", + recoveryRequired: true); } - throw; + HashSet completedSet = new(completedTargets, StringComparer.Ordinal); + throw new SyncTransactionException( + error, + [], + backupDir, + completedTargets, + potentialTargets.Where(targetPath => !completedSet.Contains(targetPath)).ToArray(), + rollbackStatus: "complete", + recoveryRequired: false); } } @@ -453,7 +610,10 @@ public async Task RunRestoreAsync( storage.EnsureSqliteAccessSupported("restore"); await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore"); - return await _backupService.RestoreBackupAsync(Path.GetFullPath(backupDir), storage, options); + string normalizedBackupDir = Path.GetFullPath(backupDir); + RestoreResult result = await _backupService.RestoreBackupAsync(normalizedBackupDir, storage, options); + await FileTransactionJournal.MarkBackupRolledBackAsync(normalizedBackupDir); + return result; } public async Task RunPruneBackupsAsync( diff --git a/desktop/CodexProviderSync.Core/ConfigFileService.cs b/desktop/CodexProviderSync.Core/ConfigFileService.cs index 9889599..763e0f0 100644 --- a/desktop/CodexProviderSync.Core/ConfigFileService.cs +++ b/desktop/CodexProviderSync.Core/ConfigFileService.cs @@ -60,7 +60,7 @@ public Task ReadConfigTextAsync(string configPath) public async Task WriteConfigTextAsync(string configPath, string configText) { - await File.WriteAllTextAsync(configPath, configText); + await AtomicFile.WriteAllTextAsync(configPath, configText); } public CurrentProviderInfo ReadCurrentProviderFromConfigText(string configText) diff --git a/desktop/CodexProviderSync.Core/GlobalStateService.cs b/desktop/CodexProviderSync.Core/GlobalStateService.cs index 416bae6..50cf09f 100644 --- a/desktop/CodexProviderSync.Core/GlobalStateService.cs +++ b/desktop/CodexProviderSync.Core/GlobalStateService.cs @@ -102,7 +102,9 @@ public async Task SyncWorkspaceRootsAsync( public async Task SyncWorkspaceRootsAsync( CodexStorageLayout storage, - IReadOnlyList? cwdStats = null) + IReadOnlyList? cwdStats = null, + Func? onBeforeWrite = null, + Func? onApplied = null) { string codexHome = storage.CodexHome; string statePath = StatePath(codexHome); @@ -177,8 +179,25 @@ public async Task SyncWorkspaceRootsAsync( if (updated) { string json = state.ToJsonString(JsonOptions()) + Environment.NewLine; - await File.WriteAllTextAsync(statePath, json); - await File.WriteAllTextAsync(BackupPath(codexHome), json); + if (onBeforeWrite is not null) + { + await onBeforeWrite(statePath); + } + await AtomicFile.WriteAllTextAsync(statePath, json); + if (onApplied is not null) + { + await onApplied(statePath); + } + string backupPath = BackupPath(codexHome); + if (onBeforeWrite is not null) + { + await onBeforeWrite(backupPath); + } + await AtomicFile.WriteAllTextAsync(backupPath, json); + if (onApplied is not null) + { + await onApplied(backupPath); + } } return new WorkspaceRootSyncResult diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs index b37dce8..adb2930 100644 --- a/desktop/CodexProviderSync.Core/Models.cs +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -33,8 +33,15 @@ public sealed class StatusSnapshot public IReadOnlyList ProjectThreadVisibility { get; init; } = []; public required string BackupRoot { get; init; } public required BackupSummary BackupSummary { get; init; } + public IReadOnlyList PendingTransactions { get; init; } = []; } +public sealed record TransactionRecoveryInfo( + string? OperationId, + string State, + string BackupDirectory, + string JournalPath); + public sealed record StateDbLocation(string Path, string RelativePath, string Source); public sealed record SqliteAccessInfo(bool Supported, string? Reason, string? Message) @@ -298,6 +305,45 @@ public sealed class WorkspaceRootSyncResult public required int SavedWorkspaceRootCount { get; init; } } +public sealed class SyncTransactionException : InvalidOperationException +{ + public SyncTransactionException( + Exception originalError, + IReadOnlyList rollbackErrors, + string backupDirectory, + IReadOnlyList completedTargets, + IReadOnlyList uncompletedTargets, + string rollbackStatus = "incomplete", + bool recoveryRequired = true) + : base( + recoveryRequired + ? $"Failed to restore state after sync error. Original error: {originalError.Message}. Restore error: {string.Join("; ", rollbackErrors)}" + : $"Provider sync failed and all observed changes were rolled back. Original error: {originalError.Message}", + originalError) + { + OriginalError = originalError; + RollbackErrors = rollbackErrors; + BackupDirectory = backupDirectory; + CompletedTargets = completedTargets; + UncompletedTargets = uncompletedTargets; + RollbackStatus = rollbackStatus; + RecoveryRequired = recoveryRequired; + } + + public string Code => RecoveryRequired ? "RECOVERY_REQUIRED" : "SYNC_FAILED_ROLLED_BACK"; + public Exception OriginalError { get; } + public IReadOnlyList RollbackErrors { get; } + public string BackupDirectory { get; } + public IReadOnlyList CompletedTargets { get; } + public IReadOnlyList UncompletedTargets { get; } + public string RollbackStatus { get; } + public bool RecoveryRequired { get; } + public string RecoveryInstructions => + RecoveryRequired + ? $"Restore the managed backup at {BackupDirectory}, inspect the pending transaction journal, then retry." + : "No manual recovery is required. Inspect the original error, correct its cause, and retry."; +} + public sealed class ThreadCwdStat { public required string Cwd { get; init; } diff --git a/desktop/CodexProviderSync.Core/SessionRolloutService.cs b/desktop/CodexProviderSync.Core/SessionRolloutService.cs index fc01fcb..a547352 100644 --- a/desktop/CodexProviderSync.Core/SessionRolloutService.cs +++ b/desktop/CodexProviderSync.Core/SessionRolloutService.cs @@ -165,7 +165,9 @@ record = await ReadFirstLineRecordAsync(rolloutPath); public async Task ApplySessionChangesAsync( IEnumerable changes, - string? targetModel = null) + string? targetModel = null, + Func? onBeforeApply = null, + Func? onApplied = null) { int appliedCount = 0; List appliedPaths = []; @@ -173,6 +175,10 @@ public async Task ApplySessionChangesAsync( foreach (SessionChange change in changes) { + if (onBeforeApply is not null) + { + await onBeforeApply(change); + } bool providerApplied = change.ModelOnlyChange || await TryRewriteCollectedSessionChangeAsync(change); if (!providerApplied) { @@ -210,6 +216,10 @@ await RewriteFirstLineAsync( TryRestoreLastWriteTimeUtc(change.Path, change.OriginalLastWriteTimeUtcTicks); appliedCount += 1; appliedPaths.Add(change.Path); + if (onApplied is not null) + { + await onApplied(change); + } } appliedPaths.Sort(StringComparer.Ordinal); @@ -603,9 +613,7 @@ private async Task TryRewriteRolloutModelFieldAsync( return ModelRewriteResult.Empty; } - await OverwriteOpenFileFromTempAsync(sourceStream, tempPath); - - File.Delete(tempPath); + await AtomicFile.ReplaceOpenFileFromTempAsync(sourceStream, tempPath); return new ModelRewriteResult(replacements, originalModels); } catch (Exception error) @@ -732,8 +740,7 @@ private static async Task RestoreTurnContextModelsAsync( return; } - await OverwriteOpenFileFromTempAsync(sourceStream, tempPath); - File.Delete(tempPath); + await AtomicFile.ReplaceOpenFileFromTempAsync(sourceStream, tempPath); } catch (Exception error) { @@ -786,69 +793,6 @@ private static async Task EndsWithNewlineAsync(FileStream stream) return bytesRead == 1 && tail[0] == (byte)'\n'; } - private static async Task OverwriteOpenFileFromTempAsync( - FileStream destination, - string tempPath) - { - string rollbackPath = $"{tempPath}.rollback"; - try - { - destination.Seek(0, SeekOrigin.Begin); - await using (FileStream rollbackWriter = new( - rollbackPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - 64 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan)) - { - await destination.CopyToAsync(rollbackWriter); - await rollbackWriter.FlushAsync(); - } - - try - { - await using FileStream tempReader = new( - tempPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 64 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan); - destination.SetLength(0); - destination.Seek(0, SeekOrigin.Begin); - await tempReader.CopyToAsync(destination); - await destination.FlushAsync(); - } - catch - { - await using FileStream rollbackReader = new( - rollbackPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 64 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan); - destination.SetLength(0); - destination.Seek(0, SeekOrigin.Begin); - await rollbackReader.CopyToAsync(destination); - await destination.FlushAsync(); - throw; - } - } - finally - { - try - { - File.Delete(rollbackPath); - } - catch - { - // The original exception, if any, is more useful than cleanup failure. - } - } - } - private static async Task RewriteFirstLineAsync( FileStream sourceStream, string filePath, @@ -884,21 +828,7 @@ private static async Task RewriteFirstLineAsync( } } - await using (FileStream tempReader = new( - tempPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 64 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan)) - { - sourceStream.SetLength(0); - sourceStream.Seek(0, SeekOrigin.Begin); - await tempReader.CopyToAsync(sourceStream); - await sourceStream.FlushAsync(); - } - - File.Delete(tempPath); + await AtomicFile.ReplaceOpenFileFromTempAsync(sourceStream, tempPath); } catch { diff --git a/desktop/CodexProviderSync.Core/TextFormatter.cs b/desktop/CodexProviderSync.Core/TextFormatter.cs index 71c9596..291d9d9 100644 --- a/desktop/CodexProviderSync.Core/TextFormatter.cs +++ b/desktop/CodexProviderSync.Core/TextFormatter.cs @@ -134,6 +134,16 @@ private static string FormatStatusEnglish(StatusSnapshot status) } lines.InsertRange(12, rolloutNotes); + if (status.PendingTransactions.Count > 0) + { + lines.Insert(6, " Run restore with the listed backup before the next write operation."); + foreach (TransactionRecoveryInfo transaction in status.PendingTransactions.Reverse()) + { + lines.Insert(6, $" {transaction.State}: {transaction.BackupDirectory}"); + } + lines.Insert(6, "Recovery required:"); + } + AppendSqliteStatus(lines, status, chinese: false); AppendProjectVisibility(lines, status, chinese: false); return string.Join(Environment.NewLine, lines); @@ -176,6 +186,16 @@ private static string FormatStatusChinese(StatusSnapshot status) } lines.InsertRange(12, rolloutNotes); + if (status.PendingTransactions.Count > 0) + { + lines.Insert(6, " 下一次写操作前,请使用列出的备份执行恢复。"); + foreach (TransactionRecoveryInfo transaction in status.PendingTransactions.Reverse()) + { + lines.Insert(6, $" {transaction.State}: {transaction.BackupDirectory}"); + } + lines.Insert(6, "需要恢复:"); + } + AppendSqliteStatus(lines, status, chinese: true); AppendProjectVisibility(lines, status, chinese: true); return string.Join(Environment.NewLine, lines); diff --git a/desktop/CodexProviderSync.Core/TransactionJournalService.cs b/desktop/CodexProviderSync.Core/TransactionJournalService.cs new file mode 100644 index 0000000..730d725 --- /dev/null +++ b/desktop/CodexProviderSync.Core/TransactionJournalService.cs @@ -0,0 +1,237 @@ +using System.Text; +using System.Text.Json; + +namespace CodexProviderSync.Core; + +internal sealed class FileTransactionJournal +{ + internal const string FileName = "transaction-journal.jsonl"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly string _filePath; + private readonly string _operationId; + private int _sequence; + + private FileTransactionJournal(string filePath, string operationId, int sequence = 0) + { + _filePath = filePath; + _operationId = operationId; + _sequence = sequence; + } + + internal static async Task CreateAsync( + string backupDir, + string codexHome, + string targetProvider, + IEnumerable potentialTargets) + { + string operationId = Guid.NewGuid().ToString("D"); + FileTransactionJournal journal = new( + Path.Combine(backupDir, FileName), + operationId); + await journal.AppendAsync("prepared", new Dictionary + { + ["protocolVersion"] = 1, + ["backupDir"] = Path.GetFullPath(backupDir), + ["codexHome"] = Path.GetFullPath(codexHome), + ["targetProvider"] = targetProvider, + ["potentialTargets"] = potentialTargets + .Select(Path.GetFullPath) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray() + }); + return journal; + } + + internal Task ApplyingAsync(string kind, string targetPath) => AppendAsync( + "applying", + new Dictionary + { + ["kind"] = kind, + ["targetPath"] = Path.GetFullPath(targetPath) + }); + + internal Task AppliedAsync(string kind, string targetPath) => AppendAsync( + "applied", + new Dictionary + { + ["kind"] = kind, + ["targetPath"] = Path.GetFullPath(targetPath) + }); + + internal Task CommittedAsync() => AppendAsync("committed"); + + internal Task RollingBackAsync(Exception originalError) => AppendAsync( + "rollingBack", + new Dictionary { ["originalError"] = originalError.Message }); + + internal Task RolledBackAsync() => AppendAsync("rolledBack"); + + internal Task RecoveryRequiredAsync(Exception originalError, IReadOnlyList rollbackErrors) => AppendAsync( + "recoveryRequired", + new Dictionary + { + ["originalError"] = originalError.Message, + ["rollbackErrors"] = rollbackErrors + }); + + private async Task AppendAsync(string state, IReadOnlyDictionary? details = null) + { + Dictionary value = new() + { + ["protocolVersion"] = 1, + ["operationId"] = _operationId, + ["sequence"] = ++_sequence, + ["state"] = state, + ["recordedAt"] = DateTimeOffset.UtcNow + }; + if (details is not null) + { + foreach ((string key, object? detail) in details) + { + value[key] = detail; + } + } + + byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOptions) + Environment.NewLine); + await using FileStream stream = new( + _filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await stream.WriteAsync(bytes); + await stream.FlushAsync(); + stream.Flush(flushToDisk: true); + } + + internal static async Task> FindPendingAsync(string codexHome) + { + string root = new CodexHomeService().BackupRoot(codexHome); + if (!Directory.Exists(root)) + { + return []; + } + + List pending = []; + foreach (string directory in Directory.EnumerateDirectories(root).Order(StringComparer.Ordinal)) + { + string journalPath = Path.Combine(directory, FileName); + if (!File.Exists(journalPath)) + { + continue; + } + + PendingTransactionInfo info = await ReadInfoAsync(journalPath); + if (!info.Terminal) + { + pending.Add(info); + } + } + return pending; + } + + internal static async Task AssertNoPendingAsync(string codexHome) + { + IReadOnlyList pending = await FindPendingAsync(codexHome); + if (pending.Count == 0) + { + return; + } + + string backups = string.Join(", ", pending.Select(static item => item.BackupDir)); + throw new RecoveryRequiredException( + $"An unfinished provider-sync transaction requires recovery before another write. Restore the bound backup, then retry. Backup(s): {backups}", + pending); + } + + internal static async Task MarkBackupRolledBackAsync(string backupDir) + { + string journalPath = Path.Combine(Path.GetFullPath(backupDir), FileName); + if (!File.Exists(journalPath)) + { + return; + } + + PendingTransactionInfo info = await ReadInfoAsync(journalPath); + if (info.Terminal) + { + return; + } + + FileTransactionJournal journal = new( + journalPath, + info.OperationId ?? Guid.NewGuid().ToString("D"), + info.LastSequence); + await journal.RolledBackAsync(); + } + + private static async Task ReadInfoAsync(string journalPath) + { + string? operationId = null; + int lastSequence = 0; + string state = "recoveryRequired"; + bool invalidTail = false; + foreach (string line in await File.ReadAllLinesAsync(journalPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + try + { + using JsonDocument document = JsonDocument.Parse(line); + JsonElement root = document.RootElement; + operationId ??= root.TryGetProperty("operationId", out JsonElement operation) + ? operation.GetString() + : null; + lastSequence = root.TryGetProperty("sequence", out JsonElement sequence) + ? sequence.GetInt32() + : lastSequence; + state = root.TryGetProperty("state", out JsonElement stateValue) + ? stateValue.GetString() ?? state + : state; + } + catch (JsonException) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + } + + bool terminal = !invalidTail && state is "committed" or "rolledBack"; + return new PendingTransactionInfo( + journalPath, + Path.GetDirectoryName(journalPath)!, + operationId, + lastSequence, + state, + terminal, + invalidTail); + } +} + +internal sealed record PendingTransactionInfo( + string JournalPath, + string BackupDir, + string? OperationId, + int LastSequence, + string State, + bool Terminal, + bool InvalidTail); + +public sealed class RecoveryRequiredException : InvalidOperationException +{ + internal RecoveryRequiredException(string message, IReadOnlyList pending) + : base(message) + { + PendingBackupDirectories = pending.Select(static item => item.BackupDir).ToArray(); + } + + public string Code => "RECOVERY_REQUIRED"; + + public IReadOnlyList PendingBackupDirectories { get; } +} diff --git a/docs/ADR-0001-v0.4-automation-architecture.md b/docs/ADR-0001-v0.4-automation-architecture.md new file mode 100644 index 0000000..5cfc22c --- /dev/null +++ b/docs/ADR-0001-v0.4-automation-architecture.md @@ -0,0 +1,103 @@ +# ADR-0001: v0.4 Application, transaction, and Automation architecture + +- Status: accepted for implementation after independent adversarial review +- Date: 2026-08-03 +- Scope: v0.4.0 + +## Context + +PR #70 establishes a UI-independent controller seam but leaves restore, prune, +settings, dialogs, and execution orchestration in MainForm. Issue #69 shows +that the current Node and .NET rollback coordinators learn about applied files +only after a batch returns, so a mid-batch exception can conceal partial +writes. v0.4 also requires scriptable business operations and complete real +WinForms entry automation without exposing production control surfaces. + +## Decision + +1. Core remains the exclusive data-operation layer and gains a durable, + backup-bound file transaction journal with crash detection and compensating + rollback. +2. Application becomes the exclusive use-case layer for all GUI and Business + operations. Requests are immutable; plans bind normalized input, targets, + fingerprints, digest, expiry, and single-use execution state. +3. Business Automation is a one-shot console host using experimental JSON + protocol 0.4. stdout is machine JSON only; writes require explicit + `--apply` plus an exact fresh plan and digest. +4. GUI Automation runs only in an explicit isolated test launch. It uses a + current-user-only random named pipe and one-time credential from a protected + bootstrap descriptor. The bridge only invokes registered control actions on + the UI thread. +5. A versioned static manifest plus runtime enumeration and causal traces form + a 100% entry/action coverage gate. Native shell-dialog internals are narrow + exemptions, but their application-owned launch buttons remain real GUI + actions. +6. The release package contains the GUI, Automation host, schema, and manifest. + Protocol 0.4 and manifest versions explicitly permit pre-1.0 evolution. + +## Safety invariants + +- No Automation path reads, copies, logs, or modifies `auth.json` or + credentials. +- Test roots require a sentinel and canonical path containment before any + process starts or write occurs. +- Normal GUI mode creates no Automation listener. +- The named pipe is local/current-user only; arbitrary reflection and arbitrary + file access are absent. +- A transaction cannot disappear while non-terminal. Recovery is explicit and + diagnostic; rollback failure preserves both original and rollback errors. +- The current agent never writes remote `main`, merges a PR based on `main`, + enables auto-merge, or creates a formal tag/Release. + +## Alternatives rejected + +- Continuing separate MainForm workflows: duplicates behavior and makes + Business/GUI equivalence unverifiable. +- Direct Application calls from the GUI bridge: does not prove the real control + event path. +- Public HTTP/TCP listener: unnecessary attack surface for a local release + tool. +- Hidden WinForms probe as the release gate: cannot prove visible desktop, + focus, dialog, keyboard, or obstruction behavior. +- Stable JSONL v1 now: commits to compatibility before the real use cases and + safety model have release evidence. +- In-memory applied-file tracking only: cannot recover after process death and + repeats Issue #69's fundamental visibility gap. + +## Consequences + +The implementation adds projects, release artifacts, schema/manifest checks, +and Windows-only E2E infrastructure. Normal operation remains local and +backup-first. Tests become more expensive but produce repeatable evidence and +remove the manual full-entry regression burden. The macOS UI continues to use +Core and must keep building, but full macOS Application migration is outside +the v0.4 Windows release gate. + +## Independent review reconciliation + +A local Claude Opus consultation completed on 2026-08-03 after 687 seconds +(`fea38f91-b679-405f-bb25-da26bba8fdeb`). Codex compared its threat model with +the repository and accepted these release-gating corrections: + +- read-only status/describe remains available during an unfinished + transaction and reports the bound backup; only mutations are blocked; +- pruning must never delete a backup referenced by a non-terminal journal; +- modal GUI actions use asynchronous operation IDs so the pipe reader and UI + message pump cannot deadlock each other; +- the GUI coverage denominator comes from runtime interactive-control + enumeration, not from the static manifest alone; +- causal proof includes an architecture rule preventing bridge-to-Application + shortcuts, an observed GUI event/message leg, and independent filesystem or + SQLite effects; a bypass negative-control test must fail the gate; +- the Headful harness rejects real-home aliases/reparse points and only cleans + a sentinel-bearing disposable root beneath its owned evidence directory. + +The review's proposed Node-to-C# thin-wrapper conversion was not adopted: +the npm CLI is an existing cross-platform product surface, while the Business +host is a Windows release companion. Instead, a shared declarative transaction +corpus will keep Node and C# failure semantics aligned. Suggestions involving +`auth.json` replacement and credential backup were inapplicable: this tool +does not read, copy, or modify `auth.json`. Full two-participant crash +forward-recovery is not promised by v0.4; a post-crash journal deliberately +blocks later mutations and exposes the exact backup for explicit restore, +which matches the Issue #69 acceptance boundary. diff --git a/docs/V0.4_AUTOMATION_PLAN.md b/docs/V0.4_AUTOMATION_PLAN.md new file mode 100644 index 0000000..08e34e2 --- /dev/null +++ b/docs/V0.4_AUTOMATION_PLAN.md @@ -0,0 +1,355 @@ +# v0.4.0 Automation execution plan + +> Status: authoritative and resumable. +> +> Integration branch: `agent/v0.4-automation-integration` +> +> Frozen Phase 1 baseline: PR #70 head +> `b4308ee73dfb6206a61fb71e6f70b4447656af05` + +This file is the source of truth for the v0.4.0 engineering work. After a +context reset, read this file, the final integration PR description, and the +current Git status before continuing from the first incomplete gate. + +## 1. Live baseline + +Recorded on 2026-08-03 (Asia/Hong_Kong): + +- `origin/main`: `ca347b915026ac44b5f9e63a3224bc4eabe53e6f` +- PR #70: open Draft, base `main`, head `b4308ee73dfb6206a61fb71e6f70b4447656af05` +- PR #70 CI: run `30808409605`, conclusion `success` +- PR #70 review: Codex found no major issue at `b4308ee`; no unresolved + review threads +- PR #70 manual smoke checkbox is still unchecked, so no historical GUI smoke + result is accepted as final evidence +- Issue #69: open; partial rollout and global-state writes are not reliably + visible to the outer rollback path +- PR #62: closed, unmerged, superseded by #70; design reference only +- final integration branch did not exist remotely before this worktree was + created + +Local isolated baseline at `b4308ee`: + +- Node: 110 passed +- Core: 91 passed, 1 conditional WSL-only test skipped +- Application: 9 passed +- WinForms: 29 passed +- release version verification for v0.3.2: passed +- `git diff --check`: passed + +Evidence is under the ignored `artifacts/evidence/baseline-b4308ee-*` directory. + +## 2. Architecture and ownership + +The required dependency direction is: + +```text +Core storage and domain operations + ^ +Application use cases and immutable operation state + ^ ^ +Business Automation host WinForms event adapter + ^ + real WinForms controls + ^ + GUI Automation bridge +``` + +Responsibilities: + +- Core owns config, rollout, SQLite, global state, backups, transaction + primitives, restore, storage resolution, locking, and WSL safety. +- Application owns status, diagnostics, plan, sync, switch, restore, prune, + settings/provider changes, operation serialization, cancellation, immutable + inputs, plan freshness, and structured outcomes. +- WinForms owns rendering, native/platform dialogs, focus, external folder + launching, and conversion of real control events to Application calls. +- Business Automation calls Application use cases directly and never copies + Core workflows. +- GUI Automation drives actual control instances on the UI thread and records + the control-event-to-Application causal chain. It may not call Application + directly while claiming a control was exercised. + +## 3. Transaction and recovery model (Issue #69) + +Both Node and .NET implementations use equivalent semantics: + +1. Acquire the existing per-Codex-Home operation lock. +2. Refuse a new write if an unresolved transaction journal exists. +3. Scan and fingerprint targets, validate SQLite, and create the managed + backup before any target mutation. +4. Create a durable transaction journal under + the operation's managed backup as `transaction-journal.jsonl` before the + first target mutation. +5. Stage replacement content beside each target, flush it, and use a + same-directory atomic replacement. Each append-only journal record is + flushed before the associated target transition. +6. Before replacing each target, record `applying`; after replacement and + metadata restoration, record `applied`. The outer coordinator receives the + durable applied list even when the batch throws. +7. SQLite remains protected by its own transaction. Rollout/global-state file + mutations are compensated from the managed backup if any normal failure or + cancellation occurs. +8. Rollback records each target independently. If rollback itself fails, keep + the journal and return the original error, rollback errors, backup path, + incomplete targets, and safe recovery instructions. +9. A completed operation atomically marks the journal `committed`; a completed + rollback marks it `rolledBack`. Terminal journals may be retained as audit + evidence and pruned with their managed backup. +10. Startup/status checks detect non-terminal or truncated journals. Read-only + diagnostics remain available and show the bound backup; mutations are + blocked until an explicit restore marks that journal rolled back. No + silent half-complete state is accepted, and pruning protects every backup + referenced by a non-terminal journal. + +The fault-injection matrix covers backup failure, staging failure, first and +Nth target failure, atomic replacement failure, global-state primary/backup +partial failure, rollback failure, cancellation, crash/restart recovery, +concurrency, duplicate execution, idempotent success, and the ordinary success +path. Tests inspect disk state independently of returned objects. + +`--apply` remains unavailable until this matrix passes in both implementations. + +M1 evidence at the first implementation checkpoint: Node `120/120`; Core +`101 passed / 1 pre-existing WSL-only conditional skip`. Both ran with fresh +temporary HOME/UserProfile/Codex/SQLite/AppData/Temp/cache roots and inherited +credential-like environment variables removed. + +## 4. Application use cases + +Application exposes one `IApplicationService`-style boundary with immutable +request and result records for: + +- `Describe` +- `GetStatus` / diagnostics +- `CreatePlan` +- `ExecutePlan` for sync and switch +- `Restore` +- `PruneBackups` +- `Refresh` +- manual provider add/remove and persisted storage/settings changes needed by + the current GUI + +Every operation has an `operationId`, lifecycle state, structured warnings and +errors, cancellation, and one-at-a-time concurrency protection. WinForms and +Business Automation use the same implementation. Existing #70 behavior is a +regression gate: provider fallback, three model modes, busy locking, immutable +snapshots, no await-time parameter drift, refresh serialization, and hard +blocking after a failed storage refresh. + +## 5. Business Automation protocol + +The release contains `CodexProviderSync.Automation.exe` and +`automation-protocol-v0.4.schema.json`. + +Protocol family: experimental `0.4`; one process invocation emits exactly one +JSON result on stdout. Diagnostics go to stderr. Commands: + +- `describe` +- `status` +- `plan` +- `sync` +- `switch` +- `restore` +- `prune` + +Write commands default to plan-only. Mutation requires all of: + +- explicit `--apply` +- a plan document produced by the same protocol version +- the exact SHA-256 plan digest +- an unexpired plan +- matching normalized inputs, target paths, target fingerprint, and isolated + root policy + +Exit codes are stable within protocol 0.4: `0` success, `2` validation or +usage, `3` stale/invalid plan, `4` target busy/concurrency, `5` operation +failed but rollback completed, `6` recovery required/rollback incomplete, +`7` cancellation/timeout, and `10` internal protocol failure. + +Machine-readable results distinguish `success`, `warning`, `failure`, +`rollback`, and `recovery`. Unknown commands, malformed input, unsupported +capabilities, path escape, timeout, cancellation, stale plans, and duplicate +execution are schema-tested. No command reads or modifies `auth.json`. + +## 6. GUI interaction inventory + +Static MainForm entries currently discovered: + +| Region | Stable ID | Type | Actions | Capability | +| --- | --- | --- | --- | --- | +| storage | `storage.codexHome` | ComboBox | get/set/select | settings + refresh input | +| storage | `storage.codexHome.browse` | Button | invoke | dialog service | +| storage | `storage.sqliteHome` | TextBox | get/set | settings + refresh input | +| storage | `storage.sqliteHome.browse` | Button | invoke | dialog service | +| storage | `status.refresh` | Button | invoke | refresh | +| status | `status.output` | RichTextBox | get | ui-only result rendering | +| provider | `provider.list` | ListView | get/select | provider selection | +| provider | `provider.manualId` | TextBox | get/set | manual provider input | +| provider | `provider.addManual` | Button | invoke | add provider | +| provider | `provider.removeManual` | Button | invoke | remove provider | +| execution | `execution.updateConfig` | CheckBox | get/set/toggle | sync/switch mode | +| execution | `execution.model.followProvider` | RadioButton | get/set | model mode | +| execution | `execution.model.keepCurrent` | RadioButton | get/set | model mode | +| execution | `execution.model.custom` | RadioButton | get/set | model mode | +| execution | `execution.customModel` | TextBox | get/set | model input | +| restore | `restore.includeConfig` | CheckBox | get/set | restore options | +| restore | `restore.includeDatabase` | CheckBox | get/set | restore options | +| restore | `restore.includeSessions` | CheckBox | get/set | restore options | +| backup | `backup.retentionCount` | NumericUpDown | get/set | retention | +| operation | `operation.execute` | Button | invoke | sync/switch | +| restore | `restore.execute` | Button | invoke | restore | +| backup | `backups.openDirectory` | Button | invoke | ui-only shell boundary | +| backup | `backups.prune` | Button | invoke | prune | +| update | `updates.check` | Button | invoke | update check | +| logs | `logs.openDirectory` | Button | invoke | ui-only shell boundary | + +Runtime enumeration is authoritative and may add stable IDs for containers, +focusable status elements, application-owned dialogs, and keyboard commands. +Provider rows use template `provider.row` with a normalized provider ID hash as +`instanceKey`; visible text and list order never participate in identity. + +Native folder-picker internals are exempt because Windows owns them. The real +browse button event must still run, while an injected restricted dialog +service supplies a path inside the isolated root. Shell-open actions use an +injected launcher in Automation mode and verify the requested isolated path. + +## 7. GUI bridge and safety + +The bridge is disabled in normal mode. Automation launch requires an isolated +root containing the sentinel `.codex-provider-sync-test-root` and a +bootstrap descriptor created with user-only access. The descriptor contains a +random pipe name and one-time token; the token is never placed on the command +line or written to ordinary logs. + +Transport is a Windows named pipe with `PipeOptions.CurrentUserOnly`, one +authenticated client, request allowlists, bounded messages, timeouts, and +replay rejection. Paths must remain under the canonical isolated root. Normal +mode, missing/wrong/replayed credentials, a second client, path traversal, and +non-isolated dialog/capture paths are rejected. + +Capabilities: + +- `ui.launch` (harness/bootstrap) +- `ui.describe` +- `ui.snapshot` +- `ui.get` +- `ui.set` +- `ui.invoke` +- `ui.wait` +- `ui.respond` +- `ui.capture` +- `ui.shutdown` + +Commands marshal to the actual WinForms UI thread. `ui.invoke` uses real +control APIs (`PerformClick`, selection/text/check changes, and real window +messages for keyboard paths). Dialog operations are asynchronous and return an +`actionId`/`operationId`; `ui.respond` operates actual application-owned dialog +buttons. A deterministic operation gate can hold a real use case to test busy, +disabled, cancel, and timeout states. + +Each business action emits: + +```text +automationId -> GUI event -> operationId -> Application capability -> result +``` + +with UI-thread identity, event name, timestamps, structured outcome, and +redacted values. + +## 8. Manifest and coverage gate + +The static manifest records window/region, control type, actions, +visible/enabled conditions, Application capability or `ui-only`, risk, +scenario IDs, and narrow exemptions. Runtime snapshots record created +instances, visible/enabled/busy state, safe values/options, dialogs, and active +operations. + +The gate fails for an unregistered interactive control, duplicate ID, missing +template, unused manifest entry, unexecuted declared action, missing E2E +mapping, missing business trace, or unjustified exemption. Required coverage is +100% for declarations, runtime instances, and declared entry actions. + +Generated matrix: + +```text +window -> automationId/templateId -> control -> action -> API + -> Application capability/ui-only -> test -> trace -> result +``` + +## 9. Isolated verification and one-command regression + +Every test process receives an explicit allowlisted environment with temporary +`HOME`, `USERPROFILE`, `CODEX_HOME`, SQLite Home, AppData, LocalAppData, Temp, +NuGet cache, npm cache, settings, backups, logs, captures, and fixture provider +URLs using `example.invalid`. Credential-like inherited variables are removed. +Every root contains the sentinel and every candidate read/write path is checked +before launch. Tests refuse to start if isolation cannot be proven. + +The one-command Windows gate will be: + +```powershell +pwsh ./scripts/test-windows-release-automation.ps1 +``` + +It performs Release publish, isolated fixture creation, real visible GUI +launch, bridge authentication, manifest traversal, all Business and GUI +scenarios, filesystem/result equivalence, trace/capture/matrix generation, +restart verification, and safe shutdown. A hidden or skipped run is not a +Headful pass. + +Formal matrix: + +- all existing Node/.NET tests +- transaction fault injection and crash recovery +- Application unit and concurrency/cancellation tests +- Business host process/schema/exit-code tests +- bridge protocol and security tests +- manifest/runtime/action/trace gate +- sync/switch/restore/prune Business-vs-GUI filesystem equivalence +- Windows Release publish and published-host invocation +- real visible Windows Release GUI full-entry E2E and restart +- macOS Core/Application build compatibility +- release version and package-content verification +- GitHub CI at the exact final head + +## 10. Version, packaging, and compatibility + +All shipped npm/.NET projects move together to `0.4.0`. The protocol remains +experimental `0.4`; manifest and protocol versions identify incompatible +changes. The Windows ZIP includes the GUI, Automation host, schema, and static +manifest. No formal tag or Release is created in this work. + +## 11. PR #62 disposition + +Retained: layering, shared application behavior, structured machine protocol, +plan/apply, explicit write opt-in, temporary fixtures, stable identifiers, +schema validation, and package verification. + +Changed: no stable JSONL v1 promise; one-shot 0.4 JSON is the initial Business +API; plan lifetime is evidence-driven; UI Automation is a real Headful bridge, +not a hidden layout probe; the release may contain the companion executable +because current requirements now justify it. + +Rejected: fixed five-minute lifetime as a permanent contract, a prematurely +stable v1 surface, reflection-based MainForm invocation, hidden/mock GUI +substitution, and future macOS migration as a v0.4 blocker. + +## 12. Milestones and gates + +- [x] M0a: read governing instructions and live GitHub state +- [x] M0b: create integration worktree from exact PR #70 head +- [x] M0c: isolated baseline bound to `b4308ee` +- [x] M0d: independent architecture review and checkpoint push +- [x] M1: #69 transaction/recovery implementation and fault matrix +- [ ] M2: complete shared Application use cases and WinForms migration +- [ ] M3: Business Automation host, schema, packaging, and process tests +- [ ] M4: GUI manifest, stable IDs, real-control bridge, trace, and safety +- [ ] M5: one-command Release Headful E2E and 100% entry/action gate +- [ ] M6: version/docs/release notes/migration notes and preview package +- [ ] M7: final exact-head local suite, Claude/Codex/GitHub review, CI, and + handoff report + +Completion is permitted only when every required item passes at the same final +head. Otherwise the handoff states `PARTIAL-BLOCKED` with exact external +blockers; touching real data or a protected boundary is `FAILED`. diff --git a/src/atomic-file.js b/src/atomic-file.js new file mode 100644 index 0000000..ee4ac7d --- /dev/null +++ b/src/atomic-file.js @@ -0,0 +1,44 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +export async function writeFileAtomic( + filePath, + content, + encoding = "utf8", + { faultInjector } = {} +) { + const fullPath = path.resolve(filePath); + const directory = path.dirname(fullPath); + const tempPath = path.join( + directory, + `.${path.basename(fullPath)}.provider-sync.${process.pid}.${randomUUID()}.tmp` + ); + let originalMode = null; + try { + originalMode = (await fs.stat(fullPath)).mode; + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + try { + const handle = await fs.open(tempPath, "wx", originalMode ?? 0o600); + try { + await faultInjector?.({ point: "before_stage_write", filePath: fullPath, tempPath }); + await handle.writeFile(content, encoding); + await handle.sync(); + } finally { + await handle.close(); + } + if (originalMode !== null) { + await fs.chmod(tempPath, originalMode); + } + await faultInjector?.({ point: "before_atomic_replace", filePath: fullPath, tempPath }); + await fs.rename(tempPath, fullPath); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } +} diff --git a/src/backup.js b/src/backup.js index e01e316..0b85aa7 100644 --- a/src/backup.js +++ b/src/backup.js @@ -16,6 +16,7 @@ import { resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; +import { findPendingTransactions } from "./transaction-journal.js"; function timestampSlug(date = new Date()) { return date.toISOString().replaceAll(":", "").replaceAll("-", "").replace(".", ""); @@ -237,7 +238,11 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI const backupRoot = defaultBackupRoot(codexHome); const backupDirs = await listManagedBackupDirectories(backupRoot); - const toDelete = backupDirs.slice(keepCount); + const pending = await findPendingTransactions(codexHome); + const protectedBackups = new Set(pending.map((transaction) => path.resolve(transaction.backupDir))); + const toDelete = backupDirs + .slice(keepCount) + .filter((entry) => !protectedBackups.has(path.resolve(entry.fullPath))); let freedBytes = 0; for (const entry of toDelete) { freedBytes += await getDirectorySize(entry.fullPath); diff --git a/src/config-file.js b/src/config-file.js index 479f111..4803760 100644 --- a/src/config-file.js +++ b/src/config-file.js @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { writeFileAtomic } from "./atomic-file.js"; import { DEFAULT_PROVIDER } from "./constants.js"; @@ -219,5 +220,5 @@ export function setRootModelInConfigText(configText, model) { } export async function writeConfigText(configPath, configText) { - await fs.writeFile(configPath, configText, "utf8"); + await writeFileAtomic(configPath, configText, "utf8"); } diff --git a/src/service.js b/src/service.js index 221fef4..f107bd7 100644 --- a/src/service.js +++ b/src/service.js @@ -53,6 +53,50 @@ import { resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; +import { + TransactionJournal, + assertNoPendingTransactions, + findPendingTransactions, + markBackupTransactionRolledBack +} from "./transaction-journal.js"; + +export class SyncTransactionError extends Error { + constructor( + originalError, + rollbackErrors, + backupDir, + completedTargets, + uncompletedTargets, + { rollbackStatus = "incomplete", recoveryRequired = true } = {} + ) { + const message = recoveryRequired + ? `Failed to restore state after sync error. Original error: ${originalError.message}. Restore error: ${rollbackErrors.join("; ")}` + : `Provider sync failed and all observed changes were rolled back. Original error: ${originalError.message}`; + super(message, { cause: originalError }); + this.name = "SyncTransactionError"; + this.code = recoveryRequired ? "RECOVERY_REQUIRED" : "SYNC_FAILED_ROLLED_BACK"; + this.originalError = originalError; + this.rollbackErrors = rollbackErrors; + this.backupDir = backupDir; + this.completedTargets = completedTargets; + this.uncompletedTargets = uncompletedTargets; + this.rollbackStatus = rollbackStatus; + this.recoveryRequired = recoveryRequired; + this.recoveryInstructions = recoveryRequired + ? `Restore the managed backup at ${backupDir}, inspect the pending transaction journal, then retry.` + : "No manual recovery is required. Inspect the original error, correct its cause, and retry."; + } +} + +function throwIfAborted(signal) { + if (!signal?.aborted) { + return; + } + const error = new Error("The provider-sync operation was cancelled."); + error.name = "AbortError"; + error.code = "ABORT_ERR"; + throw error; +} async function prepareStorage({ codexHome: explicitCodexHome, sqliteHome, configText, storage, platform }) { if (storage) { @@ -135,6 +179,7 @@ export async function getStatus({ codexHome: explicitCodexHome, sqliteHome, plat ? [] : await readProjectThreadVisibility(storage); const backupSummary = await getBackupSummary(codexHome); + const pendingTransactions = await findPendingTransactions(codexHome); return { codexHome, @@ -154,7 +199,13 @@ export async function getStatus({ codexHome: explicitCodexHome, sqliteHome, plat sqliteRepairStats, projectThreadVisibility, backupRoot: defaultBackupRoot(codexHome), - backupSummary + backupSummary, + pendingTransactions: pendingTransactions.map((transaction) => ({ + operationId: transaction.operationId ?? null, + state: transaction.state, + backupDir: transaction.backupDir, + journalPath: transaction.filePath + })) }; } @@ -168,6 +219,15 @@ export function renderStatus(status) { `Backup root: ${status.backupRoot}` ]; + if (status.pendingTransactions?.length) { + lines.push(""); + lines.push("Recovery required:"); + for (const transaction of status.pendingTransactions) { + lines.push(` ${transaction.state}: ${transaction.backupDir}`); + } + lines.push(" Run restore with the listed backup before the next write operation."); + } + lines.push(""); lines.push("Rollout files:"); lines.push(` sessions: ${formatCounts(status.rolloutCounts.sessions)}`); @@ -239,7 +299,9 @@ async function runSyncCore({ sqliteBusyTimeoutMs, onProgress, model = null, - platform + platform, + faultInjector, + signal } = {}, { afterBackup } = {}) { if (!Number.isInteger(keepCount) || keepCount < 1) { throw new Error(`Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.`); @@ -247,19 +309,21 @@ async function runSyncCore({ const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); - const configText = await readConfigText(configPath); - const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); - assertSqliteAccessSupported(storage, "sync"); - if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { - throw missingConfiguredStateDbError(storage); - } - const current = readCurrentProviderFromConfigText(configText); - const targetProvider = provider ?? current.provider ?? DEFAULT_PROVIDER; - const releaseLock = await acquireLock(codexHome, "sync"); let backupDir = null; + let journal = null; let backupDurationMs = 0; try { + await assertNoPendingTransactions(codexHome); + throwIfAborted(signal); + const configText = await readConfigText(configPath); + const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); + assertSqliteAccessSupported(storage, "sync"); + if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } + const current = readCurrentProviderFromConfigText(configText); + const targetProvider = provider ?? current.provider ?? DEFAULT_PROVIDER; emitProgress(onProgress, { stage: "scan_rollout_files", status: "start" }); const { changes, @@ -301,6 +365,8 @@ async function runSyncCore({ status: "start", writableCount: writableChanges.length }); + throwIfAborted(signal); + await faultInjector?.({ point: "before_backup" }); const backupStartedAt = Date.now(); backupDir = await createBackup({ storage, @@ -318,12 +384,30 @@ async function runSyncCore({ durationMs: backupDurationMs }); - if (typeof afterBackup === "function") { - await afterBackup(backupDir); - } + const globalStatePath = path.join(codexHome, ".codex-global-state.json"); + const globalStateBackupPath = path.join(codexHome, ".codex-global-state.json.bak"); + const potentialTargets = [ + ...writableChanges.map((change) => change.path), + globalStatePath, + globalStateBackupPath, + ...(configBackupText !== undefined ? [configPath] : []), + ...(storage.stateDbCandidates ?? []).map((candidate) => candidate.path) + ].map((targetPath) => path.resolve(targetPath)); + journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider, + potentialTargets + }); let sessionRestoreNeeded = false; let appliedSessionChanges = []; + const completedTargets = []; + const recordCompletedTarget = (targetPath) => { + const fullPath = path.resolve(targetPath); + if (!completedTargets.includes(fullPath)) { + completedTargets.push(fullPath); + } + }; let globalStateRestoreNeeded = false; let workspaceRootResult = { updated: false, @@ -331,6 +415,15 @@ async function runSyncCore({ savedWorkspaceRootCount: 0 }; try { + if (typeof afterBackup === "function") { + throwIfAborted(signal); + await journal.applying("config", configPath); + await afterBackup(backupDir); + await journal.applied("config", configPath); + recordCompletedTarget(configPath); + await faultInjector?.({ point: "after_config_apply", path: configPath }); + } + let applyResult = { appliedChanges: 0, appliedPaths: [], skippedPaths: [] }; emitProgress(onProgress, { stage: "update_sqlite", status: "start" }); emitProgress(onProgress, { @@ -343,14 +436,40 @@ async function runSyncCore({ targetProvider, async () => { if (writableChanges.length > 0) { - applyResult = await applySessionChanges(writableChanges, { targetModel: model }); - const appliedPathSet = new Set(applyResult.appliedPaths ?? []); - appliedSessionChanges = writableChanges.filter((change) => appliedPathSet.has(change.path)); - sessionRestoreNeeded = appliedSessionChanges.length > 0; - await updateSessionBackupManifest(backupDir, appliedSessionChanges); + applyResult = await applySessionChanges(writableChanges, { + targetModel: model, + onBeforeApply: async (change) => { + throwIfAborted(signal); + await journal.applying("rollout", change.path); + await faultInjector?.({ + point: "before_rollout_apply", + path: change.path, + targetIndex: appliedSessionChanges.length + 1 + }); + }, + onApplied: async (change) => { + appliedSessionChanges.push(change); + sessionRestoreNeeded = true; + await journal.applied("rollout", change.path); + recordCompletedTarget(change.path); + await updateSessionBackupManifest(backupDir, appliedSessionChanges); + await faultInjector?.({ point: "after_rollout_apply", path: change.path, appliedCount: appliedSessionChanges.length }); + } + }); } - workspaceRootResult = await syncWorkspaceRoots(storage, { cwdStats }); - globalStateRestoreNeeded = workspaceRootResult.updated; + workspaceRootResult = await syncWorkspaceRoots(storage, { + cwdStats, + onBeforeWrite: async (targetPath) => { + throwIfAborted(signal); + await journal.applying("globalState", targetPath); + }, + onApplied: async (targetPath) => { + globalStateRestoreNeeded = true; + await journal.applied("globalState", targetPath); + recordCompletedTarget(targetPath); + await faultInjector?.({ point: "after_global_state_apply", path: targetPath }); + } + }); }, { busyTimeoutMs: sqliteBusyTimeoutMs, userEventThreadIds, threadCwdById, targetModel: model } ); @@ -365,6 +484,7 @@ async function runSyncCore({ status: "complete", updatedRows: sqliteResult.updatedRows }); + recordCompletedTarget(storage.stateDbLocation?.path ?? storage.sqliteHome); const skippedLockedRolloutFiles = [...new Set([ ...skippedRolloutFiles, ...applyResult.skippedPaths @@ -387,7 +507,7 @@ async function runSyncCore({ deletedCount: autoPruneResult?.deletedCount ?? 0, warning: autoPruneWarning }); - return { + const result = { codexHome, sqliteHome: storage.sqliteHome, sqliteHomeSource: storage.sqliteHomeSource, @@ -410,32 +530,72 @@ async function runSyncCore({ autoPruneResult, autoPruneWarning }; + await journal.committed(); + return result; } catch (error) { const restoreFailures = []; + try { + await journal?.rollingBack(error); + } catch (journalError) { + restoreFailures.push(`transaction journal: ${journalError.message}`); + } if (sessionRestoreNeeded) { try { - await restoreSessionChanges(appliedSessionChanges.map((change) => ({ - path: change.path, - originalFirstLine: change.originalFirstLine, - originalSeparator: change.originalSeparator - }))); + await faultInjector?.({ point: "before_rollout_rollback", appliedCount: appliedSessionChanges.length }); + await restoreSessionChanges(appliedSessionChanges); } catch (restoreError) { restoreFailures.push(`rollout files: ${restoreError.message}`); } } if (globalStateRestoreNeeded && backupDir) { try { + await faultInjector?.({ point: "before_global_state_rollback" }); await restoreGlobalStateFilesFromBackup(backupDir, codexHome); } catch (restoreError) { restoreFailures.push(`global state: ${restoreError.message}`); } } + if (configBackupText !== undefined) { + try { + await writeConfigText(configPath, configBackupText); + } catch (restoreError) { + restoreFailures.push(`config: ${restoreError.message}`); + } + } + if (restoreFailures.length === 0) { + try { + await journal?.rolledBack(); + } catch (journalError) { + restoreFailures.push(`transaction journal: ${journalError.message}`); + } + } if (restoreFailures.length > 0) { - throw new Error( - `Failed to restore state after sync error. Original error: ${error.message}. Restore error: ${restoreFailures.join("; ")}` + try { + await journal?.recoveryRequired(error, restoreFailures); + } catch { + // Preserve the original and rollback errors even if the journal is + // no longer writable. + } + const completedSet = new Set(completedTargets); + const uncompletedTargets = potentialTargets.filter((targetPath) => !completedSet.has(targetPath)); + throw new SyncTransactionError( + error, + restoreFailures, + backupDir, + completedTargets, + uncompletedTargets, + { rollbackStatus: "incomplete", recoveryRequired: true } ); } - throw error; + const completedSet = new Set(completedTargets); + throw new SyncTransactionError( + error, + [], + backupDir, + completedTargets, + potentialTargets.filter((targetPath) => !completedSet.has(targetPath)), + { rollbackStatus: "complete", recoveryRequired: false } + ); } } finally { await releaseLock(); @@ -571,12 +731,15 @@ export async function runRestore({ } const releaseLock = await acquireLock(codexHome, "restore"); try { - return await restoreBackup(path.resolve(backupDir), storage, { + const normalizedBackupDir = path.resolve(backupDir); + const result = await restoreBackup(normalizedBackupDir, storage, { restoreConfig, restoreDatabase, restoreSessions, allowSqliteHomeRelocation }); + await markBackupTransactionRolledBack(normalizedBackupDir); + return result; } finally { await releaseLock(); } diff --git a/src/session-files.js b/src/session-files.js index d7c1b00..5a34ee2 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -1157,7 +1157,7 @@ export async function collectSessionChanges(codexHome, targetProvider, options = export async function applySessionChanges(changes, options = {}) { const normalizedChanges = changes ?? []; - const { targetModel = null } = options ?? {}; + const { targetModel = null, onBeforeApply, onApplied } = options ?? {}; const skippedPaths = []; const appliedPaths = []; let appliedChanges = 0; @@ -1174,14 +1174,16 @@ export async function applySessionChanges(changes, options = {}) { const firstLineChanges = normalizedChanges.filter((change) => !change?.modelOnlyChange); if (process.platform === "win32") { - const results = firstLineChanges.length > 0 - ? await invokeWindowsExclusiveRewriteBatch(firstLineChanges, { requireOriginalMatch: true }) - : []; - for (let index = 0; index < firstLineChanges.length; index += 1) { - const change = firstLineChanges[index]; - if (results[index] === "APPLIED" || results[index] === "APPLIED_IN_PLACE") { + // Process one file per helper invocation. A failed Windows batch cannot + // report which earlier members were already replaced, which was the root + // cause of #69. Per-target calls let the durable coordinator observe each + // successful mutation before the next target starts. + for (const change of firstLineChanges) { + await onBeforeApply?.(change); + const [result] = await invokeWindowsExclusiveRewriteBatch([change], { requireOriginalMatch: true }); + if (result === "APPLIED" || result === "APPLIED_IN_PLACE") { appliedChanges += 1; - inPlaceChanges += results[index] === "APPLIED_IN_PLACE" ? 1 : 0; + inPlaceChanges += result === "APPLIED_IN_PLACE" ? 1 : 0; appliedPaths.push(change.path); if (change.modelRewriteRequired) { const modelResult = await rewriteRolloutModelField(change, targetModel); @@ -1189,12 +1191,14 @@ export async function applySessionChanges(changes, options = {}) { change.appliedTurnContextRewrites = modelResult.replacedLines; } await restoreOriginalMtime(change.path, change.originalMtimeMs); + await onApplied?.(change); } else { skippedPaths.push(change.path); } } } else { for (const change of firstLineChanges) { + await onBeforeApply?.(change); const result = await tryRewriteCollectedFirstLine(change); if (result === "APPLIED" || result === "APPLIED_IN_PLACE") { appliedChanges += 1; @@ -1206,6 +1210,7 @@ export async function applySessionChanges(changes, options = {}) { change.appliedTurnContextRewrites = modelResult.replacedLines; } await restoreOriginalMtime(change.path, change.originalMtimeMs); + await onApplied?.(change); } else { skippedPaths.push(change.path); } @@ -1220,6 +1225,7 @@ export async function applySessionChanges(changes, options = {}) { // the file's timestamp is preserved exactly the way the user // set it. for (const change of modelOnlyChanges) { + await onBeforeApply?.(change); let modelResult; try { modelResult = await rewriteRolloutModelField(change, targetModel); @@ -1233,6 +1239,7 @@ export async function applySessionChanges(changes, options = {}) { appliedPaths.push(change.path); change.originalTurnContextModels = modelResult.originalTurnContextModels; change.appliedTurnContextRewrites = modelResult.replacedLines; + await onApplied?.(change); } else { skippedPaths.push(change.path); } diff --git a/src/transaction-journal.js b/src/transaction-journal.js new file mode 100644 index 0000000..3803d91 --- /dev/null +++ b/src/transaction-journal.js @@ -0,0 +1,181 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { defaultBackupRoot } from "./constants.js"; + +export const TRANSACTION_JOURNAL_BASENAME = "transaction-journal.jsonl"; +const TERMINAL_STATES = new Set(["committed", "rolledBack"]); + +async function appendDurableJsonLine(filePath, value) { + const handle = await fs.open(filePath, "a"); + try { + await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } +} + +export class TransactionJournal { + constructor(filePath, operationId, sequence = 0) { + this.filePath = filePath; + this.operationId = operationId; + this.sequence = sequence; + } + + static async create(backupDir, details) { + const operationId = randomUUID(); + const filePath = path.join(backupDir, TRANSACTION_JOURNAL_BASENAME); + const journal = new TransactionJournal(filePath, operationId); + await journal.append("prepared", { + protocolVersion: 1, + backupDir: path.resolve(backupDir), + codexHome: path.resolve(details.codexHome), + targetProvider: details.targetProvider, + potentialTargets: [...new Set(details.potentialTargets.map((value) => path.resolve(value)))].sort() + }); + return journal; + } + + async append(state, details = {}) { + this.sequence += 1; + await appendDurableJsonLine(this.filePath, { + protocolVersion: 1, + operationId: this.operationId, + sequence: this.sequence, + state, + recordedAt: new Date().toISOString(), + ...details + }); + } + + async applying(kind, targetPath) { + await this.append("applying", { kind, targetPath: path.resolve(targetPath) }); + } + + async applied(kind, targetPath) { + await this.append("applied", { kind, targetPath: path.resolve(targetPath) }); + } + + async committed() { + await this.append("committed"); + } + + async rollingBack(originalError) { + await this.append("rollingBack", { originalError: String(originalError?.message ?? originalError) }); + } + + async rolledBack() { + await this.append("rolledBack"); + } + + async recoveryRequired(originalError, rollbackErrors) { + await this.append("recoveryRequired", { + originalError: String(originalError?.message ?? originalError), + rollbackErrors: rollbackErrors.map(String) + }); + } +} + +export async function readTransactionJournal(filePath) { + const text = await fs.readFile(filePath, "utf8"); + const events = []; + let invalidTail = false; + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + try { + events.push(JSON.parse(line)); + } catch { + invalidTail = true; + break; + } + } + const lastEvent = events.at(-1) ?? null; + return { + filePath, + events, + invalidTail, + operationId: events[0]?.operationId ?? null, + backupDir: events[0]?.backupDir ?? path.dirname(filePath), + state: invalidTail ? "recoveryRequired" : (lastEvent?.state ?? "recoveryRequired"), + terminal: !invalidTail && TERMINAL_STATES.has(lastEvent?.state) + }; +} + +export async function findPendingTransactions(codexHome) { + const root = defaultBackupRoot(codexHome); + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + + const pending = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const journalPath = path.join(root, entry.name, TRANSACTION_JOURNAL_BASENAME); + try { + const journal = await readTransactionJournal(journalPath); + if (!journal.terminal) { + pending.push(journal); + } + } catch (error) { + if (error?.code !== "ENOENT") { + pending.push({ + filePath: journalPath, + backupDir: path.dirname(journalPath), + state: "recoveryRequired", + terminal: false, + readError: error.message + }); + } + } + } + return pending.sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +export class RecoveryRequiredError extends Error { + constructor(pendingTransactions) { + const backups = pendingTransactions.map((item) => item.backupDir).join(", "); + super(`An unfinished provider-sync transaction requires recovery before another write. Restore the bound backup, then retry. Backup(s): ${backups}`); + this.name = "RecoveryRequiredError"; + this.code = "RECOVERY_REQUIRED"; + this.pendingTransactions = pendingTransactions; + } +} + +export async function assertNoPendingTransactions(codexHome) { + const pending = await findPendingTransactions(codexHome); + if (pending.length > 0) { + throw new RecoveryRequiredError(pending); + } +} + +export async function markBackupTransactionRolledBack(backupDir) { + const filePath = path.join(path.resolve(backupDir), TRANSACTION_JOURNAL_BASENAME); + try { + const current = await readTransactionJournal(filePath); + if (current.terminal) { + return; + } + const journal = new TransactionJournal( + filePath, + current.operationId ?? randomUUID(), + current.events?.at(-1)?.sequence ?? 0 + ); + await journal.rolledBack(); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } +} diff --git a/src/workspace-roots.js b/src/workspace-roots.js index b2b0dad..54e56ab 100644 --- a/src/workspace-roots.js +++ b/src/workspace-roots.js @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { writeFileAtomic } from "./atomic-file.js"; import { GLOBAL_STATE_BACKUP_FILE_BASENAME, @@ -436,8 +437,12 @@ export async function syncWorkspaceRoots(storageOrCodexHome, options = {}) { const backupMissing = await fs.access(backupPath).then(() => false).catch(() => true); const updated = savedRootsChanged || projectOrderChanged || activeRootsChanged || labelsChanged || openTargetsChanged || backupMissing; if (updated) { - await fs.writeFile(filePath, nextText, "utf8"); - await fs.writeFile(backupPath, nextText, "utf8"); + await options.onBeforeWrite?.(filePath); + await writeFileAtomic(filePath, nextText, "utf8"); + await options.onApplied?.(filePath); + await options.onBeforeWrite?.(backupPath); + await writeFileAtomic(backupPath, nextText, "utf8"); + await options.onApplied?.(backupPath); } return { diff --git a/test/sync-service.test.js b/test/sync-service.test.js index ad064b1..e55e26f 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -17,9 +17,270 @@ import { DB_FILE_BASENAME, DEFAULT_BACKUP_RETENTION_COUNT, SQLITE_DIR_BASENAME } import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; +import { TransactionJournal, findPendingTransactions } from "../src/transaction-journal.js"; +import { writeFileAtomic } from "../src/atomic-file.js"; delete process.env.CODEX_SQLITE_HOME; +test("runSync rolls back the first rollout when a later target fails (#69)", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const firstPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + const secondPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-b.jsonl"); + await writeRollout(firstPath, "thread-a", "apigather"); + await writeRollout(secondPath, "thread-b", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather" }, + { id: "thread-b", model_provider: "apigather" } + ]); + const firstBefore = await fs.readFile(firstPath, "utf8"); + const secondBefore = await fs.readFile(secondPath, "utf8"); + + await assert.rejects( + runSync({ + codexHome, + provider: "openai", + faultInjector: ({ point, appliedCount }) => { + if (point === "after_rollout_apply" && appliedCount === 1) { + throw new Error("injected second-target failure"); + } + } + }), + /injected second-target failure/ + ); + + assert.equal(await fs.readFile(firstPath, "utf8"), firstBefore); + assert.equal(await fs.readFile(secondPath, "utf8"), secondBefore); + const db = await openDatabase(stateDbPath(codexHome)); + try { + const providers = db.prepare("SELECT model_provider FROM threads ORDER BY id").all(); + assert.deepEqual(providers.map((row) => row.model_provider), ["apigather", "apigather"]); + } finally { + db.close(); + } + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("runSync restores global-state primary when backup write fails (#69)", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const originalState = { + "electron-saved-workspace-roots": ["\\\\?\\D:\\Workspace\\sample"], + "project-order": ["\\\\?\\D:\\Workspace\\sample"], + "active-workspace-roots": ["\\\\?\\D:\\Workspace\\sample"] + }; + await writeGlobalState(codexHome, originalState); + await writeStateDb(codexHome, [ + { id: "thread-global", model_provider: "openai", cwd: "\\\\?\\D:\\Workspace\\sample" } + ]); + const primaryPath = path.join(codexHome, ".codex-global-state.json"); + const backupPath = path.join(codexHome, ".codex-global-state.json.bak"); + const primaryBefore = await fs.readFile(primaryPath, "utf8"); + const backupBefore = await fs.readFile(backupPath, "utf8"); + + await assert.rejects( + runSync({ + codexHome, + faultInjector: ({ point, path: appliedPath }) => { + if (point === "after_global_state_apply" && appliedPath === primaryPath) { + throw new Error("injected global-state backup failure"); + } + } + }), + /injected global-state backup failure/ + ); + + assert.equal(await fs.readFile(primaryPath, "utf8"), primaryBefore); + assert.equal(await fs.readFile(backupPath, "utf8"), backupBefore); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("unfinished journal blocks writes until the bound backup is restored", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeStateDb(codexHome, [{ id: "thread-recovery", model_provider: "openai" }]); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + + const status = await getStatus({ codexHome }); + assert.equal(status.pendingTransactions.length, 1); + assert.match(renderStatus(status), /Recovery required:[\s\S]*Run restore/); + await assert.rejects( + runSync({ codexHome }), + (error) => error?.code === "RECOVERY_REQUIRED" && error.pendingTransactions.length === 1 + ); + + await runRestore({ backupDir, codexHome }); + assert.deepEqual(await findPendingTransactions(codexHome), []); + const result = await runSync({ codexHome }); + assert.equal(result.targetProvider, "openai"); +}); + +test("rollback failure preserves both errors and manual recovery evidence", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-rollback-failure.jsonl"); + await writeRollout(sessionPath, "thread-rollback", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-rollback", model_provider: "apigather" }]); + + let error; + try { + await runSync({ + codexHome, + faultInjector: ({ point }) => { + if (point === "after_rollout_apply") { + throw new Error("injected original failure"); + } + if (point === "before_rollout_rollback") { + throw new Error("injected rollback failure"); + } + } + }); + assert.fail("runSync should fail when rollback is injected to fail"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.match(error.originalError.message, /injected original failure/); + assert.ok(error.rollbackErrors.some((value) => value.includes("injected rollback failure"))); + assert.equal(error.rollbackStatus, "incomplete"); + assert.equal(error.recoveryRequired, true); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + + await runRestore({ backupDir: error.backupDir, codexHome }); + const firstLine = (await fs.readFile(sessionPath, "utf8")).split("\n")[0]; + assert.equal(JSON.parse(firstLine).payload.model_provider, "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("cancellation after the first target rolls back disk and SQLite with structured evidence", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const firstPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cancel-a.jsonl"); + const secondPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cancel-b.jsonl"); + await writeRollout(firstPath, "thread-cancel-a", "apigather"); + await writeRollout(secondPath, "thread-cancel-b", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-cancel-a", model_provider: "apigather" }, + { id: "thread-cancel-b", model_provider: "apigather" } + ]); + const before = await Promise.all([fs.readFile(firstPath, "utf8"), fs.readFile(secondPath, "utf8")]); + const controller = new AbortController(); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + signal: controller.signal, + faultInjector: ({ point, appliedCount }) => { + if (point === "after_rollout_apply" && appliedCount === 1) { + controller.abort(); + } + } + }); + assert.fail("runSync should observe cancellation at the next target checkpoint"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.code, "SYNC_FAILED_ROLLED_BACK"); + assert.equal(error.originalError.name, "AbortError"); + assert.equal(error.rollbackStatus, "complete"); + assert.equal(error.recoveryRequired, false); + assert.ok(error.completedTargets.includes(path.resolve(firstPath))); + assert.equal(await fs.readFile(firstPath, "utf8"), before[0]); + assert.equal(await fs.readFile(secondPath, "utf8"), before[1]); + const db = await openDatabase(stateDbPath(codexHome)); + try { + const providers = db.prepare("SELECT model_provider FROM threads ORDER BY id").all(); + assert.deepEqual(providers.map((row) => row.model_provider), ["apigather", "apigather"]); + } finally { + db.close(); + } + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("backup failure occurs before journal or target mutation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-backup-failure.jsonl"); + await writeRollout(sessionPath, "thread-backup-failure", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-backup-failure", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + + await assert.rejects( + runSync({ + codexHome, + faultInjector: ({ point }) => { + if (point === "before_backup") { + throw new Error("injected backup creation failure"); + } + } + }), + /injected backup creation failure/ + ); + + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.deepEqual(await findPendingTransactions(codexHome), []); + await assert.rejects(fs.access(backupRoot(codexHome))); +}); + +test("atomic replacement failure preserves the original file and removes staging", async () => { + if (process.platform !== "win32") { + return; + } + const { root } = await makeTempCodexHome(); + const targetPath = path.join(root, "atomic-target.txt"); + await fs.writeFile(targetPath, "before", "utf8"); + const lockProcess = await lockRolloutFile(targetPath); + try { + await assert.rejects(() => writeFileAtomic(targetPath, "after")); + } finally { + lockProcess.kill(); + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } + assert.equal(await fs.readFile(targetPath, "utf8"), "before"); + const staging = (await fs.readdir(root)).filter((name) => name.includes(".provider-sync.") && name.endsWith(".tmp")); + assert.deepEqual(staging, []); +}); + +for (const faultPoint of ["before_stage_write", "before_atomic_replace"]) { + test(`atomic writer ${faultPoint} failure preserves the original and removes staging`, async () => { + const { root } = await makeTempCodexHome(); + const targetPath = path.join(root, `atomic-${faultPoint}.txt`); + await fs.writeFile(targetPath, "before", "utf8"); + + await assert.rejects( + () => writeFileAtomic(targetPath, "after", "utf8", { + faultInjector: ({ point }) => { + if (point === faultPoint) { + throw new Error(`injected ${faultPoint}`); + } + } + }), + new RegExp(`injected ${faultPoint}`) + ); + + assert.equal(await fs.readFile(targetPath, "utf8"), "before"); + const staging = (await fs.readdir(root)).filter((name) => name.includes(".provider-sync.") && name.endsWith(".tmp")); + assert.deepEqual(staging, []); + }); +} + async function makeTempCodexHome() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-")); const codexHome = path.join(root, ".codex"); @@ -2215,6 +2476,25 @@ test("pruneBackups ignores directories without managed backup metadata", async ( await fs.access(junkDirectory); }); +test("pruneBackups never deletes a backup referenced by an unfinished transaction", async () => { + const { codexHome } = await makeTempCodexHome(); + const pendingDir = path.join(backupRoot(codexHome), "20260319T000000000Z"); + await writeBackup(codexHome, "20260319T000000000Z", [["note.txt", "pending"]]); + await writeBackup(codexHome, "20260320T000000000Z", [["note.txt", "terminal"]]); + await TransactionJournal.create(pendingDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [] + }); + + const result = await pruneBackups(codexHome, 0); + + assert.equal(result.deletedCount, 1); + assert.equal(result.remainingCount, 1); + await fs.access(pendingDir); + await assert.rejects(fs.access(path.join(backupRoot(codexHome), "20260320T000000000Z"))); +}); + test("runSync auto-prunes backups to the default retention count", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); From 38062c3d34169b334239f407f4b020af465f4503 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Tue, 4 Aug 2026 12:45:08 +0800 Subject: [PATCH 04/19] fix: harden cross-runtime transaction recovery --- .../BackupParityTests.cs | 167 ++ .../CodexProviderSync.Core.Tests.csproj | 7 +- .../CoreIntegrationTests.cs | 1409 ++++++++++++++++- .../CodexProviderSync.CrashHost.csproj | 14 + .../CrashHost/Program.cs | 21 + .../LockServiceTests.cs | 436 ++++- .../SqliteOnlineBackupTests.cs | 166 ++ .../TransactionJournalTests.cs | 208 +++ desktop/CodexProviderSync.Core/AtomicFile.cs | 103 +- .../CodexProviderSync.Core/BackupService.cs | 364 ++++- .../CodexSyncService.cs | 515 ++++-- desktop/CodexProviderSync.Core/LockService.cs | 786 ++++++++- desktop/CodexProviderSync.Core/Models.cs | 111 ++ .../Properties/AssemblyInfo.cs | 1 + .../SessionRolloutService.cs | 111 +- .../SqliteStateService.cs | 207 +++ .../TransactionJournalService.cs | 553 ++++++- src/atomic-file.js | 37 + src/backup.js | 382 ++++- src/locking.js | 594 ++++++- src/service.js | 408 ++++- src/session-files.js | 338 ++-- src/sqlite-state.js | 167 +- src/sqlite.js | 31 +- src/transaction-journal.js | 348 +++- test/locking.test.js | 379 ++++- test/sqlite-online-backup.test.js | 120 ++ test/sync-service.test.js | 1294 ++++++++++++++- 28 files changed, 8477 insertions(+), 800 deletions(-) create mode 100644 desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs create mode 100644 desktop/CodexProviderSync.Core.Tests/CrashHost/CodexProviderSync.CrashHost.csproj create mode 100644 desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs create mode 100644 desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs create mode 100644 desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs create mode 100644 test/sqlite-online-backup.test.js diff --git a/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs b/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs new file mode 100644 index 0000000..b431a96 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json; + +namespace CodexProviderSync.Core.Tests; + +public sealed class BackupParityTests +{ + [Fact] + public async Task CreateBackup_WritesCanonicalCrossRuntimeMetadata() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string rolloutPath = fixture.RolloutPath("sessions", "rollout-backup-parity.jsonl"); + await fixture.WriteRolloutAsync(rolloutPath, "thread-backup-parity", "apigather"); + DateTimeOffset originalTimestamp = new(2026, 7, 8, 9, 10, 11, 123, TimeSpan.Zero); + File.SetLastWriteTimeUtc(rolloutPath, originalTimestamp.UtcDateTime); + await File.WriteAllTextAsync( + Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename), + "{\"source\":\"csharp\"}\n"); + + SessionRolloutService rollouts = new(); + SessionChangeCollection changes = await rollouts.CollectSessionChangesAsync( + fixture.CodexHome, + "openai"); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + + using JsonDocument metadata = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(backupDir, "metadata.json"))); + JsonElement metadataRoot = metadata.RootElement; + JsonElement globalStateFiles = metadataRoot.GetProperty("globalStateFiles"); + Assert.True(globalStateFiles.GetProperty(AppConstants.GlobalStateFileBasename).GetBoolean()); + Assert.False(globalStateFiles.GetProperty(AppConstants.GlobalStateBackupFileBasename).GetBoolean()); + Assert.True(metadataRoot.GetProperty("globalStateFilePresent").GetBoolean()); + Assert.False(metadataRoot.GetProperty("globalStateBackupFilePresent").GetBoolean()); + + using JsonDocument manifest = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(backupDir, "session-meta-backup.json"))); + JsonElement entry = Assert.Single(manifest.RootElement.GetProperty("files").EnumerateArray()); + Assert.Equal("2026-07-08T09:10:11.123Z", entry.GetProperty("originalLastWriteTimeUtc").GetString()); + Assert.Equal(originalTimestamp.ToUnixTimeMilliseconds(), entry.GetProperty("originalMtimeMs").GetInt64()); + Assert.Equal(JsonValueKind.String, entry.GetProperty("originalLastWriteTimeUtcTicks").ValueKind); + Assert.Equal( + originalTimestamp.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), + entry.GetProperty("originalLastWriteTimeUtcTicks").GetString()); + } + + [Fact] + public async Task RestoreBackup_AcceptsNodeStyleV2MetadataAndMillisecondSessionTimestamp() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string rolloutPath = fixture.RolloutPath("sessions", "rollout-node-backup.jsonl"); + await fixture.WriteRolloutAsync(rolloutPath, "thread-node-backup", "apigather"); + string originalFirstLine = (await File.ReadAllLinesAsync(rolloutPath))[0]; + DateTimeOffset originalTimestamp = new(2026, 7, 8, 9, 10, 11, 456, TimeSpan.Zero); + + string backupDir = fixture.BackupPath("20260708T091011456Z"); + Directory.CreateDirectory(backupDir); + await File.WriteAllTextAsync(Path.Combine(backupDir, "config.toml"), "model_provider = \"apigather\"\n"); + await File.WriteAllTextAsync( + Path.Combine(backupDir, AppConstants.GlobalStateFileBasename), + "{\"source\":\"node-backup\"}\n"); + await File.WriteAllTextAsync( + Path.Combine(backupDir, "metadata.json"), + JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + sqliteHome = Path.Combine(fixture.CodexHome, AppConstants.SqliteDirBasename), + targetProvider = "openai", + createdAt = "2026-07-08T09:10:11.456Z", + dbFiles = Array.Empty(), + sqliteDbFiles = Array.Empty(), + changedSessionFiles = 1, + globalStateFiles = new Dictionary + { + [AppConstants.GlobalStateFileBasename] = true, + [AppConstants.GlobalStateBackupFileBasename] = false + } + })); + await File.WriteAllTextAsync( + Path.Combine(backupDir, "session-meta-backup.json"), + JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = "2026-07-08T09:10:11.456Z", + files = new[] + { + new + { + path = rolloutPath, + originalFirstLine, + originalSeparator = "\n", + originalLastWriteTimeUtc = "2026-07-08T09:10:11.456Z", + originalMtimeMs = originalTimestamp.ToUnixTimeMilliseconds(), + modelOnlyChange = false, + originalTurnContextModels = Array.Empty() + } + } + })); + + await fixture.WriteRolloutAsync(rolloutPath, "thread-node-backup", "openai"); + string statePath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename); + string stateBackupPath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateBackupFileBasename); + await File.WriteAllTextAsync(statePath, "{\"source\":\"current\"}\n"); + await File.WriteAllTextAsync(stateBackupPath, "created-after-backup\n"); + + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + await backups.RestoreBackupAsync( + backupDir, + fixture.CodexHome, + new RestoreBackupOptions { RestoreDatabase = false }); + + Assert.Equal(originalFirstLine, (await File.ReadAllLinesAsync(rolloutPath))[0]); + Assert.Equal(originalTimestamp.UtcDateTime, File.GetLastWriteTimeUtc(rolloutPath)); + Assert.Equal("{\"source\":\"node-backup\"}\n", await File.ReadAllTextAsync(statePath)); + Assert.False(File.Exists(stateBackupPath)); + } + + [Fact] + public async Task RestoreGlobalState_RejectsCanonicalAndLegacyPresenceConflictBeforeMutation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string backupDir = fixture.BackupPath("20260708T091011999Z"); + Directory.CreateDirectory(backupDir); + string statePath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename); + await File.WriteAllTextAsync(statePath, "current\n"); + await File.WriteAllTextAsync( + Path.Combine(backupDir, AppConstants.GlobalStateFileBasename), + "backup\n"); + await File.WriteAllTextAsync( + Path.Combine(backupDir, "metadata.json"), + JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = "2026-07-08T09:10:11.999Z", + dbFiles = Array.Empty(), + sqliteDbFiles = Array.Empty(), + changedSessionFiles = 0, + globalStateFiles = new Dictionary + { + [AppConstants.GlobalStateFileBasename] = true, + [AppConstants.GlobalStateBackupFileBasename] = true + }, + globalStateFilePresent = true, + globalStateBackupFilePresent = false + })); + + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + InvalidOperationException error = await Assert.ThrowsAsync( + () => backups.RestoreGlobalStateFilesAsync(backupDir, fixture.CodexHome)); + + Assert.Contains("disagrees", error.Message, StringComparison.Ordinal); + Assert.Equal("current\n", await File.ReadAllTextAsync(statePath)); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj index 6cb59ac..b60a51c 100644 --- a/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj +++ b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj @@ -18,8 +18,13 @@ + + + + + - \ No newline at end of file + diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index 838d106..91e82ae 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -25,7 +25,7 @@ await fixture.WriteStateDbAsync([ CodexSyncService service = new(); service.FaultInjector = (point, _, appliedCount) => { - if (point == "after_rollout_apply" && appliedCount == 1) + if (point == "before_rollout_apply" && appliedCount == 2) { throw new IOException("injected second-target failure"); } @@ -37,6 +37,9 @@ await fixture.WriteStateDbAsync([ Assert.Contains("injected second-target failure", error.OriginalError.Message); Assert.Equal("complete", error.RollbackStatus); Assert.False(error.RecoveryRequired); + Assert.Equal( + RelativeTargetIdentity(fixture.CodexHome, firstPath), + RelativeTargetIdentity(fixture.CodexHome, Assert.Single(error.CompletedTargets))); Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-a")); @@ -84,6 +87,185 @@ await fixture.WriteStateDbWithCwdAsync([ Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); } + [Fact] + public async Task FailureAfterSqliteCommit_RestoresRolloutAndDatabase() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-after-sqlite.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-after-sqlite", "apigather"); + await fixture.WriteStateDbAsync([("thread-after-sqlite", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_sqlite_commit") + { + throw new IOException("injected post-SQLite failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome, provider: "openai")); + + Assert.Contains("injected post-SQLite failure", error.OriginalError.Message); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Contains( + RelativeTargetIdentity(fixture.CodexHome, sessionPath), + error.CompletedTargets.Select(path => RelativeTargetIdentity(fixture.CodexHome, path))); + Assert.Contains( + RelativeTargetIdentity(fixture.CodexHome, fixture.StateDbPath()), + error.CompletedTargets.Select(path => RelativeTargetIdentity(fixture.CodexHome, path))); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-after-sqlite")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task CancellationAfterSqliteCommit_RestoresRolloutAndDatabase() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-cancel-after-sqlite.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-cancel-after-sqlite", "apigather"); + await fixture.WriteStateDbAsync([("thread-cancel-after-sqlite", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + using CancellationTokenSource cancellation = new(); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_sqlite_commit") + { + cancellation.Cancel(); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync( + fixture.CodexHome, + provider: "openai", + cancellationToken: cancellation.Token)); + + Assert.IsType(error.OriginalError); + Assert.True(error.WasCanceled); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-after-sqlite")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task CancellationAfterTransactionCommit_DoesNotRollBackCommittedState() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-cancel-after-commit.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-cancel-after-commit", "apigather"); + await fixture.WriteStateDbAsync([("thread-cancel-after-commit", "apigather", false)]); + using CancellationTokenSource cancellation = new(); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_transaction_commit") + { + cancellation.Cancel(); + } + return Task.CompletedTask; + }; + + SyncResult result = await service.RunSyncAsync( + fixture.CodexHome, + provider: "openai", + cancellationToken: cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.Equal(1, result.ChangedSessionFiles); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-after-commit")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task FailureBeforeTransactionCommit_RollsBackBeforePruningOldBackups() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-before-commit-failure.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-before-commit", "apigather"); + await fixture.WriteStateDbAsync([("thread-before-commit", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + const string oldBackupName = "20260319T000000000Z"; + await fixture.WriteBackupAsync(oldBackupName, ("note.txt", "must survive rollback")); + string oldBackupDir = fixture.BackupPath(oldBackupName); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "before_transaction_commit") + { + throw new IOException("injected transaction-commit failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome, provider: "openai", keepCount: 1)); + + Assert.Contains("injected transaction-commit failure", error.OriginalError.Message); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.True(Directory.Exists(oldBackupDir)); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-before-commit")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task SqliteRollbackFailure_PreservesRecoveryEvidence_AndManualRestoreRecovers() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-sqlite-rollback-failure.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-sqlite-rollback", "apigather"); + await fixture.WriteStateDbAsync([("thread-sqlite-rollback", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_sqlite_commit") + { + throw new IOException("injected post-SQLite failure"); + } + if (point == "before_sqlite_rollback") + { + throw new IOException("injected SQLite rollback failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome, provider: "openai")); + + Assert.True(error.RecoveryRequired); + Assert.Equal("incomplete", error.RollbackStatus); + Assert.Contains(error.RollbackErrors, value => value.Contains("injected SQLite rollback failure")); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-sqlite-rollback")); + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + + await service.RunRestoreAsync(fixture.CodexHome, error.BackupDirectory); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-sqlite-rollback")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + [Fact] public async Task UnfinishedJournal_BlocksWrites_UntilBoundBackupIsRestored() { @@ -118,6 +300,54 @@ await FileTransactionJournal.CreateAsync( Assert.Equal("openai", result.TargetProvider); } + [Fact] + public async Task CrashRecovery_RestoresActuallyMutatedRolloutAndDatabase_FromPendingJournal() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-crash-recovery.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-crash-recovery", "apigather"); + await fixture.WriteStateDbAsync([("thread-crash-recovery", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + + SessionRolloutService rolloutService = new(); + SqliteStateService sqliteService = new(); + SessionChangeCollection changes = await rolloutService.CollectSessionChangesAsync( + fixture.CodexHome, + "openai"); + BackupService backupService = new(rolloutService, sqliteService); + string backupDir = await backupService.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [sessionPath, fixture.StateDbPath()]); + + await journal.ApplyingAsync("rollout", sessionPath); + await rolloutService.ApplySessionChangesAsync(changes.Changes); + await journal.AppliedAsync("rollout", sessionPath); + await journal.ApplyingAsync("sqlite", fixture.StateDbPath()); + await sqliteService.UpdateSqliteProviderAsync(fixture.CodexHome, "openai"); + await journal.AppliedAsync("sqlite", fixture.StateDbPath()); + + Assert.NotEqual(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-crash-recovery")); + CodexSyncService service = new(); + Assert.Single((await service.GetStatusAsync(fixture.CodexHome)).PendingTransactions); + await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + + await service.RunRestoreAsync(fixture.CodexHome, backupDir); + + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-crash-recovery")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + [Fact] public async Task RollbackFailure_PreservesBothErrors_AndManualRecoveryEvidence() { @@ -173,10 +403,12 @@ await fixture.WriteStateDbAsync([ string secondBefore = await File.ReadAllTextAsync(secondPath); using CancellationTokenSource cancellation = new(); CodexSyncService service = new(); - service.FaultInjector = (point, _, appliedCount) => + string? cancelledAfterPath = null; + service.FaultInjector = (point, appliedPath, appliedCount) => { if (point == "after_rollout_apply" && appliedCount == 1) { + cancelledAfterPath = appliedPath; cancellation.Cancel(); } return Task.CompletedTask; @@ -189,10 +421,19 @@ await fixture.WriteStateDbAsync([ cancellationToken: cancellation.Token)); Assert.IsType(error.OriginalError); + Assert.True(error.WasCanceled); Assert.Equal("SYNC_FAILED_ROLLED_BACK", error.Code); Assert.Equal("complete", error.RollbackStatus); Assert.False(error.RecoveryRequired); - Assert.Contains(Path.GetFullPath(firstPath), error.CompletedTargets); + string observedRelativeTarget = RelativeTargetIdentity( + fixture.CodexHome, + Assert.IsType(cancelledAfterPath)); + Assert.Equal( + RelativeTargetIdentity(fixture.CodexHome, firstPath), + observedRelativeTarget); + Assert.Equal( + RelativeTargetIdentity(fixture.CodexHome, firstPath), + RelativeTargetIdentity(fixture.CodexHome, Assert.Single(error.CompletedTargets))); Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-a")); @@ -201,98 +442,339 @@ await fixture.WriteStateDbAsync([ } [Fact] - public async Task BackupFailure_OccursBeforeJournalOrTargetMutation() + public async Task Rollback_ContinuesPerTarget_WhenOneRolloutRestoreFails() { TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); await fixture.WriteConfigAsync("model_provider = \"openai\""); - string sessionPath = fixture.RolloutPath("sessions", "rollout-backup-failure.jsonl"); - await fixture.WriteRolloutAsync(sessionPath, "thread-backup-failure", "apigather"); - await fixture.WriteStateDbAsync([("thread-backup-failure", "apigather", false)]); - string before = await File.ReadAllTextAsync(sessionPath); + string firstPath = fixture.RolloutPath("sessions", "rollout-per-target-a.jsonl"); + string secondPath = fixture.RolloutPath("sessions", "rollout-per-target-b.jsonl"); + await fixture.WriteRolloutAsync(firstPath, "thread-per-target-a", "apigather"); + await fixture.WriteRolloutAsync(secondPath, "thread-per-target-b", "apigather"); + string firstBefore = await File.ReadAllTextAsync(firstPath); + string secondBefore = await File.ReadAllTextAsync(secondPath); CodexSyncService service = new(); - service.FaultInjector = (point, _, _) => + service.FaultInjector = (point, path, count) => { - if (point == "before_backup") + if (point == "after_rollout_apply" && count == 2) { - throw new IOException("injected backup creation failure"); + throw new IOException("injected after both rollout writes"); + } + if (point == "before_rollout_rollback" + && string.Equals(Path.GetFullPath(path!), Path.GetFullPath(secondPath), StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("injected second rollout restore failure"); } return Task.CompletedTask; }; - IOException error = await Assert.ThrowsAsync( + SyncTransactionException error = await Assert.ThrowsAsync( () => service.RunSyncAsync(fixture.CodexHome)); - Assert.Contains("injected backup creation failure", error.Message); - Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); - Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); - Assert.False(Directory.Exists(fixture.BackupRoot())); + + Assert.True(error.RecoveryRequired); + Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.NotEqual(secondBefore, await File.ReadAllTextAsync(secondPath)); + Assert.Contains(error.RollbackErrors, failure => + failure.Contains(secondPath, StringComparison.Ordinal) + && failure.Contains("injected second rollout restore failure", StringComparison.Ordinal)); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + error.BackupDirectory, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = true + }); + Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); } [Fact] - public async Task AtomicReplacementFailure_PreservesOriginalAndRemovesStaging() + public async Task Rollback_DoesNotRestoreSqlite_WhenItsTransactionNeverCommitted() { - if (!OperatingSystem.IsWindows()) - { - return; - } TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); - string targetPath = Path.Combine(fixture.Root, "atomic-target.txt"); - await File.WriteAllTextAsync(targetPath, "before"); - await using (FileStream locked = new( - targetPath, - FileMode.Open, - FileAccess.Read, - FileShare.None)) + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-sqlite-not-committed.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-sqlite-not-committed", "apigather"); + await fixture.WriteStateDbAsync([("thread-sqlite-not-committed", "apigather", false)]); + bool concurrentMarkerWritten = false; + CodexSyncService service = new(); + service.FaultInjector = async (point, _, _) => { - await Assert.ThrowsAnyAsync( - () => AtomicFile.WriteAllTextAsync(targetPath, "after")); - } - Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); - Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); - } - - [Theory] - [InlineData("before_stage_write")] - [InlineData("before_atomic_replace")] - public async Task AtomicWriter_InjectedFailure_PreservesOriginalAndRemovesStaging(string faultPoint) - { - TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); - string targetPath = Path.Combine(fixture.Root, $"atomic-{faultPoint}.txt"); - await File.WriteAllTextAsync(targetPath, "before"); + if (point == "after_rollout_apply") + { + throw new IOException("injected before SQLite commit"); + } + if (point == "before_rollout_rollback" && !concurrentMarkerWritten) + { + await using SqliteConnection connection = fixture.OpenSqliteConnection(); + await connection.OpenAsync(); + SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) + VALUES ('concurrent-marker', 'external', '', 0, 'external') + """; + await command.ExecuteNonQueryAsync(); + concurrentMarkerWritten = true; + } + }; - IOException error = await Assert.ThrowsAsync( - () => AtomicFile.WriteAllTextAsync( - targetPath, - "after", - faultInjector: (point, _, _) => - { - if (point == faultPoint) - { - throw new IOException($"injected {faultPoint}"); - } - return Task.CompletedTask; - })); + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); - Assert.Contains($"injected {faultPoint}", error.Message); - Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); - Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); + Assert.False(error.RecoveryRequired); + Assert.True(concurrentMarkerWritten); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-sqlite-not-committed")); + Assert.Equal("external", await ReadProviderAsync(fixture.StateDbPath(), "concurrent-marker")); } [Fact] - public async Task PruneBackups_NeverDeletesBackupReferencedByUnfinishedTransaction() + public async Task Cancellation_AfterOnlyRollout_IsObservedBeforeSqliteCommit() { TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); - await fixture.WriteBackupAsync("20260319T000000000Z", ("note.txt", "pending")); - await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "terminal")); - string pendingDir = fixture.BackupPath("20260319T000000000Z"); - await FileTransactionJournal.CreateAsync( - pendingDir, - fixture.CodexHome, - "openai", - []); - - BackupPruneResult result = await new BackupService( - new SessionRolloutService(), - new SqliteStateService()).PruneBackupsAsync(fixture.CodexHome, 0); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-cancel-only.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-cancel-only", "apigather"); + await fixture.WriteStateDbAsync([("thread-cancel-only", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + using CancellationTokenSource cancellation = new(); + CodexSyncService service = new(); + service.FaultInjector = (point, _, appliedCount) => + { + if (point == "after_rollout_apply" && appliedCount == 1) + { + cancellation.Cancel(); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync( + fixture.CodexHome, + provider: "openai", + cancellationToken: cancellation.Token)); + + Assert.IsType(error.OriginalError); + Assert.True(error.WasCanceled); + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + Assert.Equal( + RelativeTargetIdentity(fixture.CodexHome, sessionPath), + RelativeTargetIdentity(fixture.CodexHome, Assert.Single(error.CompletedTargets))); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-cancel-only")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task ConcurrentSync_IsRejectedByOperationLock_WithoutCompetingMutation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-concurrent.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-concurrent", "apigather"); + await fixture.WriteStateDbAsync([("thread-concurrent", "apigather", false)]); + TaskCompletionSource firstMutationObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseFirstOperation = new(TaskCreationOptions.RunContinuationsAsynchronously); + CodexSyncService firstService = new(); + firstService.FaultInjector = async (point, _, appliedCount) => + { + if (point == "after_rollout_apply" && appliedCount == 1) + { + firstMutationObserved.TrySetResult(true); + await releaseFirstOperation.Task; + } + }; + + Task firstSync = firstService.RunSyncAsync(fixture.CodexHome, provider: "openai"); + await firstMutationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + try + { + InvalidOperationException error = await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync(fixture.CodexHome, provider: "openai")); + Assert.Contains("Lock already exists", error.Message); + } + finally + { + releaseFirstOperation.TrySetResult(true); + } + + SyncResult result = await firstSync.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(1, result.ChangedSessionFiles); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-concurrent")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task RepeatedSync_IsIdempotentForRolloutAndSqliteState() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-idempotent.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-idempotent", "apigather"); + await fixture.WriteStateDbAsync([("thread-idempotent", "apigather", false)]); + CodexSyncService service = new(); + + SyncResult first = await service.RunSyncAsync(fixture.CodexHome, provider: "openai"); + string afterFirst = await File.ReadAllTextAsync(sessionPath); + SyncResult second = await service.RunSyncAsync(fixture.CodexHome, provider: "openai"); + + Assert.Equal(1, first.ChangedSessionFiles); + Assert.Equal(1, first.SqliteProviderRowsUpdated); + Assert.Equal(0, second.ChangedSessionFiles); + Assert.Equal(0, second.SqliteProviderRowsUpdated); + Assert.Equal(0, second.SqliteRowsUpdated); + Assert.Equal(afterFirst, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-idempotent")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task BackupFailure_OccursBeforeJournalOrTargetMutation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-backup-failure.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-backup-failure", "apigather"); + await fixture.WriteStateDbAsync([("thread-backup-failure", "apigather", false)]); + string before = await File.ReadAllTextAsync(sessionPath); + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "before_backup") + { + throw new IOException("injected backup creation failure"); + } + return Task.CompletedTask; + }; + + IOException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + Assert.Contains("injected backup creation failure", error.Message); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.False(Directory.Exists(fixture.BackupRoot())); + } + + [Fact] + public async Task AtomicReplacementFailure_PreservesOriginalAndRemovesStaging() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string targetPath = Path.Combine(fixture.Root, "atomic-target.txt"); + await File.WriteAllTextAsync(targetPath, "before"); + await using (FileStream locked = new( + targetPath, + FileMode.Open, + FileAccess.Read, + FileShare.None)) + { + await Assert.ThrowsAnyAsync( + () => AtomicFile.WriteAllTextAsync(targetPath, "after")); + } + Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); + Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); + } + + [Theory] + [InlineData("before_stage_write")] + [InlineData("before_atomic_replace")] + public async Task AtomicWriter_InjectedFailure_PreservesOriginalAndRemovesStaging(string faultPoint) + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string targetPath = Path.Combine(fixture.Root, $"atomic-{faultPoint}.txt"); + await File.WriteAllTextAsync(targetPath, "before"); + + IOException error = await Assert.ThrowsAsync( + () => AtomicFile.WriteAllTextAsync( + targetPath, + "after", + faultInjector: (point, _, _) => + { + if (point == faultPoint) + { + throw new IOException($"injected {faultPoint}"); + } + return Task.CompletedTask; + })); + + Assert.Contains($"injected {faultPoint}", error.Message); + Assert.Equal("before", await File.ReadAllTextAsync(targetPath)); + Assert.Empty(Directory.GetFiles(fixture.Root, "*.provider-sync.*.tmp")); + } + + [Fact] + public async Task RestoreSqlite_AtomicReplacementFailurePreservesCurrentDatabaseBytes() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([ + ("thread-atomic-restore", "openai", false) + ]); + SessionRolloutService rollouts = new(); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + string stateDbPath = fixture.StateDbPath(); + await using (SqliteConnection connection = fixture.OpenSqliteConnection()) + { + await connection.OpenAsync(); + SqliteCommand update = connection.CreateCommand(); + update.CommandText = "UPDATE threads SET model_provider = 'apigather' WHERE id = 'thread-atomic-restore'"; + Assert.Equal(1, await update.ExecuteNonQueryAsync()); + } + byte[] currentBytes = await File.ReadAllBytesAsync(stateDbPath); + backups.AtomicWriteFaultInjector = (point, targetPath, _) => + point == "before_atomic_replace" + && string.Equals( + Path.GetFullPath(targetPath), + Path.GetFullPath(stateDbPath), + StringComparison.OrdinalIgnoreCase) + ? Task.FromException(new IOException("injected SQLite restore replacement failure")) + : Task.CompletedTask; + + IOException error = await Assert.ThrowsAsync( + () => backups.RestoreBackupAsync( + backupDir, + fixture.CodexHome, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = false + })); + + Assert.Contains("injected SQLite restore", error.Message); + Assert.Equal(currentBytes, await File.ReadAllBytesAsync(stateDbPath)); + Assert.Empty(Directory.EnumerateFiles( + Path.GetDirectoryName(stateDbPath)!, + "*.provider-sync.*.tmp", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task PruneBackups_NeverDeletesBackupReferencedByUnfinishedTransaction() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteBackupAsync("20260319T000000000Z", ("note.txt", "pending")); + await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "terminal")); + string pendingDir = fixture.BackupPath("20260319T000000000Z"); + await FileTransactionJournal.CreateAsync( + pendingDir, + fixture.CodexHome, + "openai", + []); + + BackupPruneResult result = await new BackupService( + new SessionRolloutService(), + new SqliteStateService()).PruneBackupsAsync(fixture.CodexHome, 0); Assert.Equal(1, result.DeletedCount); Assert.Equal(1, result.RemainingCount); @@ -1527,6 +2009,71 @@ public async Task RunSync_RewritesTurnContextModelFieldInRolloutFiles() Assert.Equal(2, turnContextCount); } + [Fact] + public async Task ApplySessionChanges_RejectsTurnContextAppendedBetweenProviderAndModelRewrite() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string sessionPath = fixture.RolloutPath("sessions", "rollout-concurrent-append.jsonl"); + await fixture.WriteRolloutWithTurnContextAsync( + sessionPath, + "thread-concurrent-append", + "apigather", + "old-model"); + + SessionRolloutService service = new(); + SessionChangeCollection collected = await service.CollectSessionChangesAsync( + fixture.CodexHome, + "openai", + targetModel: "target-model"); + Assert.Single(collected.Changes); + string appendedLine = JsonSerializer.Serialize(new + { + timestamp = "2026-08-04T00:00:00Z", + type = "turn_context", + payload = new + { + turn_id = "late-turn", + model = "late-model", + collaboration_mode = new + { + settings = new { model = "late-collaboration-model" } + } + } + }); + service.ApplyFaultInjector = async (phase, _) => + { + if (phase == "after-provider-before-model") + { + await File.AppendAllTextAsync(sessionPath, appendedLine + "\n"); + } + }; + + InvalidOperationException error = await Assert.ThrowsAsync( + () => service.ApplySessionChangesAsync(collected.Changes, "target-model")); + + Assert.Contains("changed after it was scanned", error.Message); + string[] lines = await File.ReadAllLinesAsync(sessionPath); + using (JsonDocument header = JsonDocument.Parse(lines[0])) + { + Assert.Equal( + "apigather", + header.RootElement.GetProperty("payload").GetProperty("model_provider").GetString()); + } + string late = Assert.Single(lines, line => line.Contains("late-turn", StringComparison.Ordinal)); + using JsonDocument lateRecord = JsonDocument.Parse(late); + Assert.Equal( + "late-model", + lateRecord.RootElement.GetProperty("payload").GetProperty("model").GetString()); + Assert.Equal( + "late-collaboration-model", + lateRecord.RootElement + .GetProperty("payload") + .GetProperty("collaboration_mode") + .GetProperty("settings") + .GetProperty("model") + .GetString()); + } + [Fact] public async Task RunSync_LeavesTurnContextModelFieldAlone_WhenNoRootModelConfigured() { @@ -2090,19 +2637,713 @@ await Assert.ThrowsAsync(() => service.RunRestoreAsyn await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml"))); } - private static async Task ReadProviderAsync(string dbPath, string threadId) + [Fact] + public async Task CrashWindow_AfterSecondRolloutMutationBeforeApplied_ExplicitRestoreUsesImmutableManifest() { - SqliteConnectionStringBuilder builder = new() - { - DataSource = dbPath, - Mode = SqliteOpenMode.ReadOnly, - Pooling = false - }; - await using SqliteConnection connection = new(builder.ConnectionString); - await connection.OpenAsync(); - SqliteCommand command = connection.CreateCommand(); - command.CommandText = "SELECT model_provider FROM threads WHERE id = $id"; - command.Parameters.AddWithValue("$id", threadId); - return Convert.ToString(await command.ExecuteScalarAsync())!; + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string firstPath = fixture.RolloutPath("sessions", "rollout-crash-window-a.jsonl"); + string secondPath = fixture.RolloutPath("sessions", "rollout-crash-window-b.jsonl"); + await fixture.WriteRolloutAsync(firstPath, "thread-crash-window-a", "apigather"); + await fixture.WriteRolloutAsync(secondPath, "thread-crash-window-b", "apigather"); + string firstBefore = await File.ReadAllTextAsync(firstPath); + string secondBefore = await File.ReadAllTextAsync(secondPath); + SessionRolloutService rollouts = new(); + SessionChangeCollection changes = await rollouts.CollectSessionChangesAsync( + fixture.CodexHome, + "openai"); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [firstPath, secondPath]); + int mutated = 0; + + await Assert.ThrowsAsync(() => rollouts.ApplySessionChangesAsync( + changes.Changes, + onBeforeApply: change => journal.ApplyingAsync("rollout", change.Path), + onApplied: async change => + { + mutated += 1; + if (mutated == 2) + { + throw new IOException("simulated process loss before applied record"); + } + await journal.AppliedAsync("rollout", change.Path); + }, + onSkipped: change => journal.SkippedAsync("rollout", change.Path))); + + Assert.NotEqual(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.NotEqual(secondBefore, await File.ReadAllTextAsync(secondPath)); + PendingTransactionInfo pending = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.Equal(2, pending.AffectedTargets.Count(static target => target.Kind == "rollout")); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = true + }); + + Assert.Equal(firstBefore, await File.ReadAllTextAsync(firstPath)); + Assert.Equal(secondBefore, await File.ReadAllTextAsync(secondPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task Journal_SkippedTargetResolvesApplying_AndCanCommit() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-raced-skip.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-raced-skip", "apigather"); + SessionRolloutService rollouts = new(); + SessionChangeCollection changes = await rollouts.CollectSessionChangesAsync(fixture.CodexHome, "openai"); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [sessionPath]); + + await fixture.WriteRolloutAsync(sessionPath, "thread-raced-skip", "changed-by-codex"); + SessionApplyResult result = await rollouts.ApplySessionChangesAsync( + changes.Changes, + onBeforeApply: change => journal.ApplyingAsync("rollout", change.Path), + onApplied: change => journal.AppliedAsync("rollout", change.Path), + onSkipped: change => journal.SkippedAsync("rollout", change.Path)); + await journal.CommittedAsync(); + + Assert.Empty(result.AppliedPaths); + PendingTransactionInfo info = await FileTransactionJournal.ReadInfoAsync(journal.FilePath); + Assert.True(info.Terminal); + Assert.Empty(info.AffectedTargets); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task ForeignOperationCommittedTail_RemainsPending_UntilExplicitRestoreRepairsJournal() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-foreign-operation.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-foreign-operation", "apigather"); + string before = await File.ReadAllTextAsync(sessionPath); + SyncResult sync = await new CodexSyncService().RunSyncAsync(fixture.CodexHome); + string journalPath = Path.Combine(sync.BackupDir, FileTransactionJournal.FileName); + PendingTransactionInfo committed = await FileTransactionJournal.ReadInfoAsync(journalPath); + string foreignOperationId = Guid.NewGuid().ToString("D"); + string foreignRecord = JsonSerializer.Serialize(new + { + protocolVersion = 1, + operationId = foreignOperationId, + sequence = committed.LastSequence + 1, + state = "committed", + recordedAt = DateTimeOffset.UtcNow + }); + await File.AppendAllTextAsync(journalPath, foreignRecord + "\n"); + + PendingTransactionInfo corrupted = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.True(corrupted.InvalidTail); + Assert.Equal("committed", corrupted.LastValidState); + await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync(fixture.CodexHome)); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + sync.BackupDir, + new RestoreBackupOptions + { + RestoreConfig = true, + RestoreDatabase = false, + RestoreSessions = true + }); + + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + PendingTransactionInfo repaired = await FileTransactionJournal.ReadInfoAsync(journalPath); + Assert.True(repaired.Terminal); + Assert.Equal("rolledBack", repaired.State); + Assert.DoesNotContain(foreignOperationId, await File.ReadAllTextAsync(journalPath)); + Assert.Single(Directory.EnumerateFiles( + sync.BackupDir, + "transaction-journal.invalid.*.jsonl", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task ExplicitRestore_RepairsCompletelyUnreadableJournal_AndArchivesRawEvidence() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + string journalPath = Path.Combine(backupDir, FileTransactionJournal.FileName); + const string corruptRaw = "this is not JSON\n"; + await File.WriteAllTextAsync(journalPath, corruptRaw); + + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = true, + RestoreDatabase = false, + RestoreSessions = false + }); + + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + PendingTransactionInfo repaired = await FileTransactionJournal.ReadInfoAsync(journalPath); + Assert.True(repaired.Terminal); + Assert.Equal("rolledBack", repaired.State); + string archivePath = Assert.Single(Directory.EnumerateFiles( + backupDir, + "transaction-journal.invalid.*.jsonl", + SearchOption.TopDirectoryOnly)); + Assert.Equal(corruptRaw, await File.ReadAllTextAsync(archivePath)); + } + + [Fact] + public async Task JournalMissingFinalLf_IsPending_AndCannotAppendUntilExplicitRestoreRepairsIt() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + []); + string completeLineWithoutLf = (await File.ReadAllTextAsync(journal.FilePath)).TrimEnd('\n'); + await File.WriteAllTextAsync(journal.FilePath, completeLineWithoutLf); + + PendingTransactionInfo pending = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.True(pending.InvalidTail); + InvalidOperationException appendError = await Assert.ThrowsAsync( + () => journal.CommittedAsync()); + Assert.Contains("cannot commit", appendError.Message); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = true, + RestoreDatabase = false, + RestoreSessions = false + }); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.EndsWith("\n", await File.ReadAllTextAsync(journal.FilePath)); + Assert.Single(Directory.EnumerateFiles( + backupDir, + "transaction-journal.invalid.*.jsonl", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task RecoveryRequiredThenCommitted_CannotForgeTerminalJournal() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteBackupAsync("20260804T000000000Z"); + string backupDir = fixture.BackupPath("20260804T000000000Z"); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + []); + IOException original = new("injected"); + await journal.RollingBackAsync(original); + await journal.RecoveryRequiredAsync(original, ["rollback failed"]); + PendingTransactionInfo recovery = await FileTransactionJournal.ReadInfoAsync(journal.FilePath); + string forged = JsonSerializer.Serialize(new + { + protocolVersion = 1, + operationId = recovery.OperationId, + sequence = recovery.LastSequence + 1, + state = "committed", + recordedAt = DateTimeOffset.UtcNow + }); + await File.AppendAllTextAsync(journal.FilePath, forged + "\n"); + + PendingTransactionInfo pending = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.True(pending.InvalidTail); + Assert.Equal("recoveryRequired", pending.State); + await Assert.ThrowsAsync( + () => FileTransactionJournal.AssertNoPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task Switch_ConfigCompensationFailure_PreservesStructuredRecoveryEvidence() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string configPath = Path.Combine(fixture.CodexHome, "config.toml"); + string before = await File.ReadAllTextAsync(configPath); + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => point switch + { + "after_config_mutation_before_applied" => Task.FromException(new IOException("injected post-config failure")), + "before_config_rollback" => Task.FromException(new IOException("injected config compensation failure")), + _ => Task.CompletedTask + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSwitchAsync(fixture.CodexHome, "apigather")); + + Assert.True(error.RecoveryRequired); + Assert.Contains("injected post-config failure", error.OriginalError.Message); + Assert.Contains(error.RollbackErrors, failure => + failure.Contains("config", StringComparison.Ordinal) + && failure.Contains("injected config compensation failure", StringComparison.Ordinal)); + Assert.Contains("model_provider = \"apigather\"", await File.ReadAllTextAsync(configPath)); + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + error.BackupDirectory, + new RestoreBackupOptions { RestoreDatabase = false }); + Assert.Equal(before, await File.ReadAllTextAsync(configPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task Rollback_RemovesGlobalStateBackup_WhenItDidNotExistBeforeSync() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbWithCwdAsync([ + ("thread-global-bak", "apigather", false, @"C:\AITemp") + ]); + string statePath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename); + string stateBackupPath = Path.Combine(fixture.CodexHome, AppConstants.GlobalStateBackupFileBasename); + string originalState = JsonSerializer.Serialize(new Dictionary + { + ["electron-saved-workspace-roots"] = new[] { @"\\?\C:\AITemp" }, + ["project-order"] = new[] { @"\\?\C:\AITemp" }, + ["active-workspace-roots"] = new[] { @"\\?\C:\AITemp" } + }); + await File.WriteAllTextAsync(statePath, originalState); + Assert.False(File.Exists(stateBackupPath)); + CodexSyncService service = new(); + service.FaultInjector = (point, path, _) => + point == "after_global_state_apply" + && string.Equals(Path.GetFullPath(path!), Path.GetFullPath(stateBackupPath), StringComparison.OrdinalIgnoreCase) + ? Task.FromException(new IOException("injected after global-state backup write")) + : Task.CompletedTask; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome)); + + Assert.False(error.RecoveryRequired); + Assert.Equal(originalState, await File.ReadAllTextAsync(statePath)); + Assert.False(File.Exists(stateBackupPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task RestoreGlobalState_DeclaredOriginalMissingFromBackup_IsFailure() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteGlobalStateAsync(new { project_order = Array.Empty() }); + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + File.Delete(Path.Combine(backupDir, AppConstants.GlobalStateBackupFileBasename)); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => backups.RestoreGlobalStateFilesAsync(backupDir, fixture.CodexHome)); + Assert.Contains("declares an original file", error.Message); + } + + [Fact] + public async Task AtomicManifestUpdateFailure_PreservesPreviousManifestAndMetadata() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-atomic-manifest.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-atomic-manifest", "apigather"); + SessionRolloutService rollouts = new(); + SessionChangeCollection changes = await rollouts.CollectSessionChangesAsync(fixture.CodexHome, "openai"); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + string manifestPath = Path.Combine(backupDir, "session-meta-backup.json"); + string metadataPath = Path.Combine(backupDir, "metadata.json"); + string manifestBefore = await File.ReadAllTextAsync(manifestPath); + string metadataBefore = await File.ReadAllTextAsync(metadataPath); + backups.AtomicWriteFaultInjector = (point, targetPath, _) => + point == "before_atomic_replace" + && string.Equals(targetPath, Path.GetFullPath(manifestPath), StringComparison.OrdinalIgnoreCase) + ? Task.FromException(new IOException("injected manifest replace failure")) + : Task.CompletedTask; + + await Assert.ThrowsAsync(() => backups.UpdateSessionBackupManifestAsync(backupDir, [])); + + Assert.Equal(manifestBefore, await File.ReadAllTextAsync(manifestPath)); + Assert.Equal(metadataBefore, await File.ReadAllTextAsync(metadataPath)); + Assert.Empty(Directory.EnumerateFiles(backupDir, "*.tmp", SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task Restore_RejectsSessionManifestPathOutsideCodexHome() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + SessionRolloutService rollouts = new(); + BackupService backups = new(rollouts, new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + string outsidePath = Path.Combine(fixture.Root, "rollout-outside.jsonl"); + await File.WriteAllTextAsync(outsidePath, "outside must remain unchanged\n"); + string outsideBefore = await File.ReadAllTextAsync(outsidePath); + string maliciousManifest = JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = DateTimeOffset.UtcNow, + files = new[] + { + new + { + path = outsidePath, + originalFirstLine = "attacker controlled", + originalSeparator = "\n", + originalLastWriteTimeUtcTicks = (long?)null, + modelOnlyChange = false, + originalTurnContextModels = Array.Empty() + } + } + }); + await AtomicFile.WriteAllTextAsync( + Path.Combine(backupDir, "session-meta-backup.json"), + maliciousManifest); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => backups.RestoreBackupAsync( + backupDir, + fixture.CodexHome, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = true + })); + + Assert.Contains("escapes the Codex rollout directories", error.Message); + Assert.Equal(outsideBefore, await File.ReadAllTextAsync(outsidePath)); + } + + [Fact] + public async Task TruncatedMetadata_WithPendingJournal_BlocksWritesAndPreservesRecoveryEvidence() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + Path.Combine(fixture.CodexHome, "config.toml")); + await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + []); + await File.WriteAllTextAsync(Path.Combine(backupDir, "metadata.json"), "{\"version\":2"); + + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + await Assert.ThrowsAnyAsync(() => new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = false + })); + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync(fixture.CodexHome)); + } + + [Fact] + public async Task PendingTransaction_PartialRestoreCannotUnlockUnrestoredSqlite() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-partial-recovery.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-partial-recovery", "apigather"); + await fixture.WriteStateDbAsync([("thread-partial-recovery", "apigather", false)]); + string sessionBefore = await File.ReadAllTextAsync(sessionPath); + SessionRolloutService rollouts = new(); + SqliteStateService sqlite = new(); + SessionChangeCollection changes = await rollouts.CollectSessionChangesAsync(fixture.CodexHome, "openai"); + BackupService backups = new(rollouts, sqlite); + string backupDir = await backups.CreateBackupAsync( + fixture.CodexHome, + "openai", + changes.Changes, + Path.Combine(fixture.CodexHome, "config.toml")); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [sessionPath, fixture.StateDbPath()]); + await journal.ApplyingAsync("rollout", sessionPath); + await rollouts.ApplySessionChangesAsync(changes.Changes); + await journal.AppliedAsync("rollout", sessionPath); + await journal.ApplyingAsync("sqlite", fixture.StateDbPath()); + await sqlite.UpdateSqliteProviderAsync(fixture.CodexHome, "openai"); + await journal.AppliedAsync("sqlite", fixture.StateDbPath()); + + CodexSyncService service = new(); + InvalidOperationException partialError = await Assert.ThrowsAsync( + () => service.RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = true + })); + + Assert.Contains("partial restore", partialError.Message); + Assert.Contains("SQLite", partialError.Message); + Assert.NotEqual(sessionBefore, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-partial-recovery")); + Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + + await service.RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = true + }); + Assert.Equal(sessionBefore, await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-partial-recovery")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task Journal_InvalidTargetKind_CannotBecomeTerminal() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteBackupAsync("20260804T010000000Z"); + string backupDir = fixture.BackupPath("20260804T010000000Z"); + string targetPath = Path.Combine(fixture.CodexHome, "config.toml"); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [targetPath]); + PendingTransactionInfo prepared = await FileTransactionJournal.ReadInfoAsync(journal.FilePath); + string invalidKind = JsonSerializer.Serialize(new + { + protocolVersion = 1, + operationId = prepared.OperationId, + sequence = prepared.LastSequence + 1, + state = "applying", + kind = "arbitraryFile", + targetPath, + recordedAt = DateTimeOffset.UtcNow + }); + await File.AppendAllTextAsync(journal.FilePath, invalidKind + "\n"); + + PendingTransactionInfo pending = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.True(pending.InvalidTail); + Assert.Empty(pending.AffectedTargets); + await Assert.ThrowsAsync( + () => FileTransactionJournal.AssertNoPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task AbruptChildProcessExit_AfterRolloutMutation_BlocksWritesUntilExplicitRestore() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-real-process-crash.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-real-process-crash", "apigather"); + string before = await File.ReadAllTextAsync(sessionPath); + string configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent?.Name ?? "Debug"; + string testProjectDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..")); + string crashHostPath = Path.Combine( + testProjectDirectory, + "CrashHost", + "bin", + configuration, + "net10.0", + "CodexProviderSync.CrashHost.dll"); + Assert.True(File.Exists(crashHostPath), $"Crash host was not built: {crashHostPath}"); + string dotnetHost = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; + ProcessStartInfo startInfo = new(dotnetHost) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(crashHostPath); + startInfo.ArgumentList.Add(fixture.CodexHome); + using Process child = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the crash-test child process."); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(20)); + try + { + await child.WaitForExitAsync(timeout.Token); + } + catch + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + throw; + } + + Assert.NotEqual(0, child.ExitCode); + Assert.NotEqual(before, await File.ReadAllTextAsync(sessionPath)); + PendingTransactionInfo pending = Assert.Single(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.Contains(pending.AffectedTargets, target => + target.Kind == "rollout" + && string.Equals(Path.GetFullPath(target.TargetPath), Path.GetFullPath(sessionPath), StringComparison.OrdinalIgnoreCase) + && target.State == "applying"); + await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync(fixture.CodexHome)); + + await new CodexSyncService().RunRestoreAsync( + fixture.CodexHome, + pending.BackupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = false, + RestoreSessions = true + }); + Assert.Equal(before, await File.ReadAllTextAsync(sessionPath)); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task AtomicFile_PreservesUnixOwnerOnlyModeOnReplace() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string directory = Path.Combine(Path.GetTempPath(), $"codex-provider-mode-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "sensitive.json"); + await File.WriteAllTextAsync(path, "before"); + UnixFileMode ownerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, ownerOnly); + + await AtomicFile.WriteAllTextAsync(path, "after"); + + Assert.Equal(ownerOnly, File.GetUnixFileMode(path)); + } + + [Fact] + public async Task RunSync_PreservesUnixOwnerOnlyModeOnRolloutReplace() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\"\nmodel = \"target-model\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-mode-0600.jsonl"); + await fixture.WriteRolloutWithTurnContextAsync( + sessionPath, + "thread-mode-0600", + "apigather", + "old-model"); + UnixFileMode ownerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(sessionPath, ownerOnly); + + await new CodexSyncService().RunSyncAsync(fixture.CodexHome); + + Assert.Equal(ownerOnly, File.GetUnixFileMode(sessionPath)); + } + + private static async Task ReadProviderAsync(string dbPath, string threadId) + { + SqliteConnectionStringBuilder builder = new() + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false + }; + await using SqliteConnection connection = new(builder.ConnectionString); + await connection.OpenAsync(); + SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT model_provider FROM threads WHERE id = $id"; + command.Parameters.AddWithValue("$id", threadId); + return Convert.ToString(await command.ExecuteScalarAsync())!; + } + + private static string RelativeTargetIdentity(string codexHome, string targetPath) + { + string relative = Path.GetRelativePath( + Path.GetFullPath(codexHome), + Path.GetFullPath(targetPath)) + .Replace('\\', '/'); + if (relative != ".." && !relative.StartsWith("../", StringComparison.Ordinal)) + { + return relative; + } + + // macOS temp roots can be exposed through both /var and + // /private/var. Preserve the complete logical path within the test + // Codex Home if the two absolute spellings cross that symlink alias. + string normalizedTarget = Path.GetFullPath(targetPath).Replace('\\', '/'); + string homeMarker = $"/{Path.GetFileName(Path.TrimEndingDirectorySeparator(codexHome))}/"; + int markerIndex = normalizedTarget.LastIndexOf(homeMarker, StringComparison.Ordinal); + return markerIndex >= 0 + ? normalizedTarget[(markerIndex + homeMarker.Length)..] + : relative; } } diff --git a/desktop/CodexProviderSync.Core.Tests/CrashHost/CodexProviderSync.CrashHost.csproj b/desktop/CodexProviderSync.Core.Tests/CrashHost/CodexProviderSync.CrashHost.csproj new file mode 100644 index 0000000..ef86c48 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/CrashHost/CodexProviderSync.CrashHost.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs b/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs new file mode 100644 index 0000000..c75826f --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; +using CodexProviderSync.Core; + +if (args is not [string codexHome]) +{ + return 64; +} + +CodexSyncService service = new(); +service.FaultInjector = (point, _, _) => +{ + if (point == "after_rollout_mutation_before_applied") + { + Process.GetCurrentProcess().Kill(); + Thread.Sleep(Timeout.Infinite); + } + return Task.CompletedTask; +}; + +await service.RunSyncAsync(codexHome, provider: "openai"); +return 65; diff --git a/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs index d65ce08..88207df 100644 --- a/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs @@ -1,20 +1,403 @@ +using System.Text.Json; + namespace CodexProviderSync.Core.Tests; public sealed class LockServiceTests { [Fact] - public async Task AcquireLockAsync_CreatesAndReleasesLockDirectory() + public async Task AcquireLockAsync_PublishesVersionedOwnerAndClaim_ThenReleasesOnlyItsGeneration() { - string codexHome = Path.Combine(Path.GetTempPath(), $"codex-provider-lock-{Guid.NewGuid():N}"); - Directory.CreateDirectory(codexHome); + string codexHome = CreateTempDirectory(); string lockPath = AppConstants.LockPath(codexHome); + string claimsPath = lockPath + ".claims"; - await using (await new LockService().AcquireLockAsync(codexHome, "test")) + LockHandle handle = await new LockService().AcquireLockAsync(codexHome, "test"); + try { + Assert.Equal(Path.GetFullPath(lockPath), handle.LockPath); Assert.True(Directory.Exists(lockPath)); - Assert.True(File.Exists(Path.Combine(lockPath, "owner.json"))); + string claimPath = Assert.Single(Directory.EnumerateFiles(claimsPath, "*.json")); + + using JsonDocument owner = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); + JsonElement root = owner.RootElement; + Assert.Equal(2, root.GetProperty("protocolVersion").GetInt32()); + Assert.Equal("dotnet", root.GetProperty("runtime").GetString()); + Assert.Equal(Environment.ProcessId, root.GetProperty("pid").GetInt32()); + Assert.Equal(Environment.ProcessId, root.GetProperty("processId").GetInt32()); + Assert.Equal(handle.InstanceId, root.GetProperty("instanceId").GetString()); + Assert.Equal("test", root.GetProperty("label").GetString()); + Assert.Equal(Environment.CurrentDirectory, root.GetProperty("cwd").GetString()); + Assert.Matches( + @"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", + root.GetProperty("processStartedAt").GetString()!); + + using JsonDocument claim = JsonDocument.Parse(await File.ReadAllTextAsync(claimPath)); + Assert.Equal(handle.InstanceId, claim.RootElement.GetProperty("instanceId").GetString()); + } + finally + { + await handle.DisposeAsync(); + } + + Assert.False(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(claimsPath, "*.json")); + } + + [Fact] + public async Task AcquirePathLockAsync_SupportsArbitraryExplicitResourcePath() + { + string root = CreateTempDirectory(); + string lockPath = Path.Combine(root, "resource-locks", "sqlite-home.lock"); + + await using (LockHandle handle = await new LockService().AcquirePathLockAsync(lockPath, "sqlite")) + { + Assert.Equal(Path.GetFullPath(lockPath), handle.LockPath); + Assert.True(Directory.Exists(lockPath)); + Assert.True(Directory.Exists(lockPath + ".claims")); + } + + Assert.False(Directory.Exists(lockPath)); + } + + [Fact] + public async Task AcquireLockAsync_ReclaimsCanonicalOwnedByExitedProcess() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + processId = int.MaxValue, + startedAt = DateTimeOffset.UtcNow.AddHours(-1), + processStartedAt = "2000-01-01T00:00:00Z", + label = "crashed", + currentDirectory = codexHome + }); + + await using (LockHandle handle = await new LockService().AcquireLockAsync(codexHome, "recovery")) + { + using JsonDocument owner = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); + Assert.Equal(handle.InstanceId, owner.RootElement.GetProperty("instanceId").GetString()); + } + + Assert.False(Directory.Exists(lockPath)); + } + + [Fact] + public async Task AcquireLockAsync_DoesNotReclaimLiveLegacyDotNetOwner() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + processId = Environment.ProcessId, + startedAt = DateTimeOffset.UtcNow, + processStartedAt = LockService.CurrentProcessStartedAtForTests(), + label = "active-legacy-dotnet", + currentDirectory = codexHome + }); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.Contains("verified owner", error.Message); + Assert.True(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_DoesNotReclaimLiveLegacyNodeOwner() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + pid = Environment.ProcessId, + processStartedAt = LockService.CurrentProcessStartedAtForTests(), + instanceId = Guid.NewGuid().ToString("D"), + runtime = "node", + label = "active-legacy-node", + cwd = codexHome + }); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.True(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_DoesNotReclaimLiveLegacyNodeMarkerOwner() + { + string? marker = LockService.CurrentProcessStartMarkerForTests(); + if (marker is null) + { + return; + } + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + pid = Environment.ProcessId, + processStartMarker = marker, + instanceId = Guid.NewGuid().ToString("D"), + runtime = "node", + label = "active-legacy-node-marker", + cwd = codexHome + }); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.True(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_FailsClosedForOwnerlessLegacyLock() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.Contains("retained fail-closed", error.Message); + Assert.True(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_ReclaimsCanonicalAndClaimWhenPidWasReused() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + string staleInstanceId = Guid.NewGuid().ToString("D"); + Directory.CreateDirectory(lockPath); + await WriteVersionTwoOwnerAsync( + Path.Combine(lockPath, "owner.json"), + staleInstanceId, + "2000-01-01T00:00:00Z"); + string claimsPath = lockPath + ".claims"; + Directory.CreateDirectory(claimsPath); + await WriteVersionTwoOwnerAsync( + Path.Combine(claimsPath, staleInstanceId + ".json"), + staleInstanceId, + "2000-01-01T00:00:00Z"); + + await using (LockHandle handle = await new LockService().AcquireLockAsync(codexHome, "replacement")) + { + Assert.NotEqual(staleInstanceId, handle.InstanceId); + Assert.DoesNotContain( + Directory.EnumerateFiles(claimsPath, "*.json"), + path => string.Equals( + Path.GetFileNameWithoutExtension(path), + staleInstanceId, + StringComparison.OrdinalIgnoreCase)); + } + Assert.False(Directory.Exists(lockPath)); + } + + [Fact] + public async Task AcquireLockAsync_TwoReclaimersCannotBothOwnStaleCanonicalGeneration() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + processId = int.MaxValue, + processStartedAt = "2000-01-01T00:00:00Z", + label = "stale", + currentDirectory = codexHome + }); + + int published = 0; + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + Func hook = (phase, _) => + { + if (phase == "claim-published" && Interlocked.Increment(ref published) == 2) + { + gate.TrySetResult(); + } + return gate.Task; + }; + + Task first = TryAcquireAsync(new LockService(hook), codexHome); + Task second = TryAcquireAsync(new LockService(hook), codexHome); + LockHandle?[] acquired = await Task.WhenAll(first, second); + LockHandle[] winners = acquired.OfType().ToArray(); + Assert.True(winners.Length <= 1, "Two concurrent stale-lock reclaimers both acquired the canonical lock."); + + foreach (LockHandle winner in winners) + { + await winner.DisposeAsync(); } + await using LockHandle retry = await new LockService().AcquireLockAsync(codexHome, "retry"); + Assert.True(Directory.Exists(lockPath)); + } + + [Fact] + public async Task DisposeAsync_DoesNotDeleteReplacementCanonicalOwner_AbaDefense() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + LockHandle original = await new LockService().AcquireLockAsync(codexHome, "original"); + string replacementInstanceId = Guid.NewGuid().ToString("D"); + await WriteVersionTwoOwnerAsync( + Path.Combine(lockPath, "owner.json"), + replacementInstanceId, + LockService.CurrentProcessStartedAtForTests()); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => original.DisposeAsync().AsTask()); + + Assert.Contains("owner identity changed", error.Message); + Assert.True(Directory.Exists(lockPath)); + using JsonDocument owner = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); + Assert.Equal(replacementInstanceId, owner.RootElement.GetProperty("instanceId").GetString()); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_StaleReclaimAbaRestoresAndPreservesReplacementOwner() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + string oldInstanceId = Guid.NewGuid().ToString("D"); + string replacementInstanceId = Guid.NewGuid().ToString("D"); + Directory.CreateDirectory(lockPath); + await WriteVersionTwoOwnerAsync( + Path.Combine(lockPath, "owner.json"), + oldInstanceId, + "2000-01-01T00:00:00Z"); + string releasedOldPath = lockPath + ".released-old"; + LockService service = new(async (phase, _) => + { + if (phase != "before-stale-canonical-reclaim") + { + return; + } + Directory.Move(lockPath, releasedOldPath); + Directory.CreateDirectory(lockPath); + await WriteVersionTwoOwnerAsync( + Path.Combine(lockPath, "owner.json"), + replacementInstanceId, + LockService.CurrentProcessStartedAtForTests()); + }); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => service.AcquireLockAsync(codexHome, "aba-contender")); + + Assert.Contains("owner changed during reclamation", error.Message); + Assert.True(Directory.Exists(lockPath)); + using JsonDocument owner = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); + Assert.Equal(replacementInstanceId, owner.RootElement.GetProperty("instanceId").GetString()); + Assert.True(Directory.Exists(releasedOldPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_FailsClosedForLiveVersionTwoClaimWithoutTouchingCanonical() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + string claimsPath = lockPath + ".claims"; + string liveInstance = Guid.NewGuid().ToString("D"); + Directory.CreateDirectory(claimsPath); + await WriteVersionTwoOwnerAsync( + Path.Combine(claimsPath, liveInstance + ".json"), + liveInstance, + LockService.CurrentProcessStartedAtForTests()); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.False(Directory.Exists(lockPath)); + Assert.Equal( + [liveInstance + ".json"], + Directory.EnumerateFiles(claimsPath, "*.json").Select(path => Path.GetFileName(path)!).ToArray()); + } + + [Fact] + public async Task AcquireLockAsync_FailsClosedForConflictingPidFieldsInVersionTwoClaim() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + string claimsPath = lockPath + ".claims"; + string instanceId = Guid.NewGuid().ToString("D"); + Directory.CreateDirectory(claimsPath); + string claimPath = Path.Combine(claimsPath, instanceId + ".json"); + await WriteJsonAsync(claimPath, new + { + protocolVersion = 2, + runtime = "node", + pid = Environment.ProcessId, + processId = int.MaxValue, + processStartedAt = LockService.CurrentProcessStartedAtForTests(), + instanceId, + label = "conflicting-schema", + cwd = Environment.CurrentDirectory + }); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.True(File.Exists(claimPath)); + Assert.False(Directory.Exists(lockPath)); + } + + [Fact] + public async Task AcquireLockAsync_FailsClosedForFutureCanonicalProtocol() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await WriteJsonAsync(Path.Combine(lockPath, "owner.json"), new + { + protocolVersion = 99, + pid = int.MaxValue, + processId = int.MaxValue, + processStartedAt = "2000-01-01T00:00:00Z", + instanceId = "future-owner", + label = "future", + cwd = Environment.CurrentDirectory + }); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.True(Directory.Exists(lockPath)); + Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); + } + + [Fact] + public async Task AcquireLockAsync_FailsClosedWhenClaimFilenameDoesNotMatchInstanceId() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + string claimsPath = lockPath + ".claims"; + Directory.CreateDirectory(claimsPath); + string mismatchedPath = Path.Combine(claimsPath, Guid.NewGuid().ToString("D") + ".json"); + await WriteVersionTwoOwnerAsync( + mismatchedPath, + "opaque-node-instance-id", + LockService.CurrentProcessStartedAtForTests()); + + await Assert.ThrowsAsync( + () => new LockService().AcquireLockAsync(codexHome, "competing")); + + Assert.True(File.Exists(mismatchedPath)); Assert.False(Directory.Exists(lockPath)); } @@ -67,4 +450,47 @@ public async Task CreateLockDirectoryAsync_ThrowsAfterTransientRetryBudgetIsExha Assert.Contains("Win32 error: 5", error.Message); } + + private static async Task TryAcquireAsync(LockService service, string codexHome) + { + try + { + return await service.AcquireLockAsync(codexHome, "contender"); + } + catch (InvalidOperationException) + { + return null; + } + } + + private static string CreateTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), $"codex-provider-lock-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static Task WriteJsonAsync(string path, object value) + { + return File.WriteAllTextAsync(path, JsonSerializer.Serialize(value)); + } + + private static Task WriteVersionTwoOwnerAsync( + string path, + string instanceId, + string processStartedAt) + { + return WriteJsonAsync(path, new + { + protocolVersion = 2, + runtime = "node", + pid = Environment.ProcessId, + processId = Environment.ProcessId, + processStartedAt, + instanceId, + startedAt = DateTimeOffset.UtcNow, + label = "fixture", + cwd = Environment.CurrentDirectory + }); + } } diff --git a/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs b/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs new file mode 100644 index 0000000..84cbf46 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs @@ -0,0 +1,166 @@ +using Microsoft.Data.Sqlite; + +namespace CodexProviderSync.Core.Tests; + +public sealed class SqliteOnlineBackupTests +{ + [Fact] + public async Task WritesConfigureSynchronousFull_AndNodeDotNetCountersAgree() + { + await using Fixture fixture = Fixture.Create(); + await using (SqliteConnection setup = Open(fixture.DbPath)) + { + await setup.OpenAsync(); + await ExecuteAsync(setup, """ + PRAGMA synchronous = OFF; + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT, + model TEXT + ); + INSERT INTO threads VALUES ('a', 'legacy', 'old'); + INSERT INTO threads VALUES ('b', 'openai', 'old'); + """); + Assert.Equal(0L, await ScalarAsync(setup, "PRAGMA synchronous")); + Assert.Equal(2, await SqliteStateService.ConfigureSqliteWriteDurabilityAsync(setup)); + Assert.Equal(2L, await ScalarAsync(setup, "PRAGMA synchronous")); + } + + var update = await fixture.Service.UpdateSqliteProviderAsync( + fixture.Storage, + "openai", + targetModel: "new"); + Assert.True(update.DatabasePresent); + Assert.Equal( + (3, 1, 2), + (update.UpdatedRows, update.ProviderRowsUpdated, update.ModelRowsUpdated)); + + await using SqliteConnection verified = Open(fixture.DbPath, SqliteOpenMode.ReadOnly); + await verified.OpenAsync(); + await using SqliteCommand command = verified.CreateCommand(); + command.CommandText = "SELECT model_provider, model FROM threads ORDER BY id"; + await using SqliteDataReader reader = await command.ExecuteReaderAsync(); + int rowCount = 0; + while (await reader.ReadAsync()) + { + Assert.Equal("openai", reader.GetString(0)); + Assert.Equal("new", reader.GetString(1)); + rowCount += 1; + } + Assert.Equal(2, rowCount); + } + + [Fact] + public async Task OfficialOnlineBackup_CapturesLiveWalIntoOneStandaloneMainFile() + { + await using Fixture fixture = Fixture.Create(); + string backupPath = Path.Combine(fixture.Root, "backup", "state_5.sqlite"); + await using SqliteConnection source = Open(fixture.DbPath); + await source.OpenAsync(); + await ExecuteAsync(source, "PRAGMA page_size = 8192; VACUUM;"); + Assert.Equal("wal", Convert.ToString(await ScalarObjectAsync(source, "PRAGMA journal_mode = WAL"))); + await ExecuteAsync(source, """ + PRAGMA user_version = 73; + PRAGMA application_id = 1129333840; + CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT); + INSERT INTO threads VALUES ('wal-row', 'openai'); + """); + Assert.True(new FileInfo(fixture.DbPath + "-wal").Length > 0); + + SqliteOnlineBackupResult result = await fixture.Service.CreateSqliteOnlineBackupAsync( + fixture.Storage, + backupPath); + Assert.True(result.DatabasePresent); + Assert.Equal(Path.GetFullPath(backupPath), result.BackupPath); + Assert.NotNull(result.Metadata); + Assert.Equal(new SqliteFileMetadata("wal", 8192, 73, 1129333840), result.Metadata.Source); + Assert.Equal(result.Metadata.Source, result.Metadata.Backup); + Assert.Equal( + new SqliteOnlineBackupPreservation(true, true, true, true), + result.Metadata.Preserved); + Assert.True(File.Exists(backupPath)); + Assert.False(File.Exists(backupPath + "-wal")); + Assert.False(File.Exists(backupPath + "-shm")); + + await source.CloseAsync(); + await using SqliteConnection backup = Open(backupPath, SqliteOpenMode.ReadOnly); + await backup.OpenAsync(); + Assert.Equal( + "openai", + Convert.ToString(await ScalarObjectAsync( + backup, + "SELECT model_provider FROM threads WHERE id = 'wal-row'"))); + } + + private sealed class Fixture : IAsyncDisposable + { + private Fixture(string root) + { + Root = root; + CodexHome = Path.Combine(root, "codex-home"); + DbPath = Path.Combine(CodexHome, "sqlite", "state_5.sqlite"); + Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!); + Service = new SqliteStateService(); + Storage = new CodexStorageLayoutService().CreateDefault(CodexHome); + } + + public string Root { get; } + public string CodexHome { get; } + public string DbPath { get; } + public SqliteStateService Service { get; } + public CodexStorageLayout Storage { get; } + + public static Fixture Create() + { + string root = Path.Combine( + Path.GetTempPath(), + $"provider-sync-sqlite-online-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return new Fixture(root); + } + + public ValueTask DisposeAsync() + { + try + { + Directory.Delete(Root, recursive: true); + } + catch + { + // SQLite teardown can briefly retain handles on Windows. + } + return ValueTask.CompletedTask; + } + } + + private static SqliteConnection Open( + string dbPath, + SqliteOpenMode mode = SqliteOpenMode.ReadWriteCreate) + { + return new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = mode, + Pooling = false + }.ConnectionString); + } + + private static async Task ExecuteAsync(SqliteConnection connection, string sql) + { + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + + private static async Task ScalarAsync(SqliteConnection connection, string sql) + { + return Convert.ToInt64(await ScalarObjectAsync(connection, sql)); + } + + private static async Task ScalarObjectAsync(SqliteConnection connection, string sql) + { + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = sql; + return await command.ExecuteScalarAsync(); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs new file mode 100644 index 0000000..4ab78f0 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs @@ -0,0 +1,208 @@ +using System.Text.Json; + +namespace CodexProviderSync.Core.Tests; + +public sealed class TransactionJournalTests +{ + [Fact] + public async Task AppendAsync_FailureBeforeWriteDoesNotConsumeSequence() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + bool fail = true; + fixture.Journal.AppendFaultInjector = (phase, state) => + { + if (fail && phase == "before-write" && state == "applying") + { + fail = false; + throw new IOException("injected before write"); + } + return Task.CompletedTask; + }; + + await Assert.ThrowsAsync( + () => fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0])); + PendingTransactionInfo prepared = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.Equal(1, prepared.LastSequence); + Assert.Equal("prepared", prepared.State); + + fixture.Journal.AppendFaultInjector = null; + await fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0]); + await fixture.Journal.AppliedAsync("rollout", fixture.Targets[0]); + await fixture.Journal.CommittedAsync(); + + PendingTransactionInfo committed = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.Equal(4, committed.LastSequence); + Assert.True(committed.Terminal); + } + + [Fact] + public async Task AppendAsync_FailureAfterReadableWriteResynchronizesSequence() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + fixture.Journal.AppendFaultInjector = (phase, state) => + { + if (phase == "after-write-before-flush" && state == "applying") + { + throw new IOException("write reported failure after bytes became readable"); + } + return Task.CompletedTask; + }; + + await Assert.ThrowsAsync( + () => fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0])); + PendingTransactionInfo applying = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.Equal(2, applying.LastSequence); + Assert.Equal("applying", applying.State); + + fixture.Journal.AppendFaultInjector = null; + await fixture.Journal.AppliedAsync("rollout", fixture.Targets[0]); + await fixture.Journal.CommittedAsync(); + Assert.True((await fixture.Journal.ReadCurrentInfoAsync()).Terminal); + } + + [Fact] + public async Task AppendAsync_ResynchronizesFromValidExternallyAppendedRecord() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + PendingTransactionInfo prepared = await fixture.Journal.ReadCurrentInfoAsync(); + string applying = JsonSerializer.Serialize(new + { + protocolVersion = 1, + operationId = prepared.OperationId, + sequence = 2, + state = "applying", + recordedAt = DateTimeOffset.UtcNow, + kind = "rollout", + targetPath = Path.GetFullPath(fixture.Targets[0]) + }); + await File.AppendAllTextAsync(fixture.Journal.FilePath, applying + "\n"); + + await fixture.Journal.AppliedAsync("rollout", fixture.Targets[0]); + PendingTransactionInfo applied = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.Equal(3, applied.LastSequence); + Assert.Equal("applied", applied.State); + Assert.False(applied.InvalidTail); + } + + [Fact] + public async Task AppendAsync_ValidatesTargetTransitionBeforeWriting() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + string before = await File.ReadAllTextAsync(fixture.Journal.FilePath); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => fixture.Journal.AppliedAsync("rollout", fixture.Targets[0])); + + Assert.Contains("must be applying", error.Message); + Assert.Equal(before, await File.ReadAllTextAsync(fixture.Journal.FilePath)); + } + + [Fact] + public async Task AppendAsync_SerializesConcurrentAppendsAgainstFreshJournalState() + { + JournalFixture fixture = await JournalFixture.CreateAsync(2); + + await Task.WhenAll( + fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0]), + fixture.Journal.ApplyingAsync("rollout", fixture.Targets[1])); + + PendingTransactionInfo info = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.Equal(3, info.LastSequence); + Assert.Equal(2, info.AffectedTargets.Count(static target => target.State == "applying")); + Assert.False(info.InvalidTail); + } + + [Fact] + public async Task TerminalAppend_ReReadsJournalAndRejectsConcurrentTail() + { + JournalFixture fixture = await JournalFixture.CreateAsync(0); + fixture.Journal.AppendFaultInjector = async (phase, state) => + { + if (phase != "after-flush-before-verify" || state != "committed") + { + return; + } + PendingTransactionInfo committed = await FileTransactionJournal.ReadInfoAsync( + fixture.Journal.FilePath); + string impossibleTail = JsonSerializer.Serialize(new + { + protocolVersion = 1, + operationId = committed.OperationId, + sequence = committed.LastSequence + 1, + state = "rollingBack", + recordedAt = DateTimeOffset.UtcNow, + originalError = "forged after commit" + }); + await File.AppendAllTextAsync(fixture.Journal.FilePath, impossibleTail + "\n"); + }; + + InvalidOperationException error = await Assert.ThrowsAsync( + () => fixture.Journal.CommittedAsync()); + + Assert.Contains("could not be verified", error.Message); + PendingTransactionInfo pending = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.True(pending.InvalidTail); + Assert.False(pending.Terminal); + } + + [Fact] + public async Task CommittedAppend_ApiFailureAfterDurableWriteReconcilesWithoutRollback() + { + JournalFixture fixture = await JournalFixture.CreateAsync(0); + fixture.Journal.AppendFaultInjector = (phase, state) => + phase == "after-flush-before-verify" && state == "committed" + ? Task.FromException(new IOException("injected post-flush reporting failure")) + : Task.CompletedTask; + + await fixture.Journal.CommittedAsync(); + + PendingTransactionInfo committed = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.True(committed.Terminal); + Assert.Equal("committed", committed.State); + await Assert.ThrowsAsync( + () => fixture.Journal.RollingBackAsync(new IOException("must not roll back"))); + } + + [Fact] + public async Task CommittedJournal_RejectsRollbackWithoutAppendingOrLosingTerminalState() + { + JournalFixture fixture = await JournalFixture.CreateAsync(0); + await fixture.Journal.CommittedAsync(); + string committedBytes = await File.ReadAllTextAsync(fixture.Journal.FilePath); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => fixture.Journal.RollingBackAsync(new IOException("too late"))); + + Assert.Contains("already terminal", error.Message); + Assert.Equal(committedBytes, await File.ReadAllTextAsync(fixture.Journal.FilePath)); + PendingTransactionInfo committed = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.True(committed.Terminal); + Assert.Equal("committed", committed.State); + } + + private sealed record JournalFixture( + string Root, + FileTransactionJournal Journal, + IReadOnlyList Targets) + { + internal static async Task CreateAsync(int targetCount) + { + string root = Path.Combine( + Path.GetTempPath(), + $"codex-provider-journal-{Guid.NewGuid():N}"); + string backupDir = Path.Combine(root, "backup"); + string codexHome = Path.Combine(root, ".codex"); + Directory.CreateDirectory(backupDir); + Directory.CreateDirectory(codexHome); + string[] targets = Enumerable.Range(0, targetCount) + .Select(index => Path.Combine(codexHome, $"rollout-{index}.jsonl")) + .ToArray(); + FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + codexHome, + "target-provider", + targets); + return new JournalFixture(root, journal, targets); + } + } +} diff --git a/desktop/CodexProviderSync.Core/AtomicFile.cs b/desktop/CodexProviderSync.Core/AtomicFile.cs index 5c90246..b95035f 100644 --- a/desktop/CodexProviderSync.Core/AtomicFile.cs +++ b/desktop/CodexProviderSync.Core/AtomicFile.cs @@ -4,6 +4,75 @@ namespace CodexProviderSync.Core; internal static class AtomicFile { + internal static async Task CopyAsync( + string sourcePath, + string destinationPath, + bool overwrite, + CancellationToken cancellationToken = default, + Func? faultInjector = null) + { + string fullSourcePath = Path.GetFullPath(sourcePath); + if (!File.Exists(fullSourcePath)) + { + return false; + } + + string fullDestinationPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullDestinationPath); + if (string.IsNullOrEmpty(directory)) + { + throw new InvalidOperationException($"Cannot resolve the parent directory for {fullDestinationPath}."); + } + if (!overwrite && File.Exists(fullDestinationPath)) + { + throw new IOException($"The destination file already exists: {fullDestinationPath}"); + } + + Directory.CreateDirectory(directory); + string tempPath = CreateTempPath(directory, Path.GetFileName(fullDestinationPath)); + UnixFileMode? sourceMode = TryGetUnixMode(fullSourcePath); + try + { + await using (FileStream source = new( + fullSourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (FileStream destination = new( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + if (faultInjector is not null) + { + await faultInjector("before_stage_write", fullDestinationPath, tempPath); + } + await source.CopyToAsync(destination, cancellationToken); + await destination.FlushAsync(cancellationToken); + destination.Flush(flushToDisk: true); + } + + File.SetLastWriteTimeUtc(tempPath, File.GetLastWriteTimeUtc(fullSourcePath)); + ApplyUnixMode(tempPath, sourceMode ?? OwnerReadWriteMode); + if (faultInjector is not null) + { + await faultInjector("before_atomic_replace", fullDestinationPath, tempPath); + } + File.Move(tempPath, fullDestinationPath, overwrite); + return true; + } + catch + { + TryDelete(tempPath); + throw; + } + } + internal static async Task WriteAllTextAsync( string filePath, string content, @@ -18,9 +87,8 @@ internal static async Task WriteAllTextAsync( } Directory.CreateDirectory(directory); - string tempPath = Path.Combine( - directory, - $".{Path.GetFileName(fullPath)}.provider-sync.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + string tempPath = CreateTempPath(directory, Path.GetFileName(fullPath)); + UnixFileMode targetMode = TryGetUnixMode(fullPath) ?? OwnerReadWriteMode; try { byte[] bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); @@ -40,6 +108,7 @@ internal static async Task WriteAllTextAsync( await stream.FlushAsync(cancellationToken); stream.Flush(flushToDisk: true); } + ApplyUnixMode(tempPath, targetMode); if (faultInjector is not null) { await faultInjector("before_atomic_replace", fullPath, tempPath); @@ -53,9 +122,17 @@ internal static async Task WriteAllTextAsync( } } + private static string CreateTempPath(string directory, string fileName) + { + return Path.Combine( + directory, + $".{fileName}.provider-sync.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + } + internal static async Task ReplaceOpenFileFromTempAsync(FileStream destination, string tempPath) { string destinationPath = destination.Name; + UnixFileMode targetMode = TryGetUnixMode(destinationPath) ?? OwnerReadWriteMode; await using (FileStream staged = new( tempPath, FileMode.Open, @@ -68,10 +145,30 @@ internal static async Task ReplaceOpenFileFromTempAsync(FileStream destination, staged.Flush(flushToDisk: true); } + ApplyUnixMode(tempPath, targetMode); await destination.DisposeAsync(); File.Move(tempPath, destinationPath, overwrite: true); } + private const UnixFileMode OwnerReadWriteMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + private static UnixFileMode? TryGetUnixMode(string path) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + { + return null; + } + return File.GetUnixFileMode(path); + } + + private static void ApplyUnixMode(string path, UnixFileMode mode) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, mode); + } + } + private static void TryDelete(string path) { try diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index 85a626b..a30d513 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -7,6 +7,8 @@ public sealed class BackupService private readonly SessionRolloutService _sessionRolloutService; private readonly SqliteStateService _sqliteStateService; + internal Func? AtomicWriteFaultInjector { get; set; } + public BackupService(SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService) { _sessionRolloutService = sessionRolloutService; @@ -73,7 +75,10 @@ public async Task CreateBackupAsync( string configBackupPath = Path.Combine(backupDir, "config.toml"); if (configBackupText is not null) { - await File.WriteAllTextAsync(configBackupPath, configBackupText); + await AtomicFile.WriteAllTextAsync( + configBackupPath, + configBackupText, + faultInjector: AtomicWriteFaultInjector); } else { @@ -96,20 +101,17 @@ await CopyIfPresentAsync( CodexHome = codexHome, TargetProvider = targetProvider, CreatedAt = createdAt, - Files = sessionChanges.Select(static change => new SessionBackupManifestEntry - { - Path = change.Path, - OriginalFirstLine = change.OriginalFirstLine, - OriginalSeparator = change.OriginalSeparator, - OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks, - ModelOnlyChange = change.ModelOnlyChange, - OriginalTurnContextModels = [.. change.OriginalTurnContextModels] - }).ToList() + Files = sessionChanges.Select(SessionBackupManifestEntry.FromChange).ToList() }; - await File.WriteAllTextAsync( + await AtomicFile.WriteAllTextAsync( Path.Combine(backupDir, "session-meta-backup.json"), - JsonSerializer.Serialize(sessionManifest, JsonOptions())); + JsonSerializer.Serialize(sessionManifest, JsonOptions()), + faultInjector: AtomicWriteFaultInjector); + bool globalStateFilePresent = File.Exists( + Path.Combine(codexHome, AppConstants.GlobalStateFileBasename)); + bool globalStateBackupFilePresent = File.Exists( + Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename)); BackupMetadataFile metadata = new() { Version = 2, @@ -120,11 +122,19 @@ await File.WriteAllTextAsync( CreatedAt = createdAt, DbFiles = copiedDbFiles, SqliteDbFiles = copiedSqliteDbFiles, - ChangedSessionFiles = sessionChanges.Count + ChangedSessionFiles = sessionChanges.Count, + GlobalStateFiles = new Dictionary(StringComparer.Ordinal) + { + [AppConstants.GlobalStateFileBasename] = globalStateFilePresent, + [AppConstants.GlobalStateBackupFileBasename] = globalStateBackupFilePresent + }, + GlobalStateFilePresent = globalStateFilePresent, + GlobalStateBackupFilePresent = globalStateBackupFilePresent }; - await File.WriteAllTextAsync( + await AtomicFile.WriteAllTextAsync( Path.Combine(backupDir, "metadata.json"), - JsonSerializer.Serialize(metadata, JsonOptions())); + JsonSerializer.Serialize(metadata, JsonOptions()), + faultInjector: AtomicWriteFaultInjector); return backupDir; } @@ -165,6 +175,11 @@ await File.ReadAllTextAsync(metadataPath), throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}."); } + if (options.RestoreConfig) + { + ValidateGlobalStatePresenceMetadata(metadata); + } + SessionBackupManifest? sessionManifest = null; if (options.RestoreSessions) { @@ -172,6 +187,9 @@ await File.ReadAllTextAsync(metadataPath), await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "session-meta-backup.json")), JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); + ValidateSessionManifest(sessionManifest, codexHome, normalizedBackupDir); + sessionManifest = await SelectSessionEntriesForRestoreAsync(normalizedBackupDir, sessionManifest); + await _sessionRolloutService.AssertSessionFilesWritableAsync( sessionManifest.Files.Select(static entry => entry.Path)); } @@ -273,8 +291,11 @@ await CopyIfPresentAsync( foreach ((string sourcePath, string targetPath) in databaseEntries) { - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Copy(sourcePath, targetPath, overwrite: true); + await AtomicFile.CopyAsync( + sourcePath, + targetPath, + overwrite: true, + faultInjector: AtomicWriteFaultInjector); } } @@ -313,15 +334,7 @@ await File.ReadAllTextAsync(metadataPath), CodexHome = sessionManifest.CodexHome, TargetProvider = sessionManifest.TargetProvider, CreatedAt = sessionManifest.CreatedAt, - Files = sessionChanges.Select(static change => new SessionBackupManifestEntry - { - Path = change.Path, - OriginalFirstLine = change.OriginalFirstLine, - OriginalSeparator = change.OriginalSeparator, - OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks, - ModelOnlyChange = change.ModelOnlyChange, - OriginalTurnContextModels = [.. change.OriginalTurnContextModels] - }).ToList() + Files = sessionChanges.Select(SessionBackupManifestEntry.FromChange).ToList() }; metadata = new BackupMetadataFile { @@ -333,24 +346,129 @@ await File.ReadAllTextAsync(metadataPath), CreatedAt = metadata.CreatedAt, DbFiles = metadata.DbFiles, SqliteDbFiles = metadata.SqliteDbFiles, - ChangedSessionFiles = sessionChanges.Count + ChangedSessionFiles = sessionChanges.Count, + GlobalStateFiles = metadata.GlobalStateFiles, + GlobalStateFilePresent = metadata.GlobalStateFilePresent, + GlobalStateBackupFilePresent = metadata.GlobalStateBackupFilePresent }; - await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(sessionManifest, JsonOptions())); - await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata, JsonOptions())); + await AtomicFile.WriteAllTextAsync( + manifestPath, + JsonSerializer.Serialize(sessionManifest, JsonOptions()), + faultInjector: AtomicWriteFaultInjector); + await AtomicFile.WriteAllTextAsync( + metadataPath, + JsonSerializer.Serialize(metadata, JsonOptions()), + faultInjector: AtomicWriteFaultInjector); + } + + internal async Task> ReadSessionBackupEntriesAsync( + string backupDir, + string codexHome) + { + string normalizedBackupDir = Path.GetFullPath(backupDir); + SessionBackupManifest manifest = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "session-meta-backup.json")), + JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); + ValidateSessionManifest(manifest, codexHome, normalizedBackupDir); + return manifest.Files; + } + + internal async Task GetRecoveryCoverageAsync( + string backupDir, + string codexHome) + { + string normalizedBackupDir = Path.GetFullPath(backupDir); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2) + || !PathsEqual(metadata.CodexHome, codexHome)) + { + throw new InvalidOperationException($"Backup metadata is not valid for recovery: {backupDir}"); + } + + bool database = (metadata.Version >= 2 ? metadata.SqliteDbFiles : metadata.DbFiles) + .Any(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename); + bool sessions = false; + string sessionManifestPath = Path.Combine(normalizedBackupDir, "session-meta-backup.json"); + if (File.Exists(sessionManifestPath)) + { + sessions = (await ReadSessionBackupEntriesAsync(normalizedBackupDir, codexHome)).Count > 0; + } + bool? globalStatePresent = ResolveGlobalStatePresence( + metadata, + AppConstants.GlobalStateFileBasename); + bool? globalStateBackupPresent = ResolveGlobalStatePresence( + metadata, + AppConstants.GlobalStateBackupFileBasename); + bool config = File.Exists(Path.Combine(normalizedBackupDir, "config.toml")) + || globalStatePresent == true + || globalStateBackupPresent == true; + return new BackupRecoveryCoverage(config, database, sessions); + } + + internal async Task RestoreConfigFileAsync(string backupDir, string codexHome) + { + string sourcePath = Path.Combine(Path.GetFullPath(backupDir), "config.toml"); + if (!await CopyIfPresentAsync(sourcePath, Path.Combine(codexHome, "config.toml"), overwrite: true)) + { + throw new InvalidOperationException($"Backup config is missing: {sourcePath}"); + } + } + + internal async Task RestoreGlobalStateTargetAsync(string backupDir, string codexHome, string targetPath) + { + string statePath = Path.GetFullPath(Path.Combine(codexHome, AppConstants.GlobalStateFileBasename)); + string backupPath = Path.GetFullPath(Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename)); + string normalizedTarget = Path.GetFullPath(targetPath); + string fileName; + if (PathsEqual(normalizedTarget, statePath)) + { + fileName = AppConstants.GlobalStateFileBasename; + } + else if (PathsEqual(normalizedTarget, backupPath)) + { + fileName = AppConstants.GlobalStateBackupFileBasename; + } + else + { + throw new InvalidOperationException($"Unexpected global-state rollback target: {targetPath}"); + } + + string normalizedBackupDir = Path.GetFullPath(backupDir); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + ValidateGlobalStatePresenceMetadata(metadata); + await RestoreOptionalFileAsync( + Path.Combine(normalizedBackupDir, fileName), + normalizedTarget, + ResolveGlobalStatePresence(metadata, fileName)); } public async Task RestoreGlobalStateFilesAsync(string backupDir, string codexHome) { string normalizedBackupDir = Path.GetFullPath(backupDir); - await CopyIfPresentAsync( + string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(metadataPath), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + bool? globalStatePresent = ResolveGlobalStatePresence( + metadata, + AppConstants.GlobalStateFileBasename); + bool? globalStateBackupPresent = ResolveGlobalStatePresence( + metadata, + AppConstants.GlobalStateBackupFileBasename); + await RestoreOptionalFileAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateFileBasename), - overwrite: true); - await CopyIfPresentAsync( + globalStatePresent); + await RestoreOptionalFileAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename), - overwrite: true); + globalStateBackupPresent); } public async Task GetBackupStorageInfoAsync(string backupDir) @@ -444,17 +562,183 @@ public async Task PruneBackupsAsync(string codexHome, int kee }); } + private static async Task SelectSessionEntriesForRestoreAsync( + string backupDir, + SessionBackupManifest manifest) + { + string journalPath = Path.Combine(backupDir, FileTransactionJournal.FileName); + if (!File.Exists(journalPath)) + { + return manifest; + } + + PendingTransactionInfo journal = await FileTransactionJournal.ReadInfoAsync(journalPath); + if (string.IsNullOrWhiteSpace(journal.OperationId)) + { + // A legacy or externally damaged journal cannot authoritatively + // narrow the immutable backup manifest. An explicit restore is + // safest when it restores the whole validated manifest. + return manifest; + } + + HashSet affectedRollouts = new( + journal.AffectedTargets + .Where(static target => target.Kind == "rollout") + .Select(static target => Path.GetFullPath(target.TargetPath)), + PathComparer); + return new SessionBackupManifest + { + Version = manifest.Version, + Namespace = manifest.Namespace, + CodexHome = manifest.CodexHome, + TargetProvider = manifest.TargetProvider, + CreatedAt = manifest.CreatedAt, + Files = manifest.Files + .Where(entry => affectedRollouts.Contains(Path.GetFullPath(entry.Path))) + .ToList() + }; + } + + private static void ValidateSessionManifest( + SessionBackupManifest manifest, + string codexHome, + string backupDir) + { + if (!string.Equals(manifest.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || manifest.Version is not (1 or 2)) + { + throw new InvalidOperationException( + $"Unsupported session backup manifest in {Path.Combine(backupDir, "session-meta-backup.json")}."); + } + if (!PathsEqual(manifest.CodexHome, codexHome)) + { + throw new InvalidOperationException( + $"Session backup was created for {manifest.CodexHome}, not {codexHome}."); + } + + HashSet seen = new(PathComparer); + foreach (SessionBackupManifestEntry entry in manifest.Files) + { + string fullPath = ValidateSessionRestorePath(codexHome, entry.Path); + _ = entry.ResolveOriginalLastWriteTimeUtcTicks(); + if (!seen.Add(fullPath)) + { + throw new InvalidOperationException( + $"Session backup manifest contains a duplicate rollout path: {entry.Path}"); + } + } + } + + private static string ValidateSessionRestorePath(string codexHome, string candidatePath) + { + if (string.IsNullOrWhiteSpace(candidatePath)) + { + throw new InvalidOperationException("Session backup manifest contains an empty rollout path."); + } + + string fullHome = Path.GetFullPath(codexHome); + string fullPath = Path.GetFullPath(candidatePath); + string relativePath = Path.GetRelativePath(fullHome, fullPath); + string[] segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + bool validRoot = segments.Length >= 2 + && AppConstants.SessionDirectories.Contains(segments[0], PathComparer); + if (Path.IsPathRooted(relativePath) + || relativePath == ".." + || relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) + || !validRoot + || !Path.GetFileName(fullPath).StartsWith("rollout-", StringComparison.Ordinal) + || !string.Equals(Path.GetExtension(fullPath), ".jsonl", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Session backup path escapes the Codex rollout directories: {candidatePath}"); + } + + string currentPath = fullHome; + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!File.Exists(currentPath) && !Directory.Exists(currentPath)) + { + continue; + } + if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Session backup path crosses a symbolic link or reparse point: {candidatePath}"); + } + } + return fullPath; + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + private static async Task CopyIfPresentAsync(string sourcePath, string destinationPath, bool overwrite) { - if (!File.Exists(sourcePath)) + return await AtomicFile.CopyAsync(sourcePath, destinationPath, overwrite); + } + + private static async Task RestoreOptionalFileAsync( + string sourcePath, + string destinationPath, + bool? originallyPresent) + { + if (await CopyIfPresentAsync(sourcePath, destinationPath, overwrite: true)) { - return false; + return; + } + + if (originallyPresent == true) + { + throw new InvalidOperationException( + $"Backup declares an original file but the backup copy is missing: {sourcePath}"); + } + + // Nullable markers keep metadata v1 and early-v2 backups backward + // compatible. A new backup explicitly records absence so rollback can + // remove a .bak file created by the interrupted operation. + if (originallyPresent == false && File.Exists(destinationPath)) + { + File.Delete(destinationPath); } + } - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - File.Copy(sourcePath, destinationPath, overwrite); - await Task.CompletedTask; - return true; + private static bool? ResolveGlobalStatePresence( + BackupMetadataFile metadata, + string fileName) + { + bool? legacy = fileName switch + { + AppConstants.GlobalStateFileBasename => metadata.GlobalStateFilePresent, + AppConstants.GlobalStateBackupFileBasename => metadata.GlobalStateBackupFilePresent, + _ => throw new InvalidOperationException( + $"Unsupported global-state metadata key: {fileName}") + }; + if (metadata.GlobalStateFiles is null) + { + return legacy; + } + if (!metadata.GlobalStateFiles.TryGetValue(fileName, out bool canonical)) + { + throw new InvalidOperationException( + $"Backup globalStateFiles is missing required key {fileName}."); + } + if (legacy is not null && legacy.Value != canonical) + { + throw new InvalidOperationException( + $"Backup global-state metadata disagrees for {fileName}."); + } + return canonical; + } + + private static void ValidateGlobalStatePresenceMetadata(BackupMetadataFile metadata) + { + _ = ResolveGlobalStatePresence(metadata, AppConstants.GlobalStateFileBasename); + _ = ResolveGlobalStatePresence(metadata, AppConstants.GlobalStateBackupFileBasename); } private static string? SafeRelativePath(string root, string target) @@ -571,3 +855,5 @@ private static bool IsManagedBackupDirectory(string backupDirectoryPath) } } } + +internal sealed record BackupRecoveryCoverage(bool Config, bool Database, bool Sessions); diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs index cdc4dd7..da45375 100644 --- a/desktop/CodexProviderSync.Core/CodexSyncService.cs +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -132,7 +132,7 @@ public Task RunSyncAsync( sqliteBusyTimeoutMs, model, explicitSqliteHome, - afterBackup: null, + switchPreparationFactory: null, cancellationToken); } @@ -144,7 +144,7 @@ private async Task RunSyncCoreAsync( int? sqliteBusyTimeoutMs, string? model, string? explicitSqliteHome, - Func? afterBackup, + Func? switchPreparationFactory, CancellationToken cancellationToken = default) { if (keepCount < 1) @@ -159,18 +159,22 @@ private async Task RunSyncCoreAsync( cancellationToken.ThrowIfCancellationRequested(); string configPath = _codexHomeService.ConfigPath(codexHome); string configText = await _configFileService.ReadConfigTextAsync(configPath); + SwitchPreparation? switchPreparation = switchPreparationFactory?.Invoke(configText); CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); - storage.EnsureSqliteAccessSupported("sync"); + storage.EnsureSqliteAccessSupported(switchPreparation is null ? "sync" : "switch"); EnsureWritableStorage(storage); CurrentProviderInfo current = _configFileService.ReadCurrentProviderFromConfigText(configText); - string targetProvider = provider ?? current.Provider ?? AppConstants.DefaultProvider; + string targetProvider = switchPreparation?.Provider + ?? provider + ?? current.Provider + ?? AppConstants.DefaultProvider; // When the caller did not pin a model, mirror the active root-level // `model = "..."` field from config.toml into the per-thread SQLite // `model` column. Without this, old sessions keep showing the model // they were created with in Codex's bottom-right UI label, even after // the root-level `model` changes. - string? targetModel = model; + string? targetModel = switchPreparation?.ThreadModel ?? model; if (string.IsNullOrEmpty(targetModel)) { targetModel = _configFileService.ReadRootModelFromConfigText(configText); @@ -198,17 +202,23 @@ private async Task RunSyncCoreAsync( { await FaultInjector("before_backup", null, 0); } - string backupDir = await _backupService.CreateBackupAsync(storage, targetProvider, writableChanges, configPath, configBackupText); - bool sessionRestoreNeeded = false; + string? effectiveConfigBackupText = switchPreparation is null ? configBackupText : configText; + string backupDir = await _backupService.CreateBackupAsync( + storage, + targetProvider, + writableChanges, + configPath, + effectiveConfigBackupText); List appliedSessionChanges = []; - bool globalStateRestoreNeeded = false; + bool sqliteMutationCommitted = false; string globalStatePath = _globalStateService.StatePath(codexHome); string globalStateBackupPath = _globalStateService.BackupPath(codexHome); string[] potentialTargets = writableChanges.Select(static change => Path.GetFullPath(change.Path)) - .Append(Path.GetFullPath(globalStatePath)) - .Append(Path.GetFullPath(globalStateBackupPath)) - .Concat(configBackupText is null ? [] : [Path.GetFullPath(configPath)]) - .Concat(storage.StateDbCandidates.Select(static candidate => Path.GetFullPath(candidate.Path))) + .Concat(File.Exists(globalStatePath) + ? [Path.GetFullPath(globalStatePath), Path.GetFullPath(globalStateBackupPath)] + : []) + .Concat(switchPreparation is null ? [] : [Path.GetFullPath(configPath)]) + .Concat(storage.StateDbLocation is null ? [] : [Path.GetFullPath(storage.StateDbLocation.Path)]) .ToArray(); FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( backupDir, @@ -216,6 +226,8 @@ private async Task RunSyncCoreAsync( targetProvider, potentialTargets); List completedTargets = []; + HashSet observedMutatedTargets = new(PathComparer); + bool transactionCommitted = false; void RecordCompletedTarget(string targetPath) { string fullPath = Path.GetFullPath(targetPath); @@ -233,11 +245,20 @@ void RecordCompletedTarget(string targetPath) }; try { - if (afterBackup is not null) + if (switchPreparation is not null) { cancellationToken.ThrowIfCancellationRequested(); await journal.ApplyingAsync("config", configPath); - await afterBackup(backupDir); + if (FaultInjector is not null) + { + await FaultInjector("before_config_apply", configPath, 0); + } + await _configFileService.WriteConfigTextAsync(configPath, switchPreparation.NextConfigText); + observedMutatedTargets.Add(Path.GetFullPath(configPath)); + if (FaultInjector is not null) + { + await FaultInjector("after_config_mutation_before_applied", configPath, 1); + } await journal.AppliedAsync("config", configPath); RecordCompletedTarget(configPath); if (FaultInjector is not null) @@ -247,7 +268,11 @@ void RecordCompletedTarget(string targetPath) } SessionApplyResult? applyResult = null; - await journal.ApplyingAsync("sqlite", storage.StateDbLocation?.Path ?? storage.SqliteHome); + string? sqliteTargetPath = storage.StateDbLocation?.Path; + if (sqliteTargetPath is not null) + { + await journal.ApplyingAsync("sqlite", sqliteTargetPath); + } (int updatedRows, int providerRowsUpdated, int modelRowsUpdated, int userEventRowsUpdated, int cwdRowsUpdated, bool databasePresent) = await _sqliteStateService.UpdateSqliteProviderAsync( storage, targetProvider, @@ -273,15 +298,25 @@ await FaultInjector( }, async change => { + observedMutatedTargets.Add(Path.GetFullPath(change.Path)); + if (FaultInjector is not null) + { + await FaultInjector( + "after_rollout_mutation_before_applied", + change.Path, + appliedSessionChanges.Count + 1); + } appliedSessionChanges.Add(change); - sessionRestoreNeeded = true; await journal.AppliedAsync("rollout", change.Path); RecordCompletedTarget(change.Path); - await _backupService.UpdateSessionBackupManifestAsync(backupDir, appliedSessionChanges); if (FaultInjector is not null) { await FaultInjector("after_rollout_apply", change.Path, appliedSessionChanges.Count); } + }, + async change => + { + await journal.SkippedAsync("rollout", change.Path); }); } workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync( @@ -294,7 +329,11 @@ await FaultInjector( }, async targetPath => { - globalStateRestoreNeeded = true; + observedMutatedTargets.Add(Path.GetFullPath(targetPath)); + if (FaultInjector is not null) + { + await FaultInjector("after_global_state_mutation_before_applied", targetPath, 1); + } await journal.AppliedAsync("globalState", targetPath); RecordCompletedTarget(targetPath); if (FaultInjector is not null) @@ -302,16 +341,47 @@ await FaultInjector( await FaultInjector("after_global_state_apply", targetPath, 1); } }); + cancellationToken.ThrowIfCancellationRequested(); }, sqliteBusyTimeoutMs, sessionInfo.UserEventThreadIds, sessionInfo.ThreadCwdsById); - await journal.AppliedAsync("sqlite", storage.StateDbLocation?.Path ?? storage.SqliteHome); - RecordCompletedTarget(storage.StateDbLocation?.Path ?? storage.SqliteHome); + sqliteMutationCommitted = databasePresent && updatedRows > 0; + if (sqliteMutationCommitted) + { + observedMutatedTargets.Add(Path.GetFullPath(sqliteTargetPath!)); + RecordCompletedTarget(sqliteTargetPath!); + } + cancellationToken.ThrowIfCancellationRequested(); + if (sqliteTargetPath is not null) + { + if (FaultInjector is not null) + { + await FaultInjector("after_sqlite_mutation_before_applied", sqliteTargetPath, 1); + } + await journal.AppliedAsync("sqlite", sqliteTargetPath); + } + if (FaultInjector is not null) + { + await FaultInjector("after_sqlite_commit", sqliteTargetPath ?? storage.SqliteHome, 1); + } + cancellationToken.ThrowIfCancellationRequested(); skippedRolloutFiles.AddRange(applyResult?.SkippedPaths ?? []); skippedRolloutFiles = skippedRolloutFiles.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); + cancellationToken.ThrowIfCancellationRequested(); + if (FaultInjector is not null) + { + await FaultInjector("before_transaction_commit", null, completedTargets.Count); + } + await journal.CommittedAsync(); + transactionCommitted = true; + if (FaultInjector is not null) + { + await FaultInjector("after_transaction_commit", null, completedTargets.Count); + } + BackupPruneResult? autoPruneResult = null; string? autoPruneWarning = null; try @@ -346,64 +416,51 @@ await FaultInjector( EncryptedContentCounts = sessionInfo.EncryptedContentCounts, EncryptedContentWarning = encryptedContentWarning, AutoPruneResult = autoPruneResult, - AutoPruneWarning = autoPruneWarning + AutoPruneWarning = autoPruneWarning, + ConfigUpdated = switchPreparation is not null, + ModelSync = switchPreparation?.ModelSync ?? ModelSyncOutcome.NotApplicable() }; - await journal.CommittedAsync(); return result; } catch (Exception error) { - List restoreFailures = []; - try + if (transactionCommitted) { - await journal.RollingBackAsync(error); + throw; } - catch (Exception journalError) + + List restoreFailures = []; + IReadOnlyList affectedTargets; + try { - restoreFailures.Add($"transaction journal: {journalError.Message}"); + PendingTransactionInfo persisted = await journal.ReadCurrentInfoAsync(); + affectedTargets = persisted.AffectedTargets; } - if (sessionRestoreNeeded) + catch (Exception journalReadError) { - try - { - if (FaultInjector is not null) - { - await FaultInjector("before_rollout_rollback", null, appliedSessionChanges.Count); - } - await _sessionRolloutService.RestoreSessionChangesAsync(appliedSessionChanges); - } - catch (Exception restoreError) - { - restoreFailures.Add($"rollout files: {restoreError.Message}"); - } + restoreFailures.Add($"transaction journal read: {journalReadError.Message}"); + affectedTargets = BuildConservativeRollbackTargets( + writableChanges, + switchPreparation is not null ? configPath : null, + File.Exists(globalStatePath) ? globalStatePath : null, + File.Exists(globalStateBackupPath) ? globalStateBackupPath : null, + sqliteMutationCommitted ? storage.StateDbLocation?.Path : null); } - if (globalStateRestoreNeeded) + try { - try - { - if (FaultInjector is not null) - { - await FaultInjector("before_global_state_rollback", null, 1); - } - await _backupService.RestoreGlobalStateFilesAsync(backupDir, codexHome); - } - catch (Exception restoreError) - { - restoreFailures.Add($"global state: {restoreError.Message}"); - } + await journal.RollingBackAsync(error); } - - if (configBackupText is not null) + catch (Exception journalError) { - try - { - await _configFileService.WriteConfigTextAsync(configPath, configBackupText); - } - catch (Exception restoreError) - { - restoreFailures.Add($"config: {restoreError.Message}"); - } + restoreFailures.Add($"transaction journal: {journalError.Message}"); } + restoreFailures.AddRange(await RollBackTargetsAsync( + affectedTargets + .Where(target => target.Kind != "sqlite" || sqliteMutationCommitted) + .ToArray(), + backupDir, + codexHome, + storage)); if (restoreFailures.Count == 0) { @@ -427,7 +484,10 @@ await FaultInjector( // Preserve the original and rollback failures when the // journal itself is no longer writable. } - HashSet completedTargetSet = new(completedTargets, StringComparer.Ordinal); + IReadOnlyList reportedCompletedTargets = BuildReportedCompletedTargets( + completedTargets, + observedMutatedTargets); + HashSet completedTargetSet = new(reportedCompletedTargets, PathComparer); IReadOnlyList uncompletedTargets = potentialTargets .Where(targetPath => !completedTargetSet.Contains(targetPath)) .ToArray(); @@ -435,115 +495,210 @@ await FaultInjector( error, restoreFailures, backupDir, - completedTargets, + reportedCompletedTargets, uncompletedTargets, rollbackStatus: "incomplete", recoveryRequired: true); } - HashSet completedSet = new(completedTargets, StringComparer.Ordinal); + IReadOnlyList completedAfterRollback = BuildReportedCompletedTargets( + completedTargets, + observedMutatedTargets); + HashSet completedSet = new(completedAfterRollback, PathComparer); throw new SyncTransactionException( error, [], backupDir, - completedTargets, + completedAfterRollback, potentialTargets.Where(targetPath => !completedSet.Contains(targetPath)).ToArray(), rollbackStatus: "complete", recoveryRequired: false); } } - public async Task RunSwitchAsync( - string? explicitCodexHome, - string provider, - int keepCount = AppConstants.DefaultBackupRetentionCount, - string? model = null, - bool keepRootModel = false, - string? explicitSqliteHome = null) + private async Task> RollBackTargetsAsync( + IReadOnlyList affectedTargets, + string backupDir, + string codexHome, + CodexStorageLayout storage) { - if (string.IsNullOrWhiteSpace(provider)) + List failures = []; + Dictionary? sessionEntries = null; + HashSet restoredTargets = new(PathComparer); + foreach (TransactionTargetInfo target in affectedTargets.Reverse()) { - throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch "); + string normalizedTarget = Path.GetFullPath(target.TargetPath); + string targetKey = target.Kind + "\0" + normalizedTarget; + if (!restoredTargets.Add(targetKey)) + { + continue; + } + + try + { + switch (target.Kind) + { + case "rollout": + if (FaultInjector is not null) + { + await FaultInjector("before_rollout_rollback", normalizedTarget, 1); + } + if (sessionEntries is null) + { + sessionEntries = (await _backupService.ReadSessionBackupEntriesAsync(backupDir, codexHome)) + .ToDictionary( + static entry => Path.GetFullPath(entry.Path), + static entry => entry, + PathComparer); + } + if (!sessionEntries.TryGetValue(normalizedTarget, out SessionBackupManifestEntry? entry)) + { + throw new InvalidOperationException( + $"Immutable session backup does not contain rollback target {normalizedTarget}."); + } + await _sessionRolloutService.RestoreSessionChangesAsync([entry]); + break; + + case "globalState": + if (FaultInjector is not null) + { + await FaultInjector("before_global_state_rollback", normalizedTarget, 1); + } + await _backupService.RestoreGlobalStateTargetAsync(backupDir, codexHome, normalizedTarget); + break; + + case "config": + if (FaultInjector is not null) + { + await FaultInjector("before_config_rollback", normalizedTarget, 1); + } + await _backupService.RestoreConfigFileAsync(backupDir, codexHome); + break; + + case "sqlite": + if (FaultInjector is not null) + { + await FaultInjector("before_sqlite_rollback", normalizedTarget, 1); + } + await _backupService.RestoreBackupAsync( + backupDir, + storage, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = false + }); + break; + + default: + throw new InvalidOperationException( + $"Unsupported transaction rollback target kind \"{target.Kind}\"."); + } + } + catch (Exception restoreError) + { + failures.Add($"{target.Kind} {normalizedTarget}: {restoreError.Message}"); + } } + return failures; + } - string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); - await _codexHomeService.EnsureCodexHomeAsync(codexHome); - string configPath = _codexHomeService.ConfigPath(codexHome); - string originalConfigText = await _configFileService.ReadConfigTextAsync(configPath); - CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, originalConfigText); - storage.EnsureSqliteAccessSupported("switch"); - EnsureWritableStorage(storage); - if (!_configFileService.ConfigDeclaresProvider(originalConfigText, provider)) + private static IReadOnlyList BuildConservativeRollbackTargets( + IReadOnlyList writableChanges, + string? configPath, + string? globalStatePath, + string? globalStateBackupPath, + string? sqlitePath) + { + List targets = writableChanges + .Select(static change => new TransactionTargetInfo("rollout", Path.GetFullPath(change.Path), "applying")) + .ToList(); + if (globalStatePath is not null) { - string configuredProviders = string.Join(", ", _configFileService.ListConfiguredProviderIds(originalConfigText)); - throw new InvalidOperationException( - $"Provider \"{provider}\" is not available in config.toml. Configure it first or use one of: {configuredProviders}"); + targets.Add(new TransactionTargetInfo("globalState", Path.GetFullPath(globalStatePath), "applying")); } - - string nextConfigText = _configFileService.SetRootProviderInConfigText(originalConfigText, provider); - ModelSyncOutcome modelSync = ResolveModelSyncOutcome(originalConfigText, provider, model, keepRootModel); - if (modelSync.Applied) + if (globalStateBackupPath is not null) { - nextConfigText = _configFileService.SetRootModelInConfigText(nextConfigText, modelSync.Model!); + targets.Add(new TransactionTargetInfo("globalState", Path.GetFullPath(globalStateBackupPath), "applying")); } - - bool configMutationAttempted = false; - try + if (sqlitePath is not null) { - // Even when the switch keeps the existing root model, keep - // SQLite and rollout turn_context fields aligned with it. - string? modelForThreads = modelSync.Applied - ? modelSync.Model - : _configFileService.ReadRootModelFromConfigText(nextConfigText); - SyncResult result = await RunSyncCoreAsync( - codexHome, - provider, - originalConfigText, - keepCount, - sqliteBusyTimeoutMs: null, - model: modelForThreads, - explicitSqliteHome: explicitSqliteHome, - afterBackup: async _ => - { - configMutationAttempted = true; - await _configFileService.WriteConfigTextAsync(configPath, nextConfigText); - }); - return new SyncResult - { - CodexHome = result.CodexHome, - SqliteHome = result.SqliteHome, - SqliteHomeSource = result.SqliteHomeSource, - TargetProvider = result.TargetProvider, - PreviousProvider = result.PreviousProvider, - BackupDir = result.BackupDir, - ChangedSessionFiles = result.ChangedSessionFiles, - SkippedLockedRolloutFiles = result.SkippedLockedRolloutFiles, - SkippedUnreadableRolloutFiles = result.SkippedUnreadableRolloutFiles, - SqliteRowsUpdated = result.SqliteRowsUpdated, - SqliteProviderRowsUpdated = result.SqliteProviderRowsUpdated, - SqliteModelRowsUpdated = result.SqliteModelRowsUpdated, - SqliteUserEventRowsUpdated = result.SqliteUserEventRowsUpdated, - SqliteCwdRowsUpdated = result.SqliteCwdRowsUpdated, - UpdatedWorkspaceRoots = result.UpdatedWorkspaceRoots, - SavedWorkspaceRootCount = result.SavedWorkspaceRootCount, - SqlitePresent = result.SqlitePresent, - RolloutCountsBefore = result.RolloutCountsBefore, - EncryptedContentCounts = result.EncryptedContentCounts, - EncryptedContentWarning = result.EncryptedContentWarning, - ConfigUpdated = true, - ModelSync = modelSync, - AutoPruneResult = result.AutoPruneResult, - AutoPruneWarning = result.AutoPruneWarning - }; + targets.Add(new TransactionTargetInfo("sqlite", Path.GetFullPath(sqlitePath), "applying")); + } + if (configPath is not null) + { + targets.Add(new TransactionTargetInfo("config", Path.GetFullPath(configPath), "applying")); } - catch + return targets; + } + + private static IReadOnlyList BuildReportedCompletedTargets( + IEnumerable journaledCompletedTargets, + IEnumerable observedMutatedTargets) + { + List result = []; + foreach (string target in journaledCompletedTargets.Concat(observedMutatedTargets)) { - if (configMutationAttempted) + string fullPath = Path.GetFullPath(target); + if (!result.Contains(fullPath, PathComparer)) { - await _configFileService.WriteConfigTextAsync(configPath, originalConfigText); + result.Add(fullPath); } - throw; } + return result; + } + + public async Task RunSwitchAsync( + string? explicitCodexHome, + string provider, + int keepCount = AppConstants.DefaultBackupRetentionCount, + string? model = null, + bool keepRootModel = false, + string? explicitSqliteHome = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(provider)) + { + throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch "); + } + + return await RunSyncCoreAsync( + explicitCodexHome, + provider: null, + configBackupText: null, + keepCount, + sqliteBusyTimeoutMs: null, + model: null, + explicitSqliteHome, + switchPreparationFactory: originalConfigText => + { + if (!_configFileService.ConfigDeclaresProvider(originalConfigText, provider)) + { + string configuredProviders = string.Join(", ", _configFileService.ListConfiguredProviderIds(originalConfigText)); + throw new InvalidOperationException( + $"Provider \"{provider}\" is not available in config.toml. Configure it first or use one of: {configuredProviders}"); + } + + string nextConfigText = _configFileService.SetRootProviderInConfigText(originalConfigText, provider); + ModelSyncOutcome modelSync = ResolveModelSyncOutcome( + originalConfigText, + provider, + model, + keepRootModel); + if (modelSync.Applied) + { + nextConfigText = _configFileService.SetRootModelInConfigText(nextConfigText, modelSync.Model!); + } + + // Even when the switch keeps the existing root model, keep + // SQLite and rollout turn_context fields aligned with it. + string? modelForThreads = modelSync.Applied + ? modelSync.Model + : _configFileService.ReadRootModelFromConfigText(nextConfigText); + return new SwitchPreparation(provider, nextConfigText, modelForThreads, modelSync); + }, + cancellationToken); } private ModelSyncOutcome ResolveModelSyncOutcome( @@ -611,11 +766,71 @@ public async Task RunRestoreAsync( await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore"); string normalizedBackupDir = Path.GetFullPath(backupDir); + await EnsurePendingRecoveryCoverageAsync(normalizedBackupDir, codexHome, options); RestoreResult result = await _backupService.RestoreBackupAsync(normalizedBackupDir, storage, options); - await FileTransactionJournal.MarkBackupRolledBackAsync(normalizedBackupDir); + await FileTransactionJournal.MarkBackupRolledBackAsync( + normalizedBackupDir, + codexHome, + result.TargetProvider); return result; } + private async Task EnsurePendingRecoveryCoverageAsync( + string backupDir, + string codexHome, + RestoreBackupOptions options) + { + string journalPath = Path.Combine(backupDir, FileTransactionJournal.FileName); + if (!File.Exists(journalPath)) + { + return; + } + + PendingTransactionInfo journal = await FileTransactionJournal.ReadInfoAsync(journalPath); + if (journal.Terminal) + { + return; + } + + bool requireConfig; + bool requireDatabase; + bool requireSessions; + if (journal.InvalidTail || string.IsNullOrWhiteSpace(journal.OperationId)) + { + BackupRecoveryCoverage coverage = await _backupService.GetRecoveryCoverageAsync(backupDir, codexHome); + requireConfig = coverage.Config; + requireDatabase = coverage.Database; + requireSessions = coverage.Sessions; + } + else + { + requireConfig = journal.AffectedTargets.Any( + static target => target.Kind is "config" or "globalState"); + requireDatabase = journal.AffectedTargets.Any(static target => target.Kind == "sqlite"); + requireSessions = journal.AffectedTargets.Any(static target => target.Kind == "rollout"); + } + + List missing = []; + if (requireConfig && !options.RestoreConfig) + { + missing.Add("config/global state"); + } + if (requireDatabase && !options.RestoreDatabase) + { + missing.Add("SQLite"); + } + if (requireSessions && !options.RestoreSessions) + { + missing.Add("rollout sessions"); + } + if (missing.Count > 0) + { + throw new InvalidOperationException( + "Cannot resolve the pending transaction with a partial restore. " + + $"Enable restore for: {string.Join(", ", missing)}. The recovery journal remains pending."); + } + } + public async Task RunPruneBackupsAsync( string? explicitCodexHome = null, int keepCount = AppConstants.DefaultBackupRetentionCount) @@ -676,4 +891,14 @@ private static void EnsureWritableStorage(CodexStorageLayout storage) + $"(source: {storage.SqliteHomeSource})."); } } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private sealed record SwitchPreparation( + string Provider, + string NextConfigText, + string? ThreadModel, + ModelSyncOutcome ModelSync); } diff --git a/desktop/CodexProviderSync.Core/LockService.cs b/desktop/CodexProviderSync.Core/LockService.cs index a94d6dc..7796b8f 100644 --- a/desktop/CodexProviderSync.Core/LockService.cs +++ b/desktop/CodexProviderSync.Core/LockService.cs @@ -1,47 +1,736 @@ +using System.Diagnostics; +using System.Globalization; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; namespace CodexProviderSync.Core; public sealed class LockService { + private const int ProtocolVersion = 2; private const int Win32ErrorAlreadyExists = 183; private const int Win32ErrorAccessDenied = 5; private const int DefaultLockCreateRetryCount = 3; private const int DefaultLockCreateRetryDelayMs = 75; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly Func? _testHook; + + public LockService() + { + } + + internal LockService(Func? testHook) + { + _testHook = testHook; + } + + public Task AcquireLockAsync( + string codexHome, + string label = "codex-provider-sync") + { + return AcquirePathLockAsync(AppConstants.LockPath(codexHome), label); + } - public async Task AcquireLockAsync(string codexHome, string label = "codex-provider-sync") + /// + /// Acquires an operation lock at an explicit canonical path. Keeping this + /// primitive path-based lets callers apply the same cross-runtime protocol + /// to narrower resources (for example a resolved SQLite home) later. + /// + public async Task AcquirePathLockAsync( + string lockPath, + string label = "codex-provider-sync") { - string lockPath = AppConstants.LockPath(codexHome); - Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + string canonicalPath = Path.GetFullPath(lockPath); + string parentPath = Path.GetDirectoryName(canonicalPath) + ?? throw new InvalidOperationException($"Cannot resolve the parent directory for lock {canonicalPath}."); + string claimsPath = canonicalPath + ".claims"; + Directory.CreateDirectory(parentPath); + Directory.CreateDirectory(claimsPath); - await CreateLockDirectoryAsync(lockPath); + LockOwner owner = CreateCurrentOwner(label); + string claimPath = Path.Combine(claimsPath, owner.InstanceId + ".json"); + string candidatePath = $"{canonicalPath}.candidate.{owner.ProcessId}.{owner.InstanceId}"; + bool claimPublished = false; try { - LockOwner owner = new() + await PublishClaimAsync(claimPath, owner); + claimPublished = true; + if (_testHook is not null) { - ProcessId = Environment.ProcessId, - StartedAt = DateTimeOffset.UtcNow, - Label = label, - CurrentDirectory = Environment.CurrentDirectory - }; - await File.WriteAllTextAsync( - Path.Combine(lockPath, "owner.json"), - JsonSerializer.Serialize(owner, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - })); - return new LockHandle(lockPath); + await _testHook("claim-published", owner.InstanceId); + } + + await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner); + await ReclaimCanonicalIfStaleAsync(canonicalPath); + + // A contender can publish while a stale canonical lock is being + // quarantined. Re-check immediately before publishing ours. A new + // protocol contender will see this live claim and withdraw. + await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner); + + Directory.CreateDirectory(candidatePath); + await AtomicFile.WriteAllTextAsync( + Path.Combine(candidatePath, "owner.json"), + JsonSerializer.Serialize(owner, JsonOptions)); + + try + { + Directory.Move(candidatePath, canonicalPath); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + throw LockAlreadyExists(canonicalPath, "another owner published the canonical lock first"); + } + + return new LockHandle(canonicalPath, claimsPath, claimPath, owner.InstanceId); } catch { - Directory.Delete(lockPath, recursive: true); + TryDeleteDirectory(candidatePath); + if (claimPublished) + { + await TryDeleteOwnedClaimAsync(claimPath, owner.InstanceId); + } throw; } } + private static LockOwner CreateCurrentOwner(string label) + { + using Process process = Process.GetCurrentProcess(); + string processStartedAt = FormatUtcSecond(process.StartTime.ToUniversalTime()); + return new LockOwner + { + ProtocolVersion = ProtocolVersion, + Runtime = "dotnet", + Pid = Environment.ProcessId, + ProcessId = Environment.ProcessId, + ProcessStartedAt = processStartedAt, + InstanceId = Guid.NewGuid().ToString("D"), + StartedAt = FormatUtcSecond(DateTime.UtcNow), + Label = label, + Cwd = Environment.CurrentDirectory, + CurrentDirectory = Environment.CurrentDirectory + }; + } + + private static async Task PublishClaimAsync(string claimPath, LockOwner owner) + { + string directory = Path.GetDirectoryName(claimPath)!; + string tempPath = Path.Combine( + directory, + $".{Path.GetFileName(claimPath)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + try + { + byte[] bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) + .GetBytes(JsonSerializer.Serialize(owner, JsonOptions)); + await using (FileStream stream = new( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes); + await stream.FlushAsync(); + stream.Flush(flushToDisk: true); + } + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + tempPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + File.Move(tempPath, claimPath, overwrite: false); + } + finally + { + TryDeleteFile(tempPath); + } + } + + private static async Task AssertSoleLiveClaimAsync( + string canonicalPath, + string claimsPath, + string ownClaimPath, + LockOwner ownOwner) + { + foreach (string candidate in Directory + .EnumerateFiles(claimsPath, "*.json", SearchOption.TopDirectoryOnly) + .Order(StringComparer.Ordinal)) + { + if (PathComparer.Equals(Path.GetFullPath(candidate), Path.GetFullPath(ownClaimPath))) + { + OwnerReadResult ownRead = await ReadOwnerAsync(candidate, requireVersionTwo: true); + if (!ownRead.Valid + || !string.Equals(ownRead.Owner!.InstanceId, ownOwner.InstanceId, StringComparison.OrdinalIgnoreCase)) + { + throw LockAlreadyExists(canonicalPath, "this process's claim identity changed before acquisition"); + } + continue; + } + + OwnerReadResult read = await ReadOwnerAsync(candidate, requireVersionTwo: true); + if (!read.Valid) + { + throw LockAlreadyExists(canonicalPath, $"claim {candidate} cannot be verified and is retained fail-closed"); + } + if (!string.Equals( + Path.GetFileNameWithoutExtension(candidate), + read.Owner!.InstanceId, + StringComparison.OrdinalIgnoreCase)) + { + throw LockAlreadyExists(canonicalPath, $"claim {candidate} does not match its owner instanceId"); + } + + if (IsOwnerLive(read.Owner)) + { + throw LockAlreadyExists(canonicalPath, $"another live claim ({read.Owner!.InstanceId}) exists"); + } + + if (!await TryQuarantineAndDeleteStaleClaimAsync(candidate, read.Owner!)) + { + throw LockAlreadyExists(canonicalPath, $"stale claim {candidate} changed while it was being reclaimed"); + } + } + + // Enumeration is a snapshot. Check once more for a claim published + // during cleanup. Any other remaining final claim is conservatively a + // contender; it will independently observe this live claim as well. + string? otherClaim = Directory + .EnumerateFiles(claimsPath, "*.json", SearchOption.TopDirectoryOnly) + .FirstOrDefault(path => !PathComparer.Equals( + Path.GetFullPath(path), + Path.GetFullPath(ownClaimPath))); + if (otherClaim is not null) + { + throw LockAlreadyExists(canonicalPath, $"a concurrent claim appeared at {otherClaim}"); + } + } + + private static async Task TryQuarantineAndDeleteStaleClaimAsync( + string claimPath, + LockOwnerSnapshot expectedOwner) + { + string quarantinePath = $"{claimPath}.stale.{Environment.ProcessId}.{Guid.NewGuid():N}"; + try + { + File.Move(claimPath, quarantinePath, overwrite: false); + } + catch (FileNotFoundException) + { + return true; + } + catch (DirectoryNotFoundException) + { + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + return false; + } + + OwnerReadResult moved = await ReadOwnerAsync(quarantinePath, requireVersionTwo: true); + if (!moved.Valid || !SameOwnerGeneration(moved.Owner!, expectedOwner)) + { + TryRestoreFile(quarantinePath, claimPath); + return false; + } + + TryDeleteFile(quarantinePath); + return !File.Exists(quarantinePath); + } + + private async Task ReclaimCanonicalIfStaleAsync(string canonicalPath) + { + if (!Directory.Exists(canonicalPath)) + { + if (File.Exists(canonicalPath)) + { + throw LockAlreadyExists(canonicalPath, "the canonical lock path is not a directory"); + } + return; + } + + string ownerPath = Path.Combine(canonicalPath, "owner.json"); + OwnerReadResult read = await ReadOwnerAsync(ownerPath, requireVersionTwo: false); + if (!read.Valid) + { + throw LockAlreadyExists(canonicalPath, "owner.json cannot be verified and is retained fail-closed"); + } + if (IsOwnerLive(read.Owner!)) + { + throw LockAlreadyExists(canonicalPath, $"PID {read.Owner!.ProcessId} is still the verified owner"); + } + if (_testHook is not null) + { + await _testHook("before-stale-canonical-reclaim", read.Owner!.InstanceId ?? string.Empty); + } + + string quarantinePath = $"{canonicalPath}.stale.{Environment.ProcessId}.{Guid.NewGuid():N}"; + try + { + Directory.Move(canonicalPath, quarantinePath); + } + catch (DirectoryNotFoundException) + { + return; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + throw LockAlreadyExists(canonicalPath, "the canonical lock changed during stale-owner reclamation"); + } + + OwnerReadResult moved = await ReadOwnerAsync( + Path.Combine(quarantinePath, "owner.json"), + requireVersionTwo: false); + if (!moved.Valid || !SameOwnerGeneration(moved.Owner!, read.Owner!)) + { + bool restored = TryRestoreDirectory(quarantinePath, canonicalPath); + throw LockAlreadyExists( + canonicalPath, + restored + ? "the owner changed during reclamation, so the moved lock was restored" + : $"the owner changed during reclamation; it is preserved at {quarantinePath}"); + } + + TryDeleteDirectory(quarantinePath); + if (Directory.Exists(quarantinePath)) + { + throw new IOException($"Unable to remove quarantined stale lock {quarantinePath}."); + } + } + + private static async Task ReadOwnerAsync( + string ownerPath, + bool requireVersionTwo) + { + string text; + try + { + text = await File.ReadAllTextAsync(ownerPath); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + return new OwnerReadResult(null, error.Message); + } + + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + int? protocolVersion = TryReadInt(root, "protocolVersion"); + int? pid = TryReadInt(root, "pid"); + int? processId = TryReadInt(root, "processId"); + if (pid is not null && processId is not null && pid != processId) + { + return new OwnerReadResult(null, "pid and processId disagree"); + } + int? effectivePid = pid ?? processId; + if (effectivePid is null || effectivePid <= 0) + { + return new OwnerReadResult(null, "process identity is missing"); + } + + string? instanceId = TryReadString(root, "instanceId"); + string? processStartedAtText = TryReadString(root, "processStartedAt"); + DateTimeOffset? processStartedAt = null; + if (processStartedAtText is not null) + { + if (!DateTimeOffset.TryParse( + processStartedAtText, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTimeOffset parsed)) + { + return new OwnerReadResult(null, "processStartedAt is invalid"); + } + processStartedAt = TruncateToUtcSecond(parsed); + } + string? processStartMarker = TryReadString(root, "processStartMarker"); + + if (protocolVersion is not null + && protocolVersion is not (1 or ProtocolVersion)) + { + return new OwnerReadResult(null, "owner protocol version is unsupported"); + } + if (requireVersionTwo && protocolVersion != ProtocolVersion) + { + return new OwnerReadResult(null, "version 2 owner identity is required"); + } + if (protocolVersion == ProtocolVersion + && (pid is null + || processId is null + || string.IsNullOrWhiteSpace(instanceId) + || processStartedAt is null)) + { + return new OwnerReadResult(null, "version 2 owner identity is incomplete"); + } + if (processStartedAt is null && string.IsNullOrWhiteSpace(processStartMarker)) + { + // Legacy records without a process start identity can only be + // treated as live when their PID exists; they are never + // reclaimed on a PID-reuse guess. + processStartMarker = null; + } + + return new OwnerReadResult(new LockOwnerSnapshot( + protocolVersion ?? 0, + effectivePid.Value, + processStartedAt, + processStartMarker, + instanceId, + text), null); + } + catch (Exception error) when (error is JsonException or InvalidOperationException) + { + return new OwnerReadResult(null, error.Message); + } + } + + private static bool IsOwnerLive(LockOwnerSnapshot owner) + { + try + { + using Process process = Process.GetProcessById(owner.ProcessId); + if (process.HasExited) + { + return false; + } + + if (owner.ProcessStartedAt is not null) + { + DateTimeOffset actual = TruncateToUtcSecond(process.StartTime.ToUniversalTime()); + return actual == owner.ProcessStartedAt.Value; + } + + if (!string.IsNullOrWhiteSpace(owner.ProcessStartMarker)) + { + bool? markerMatches = TryMatchLegacyProcessStartMarker(process, owner.ProcessStartMarker); + return markerMatches ?? true; + } + + // A legacy live PID without a comparable start identity is kept + // fail-closed so PID reuse can never delete an active lock. + return true; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (System.ComponentModel.Win32Exception) + { + return true; + } + } + + private static bool? TryMatchLegacyProcessStartMarker(Process process, string marker) + { + if (marker.StartsWith("windows:", StringComparison.Ordinal)) + { + string ticksText = marker["windows:".Length..]; + return long.TryParse(ticksText, CultureInfo.InvariantCulture, out long ticks) + ? process.StartTime.ToUniversalTime().Ticks == ticks + : null; + } + + if (marker.StartsWith("linux:", StringComparison.Ordinal) && OperatingSystem.IsLinux()) + { + try + { + string stat = File.ReadAllText($"/proc/{process.Id}/stat"); + string bootId = File.ReadAllText("/proc/sys/kernel/random/boot_id").Trim(); + int closeParen = stat.LastIndexOf(')'); + if (closeParen < 0) + { + return null; + } + string[] fields = stat[(closeParen + 1)..] + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length <= 19) + { + return null; + } + return string.Equals( + marker, + $"linux:{bootId}:{fields[19]}", + StringComparison.Ordinal); + } + catch + { + return null; + } + } + + int separator = marker.IndexOf(':'); + if (separator > 0 + && DateTimeOffset.TryParse( + marker[(separator + 1)..], + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out DateTimeOffset parsed)) + { + return TruncateToUtcSecond(parsed) == TruncateToUtcSecond(process.StartTime.ToUniversalTime()); + } + return null; + } + + private static bool SameOwnerGeneration(LockOwnerSnapshot left, LockOwnerSnapshot right) + { + if (!string.IsNullOrWhiteSpace(left.InstanceId) + || !string.IsNullOrWhiteSpace(right.InstanceId)) + { + return string.Equals(left.InstanceId, right.InstanceId, StringComparison.OrdinalIgnoreCase) + && left.ProcessId == right.ProcessId + && left.ProcessStartedAt == right.ProcessStartedAt + && string.Equals(left.ProcessStartMarker, right.ProcessStartMarker, StringComparison.Ordinal); + } + return string.Equals(left.RawText, right.RawText, StringComparison.Ordinal); + } + + private static async Task TryDeleteOwnedClaimAsync(string claimPath, string instanceId) + { + if (!File.Exists(claimPath)) + { + return true; + } + OwnerReadResult read = await ReadOwnerAsync(claimPath, requireVersionTwo: true); + if (!read.Valid + || !string.Equals(read.Owner!.InstanceId, instanceId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + try + { + File.Delete(claimPath); + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + return false; + } + } + + internal static async ValueTask ReleaseAsync( + string canonicalPath, + string claimsPath, + string claimPath, + string instanceId) + { + if (!Directory.Exists(canonicalPath)) + { + if (!await TryDeleteOwnedClaimAsync(claimPath, instanceId)) + { + throw new InvalidOperationException( + $"Refusing to delete claim {claimPath} because its owner identity changed."); + } + return; + } + + OwnerReadResult current = await ReadOwnerAsync( + Path.Combine(canonicalPath, "owner.json"), + requireVersionTwo: true); + if (!current.Valid + || !string.Equals(current.Owner!.InstanceId, instanceId, StringComparison.OrdinalIgnoreCase)) + { + await TryDeleteOwnedClaimAsync(claimPath, instanceId); + throw new InvalidOperationException( + $"Refusing to release lock {canonicalPath} because its owner identity changed."); + } + + string releasePath = $"{canonicalPath}.release.{Environment.ProcessId}.{Guid.NewGuid():N}"; + try + { + Directory.Move(canonicalPath, releasePath); + } + catch (DirectoryNotFoundException) + { + if (!await TryDeleteOwnedClaimAsync(claimPath, instanceId)) + { + throw new InvalidOperationException( + $"Refusing to delete claim {claimPath} because its owner identity changed."); + } + return; + } + + OwnerReadResult moved = await ReadOwnerAsync( + Path.Combine(releasePath, "owner.json"), + requireVersionTwo: true); + if (!moved.Valid + || !string.Equals(moved.Owner!.InstanceId, instanceId, StringComparison.OrdinalIgnoreCase)) + { + bool restored = TryRestoreDirectory(releasePath, canonicalPath); + await TryDeleteOwnedClaimAsync(claimPath, instanceId); + throw new InvalidOperationException( + restored + ? $"Refusing to release lock {canonicalPath} because its owner identity changed; the lock was restored." + : $"Refusing to release lock {canonicalPath} because its owner identity changed; it is preserved at {releasePath}."); + } + + Directory.Delete(releasePath, recursive: true); + if (!await TryDeleteOwnedClaimAsync(claimPath, instanceId)) + { + throw new InvalidOperationException( + $"Released canonical lock {canonicalPath}, but refused to delete claim {claimPath} because its owner identity changed."); + } + + _ = claimsPath; // The sibling claims directory intentionally persists. + } + + private static InvalidOperationException LockAlreadyExists(string lockPath, string reason) + { + return new InvalidOperationException( + $"Lock already exists at {lockPath}: {reason}. Close Codex/App and retry; do not remove it unless the recorded owner is known to be gone."); + } + + private static int? TryReadInt(JsonElement root, string name) + { + return root.TryGetProperty(name, out JsonElement value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out int parsed) + ? parsed + : null; + } + + private static string? TryReadString(JsonElement root, string name) + { + return root.TryGetProperty(name, out JsonElement value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static string FormatUtcSecond(DateTime value) + { + return TruncateToUtcSecond(value).ToString( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture); + } + + internal static string CurrentProcessStartedAtForTests() + { + using Process process = Process.GetCurrentProcess(); + return FormatUtcSecond(process.StartTime.ToUniversalTime()); + } + + internal static string? CurrentProcessStartMarkerForTests() + { + using Process process = Process.GetCurrentProcess(); + if (OperatingSystem.IsWindows()) + { + return $"windows:{process.StartTime.ToUniversalTime().Ticks}"; + } + if (OperatingSystem.IsLinux()) + { + string stat = File.ReadAllText($"/proc/{process.Id}/stat"); + string bootId = File.ReadAllText("/proc/sys/kernel/random/boot_id").Trim(); + int closeParen = stat.LastIndexOf(')'); + string[] fields = stat[(closeParen + 1)..] + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + return $"linux:{bootId}:{fields[19]}"; + } + if (OperatingSystem.IsMacOS()) + { + return "darwin:" + process.StartTime.ToString( + "ddd MMM d HH:mm:ss yyyy", + CultureInfo.InvariantCulture); + } + return null; + } + + private static DateTimeOffset TruncateToUtcSecond(DateTime value) + { + return TruncateToUtcSecond(new DateTimeOffset(value.ToUniversalTime())); + } + + private static DateTimeOffset TruncateToUtcSecond(DateTimeOffset value) + { + DateTimeOffset utc = value.ToUniversalTime(); + return new DateTimeOffset( + utc.Ticks - (utc.Ticks % TimeSpan.TicksPerSecond), + TimeSpan.Zero); + } + + private static bool TryRestoreDirectory(string source, string destination) + { + try + { + if (Directory.Exists(destination) || File.Exists(destination)) + { + return false; + } + Directory.Move(source, destination); + return true; + } + catch + { + return false; + } + } + + private static void TryRestoreFile(string source, string destination) + { + try + { + if (!File.Exists(destination)) + { + File.Move(source, destination, overwrite: false); + } + } + catch + { + // Preserve the quarantined claim for diagnosis if restoration is + // not possible. Never delete an identity that failed validation. + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Best effort cleanup must not hide the ownership decision. + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Best effort cleanup must not hide the ownership decision. + } + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + internal static async Task CreateLockDirectoryAsync( string lockPath, int retryCount = DefaultLockCreateRetryCount, @@ -63,8 +752,7 @@ internal static async Task CreateLockDirectoryAsync( if (errorCode == Win32ErrorAlreadyExists) { - throw new InvalidOperationException( - $"Lock already exists at {lockPath}. Close Codex/App and retry, or remove the stale lock if you are sure no sync is running."); + throw LockAlreadyExists(lockPath, "the canonical directory already exists"); } if (!IsTransientLockCreateError(errorCode) || attempts >= retryCount) @@ -118,36 +806,68 @@ private static int TryCreateDirectoryUnix(string lockPath) private sealed class LockOwner { + public required int ProtocolVersion { get; init; } + public required string Runtime { get; init; } + public required int Pid { get; init; } public required int ProcessId { get; init; } - public required DateTimeOffset StartedAt { get; init; } + public required string ProcessStartedAt { get; init; } + public required string InstanceId { get; init; } + public required string StartedAt { get; init; } public required string Label { get; init; } + public required string Cwd { get; init; } public required string CurrentDirectory { get; init; } } + + private sealed record LockOwnerSnapshot( + int ProtocolVersion, + int ProcessId, + DateTimeOffset? ProcessStartedAt, + string? ProcessStartMarker, + string? InstanceId, + string RawText); + + private sealed record OwnerReadResult(LockOwnerSnapshot? Owner, string? Error) + { + public bool Valid => Owner is not null; + } } public sealed class LockHandle : IAsyncDisposable { - private readonly string _lockPath; + private readonly string _canonicalPath; + private readonly string _claimsPath; + private readonly string _claimPath; + private readonly string _instanceId; private bool _released; - public LockHandle(string lockPath) + internal LockHandle( + string canonicalPath, + string claimsPath, + string claimPath, + string instanceId) { - _lockPath = lockPath; + _canonicalPath = canonicalPath; + _claimsPath = claimsPath; + _claimPath = claimPath; + _instanceId = instanceId; } - public ValueTask DisposeAsync() + public string LockPath => _canonicalPath; + + public string InstanceId => _instanceId; + + public async ValueTask DisposeAsync() { if (_released) { - return ValueTask.CompletedTask; + return; } + await LockService.ReleaseAsync( + _canonicalPath, + _claimsPath, + _claimPath, + _instanceId); _released = true; - if (Directory.Exists(_lockPath)) - { - Directory.Delete(_lockPath, recursive: true); - } - - return ValueTask.CompletedTask; } } diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs index adb2930..4f744c8 100644 --- a/desktop/CodexProviderSync.Core/Models.cs +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace CodexProviderSync.Core; @@ -275,6 +278,9 @@ internal sealed class BackupMetadataFile public required List DbFiles { get; init; } public List SqliteDbFiles { get; init; } = []; public int ChangedSessionFiles { get; init; } + public Dictionary? GlobalStateFiles { get; init; } + public bool? GlobalStateFilePresent { get; init; } + public bool? GlobalStateBackupFilePresent { get; init; } } internal sealed class SessionBackupManifest @@ -292,9 +298,113 @@ internal sealed class SessionBackupManifestEntry public required string Path { get; init; } public required string OriginalFirstLine { get; init; } public required string OriginalSeparator { get; init; } + public string? OriginalLastWriteTimeUtc { get; init; } + public double? OriginalMtimeMs { get; init; } + [JsonConverter(typeof(NullableInt64DecimalStringJsonConverter))] public long? OriginalLastWriteTimeUtcTicks { get; init; } public bool ModelOnlyChange { get; init; } public List OriginalTurnContextModels { get; init; } = []; + + internal long? ResolveOriginalLastWriteTimeUtcTicks() + { + long? isoTicks = null; + if (!string.IsNullOrWhiteSpace(OriginalLastWriteTimeUtc)) + { + if (!DateTimeOffset.TryParseExact( + OriginalLastWriteTimeUtc, + "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTimeOffset parsed)) + { + throw new InvalidOperationException( + $"Session backup has invalid originalLastWriteTimeUtc for {Path}."); + } + isoTicks = parsed.UtcTicks; + } + + long? mtimeTicks = null; + if (OriginalMtimeMs is double mtimeMs) + { + if (!double.IsFinite(mtimeMs)) + { + throw new InvalidOperationException( + $"Session backup has non-finite originalMtimeMs for {Path}."); + } + double truncated = Math.Truncate(mtimeMs); + if (truncated < DateTimeOffset.MinValue.ToUnixTimeMilliseconds() + || truncated > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) + { + throw new InvalidOperationException( + $"Session backup originalMtimeMs is out of range for {Path}."); + } + mtimeTicks = DateTimeOffset.FromUnixTimeMilliseconds(checked((long)truncated)).UtcTicks; + } + + long? ticksAtMillisecond = OriginalLastWriteTimeUtcTicks is long ticks + ? ticks - (ticks % TimeSpan.TicksPerMillisecond) + : null; + long? expected = isoTicks ?? mtimeTicks ?? ticksAtMillisecond; + if ((isoTicks is not null && isoTicks != expected) + || (mtimeTicks is not null && mtimeTicks != expected) + || (ticksAtMillisecond is not null && ticksAtMillisecond != expected)) + { + throw new InvalidOperationException( + $"Session backup timestamp fields disagree for {Path}."); + } + return OriginalLastWriteTimeUtcTicks ?? expected; + } + + internal static SessionBackupManifestEntry FromChange(SessionChange change) + { + DateTimeOffset original = new( + new DateTime(change.OriginalLastWriteTimeUtcTicks, DateTimeKind.Utc)); + long unixMilliseconds = original.ToUnixTimeMilliseconds(); + return new SessionBackupManifestEntry + { + Path = change.Path, + OriginalFirstLine = change.OriginalFirstLine, + OriginalSeparator = change.OriginalSeparator, + OriginalLastWriteTimeUtc = original.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture), + OriginalMtimeMs = unixMilliseconds, + OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks, + ModelOnlyChange = change.ModelOnlyChange, + OriginalTurnContextModels = [.. change.OriginalTurnContextModels] + }; + } +} + +internal sealed class NullableInt64DecimalStringJsonConverter : JsonConverter +{ + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType == JsonTokenType.String + && long.TryParse(reader.GetString(), NumberStyles.None, CultureInfo.InvariantCulture, out long textValue)) + { + return textValue; + } + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt64(out long numericValue)) + { + return numericValue; + } + throw new JsonException("Expected a decimal string or Int64 JSON number."); + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); + } } public sealed class WorkspaceRootSyncResult @@ -338,6 +448,7 @@ public SyncTransactionException( public IReadOnlyList UncompletedTargets { get; } public string RollbackStatus { get; } public bool RecoveryRequired { get; } + public bool WasCanceled => OriginalError is OperationCanceledException; public string RecoveryInstructions => RecoveryRequired ? $"Restore the managed backup at {BackupDirectory}, inspect the pending transaction journal, then retry." diff --git a/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs index e518ce2..bc56cc4 100644 --- a/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs +++ b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("CodexProviderSync.Core.Tests")] +[assembly: InternalsVisibleTo("CodexProviderSync.CrashHost")] diff --git a/desktop/CodexProviderSync.Core/SessionRolloutService.cs b/desktop/CodexProviderSync.Core/SessionRolloutService.cs index a547352..bad86b9 100644 --- a/desktop/CodexProviderSync.Core/SessionRolloutService.cs +++ b/desktop/CodexProviderSync.Core/SessionRolloutService.cs @@ -11,6 +11,8 @@ public sealed class SessionRolloutService private const string StatusOnlyProvider = "__status_only__"; private const int ScanBufferSize = 1024 * 1024; + internal Func? ApplyFaultInjector { get; set; } + public async Task CollectSessionChangesAsync( string codexHome, string targetProvider, @@ -35,7 +37,9 @@ public async Task CollectSessionChangesAsync( continue; } - foreach (string rolloutPath in Directory.EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories)) + foreach (string rolloutPath in Directory + .EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal)) { FirstLineRecord record; try @@ -97,13 +101,19 @@ record = await ReadFirstLineRecordAsync(rolloutPath); bool providerChanged = !string.Equals(targetProvider, StatusOnlyProvider, StringComparison.Ordinal) && !string.Equals(currentProvider, targetProvider, StringComparison.Ordinal); + IReadOnlyList currentModelBackups = []; IReadOnlyList currentModels = []; bool modelChanged = false; if (!string.IsNullOrEmpty(targetModel)) { try { - currentModels = await ReadTurnContextModelsAsync(rolloutPath, record); + currentModelBackups = await ReadTurnContextModelBackupsAsync(rolloutPath, record); + currentModels = currentModelBackups + .SelectMany(static backup => backup.OriginalModels.Count > 0 + ? backup.OriginalModels + : [backup.OriginalModel]) + .ToArray(); modelChanged = currentModels.Any(model => !string.Equals(model, targetModel, StringComparison.Ordinal)); } catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error)) @@ -137,7 +147,8 @@ record = await ReadFirstLineRecordAsync(rolloutPath); OriginalLastWriteTimeUtcTicks = snapshot.LastWriteTimeUtcTicks, OriginalProvider = currentProvider, UpdatedFirstLine = providerChanged ? root!.ToJsonString() : record.FirstLine, - ModelOnlyChange = !providerChanged && modelChanged + ModelOnlyChange = !providerChanged && modelChanged, + OriginalTurnContextModels = currentModelBackups }); } } @@ -167,7 +178,8 @@ public async Task ApplySessionChangesAsync( IEnumerable changes, string? targetModel = null, Func? onBeforeApply = null, - Func? onApplied = null) + Func? onApplied = null, + Func? onSkipped = null) { int appliedCount = 0; List appliedPaths = []; @@ -183,6 +195,10 @@ public async Task ApplySessionChangesAsync( if (!providerApplied) { skippedPaths.Add(change.Path); + if (onSkipped is not null) + { + await onSkipped(change); + } continue; } @@ -191,6 +207,10 @@ public async Task ApplySessionChangesAsync( { if (!string.IsNullOrEmpty(targetModel)) { + if (ApplyFaultInjector is not null) + { + await ApplyFaultInjector("after-provider-before-model", change); + } modelResult = await TryRewriteRolloutModelFieldAsync(change, targetModel); change.OriginalTurnContextModels = modelResult.OriginalModels; } @@ -210,6 +230,10 @@ await RewriteFirstLineAsync( if (change.ModelOnlyChange && modelResult.ReplacedLines == 0) { skippedPaths.Add(change.Path); + if (onSkipped is not null) + { + await onSkipped(change); + } continue; } @@ -290,22 +314,14 @@ await RestoreTurnContextModelsAsync( entry.OriginalTurnContextModels, entry.OriginalSeparator); } - TryRestoreLastWriteTimeUtc(entry.Path, entry.OriginalLastWriteTimeUtcTicks); + TryRestoreLastWriteTimeUtc(entry.Path, entry.ResolveOriginalLastWriteTimeUtcTicks()); } } internal Task RestoreSessionChangesAsync(IEnumerable changes) { return RestoreSessionChangesAsync( - changes.Select(static change => new SessionBackupManifestEntry - { - Path = change.Path, - OriginalFirstLine = change.OriginalFirstLine, - OriginalSeparator = change.OriginalSeparator, - OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks, - ModelOnlyChange = change.ModelOnlyChange, - OriginalTurnContextModels = [.. change.OriginalTurnContextModels] - })); + changes.Select(SessionBackupManifestEntry.FromChange)); } private static bool TryParseSessionMetaRecord( @@ -483,11 +499,11 @@ private static async Task ReadFirstLineRecordAsync(FileStream s "\"model\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static async Task> ReadTurnContextModelsAsync( + private static async Task> ReadTurnContextModelBackupsAsync( string rolloutPath, FirstLineRecord record) { - List models = []; + List backups = []; try { await using FileStream stream = new( @@ -497,22 +513,25 @@ private static async Task> ReadTurnContextModelsAsync( FileShare.ReadWrite | FileShare.Delete); stream.Seek(record.Offset, SeekOrigin.Begin); using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); + int lineIndex = 1; string? line; while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null) { if (!TurnContextTypeRegex.IsMatch(line)) { + lineIndex += 1; continue; } + List originals = []; foreach (Match match in TurnContextModelFieldRegex.Matches(line)) { try { string? model = JsonSerializer.Deserialize($"\"{match.Groups[1].Value}\""); - if (!string.IsNullOrEmpty(model)) + if (model is not null) { - models.Add(model); + originals.Add(model); } } catch (JsonException) @@ -520,8 +539,18 @@ private static async Task> ReadTurnContextModelsAsync( // Leave malformed model literals untouched. } } + if (originals.Count > 0) + { + backups.Add(new TurnContextModelBackup + { + LineIndex = lineIndex, + OriginalModel = originals[0], + OriginalModels = originals + }); + } + lineIndex += 1; } - return models; + return backups; } catch (Exception error) when (IsRolloutFileBusyError(error)) { @@ -560,6 +589,7 @@ private async Task TryRewriteRolloutModelFieldAsync( sourceStream.Seek(0, SeekOrigin.Begin); string separator = change.OriginalSeparator == "\r\n" ? "\r\n" : "\n"; List originalModels = []; + List observedModels = []; int replacements = 0; using (StreamReader reader = new( @@ -593,6 +623,15 @@ private async Task TryRewriteRolloutModelFieldAsync( OriginalModels = lineResult.OriginalModels }); } + if (lineResult.OriginalModels.Count > 0) + { + observedModels.Add(new TurnContextModelBackup + { + LineIndex = lineIndex, + OriginalModel = lineResult.OriginalModels[0], + OriginalModels = lineResult.OriginalModels + }); + } if (!firstLine) { await writer.WriteAsync(separator); @@ -607,6 +646,13 @@ private async Task TryRewriteRolloutModelFieldAsync( } } + if (!ModelSnapshotsMatch(change.OriginalTurnContextModels, observedModels)) + { + File.Delete(tempPath); + throw new InvalidOperationException( + $"Rollout file changed after it was scanned; refusing to rewrite newly appended turn_context records: {change.Path}"); + } + if (replacements == 0) { File.Delete(tempPath); @@ -630,6 +676,33 @@ private async Task TryRewriteRolloutModelFieldAsync( } } + private static bool ModelSnapshotsMatch( + IReadOnlyList expected, + IReadOnlyList observed) + { + if (expected.Count != observed.Count) + { + return false; + } + for (int index = 0; index < expected.Count; index += 1) + { + TurnContextModelBackup left = expected[index]; + TurnContextModelBackup right = observed[index]; + IReadOnlyList leftModels = left.OriginalModels.Count > 0 + ? left.OriginalModels + : [left.OriginalModel]; + IReadOnlyList rightModels = right.OriginalModels.Count > 0 + ? right.OriginalModels + : [right.OriginalModel]; + if (left.LineIndex != right.LineIndex + || !leftModels.SequenceEqual(rightModels, StringComparer.Ordinal)) + { + return false; + } + } + return true; + } + private static ModelLineRewrite RewriteTurnContextModelInLine(string line, string newModel) { if (!TurnContextTypeRegex.IsMatch(line)) diff --git a/desktop/CodexProviderSync.Core/SqliteStateService.cs b/desktop/CodexProviderSync.Core/SqliteStateService.cs index a653c27..c0e9aed 100644 --- a/desktop/CodexProviderSync.Core/SqliteStateService.cs +++ b/desktop/CodexProviderSync.Core/SqliteStateService.cs @@ -1,7 +1,30 @@ +using System.Buffers.Binary; using Microsoft.Data.Sqlite; namespace CodexProviderSync.Core; +public sealed record SqliteFileMetadata( + string JournalMode, + long PageSize, + long UserVersion, + long ApplicationId); + +public sealed record SqliteOnlineBackupPreservation( + bool JournalMode, + bool PageSize, + bool UserVersion, + bool ApplicationId); + +public sealed record SqliteOnlineBackupMetadata( + SqliteFileMetadata Source, + SqliteFileMetadata Backup, + SqliteOnlineBackupPreservation Preserved); + +public sealed record SqliteOnlineBackupResult( + bool DatabasePresent, + string? BackupPath, + SqliteOnlineBackupMetadata? Metadata); + public sealed class SqliteStateService { private const int DefaultBusyTimeoutMs = 5000; @@ -267,6 +290,7 @@ public async Task AssertSqliteWritableAsync(CodexStorageLayout storage, in { await connection.OpenAsync(); await SetBusyTimeoutAsync(connection, busyTimeoutMs); + await ConfigureSqliteWriteDurabilityAsync(connection); await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE"); await ExecuteNonQueryAsync(connection, "ROLLBACK"); return true; @@ -324,6 +348,7 @@ public async Task AssertSqliteWritableAsync(CodexStorageLayout storage, in { await connection.OpenAsync(); await SetBusyTimeoutAsync(connection, busyTimeoutMs); + await ConfigureSqliteWriteDurabilityAsync(connection); await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE"); transactionOpen = true; @@ -427,6 +452,188 @@ UPDATE threads } } + /// + /// Creates one consistent SQLite main database via SQLite's online-backup + /// API. WAL/SHM sidecars are neither copied nor emitted. + /// + public async Task CreateSqliteOnlineBackupAsync( + CodexStorageLayout storage, + string destinationPath, + int? busyTimeoutMs = null) + { + string? dbPath = ExistingStateDbPath(storage); + if (dbPath is null) + { + return new SqliteOnlineBackupResult(false, null, null); + } + + string fullSourcePath = Path.GetFullPath(dbPath); + string fullDestinationPath = Path.GetFullPath(destinationPath); + StringComparison pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(fullSourcePath, fullDestinationPath, pathComparison)) + { + throw new InvalidOperationException( + "SQLite online backup destination must differ from the source database."); + } + if (File.Exists(fullDestinationPath)) + { + throw new IOException("SQLite online backup destination already exists."); + } + string? destinationDirectory = Path.GetDirectoryName(fullDestinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + throw new InvalidOperationException("Cannot resolve SQLite online backup directory."); + } + Directory.CreateDirectory(destinationDirectory); + + try + { + SqliteFileMetadata sourceMetadata; + await using (SqliteConnection source = OpenConnection( + fullSourcePath, + SqliteOpenMode.ReadWrite)) + { + await source.OpenAsync(); + await SetBusyTimeoutAsync(source, busyTimeoutMs); + sourceMetadata = await ReadSqliteConnectionMetadataAsync(source); + await using SqliteConnection destination = OpenConnection( + fullDestinationPath, + SqliteOpenMode.ReadWriteCreate); + await destination.OpenAsync(); + source.BackupDatabase(destination); + } + + await using (FileStream stream = new( + fullDestinationPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.Read, + 4096, + FileOptions.WriteThrough)) + { + await stream.FlushAsync(); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(fullDestinationPath + "-wal") + || File.Exists(fullDestinationPath + "-shm")) + { + throw new InvalidOperationException( + "SQLite online backup unexpectedly emitted a WAL/SHM sidecar."); + } + + SqliteFileMetadata backupMetadata = await ReadStandaloneSqliteHeaderMetadataAsync( + fullDestinationPath); + SqliteOnlineBackupPreservation preserved = new( + sourceMetadata.JournalMode == backupMetadata.JournalMode, + sourceMetadata.PageSize == backupMetadata.PageSize, + sourceMetadata.UserVersion == backupMetadata.UserVersion, + sourceMetadata.ApplicationId == backupMetadata.ApplicationId); + return new SqliteOnlineBackupResult( + true, + fullDestinationPath, + new SqliteOnlineBackupMetadata(sourceMetadata, backupMetadata, preserved)); + } + catch (Exception error) + { + TryDeleteSqliteBackupArtifact(fullDestinationPath); + TryDeleteSqliteBackupArtifact(fullDestinationPath + "-wal"); + TryDeleteSqliteBackupArtifact(fullDestinationPath + "-shm"); + throw WrapSqliteMalformedError( + WrapSqliteBusyError(error, "create a consistent SQLite online backup"), + "create a consistent SQLite online backup"); + } + } + + public async Task CreateSqliteOnlineBackupAsync( + string codexHome, + string destinationPath, + int? busyTimeoutMs = null) + { + return await CreateSqliteOnlineBackupAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + destinationPath, + busyTimeoutMs); + } + + internal static async Task ConfigureSqliteWriteDurabilityAsync( + SqliteConnection connection) + { + await ExecuteNonQueryAsync(connection, "PRAGMA synchronous = FULL"); + object? rawValue = await ExecuteScalarAsync(connection, "PRAGMA synchronous"); + int synchronous = Convert.ToInt32(rawValue); + if (synchronous != 2) + { + throw new InvalidOperationException( + $"Unable to configure SQLite synchronous=FULL (reported {synchronous})."); + } + return synchronous; + } + + private static async Task ReadSqliteConnectionMetadataAsync( + SqliteConnection connection) + { + string journalMode = Convert.ToString( + await ExecuteScalarAsync(connection, "PRAGMA journal_mode"))?.ToLowerInvariant() ?? ""; + long pageSize = Convert.ToInt64(await ExecuteScalarAsync(connection, "PRAGMA page_size")); + long userVersion = Convert.ToInt64(await ExecuteScalarAsync(connection, "PRAGMA user_version")); + long applicationId = Convert.ToInt64(await ExecuteScalarAsync(connection, "PRAGMA application_id")); + return new SqliteFileMetadata(journalMode, pageSize, userVersion, applicationId); + } + + private static async Task ReadStandaloneSqliteHeaderMetadataAsync( + string dbPath) + { + byte[] header = new byte[100]; + await using FileStream stream = new( + dbPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await stream.ReadExactlyAsync(header); + ReadOnlySpan magic = "SQLite format 3\0"u8; + if (!header.AsSpan(0, magic.Length).SequenceEqual(magic)) + { + throw new InvalidOperationException( + "SQLite online backup did not produce a valid standalone database header."); + } + int rawPageSize = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(16, 2)); + string journalMode = header[18] == 2 && header[19] == 2 ? "wal" : "delete"; + return new SqliteFileMetadata( + journalMode, + rawPageSize == 1 ? 65536 : rawPageSize, + BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(60, 4)), + BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(68, 4))); + } + + private static async Task ExecuteScalarAsync( + SqliteConnection connection, + string commandText) + { + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = commandText; + return await command.ExecuteScalarAsync(); + } + + private static void TryDeleteSqliteBackupArtifact(string filePath) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch + { + // Cleanup must not hide the original backup failure. + } + } + private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode) { SqliteConnectionStringBuilder builder = new() diff --git a/desktop/CodexProviderSync.Core/TransactionJournalService.cs b/desktop/CodexProviderSync.Core/TransactionJournalService.cs index 730d725..6f2a94c 100644 --- a/desktop/CodexProviderSync.Core/TransactionJournalService.cs +++ b/desktop/CodexProviderSync.Core/TransactionJournalService.cs @@ -10,6 +10,7 @@ internal sealed class FileTransactionJournal private readonly string _filePath; private readonly string _operationId; + private readonly SemaphoreSlim _appendGate = new(1, 1); private int _sequence; private FileTransactionJournal(string filePath, string operationId, int sequence = 0) @@ -19,6 +20,12 @@ private FileTransactionJournal(string filePath, string operationId, int sequence _sequence = sequence; } + internal string FilePath => _filePath; + + internal Func? AppendFaultInjector { get; set; } + + internal Task ReadCurrentInfoAsync() => ReadInfoAsync(_filePath); + internal static async Task CreateAsync( string backupDir, string codexHome, @@ -37,8 +44,8 @@ internal static async Task CreateAsync( ["targetProvider"] = targetProvider, ["potentialTargets"] = potentialTargets .Select(Path.GetFullPath) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) + .Distinct(PathComparer) + .Order(PathComparer) .ToArray() }); return journal; @@ -60,13 +67,21 @@ internal Task AppliedAsync(string kind, string targetPath) => AppendAsync( ["targetPath"] = Path.GetFullPath(targetPath) }); - internal Task CommittedAsync() => AppendAsync("committed"); + internal Task SkippedAsync(string kind, string targetPath) => AppendAsync( + "skipped", + new Dictionary + { + ["kind"] = kind, + ["targetPath"] = Path.GetFullPath(targetPath) + }); + + internal Task CommittedAsync() => AppendTerminalAsync("committed"); internal Task RollingBackAsync(Exception originalError) => AppendAsync( "rollingBack", new Dictionary { ["originalError"] = originalError.Message }); - internal Task RolledBackAsync() => AppendAsync("rolledBack"); + internal Task RolledBackAsync() => AppendTerminalAsync("rolledBack"); internal Task RecoveryRequiredAsync(Exception originalError, IReadOnlyList rollbackErrors) => AppendAsync( "recoveryRequired", @@ -78,33 +93,254 @@ internal Task RecoveryRequiredAsync(Exception originalError, IReadOnlyList? details = null) { - Dictionary value = new() + await _appendGate.WaitAsync(); + try { - ["protocolVersion"] = 1, - ["operationId"] = _operationId, - ["sequence"] = ++_sequence, - ["state"] = state, - ["recordedAt"] = DateTimeOffset.UtcNow - }; - if (details is not null) - { - foreach ((string key, object? detail) in details) - { - value[key] = detail; - } - } - - byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOptions) + Environment.NewLine); - await using FileStream stream = new( - _filePath, - FileMode.Append, - FileAccess.Write, - FileShare.Read, - 4096, - FileOptions.Asynchronous | FileOptions.WriteThrough); - await stream.WriteAsync(bytes); - await stream.FlushAsync(); - stream.Flush(flushToDisk: true); + PendingTransactionInfo? before = null; + if (File.Exists(_filePath) && new FileInfo(_filePath).Length > 0) + { + before = await ReadInfoAsync(_filePath); + if (before.InvalidTail) + { + throw new InvalidOperationException( + state == "committed" + ? $"Transaction journal is invalid and cannot commit until recovery: {_filePath}" + : $"Transaction journal is invalid and requires recovery before append: {_filePath}"); + } + if (!string.Equals(before.OperationId, _operationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Transaction journal operationId changed before append: {_filePath}"); + } + _sequence = before.LastSequence; + } + else if (_sequence != 0) + { + throw new InvalidOperationException( + $"Transaction journal disappeared after it was created: {_filePath}"); + } + + ValidateAppendTransition(before, state, details); + int nextSequence = _sequence + 1; + Dictionary value = new() + { + ["protocolVersion"] = 1, + ["operationId"] = _operationId, + ["sequence"] = nextSequence, + ["state"] = state, + ["recordedAt"] = DateTimeOffset.UtcNow + }; + if (details is not null) + { + foreach ((string key, object? detail) in details) + { + value[key] = detail; + } + } + + // The journal is a cross-runtime recovery protocol. Always use LF + // so Node and .NET produce byte-compatible JSONL on every platform. + byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOptions) + "\n"); + try + { + if (AppendFaultInjector is not null) + { + await AppendFaultInjector("before-write", state); + } + await using (FileStream stream = new( + _filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes); + if (AppendFaultInjector is not null) + { + await AppendFaultInjector("after-write-before-flush", state); + } + await stream.FlushAsync(); + stream.Flush(flushToDisk: true); + } + if (AppendFaultInjector is not null) + { + await AppendFaultInjector("after-flush-before-verify", state); + } + } + catch + { + await ReconcileSequenceAfterAppendAttemptAsync(); + throw; + } + + PendingTransactionInfo after = await ReadInfoAsync(_filePath); + _sequence = after.LastSequence; + if (after.InvalidTail + || !string.Equals(after.OperationId, _operationId, StringComparison.Ordinal) + || after.LastSequence != nextSequence + || !string.Equals(after.State, state, StringComparison.Ordinal) + || (state is "committed" or "rolledBack") != after.Terminal) + { + throw new InvalidOperationException( + $"Transaction journal append could not be verified after writing {state}: {_filePath}"); + } + } + finally + { + _appendGate.Release(); + } + } + + private async Task AppendTerminalAsync(string state) + { + try + { + await AppendAsync(state); + } + catch + { + // A flush API may report failure after the complete terminal record + // reached the durable/readable journal. Treat that exact terminal + // event as authoritative so the coordinator never compensates a + // transaction after it was already recorded committed. + PendingTransactionInfo current = await ReadInfoAsync(_filePath); + if (!current.InvalidTail + && current.Terminal + && string.Equals(current.OperationId, _operationId, StringComparison.Ordinal) + && string.Equals(current.State, state, StringComparison.Ordinal)) + { + _sequence = current.LastSequence; + return; + } + throw; + } + } + + private async Task ReconcileSequenceAfterAppendAttemptAsync() + { + if (!File.Exists(_filePath)) + { + _sequence = 0; + return; + } + try + { + PendingTransactionInfo current = await ReadInfoAsync(_filePath); + if (string.Equals(current.OperationId, _operationId, StringComparison.Ordinal)) + { + _sequence = current.LastSequence; + } + } + catch + { + // Preserve the original append failure. The next append performs a + // full journal validation and will fail closed if recovery is needed. + } + } + + private static void ValidateAppendTransition( + PendingTransactionInfo? current, + string nextState, + IReadOnlyDictionary? details) + { + if (current is null) + { + if (!string.Equals(nextState, "prepared", StringComparison.Ordinal)) + { + throw new InvalidOperationException("A transaction journal must begin with prepared."); + } + return; + } + + if (current.Terminal || current.State is "committed" or "rolledBack") + { + throw new InvalidOperationException( + $"Transaction journal is already terminal ({current.State}) and cannot append {nextState}: {current.JournalPath}"); + } + if (string.Equals(nextState, "prepared", StringComparison.Ordinal)) + { + throw new InvalidOperationException("A transaction journal cannot contain a second prepared record."); + } + if (current.State == "rollingBack" && nextState is not ("rolledBack" or "recoveryRequired")) + { + throw new InvalidOperationException( + $"Transaction journal cannot append {nextState} after rollingBack."); + } + if (current.State == "recoveryRequired" && nextState != "rolledBack") + { + throw new InvalidOperationException( + $"Transaction journal cannot append {nextState} after recoveryRequired."); + } + if (nextState == "rolledBack" && current.State is not ("rollingBack" or "recoveryRequired")) + { + throw new InvalidOperationException( + $"Transaction journal cannot append rolledBack after {current.State}."); + } + if (nextState == "committed") + { + if (current.State is "rollingBack" or "recoveryRequired" + || current.AffectedTargets.Any(static target => target.State == "applying")) + { + throw new InvalidOperationException( + $"Transaction journal cannot commit while rollback or target application is unresolved: {current.JournalPath}"); + } + return; + } + if (nextState is "rollingBack" or "recoveryRequired" or "rolledBack") + { + return; + } + if (nextState is not ("applying" or "applied" or "skipped")) + { + throw new InvalidOperationException($"Unknown transaction journal state: {nextState}"); + } + if (current.State is "rollingBack" or "recoveryRequired") + { + throw new InvalidOperationException( + $"Transaction journal cannot mutate targets after {current.State}."); + } + + string kind = ReadRequiredDetail(details, "kind"); + string targetPathValue = ReadRequiredDetail(details, "targetPath"); + if (kind is not ("config" or "rollout" or "globalState" or "sqlite") + || !Path.IsPathFullyQualified(targetPathValue)) + { + throw new InvalidOperationException("Transaction target details are invalid."); + } + string targetPath = Path.GetFullPath(targetPathValue); + if (!current.PotentialTargets.Contains(targetPath, PathComparer)) + { + throw new InvalidOperationException( + $"Transaction target was not declared by prepared: {targetPath}"); + } + string keyKind = kind; + TransactionTargetInfo? affected = current.AffectedTargets.FirstOrDefault(target => + string.Equals(target.Kind, keyKind, StringComparison.Ordinal) + && PathComparer.Equals(target.TargetPath, targetPath)); + if (nextState == "applying" && affected is not null) + { + throw new InvalidOperationException( + $"Transaction target is already being tracked: {targetPath}"); + } + if (nextState is "applied" or "skipped" + && affected?.State != "applying") + { + throw new InvalidOperationException( + $"Transaction target must be applying before {nextState}: {targetPath}"); + } + } + + private static string ReadRequiredDetail( + IReadOnlyDictionary? details, + string name) + { + return details is not null + && details.TryGetValue(name, out object? value) + && value is string text + && !string.IsNullOrWhiteSpace(text) + ? text + : throw new InvalidOperationException($"Transaction journal detail {name} is required."); } internal static async Task> FindPendingAsync(string codexHome) @@ -147,7 +383,10 @@ internal static async Task AssertNoPendingAsync(string codexHome) pending); } - internal static async Task MarkBackupRolledBackAsync(string backupDir) + internal static async Task MarkBackupRolledBackAsync( + string backupDir, + string codexHome, + string targetProvider) { string journalPath = Path.Combine(Path.GetFullPath(backupDir), FileName); if (!File.Exists(journalPath)) @@ -155,26 +394,90 @@ internal static async Task MarkBackupRolledBackAsync(string backupDir) return; } - PendingTransactionInfo info = await ReadInfoAsync(journalPath); + JournalReadResult readResult = await ReadJournalAsync(journalPath); + PendingTransactionInfo info = readResult.Info; if (info.Terminal) { return; } + if (info.InvalidTail) + { + string invalidArchivePath = Path.Combine( + Path.GetDirectoryName(journalPath)!, + $"transaction-journal.invalid.{DateTimeOffset.UtcNow:yyyyMMdd'T'HHmmssfff'Z'}.{Guid.NewGuid():N}.jsonl"); + await AtomicFile.CopyAsync(journalPath, invalidArchivePath, overwrite: false); + + if (string.IsNullOrWhiteSpace(info.OperationId) || readResult.ValidLines.Count == 0) + { + await AtomicFile.WriteAllTextAsync(journalPath, string.Empty); + FileTransactionJournal replacement = await CreateAsync( + Path.GetDirectoryName(journalPath)!, + codexHome, + targetProvider, + []); + await replacement.RollingBackAsync( + new InvalidOperationException("Explicit managed-backup restore repaired an unreadable journal")); + await replacement.RolledBackAsync(); + await AssertRolledBackTerminalAsync(replacement); + return; + } + + List validPrefix = [.. readResult.ValidLines]; + if (info.LastValidState is "committed" or "rolledBack") + { + validPrefix.RemoveAt(validPrefix.Count - 1); + } + string normalizedPrefix = string.Join("\n", validPrefix) + "\n"; + await AtomicFile.WriteAllTextAsync(journalPath, normalizedPrefix); + info = await ReadInfoAsync(journalPath); + } + FileTransactionJournal journal = new( journalPath, - info.OperationId ?? Guid.NewGuid().ToString("D"), + info.OperationId!, info.LastSequence); + if (info.State is not ("rollingBack" or "recoveryRequired")) + { + await journal.RollingBackAsync(new InvalidOperationException("Explicit managed-backup restore")); + } await journal.RolledBackAsync(); + await AssertRolledBackTerminalAsync(journal); } - private static async Task ReadInfoAsync(string journalPath) + private static async Task AssertRolledBackTerminalAsync(FileTransactionJournal journal) { + PendingTransactionInfo verified = await journal.ReadCurrentInfoAsync(); + if (verified.InvalidTail + || !verified.Terminal + || !string.Equals(verified.State, "rolledBack", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Transaction journal repair did not reach a verified rolledBack state: {journal.FilePath}"); + } + } + + internal static async Task ReadInfoAsync(string journalPath) + { + return (await ReadJournalAsync(journalPath)).Info; + } + + private static async Task ReadJournalAsync(string journalPath) + { + byte[] journalBytes = await File.ReadAllBytesAsync(journalPath); + bool missingTerminalLf = journalBytes.Length > 0 && journalBytes[^1] != (byte)'\n'; + string journalText = Encoding.UTF8.GetString(journalBytes); string? operationId = null; int lastSequence = 0; string state = "recoveryRequired"; + string lastValidState = "none"; bool invalidTail = false; - foreach (string line in await File.ReadAllLinesAsync(journalPath)) + bool sawRecord = false; + bool sawTerminal = false; + List validLines = []; + List potentialTargets = []; + Dictionary affectedTargets = new(PathComparer); + foreach (string line in journalText.Split('\n')) { if (string.IsNullOrWhiteSpace(line)) { @@ -184,17 +487,149 @@ private static async Task ReadInfoAsync(string journalPa { using JsonDocument document = JsonDocument.Parse(line); JsonElement root = document.RootElement; - operationId ??= root.TryGetProperty("operationId", out JsonElement operation) + string? recordOperationId = root.TryGetProperty("operationId", out JsonElement operation) ? operation.GetString() : null; - lastSequence = root.TryGetProperty("sequence", out JsonElement sequence) - ? sequence.GetInt32() - : lastSequence; - state = root.TryGetProperty("state", out JsonElement stateValue) - ? stateValue.GetString() ?? state - : state; + int recordSequence = root.TryGetProperty("sequence", out JsonElement sequence) + && sequence.TryGetInt32(out int parsedSequence) + ? parsedSequence + : -1; + string? recordState = root.TryGetProperty("state", out JsonElement stateValue) + ? stateValue.GetString() + : null; + int protocolVersion = root.TryGetProperty("protocolVersion", out JsonElement protocol) + && protocol.TryGetInt32(out int parsedProtocol) + ? parsedProtocol + : -1; + + if (protocolVersion != 1 + || string.IsNullOrWhiteSpace(recordOperationId) + || !Guid.TryParse(recordOperationId, out _) + || recordSequence != lastSequence + 1 + || string.IsNullOrWhiteSpace(recordState) + || sawTerminal + || (operationId is not null + && !string.Equals(operationId, recordOperationId, StringComparison.Ordinal))) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + if (sawRecord + && (recordState == "prepared" + || (state == "rollingBack" && recordState is not ("rolledBack" or "recoveryRequired")) + || (state == "recoveryRequired" && recordState != "rolledBack") + || (recordState == "rolledBack" && state is not ("rollingBack" or "recoveryRequired")))) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + if (!sawRecord) + { + if (!string.Equals(recordState, "prepared", StringComparison.Ordinal) + || recordSequence != 1) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + operationId = recordOperationId; + if (!root.TryGetProperty("potentialTargets", out JsonElement targets) + || targets.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Prepared journal record must declare potentialTargets."); + } + + HashSet declaredTargets = new(PathComparer); + foreach (JsonElement target in targets.EnumerateArray()) + { + string? targetPath = target.GetString(); + if (string.IsNullOrWhiteSpace(targetPath) + || !Path.IsPathFullyQualified(targetPath)) + { + throw new InvalidOperationException( + "Prepared journal potentialTargets must contain absolute paths."); + } + string fullTargetPath = Path.GetFullPath(targetPath); + if (!declaredTargets.Add(fullTargetPath)) + { + throw new InvalidOperationException( + $"Prepared journal contains a duplicate potential target: {fullTargetPath}"); + } + potentialTargets.Add(fullTargetPath); + } + } + else if (recordState is "applying" or "applied" or "skipped") + { + string? kind = root.TryGetProperty("kind", out JsonElement kindValue) + ? kindValue.GetString() + : null; + string? targetPath = root.TryGetProperty("targetPath", out JsonElement targetValue) + ? targetValue.GetString() + : null; + if (kind is not ("config" or "rollout" or "globalState" or "sqlite") + || string.IsNullOrWhiteSpace(targetPath) + || !Path.IsPathFullyQualified(targetPath)) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + string fullTargetPath = Path.GetFullPath(targetPath); + if (!potentialTargets.Contains(fullTargetPath, PathComparer)) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + string key = kind + "\0" + fullTargetPath; + if (recordState is "applied" or "skipped" + && (!affectedTargets.TryGetValue(key, out TransactionTargetInfo? applying) + || applying.State != "applying")) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + if (recordState == "skipped") + { + affectedTargets.Remove(key); + } + else + { + affectedTargets[key] = new TransactionTargetInfo(kind, fullTargetPath, recordState); + } + } + else if (recordState is not ( + "prepared" or "committed" or "rollingBack" or "rolledBack" or "recoveryRequired")) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + + if (recordState == "committed" + && affectedTargets.Values.Any(static target => target.State == "applying")) + { + invalidTail = true; + state = "recoveryRequired"; + break; + } + sawRecord = true; + lastSequence = recordSequence; + state = recordState; + lastValidState = recordState; + sawTerminal = recordState is "committed" or "rolledBack"; + validLines.Add(line); } - catch (JsonException) + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or NotSupportedException) { invalidTail = true; state = "recoveryRequired"; @@ -202,18 +637,41 @@ private static async Task ReadInfoAsync(string journalPa } } + if (!sawRecord) + { + invalidTail = true; + state = "recoveryRequired"; + } + else if (missingTerminalLf) + { + invalidTail = true; + state = "recoveryRequired"; + } + bool terminal = !invalidTail && state is "committed" or "rolledBack"; - return new PendingTransactionInfo( + PendingTransactionInfo info = new( journalPath, Path.GetDirectoryName(journalPath)!, operationId, lastSequence, state, terminal, - invalidTail); + invalidTail, + lastValidState, + potentialTargets.Distinct(PathComparer).Order(PathComparer).ToArray(), + affectedTargets.Values.ToArray()); + return new JournalReadResult(info, validLines); } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private sealed record JournalReadResult(PendingTransactionInfo Info, IReadOnlyList ValidLines); } +internal sealed record TransactionTargetInfo(string Kind, string TargetPath, string State); + internal sealed record PendingTransactionInfo( string JournalPath, string BackupDir, @@ -221,7 +679,10 @@ internal sealed record PendingTransactionInfo( int LastSequence, string State, bool Terminal, - bool InvalidTail); + bool InvalidTail, + string LastValidState, + IReadOnlyList PotentialTargets, + IReadOnlyList AffectedTargets); public sealed class RecoveryRequiredException : InvalidOperationException { diff --git a/src/atomic-file.js b/src/atomic-file.js index ee4ac7d..4631aa9 100644 --- a/src/atomic-file.js +++ b/src/atomic-file.js @@ -2,6 +2,36 @@ import fs from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; +const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set([ + "EACCES", + "EBADF", + "EISDIR", + "EINVAL", + "ENOTSUP", + "EPERM" +]); + +export async function syncDirectory( + directoryPath, + { fsImpl = fs, platform = process.platform } = {} +) { + let handle; + try { + handle = await fsImpl.open(directoryPath, "r"); + await handle.sync(); + } catch (error) { + // Windows does not consistently allow directories to be opened and + // flushed through the Node fs API. The staged file itself is still + // flushed before rename; directory fsync remains mandatory wherever the + // host supports it. + if (platform !== "win32" || !UNSUPPORTED_DIRECTORY_SYNC_CODES.has(error?.code)) { + throw error; + } + } finally { + await handle?.close(); + } +} + export async function writeFileAtomic( filePath, content, @@ -34,9 +64,16 @@ export async function writeFileAtomic( } if (originalMode !== null) { await fs.chmod(tempPath, originalMode); + const modeHandle = await fs.open(tempPath, "r+"); + try { + await modeHandle.sync(); + } finally { + await modeHandle.close(); + } } await faultInjector?.({ point: "before_atomic_replace", filePath: fullPath, tempPath }); await fs.rename(tempPath, fullPath); + await syncDirectory(directory); } catch (error) { await fs.rm(tempPath, { force: true }).catch(() => {}); throw error; diff --git a/src/backup.js b/src/backup.js index 0b85aa7..a871857 100644 --- a/src/backup.js +++ b/src/backup.js @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import { BACKUP_NAMESPACE, @@ -9,14 +10,20 @@ import { GLOBAL_STATE_BACKUP_FILE_BASENAME, GLOBAL_STATE_FILE_BASENAME } from "./constants.js"; -import { assertSessionFilesWritable, restoreSessionChanges } from "./session-files.js"; +import { restoreSessionChanges } from "./session-files.js"; import { assertSqliteWritable, detectStateDb } from "./sqlite-state.js"; import { assertSqliteAccessSupported, resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; -import { findPendingTransactions } from "./transaction-journal.js"; +import { + TRANSACTION_JOURNAL_BASENAME, + findPendingTransactions, + getStartedJournalTargets, + readTransactionJournal +} from "./transaction-journal.js"; +import { syncDirectory, writeFileAtomic } from "./atomic-file.js"; function timestampSlug(date = new Date()) { return date.toISOString().replaceAll(":", "").replaceAll("-", "").replace(".", ""); @@ -25,14 +32,51 @@ function timestampSlug(date = new Date()) { async function copyIfPresent(sourcePath, destinationPath) { try { await fs.access(sourcePath); - } catch { - return false; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; } - await fs.mkdir(path.dirname(destinationPath), { recursive: true }); - await fs.copyFile(sourcePath, destinationPath); + await copyFileAtomic(sourcePath, destinationPath); return true; } +async function copyFileAtomic(sourcePath, destinationPath) { + const fullDestination = path.resolve(destinationPath); + const directory = path.dirname(fullDestination); + const tempPath = path.join( + directory, + `.${path.basename(fullDestination)}.provider-sync.${process.pid}.${randomUUID()}.tmp` + ); + await fs.mkdir(directory, { recursive: true }); + try { + const sourceStat = await fs.stat(sourcePath); + await fs.copyFile(sourcePath, tempPath); + await fs.chmod(tempPath, sourceStat.mode); + const handle = await fs.open(tempPath, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(tempPath, fullDestination); + await syncDirectory(directory); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } +} + +async function syncFile(filePath) { + const handle = await fs.open(filePath, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + function restoreDbTargetPath(codexHome, relativePath) { if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) { throw new Error(`Invalid database backup path: ${relativePath}`); @@ -62,6 +106,66 @@ function storagePathsEqual(left, right) { : normalizedLeft === normalizedRight; } +function pathComparisonKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function pathIsWithin(root, target) { + const relativePath = path.relative(root, target); + return relativePath !== "" + && !relativePath.startsWith(`..${path.sep}`) + && relativePath !== ".." + && !path.isAbsolute(relativePath); +} + +async function assertNoLinkedPathSegments(root, target) { + const relativePath = path.relative(root, target); + const segments = relativePath.split(path.sep).filter(Boolean); + let current = root; + for (const segment of [null, ...segments]) { + if (segment !== null) { + current = path.join(current, segment); + } + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Backup session target traverses a symbolic link or reparse point: ${current}`); + } + } +} + +async function validateSessionManifestEntries(entries, codexHome) { + const roots = ["sessions", "archived_sessions"].map((name) => path.resolve(codexHome, name)); + const seen = new Set(); + for (const entry of entries) { + if (!entry || typeof entry.path !== "string" || !path.isAbsolute(entry.path)) { + throw new Error("Backup session manifest contains a missing or non-absolute rollout path."); + } + const target = path.resolve(entry.path); + const lexicalRoot = roots.find((root) => pathIsWithin(root, target)); + if (!lexicalRoot || !/^rollout-.*\.jsonl$/i.test(path.basename(target))) { + throw new Error(`Backup session target is outside the allowed rollout roots: ${entry.path}`); + } + const key = pathComparisonKey(target); + if (seen.has(key)) { + throw new Error(`Backup session manifest contains a duplicate rollout target: ${entry.path}`); + } + seen.add(key); + await assertNoLinkedPathSegments(lexicalRoot, target); + const [canonicalRoot, canonicalTarget] = await Promise.all([ + fs.realpath(lexicalRoot), + fs.realpath(target) + ]); + if (!pathIsWithin(canonicalRoot, canonicalTarget)) { + throw new Error(`Backup session target resolves outside the allowed rollout roots: ${entry.path}`); + } + const stat = await fs.stat(canonicalTarget); + if (!stat.isFile()) { + throw new Error(`Backup session target is not a regular file: ${entry.path}`); + } + } +} + function resolveRestoreSqliteHome(storage, metadata, stateDb) { if (stateDb) { return path.dirname(stateDb.path); @@ -82,14 +186,49 @@ async function removeIfPresent(targetPath) { } async function backupGlobalStateFiles(codexHome, backupDir) { + const presence = {}; for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) { - await copyIfPresent(path.join(codexHome, fileName), path.join(backupDir, fileName)); + presence[fileName] = await copyIfPresent( + path.join(codexHome, fileName), + path.join(backupDir, fileName) + ); } + return presence; } -export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome) { +export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome, options = {}) { + let metadata = null; + try { + metadata = JSON.parse(await fs.readFile(path.join(backupDir, "metadata.json"), "utf8")); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const selectedTargets = options.targetPaths + ? new Set(options.targetPaths.map(pathComparisonKey)) + : null; for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) { - await copyIfPresent(path.join(backupDir, fileName), path.join(codexHome, fileName)); + const targetPath = path.join(codexHome, fileName); + if (selectedTargets && !selectedTargets.has(pathComparisonKey(targetPath))) { + continue; + } + const sourcePath = path.join(backupDir, fileName); + const originalPresent = metadata?.globalStateFiles?.[fileName]; + if (originalPresent === true) { + try { + await fs.access(sourcePath); + } catch { + throw new Error(`Backup metadata says ${fileName} was present, but its backup copy is missing.`); + } + await copyFileAtomic(sourcePath, targetPath); + } else if (originalPresent === false) { + await removeIfPresent(targetPath); + } else { + // Legacy metadata did not record absence, so preserve its copy-only + // behavior instead of deleting a file we cannot classify safely. + await copyIfPresent(sourcePath, targetPath); + } } } @@ -134,11 +273,15 @@ export async function createBackup({ } if (configBackupText !== undefined) { - await fs.writeFile(path.join(backupDir, "config.toml"), configBackupText, "utf8"); + const configBackupPath = path.join(backupDir, "config.toml"); + await writeFileAtomic(configBackupPath, configBackupText, "utf8"); + const configStat = await fs.stat(configPath); + await fs.chmod(configBackupPath, configStat.mode); + await syncFile(configBackupPath); } else { await copyIfPresent(configPath, path.join(backupDir, "config.toml")); } - await backupGlobalStateFiles(codexHome, backupDir); + const globalStateFiles = await backupGlobalStateFiles(codexHome, backupDir); const sessionManifest = { version: 2, @@ -146,6 +289,11 @@ export async function createBackup({ codexHome, targetProvider, createdAt: new Date().toISOString(), + // Keep the full pre-mutation source of truth for the lifetime of the + // backup. appliedPaths is only a compatibility hint for backups without a + // transaction journal; journal `applying`/`applied` events decide which + // entries a transactional restore must compensate. + appliedPaths: null, files: sessionChanges.map((change) => ({ path: change.path, originalFirstLine: change.originalFirstLine, @@ -162,13 +310,13 @@ export async function createBackup({ modelOnlyChange: Boolean(change.modelOnlyChange) })) }; - await fs.writeFile( + await writeFileAtomic( path.join(backupDir, "session-meta-backup.json"), JSON.stringify(sessionManifest, null, 2), "utf8" ); - await fs.writeFile( + await writeFileAtomic( path.join(backupDir, "metadata.json"), JSON.stringify( { @@ -180,6 +328,7 @@ export async function createBackup({ createdAt: sessionManifest.createdAt, dbFiles: copiedDbFiles, sqliteDbFiles: copiedSqliteDbFiles, + globalStateFiles, changedSessionFiles: sessionChanges.length }, null, @@ -191,7 +340,7 @@ export async function createBackup({ return backupDir; } -export async function updateSessionBackupManifest(backupDir, sessionChanges) { +export async function updateSessionBackupManifest(backupDir, sessionChanges, options = {}) { const manifestPath = path.join(backupDir, "session-meta-backup.json"); const metadataPath = path.join(backupDir, "metadata.json"); const sessionManifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); @@ -203,18 +352,36 @@ export async function updateSessionBackupManifest(backupDir, sessionChanges) { sessionManifest.version = 2; } - sessionManifest.files = sessionChanges.map((change) => ({ - path: change.path, - originalFirstLine: change.originalFirstLine, - originalSeparator: change.originalSeparator, - originalMtimeMs: change.originalMtimeMs, - originalTurnContextModels: change.originalTurnContextModels ?? [], - modelOnlyChange: Boolean(change.modelOnlyChange) - })); + const filesByPath = new Map( + (sessionManifest.files ?? []).map((entry) => [pathComparisonKey(entry.path), entry]) + ); + for (const change of sessionChanges) { + const existing = filesByPath.get(pathComparisonKey(change.path)); + if (!existing) { + throw new Error(`Applied rollout is missing from the immutable backup manifest: ${change.path}`); + } + // Compatibility for callers that constructed a pre-v2 change without a + // scan-time model snapshot. Never remove or replace a full-original entry. + if ((!existing.originalTurnContextModels?.length) + && change.originalTurnContextModels?.length) { + existing.originalTurnContextModels = change.originalTurnContextModels; + } + } + sessionManifest.appliedPaths = sessionChanges.map((change) => path.resolve(change.path)); metadata.changedSessionFiles = sessionChanges.length; - await fs.writeFile(manifestPath, JSON.stringify(sessionManifest, null, 2), "utf8"); - await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + await writeFileAtomic( + manifestPath, + JSON.stringify(sessionManifest, null, 2), + "utf8", + { faultInjector: options.faultInjector } + ); + await writeFileAtomic( + metadataPath, + JSON.stringify(metadata, null, 2), + "utf8", + { faultInjector: options.faultInjector } + ); } export async function getBackupSummary(codexHome) { @@ -239,10 +406,12 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI const backupRoot = defaultBackupRoot(codexHome); const backupDirs = await listManagedBackupDirectories(backupRoot); const pending = await findPendingTransactions(codexHome); - const protectedBackups = new Set(pending.map((transaction) => path.resolve(transaction.backupDir))); + const protectedBackups = new Set( + pending.map((transaction) => pathComparisonKey(path.dirname(transaction.filePath))) + ); const toDelete = backupDirs .slice(keepCount) - .filter((entry) => !protectedBackups.has(path.resolve(entry.fullPath))); + .filter((entry) => !protectedBackups.has(pathComparisonKey(entry.fullPath))); let freedBytes = 0; for (const entry of toDelete) { freedBytes += await getDirectorySize(entry.fullPath); @@ -257,32 +426,162 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI }; } +async function selectSessionRestoreEntries(backupDir, sessionManifest) { + const files = sessionManifest.files ?? []; + const journalPath = path.join(backupDir, TRANSACTION_JOURNAL_BASENAME); + try { + const journal = await readTransactionJournal(journalPath); + // A damaged tail cannot prove that no later target was mutated before its + // journal record became unreadable. Restore the immutable full-original + // manifest for any invalid/empty journal; only a fully valid prefix may + // narrow recovery to its applying/applied targets. + if (journal.invalidTail || journal.events.length === 0) { + return files; + } + if (journal.events.length > 0) { + const startedPaths = new Set( + getStartedJournalTargets(journal, "rollout").map(pathComparisonKey) + ); + return files.filter((entry) => startedPaths.has(pathComparisonKey(entry.path))); + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + if (Array.isArray(sessionManifest.appliedPaths)) { + const applied = new Set(sessionManifest.appliedPaths.map(pathComparisonKey)); + return files.filter((entry) => applied.has(pathComparisonKey(entry.path))); + } + return files; +} + +async function readValidatedBackupMetadata(backupDir, codexHome) { + const metadataPath = path.join(backupDir, "metadata.json"); + const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + if (metadata.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(metadata.version)) { + throw new Error(`Unsupported backup metadata in ${metadataPath}.`); + } + if (typeof metadata.codexHome !== "string" || !storagePathsEqual(metadata.codexHome, codexHome)) { + throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`); + } + return metadata; +} + +async function backupFileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +} + +export async function getBackupRecoveryCoverage(backupDir, storageOrCodexHome) { + const storage = typeof storageOrCodexHome === "string" + ? resolveStorageLayout({ codexHome: storageOrCodexHome, env: {} }) + : storageOrCodexHome; + const codexHome = storage.codexHome; + const metadata = await readValidatedBackupMetadata(backupDir, codexHome); + const databaseFiles = metadata.version >= 2 + ? metadata.sqliteDbFiles + : metadata.dbFiles; + if (!Array.isArray(databaseFiles) + || databaseFiles.some((fileName) => typeof fileName !== "string")) { + throw new Error(`Backup metadata contains an invalid SQLite file manifest: ${path.join(backupDir, "metadata.json")}`); + } + for (const fileName of databaseFiles) { + if (path.isAbsolute(fileName) || fileName.split(/[\\/]/).includes("..")) { + throw new Error(`Invalid SQLite backup path: ${fileName}`); + } + } + + const sessionManifestPath = path.join(backupDir, "session-meta-backup.json"); + const sessionManifest = JSON.parse(await fs.readFile(sessionManifestPath, "utf8")); + if (sessionManifest.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(sessionManifest.version)) { + throw new Error(`Unsupported session backup manifest in ${sessionManifestPath}.`); + } + if (typeof sessionManifest.codexHome !== "string" + || !storagePathsEqual(sessionManifest.codexHome, codexHome)) { + throw new Error(`Session backup was created for ${sessionManifest.codexHome}, not ${codexHome}.`); + } + if (!Array.isArray(sessionManifest.files)) { + throw new Error(`Session backup manifest has an invalid files collection: ${sessionManifestPath}`); + } + await validateSessionManifestEntries(sessionManifest.files, codexHome); + + let globalState = false; + if (metadata.version >= 2) { + if (!metadata.globalStateFiles + || typeof metadata.globalStateFiles !== "object" + || Array.isArray(metadata.globalStateFiles)) { + throw new Error(`Backup metadata contains invalid global-state presence data: ${path.join(backupDir, "metadata.json")}`); + } + for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) { + if (typeof metadata.globalStateFiles[fileName] !== "boolean") { + throw new Error( + `Backup metadata lacks a boolean presence record for ${fileName}: ${path.join(backupDir, "metadata.json")}` + ); + } + } + // A complete v2 presence map is itself recovery coverage. Two false + // values mean rollback must delete both targets, not that there is no + // global-state work to restore. + globalState = true; + } else { + globalState = await backupFileExists(path.join(backupDir, GLOBAL_STATE_FILE_BASENAME)) + || await backupFileExists(path.join(backupDir, GLOBAL_STATE_BACKUP_FILE_BASENAME)); + } + + return { + config: await backupFileExists(path.join(backupDir, "config.toml")), + globalState, + database: databaseFiles.some((fileName) => path.basename(fileName) === DB_FILE_BASENAME), + sessions: sessionManifest.files.length > 0 + }; +} + export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) { const { restoreConfig = true, + restoreGlobalState = restoreConfig, restoreDatabase = true, restoreSessions = true, - allowSqliteHomeRelocation = false + allowSqliteHomeRelocation = false, + globalStateTargetPaths = null, + sessionTargetPaths = null } = options; const storage = typeof storageOrCodexHome === "string" ? resolveStorageLayout({ codexHome: storageOrCodexHome, env: {} }) : storageOrCodexHome; assertSqliteAccessSupported(storage, "restore"); const codexHome = storage.codexHome; - const metadataPath = path.join(backupDir, "metadata.json"); - const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); - if (metadata.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(metadata.version)) { - throw new Error(`Unsupported backup metadata in ${metadataPath}.`); - } - if (metadata.codexHome !== codexHome) { - throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`); - } + const metadata = await readValidatedBackupMetadata(backupDir, codexHome); let sessionManifest = null; + let sessionRestoreEntries = []; if (restoreSessions) { const sessionManifestPath = path.join(backupDir, "session-meta-backup.json"); sessionManifest = JSON.parse(await fs.readFile(sessionManifestPath, "utf8")); - await assertSessionFilesWritable(sessionManifest.files ?? []); + if (sessionManifest.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(sessionManifest.version)) { + throw new Error(`Unsupported session backup manifest in ${sessionManifestPath}.`); + } + if (typeof sessionManifest.codexHome !== "string" + || !storagePathsEqual(sessionManifest.codexHome, codexHome)) { + throw new Error(`Session backup was created for ${sessionManifest.codexHome}, not ${codexHome}.`); + } + await validateSessionManifestEntries(sessionManifest.files ?? [], codexHome); + if (sessionTargetPaths) { + const selected = new Set(sessionTargetPaths.map(pathComparisonKey)); + sessionRestoreEntries = (sessionManifest.files ?? []) + .filter((entry) => selected.has(pathComparisonKey(entry.path))); + } else { + sessionRestoreEntries = await selectSessionRestoreEntries(backupDir, sessionManifest); + } } let stateDb = null; @@ -356,7 +655,11 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) const configBackupPath = path.join(backupDir, "config.toml"); if (restoreConfig) { await copyIfPresent(configBackupPath, path.join(codexHome, "config.toml")); - await restoreGlobalStateFilesFromBackup(backupDir, codexHome); + } + if (restoreGlobalState) { + await restoreGlobalStateFilesFromBackup(backupDir, codexHome, { + targetPaths: globalStateTargetPaths + }); } if (databaseRestorePlan) { @@ -364,13 +667,12 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) await removeIfPresent(sidecarPath); } for (const { sourcePath, targetPath } of databaseRestorePlan.entries) { - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.copyFile(sourcePath, targetPath); + await copyFileAtomic(sourcePath, targetPath); } } if (restoreSessions) { - await restoreSessionChanges(sessionManifest.files ?? []); + await restoreSessionChanges(sessionRestoreEntries); } return metadata; diff --git a/src/locking.js b/src/locking.js index d78ab35..0cae3ec 100644 --- a/src/locking.js +++ b/src/locking.js @@ -1,77 +1,617 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { DEFAULT_LOCK_NAME } from "./constants.js"; +import { syncDirectory } from "./atomic-file.js"; +const execFileAsync = promisify(execFile); const DEFAULT_LOCK_CREATE_RETRY_COUNT = 3; const DEFAULT_LOCK_CREATE_RETRY_DELAY_MS = 75; function isTransientLockCreateError(error) { - return error?.code === "EPERM"; + return error?.code === "EPERM" || error?.code === "EACCES"; } async function sleep(delayMs) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } -async function createLockDirectory(lockDir, { - fsImpl, - retryCount, - retryDelayMs, - sleepImpl -}) { +async function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") { + return false; + } + if (error?.code === "EPERM") { + return true; + } + throw error; + } +} + +async function getProcessStartMarker(pid) { + if (!Number.isInteger(pid) || pid <= 0 || !(await processExists(pid))) { + return null; + } + if (process.platform === "win32") { + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks` + ]); + const marker = stdout.trim(); + if (!/^\d+$/.test(marker)) { + throw new Error(`Unable to verify process start identity for PID ${pid}.`); + } + return `windows:${marker}`; + } + if (process.platform === "linux") { + try { + const [stat, bootId] = await Promise.all([ + fs.readFile(`/proc/${pid}/stat`, "utf8"), + fs.readFile("/proc/sys/kernel/random/boot_id", "utf8") + ]); + const closeParen = stat.lastIndexOf(")"); + const fieldsAfterCommand = stat.slice(closeParen + 2).trim().split(/\s+/); + const startTicks = fieldsAfterCommand[19]; + if (!startTicks) { + throw new Error("missing process start ticks"); + } + return `linux:${bootId.trim()}:${startTicks}`; + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + throw error; + } + } + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "lstart="] , { + env: { ...process.env, LC_ALL: "C", LANG: "C" } + }); + const marker = stdout.trim().replace(/\s+/g, " "); + return marker ? `${process.platform}:${marker}` : null; +} + +function lockExistsError(lockDir, reason) { + return new Error( + `Lock already exists at ${lockDir}. ${reason} Close Codex/App and retry; do not remove it unless the recorded owner is known to be gone.` + ); +} + +function processStartedAtFromMarker(marker) { + const windowsTicks = /^windows:(\d+)$/.exec(marker ?? ""); + if (windowsTicks) { + try { + const unixEpochTicks = 621355968000000000n; + const milliseconds = (BigInt(windowsTicks[1]) - unixEpochTicks) / 10000n; + return toUtcSecond(new Date(Number(milliseconds))); + } catch { + return null; + } + } + + const calendarStart = /^[^:]+:(.+)$/.exec(marker ?? "")?.[1]; + if (calendarStart && !marker.startsWith("linux:")) { + const parsed = Date.parse(calendarStart); + return Number.isFinite(parsed) ? toUtcSecond(new Date(parsed)) : null; + } + return null; +} + +function toUtcSecond(value) { + const milliseconds = value instanceof Date ? value.getTime() : Date.parse(value); + if (!Number.isFinite(milliseconds)) { + return null; + } + return new Date(Math.floor(milliseconds / 1000) * 1000).toISOString(); +} + +async function getProcessStartedAt(pid, marker) { + const fromMarker = processStartedAtFromMarker(marker); + if (fromMarker) { + return fromMarker; + } + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "lstart="], { + env: { ...process.env, LC_ALL: "C", LANG: "C" } + }); + const startedAt = stdout.trim().replace(/\s+/g, " "); + return startedAt ? toUtcSecond(startedAt) : null; +} + +function ownerGeneration(owner) { + return owner.instanceId ?? owner.rawText; +} + +function ownerMatchesExpected(actual, expected) { + return actual.pid === expected.pid + && ownerGeneration(actual) === ownerGeneration(expected) + && actual.processStartMarker === expected.processStartMarker + && actual.processStartedAt === expected.processStartedAt; +} + +function liveIdentityMatchesOwner(liveMarker, liveStartedAt, owner) { + if (!liveMarker) { + return false; + } + if (owner.processStartMarker + && (owner.runtime === "node" || owner.protocolVersion !== 2)) { + return liveMarker === owner.processStartMarker; + } + if (owner.protocolVersion >= 2 && owner.processStartedAt) { + if (!liveStartedAt) { + // A live PID whose start time cannot be compared safely is retained. + // This is preferable to reclaiming another runtime's active lock. + return true; + } + return Math.abs(Date.parse(liveStartedAt) - Date.parse(owner.processStartedAt)) < 1000; + } + if (owner.processStartMarker) { + return liveMarker === owner.processStartMarker; + } + if (owner.processStartedAt) { + if (!liveStartedAt) { + return true; + } + return Math.abs(Date.parse(liveStartedAt) - Date.parse(owner.processStartedAt)) < 1000; + } + return true; +} + +async function readLockOwner(ownerPath, fsImpl) { + let text; + try { + text = await fsImpl.readFile(ownerPath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + const missingOwner = lockExistsError( + path.dirname(ownerPath), + "owner.json is not visible yet, so ownership cannot be proven safely." + ); + missingOwner.code = "ENOENT"; + throw missingOwner; + } + throw error; + } + let owner; + try { + owner = JSON.parse(text); + } catch { + throw lockExistsError(path.dirname(ownerPath), "owner.json is malformed, so the lock is retained fail-closed."); + } + const protocolVersion = owner?.protocolVersion; + if (protocolVersion !== undefined + && (!Number.isInteger(protocolVersion) || protocolVersion < 1 || protocolVersion > 2)) { + throw lockExistsError( + path.dirname(ownerPath), + `owner.json uses unsupported lock protocol ${String(protocolVersion)}.` + ); + } + const hasPid = Number.isInteger(owner?.pid); + const hasProcessId = Number.isInteger(owner?.processId); + if (hasPid && hasProcessId && owner.pid !== owner.processId) { + throw lockExistsError(path.dirname(ownerPath), "owner.json has conflicting pid and processId values."); + } + const pid = hasPid ? owner.pid : owner?.processId; + const processStartMarker = typeof owner?.processStartMarker === "string" + && owner.processStartMarker + ? owner.processStartMarker + : null; + const processStartedAt = typeof owner?.processStartedAt === "string" + && Number.isFinite(Date.parse(owner.processStartedAt)) + ? toUtcSecond(owner.processStartedAt) + : null; + const instanceId = typeof owner?.instanceId === "string" && owner.instanceId + ? owner.instanceId + : null; + if (protocolVersion === 2 + && (!hasPid || !hasProcessId || !processStartedAt || !instanceId)) { + throw lockExistsError( + path.dirname(ownerPath), + "owner.json is missing required version 2 identity fields." + ); + } + if (!Number.isInteger(pid) + || pid <= 0 + || (!processStartMarker && !processStartedAt && !Number.isInteger(owner?.processId))) { + throw lockExistsError(path.dirname(ownerPath), "owner.json lacks a verifiable process identity."); + } + return { + ...owner, + pid, + processId: pid, + processStartMarker, + processStartedAt, + instanceId, + rawText: text + }; +} + +async function quarantineStaleLock(lockDir, expectedOwner, fsImpl) { + const quarantinePath = `${lockDir}.stale.${Date.now()}.${randomUUID()}`; + try { + await fsImpl.rename(lockDir, quarantinePath); + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } + + let quarantinedOwner; + try { + quarantinedOwner = await readLockOwner(path.join(quarantinePath, "owner.json"), fsImpl); + } catch (error) { + await fsImpl.rename(quarantinePath, lockDir).catch(() => {}); + throw error; + } + if (!ownerMatchesExpected(quarantinedOwner, expectedOwner)) { + let restored = false; + try { + await fsImpl.rename(quarantinePath, lockDir); + restored = true; + } catch { + // Preserve the moved lock for diagnosis if another contender occupied + // the canonical path before it could be restored. + } + throw lockExistsError( + lockDir, + restored + ? "The owner changed during stale-lock reclamation, so the newer lock was restored." + : `The owner changed during stale-lock reclamation; its lock is preserved at ${quarantinePath}.` + ); + } + await fsImpl.rm(quarantinePath, { recursive: true, force: true }); + return true; +} + +async function pathExists(targetPath, fsImpl) { + try { + await fsImpl.stat(targetPath); + return true; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +} + +async function createCandidateDirectory(candidateDir, fsImpl, retryCount, retryDelayMs, sleepImpl) { let attempts = 0; while (true) { try { - await fsImpl.mkdir(lockDir); + await fsImpl.mkdir(candidateDir, { mode: 0o700 }); return; } catch (error) { - if (error && error.code === "EEXIST") { - throw new Error(`Lock already exists at ${lockDir}. Close Codex/App and retry, or remove the stale lock if you are sure no sync is running.`); - } - - // Windows can briefly surface EPERM after a previous run releases the lock directory. if (!isTransientLockCreateError(error) || attempts >= retryCount) { throw error; } - attempts += 1; await sleepImpl(retryDelayMs); } } } +async function removeOwnedCanonical( + lockDir, + owner, + fsImpl, + syncDirectoryImpl, + platform, + suffix = "release" +) { + const parentDir = path.dirname(lockDir); + const removalPath = `${lockDir}.${suffix}.${process.pid}.${randomUUID()}`; + await fsImpl.rename(lockDir, removalPath); + const currentOwner = await readLockOwner(path.join(removalPath, "owner.json"), fsImpl); + if (!ownerMatchesExpected(currentOwner, owner)) { + let restored = false; + try { + await fsImpl.rename(removalPath, lockDir); + restored = true; + } catch { + // Keep the moved generation for diagnosis when the canonical name was + // concurrently occupied by an older runtime. + } + throw new Error( + restored + ? `Refusing to remove lock ${lockDir} because its owner identity changed.` + : `Refusing to remove lock ${lockDir}; the changed owner is preserved at ${removalPath}.` + ); + } + await fsImpl.rm(removalPath, { recursive: true, force: true }); + await syncDirectoryImpl(parentDir, { fsImpl, platform }); +} + +async function publishClaim(claimsDir, owner, fsImpl, syncDirectoryImpl, platform) { + const claimPath = path.join(claimsDir, `${owner.instanceId}.json`); + const candidatePath = path.join( + claimsDir, + `.${owner.instanceId}.${process.pid}.${randomUUID()}.tmp` + ); + const handle = await fsImpl.open(candidatePath, "wx", 0o600); + try { + await handle.writeFile(JSON.stringify(owner, null, 2), "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fsImpl.rename(candidatePath, claimPath); + await syncDirectoryImpl(claimsDir, { fsImpl, platform }); + return claimPath; + } catch (error) { + await fsImpl.rm(candidatePath, { force: true }).catch(() => {}); + throw error; + } +} + +async function isOwnerLive(owner, getProcessIdentity, getProcessStartedAtIdentity) { + const liveMarker = await getProcessIdentity(owner.pid); + if (!liveMarker) { + return false; + } + let liveStartedAt = null; + try { + liveStartedAt = await getProcessStartedAtIdentity(owner.pid, liveMarker); + } catch { + // The exact Node process marker remains sufficient for legacy/current + // Node owners. Cross-runtime owners fail closed if their start time cannot + // be inspected. + } + return liveIdentityMatchesOwner(liveMarker, liveStartedAt, owner); +} + +async function establishUniqueClaim({ + claimsDir, + claimPath, + owner, + fsImpl, + getProcessIdentity, + getProcessStartedAtIdentity, + syncDirectoryImpl, + platform +}) { + for (let scan = 0; scan < 2; scan += 1) { + const entries = await fsImpl.readdir(claimsDir, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + const otherPath = path.join(claimsDir, entry.name); + if (path.resolve(otherPath) === path.resolve(claimPath)) { + continue; + } + const otherOwner = await readLockOwner(otherPath, fsImpl); + if (!otherOwner.instanceId || entry.name !== `${otherOwner.instanceId}.json`) { + throw lockExistsError(claimsDir, `Claim ${otherPath} has no matching immutable instance identity.`); + } + let live; + try { + live = await isOwnerLive(otherOwner, getProcessIdentity, getProcessStartedAtIdentity); + } catch (identityError) { + throw lockExistsError( + claimsDir, + `Claim ${otherPath} could not be verified (${identityError.message}).` + ); + } + if (live) { + throw lockExistsError(claimsDir, `PID ${otherOwner.pid} holds live claim ${entry.name}.`); + } + // The filename is a never-reused instance generation. Removing this one + // stale file cannot delete a newer claimant's record. + await fsImpl.rm(otherPath, { force: true }); + await syncDirectoryImpl(claimsDir, { fsImpl, platform }); + } + } + + const currentOwner = await readLockOwner(claimPath, fsImpl); + if (currentOwner.instanceId !== owner.instanceId) { + throw new Error(`Refusing to use claim ${claimPath} because its owner identity changed.`); + } +} + +async function removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform) { + let currentOwner; + try { + currentOwner = await readLockOwner(claimPath, fsImpl); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + throw error; + } + if (currentOwner.instanceId !== owner.instanceId) { + throw new Error(`Refusing to remove claim ${claimPath} because its owner identity changed.`); + } + await fsImpl.rm(claimPath, { force: true }); + await syncDirectoryImpl(path.dirname(claimPath), { fsImpl, platform }); +} + export async function acquireLock(codexHome, label = "codex-provider-sync", options = {}) { + return acquirePathLock( + path.join(codexHome, "tmp", DEFAULT_LOCK_NAME), + label, + options + ); +} + +export async function acquirePathLock(lockPath, label = "codex-provider-sync", options = {}) { const { fsImpl = fs, retryCount = DEFAULT_LOCK_CREATE_RETRY_COUNT, retryDelayMs = DEFAULT_LOCK_CREATE_RETRY_DELAY_MS, - sleepImpl = sleep + sleepImpl = sleep, + getProcessIdentity = getProcessStartMarker, + getProcessStartedAtIdentity = getProcessStartedAt, + syncDirectoryImpl = syncDirectory, + onCandidateReady, + onBeforeStaleReclaim, + platform = process.platform } = options; - const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); - await fsImpl.mkdir(path.dirname(lockDir), { recursive: true }); - await createLockDirectory(lockDir, { - fsImpl, - retryCount, - retryDelayMs, - sleepImpl - }); - + const lockDir = path.resolve(lockPath); const ownerPath = path.join(lockDir, "owner.json"); + const parentDir = path.dirname(lockDir); + const claimsDir = `${lockDir}.claims`; + const candidateDir = path.join( + parentDir, + `.${path.basename(lockDir)}.candidate.${process.pid}.${randomUUID()}` + ); + await fsImpl.mkdir(parentDir, { recursive: true }); + await fsImpl.mkdir(claimsDir, { recursive: true, mode: 0o700 }); + const processStartMarker = await getProcessIdentity(process.pid); + if (!processStartMarker) { + throw new Error(`Unable to establish the current process identity for lock ${lockDir}.`); + } + + const processStartedAt = await getProcessStartedAtIdentity(process.pid, processStartMarker); + if (!processStartedAt) { + throw new Error(`Unable to establish the current process start time for lock ${lockDir}.`); + } const owner = { + protocolVersion: 2, + runtime: "node", pid: process.pid, + processId: process.pid, + processStartMarker, + processStartedAt: toUtcSecond(processStartedAt), + instanceId: randomUUID(), startedAt: new Date().toISOString(), label, - cwd: process.cwd() + cwd: process.cwd(), + currentDirectory: process.cwd() }; - await fsImpl.writeFile(ownerPath, JSON.stringify(owner, null, 2), "utf8"); + let published = false; + let claimPath = null; + try { + claimPath = await publishClaim( + claimsDir, + owner, + fsImpl, + syncDirectoryImpl, + platform + ); + await establishUniqueClaim({ + claimsDir, + claimPath, + owner, + fsImpl, + getProcessIdentity, + getProcessStartedAtIdentity, + syncDirectoryImpl, + platform + }); + await createCandidateDirectory( + candidateDir, + fsImpl, + retryCount, + retryDelayMs, + sleepImpl + ); + const candidateOwnerPath = path.join(candidateDir, "owner.json"); + const ownerHandle = await fsImpl.open(candidateOwnerPath, "wx", 0o600); + try { + await ownerHandle.writeFile(JSON.stringify(owner, null, 2), "utf8"); + await ownerHandle.sync(); + } finally { + await ownerHandle.close(); + } + await syncDirectoryImpl(candidateDir, { fsImpl, platform }); + await onCandidateReady?.({ candidateDir, lockDir, ownerPath: candidateOwnerPath, owner }); + + let attempts = 0; + while (true) { + try { + await fsImpl.rename(candidateDir, lockDir); + published = true; + await syncDirectoryImpl(parentDir, { fsImpl, platform }); + break; + } catch (error) { + const canonicalExists = await pathExists(lockDir, fsImpl); + if (canonicalExists) { + const existingOwner = await readLockOwner(ownerPath, fsImpl); + let live; + try { + live = await isOwnerLive( + existingOwner, + getProcessIdentity, + getProcessStartedAtIdentity + ); + } catch (identityError) { + throw lockExistsError(lockDir, `The recorded owner could not be verified (${identityError.message}).`); + } + if (live) { + throw lockExistsError(lockDir, `PID ${existingOwner.pid} is still the verified owner.`); + } + await onBeforeStaleReclaim?.({ lockDir, existingOwner, owner }); + if (await quarantineStaleLock(lockDir, existingOwner, fsImpl)) { + await syncDirectoryImpl(parentDir, { fsImpl, platform }); + } + continue; + } + if (!isTransientLockCreateError(error) || attempts >= retryCount) { + throw error; + } + attempts += 1; + await sleepImpl(retryDelayMs); + } + } + } catch (error) { + const cleanupFailures = []; + let canonicalCleanupSafe = !published; + if (published) { + try { + await removeOwnedCanonical( + lockDir, + owner, + fsImpl, + syncDirectoryImpl, + platform, + "acquire-failed" + ); + canonicalCleanupSafe = true; + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + } else { + try { + await fsImpl.rm(candidateDir, { recursive: true, force: true }); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + } + if (claimPath && canonicalCleanupSafe) { + try { + await removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + `Lock acquisition failed and cleanup was incomplete: ${error.message}`, + { cause: error } + ); + } + throw error; + } let released = false; return async function releaseLock() { if (released) { return; } + await removeOwnedCanonical(lockDir, owner, fsImpl, syncDirectoryImpl, platform); + await removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform); released = true; - await fsImpl.rm(lockDir, { recursive: true, force: true }); }; } diff --git a/src/service.js b/src/service.js index f107bd7..b7d905c 100644 --- a/src/service.js +++ b/src/service.js @@ -1,4 +1,5 @@ import path from "node:path"; +import fs from "node:fs/promises"; import { DEFAULT_BACKUP_RETENTION_COUNT, @@ -18,6 +19,7 @@ import { } from "./config-file.js"; import { createBackup, + getBackupRecoveryCoverage, getBackupSummary, pruneBackups, restoreBackup, @@ -28,7 +30,6 @@ import { acquireLock } from "./locking.js"; import { applySessionChanges, collectSessionChanges, - restoreSessionChanges, splitLockedSessionChanges, summarizeProviderCounts } from "./session-files.js"; @@ -57,9 +58,29 @@ import { TransactionJournal, assertNoPendingTransactions, findPendingTransactions, + getAppliedJournalTargets, + getStartedJournalTargets, + readTransactionJournal, markBackupTransactionRolledBack } from "./transaction-journal.js"; +function pathComparisonKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function uniqueResolvedPaths(values) { + const pathsByKey = new Map(); + for (const value of values) { + if (typeof value !== "string" || !value) { + continue; + } + const resolved = path.resolve(value); + pathsByKey.set(pathComparisonKey(resolved), resolved); + } + return [...pathsByKey.values()]; +} + export class SyncTransactionError extends Error { constructor( originalError, @@ -129,9 +150,88 @@ function formatBytes(bytes) { } function emitProgress(onProgress, event) { - if (typeof onProgress === "function") { - onProgress(event); + if (typeof onProgress !== "function") { + return; + } + try { + const observerResult = onProgress(event); + if (observerResult && typeof observerResult.then === "function") { + observerResult.catch(() => { + // Progress is an observer channel. Async observer failures must not + // change transaction state or surface as unhandled rejections. + }); + } + } catch { + // Progress is non-authoritative. A UI/CLI observer failure must never + // trigger compensation before commit or turn a committed operation into + // an apparent failure afterwards. + } +} + +async function commitJournalWithReconciliation(journal, faultInjector) { + let acknowledgementError = null; + try { + await journal.committed(); + await faultInjector?.({ point: "after_transaction_journal_commit_before_ack" }); + } catch (error) { + acknowledgementError = error; + } + + let persisted; + try { + persisted = await readTransactionJournal(journal.filePath); + } catch (readError) { + if (acknowledgementError) { + throw new AggregateError( + [acknowledgementError, readError], + `Unable to reconcile transaction commit acknowledgement: ${acknowledgementError.message}`, + { cause: acknowledgementError } + ); + } + throw readError; + } + if (persisted.terminal + && persisted.state === "committed" + && persisted.operationId === journal.operationId) { + return; + } + if (acknowledgementError) { + throw acknowledgementError; + } + throw new Error(`Transaction journal did not persist a valid committed terminal state: ${journal.filePath}`); +} + +async function rollbackJournalWithReconciliation(journal, faultInjector) { + let acknowledgementError = null; + try { + await journal.rolledBack(); + await faultInjector?.({ point: "after_transaction_journal_rollback_before_ack" }); + } catch (error) { + acknowledgementError = error; + } + + let persisted; + try { + persisted = await readTransactionJournal(journal.filePath); + } catch (readError) { + if (acknowledgementError) { + throw new AggregateError( + [acknowledgementError, readError], + `Unable to reconcile transaction rollback acknowledgement: ${acknowledgementError.message}`, + { cause: acknowledgementError } + ); + } + throw readError; } + if (persisted.terminal + && persisted.state === "rolledBack" + && persisted.operationId === journal.operationId) { + return; + } + if (acknowledgementError) { + throw acknowledgementError; + } + throw new Error(`Transaction journal did not persist a valid rolledBack terminal state: ${journal.filePath}`); } function sumCounts(counts) { @@ -317,6 +417,9 @@ async function runSyncCore({ await assertNoPendingTransactions(codexHome); throwIfAborted(signal); const configText = await readConfigText(configPath); + if (configBackupText !== undefined && configText !== configBackupText) { + throw new Error("config.toml changed before the switch operation acquired its lock. Refresh and retry."); + } const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); assertSqliteAccessSupported(storage, "sync"); if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { @@ -386,13 +489,14 @@ async function runSyncCore({ const globalStatePath = path.join(codexHome, ".codex-global-state.json"); const globalStateBackupPath = path.join(codexHome, ".codex-global-state.json.bak"); - const potentialTargets = [ + const globalStatePresent = await fs.access(globalStatePath).then(() => true).catch(() => false); + const sqliteTarget = storage.stateDbLocation?.path ?? null; + const potentialTargets = uniqueResolvedPaths([ ...writableChanges.map((change) => change.path), - globalStatePath, - globalStateBackupPath, + ...(globalStatePresent ? [globalStatePath, globalStateBackupPath] : []), ...(configBackupText !== undefined ? [configPath] : []), - ...(storage.stateDbCandidates ?? []).map((candidate) => candidate.path) - ].map((targetPath) => path.resolve(targetPath)); + ...(sqliteTarget ? [sqliteTarget] : []) + ]); journal = await TransactionJournal.create(backupDir, { codexHome, targetProvider, @@ -401,10 +505,16 @@ async function runSyncCore({ let sessionRestoreNeeded = false; let appliedSessionChanges = []; + let sqliteMutationCommitted = false; + let configMutationAttempted = false; const completedTargets = []; + const completedTargetKeys = new Set(); + let transactionCommitted = false; const recordCompletedTarget = (targetPath) => { const fullPath = path.resolve(targetPath); - if (!completedTargets.includes(fullPath)) { + const key = pathComparisonKey(fullPath); + if (!completedTargetKeys.has(key)) { + completedTargetKeys.add(key); completedTargets.push(fullPath); } }; @@ -418,9 +528,11 @@ async function runSyncCore({ if (typeof afterBackup === "function") { throwIfAborted(signal); await journal.applying("config", configPath); + configMutationAttempted = true; await afterBackup(backupDir); - await journal.applied("config", configPath); recordCompletedTarget(configPath); + await faultInjector?.({ point: "after_config_mutation_before_applied", path: configPath }); + await journal.applied("config", configPath); await faultInjector?.({ point: "after_config_apply", path: configPath }); } @@ -431,6 +543,9 @@ async function runSyncCore({ status: "start", writableCount: writableChanges.length }); + if (sqliteTarget) { + await journal.applying("sqlite", sqliteTarget); + } const sqliteResult = await updateSqliteProvider( storage, targetProvider, @@ -447,13 +562,24 @@ async function runSyncCore({ targetIndex: appliedSessionChanges.length + 1 }); }, + onMutation: async (change, mutation) => { + recordCompletedTarget(change.path); + await faultInjector?.({ + point: "after_rollout_mutation_before_applied", + path: change.path, + mutation + }); + }, onApplied: async (change) => { appliedSessionChanges.push(change); sessionRestoreNeeded = true; await journal.applied("rollout", change.path); - recordCompletedTarget(change.path); await updateSessionBackupManifest(backupDir, appliedSessionChanges); await faultInjector?.({ point: "after_rollout_apply", path: change.path, appliedCount: appliedSessionChanges.length }); + }, + onSkipped: async (change, reason) => { + await journal.skipped("rollout", change.path); + await faultInjector?.({ point: "after_rollout_skip", path: change.path, reason }); } }); } @@ -465,14 +591,28 @@ async function runSyncCore({ }, onApplied: async (targetPath) => { globalStateRestoreNeeded = true; - await journal.applied("globalState", targetPath); recordCompletedTarget(targetPath); + await journal.applied("globalState", targetPath); await faultInjector?.({ point: "after_global_state_apply", path: targetPath }); } }); + throwIfAborted(signal); }, { busyTimeoutMs: sqliteBusyTimeoutMs, userEventThreadIds, threadCwdById, targetModel: model } ); + sqliteMutationCommitted = sqliteResult.databasePresent && sqliteResult.updatedRows > 0; + if (sqliteMutationCommitted) { + recordCompletedTarget(sqliteTarget); + } + throwIfAborted(signal); + if (sqliteTarget) { + if (sqliteMutationCommitted) { + await journal.applied("sqlite", sqliteTarget); + } else { + await journal.skipped("sqlite", sqliteTarget); + } + await faultInjector?.({ point: "after_sqlite_commit", path: sqliteTarget }); + } emitProgress(onProgress, { stage: "rewrite_rollout_files", status: "complete", @@ -484,11 +624,15 @@ async function runSyncCore({ status: "complete", updatedRows: sqliteResult.updatedRows }); - recordCompletedTarget(storage.stateDbLocation?.path ?? storage.sqliteHome); const skippedLockedRolloutFiles = [...new Set([ ...skippedRolloutFiles, ...applyResult.skippedPaths ])].sort((left, right) => left.localeCompare(right)); + throwIfAborted(signal); + await faultInjector?.({ point: "before_transaction_commit", completedCount: completedTargets.length }); + await commitJournalWithReconciliation(journal, faultInjector); + transactionCommitted = true; + await faultInjector?.({ point: "after_transaction_commit", completedCount: completedTargets.length }); let autoPruneResult = null; let autoPruneWarning = null; emitProgress(onProgress, { @@ -530,41 +674,104 @@ async function runSyncCore({ autoPruneResult, autoPruneWarning }; - await journal.committed(); return result; } catch (error) { + if (transactionCommitted) { + throw error; + } + try { + const persistedTerminal = journal + ? await readTransactionJournal(journal.filePath) + : null; + if (persistedTerminal?.terminal && persistedTerminal.state === "committed") { + // A terminal commit is authoritative even if an observer or the + // acknowledgement path failed before the in-memory flag advanced. + // Never append rollback events or compensate committed state. + throw error; + } + } catch (reconciliationError) { + if (reconciliationError === error) { + throw error; + } + // A journal read failure is handled by the recovery path below. + } + const restoreFailures = []; try { await journal?.rollingBack(error); } catch (journalError) { restoreFailures.push(`transaction journal: ${journalError.message}`); } - if (sessionRestoreNeeded) { + let journalSnapshot = null; + try { + journalSnapshot = journal ? await readTransactionJournal(journal.filePath) : null; + } catch (journalError) { + restoreFailures.push(`transaction journal read: ${journalError.message}`); + } + const startedRolloutTargets = journalSnapshot + ? getStartedJournalTargets(journalSnapshot, "rollout") + : (sessionRestoreNeeded + ? appliedSessionChanges.map((change) => change.path) + : writableChanges.map((change) => change.path)); + const startedGlobalStateTargets = journalSnapshot + ? getStartedJournalTargets(journalSnapshot, "globalState") + : (globalStateRestoreNeeded || globalStatePresent + ? [globalStatePath, globalStateBackupPath] + : []); + const startedConfigTargets = journalSnapshot + ? getStartedJournalTargets(journalSnapshot, "config") + : (configMutationAttempted ? [configPath] : []); + + if (startedRolloutTargets.length > 0) { try { - await faultInjector?.({ point: "before_rollout_rollback", appliedCount: appliedSessionChanges.length }); - await restoreSessionChanges(appliedSessionChanges); + await faultInjector?.({ point: "before_rollout_rollback", appliedCount: startedRolloutTargets.length }); + await restoreBackup(backupDir, storage, { + restoreConfig: false, + restoreGlobalState: false, + restoreDatabase: false, + restoreSessions: true, + sessionTargetPaths: startedRolloutTargets + }); } catch (restoreError) { restoreFailures.push(`rollout files: ${restoreError.message}`); } } - if (globalStateRestoreNeeded && backupDir) { + if (startedGlobalStateTargets.length > 0 && backupDir) { try { await faultInjector?.({ point: "before_global_state_rollback" }); - await restoreGlobalStateFilesFromBackup(backupDir, codexHome); + await restoreGlobalStateFilesFromBackup(backupDir, codexHome, { + targetPaths: startedGlobalStateTargets + }); } catch (restoreError) { restoreFailures.push(`global state: ${restoreError.message}`); } } - if (configBackupText !== undefined) { + if (startedConfigTargets.length > 0 && configBackupText !== undefined) { try { + await faultInjector?.({ point: "before_config_rollback", path: configPath }); await writeConfigText(configPath, configBackupText); } catch (restoreError) { restoreFailures.push(`config: ${restoreError.message}`); } } + if (sqliteMutationCommitted && backupDir) { + try { + const sqliteTarget = storage.stateDbLocation?.path ?? storage.sqliteHome; + await faultInjector?.({ point: "before_sqlite_rollback", path: sqliteTarget }); + await restoreBackup(backupDir, storage, { + restoreConfig: false, + restoreDatabase: true, + restoreSessions: false + }); + } catch (restoreError) { + restoreFailures.push(`SQLite: ${restoreError.message}`); + } + } if (restoreFailures.length === 0) { try { - await journal?.rolledBack(); + if (journal) { + await rollbackJournalWithReconciliation(journal, faultInjector); + } } catch (journalError) { restoreFailures.push(`transaction journal: ${journalError.message}`); } @@ -576,24 +783,33 @@ async function runSyncCore({ // Preserve the original and rollback errors even if the journal is // no longer writable. } - const completedSet = new Set(completedTargets); - const uncompletedTargets = potentialTargets.filter((targetPath) => !completedSet.has(targetPath)); + const persistedCompletedTargets = journalSnapshot + ? uniqueResolvedPaths([...getAppliedJournalTargets(journalSnapshot), ...completedTargets]) + : uniqueResolvedPaths(completedTargets); + const uncompletedTargets = uniqueResolvedPaths([ + ...startedRolloutTargets, + ...startedGlobalStateTargets, + ...startedConfigTargets, + ...(sqliteMutationCommitted && sqliteTarget ? [sqliteTarget] : []) + ]); throw new SyncTransactionError( error, restoreFailures, backupDir, - completedTargets, + persistedCompletedTargets, uncompletedTargets, { rollbackStatus: "incomplete", recoveryRequired: true } ); } - const completedSet = new Set(completedTargets); + const persistedCompletedTargets = journalSnapshot + ? uniqueResolvedPaths([...getAppliedJournalTargets(journalSnapshot), ...completedTargets]) + : uniqueResolvedPaths(completedTargets); throw new SyncTransactionError( error, [], backupDir, - completedTargets, - potentialTargets.filter((targetPath) => !completedSet.has(targetPath)), + persistedCompletedTargets, + [], { rollbackStatus: "complete", recoveryRequired: false } ); } @@ -610,7 +826,9 @@ export async function runSwitch({ keepRootModel = false, keepCount = DEFAULT_BACKUP_RETENTION_COUNT, onProgress, - platform + platform, + faultInjector, + signal }) { if (!provider) { throw new Error("Missing provider id. Usage: codex-provider switch "); @@ -656,54 +874,47 @@ export async function runSwitch({ } } - let configMutationAttempted = false; - try { - // `nextConfigText` has the final root-level `model` value. Use that to - // drive the per-thread rewrite so old sessions match new sessions. - let modelForThreads = null; - if (modelSync.applied && modelSync.model) { - modelForThreads = modelSync.model; - } else { - modelForThreads = readRootModelFromConfigText(nextConfigText); - } - const syncResult = await runSyncCore( - { - codexHome, - storage, - provider, - configBackupText: originalConfigText, - keepCount, - onProgress, - model: modelForThreads - }, - { - afterBackup: async () => { - emitProgress(onProgress, { - stage: "update_config", - status: "start", - provider - }); - configMutationAttempted = true; - await writeConfigText(configPath, nextConfigText); - emitProgress(onProgress, { - stage: "update_config", - status: "complete", - provider - }); - } + // `nextConfigText` has the final root-level `model` value. Use that to + // drive the per-thread rewrite so old sessions match new sessions. + let modelForThreads = null; + if (modelSync.applied && modelSync.model) { + modelForThreads = modelSync.model; + } else { + modelForThreads = readRootModelFromConfigText(nextConfigText); + } + const syncResult = await runSyncCore( + { + codexHome, + storage, + provider, + configBackupText: originalConfigText, + keepCount, + onProgress, + model: modelForThreads, + faultInjector, + signal + }, + { + afterBackup: async () => { + emitProgress(onProgress, { + stage: "update_config", + status: "start", + provider + }); + await writeConfigText(configPath, nextConfigText); + emitProgress(onProgress, { + stage: "update_config", + status: "complete", + provider + }); } - ); - return { - ...syncResult, - configUpdated: true, - modelSync - }; - } catch (error) { - if (configMutationAttempted) { - await writeConfigText(configPath, originalConfigText); } - throw error; - } + ); + return { + ...syncResult, + configUpdated: true, + modelSync + }; } export async function runRestore({ @@ -732,6 +943,55 @@ export async function runRestore({ const releaseLock = await acquireLock(codexHome, "restore"); try { const normalizedBackupDir = path.resolve(backupDir); + let boundJournal = null; + try { + boundJournal = await readTransactionJournal( + path.join(normalizedBackupDir, "transaction-journal.jsonl") + ); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + if (boundJournal && !boundJournal.terminal) { + const journalUncertain = boundJournal.invalidTail || boundJournal.events.length === 0; + let conservativeCoverage = null; + if (journalUncertain) { + try { + conservativeCoverage = await getBackupRecoveryCoverage(normalizedBackupDir, storage); + } catch (coverageError) { + coverageError.code = "RECOVERY_REQUIRED"; + coverageError.backupDir = normalizedBackupDir; + throw coverageError; + } + } + const missingKinds = []; + if ((getStartedJournalTargets(boundJournal, "rollout").length > 0 + || conservativeCoverage?.sessions) && !restoreSessions) { + missingKinds.push("rollout sessions"); + } + if ((getStartedJournalTargets(boundJournal, "sqlite").length > 0 + || conservativeCoverage?.database) && !restoreDatabase) { + missingKinds.push("SQLite database"); + } + if ((getStartedJournalTargets(boundJournal, "config").length > 0 + || conservativeCoverage?.config) && !restoreConfig) { + missingKinds.push("config.toml"); + } + if ((getStartedJournalTargets(boundJournal, "globalState").length > 0 + || conservativeCoverage?.globalState) && !restoreConfig) { + missingKinds.push("global state"); + } + if (missingKinds.length > 0) { + const error = new Error( + `Partial restore would leave a pending transaction unresolved. Include: ${missingKinds.join(", ")}.` + ); + error.code = "RECOVERY_REQUIRED"; + error.backupDir = normalizedBackupDir; + error.missingRestoreKinds = missingKinds; + throw error; + } + } const result = await restoreBackup(normalizedBackupDir, storage, { restoreConfig, restoreDatabase, diff --git a/src/session-files.js b/src/session-files.js index 5a34ee2..2ae0e08 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -7,10 +7,20 @@ import readline from "node:readline"; import { promisify } from "node:util"; import { SESSION_DIRS } from "./constants.js"; +import { syncDirectory } from "./atomic-file.js"; const execFileAsync = promisify(execFile); const ROLLOUT_SCAN_CHUNK_BYTES = 1024 * 1024; +async function syncStagedFile(filePath) { + const handle = await fsp.open(filePath, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + function isRolloutFileBusyError(error) { const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase(); return message.includes("ebusy") @@ -33,7 +43,8 @@ async function getFileSnapshot(filePath) { const stat = await fsp.stat(filePath); return { size: stat.size, - mtimeMs: stat.mtimeMs + mtimeMs: stat.mtimeMs, + mode: stat.mode }; } @@ -198,6 +209,7 @@ async function fileHasUserEvent(filePath, firstLine, startOffset) { async function listJsonlFiles(rootDir) { const entries = await fsp.readdir(rootDir, { withFileTypes: true }); + entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); const files = []; for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); @@ -289,10 +301,15 @@ function parseSessionMetaRecord(firstLine) { // because rollout lines are single JSON objects. const ROLLOUT_TURNCONTEXT_TYPE_RE = /"type"\s*:\s*"turn_context"/; -async function readTurnContextModels(rolloutPath, { firstLineOffset, firstLineLength } = {}) { +async function readTurnContextModelSnapshot( + rolloutPath, + { firstLineOffset, firstLineLength, targetModel = null } = {} +) { const headerSkip = Math.max(0, firstLineOffset ?? 0); const headerLength = Math.max(0, firstLineLength ?? 0); const models = []; + const originalTurnContextModels = []; + let lineIndex = 0; const stream = fs.createReadStream(rolloutPath, { encoding: "utf8", @@ -306,6 +323,7 @@ async function readTurnContextModels(rolloutPath, { firstLineOffset, firstLineLe try { for await (const line of lines) { + lineIndex += 1; if (!line.includes('"turn_context"')) { continue; } @@ -322,8 +340,18 @@ async function readTurnContextModels(rolloutPath, { firstLineOffset, firstLineLe // Leave malformed model literals untouched. } } + if (typeof targetModel === "string" && targetModel.length > 0) { + const rewrite = rewriteTurnContextModelInLine(line, targetModel); + if (rewrite.replaced) { + originalTurnContextModels.push({ + lineIndex, + originalModel: rewrite.originalModel, + originalModels: rewrite.originalModels + }); + } + } } - return models; + return { models, originalTurnContextModels }; } catch (error) { throw wrapRolloutFileBusyError(error, rolloutPath, "read"); } finally { @@ -517,10 +545,10 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc function Invoke-RewriteChange($change) { $path = [string]$change.path $tmpPath = "$path.provider-sync.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()).tmp" + $replaceBackupPath = "$path.provider-sync.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()).replace-backup" $encoding = [System.Text.UTF8Encoding]::new($false) $source = $null $writer = $null - $tempReader = $null try { try { @@ -546,22 +574,6 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc $sourceOffset = [int64]$change.originalOffset $headerOnly = $sourceOffset -ge [int64]$change.originalSize - if ($null -ne $change.inPlaceByteOffset -and -not [string]::IsNullOrEmpty([string]$change.inPlaceReplacementBase64)) { - $originalBytes = [Convert]::FromBase64String([string]$change.inPlaceOriginalBase64) - $replacementBytes = [Convert]::FromBase64String([string]$change.inPlaceReplacementBase64) - try { - $source.Seek([int64]$change.inPlaceByteOffset, [System.IO.SeekOrigin]::Begin) | Out-Null - $source.Write($replacementBytes, 0, $replacementBytes.Length) - $source.Flush() - return "APPLIED_IN_PLACE" - } catch { - $source.Seek([int64]$change.inPlaceByteOffset, [System.IO.SeekOrigin]::Begin) | Out-Null - $source.Write($originalBytes, 0, $originalBytes.Length) - $source.Flush() - # The original bytes are restored; continue into the safe - # full-file rewrite below. - } - } } else { $record = Read-FirstLineRecord $source $separator = [string]$change.separator @@ -583,21 +595,23 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc $source.CopyTo($writer) } - $writer.Flush() + $writer.Flush($true) $writer.Dispose() $writer = $null - $tempReader = [System.IO.File]::OpenRead($tmpPath) - $source.SetLength(0) - $source.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null - $tempReader.CopyTo($source) - $source.Flush() + $source.Dispose() + $source = $null + try { + [System.IO.File]::Replace($tmpPath, $path, $replaceBackupPath, $true) + } catch { + if (Test-Path $path) { + return "SKIP_BUSY" + } + return "SKIP_CHANGED" + } return "APPLIED" } finally { - if ($tempReader) { - $tempReader.Dispose() - } if ($writer) { $writer.Dispose() } @@ -605,6 +619,7 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc $source.Dispose() } Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $replaceBackupPath -Force -ErrorAction SilentlyContinue } } @@ -629,16 +644,10 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc `.trim(); try { - const manifestChanges = changes.map((change) => { - const replacement = requireOriginalMatch ? getInPlaceProviderReplacement(change) : null; - return { - ...change, - requireOriginalMatch, - inPlaceByteOffset: replacement?.byteOffset ?? null, - inPlaceOriginalBase64: replacement?.original.toString("base64") ?? null, - inPlaceReplacementBase64: replacement?.replacement.toString("base64") ?? null - }; - }); + const manifestChanges = changes.map((change) => ({ + ...change, + requireOriginalMatch + })); await fsp.writeFile( manifestPath, JSON.stringify(manifestChanges), @@ -690,6 +699,7 @@ async function rewriteFirstLine(filePath, nextFirstLine, separator) { } const current = await readFirstLineRecord(filePath); + const sourceStat = await fsp.stat(filePath); const tmpPath = `${filePath}.provider-sync.${process.pid}.${Date.now()}.tmp`; const writer = fs.createWriteStream(tmpPath, { encoding: "utf8" }); @@ -718,105 +728,16 @@ async function rewriteFirstLine(filePath, nextFirstLine, separator) { reader.pipe(writer, { end: false }); }); + await fsp.chmod(tmpPath, sourceStat.mode); + await syncStagedFile(tmpPath); await fsp.rename(tmpPath, filePath); + await syncDirectory(path.dirname(filePath)); } catch (error) { await fsp.rm(tmpPath, { force: true }); throw wrapRolloutFileBusyError(error, filePath, "rewrite"); } } -function getInPlaceProviderReplacement(change) { - if (change.modelRewriteRequired - || change.modelOnlyChange - || typeof change.originalFirstLine !== "string" - || typeof change.originalProvider !== "string" - || typeof change.updatedProvider !== "string") { - return null; - } - - const originalProviderJson = JSON.stringify(change.originalProvider); - const updatedProviderJson = JSON.stringify(change.updatedProvider); - const originalProvider = Buffer.from(originalProviderJson, "utf8"); - const updatedProvider = Buffer.from(updatedProviderJson, "utf8"); - if (originalProvider.length === 0 || originalProvider.length !== updatedProvider.length) { - return null; - } - - const providerFieldPattern = /"model_provider"\s*:\s*/g; - let fieldMatch; - while ((fieldMatch = providerFieldPattern.exec(change.originalFirstLine)) !== null) { - const valueOffset = fieldMatch.index + fieldMatch[0].length; - if (!change.originalFirstLine.startsWith(originalProviderJson, valueOffset)) { - continue; - } - - return { - byteOffset: Buffer.byteLength(change.originalFirstLine.slice(0, valueOffset), "utf8"), - original: originalProvider, - replacement: updatedProvider - }; - } - - return null; -} - -async function tryRewriteProviderInPlace(change, replacement) { - let handle; - let writeStarted = false; - try { - handle = await fsp.open(change.path, "r+"); - const stat = await handle.stat(); - if (!snapshotMatches(change, { size: stat.size, mtimeMs: stat.mtimeMs })) { - return "SKIP_CHANGED"; - } - - let totalWritten = 0; - writeStarted = true; - while (totalWritten < replacement.replacement.length) { - const { bytesWritten } = await handle.write( - replacement.replacement, - totalWritten, - replacement.replacement.length - totalWritten, - replacement.byteOffset + totalWritten - ); - if (bytesWritten <= 0) { - throw new Error(`Unable to rewrite provider bytes in rollout file: ${change.path}`); - } - totalWritten += bytesWritten; - } - await handle.sync(); - return "APPLIED_IN_PLACE"; - } catch (error) { - if (handle && writeStarted) { - try { - let totalRestored = 0; - while (totalRestored < replacement.original.length) { - const { bytesWritten } = await handle.write( - replacement.original, - totalRestored, - replacement.original.length - totalRestored, - replacement.byteOffset + totalRestored - ); - if (bytesWritten <= 0) { - throw new Error(`Unable to restore provider bytes in rollout file: ${change.path}`); - } - totalRestored += bytesWritten; - } - await handle.sync(); - return "FALLBACK"; - } catch (restoreError) { - throw new AggregateError( - [error, restoreError], - `Unable to restore provider bytes after an in-place rewrite failure: ${change.path}` - ); - } - } - throw wrapRolloutFileBusyError(error, change.path, "rewrite"); - } finally { - await handle?.close(); - } -} - async function tryRewriteCollectedFirstLine(change) { const beforeSnapshot = await getFileSnapshot(change.path); if (!snapshotMatches(change, beforeSnapshot)) { @@ -828,14 +749,6 @@ async function tryRewriteCollectedFirstLine(change) { return "SKIP_CHANGED"; } - const inPlaceReplacement = getInPlaceProviderReplacement(change); - if (inPlaceReplacement) { - const inPlaceResult = await tryRewriteProviderInPlace(change, inPlaceReplacement); - if (inPlaceResult !== "FALLBACK") { - return inPlaceResult; - } - } - const tmpPath = `${change.path}.provider-sync.${process.pid}.${Date.now()}.tmp`; const writer = fs.createWriteStream(tmpPath, { encoding: "utf8" }); @@ -867,7 +780,10 @@ async function tryRewriteCollectedFirstLine(change) { return "SKIP_CHANGED"; } + await fsp.chmod(tmpPath, beforeSnapshot.mode); + await syncStagedFile(tmpPath); await fsp.rename(tmpPath, change.path); + await syncDirectory(path.dirname(change.path)); return "APPLIED"; } catch (error) { await fsp.rm(tmpPath, { force: true }); @@ -992,7 +908,21 @@ async function rewriteRolloutModelField(change, targetModel) { return { replacedLines: 0, originalTurnContextModels: [] }; } + // Validate the immutable scan-time rollback snapshot before replacing the + // file. A turn_context appended after the first-line mutation must not be + // rewritten and then discovered only after the destructive rename: that + // new line has no original value in the backup manifest. Throwing here + // leaves the appended line untouched and lets the transaction restore the + // already-mutated first line. + if (!modelSnapshotsEqual(change.originalTurnContextModels, originalTurnContextModels)) { + await fsp.rm(tmpPath, { force: true }); + throw new Error(`Rollout turn_context model snapshot changed before rewrite: ${change.path}`); + } + + await fsp.chmod(tmpPath, beforeStat.mode); + await syncStagedFile(tmpPath); await fsp.rename(tmpPath, filePath); + await syncDirectory(path.dirname(filePath)); return { replacedLines: replacements, originalTurnContextModels }; } catch (error) { throw wrapRolloutFileBusyError(error, filePath, "rewrite model field"); @@ -1109,10 +1039,12 @@ export async function collectSessionChanges(codexHome, targetProvider, options = // keep this on the summary so the rewrite step knows what // value to swap out, without making collectSessionChanges // require a target model. - const currentModels = await readTurnContextModels(rolloutPath, { + const modelSnapshot = await readTurnContextModelSnapshot(rolloutPath, { firstLineOffset: 0, - firstLineLength: record.offset + firstLineLength: record.offset, + targetModel }); + const currentModels = modelSnapshot.models; const originalModel = currentModels[0] ?? null; // A file is rewritten when EITHER the provider needs to @@ -1144,6 +1076,7 @@ export async function collectSessionChanges(codexHome, targetProvider, options = originalProvider: currentProvider, updatedProvider: targetProvider, originalModel, + originalTurnContextModels: modelSnapshot.originalTurnContextModels, modelRewriteRequired: modelChanged, modelOnlyChange: !providerChanged && modelChanged, updatedFirstLine: providerChanged ? JSON.stringify(parsed) : record.firstLine @@ -1157,7 +1090,13 @@ export async function collectSessionChanges(codexHome, targetProvider, options = export async function applySessionChanges(changes, options = {}) { const normalizedChanges = changes ?? []; - const { targetModel = null, onBeforeApply, onApplied } = options ?? {}; + const { + targetModel = null, + onBeforeApply, + onMutation, + onApplied, + onSkipped + } = options ?? {}; const skippedPaths = []; const appliedPaths = []; let appliedChanges = 0; @@ -1185,15 +1124,20 @@ export async function applySessionChanges(changes, options = {}) { appliedChanges += 1; inPlaceChanges += result === "APPLIED_IN_PLACE" ? 1 : 0; appliedPaths.push(change.path); + await onMutation?.(change, { stage: "firstLine", result }); if (change.modelRewriteRequired) { const modelResult = await rewriteRolloutModelField(change, targetModel); - change.originalTurnContextModels = modelResult.originalTurnContextModels; + retainOrValidateModelSnapshot(change, modelResult.originalTurnContextModels); change.appliedTurnContextRewrites = modelResult.replacedLines; + if (modelResult.replacedLines > 0) { + await onMutation?.(change, { stage: "model", result: "APPLIED" }); + } } await restoreOriginalMtime(change.path, change.originalMtimeMs); await onApplied?.(change); } else { skippedPaths.push(change.path); + await onSkipped?.(change, result); } } } else { @@ -1204,15 +1148,20 @@ export async function applySessionChanges(changes, options = {}) { appliedChanges += 1; inPlaceChanges += result === "APPLIED_IN_PLACE" ? 1 : 0; appliedPaths.push(change.path); + await onMutation?.(change, { stage: "firstLine", result }); if (change.modelRewriteRequired) { const modelResult = await rewriteRolloutModelField(change, targetModel); - change.originalTurnContextModels = modelResult.originalTurnContextModels; + retainOrValidateModelSnapshot(change, modelResult.originalTurnContextModels); change.appliedTurnContextRewrites = modelResult.replacedLines; + if (modelResult.replacedLines > 0) { + await onMutation?.(change, { stage: "model", result: "APPLIED" }); + } } await restoreOriginalMtime(change.path, change.originalMtimeMs); await onApplied?.(change); } else { skippedPaths.push(change.path); + await onSkipped?.(change, result); } } } @@ -1234,14 +1183,16 @@ export async function applySessionChanges(changes, options = {}) { throw error; } if (modelResult.replacedLines > 0) { + retainOrValidateModelSnapshot(change, modelResult.originalTurnContextModels); + await onMutation?.(change, { stage: "model", result: "APPLIED" }); await restoreOriginalMtime(change.path, change.originalMtimeMs); appliedChanges += 1; appliedPaths.push(change.path); - change.originalTurnContextModels = modelResult.originalTurnContextModels; change.appliedTurnContextRewrites = modelResult.replacedLines; await onApplied?.(change); } else { skippedPaths.push(change.path); + await onSkipped?.(change, "SKIP_CHANGED"); } } @@ -1255,6 +1206,23 @@ export async function applySessionChanges(changes, options = {}) { }; } +function retainOrValidateModelSnapshot(change, actualSnapshot) { + const expected = change.originalTurnContextModels; + if (!Array.isArray(expected) || expected.length === 0) { + change.originalTurnContextModels = actualSnapshot; + return; + } + if (JSON.stringify(expected) !== JSON.stringify(actualSnapshot)) { + throw new Error(`Rollout turn_context model snapshot changed before rewrite: ${change.path}`); + } +} + +function modelSnapshotsEqual(expected, actual) { + return Array.isArray(expected) + && Array.isArray(actual) + && JSON.stringify(expected) === JSON.stringify(actual); +} + export async function assertSessionFilesWritable(changes) { if (!changes?.length || process.platform !== "win32") { return; @@ -1305,45 +1273,66 @@ export async function splitLockedSessionChanges(changes) { }; } -export async function restoreSessionChanges(manifestEntries) { +export async function restoreSessionChanges(manifestEntries, options = {}) { if (!manifestEntries?.length) { - return; + return { restoredPaths: [], failures: [] }; } - if (process.platform === "win32") { - const firstLineEntries = manifestEntries.filter((entry) => !entry.modelOnlyChange); - const changes = firstLineEntries.map((entry) => ({ - path: entry.path, - separator: entry.originalSeparator ?? "\n", - updatedFirstLine: entry.originalFirstLine, - originalMtimeMs: entry.originalMtimeMs - })); - const results = await invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch: false }); - const firstFailureIndex = results.findIndex((result) => result !== "APPLIED"); - if (firstFailureIndex !== -1) { - const filePath = changes[firstFailureIndex].path; - throw new Error( - `Unable to rewrite rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${filePath}` - ); - } - for (const entry of manifestEntries) { + const restoredPaths = []; + const failures = []; + for (const entry of manifestEntries) { + try { + await options.onBeforeRestore?.(entry); + if (!entry.modelOnlyChange) { + if (process.platform === "win32") { + const [result] = await invokeWindowsExclusiveRewriteBatch([{ + path: entry.path, + separator: entry.originalSeparator ?? "\n", + updatedFirstLine: entry.originalFirstLine, + originalMtimeMs: entry.originalMtimeMs + }], { requireOriginalMatch: false }); + if (result !== "APPLIED") { + throw new Error( + `Unable to rewrite rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${entry.path}` + ); + } + } else { + await rewriteFirstLine(entry.path, entry.originalFirstLine, entry.originalSeparator ?? "\n"); + } + } if (entry.originalTurnContextModels?.length) { await restoreTurnContextModelsInFile(entry.path, entry.originalTurnContextModels, entry.originalSeparator); } await restoreOriginalMtime(entry.path, entry.originalMtimeMs); + restoredPaths.push(entry.path); + await options.onRestored?.(entry); + } catch (error) { + const failure = new Error(`Unable to restore rollout ${entry.path}: ${error.message}`, { cause: error }); + failure.path = entry.path; + failures.push(failure); + try { + await options.onRestoreFailed?.(entry, error); + } catch (observerError) { + failures.push(new Error( + `Unable to record rollout restore failure for ${entry.path}: ${observerError.message}`, + { cause: observerError } + )); + } } - return; } - for (const entry of manifestEntries) { - if (!entry.modelOnlyChange) { - await rewriteFirstLine(entry.path, entry.originalFirstLine, entry.originalSeparator ?? "\n"); - } - if (entry.originalTurnContextModels?.length) { - await restoreTurnContextModelsInFile(entry.path, entry.originalTurnContextModels, entry.originalSeparator); - } - await restoreOriginalMtime(entry.path, entry.originalMtimeMs); + if (failures.length > 0) { + const aggregate = new AggregateError( + failures, + `Unable to restore ${failures.length} rollout target operation(s).` + ); + aggregate.failures = failures.map((failure) => ({ + path: failure.path ?? null, + message: failure.message + })); + throw aggregate; } + return { restoredPaths, failures: [] }; } // Walk a rollout file and restore the per-turn `model` field for @@ -1450,7 +1439,10 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel return; } + await fsp.chmod(tmpPath, beforeStat.mode); + await syncStagedFile(tmpPath); await fsp.rename(tmpPath, filePath); + await syncDirectory(path.dirname(filePath)); } catch (error) { throw wrapRolloutFileBusyError(error, filePath, "restore turn_context model"); } finally { diff --git a/src/sqlite-state.js b/src/sqlite-state.js index fe4e9e7..a54b656 100644 --- a/src/sqlite-state.js +++ b/src/sqlite-state.js @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { syncDirectory } from "./atomic-file.js"; import { DB_FILE_BASENAME, SESSION_DIRS, SQLITE_DIR_BASENAME } from "./constants.js"; import { openDatabase } from "./sqlite.js"; import { resolveStorageLayout } from "./storage-layout.js"; @@ -186,6 +187,15 @@ function setBusyTimeout(db, busyTimeoutMs) { db.exec(`PRAGMA busy_timeout = ${normalizeBusyTimeoutMs(busyTimeoutMs)}`); } +export function configureSqliteWriteDurability(db) { + db.exec("PRAGMA synchronous = FULL"); + const synchronous = Number(db.prepare("PRAGMA synchronous").get().synchronous); + if (synchronous !== 2) { + throw new Error(`Unable to configure SQLite synchronous=FULL (reported ${synchronous}).`); + } + return { synchronous: "full", value: synchronous }; +} + function isSqliteBusyError(error) { const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase(); return message.includes("database is locked") || message.includes("sqlite_busy") || message.includes("busy"); @@ -328,6 +338,7 @@ export async function assertSqliteWritable(storageOrLocation, options = {}) { try { db = await openDatabase(dbPath); setBusyTimeout(db, options.busyTimeoutMs); + configureSqliteWriteDurability(db); db.exec("BEGIN IMMEDIATE"); db.exec("ROLLBACK"); return { databasePresent: true }; @@ -358,6 +369,7 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af await afterUpdate({ updatedRows: 0, providerRowsUpdated: 0, + modelRowsUpdated: 0, userEventRowsUpdated: 0, cwdRowsUpdated: 0, databasePresent: false @@ -366,6 +378,7 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af return { updatedRows: 0, providerRowsUpdated: 0, + modelRowsUpdated: 0, userEventRowsUpdated: 0, cwdRowsUpdated: 0, databasePresent: false @@ -377,6 +390,7 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af try { db = await openDatabase(dbPath); setBusyTimeout(db, options.busyTimeoutMs); + configureSqliteWriteDurability(db); db.exec("BEGIN IMMEDIATE"); transactionOpen = true; // When a target model is provided, align every thread's `model` column @@ -387,16 +401,22 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af // with tableHasColumn to keep legacy layouts working. const wantsModel = targetModel != null && targetModel.length > 0 && tableHasColumn(db, "threads", "model"); - const stmt = db.prepare(wantsModel - ? `UPDATE threads - SET model_provider = ?, model = ? - WHERE COALESCE(model_provider, '') <> ? OR COALESCE(model, '') <> ?` - : `UPDATE threads - SET model_provider = ? - WHERE COALESCE(model_provider, '') <> ?`); - const result = wantsModel - ? stmt.run(targetProvider, targetModel, targetProvider, targetModel) - : stmt.run(targetProvider, targetProvider); + // Keep the update shape and counters identical to .NET: provider and + // optional model are independent writes, and a row changed in both + // columns contributes two to updatedRows. + const providerResult = db.prepare(` + UPDATE threads + SET model_provider = ? + WHERE COALESCE(model_provider, '') <> ? + `).run(targetProvider, targetProvider); + let modelUpdatedRows = 0; + if (wantsModel) { + modelUpdatedRows = db.prepare(` + UPDATE threads + SET model = ? + WHERE COALESCE(model, '') <> ? + `).run(targetModel, targetModel).changes ?? 0; + } let userEventUpdatedRows = 0; if (tableHasColumn(db, "threads", "has_user_event") && options.userEventThreadIds?.size) { const userEventStmt = db.prepare(` @@ -422,11 +442,13 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af cwdUpdatedRows += cwdStmt.run(cwd, threadId, cwd).changes ?? 0; } } - const updatedRows = (result.changes ?? 0) + userEventUpdatedRows + cwdUpdatedRows; + const providerUpdatedRows = providerResult.changes ?? 0; + const updatedRows = providerUpdatedRows + modelUpdatedRows + userEventUpdatedRows + cwdUpdatedRows; if (afterUpdate) { await afterUpdate({ updatedRows, - providerRowsUpdated: result.changes ?? 0, + providerRowsUpdated: providerUpdatedRows, + modelRowsUpdated: modelUpdatedRows, userEventRowsUpdated: userEventUpdatedRows, cwdRowsUpdated: cwdUpdatedRows, databasePresent: true @@ -436,7 +458,8 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af transactionOpen = false; return { updatedRows, - providerRowsUpdated: result.changes ?? 0, + providerRowsUpdated: providerUpdatedRows, + modelRowsUpdated: modelUpdatedRows, userEventRowsUpdated: userEventUpdatedRows, cwdRowsUpdated: cwdUpdatedRows, databasePresent: true @@ -457,3 +480,121 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af db?.close(); } } + +function readSqliteConnectionMetadata(db) { + return { + journalMode: String(db.prepare("PRAGMA journal_mode").get().journal_mode).toLowerCase(), + pageSize: Number(db.prepare("PRAGMA page_size").get().page_size), + userVersion: Number(db.prepare("PRAGMA user_version").get().user_version), + applicationId: Number(db.prepare("PRAGMA application_id").get().application_id) + }; +} + +async function readStandaloneSqliteHeaderMetadata(dbPath) { + const handle = await fs.open(dbPath, "r"); + try { + const header = Buffer.alloc(100); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + if (bytesRead !== header.length + || header.subarray(0, 16).toString("binary") !== "SQLite format 3\u0000") { + throw new Error("SQLite online backup did not produce a valid standalone database header."); + } + const rawPageSize = header.readUInt16BE(16); + return { + journalMode: header[18] === 2 && header[19] === 2 ? "wal" : "delete", + pageSize: rawPageSize === 1 ? 65536 : rawPageSize, + userVersion: header.readInt32BE(60), + applicationId: header.readInt32BE(68) + }; + } finally { + await handle.close(); + } +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +} + +/** + * Create one consistent SQLite database file via the active driver's official + * online-backup API. WAL/SHM sidecars are intentionally neither copied nor + * emitted; metadata is read from the standalone main-file header. + */ +export async function createSqliteOnlineBackup(storageOrLocation, destinationPath, options = {}) { + const dbPath = await existingStateDbPath(storageOrLocation); + if (!dbPath) { + return { databasePresent: false, backupPath: null, driver: null, metadata: null }; + } + + const fullSourcePath = path.resolve(dbPath); + const fullDestinationPath = path.resolve(destinationPath); + const sourceIdentityPath = process.platform === "win32" ? fullSourcePath.toLowerCase() : fullSourcePath; + const destinationIdentityPath = process.platform === "win32" + ? fullDestinationPath.toLowerCase() + : fullDestinationPath; + if (sourceIdentityPath === destinationIdentityPath) { + throw new Error("SQLite online backup destination must differ from the source database."); + } + await fs.mkdir(path.dirname(fullDestinationPath), { recursive: true }); + if (await pathExists(fullDestinationPath)) { + throw new Error("SQLite online backup destination already exists."); + } + + let db; + try { + db = await openDatabase(fullSourcePath); + setBusyTimeout(db, options.busyTimeoutMs); + const sourceMetadata = readSqliteConnectionMetadata(db); + const driver = db.driver ?? "unknown"; + await db.backup(fullDestinationPath, options.backupOptions ?? {}); + + const handle = await fs.open(fullDestinationPath, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(path.dirname(fullDestinationPath)); + + const sidecars = [`${fullDestinationPath}-wal`, `${fullDestinationPath}-shm`]; + if ((await Promise.all(sidecars.map(pathExists))).some(Boolean)) { + throw new Error("SQLite online backup unexpectedly emitted a WAL/SHM sidecar."); + } + const backupMetadata = await readStandaloneSqliteHeaderMetadata(fullDestinationPath); + return { + databasePresent: true, + backupPath: fullDestinationPath, + driver, + metadata: { + source: sourceMetadata, + backup: backupMetadata, + preserved: { + journalMode: sourceMetadata.journalMode === backupMetadata.journalMode, + pageSize: sourceMetadata.pageSize === backupMetadata.pageSize, + userVersion: sourceMetadata.userVersion === backupMetadata.userVersion, + applicationId: sourceMetadata.applicationId === backupMetadata.applicationId + } + } + }; + } catch (error) { + await Promise.all([ + fullDestinationPath, + `${fullDestinationPath}-wal`, + `${fullDestinationPath}-shm` + ].map((filePath) => fs.rm(filePath, { force: true }).catch(() => {}))); + throw wrapSqliteMalformedError( + wrapSqliteBusyError(error, "create a consistent SQLite online backup"), + "create a consistent SQLite online backup" + ); + } finally { + db?.close(); + } +} diff --git a/src/sqlite.js b/src/sqlite.js index 57da76d..20e821c 100644 --- a/src/sqlite.js +++ b/src/sqlite.js @@ -6,6 +6,7 @@ function normalizeImportDefault(moduleNamespace) { class BetterSqliteDatabase { constructor(Database, dbPath, options = {}) { + this.driver = "better-sqlite3"; this.db = new Database(dbPath, { readonly: Boolean(options.readOnly) }); @@ -19,6 +20,34 @@ class BetterSqliteDatabase { return this.db.exec(sql); } + async backup(destinationPath, options = {}) { + return this.db.backup(destinationPath, options); + } + + close() { + return this.db.close(); + } +} + +class NodeSqliteDatabase { + constructor(sqlite, dbPath, options = {}) { + this.driver = "node:sqlite"; + this.sqlite = sqlite; + this.db = new sqlite.DatabaseSync(dbPath, options); + } + + prepare(sql) { + return this.db.prepare(sql); + } + + exec(sql) { + return this.db.exec(sql); + } + + async backup(destinationPath, options = {}) { + return this.sqlite.backup(this.db, destinationPath, options); + } + close() { return this.db.close(); } @@ -28,7 +57,7 @@ async function loadDatabaseFactory() { try { const sqlite = await import("node:sqlite"); if (sqlite.DatabaseSync) { - return (dbPath, options) => new sqlite.DatabaseSync(dbPath, options); + return (dbPath, options) => new NodeSqliteDatabase(sqlite, dbPath, options); } } catch { // Older Node.js releases do not include node:sqlite. diff --git a/src/transaction-journal.js b/src/transaction-journal.js index 3803d91..65ee655 100644 --- a/src/transaction-journal.js +++ b/src/transaction-journal.js @@ -3,11 +3,40 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { defaultBackupRoot } from "./constants.js"; +import { writeFileAtomic, syncDirectory } from "./atomic-file.js"; export const TRANSACTION_JOURNAL_BASENAME = "transaction-journal.jsonl"; const TERMINAL_STATES = new Set(["committed", "rolledBack"]); +const TARGET_STATES = new Set(["applying", "applied", "skipped"]); +const VALID_STATES = new Set([ + "prepared", + ...TARGET_STATES, + "committed", + "rollingBack", + "rolledBack", + "recoveryRequired" +]); +const VALID_TARGET_KINDS = new Set(["config", "rollout", "globalState", "sqlite"]); + +function pathKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function targetKey(kind, targetPath) { + return `${kind}\0${pathKey(targetPath)}`; +} async function appendDurableJsonLine(filePath, value) { + let existed = true; + try { + await fs.access(filePath); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + existed = false; + } const handle = await fs.open(filePath, "a"); try { await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); @@ -15,6 +44,9 @@ async function appendDurableJsonLine(filePath, value) { } finally { await handle.close(); } + if (!existed) { + await syncDirectory(path.dirname(filePath)); + } } export class TransactionJournal { @@ -28,26 +60,66 @@ export class TransactionJournal { const operationId = randomUUID(); const filePath = path.join(backupDir, TRANSACTION_JOURNAL_BASENAME); const journal = new TransactionJournal(filePath, operationId); - await journal.append("prepared", { + const potentialTargetsByKey = new Map(); + for (const value of details.potentialTargets) { + const resolved = path.resolve(value); + potentialTargetsByKey.set(pathKey(resolved), resolved); + } + const prepared = { protocolVersion: 1, + operationId, + sequence: 1, + state: "prepared", + recordedAt: new Date().toISOString(), backupDir: path.resolve(backupDir), codexHome: path.resolve(details.codexHome), targetProvider: details.targetProvider, - potentialTargets: [...new Set(details.potentialTargets.map((value) => path.resolve(value)))].sort() - }); + potentialTargets: [...potentialTargetsByKey.values()].sort() + }; + const exclusive = await fs.open(filePath, "wx", 0o600); + try { + await exclusive.writeFile(`${JSON.stringify(prepared)}\n`, "utf8"); + await exclusive.sync(); + } finally { + await exclusive.close(); + } + await syncDirectory(path.dirname(filePath)); + journal.sequence = 1; return journal; } async append(state, details = {}) { - this.sequence += 1; - await appendDurableJsonLine(this.filePath, { + const nextSequence = this.sequence + 1; + const event = { + ...details, protocolVersion: 1, operationId: this.operationId, - sequence: this.sequence, + sequence: nextSequence, state, - recordedAt: new Date().toISOString(), - ...details - }); + recordedAt: new Date().toISOString() + }; + try { + await appendDurableJsonLine(this.filePath, event); + this.sequence = nextSequence; + } catch (error) { + // A host may report a flush error after the bytes reached the file. Keep + // subsequent records sequence-compatible with the durable prefix while + // still surfacing the original durability failure to the coordinator. + try { + const current = await readTransactionJournal(this.filePath); + const lastEvent = current.events.at(-1) ?? null; + if (!current.invalidTail + && lastEvent !== null + && JSON.stringify(lastEvent) === JSON.stringify(event)) { + this.sequence = nextSequence; + } else if (!current.invalidTail) { + this.sequence = lastEvent?.sequence ?? this.sequence; + } + } catch { + // The original append error remains authoritative. + } + throw error; + } } async applying(kind, targetPath) { @@ -58,6 +130,10 @@ export class TransactionJournal { await this.append("applied", { kind, targetPath: path.resolve(targetPath) }); } + async skipped(kind, targetPath) { + await this.append("skipped", { kind, targetPath: path.resolve(targetPath) }); + } + async committed() { await this.append("committed"); } @@ -78,33 +154,200 @@ export class TransactionJournal { } } +function validateJournalEvents(parsedEvents) { + const events = []; + const activeTargets = new Set(); + let operationId = null; + let expectedSequence = 1; + let terminalSeen = false; + let rollingBack = false; + let recoveryRequiredSeen = false; + let potentialTargets = null; + let validationError = null; + + for (const event of parsedEvents) { + const fail = (message) => { + validationError = message; + return false; + }; + if (!event || typeof event !== "object" || Array.isArray(event)) { + fail("Journal event is not an object."); + break; + } + if (event.protocolVersion !== 1 || !VALID_STATES.has(event.state)) { + fail("Journal event has an unsupported protocol or state."); + break; + } + if (typeof event.operationId !== "string" || !event.operationId) { + fail("Journal event is missing operationId."); + break; + } + if (event.sequence !== expectedSequence) { + fail(`Journal sequence mismatch: expected ${expectedSequence}, received ${event.sequence}.`); + break; + } + if (operationId === null) { + if (event.state !== "prepared") { + fail("Journal must start with prepared."); + break; + } + operationId = event.operationId; + if (!Array.isArray(event.potentialTargets) + || event.potentialTargets.some((value) => typeof value !== "string" || !path.isAbsolute(value))) { + fail("Journal prepared event has invalid potentialTargets."); + break; + } + potentialTargets = new Set(event.potentialTargets.map(pathKey)); + if (potentialTargets.size !== event.potentialTargets.length + || typeof event.backupDir !== "string" + || !path.isAbsolute(event.backupDir) + || typeof event.codexHome !== "string" + || !path.isAbsolute(event.codexHome)) { + fail("Journal prepared event has duplicate targets or invalid absolute roots."); + break; + } + } else if (event.operationId !== operationId) { + fail("Journal operationId changed within one transaction."); + break; + } else if (event.state === "prepared") { + fail("Journal contains more than one prepared event."); + break; + } + if (terminalSeen) { + fail("Journal contains events after a terminal state."); + break; + } + + if (recoveryRequiredSeen && event.state !== "rolledBack") { + fail("Journal continued after recoveryRequired without an explicit restore."); + break; + } + if (rollingBack + && !new Set(["rollingBack", "recoveryRequired", "rolledBack"]).has(event.state)) { + fail("Journal continued target work after rollback started."); + break; + } + + if (TARGET_STATES.has(event.state)) { + if (rollingBack) { + fail("Journal contains a target transition after rollback started."); + break; + } + if (!VALID_TARGET_KINDS.has(event.kind) + || typeof event.targetPath !== "string" + || !path.isAbsolute(event.targetPath)) { + fail("Journal target transition is malformed."); + break; + } + if (!potentialTargets?.has(pathKey(event.targetPath))) { + fail("Journal target is not declared in prepared.potentialTargets."); + break; + } + const key = targetKey(event.kind, event.targetPath); + if (event.state === "applying") { + if (activeTargets.has(key)) { + fail("Journal target was started twice without a result."); + break; + } + activeTargets.add(key); + } else if (!activeTargets.delete(key)) { + fail(`Journal ${event.state} event has no matching applying event.`); + break; + } + } else if (event.state === "rollingBack") { + if (rollingBack) { + fail("Journal contains duplicate rollingBack events."); + break; + } + rollingBack = true; + } else if (event.state === "recoveryRequired") { + recoveryRequiredSeen = true; + } else if (event.state === "committed") { + if (rollingBack || activeTargets.size > 0) { + fail("Journal committed with unresolved target transitions."); + break; + } + terminalSeen = true; + } else if (event.state === "rolledBack") { + if (!rollingBack && !recoveryRequiredSeen) { + fail("Journal rolledBack without first entering rollback or recoveryRequired."); + break; + } + terminalSeen = true; + } + + events.push(event); + expectedSequence += 1; + } + + return { events, operationId, validationError }; +} + export async function readTransactionJournal(filePath) { const text = await fs.readFile(filePath, "utf8"); - const events = []; - let invalidTail = false; + const parsedEvents = []; + let parseError = text.length > 0 && !text.endsWith("\n") + ? "Journal is missing its final newline and may contain a torn append." + : null; for (const line of text.split(/\r?\n/)) { if (!line.trim()) { continue; } try { - events.push(JSON.parse(line)); + parsedEvents.push(JSON.parse(line)); } catch { - invalidTail = true; + parseError = "Journal contains a truncated or malformed JSON line."; break; } } + const validated = validateJournalEvents(parsedEvents); + const events = validated.events; + const validationError = parseError ?? validated.validationError; + const invalidTail = validationError !== null || events.length !== parsedEvents.length; const lastEvent = events.at(-1) ?? null; return { filePath, events, invalidTail, - operationId: events[0]?.operationId ?? null, - backupDir: events[0]?.backupDir ?? path.dirname(filePath), + validationError, + operationId: validated.operationId, + backupDir: path.dirname(filePath), + recordedBackupDir: events[0]?.backupDir ?? null, state: invalidTail ? "recoveryRequired" : (lastEvent?.state ?? "recoveryRequired"), - terminal: !invalidTail && TERMINAL_STATES.has(lastEvent?.state) + terminal: !invalidTail && TERMINAL_STATES.has(lastEvent?.state), + rawText: text }; } +export function getJournalTargetStates(journal) { + const targets = new Map(); + for (const event of journal?.events ?? []) { + if (!TARGET_STATES.has(event.state)) { + continue; + } + const key = targetKey(event.kind, event.targetPath); + targets.set(key, { + kind: event.kind, + targetPath: path.resolve(event.targetPath), + state: event.state, + sequence: event.sequence + }); + } + return [...targets.values()]; +} + +export function getStartedJournalTargets(journal, kind) { + return getJournalTargetStates(journal) + .filter((target) => target.kind === kind && target.state !== "skipped") + .map((target) => target.targetPath); +} + +export function getAppliedJournalTargets(journal) { + return getJournalTargetStates(journal) + .filter((target) => target.state === "applied") + .map((target) => target.targetPath); +} + export async function findPendingTransactions(codexHome) { const root = defaultBackupRoot(codexHome); let entries; @@ -167,12 +410,85 @@ export async function markBackupTransactionRolledBack(backupDir) { if (current.terminal) { return; } + if (current.invalidTail || current.events.length === 0) { + const archivePath = path.join( + path.dirname(filePath), + `${TRANSACTION_JOURNAL_BASENAME}.invalid.${Date.now()}.${randomUUID()}` + ); + await writeFileAtomic(archivePath, current.rawText, "utf8"); + + let baseEvents = []; + for (const event of current.events) { + if (TERMINAL_STATES.has(event.state)) { + break; + } + baseEvents.push(event); + } + if (baseEvents.length === 0) { + let metadata = {}; + try { + metadata = JSON.parse(await fs.readFile(path.join(backupDir, "metadata.json"), "utf8")); + } catch { + // A successful restore already validated metadata. This fallback is + // only for a journal whose first record itself was damaged. + } + const operationId = randomUUID(); + baseEvents = [{ + protocolVersion: 1, + operationId, + sequence: 1, + state: "prepared", + recordedAt: new Date().toISOString(), + backupDir: path.resolve(backupDir), + codexHome: metadata.codexHome ? path.resolve(metadata.codexHome) : null, + targetProvider: metadata.targetProvider ?? null, + potentialTargets: [] + }]; + } + const operationId = baseEvents[0].operationId; + if (!new Set(["rollingBack", "recoveryRequired"]).has(baseEvents.at(-1)?.state)) { + baseEvents.push({ + protocolVersion: 1, + operationId, + sequence: baseEvents.length + 1, + state: "rollingBack", + recordedAt: new Date().toISOString(), + originalError: "Explicit restore repaired an invalid transaction journal." + }); + } + const rolledBack = { + protocolVersion: 1, + operationId, + sequence: baseEvents.length + 1, + state: "rolledBack", + recordedAt: new Date().toISOString(), + recoveredInvalidJournal: true, + invalidJournalArchive: path.basename(archivePath) + }; + await writeFileAtomic( + filePath, + `${[...baseEvents, rolledBack].map((event) => JSON.stringify(event)).join("\n")}\n`, + "utf8" + ); + const verified = await readTransactionJournal(filePath); + if (!verified.terminal || verified.state !== "rolledBack") { + throw new Error(`Transaction journal did not persist a valid rolledBack terminal state: ${filePath}`); + } + return; + } const journal = new TransactionJournal( filePath, current.operationId ?? randomUUID(), current.events?.at(-1)?.sequence ?? 0 ); + if (!new Set(["rollingBack", "recoveryRequired"]).has(current.state)) { + await journal.rollingBack(new Error("Explicit restore completed.")); + } await journal.rolledBack(); + const verified = await readTransactionJournal(filePath); + if (!verified.terminal || verified.state !== "rolledBack") { + throw new Error(`Transaction journal did not persist a valid rolledBack terminal state: ${filePath}`); + } } catch (error) { if (error?.code !== "ENOENT") { throw error; diff --git a/test/locking.test.js b/test/locking.test.js index dd672ed..65e615c 100644 --- a/test/locking.test.js +++ b/test/locking.test.js @@ -1,90 +1,345 @@ +import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; -import { acquireLock } from "../src/locking.js"; +import { acquireLock, acquirePathLock } from "../src/locking.js"; import { DEFAULT_LOCK_NAME } from "../src/constants.js"; -test("acquireLock retries transient EPERM when creating the lock directory", async () => { - const codexHome = "C:\\CodexHome"; +const TEST_STARTED_AT = "2024-01-02T03:04:05.000Z"; +const TEST_MARKER = `test:${TEST_STARTED_AT}`; + +async function makeLockHome(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-lock-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +function lockOptions(overrides = {}) { + return { + getProcessIdentity: async (pid) => pid === process.pid ? TEST_MARKER : null, + getProcessStartedAtIdentity: async (pid) => pid === process.pid ? TEST_STARTED_AT : null, + ...overrides + }; +} + +async function writeCanonicalOwner(codexHome, owner) { const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); - const calls = []; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile(path.join(lockDir, "owner.json"), JSON.stringify(owner), "utf8"); + return lockDir; +} + +async function waitForChild(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); +} + +test("acquireLock publishes a complete versioned owner and holds a unique claim", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); + const claimsDir = `${lockDir}.claims`; + const release = await acquireLock(codexHome, "sync", lockOptions()); + + const owner = JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")); + assert.equal(owner.protocolVersion, 2); + assert.equal(owner.pid, process.pid); + assert.equal(owner.processId, process.pid); + assert.equal(owner.processStartedAt, TEST_STARTED_AT); + assert.equal(typeof owner.instanceId, "string"); + assert.deepEqual(await fs.readdir(claimsDir), [`${owner.instanceId}.json`]); + + await assert.rejects(acquireLock(codexHome, "other", lockOptions()), /live claim|Lock already exists/); + await release(); + await assert.rejects(fs.access(lockDir), { code: "ENOENT" }); + assert.deepEqual(await fs.readdir(claimsDir), []); +}); + +test("acquireLock retries transient candidate creation failures", async (t) => { + const codexHome = await makeLockHome(t); + let candidateAttempts = 0; const sleepCalls = []; - let lockMkdirAttempts = 0; - - const fsImpl = { - async mkdir(targetPath, options) { - calls.push({ fn: "mkdir", targetPath, options }); - if (targetPath === lockDir) { - lockMkdirAttempts += 1; - if (lockMkdirAttempts < 3) { - const error = new Error("operation not permitted"); - error.code = "EPERM"; - throw error; - } + const fsImpl = new Proxy(fs, { + get(target, property) { + if (property === "mkdir") { + return async (targetPath, options) => { + if (path.basename(targetPath).includes(".candidate.")) { + candidateAttempts += 1; + if (candidateAttempts < 3) { + const error = new Error("operation not permitted"); + error.code = "EPERM"; + throw error; + } + } + return target.mkdir(targetPath, options); + }; } - }, - async writeFile(targetPath, content, encoding) { - calls.push({ fn: "writeFile", targetPath, content, encoding }); - }, - async rm(targetPath, options) { - calls.push({ fn: "rm", targetPath, options }); + const value = target[property]; + return typeof value === "function" ? value.bind(target) : value; } - }; + }); - const releaseLock = await acquireLock(codexHome, "sync", { + const release = await acquireLock(codexHome, "sync", lockOptions({ fsImpl, - retryCount: 3, retryDelayMs: 25, - sleepImpl: async (delayMs) => { - sleepCalls.push(delayMs); - } + sleepImpl: async (delayMs) => sleepCalls.push(delayMs) + })); + assert.equal(candidateAttempts, 3); + assert.deepEqual(sleepCalls, [25, 25]); + await release(); +}); + +test("acquirePathLock supports an arbitrary future SQLite resource path", async (t) => { + const root = await makeLockHome(t); + const lockPath = path.join(root, "resource-locks", "state-db.lock"); + const release = await acquirePathLock(lockPath, "sqlite-resource", lockOptions()); + const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8")); + assert.equal(owner.label, "sqlite-resource"); + assert.deepEqual(await fs.readdir(`${lockPath}.claims`), [`${owner.instanceId}.json`]); + await release(); + await assert.rejects(fs.access(lockPath), { code: "ENOENT" }); +}); + +test("acquireLock reads a live legacy .NET owner fail-closed", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = await writeCanonicalOwner(codexHome, { + processId: process.pid, + processStartedAt: TEST_STARTED_AT, + startedAt: TEST_STARTED_AT, + label: "dotnet", + currentDirectory: codexHome }); - assert.equal(lockMkdirAttempts, 3); - assert.deepEqual(sleepCalls, [25, 25]); - assert.equal(calls.filter((call) => call.fn === "writeFile").length, 1); + await assert.rejects( + acquireLock(codexHome, "node", lockOptions()), + /PID .* still the verified owner/ + ); + assert.equal(JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")).processId, process.pid); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); +}); + +test("acquireLock recognizes a live version 2 .NET owner from UTC-second identity", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = await writeCanonicalOwner(codexHome, { + protocolVersion: 2, + runtime: "dotnet", + pid: process.pid, + processId: process.pid, + processStartedAt: "2024-01-02T03:04:05Z", + instanceId: "dotnet-v2-live", + startedAt: TEST_STARTED_AT, + label: "dotnet", + cwd: codexHome + }); - await releaseLock(); - assert.equal(calls.at(-1).fn, "rm"); - assert.equal(calls.at(-1).targetPath, lockDir); + await assert.rejects(acquireLock(codexHome, "node", lockOptions()), /still the verified owner/); + assert.equal( + JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")).instanceId, + "dotnet-v2-live" + ); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); }); -test("acquireLock does not retry when the lock directory already exists", async () => { - const codexHome = "C:\\CodexHome"; +test("same-second PID reuse is rejected by the exact Node start marker", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = await writeCanonicalOwner(codexHome, { + protocolVersion: 2, + runtime: "node", + pid: process.pid, + processId: process.pid, + processStartMarker: "node:previous-generation", + processStartedAt: TEST_STARTED_AT, + instanceId: "previous-node-generation", + startedAt: TEST_STARTED_AT, + label: "node", + cwd: codexHome + }); + + const release = await acquireLock(codexHome, "node", lockOptions()); + assert.notEqual( + JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")).instanceId, + "previous-node-generation" + ); + await release(); +}); + +test("acquireLock reclaims a dead legacy .NET owner", async (t) => { + const codexHome = await makeLockHome(t); + const deadPid = 2_000_000_000; + const lockDir = await writeCanonicalOwner(codexHome, { + processId: deadPid, + startedAt: "2020-01-01T00:00:01.000Z", + label: "dotnet", + currentDirectory: codexHome + }); + + const release = await acquireLock(codexHome, "node", lockOptions()); + const owner = JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")); + assert.equal(owner.protocolVersion, 2); + assert.equal(owner.processId, process.pid); + await release(); +}); + +test("a crash after candidate preparation leaves only nonblocking residue", async (t) => { + const codexHome = await makeLockHome(t); + const moduleUrl = pathToFileURL(path.resolve("src/locking.js")).href; + const script = ` + import { acquireLock } from ${JSON.stringify(moduleUrl)}; + await acquireLock(${JSON.stringify(codexHome)}, "crash", { + getProcessIdentity: async () => ${JSON.stringify(TEST_MARKER)}, + getProcessStartedAtIdentity: async () => ${JSON.stringify(TEST_STARTED_AT)}, + onCandidateReady() { process.exit(29); } + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { + stdio: ["ignore", "pipe", "pipe"] + }); + const result = await waitForChild(child); + assert.equal(result.code, 29); + const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); - let lockMkdirAttempts = 0; - const sleepCalls = []; + await assert.rejects(fs.access(lockDir), { code: "ENOENT" }); + const residue = await fs.readdir(path.dirname(lockDir)); + assert.ok(residue.some((name) => name.includes(".candidate."))); + assert.equal((await fs.readdir(`${lockDir}.claims`)).length, 1); - const fsImpl = { - async mkdir(targetPath) { - if (targetPath === lockDir) { - lockMkdirAttempts += 1; - const error = new Error("already exists"); - error.code = "EEXIST"; - throw error; - } - }, - async writeFile() { - throw new Error("writeFile should not be called"); - }, - async rm() { - throw new Error("rm should not be called"); + const release = await acquireLock(codexHome, "recovery", lockOptions()); + await release(); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); +}); + +test("a unique live claim serializes stale reclaimers and prevents ABA", async (t) => { + const codexHome = await makeLockHome(t); + const deadPid = 2_000_000_000; + await writeCanonicalOwner(codexHome, { + protocolVersion: 2, + pid: deadPid, + processId: deadPid, + processStartedAt: "2020-01-01T00:00:00.000Z", + instanceId: "stale-owner", + label: "stale", + cwd: codexHome + }); + + let releaseReclaimer; + const reclaimerPaused = new Promise((resolve) => { + releaseReclaimer = resolve; + }); + let reachedReclaim; + const reclaimReached = new Promise((resolve) => { + reachedReclaim = resolve; + }); + const first = acquireLock(codexHome, "first", lockOptions({ + onBeforeStaleReclaim: async () => { + reachedReclaim(); + await reclaimerPaused; } + })); + await reclaimReached; + + const contenders = await Promise.allSettled([ + acquireLock(codexHome, "second", lockOptions()), + acquireLock(codexHome, "third", lockOptions()) + ]); + assert.equal(contenders.filter((entry) => entry.status === "fulfilled").length, 0); + assert.ok(contenders.every((entry) => /live claim|Lock already exists/.test(entry.reason.message))); + + releaseReclaimer(); + const release = await first; + await release(); +}); + +test("failed acquisition cleanup preserves a canonical generation replaced by an older runtime", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); + const parentDir = path.dirname(lockDir); + const replacementOwner = { + protocolVersion: 2, + runtime: "dotnet", + pid: process.pid, + processId: process.pid, + processStartedAt: TEST_STARTED_AT, + instanceId: "replacement-generation", + startedAt: TEST_STARTED_AT, + label: "older-runtime", + cwd: codexHome }; + let replaced = false; await assert.rejects( - () => acquireLock(codexHome, "sync", { - fsImpl, - retryCount: 3, - retryDelayMs: 25, - sleepImpl: async (delayMs) => { - sleepCalls.push(delayMs); + acquireLock(codexHome, "node", lockOptions({ + async syncDirectoryImpl(directoryPath) { + if (!replaced && path.resolve(directoryPath) === path.resolve(parentDir)) { + replaced = true; + await fs.writeFile( + path.join(lockDir, "owner.json"), + JSON.stringify(replacementOwner), + "utf8" + ); + throw new Error("injected post-publish durability failure"); + } } - }), - /Lock already exists/ + })), + /cleanup was incomplete/ + ); + + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")), + replacementOwner ); + assert.equal((await fs.readdir(`${lockDir}.claims`)).length, 1); +}); + +test("acquireLock fails closed while a legacy canonical owner is missing", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); + await fs.mkdir(lockDir, { recursive: true }); + await assert.rejects( + acquireLock(codexHome, "sync", lockOptions()), + /owner\.json is not visible yet/ + ); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); +}); - assert.equal(lockMkdirAttempts, 1); - assert.deepEqual(sleepCalls, []); +test("acquireLock fails closed for future protocols and conflicting cross-runtime PIDs", async (t) => { + for (const fixture of [ + { + name: "future protocol", + owner: { + protocolVersion: 99, + pid: 2_000_000_000, + processId: 2_000_000_000, + processStartedAt: "2020-01-01T00:00:00.000Z", + instanceId: "future-owner" + }, + expected: /unsupported lock protocol 99/ + }, + { + name: "PID conflict", + owner: { + protocolVersion: 2, + pid: 2_000_000_000, + processId: 1_999_999_999, + processStartedAt: "2020-01-01T00:00:00.000Z", + instanceId: "conflicting-owner" + }, + expected: /conflicting pid and processId/ + } + ]) { + await t.test(fixture.name, async (subtest) => { + const codexHome = await makeLockHome(subtest); + const lockDir = await writeCanonicalOwner(codexHome, fixture.owner); + await assert.rejects(acquireLock(codexHome, "sync", lockOptions()), fixture.expected); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")), + fixture.owner + ); + }); + } }); diff --git a/test/sqlite-online-backup.test.js b/test/sqlite-online-backup.test.js new file mode 100644 index 0000000..3289f46 --- /dev/null +++ b/test/sqlite-online-backup.test.js @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { openDatabase } from "../src/sqlite.js"; +import { + configureSqliteWriteDurability, + createSqliteOnlineBackup, + updateSqliteProvider +} from "../src/sqlite-state.js"; + +async function tempDatabase(prefix) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const dbPath = path.join(root, "state_5.sqlite"); + return { + root, + dbPath, + location: { path: dbPath, source: "explicit" } + }; +} + +test("SQLite writes configure synchronous=FULL and Node/.NET update counters agree", async () => { + const fixture = await tempDatabase("provider-sync-sqlite-durability-"); + const db = await openDatabase(fixture.dbPath); + try { + db.exec(` + PRAGMA synchronous = OFF; + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT, + model TEXT + ); + INSERT INTO threads VALUES ('a', 'legacy', 'old'); + INSERT INTO threads VALUES ('b', 'openai', 'old'); + `); + assert.equal(Number(db.prepare("PRAGMA synchronous").get().synchronous), 0); + assert.deepEqual(configureSqliteWriteDurability(db), { synchronous: "full", value: 2 }); + assert.equal(Number(db.prepare("PRAGMA synchronous").get().synchronous), 2); + } finally { + db.close(); + } + + const result = await updateSqliteProvider(fixture.location, "openai", { + targetModel: "new" + }); + assert.deepEqual( + { + updatedRows: result.updatedRows, + providerRowsUpdated: result.providerRowsUpdated, + modelRowsUpdated: result.modelRowsUpdated + }, + { updatedRows: 3, providerRowsUpdated: 1, modelRowsUpdated: 2 } + ); + + const verified = await openDatabase(fixture.dbPath, { readOnly: true }); + try { + assert.deepEqual( + verified.prepare("SELECT model_provider, model FROM threads ORDER BY id").all() + .map((row) => ({ model_provider: row.model_provider, model: row.model })), + [ + { model_provider: "openai", model: "new" }, + { model_provider: "openai", model: "new" } + ] + ); + } finally { + verified.close(); + await fs.rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("official SQLite online backup captures live WAL into one standalone main file", async () => { + const fixture = await tempDatabase("provider-sync-sqlite-online-backup-"); + const backupPath = path.join(fixture.root, "backup", "state_5.sqlite"); + const source = await openDatabase(fixture.dbPath); + try { + source.exec("PRAGMA page_size = 8192"); + source.exec("VACUUM"); + assert.equal(source.prepare("PRAGMA journal_mode = WAL").get().journal_mode, "wal"); + source.exec("PRAGMA user_version = 73"); + source.exec("PRAGMA application_id = 1129333840"); + source.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT)"); + source.prepare("INSERT INTO threads VALUES (?, ?)").run("wal-row", "openai"); + assert.ok((await fs.stat(`${fixture.dbPath}-wal`)).size > 0); + + const result = await createSqliteOnlineBackup(fixture.location, backupPath); + assert.equal(result.databasePresent, true); + assert.ok(["node:sqlite", "better-sqlite3"].includes(result.driver)); + assert.deepEqual(result.metadata.source, { + journalMode: "wal", + pageSize: 8192, + userVersion: 73, + applicationId: 1129333840 + }); + assert.deepEqual(result.metadata.backup, result.metadata.source); + assert.deepEqual(result.metadata.preserved, { + journalMode: true, + pageSize: true, + userVersion: true, + applicationId: true + }); + assert.equal(await fs.stat(backupPath).then((stat) => stat.isFile()), true); + await assert.rejects(fs.access(`${backupPath}-wal`), { code: "ENOENT" }); + await assert.rejects(fs.access(`${backupPath}-shm`), { code: "ENOENT" }); + } finally { + source.close(); + } + + const backup = await openDatabase(backupPath); + try { + assert.equal( + backup.prepare("SELECT model_provider FROM threads WHERE id = ?").get("wal-row").model_provider, + "openai" + ); + } finally { + backup.close(); + await fs.rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/test/sync-service.test.js b/test/sync-service.test.js index e55e26f..7d3328b 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -7,6 +7,7 @@ import path from "node:path"; import { createBackup, + getBackupRecoveryCoverage, getBackupSummary, pruneBackups, restoreBackup, @@ -17,8 +18,12 @@ import { DB_FILE_BASENAME, DEFAULT_BACKUP_RETENTION_COUNT, SQLITE_DIR_BASENAME } import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; -import { TransactionJournal, findPendingTransactions } from "../src/transaction-journal.js"; -import { writeFileAtomic } from "../src/atomic-file.js"; +import { + TransactionJournal, + findPendingTransactions, + readTransactionJournal +} from "../src/transaction-journal.js"; +import { syncDirectory, writeFileAtomic } from "../src/atomic-file.js"; delete process.env.CODEX_SQLITE_HOME; @@ -36,18 +41,25 @@ test("runSync rolls back the first rollout when a later target fails (#69)", asy const firstBefore = await fs.readFile(firstPath, "utf8"); const secondBefore = await fs.readFile(secondPath, "utf8"); - await assert.rejects( - runSync({ + let error; + try { + await runSync({ codexHome, provider: "openai", - faultInjector: ({ point, appliedCount }) => { - if (point === "after_rollout_apply" && appliedCount === 1) { + faultInjector: ({ point, targetIndex }) => { + if (point === "before_rollout_apply" && targetIndex === 2) { throw new Error("injected second-target failure"); } } - }), - /injected second-target failure/ - ); + }); + assert.fail("runSync should fail before applying the second target"); + } catch (caught) { + error = caught; + } + + assert.match(error.originalError.message, /injected second-target failure/); + assert.deepEqual(error.completedTargets, [path.resolve(firstPath)]); + assert.deepEqual(error.uncompletedTargets, []); assert.equal(await fs.readFile(firstPath, "utf8"), firstBefore); assert.equal(await fs.readFile(secondPath, "utf8"), secondBefore); @@ -95,6 +107,300 @@ test("runSync restores global-state primary when backup write fails (#69)", asyn assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("failure after SQLite commit restores rollout and database", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-after-sqlite.jsonl"); + await writeRollout(sessionPath, "thread-after-sqlite", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-after-sqlite", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + faultInjector: ({ point }) => { + if (point === "after_sqlite_commit") { + throw new Error("injected post-SQLite failure"); + } + } + }); + assert.fail("runSync should fail after the committed SQLite update"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.code, "SYNC_FAILED_ROLLED_BACK"); + assert.equal(error.rollbackStatus, "complete"); + assert.equal(error.recoveryRequired, false); + assert.ok(error.completedTargets.includes(path.resolve(sessionPath))); + assert.ok(error.completedTargets.includes(path.resolve(stateDbPath(codexHome)))); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-after-sqlite"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("cancellation after SQLite commit restores rollout and database", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cancel-after-sqlite.jsonl"); + await writeRollout(sessionPath, "thread-cancel-after-sqlite", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-cancel-after-sqlite", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + const controller = new AbortController(); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + signal: controller.signal, + faultInjector: ({ point }) => { + if (point === "after_sqlite_commit") { + controller.abort(); + } + } + }); + assert.fail("runSync should observe cancellation before transaction commit"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.originalError.name, "AbortError"); + assert.equal(error.rollbackStatus, "complete"); + assert.equal(error.recoveryRequired, false); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-cancel-after-sqlite"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("cancellation after transaction commit does not roll back committed state", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cancel-after-commit.jsonl"); + await writeRollout(sessionPath, "thread-cancel-after-commit", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-cancel-after-commit", model_provider: "apigather" }]); + const controller = new AbortController(); + + const result = await runSync({ + codexHome, + provider: "openai", + signal: controller.signal, + faultInjector: ({ point }) => { + if (point === "after_transaction_commit") { + controller.abort(); + } + } + }); + + assert.equal(result.changedSessionFiles, 1); + assert.equal(await readProvider(codexHome, "thread-cancel-after-commit"), "openai"); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"openai"/); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("a lost acknowledgement after a durable journal commit is reconciled as success", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-commit-ack.jsonl"); + await writeRollout(sessionPath, "thread-commit-ack", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-commit-ack", model_provider: "apigather" }]); + + const result = await runSync({ + codexHome, + faultInjector: ({ point }) => { + if (point === "after_transaction_journal_commit_before_ack") { + throw new Error("injected lost commit acknowledgement"); + } + } + }); + + assert.equal(result.changedSessionFiles, 1); + assert.equal(await readProvider(codexHome, "thread-commit-ack"), "openai"); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"openai"/); + const journal = await readTransactionJournal(path.join(result.backupDir, "transaction-journal.jsonl")); + assert.equal(journal.state, "committed"); + assert.equal(journal.terminal, true); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("commit acknowledgement reconciliation rejects a terminal from another operation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-foreign-commit.jsonl"); + await writeRollout(sessionPath, "thread-foreign-commit", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-foreign-commit", model_provider: "apigather" }]); + let backupDir = null; + + await assert.rejects( + runSync({ + codexHome, + onProgress(event) { + if (event.stage === "create_backup" && event.status === "complete") { + backupDir = event.backupDir; + } + }, + async faultInjector({ point }) { + if (point !== "after_transaction_journal_commit_before_ack") { + return; + } + const journalPath = path.join(backupDir, "transaction-journal.jsonl"); + const events = (await fs.readFile(journalPath, "utf8")) + .trim() + .split("\n") + .map((line) => ({ ...JSON.parse(line), operationId: "foreign-operation-id" })); + await fs.writeFile(journalPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + throw new Error("injected lost commit acknowledgement"); + } + }), + /injected lost commit acknowledgement/ + ); + + const journal = await readTransactionJournal(path.join(backupDir, "transaction-journal.jsonl")); + assert.equal(journal.terminal, true); + assert.equal(journal.state, "committed"); + assert.equal(journal.operationId, "foreign-operation-id"); +}); + +test("an exception after the committed terminal never triggers rollback", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-post-commit-error.jsonl"); + await writeRollout(sessionPath, "thread-post-commit-error", "apigather"); + let backupDir = null; + + await assert.rejects( + runSync({ + codexHome, + onProgress(event) { + if (event.stage === "create_backup" && event.status === "complete") { + backupDir = event.backupDir; + } + }, + faultInjector: ({ point }) => { + if (point === "after_transaction_commit") { + throw new Error("injected post-commit observer failure"); + } + } + }), + /post-commit observer failure/ + ); + + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"openai"/); + const journal = await readTransactionJournal(path.join(backupDir, "transaction-journal.jsonl")); + assert.equal(journal.state, "committed"); + assert.equal(journal.terminal, true); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("progress observer failures before and after commit are non-fatal", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-progress-failure.jsonl"); + await writeRollout(sessionPath, "thread-progress-failure", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-progress-failure", model_provider: "apigather" }]); + const observedFailures = []; + + const result = await runSync({ + codexHome, + onProgress(event) { + if ((event.stage === "update_sqlite" && event.status === "complete") + || (event.stage === "clean_backups" && event.status === "start")) { + observedFailures.push(`${event.stage}:${event.status}`); + throw new Error("injected progress observer failure"); + } + } + }); + + assert.deepEqual(observedFailures, ["update_sqlite:complete", "clean_backups:start"]); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"openai"/); + assert.equal(await readProvider(codexHome, "thread-progress-failure"), "openai"); + const journal = await readTransactionJournal(path.join(result.backupDir, "transaction-journal.jsonl")); + assert.equal(journal.state, "committed"); + assert.equal(journal.terminal, true); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("failure before transaction commit rolls back before pruning old backups", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-before-commit-failure.jsonl"); + await writeRollout(sessionPath, "thread-before-commit", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-before-commit", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + const oldBackupDir = path.join(backupRoot(codexHome), "20260319T000000000Z"); + await writeBackup(codexHome, "20260319T000000000Z", [["note.txt", "must-survive"]]); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + keepCount: 1, + faultInjector: ({ point }) => { + if (point === "before_transaction_commit") { + throw new Error("injected transaction-commit failure"); + } + } + }); + assert.fail("runSync should fail before committing the transaction"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.rollbackStatus, "complete"); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-before-commit"), "apigather"); + await fs.access(oldBackupDir); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("SQLite rollback failure preserves recovery evidence and manual restore recovers", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-sqlite-rollback-failure.jsonl"); + await writeRollout(sessionPath, "thread-sqlite-rollback", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-sqlite-rollback", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + faultInjector: ({ point }) => { + if (point === "after_sqlite_commit") { + throw new Error("injected post-SQLite failure"); + } + if (point === "before_sqlite_rollback") { + throw new Error("injected SQLite rollback failure"); + } + } + }); + assert.fail("runSync should require recovery when SQLite compensation fails"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.equal(error.rollbackStatus, "incomplete"); + assert.equal(error.recoveryRequired, true); + assert.ok(error.rollbackErrors.some((value) => value.includes("injected SQLite rollback failure"))); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-sqlite-rollback"), "openai"); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + + await runRestore({ backupDir: error.backupDir, codexHome }); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-sqlite-rollback"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + test("unfinished journal blocks writes until the bound backup is restored", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -126,6 +432,53 @@ test("unfinished journal blocks writes until the bound backup is restored", asyn assert.equal(result.targetProvider, "openai"); }); +test("crash recovery restores actually mutated rollout and database from a pending journal", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-crash-recovery.jsonl"); + await writeRollout(sessionPath, "thread-crash-recovery", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-crash-recovery", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath: path.join(codexHome, "config.toml") + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [sessionPath, stateDbPath(codexHome)] + }); + + await journal.applying("rollout", sessionPath); + await applySessionChanges(changes); + await journal.applied("rollout", sessionPath); + await journal.applying("sqlite", stateDbPath(codexHome)); + const db = await openDatabase(stateDbPath(codexHome)); + try { + db.prepare("UPDATE threads SET model_provider = ? WHERE id = ?") + .run("openai", "thread-crash-recovery"); + } finally { + db.close(); + } + await journal.applied("sqlite", stateDbPath(codexHome)); + + assert.notEqual(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-crash-recovery"), "openai"); + assert.equal((await getStatus({ codexHome })).pendingTransactions.length, 1); + await assert.rejects( + runSync({ codexHome }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + await runRestore({ backupDir, codexHome }); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-crash-recovery"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + test("rollback failure preserves both errors and manual recovery evidence", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -214,6 +567,460 @@ test("cancellation after the first target rolls back disk and SQLite with struct assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("cancellation after the only rollout is observed before SQLite commit", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cancel-only.jsonl"); + await writeRollout(sessionPath, "thread-cancel-only", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-cancel-only", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + const controller = new AbortController(); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + signal: controller.signal, + faultInjector: ({ point, appliedCount }) => { + if (point === "after_rollout_apply" && appliedCount === 1) { + controller.abort(); + } + } + }); + assert.fail("runSync should observe cancellation before committing SQLite"); + } catch (caught) { + error = caught; + } + + assert.equal(error.name, "SyncTransactionError"); + assert.equal(error.originalError.name, "AbortError"); + assert.equal(error.rollbackStatus, "complete"); + assert.equal(error.recoveryRequired, false); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal(await readProvider(codexHome, "thread-cancel-only"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("concurrent sync is rejected by the operation lock without competing mutation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-concurrent.jsonl"); + await writeRollout(sessionPath, "thread-concurrent", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-concurrent", model_provider: "apigather" }]); + let releaseFirstOperation; + const holdFirstOperation = new Promise((resolve) => { + releaseFirstOperation = resolve; + }); + let firstMutationObserved; + const firstMutation = new Promise((resolve) => { + firstMutationObserved = resolve; + }); + const firstSync = runSync({ + codexHome, + provider: "openai", + faultInjector: async ({ point, appliedCount }) => { + if (point === "after_rollout_apply" && appliedCount === 1) { + firstMutationObserved(); + await holdFirstOperation; + } + } + }); + await firstMutation; + + try { + await assert.rejects( + runSync({ codexHome, provider: "openai" }), + /Lock already exists/ + ); + } finally { + releaseFirstOperation(); + } + + const result = await firstSync; + assert.equal(result.changedSessionFiles, 1); + assert.equal(await readProvider(codexHome, "thread-concurrent"), "openai"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("repeated sync is idempotent for rollout and SQLite state", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-idempotent.jsonl"); + await writeRollout(sessionPath, "thread-idempotent", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-idempotent", model_provider: "apigather" }]); + + const first = await runSync({ codexHome, provider: "openai" }); + const afterFirst = await fs.readFile(sessionPath, "utf8"); + const second = await runSync({ codexHome, provider: "openai" }); + + assert.equal(first.changedSessionFiles, 1); + assert.equal(first.sqliteProviderRowsUpdated, 1); + assert.equal(second.changedSessionFiles, 0); + assert.equal(second.sqliteProviderRowsUpdated, 0); + assert.equal(second.sqliteRowsUpdated, 0); + assert.equal(await fs.readFile(sessionPath, "utf8"), afterFirst); + assert.equal(await readProvider(codexHome, "thread-idempotent"), "openai"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("failure after model mutation but before journal applied restores full rollout bytes", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"\nmodel = "gpt-new"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "06", "09", "rollout-model-window.jsonl"); + await writeRolloutWithTurnContext(sessionPath, { + id: "thread-model-window", + provider: "apigather", + model: "gpt-old" + }); + const before = await fs.readFile(sessionPath); + + let error; + try { + await runSync({ + codexHome, + provider: "openai", + model: "gpt-new", + faultInjector: ({ point, mutation }) => { + if (point === "after_rollout_mutation_before_applied" && mutation?.stage === "model") { + throw new Error("injected after-model-before-applied failure"); + } + } + }); + assert.fail("runSync should fail after the model rename"); + } catch (caught) { + error = caught; + } + + assert.equal(error.code, "SYNC_FAILED_ROLLED_BACK"); + assert.ok(error.completedTargets.includes(path.resolve(sessionPath))); + assert.deepEqual(await fs.readFile(sessionPath), before); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("immutable full manifest restores a later applying target after abrupt exit", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const firstPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-crash-full-a.jsonl"); + const secondPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-crash-full-b.jsonl"); + await writeRollout(firstPath, "thread-crash-full-a", "apigather"); + await writeRollout(secondPath, "thread-crash-full-b", "apigather"); + const before = await Promise.all([fs.readFile(firstPath), fs.readFile(secondPath)]); + + const childScript = ` + import path from "node:path"; + import { runSync } from "./src/service.js"; + const codexHome = ${JSON.stringify(codexHome)}; + const secondPath = ${JSON.stringify(secondPath)}; + await runSync({ + codexHome, + provider: "openai", + faultInjector: ({ point, path: targetPath, mutation }) => { + if (point === "after_rollout_mutation_before_applied" + && mutation?.stage === "firstLine" + && path.resolve(targetPath) === path.resolve(secondPath)) { + process.exit(23); + } + } + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childScript], { + cwd: path.resolve(".") + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + assert.equal(exitCode, 23); + assert.notDeepEqual(await fs.readFile(secondPath), before[1]); + + const status = await getStatus({ codexHome }); + assert.equal(status.pendingTransactions.length, 1); + await assert.rejects(runSync({ codexHome }), (caught) => caught?.code === "RECOVERY_REQUIRED"); + const [backupDir] = await fs.readdir(backupRoot(codexHome)); + const fullBackupDir = path.join(backupRoot(codexHome), backupDir); + const manifest = JSON.parse(await fs.readFile(path.join(fullBackupDir, "session-meta-backup.json"), "utf8")); + assert.equal(manifest.files.length, 2); + + await runRestore({ + codexHome, + backupDir: fullBackupDir, + restoreConfig: false, + restoreDatabase: false + }); + assert.deepEqual(await fs.readFile(firstPath), before[0]); + assert.deepEqual(await fs.readFile(secondPath), before[1]); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("foreign operationId and missing-final-newline journals stay pending until explicit restore", async (t) => { + for (const corruption of ["foreign-operation", "missing-final-newline", "commit-after-recovery-required"]) { + await t.test(corruption, async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + if (corruption === "foreign-operation") { + await fs.appendFile(journal.filePath, `${JSON.stringify({ + protocolVersion: 1, + operationId: "foreign-operation-id", + sequence: 2, + state: "committed", + recordedAt: new Date().toISOString() + })}\n`, "utf8"); + } else if (corruption === "missing-final-newline") { + const text = await fs.readFile(journal.filePath, "utf8"); + await fs.writeFile(journal.filePath, text.replace(/\n$/, ""), "utf8"); + } else { + await journal.recoveryRequired(new Error("original"), ["rollback"]); + await fs.appendFile(journal.filePath, `${JSON.stringify({ + protocolVersion: 1, + operationId: journal.operationId, + sequence: 3, + state: "committed", + recordedAt: new Date().toISOString() + })}\n`, "utf8"); + } + + const corrupted = await readTransactionJournal(journal.filePath); + assert.equal(corrupted.invalidTail, true); + assert.equal(corrupted.terminal, false); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + + await runRestore({ + codexHome, + backupDir, + restoreDatabase: false, + restoreSessions: false + }); + const repaired = await readTransactionJournal(journal.filePath); + assert.equal(repaired.invalidTail, false); + assert.equal(repaired.state, "rolledBack"); + assert.equal(repaired.terminal, true); + assert.deepEqual(await findPendingTransactions(codexHome), []); + assert.ok((await fs.readdir(backupDir)).some((name) => name.startsWith("transaction-journal.jsonl.invalid."))); + }); + } +}); + +test("journal rejects a target outside prepared potentialTargets", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + await fs.appendFile(journal.filePath, `${JSON.stringify({ + protocolVersion: 1, + operationId: journal.operationId, + sequence: 2, + state: "applying", + kind: "config", + targetPath: path.join(codexHome, "outside.toml"), + recordedAt: new Date().toISOString() + })}\n`, "utf8"); + + const parsed = await readTransactionJournal(journal.filePath); + assert.equal(parsed.invalidTail, true); + assert.match(parsed.validationError, /potentialTargets/); +}); + +test("journal rejects forged rolledBack and duplicate journal creation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + await assert.rejects( + TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }), + (error) => error?.code === "EEXIST" + ); + await fs.appendFile(journal.filePath, `${JSON.stringify({ + protocolVersion: 1, + operationId: journal.operationId, + sequence: 2, + state: "rolledBack", + recordedAt: new Date().toISOString() + })}\n`, "utf8"); + const parsed = await readTransactionJournal(journal.filePath); + assert.equal(parsed.invalidTail, true); + assert.match(parsed.validationError, /without first entering rollback/); +}); + +test("partial restore cannot clear a pending SQLite transaction", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeStateDb(codexHome, [{ id: "thread-partial-restore", model_provider: "apigather" }]); + const dbPath = stateDbPath(codexHome); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath: path.join(codexHome, "config.toml") + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [dbPath] + }); + await journal.applying("sqlite", dbPath); + const db = await openDatabase(dbPath); + try { + db.prepare("UPDATE threads SET model_provider = 'openai'").run(); + } finally { + db.close(); + } + await journal.applied("sqlite", dbPath); + + await assert.rejects( + runRestore({ + codexHome, + backupDir, + restoreConfig: false, + restoreDatabase: false, + restoreSessions: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + && error.missingRestoreKinds.includes("SQLite database") + ); + assert.equal(await readProvider(codexHome, "thread-partial-restore"), "openai"); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + + await runRestore({ + codexHome, + backupDir, + restoreConfig: false, + restoreDatabase: true, + restoreSessions: false + }); + assert.equal(await readProvider(codexHome, "thread-partial-restore"), "apigather"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("pruning protects the journal directory instead of trusting recorded backupDir", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + const [prepared] = (await fs.readFile(journal.filePath, "utf8")).trim().split("\n").map(JSON.parse); + prepared.backupDir = path.join(codexHome, "unrelated-backup"); + await fs.writeFile(journal.filePath, `${JSON.stringify(prepared)}\n`, "utf8"); + + await pruneBackups(codexHome, 0); + await fs.access(backupDir); + assert.equal((await findPendingTransactions(codexHome)).length, 1); +}); + +test("rollback removes a global-state backup that did not exist before the operation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const statePath = path.join(codexHome, ".codex-global-state.json"); + const stateBackupPath = `${statePath}.bak`; + const original = `${JSON.stringify({ + "electron-saved-workspace-roots": ["C:\\AITemp"], + "project-order": ["C:\\AITemp"], + "active-workspace-roots": ["C:\\AITemp"] + }, null, 2)}\n`; + await fs.writeFile(statePath, original, "utf8"); + + await assert.rejects( + runSync({ + codexHome, + faultInjector: ({ point, path: targetPath }) => { + if (point === "after_global_state_apply" && targetPath === stateBackupPath) { + throw new Error("injected after global-state backup creation"); + } + } + }), + /after global-state backup creation/ + ); + + assert.equal(await fs.readFile(statePath, "utf8"), original); + await assert.rejects(fs.access(stateBackupPath), (error) => error?.code === "ENOENT"); +}); + +test("switch preserves structured original and config rollback failures inside the operation lock", async () => { + const { codexHome } = await makeTempCodexHome(); + const originalConfig = `model_provider = "openai"\n\n[model_providers.apigather]\nbase_url = "https://example.com"\n`; + const configPath = path.join(codexHome, "config.toml"); + await fs.writeFile(configPath, originalConfig, "utf8"); + + let error; + try { + await runSwitch({ + codexHome, + provider: "apigather", + faultInjector: ({ point }) => { + if (point === "after_config_mutation_before_applied") { + throw new Error("injected switch failure"); + } + if (point === "before_config_rollback") { + throw new Error("injected config rollback failure"); + } + } + }); + assert.fail("switch should require recovery"); + } catch (caught) { + error = caught; + } + + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.match(error.originalError.message, /injected switch failure/); + assert.ok(error.rollbackErrors.some((message) => message.includes("injected config rollback failure"))); + assert.match(await fs.readFile(configPath, "utf8"), /^model_provider = "apigather"/m); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + + await runRestore({ + codexHome, + backupDir: error.backupDir, + restoreDatabase: false, + restoreSessions: false + }); + assert.equal(await fs.readFile(configPath, "utf8"), originalConfig); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + test("backup failure occurs before journal or target mutation", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -258,6 +1065,39 @@ test("atomic replacement failure preserves the original file and removes staging assert.deepEqual(staging, []); }); +test("backup, atomic rollout rewrite, and restore preserve restrictive file modes", async () => { + if (process.platform === "win32") { + return; + } + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-mode.jsonl"); + await writeRollout(sessionPath, "thread-mode", "apigather"); + await fs.chmod(configPath, 0o600); + await fs.chmod(sessionPath, 0o600); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + assert.equal((await fs.stat(path.join(backupDir, "config.toml"))).mode & 0o777, 0o600); + assert.equal((await fs.stat(path.join(backupDir, "session-meta-backup.json"))).mode & 0o777, 0o600); + + await applySessionChanges(changes); + await updateSessionBackupManifest(backupDir, changes); + assert.equal((await fs.stat(sessionPath)).mode & 0o777, 0o600); + await restoreBackup(backupDir, codexHome, { + restoreConfig: true, + restoreDatabase: false, + restoreSessions: true + }); + assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600); + assert.equal((await fs.stat(sessionPath)).mode & 0o777, 0o600); +}); + for (const faultPoint of ["before_stage_write", "before_atomic_replace"]) { test(`atomic writer ${faultPoint} failure preserves the original and removes staging`, async () => { const { root } = await makeTempCodexHome(); @@ -432,6 +1272,16 @@ async function writeStateDb(codexHome, rows) { await writeStateDbAt(stateDbPath(codexHome), rows); } +async function readProvider(codexHome, threadId) { + const db = await openDatabase(stateDbPath(codexHome)); + try { + return db.prepare("SELECT model_provider FROM threads WHERE id = ?") + .get(threadId).model_provider; + } finally { + db.close(); + } +} + async function writeLegacyStateDb(codexHome, rows) { await writeStateDbAt(legacyStateDbPath(codexHome), rows); } @@ -2147,7 +2997,7 @@ test("applySessionChanges preserves large UTF-8 session metadata", async () => { assert.match(rollout, /"large_blob":"数据块数据块/); }); -test("applySessionChanges replaces equal-length provider IDs in place", async () => { +test("applySessionChanges atomically replaces equal-length provider IDs", async () => { const { codexHome } = await makeTempCodexHome(); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-in-place.jsonl"); await writeRollout(sessionPath, "thread-in-place", "openai"); @@ -2163,22 +3013,17 @@ test("applySessionChanges replaces equal-length provider IDs in place", async () const originalTime = new Date("2026-01-02T03:04:05.000Z"); await fs.utimes(sessionPath, originalTime, originalTime); - const before = await fs.stat(sessionPath); const { changes } = await collectSessionChanges(codexHome, "prov_a"); const result = await applySessionChanges(changes); const after = await fs.stat(sessionPath); const rollout = await fs.readFile(sessionPath, "utf8"); assert.equal(result.appliedChanges, 1); - assert.equal(result.inPlaceChanges, 1); - if (process.platform !== "win32") { - assert.equal(after.ino, before.ino); - } + assert.equal(result.inPlaceChanges, 0); assert.equal(Math.round(after.mtimeMs), originalTime.getTime()); - assert.equal( - rollout, - original.replace('"model_provider" : "openai"', '"model_provider" : "prov_a"') - ); + const firstNewline = rollout.indexOf("\n"); + assert.equal(JSON.parse(rollout.slice(0, firstNewline)).payload.model_provider, "prov_a"); + assert.equal(rollout.slice(firstNewline + 1), original.slice(original.indexOf("\n") + 1)); }); test("applySessionChanges falls back when equal-length provider IDs have different JSON byte lengths", async () => { @@ -2368,6 +3213,120 @@ test("restoreBackup only restores rollout files that were actually applied", asy assert.match(rollout, /"model_provider":"manual"/); }); +test("restoreBackup rejects escaped and non-rollout session manifest targets", async () => { + const { root, codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-safe.jsonl"); + await writeRollout(sessionPath, "thread-safe", "apigather"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + const manifestPath = path.join(backupDir, "session-meta-backup.json"); + const originalManifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + + const escapedPath = path.join(root, "rollout-escaped.jsonl"); + await fs.writeFile(escapedPath, "outside", "utf8"); + const escapedManifest = structuredClone(originalManifest); + escapedManifest.files[0].path = escapedPath; + await fs.writeFile(manifestPath, JSON.stringify(escapedManifest), "utf8"); + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }), + /outside the allowed rollout roots/ + ); + + const nonRolloutPath = path.join(codexHome, "sessions", "2026", "03", "19", "arbitrary.jsonl"); + await fs.writeFile(nonRolloutPath, "inside but not a rollout", "utf8"); + const nonRolloutManifest = structuredClone(originalManifest); + nonRolloutManifest.files[0].path = nonRolloutPath; + await fs.writeFile(manifestPath, JSON.stringify(nonRolloutManifest), "utf8"); + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }), + /outside the allowed rollout roots/ + ); +}); + +test("Windows restore attempts every rollout even when one target is locked", async () => { + if (process.platform !== "win32") { + return; + } + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const lockedPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-restore-locked.jsonl"); + const restorablePath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-restore-restorable.jsonl"); + await writeRollout(lockedPath, "thread-restore-locked", "apigather"); + await writeRollout(restorablePath, "thread-restore-restorable", "apigather"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath: path.join(codexHome, "config.toml") + }); + await applySessionChanges(changes); + await updateSessionBackupManifest(backupDir, changes); + const lockProcess = await lockRolloutFile(lockedPath); + try { + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }), + (error) => error instanceof AggregateError + && error.failures.some((failure) => failure.path === lockedPath) + ); + assert.match(await fs.readFile(restorablePath, "utf8"), /"model_provider":"apigather"/); + } finally { + lockProcess.kill(); + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } + + assert.match(await fs.readFile(lockedPath, "utf8"), /"model_provider":"openai"/); + + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }); + assert.match(await fs.readFile(lockedPath, "utf8"), /"model_provider":"apigather"/); +}); + +test("restore fails when global-state presence metadata points to a missing backup copy", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeGlobalState(codexHome, { "project-order": ["C:\\AITemp"] }); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath: path.join(codexHome, "config.toml") + }); + await fs.rm(path.join(backupDir, ".codex-global-state.json.bak")); + + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreGlobalState: true, + restoreDatabase: false, + restoreSessions: false + }), + /was present, but its backup copy is missing/ + ); +}); + test("restoreBackup can skip config, database, and sessions", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -2579,3 +3538,300 @@ test("cli sync prints stage progress and backup timing", async () => { assert.match(result.stdout, /Backup created in .*: .+/); assert.match(result.stdout, /Backup creation time: /); }); + +test("syncDirectory only downgrades known unsupported flush errors on Windows", async () => { + const permissionError = Object.assign(new Error("directory flush denied"), { code: "EPERM" }); + const unsupportedFs = { + async open() { + throw permissionError; + } + }; + await assert.rejects( + syncDirectory("/tmp/provider-sync-dir", { fsImpl: unsupportedFs, platform: "linux" }), + /directory flush denied/ + ); + await syncDirectory("C:\\provider-sync-dir", { fsImpl: unsupportedFs, platform: "win32" }); + + const ioError = Object.assign(new Error("directory flush I/O failure"), { code: "EIO" }); + await assert.rejects( + syncDirectory("C:\\provider-sync-dir", { + fsImpl: { async open() { throw ioError; } }, + platform: "win32" + }), + /I\/O failure/ + ); +}); + +test("runRestore rejects all-disabled recovery when the first journal record is corrupt", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-corrupt-first.jsonl"); + await writeRollout(sessionPath, "thread-corrupt-first", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-corrupt-first", model_provider: "apigather" }]); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath, sessionPath, stateDbPath(codexHome)] + }); + await fs.writeFile(journal.filePath, "{corrupt first record\n", "utf8"); + + await assert.rejects( + runRestore({ + backupDir, + codexHome, + restoreConfig: false, + restoreDatabase: false, + restoreSessions: false + }), + (error) => { + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.deepEqual( + new Set(error.missingRestoreKinds), + new Set(["rollout sessions", "SQLite database", "config.toml"]) + ); + return true; + } + ); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + assert.equal((await readTransactionJournal(journal.filePath)).terminal, false); +}); + +test("one explicit restore repairs an empty crash-window journal", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const journalPath = path.join(backupDir, "transaction-journal.jsonl"); + await fs.writeFile(journalPath, "", "utf8"); + + await runRestore({ + backupDir, + codexHome, + restoreConfig: true, + restoreDatabase: false, + restoreSessions: false + }); + + const repaired = await readTransactionJournal(journalPath); + assert.equal(repaired.invalidTail, false); + assert.equal(repaired.terminal, true); + assert.equal(repaired.state, "rolledBack"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("an all-false global-state presence map remains conservative recovery coverage", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const metadata = JSON.parse(await fs.readFile(path.join(backupDir, "metadata.json"), "utf8")); + assert.deepEqual(metadata.globalStateFiles, { + ".codex-global-state.json": false, + ".codex-global-state.json.bak": false + }); + assert.equal((await getBackupRecoveryCoverage(backupDir, codexHome)).globalState, true); + await fs.writeFile(path.join(backupDir, "transaction-journal.jsonl"), "", "utf8"); + + await assert.rejects( + runRestore({ + backupDir, + codexHome, + restoreConfig: false, + restoreDatabase: false, + restoreSessions: false + }), + (error) => { + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.ok(error.missingRestoreKinds.includes("global state")); + return true; + } + ); +}); + +test("invalid journal tail with no validated target restores the full session manifest", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-tail-coverage.jsonl"); + await writeRollout(sessionPath, "thread-tail-coverage", "apigather"); + const original = await fs.readFile(sessionPath, "utf8"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [sessionPath] + }); + await writeRollout(sessionPath, "thread-tail-coverage", "openai"); + await fs.appendFile(journal.filePath, "{torn target record", "utf8"); + + await runRestore({ + backupDir, + codexHome, + restoreConfig: true, + restoreDatabase: false, + restoreSessions: true + }); + + assert.equal(await fs.readFile(sessionPath, "utf8"), original); + const repaired = await readTransactionJournal(journal.filePath); + assert.equal(repaired.state, "rolledBack"); + assert.equal(repaired.terminal, true); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("automatic rollback does not report success unless the rolledBack terminal re-reads valid", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-terminal-verify.jsonl"); + await writeRollout(sessionPath, "thread-terminal-verify", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-terminal-verify", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + let backupDir = null; + + await assert.rejects( + runSync({ + codexHome, + onProgress(event) { + if (event.stage === "create_backup" && event.status === "complete") { + backupDir = event.backupDir; + } + }, + async faultInjector({ point }) { + if (point === "after_rollout_apply") { + throw new Error("injected post-apply failure"); + } + if (point === "before_rollout_rollback") { + await fs.appendFile( + path.join(backupDir, "transaction-journal.jsonl"), + `${JSON.stringify({ protocolVersion: 1, operationId: "foreign", sequence: 999, state: "rollingBack" })}\n`, + "utf8" + ); + } + } + }), + (error) => { + assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.equal(error.rollbackStatus, "incomplete"); + assert.match(error.rollbackErrors.join("; "), /valid rolledBack terminal state/); + return true; + } + ); + + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + assert.equal((await findPendingTransactions(codexHome)).length, 1); + await runRestore({ backupDir, codexHome }); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("a lost rolledBack acknowledgement never appends recoveryRequired", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-rollback-ack.jsonl"); + await writeRollout(sessionPath, "thread-rollback-ack", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-rollback-ack", model_provider: "apigather" }]); + const before = await fs.readFile(sessionPath, "utf8"); + let caught = null; + + try { + await runSync({ + codexHome, + faultInjector({ point }) { + if (point === "after_rollout_apply") { + throw new Error("injected forward failure"); + } + if (point === "after_transaction_journal_rollback_before_ack") { + throw new Error("injected lost rollback acknowledgement"); + } + } + }); + } catch (error) { + caught = error; + } + + assert.equal(caught?.rollbackStatus, "complete"); + assert.equal(caught?.recoveryRequired, false); + assert.match(caught?.originalError?.message ?? "", /injected forward failure/); + assert.equal(await fs.readFile(sessionPath, "utf8"), before); + const journal = await readTransactionJournal(path.join(caught.backupDir, "transaction-journal.jsonl")); + assert.equal(journal.terminal, true); + assert.equal(journal.state, "rolledBack"); + assert.equal(journal.operationId, journal.events[0].operationId); + assert.equal(journal.events.some((event) => event.state === "recoveryRequired"), false); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + +test("a turn_context appended after first-line mutation remains byte-original on rollback", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"\nmodel = "target-model"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-concurrent-model.jsonl"); + await writeRolloutWithTurnContext(sessionPath, { + id: "thread-concurrent-model", + provider: "apigather", + model: "source-model" + }); + await writeStateDb(codexHome, [{ id: "thread-concurrent-model", model_provider: "apigather" }]); + const appended = JSON.stringify({ + timestamp: "2026-06-09T11:16:03.880Z", + type: "turn_context", + payload: { + turn_id: "concurrent-turn", + model: "concurrent-source-model", + collaboration_mode: { + mode: "default", + settings: { model: "concurrent-source-model", reasoning_effort: "xhigh" } + } + } + }); + let appendedOnce = false; + + await assert.rejects( + runSync({ + codexHome, + model: "target-model", + async faultInjector({ point, mutation }) { + if (point === "after_rollout_mutation_before_applied" + && mutation?.stage === "firstLine" + && !appendedOnce) { + appendedOnce = true; + await fs.appendFile(sessionPath, `${appended}\n`, "utf8"); + } + } + }), + (error) => { + assert.equal(error.code, "SYNC_FAILED_ROLLED_BACK"); + assert.match(error.originalError.message, /model snapshot changed before rewrite/); + return true; + } + ); + + const restored = await fs.readFile(sessionPath, "utf8"); + assert.match(restored.split(/\r?\n/)[0], /"model_provider":"apigather"/); + assert.ok(restored.includes('"model":"source-model"')); + assert.ok(restored.includes('"model":"concurrent-source-model"')); + assert.ok(!restored.includes('"model":"target-model"')); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); From 88dc703e9103f055da13e6eca4ad65f76a7cc3bc Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Tue, 4 Aug 2026 12:56:30 +0800 Subject: [PATCH 05/19] fix: make lock publication portable --- src/locking.js | 28 ++++++++++++++++++++---- test/locking.test.js | 45 +++++++++++++++++++++++++++++++++++++-- test/sync-service.test.js | 6 +++--- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/locking.js b/src/locking.js index 0cae3ec..372b333 100644 --- a/src/locking.js +++ b/src/locking.js @@ -490,6 +490,7 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o currentDirectory: process.cwd() }; let published = false; + let canonicalReserved = false; let claimPath = null; try { claimPath = await publishClaim( @@ -530,11 +531,21 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o let attempts = 0; while (true) { try { - await fsImpl.rename(candidateDir, lockDir); + // Directory rename is not an exclusive publish on POSIX: it may replace + // an existing empty directory. Reserve the canonical name with mkdir, + // then publish the already-durable owner inode with a no-replace link. + await fsImpl.mkdir(lockDir, { mode: 0o700 }); + canonicalReserved = true; + await fsImpl.link(candidateOwnerPath, ownerPath); published = true; + await syncDirectoryImpl(lockDir, { fsImpl, platform }); await syncDirectoryImpl(parentDir, { fsImpl, platform }); + await fsImpl.rm(candidateDir, { recursive: true, force: true }); break; } catch (error) { + if (canonicalReserved) { + throw error; + } const canonicalExists = await pathExists(lockDir, fsImpl); if (canonicalExists) { const existingOwner = await readLockOwner(ownerPath, fsImpl); @@ -566,7 +577,7 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o } } catch (error) { const cleanupFailures = []; - let canonicalCleanupSafe = !published; + let canonicalCleanupSafe = !published && !canonicalReserved; if (published) { try { await removeOwnedCanonical( @@ -581,13 +592,22 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o } catch (cleanupError) { cleanupFailures.push(cleanupError); } - } else { + } else if (canonicalReserved) { try { - await fsImpl.rm(candidateDir, { recursive: true, force: true }); + // rmdir is intentionally non-recursive: if another runtime populated + // the reserved directory, preserve it and retain our claim fail-closed. + await fsImpl.rmdir(lockDir); + await syncDirectoryImpl(parentDir, { fsImpl, platform }); + canonicalCleanupSafe = true; } catch (cleanupError) { cleanupFailures.push(cleanupError); } } + try { + await fsImpl.rm(candidateDir, { recursive: true, force: true }); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } if (claimPath && canonicalCleanupSafe) { try { await removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform); diff --git a/test/locking.test.js b/test/locking.test.js index 65e615c..9f48fba 100644 --- a/test/locking.test.js +++ b/test/locking.test.js @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { rmSync } from "node:fs"; import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; @@ -11,10 +12,21 @@ import { DEFAULT_LOCK_NAME } from "../src/constants.js"; const TEST_STARTED_AT = "2024-01-02T03:04:05.000Z"; const TEST_MARKER = `test:${TEST_STARTED_AT}`; +const tempLockHomes = new Set(); + +process.once("exit", () => { + for (const root of tempLockHomes) { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // Best-effort cleanup after the test process has released its handles. + } + } +}); -async function makeLockHome(t) { +async function makeLockHome() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-lock-")); - t.after(() => fs.rm(root, { recursive: true, force: true })); + tempLockHomes.add(root); return root; } @@ -94,6 +106,35 @@ test("acquireLock retries transient candidate creation failures", async (t) => { await release(); }); +test("owner publication failure removes its empty canonical reservation and claim", async (t) => { + const codexHome = await makeLockHome(t); + const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); + const fsImpl = new Proxy(fs, { + get(target, property) { + if (property === "link") { + return async () => { + const error = new Error("injected owner publication failure"); + error.code = "EIO"; + throw error; + }; + } + const value = target[property]; + return typeof value === "function" ? value.bind(target) : value; + } + }); + + await assert.rejects( + acquireLock(codexHome, "sync", lockOptions({ fsImpl })), + /injected owner publication failure/ + ); + await assert.rejects(fs.access(lockDir), { code: "ENOENT" }); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); + assert.equal( + (await fs.readdir(path.dirname(lockDir))).some((name) => name.includes(".candidate.")), + false + ); +}); + test("acquirePathLock supports an arbitrary future SQLite resource path", async (t) => { const root = await makeLockHome(t); const lockPath = path.join(root, "resource-locks", "state-db.lock"); diff --git a/test/sync-service.test.js b/test/sync-service.test.js index 7d3328b..3759e06 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -3231,7 +3231,7 @@ test("restoreBackup rejects escaped and non-rollout session manifest targets", a const escapedPath = path.join(root, "rollout-escaped.jsonl"); await fs.writeFile(escapedPath, "outside", "utf8"); - const escapedManifest = structuredClone(originalManifest); + const escapedManifest = JSON.parse(JSON.stringify(originalManifest)); escapedManifest.files[0].path = escapedPath; await fs.writeFile(manifestPath, JSON.stringify(escapedManifest), "utf8"); await assert.rejects( @@ -3245,7 +3245,7 @@ test("restoreBackup rejects escaped and non-rollout session manifest targets", a const nonRolloutPath = path.join(codexHome, "sessions", "2026", "03", "19", "arbitrary.jsonl"); await fs.writeFile(nonRolloutPath, "inside but not a rollout", "utf8"); - const nonRolloutManifest = structuredClone(originalManifest); + const nonRolloutManifest = JSON.parse(JSON.stringify(originalManifest)); nonRolloutManifest.files[0].path = nonRolloutPath; await fs.writeFile(manifestPath, JSON.stringify(nonRolloutManifest), "utf8"); await assert.rejects( @@ -3595,7 +3595,7 @@ test("runRestore rejects all-disabled recovery when the first journal record is assert.equal(error.code, "RECOVERY_REQUIRED"); assert.deepEqual( new Set(error.missingRestoreKinds), - new Set(["rollout sessions", "SQLite database", "config.toml"]) + new Set(["rollout sessions", "SQLite database", "config.toml", "global state"]) ); return true; } From ff68279d4ef196c8030831fd038c7f44dbfc632d Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Tue, 4 Aug 2026 13:01:53 +0800 Subject: [PATCH 06/19] fix: snapshot sqlite backups consistently --- .../SqliteOnlineBackupTests.cs | 65 +++++++++++++++++++ .../CodexProviderSync.Core/BackupService.cs | 39 ++++++----- src/backup.js | 35 +++++----- test/sqlite-online-backup.test.js | 62 ++++++++++++++++++ 4 files changed, 169 insertions(+), 32 deletions(-) diff --git a/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs b/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs index 84cbf46..561c4e6 100644 --- a/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/SqliteOnlineBackupTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.Data.Sqlite; namespace CodexProviderSync.Core.Tests; @@ -92,6 +93,70 @@ await ExecuteAsync(source, """ "SELECT model_provider FROM threads WHERE id = 'wal-row'"))); } + [Fact] + public async Task ManagedBackup_SnapshotsLiveWal_AndManifestsOnlyStandaloneMainDatabases() + { + await using Fixture fixture = Fixture.Create(); + string configPath = Path.Combine(fixture.CodexHome, "config.toml"); + await File.WriteAllTextAsync(configPath, "model_provider = \"openai\"\n"); + + await using SqliteConnection source = Open(fixture.DbPath); + await source.OpenAsync(); + Assert.Equal("wal", Convert.ToString(await ScalarObjectAsync(source, "PRAGMA journal_mode = WAL"))); + await ExecuteAsync(source, """ + CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT); + INSERT INTO threads VALUES ('live-wal-row', 'apigather'); + """); + Assert.True(new FileInfo(fixture.DbPath + "-wal").Length > 0); + + BackupService backups = new(new SessionRolloutService(), fixture.Service); + string backupDir = await backups.CreateBackupAsync( + fixture.Storage, + "openai", + [], + configPath); + + using JsonDocument metadata = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(backupDir, "metadata.json"))); + Assert.Equal( + [AppConstants.DbFileBasename], + metadata.RootElement.GetProperty("sqliteDbFiles") + .EnumerateArray() + .Select(static value => value.GetString()!) + .ToArray()); + Assert.Equal( + [Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename)], + metadata.RootElement.GetProperty("dbFiles") + .EnumerateArray() + .Select(static value => value.GetString()!) + .ToArray()); + + string canonicalBackupPath = Path.Combine( + backupDir, + "db", + "sqlite-home", + AppConstants.DbFileBasename); + string legacyMirrorPath = Path.Combine( + backupDir, + "db", + AppConstants.SqliteDirBasename, + AppConstants.DbFileBasename); + foreach (string backupPath in new[] { canonicalBackupPath, legacyMirrorPath }) + { + Assert.True(File.Exists(backupPath)); + Assert.False(File.Exists(backupPath + "-wal")); + Assert.False(File.Exists(backupPath + "-shm")); + } + + await using SqliteConnection backup = Open(canonicalBackupPath, SqliteOpenMode.ReadOnly); + await backup.OpenAsync(); + Assert.Equal( + "apigather", + Convert.ToString(await ScalarObjectAsync( + backup, + "SELECT model_provider FROM threads WHERE id = 'live-wal-row'"))); + } + private sealed class Fixture : IAsyncDisposable { private Fixture(string root) diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index a30d513..f5088b1 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -50,25 +50,30 @@ public async Task CreateBackupAsync( string actualSqliteHome = stateDb is null ? storage.SqliteHome : Path.GetDirectoryName(stateDb.Path)!; if (stateDb is not null) { - foreach (string suffix in new[] { string.Empty, "-shm", "-wal" }) + string sqliteRelativePath = AppConstants.DbFileBasename; + string sqliteBackupPath = Path.Combine(dbDir, "sqlite-home", sqliteRelativePath); + CodexStorageLayout detectedStorage = storage with { StateDbLocation = stateDb }; + SqliteOnlineBackupResult sqliteBackup = await _sqliteStateService.CreateSqliteOnlineBackupAsync( + detectedStorage, + sqliteBackupPath); + if (!sqliteBackup.DatabasePresent) { - string sourcePath = stateDb.Path + suffix; - string sqliteRelativePath = AppConstants.DbFileBasename + suffix; - if (!await CopyIfPresentAsync( - sourcePath, - Path.Combine(dbDir, "sqlite-home", sqliteRelativePath), - overwrite: false)) - { - continue; - } + throw new InvalidOperationException( + $"state_5.sqlite disappeared while creating a backup: {stateDb.Path}"); + } + copiedSqliteDbFiles.Add(sqliteRelativePath); - copiedSqliteDbFiles.Add(sqliteRelativePath); - string? legacyRelativePath = SafeRelativePath(codexHome, sourcePath); - if (legacyRelativePath is not null) - { - await CopyIfPresentAsync(sourcePath, Path.Combine(dbDir, legacyRelativePath), overwrite: false); - copiedDbFiles.Add(legacyRelativePath); - } + // Keep the v2 legacy mirror for readers that still consult DbFiles, + // but derive it from the consistent standalone snapshot. Never + // copy live WAL/SHM sidecars independently into a managed backup. + string? legacyRelativePath = SafeRelativePath(codexHome, stateDb.Path); + if (legacyRelativePath is not null) + { + await AtomicFile.CopyAsync( + sqliteBackupPath, + Path.Combine(dbDir, legacyRelativePath), + overwrite: false); + copiedDbFiles.Add(legacyRelativePath); } } diff --git a/src/backup.js b/src/backup.js index a871857..163b7d3 100644 --- a/src/backup.js +++ b/src/backup.js @@ -11,7 +11,11 @@ import { GLOBAL_STATE_FILE_BASENAME } from "./constants.js"; import { restoreSessionChanges } from "./session-files.js"; -import { assertSqliteWritable, detectStateDb } from "./sqlite-state.js"; +import { + assertSqliteWritable, + createSqliteOnlineBackup, + detectStateDb +} from "./sqlite-state.js"; import { assertSqliteAccessSupported, resolveStorageLayout, @@ -255,20 +259,21 @@ export async function createBackup({ : await detectStateDb(effectiveStorage); const actualSqliteHome = stateDb ? path.dirname(stateDb.path) : effectiveStorage.sqliteHome; if (stateDb) { - for (const suffix of ["", "-shm", "-wal"]) { - const sourcePath = `${stateDb.path}${suffix}`; - const sqliteRelativePath = `${DB_FILE_BASENAME}${suffix}`; - const copied = await copyIfPresent(sourcePath, path.join(dbDir, "sqlite-home", sqliteRelativePath)); - if (!copied) { - continue; - } - copiedSqliteDbFiles.push(sqliteRelativePath); - - const legacyRelativePath = safeRelativePath(codexHome, sourcePath); - if (legacyRelativePath) { - await copyIfPresent(sourcePath, path.join(dbDir, legacyRelativePath)); - copiedDbFiles.push(legacyRelativePath); - } + const sqliteRelativePath = DB_FILE_BASENAME; + const sqliteBackupPath = path.join(dbDir, "sqlite-home", sqliteRelativePath); + const sqliteBackup = await createSqliteOnlineBackup(stateDb, sqliteBackupPath); + if (!sqliteBackup.databasePresent) { + throw new Error(`state_5.sqlite disappeared while creating a backup: ${stateDb.path}`); + } + copiedSqliteDbFiles.push(sqliteRelativePath); + + // Keep the v2 legacy mirror for readers that still consult dbFiles, but + // derive it from the already-consistent standalone snapshot. Never copy + // live WAL/SHM sidecars independently into a managed backup. + const legacyRelativePath = safeRelativePath(codexHome, stateDb.path); + if (legacyRelativePath) { + await copyFileAtomic(sqliteBackupPath, path.join(dbDir, legacyRelativePath)); + copiedDbFiles.push(legacyRelativePath); } } diff --git a/test/sqlite-online-backup.test.js b/test/sqlite-online-backup.test.js index 3289f46..099e866 100644 --- a/test/sqlite-online-backup.test.js +++ b/test/sqlite-online-backup.test.js @@ -4,6 +4,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { createBackup } from "../src/backup.js"; +import { DB_FILE_BASENAME, SQLITE_DIR_BASENAME } from "../src/constants.js"; import { openDatabase } from "../src/sqlite.js"; import { configureSqliteWriteDurability, @@ -118,3 +120,63 @@ test("official SQLite online backup captures live WAL into one standalone main f await fs.rm(fixture.root, { recursive: true, force: true }); } }); + +test("managed backup snapshots live WAL and manifests only standalone main databases", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-managed-online-backup-")); + const codexHome = path.join(root, ".codex"); + const sqliteHome = path.join(codexHome, SQLITE_DIR_BASENAME); + const dbPath = path.join(sqliteHome, DB_FILE_BASENAME); + const configPath = path.join(codexHome, "config.toml"); + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.writeFile(configPath, 'model_provider = "openai"\n', "utf8"); + + const source = await openDatabase(dbPath); + try { + assert.equal(source.prepare("PRAGMA journal_mode = WAL").get().journal_mode, "wal"); + source.exec(` + CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT); + INSERT INTO threads VALUES ('live-wal-row', 'apigather'); + `); + assert.ok((await fs.stat(`${dbPath}-wal`)).size > 0); + + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + const metadata = JSON.parse(await fs.readFile(path.join(backupDir, "metadata.json"), "utf8")); + assert.deepEqual(metadata.sqliteDbFiles, [DB_FILE_BASENAME]); + assert.deepEqual( + metadata.dbFiles.map((fileName) => fileName.replaceAll("\\", "/")), + [`${SQLITE_DIR_BASENAME}/${DB_FILE_BASENAME}`] + ); + + const canonicalBackupPath = path.join(backupDir, "db", "sqlite-home", DB_FILE_BASENAME); + const legacyMirrorPath = path.join( + backupDir, + "db", + SQLITE_DIR_BASENAME, + DB_FILE_BASENAME + ); + for (const backupPath of [canonicalBackupPath, legacyMirrorPath]) { + assert.equal((await fs.stat(backupPath)).isFile(), true); + await assert.rejects(fs.access(`${backupPath}-wal`), { code: "ENOENT" }); + await assert.rejects(fs.access(`${backupPath}-shm`), { code: "ENOENT" }); + } + + const backup = await openDatabase(canonicalBackupPath, { readOnly: true }); + try { + assert.equal( + backup.prepare("SELECT model_provider FROM threads WHERE id = ?") + .get("live-wal-row").model_provider, + "apigather" + ); + } finally { + backup.close(); + } + } finally { + source.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); From fef8cef240a492dc445169e4cbfb96842e055764 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Tue, 4 Aug 2026 13:16:17 +0800 Subject: [PATCH 07/19] feat: add isolated winforms automation bridge --- .../AutomationBootstrapTests.cs | 404 +++++++++ .../GuiAutomationBridgeTests.cs | 228 +++++ .../GuiAutomationManifestTests.cs | 327 +++++++ .../CodexProviderSync.App/AppInstanceGuard.cs | 86 ++ desktop/CodexProviderSync.App/AppPaths.cs | 249 ++++++ .../Automation/AutomationBootstrap.cs | 180 ++++ .../Automation/GuiAutomationBridge.cs | 797 ++++++++++++++++++ .../Automation/GuiAutomationCatalog.cs | 298 +++++++ .../gui-automation-manifest.v0.4.json | 542 ++++++++++++ .../CodexProviderSync.App.csproj | 10 + .../ExecutionLogService.cs | 3 +- .../FocusRequestServer.cs | 10 +- desktop/CodexProviderSync.App/MainForm.cs | 286 +++++-- desktop/CodexProviderSync.App/Program.cs | 89 +- .../CodexProviderSync.App/UpdateApplier.cs | 28 +- 15 files changed, 3445 insertions(+), 92 deletions(-) create mode 100644 desktop/CodexProviderSync.App.Tests/AutomationBootstrapTests.cs create mode 100644 desktop/CodexProviderSync.App.Tests/GuiAutomationBridgeTests.cs create mode 100644 desktop/CodexProviderSync.App.Tests/GuiAutomationManifestTests.cs create mode 100644 desktop/CodexProviderSync.App/AppInstanceGuard.cs create mode 100644 desktop/CodexProviderSync.App/AppPaths.cs create mode 100644 desktop/CodexProviderSync.App/Automation/AutomationBootstrap.cs create mode 100644 desktop/CodexProviderSync.App/Automation/GuiAutomationBridge.cs create mode 100644 desktop/CodexProviderSync.App/Automation/GuiAutomationCatalog.cs create mode 100644 desktop/CodexProviderSync.App/Automation/gui-automation-manifest.v0.4.json diff --git a/desktop/CodexProviderSync.App.Tests/AutomationBootstrapTests.cs b/desktop/CodexProviderSync.App.Tests/AutomationBootstrapTests.cs new file mode 100644 index 0000000..18b8520 --- /dev/null +++ b/desktop/CodexProviderSync.App.Tests/AutomationBootstrapTests.cs @@ -0,0 +1,404 @@ +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodexProviderSync.App.Automation; +using CodexProviderSync.Core; + +namespace CodexProviderSync.App.Tests; + +public sealed class AutomationBootstrapTests +{ + [Fact] + public void ValidDescriptor_IsClaimedOnce_AndEveryPathStaysInsideTheSentinelRoot() + { + using AutomationRoot fixture = new(); + string descriptor = fixture.WriteDescriptor(); + + AutomationBootstrap bootstrap = AutomationBootstrap.ParseAndClaim( + [AutomationBootstrap.Argument, descriptor]); + IAppPathProvider paths = new IsolatedAppPathProvider(bootstrap.IsolationRoot!); + + Assert.True(bootstrap.Enabled); + Assert.True(File.Exists(descriptor + ".claimed")); + Assert.All(new[] + { + paths.SettingsPath, + paths.LogDirectory, + paths.SingletonDirectory, + paths.DefaultCodexHome, + paths.RequiredSqliteHomeOverride!, + paths.UpdateDownloadDirectory, + paths.UpdaterRoot, + paths.StartupErrorPath, + paths.AutomationTracePath! + }, path => Assert.True(paths.Contains(path), path)); + + InvalidOperationException replay = Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, descriptor])); + Assert.Contains("replay", replay.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DescriptorOutsideDeclaredRoot_IsRejectedWithoutAClaim() + { + using AutomationRoot fixture = new(); + string other = Path.Combine(Path.GetDirectoryName(fixture.Root)!, $"outside-{Guid.NewGuid():N}.json"); + try + { + fixture.WriteDescriptor(other); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, other])); + Assert.False(File.Exists(other + ".claimed")); + } + finally + { + File.Delete(other); + } + } + + [Fact] + public void MissingOrWrongSentinel_RandomPipeTokenAndExactArguments_AreFailClosed() + { + using AutomationRoot missingSentinel = new(createSentinel: false); + string missingDescriptor = missingSentinel.WriteDescriptor(); + Assert.ThrowsAny(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, missingDescriptor])); + + using AutomationRoot invalidPipe = new(); + string invalidPipeDescriptor = invalidPipe.WriteDescriptor(pipeName: "CodexProviderSync.Automation.fixed"); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, invalidPipeDescriptor])); + + using AutomationRoot invalidToken = new(); + string invalidTokenDescriptor = invalidToken.WriteDescriptor(token: "abcd"); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, invalidTokenDescriptor])); + + using AutomationRoot lowEntropyToken = new(); + string lowEntropyDescriptor = lowEntropyToken.WriteDescriptor(token: new string('a', 64)); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, lowEntropyDescriptor])); + + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument])); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim([AutomationBootstrap.Argument, "x", "--extra"])); + Assert.Throws(() => + AutomationBootstrap.ParseAndClaim(["--gui-automation-descripto", "x"])); + } + + [Fact] + public void NormalArguments_DoNotEnableOrClaimAutomation() + { + AutomationBootstrap bootstrap = AutomationBootstrap.ParseAndClaim(["--apply-update"]); + Assert.False(bootstrap.Enabled); + } + + [Fact] + public void IsolatedSingleInstanceGuard_UsesOnlyInjectedPathAndKeepsAProcessLease() + { + using AutomationRoot fixture = new(); + IAppPathProvider paths = new IsolatedAppPathProvider(fixture.Root); + AppInstanceGuard guard = new(paths); + + using AppInstanceAcquisition first = guard.Acquire(); + using AppInstanceAcquisition second = guard.Acquire(); + Assert.True(first.IsOwner); + Assert.False(second.IsOwner); + Assert.True(paths.Contains(paths.SingletonDirectory)); + + first.Dispose(); + using AppInstanceAcquisition third = guard.Acquire(); + Assert.True(third.IsOwner); + } + + [Fact] + public async Task AutomationSettings_AreSanitizedBeforeControllerInitialization() + { + using AutomationRoot fixture = new(); + IAppPathProvider paths = new IsolatedAppPathProvider(fixture.Root); + SettingsService settings = new(paths.SettingsPath); + string outside = Path.Combine(Path.GetDirectoryName(fixture.Root)!, "real-user-data"); + settings.Save(new AppSettings + { + LastCodexHome = outside, + RecentCodexHomes = [outside], + SqliteHomeOverrides = new Dictionary { [outside] = outside }, + LastBackupDirectory = outside, + ManualProviders = ["kept-provider"] + }); + + AutomationIsolation.PrepareSettings(settings, paths); + + AppSettings sanitized = await settings.LoadAsync(); + Assert.Equal(paths.DefaultCodexHome, sanitized.LastCodexHome); + Assert.All(sanitized.RecentCodexHomes, path => Assert.True(paths.Contains(path))); + Assert.Equal( + paths.RequiredSqliteHomeOverride, + sanitized.SqliteHomeOverrides[paths.DefaultCodexHome]); + Assert.Null(sanitized.LastBackupDirectory); + Assert.Equal(["kept-provider"], sanitized.ManualProviders); + Assert.True(Directory.Exists(paths.DefaultCodexHome)); + Assert.True(Directory.Exists(paths.RequiredSqliteHomeOverride)); + } + + [Fact] + public void ExternalConfigAndEnvironmentSqliteHomes_CannotEscapeTheAutomationRoot() + { + using AutomationRoot fixture = new(); + IAppPathProvider paths = new IsolatedAppPathProvider(fixture.Root); + SettingsService settings = new(paths.SettingsPath); + AutomationIsolation.PrepareSettings(settings, paths); + using MainForm form = new( + new ExecutionLogService(paths.LogDirectory), + settings, + paths: paths, + platformBoundary: new IsolatedAppPlatformBoundary(paths)); + + string outsideRoot = Path.Combine( + Path.GetDirectoryName(fixture.Root)!, + $"real-sqlite-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outsideRoot); + string outsideDb = Path.Combine(outsideRoot, "state_5.sqlite"); + File.WriteAllBytes(outsideDb, RandomNumberGenerator.GetBytes(256)); + string before = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(outsideDb))); + try + { + (string codexHome, string? sqliteHome) = form.CaptureStorageSelection(); + Assert.Equal(paths.DefaultCodexHome, codexHome); + Assert.Equal(paths.RequiredSqliteHomeOverride, sqliteHome); + + string configText = $"model_provider = \"openai\"{Environment.NewLine}sqlite_home = \"{outsideRoot.Replace("\\", "/")}\""; + CodexStorageLayout resolved = new CodexStorageLayoutService().Resolve( + codexHome, + sqliteHome, + configText, + new Dictionary + { + ["CODEX_SQLITE_HOME"] = outsideRoot + }); + Assert.Equal(paths.RequiredSqliteHomeOverride, resolved.SqliteHome); + Assert.Equal("gui", resolved.SqliteHomeSource); + + StatusSnapshot safe = CreateStatus(paths); + form.ValidateAutomationStatusSnapshot(safe); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, codexHome: outsideRoot))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, sqliteHome: outsideRoot))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, stateDbPath: outsideDb))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, backupRoot: outsideRoot))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, checkedStateDbPath: outsideDb))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, lockedRolloutPath: outsideDb))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, unreadableRolloutPath: outsideDb))); + Assert.Throws(() => + form.ValidateAutomationStatusSnapshot(CreateStatus(paths, pendingPath: outsideRoot))); + Assert.Throws(() => + form.EnsureAutomationMutationBoundary(codexHome, sqliteHome, outsideRoot)); + + string after = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(outsideDb))); + Assert.Equal(before, after); + } + finally + { + Directory.Delete(outsideRoot, recursive: true); + } + } + + private static StatusSnapshot CreateStatus( + IAppPathProvider paths, + string? codexHome = null, + string? sqliteHome = null, + string? stateDbPath = null, + string? backupRoot = null, + string? checkedStateDbPath = null, + string? lockedRolloutPath = null, + string? unreadableRolloutPath = null, + string? pendingPath = null) + { + return new StatusSnapshot + { + CodexHome = codexHome ?? paths.DefaultCodexHome, + SqliteHome = sqliteHome ?? paths.RequiredSqliteHomeOverride!, + SqliteHomeSource = "gui", + CheckedStateDbPaths = checkedStateDbPath is null ? [] : [checkedStateDbPath], + CurrentProvider = new CurrentProviderInfo("openai", false), + ConfiguredProviders = ["openai"], + RolloutCounts = new ProviderCounts(), + LockedRolloutFiles = lockedRolloutPath is null ? [] : [lockedRolloutPath], + UnreadableRolloutFiles = unreadableRolloutPath is null ? [] : [unreadableRolloutPath], + EncryptedContentCounts = new ProviderCounts(), + SqliteCounts = null, + StateDbLocation = stateDbPath is null + ? null + : new StateDbLocation(stateDbPath, "state_5.sqlite", "sqlite-home"), + BackupRoot = backupRoot ?? Path.Combine(paths.DefaultCodexHome, "backups_state", "provider-sync"), + BackupSummary = new BackupSummary + { + Count = 0, + TotalBytes = 0 + }, + PendingTransactions = pendingPath is null + ? [] + : [new TransactionRecoveryInfo( + "operation", + "prepared", + pendingPath, + Path.Combine(pendingPath, "transaction.jsonl"))] + }; + } + + [Fact] + public async Task BoundedReader_RejectsMessagesOverTheProtocolLimit() + { + await using MemoryStream stream = new(Encoding.UTF8.GetBytes(new string('x', 33))); + await Assert.ThrowsAsync(() => GuiAutomationBridge.ReadBoundedLineAsync( + stream, + maximumBytes: 32, + timeout: TimeSpan.FromSeconds(1), + cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task WrongTokenSpendsTheSingleClientPipe_AndASecondClientCannotConnect() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + using AutomationRoot fixture = new(); + IAppPathProvider paths = new IsolatedAppPathProvider(fixture.Root); + AutomationBootstrap bootstrap = new( + true, + fixture.WriteDescriptor(), + fixture.Root, + $"CodexProviderSync.Automation.{Guid.NewGuid():N}", + new string('a', 64)); + using MainForm form = new( + new ExecutionLogService(paths.LogDirectory), + new SettingsService(paths.SettingsPath), + paths: paths, + platformBoundary: new IsolatedAppPlatformBoundary(paths)); + using GuiAutomationBridge bridge = new(form, bootstrap, paths); + bridge.Start(); + + using NamedPipeClientStream first = new(".", bootstrap.PipeName!, PipeDirection.InOut, PipeOptions.Asynchronous); + await first.ConnectAsync(2000, CancellationToken.None); + byte[] request = Encoding.UTF8.GetBytes( + "{\"id\":\"one\",\"method\":\"ui.describe\",\"token\":\"" + new string('b', 64) + "\"}\n"); + await first.WriteAsync(request, CancellationToken.None); + string? response = await GuiAutomationBridge.ReadBoundedLineAsync( + first, + GuiAutomationBridge.MaximumMessageBytes, + TimeSpan.FromSeconds(2), + CancellationToken.None); + Assert.Contains("authentication-failed", response, StringComparison.Ordinal); + first.Dispose(); + + using NamedPipeClientStream second = new(".", bootstrap.PipeName!, PipeDirection.InOut, PipeOptions.Asynchronous); + await Assert.ThrowsAnyAsync(() => second.ConnectAsync(200, CancellationToken.None)); + } + + [Fact] + public async Task AuthenticatedConnection_RejectsAReplayedRequestIdBeforeDispatch() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + using AutomationRoot fixture = new(); + IAppPathProvider paths = new IsolatedAppPathProvider(fixture.Root); + string token = new('a', 64); + AutomationBootstrap bootstrap = new( + true, + fixture.WriteDescriptor(), + fixture.Root, + $"CodexProviderSync.Automation.{Guid.NewGuid():N}", + token); + using MainForm form = new( + new ExecutionLogService(paths.LogDirectory), + new SettingsService(paths.SettingsPath), + paths: paths, + platformBoundary: new IsolatedAppPlatformBoundary(paths)); + using GuiAutomationBridge bridge = new(form, bootstrap, paths); + bridge.Start(); + + using NamedPipeClientStream client = new( + ".", + bootstrap.PipeName!, + PipeDirection.InOut, + PipeOptions.Asynchronous); + await client.ConnectAsync(2000, CancellationToken.None); + await WriteLineAsync( + client, + "{\"id\":\"replay\",\"method\":\"ui.describe\",\"token\":\"" + token + "\"}"); + string? first = await GuiAutomationBridge.ReadBoundedLineAsync( + client, + GuiAutomationBridge.MaximumMessageBytes, + TimeSpan.FromSeconds(2), + CancellationToken.None); + Assert.Contains("\"ok\":true", first, StringComparison.Ordinal); + + await WriteLineAsync(client, "{\"id\":\"replay\",\"method\":\"ui.describe\"}"); + string? replay = await GuiAutomationBridge.ReadBoundedLineAsync( + client, + GuiAutomationBridge.MaximumMessageBytes, + TimeSpan.FromSeconds(2), + CancellationToken.None); + Assert.Contains("request-replayed", replay, StringComparison.Ordinal); + } + + private static async Task WriteLineAsync(Stream stream, string value) + { + byte[] payload = Encoding.UTF8.GetBytes(value + "\n"); + await stream.WriteAsync(payload, CancellationToken.None); + await stream.FlushAsync(CancellationToken.None); + } + + private sealed class AutomationRoot : IDisposable + { + internal AutomationRoot(bool createSentinel = true) + { + Root = Path.Combine(Path.GetTempPath(), $"codex-provider-gui-bootstrap-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Root); + if (createSentinel) + { + File.WriteAllText( + Path.Combine(Root, AutomationBootstrap.SentinelFileName), + AutomationBootstrap.SentinelContent); + } + } + + internal string Root { get; } + + internal string WriteDescriptor( + string? path = null, + string? pipeName = null, + string? token = null) + { + path ??= Path.Combine(Root, "automation.json"); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = 1, + isolationRoot = Root, + pipeName = pipeName ?? $"CodexProviderSync.Automation.{Guid.NewGuid():N}", + token = token ?? Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant() + })); + return path; + } + + public void Dispose() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + } +} diff --git a/desktop/CodexProviderSync.App.Tests/GuiAutomationBridgeTests.cs b/desktop/CodexProviderSync.App.Tests/GuiAutomationBridgeTests.cs new file mode 100644 index 0000000..bea8017 --- /dev/null +++ b/desktop/CodexProviderSync.App.Tests/GuiAutomationBridgeTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Windows.Forms; +using CodexProviderSync.App.Automation; +using CodexProviderSync.Application; +using CodexProviderSync.Core; + +namespace CodexProviderSync.App.Tests; + +public sealed class GuiAutomationBridgeTests +{ + [Fact] + public async Task Set_ChangesTheRealMainFormControl_RaisesItsEvent_AndWritesAnExplicitTrace() + { + using Fixture fixture = new(); + TextBox input = fixture.Form.Controls.Find(GuiAutomationCatalog.Ids.ManualProviderId, true) + .OfType() + .Single(); + int observedEvents = 0; + input.TextChanged += (_, _) => observedEvents++; + GuiAutomationRequest request = GuiAutomationRequest.Parse( + "{\"id\":\"set-1\",\"method\":\"ui.set\",\"params\":{\"automationId\":\"provider.manualId\",\"value\":\"isolated-provider\"}}"); + + JsonNode? result = await fixture.Dispatcher.DispatchAsync( + request, + CancellationToken.None); + + Assert.Equal("isolated-provider", input.Text); + Assert.Equal(1, observedEvents); + Assert.Equal("isolated-provider", result!["value"]!.GetValue()); + string trace = File.ReadAllText(fixture.TracePath); + Assert.Contains("\"automationId\":\"provider.manualId\"", trace, StringComparison.Ordinal); + Assert.Contains("\"guiEvent\":\"TextChanged\"", trace, StringComparison.Ordinal); + Assert.Contains("\"eventObserved\":true", trace, StringComparison.Ordinal); + Assert.Contains("\"applicationOperationLinked\":false", trace, StringComparison.Ordinal); + } + + [Fact] + public async Task DescribeSnapshotAndGet_AreBoundToTheManifestAndRuntimeDenominator() + { + using Fixture fixture = new(); + JsonNode? describe = await fixture.Dispatch("ui.describe"); + JsonNode? snapshot = await fixture.Dispatch("ui.snapshot"); + JsonNode? get = await fixture.Dispatch( + "ui.get", + "{\"automationId\":\"state.operation\"}"); + + Assert.Equal("0.4", describe!["schemaVersion"]!.GetValue()); + Assert.Equal(31, snapshot!["controls"]!.AsArray().Count); + Assert.Equal("state.operation", get!["automationId"]!.GetValue()); + Assert.Equal("就绪", get["value"]!.GetValue()); + } + + [Fact] + public async Task UnknownMethodAndUnknownControl_AreRejected() + { + Assert.Throws(() => GuiAutomationRequest.Parse( + "{\"id\":\"bad\",\"method\":\"application.sync\"}")); + + using Fixture fixture = new(); + GuiAutomationRequest request = GuiAutomationRequest.Parse( + "{\"id\":\"missing\",\"method\":\"ui.get\",\"params\":{\"automationId\":\"missing.control\"}}"); + await Assert.ThrowsAsync(() => fixture.Dispatcher.DispatchAsync( + request, + CancellationToken.None)); + } + + [Fact] + public async Task AutomationStorageInputs_CannotEscapeTheIsolationRoot() + { + using Fixture fixture = new(); + string outside = Path.Combine(Path.GetDirectoryName(fixture.Root)!, "real-codex-home"); + string json = JsonSerializer.Serialize(new + { + id = "escape", + method = "ui.set", + @params = new + { + automationId = GuiAutomationCatalog.Ids.CodexHome, + value = outside + } + }); + + await Assert.ThrowsAsync(() => fixture.Dispatcher.DispatchAsync( + GuiAutomationRequest.Parse(json), + CancellationToken.None)); + } + + [Fact] + public async Task DynamicProviderAndRecentHomeIds_SelectRealItemsAndRaiseRealEvents() + { + using Fixture fixture = new(); + AppController controller = Field(fixture.Form, "_appController"); + controller.ApplyProviderOptions( + [ + new ProviderOption + { + Id = "relay-a", + Sources = [ProviderSource.Config] + }, + new ProviderOption + { + Id = "relay-b", + Sources = [ProviderSource.Manual], + IsManual = true + } + ], "relay-a"); + Invoke(fixture.Form, "ReloadProviderList"); + + ListView providers = fixture.Form.Controls.Find(GuiAutomationCatalog.Ids.ProviderList, true) + .OfType() + .Single(); + _ = fixture.Form.Handle; + _ = providers.Handle; + ListViewItem relayB = providers.Items.Cast() + .Single(item => string.Equals(item.Tag as string, "relay-b", StringComparison.Ordinal)); + int providerEvents = 0; + providers.SelectedIndexChanged += (_, _) => providerEvents++; + + JsonNode? providerResult = await fixture.Dispatch( + "ui.set", + JsonSerializer.Serialize(new { automationId = relayB.Name, value = true })); + + Assert.True(relayB.Selected); + Assert.True(providerEvents > 0); + Assert.Equal("relay-b", controller.Snapshot.SelectedProviderId); + Assert.True(providerResult!["selected"]!.GetValue()); + + ComboBox recentHomes = fixture.Form.Controls.Find(GuiAutomationCatalog.Ids.CodexHome, true) + .OfType() + .Single(); + AutomationComboBoxItem recent = GuiAutomationCatalog.RecentCodexHome(fixture.Root); + recentHomes.Items.Add(recent); + int recentEvents = 0; + recentHomes.SelectedIndexChanged += (_, _) => recentEvents++; + + JsonNode? recentResult = await fixture.Dispatch( + "ui.set", + JsonSerializer.Serialize(new { automationId = recent.AutomationId, value = true })); + + Assert.Same(recent, recentHomes.SelectedItem); + Assert.Equal(1, recentEvents); + Assert.True(recentResult!["selected"]!.GetValue()); + string trace = File.ReadAllText(fixture.TracePath); + Assert.Contains(relayB.Name, trace, StringComparison.Ordinal); + Assert.Contains(recent.AutomationId, trace, StringComparison.Ordinal); + Assert.Contains("SelectedIndexChanged", trace, StringComparison.Ordinal); + } + + [Fact] + public async Task CancelledQueuedUiAction_CannotExecuteAfterCancellationIsReported() + { + Action? queued = null; + int executions = 0; + using CancellationTokenSource cancellation = new(); + Task pending = GuiAutomationDispatcher.RunScheduledOnceAsync( + callback => queued = callback, + () => ++executions, + cancellation.Token); + + Assert.NotNull(queued); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => await pending); + + queued!(); + Assert.Equal(0, executions); + } + + private static T Field(MainForm form, string name) where T : class + { + return (T)(typeof(MainForm) + .GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(form) + ?? throw new InvalidOperationException($"Missing MainForm field {name}.")); + } + + private static void Invoke(MainForm form, string name) + { + typeof(MainForm) + .GetMethod(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.Invoke(form, null); + } + + private sealed class Fixture : IDisposable + { + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"codex-provider-gui-bridge-{Guid.NewGuid():N}"); + private int _requestId; + + internal Fixture() + { + Directory.CreateDirectory(_root); + IAppPathProvider paths = new IsolatedAppPathProvider(_root); + TracePath = paths.AutomationTracePath!; + Form = new MainForm( + new ExecutionLogService(paths.LogDirectory), + new SettingsService(paths.SettingsPath), + paths: paths, + platformBoundary: new IsolatedAppPlatformBoundary(paths)); + Dispatcher = new GuiAutomationDispatcher(Form, new GuiAutomationTraceSink(TracePath)); + } + + internal MainForm Form { get; } + internal GuiAutomationDispatcher Dispatcher { get; } + internal string TracePath { get; } + internal string Root => _root; + + internal Task Dispatch(string method, string parameters = "{}") + { + string request = JsonSerializer.Serialize(new + { + id = $"request-{++_requestId}", + method, + @params = JsonDocument.Parse(parameters).RootElement + }); + return Dispatcher.DispatchAsync( + GuiAutomationRequest.Parse(request), + CancellationToken.None); + } + + public void Dispose() + { + Form.Dispose(); + Directory.Delete(_root, recursive: true); + } + } +} diff --git a/desktop/CodexProviderSync.App.Tests/GuiAutomationManifestTests.cs b/desktop/CodexProviderSync.App.Tests/GuiAutomationManifestTests.cs new file mode 100644 index 0000000..f91868a --- /dev/null +++ b/desktop/CodexProviderSync.App.Tests/GuiAutomationManifestTests.cs @@ -0,0 +1,327 @@ +using System.Drawing; +using System.Reflection; +using System.Text.Json; +using System.Windows.Forms; +using CodexProviderSync.App.Automation; +using CodexProviderSync.Core; + +namespace CodexProviderSync.App.Tests; + +/// +/// Contract tests for the static manifest and the real MainForm control tree. +/// These are in-process WinForms tests, not headful GUI E2E evidence. +/// +public sealed class GuiAutomationManifestTests +{ + [Fact] + public void RealMainForm_RuntimeDenominatorMatchesManifestWithoutMissingOrDuplicateIds() + { + using JsonDocument manifest = LoadManifest(); + using IsolatedMainForm fixture = new(); + + JsonElement root = manifest.RootElement; + JsonElement window = root.GetProperty("window"); + JsonElement[] declaredControls = root.GetProperty("controls").EnumerateArray().ToArray(); + Dictionary declaredById = declaredControls.ToDictionary( + declaration => declaration.GetProperty("id").GetString()!, + StringComparer.Ordinal); + + Control[] runtimeDenominator = EnumerateRuntimeDenominator(fixture.Form).ToArray(); + Assert.Equal(31, runtimeDenominator.Length); + Assert.All(runtimeDenominator, control => + { + Assert.False(string.IsNullOrWhiteSpace(control.Name)); + Assert.False(string.IsNullOrWhiteSpace(control.AccessibleName)); + Assert.Equal($"AutomationId:{control.Name}", control.AccessibleDescription); + }); + Button browse = fixture.Form.Controls.Find(GuiAutomationCatalog.Ids.BrowseCodexHome, true) + .OfType