namespace CodexProviderSync.Core; public sealed class CodexSyncService { private readonly CodexHomeService _codexHomeService; private readonly ConfigFileService _configFileService; private readonly SessionRolloutService _sessionRolloutService; private readonly SqliteStateService _sqliteStateService; private readonly GlobalStateService _globalStateService; private readonly BackupService _backupService; private readonly LockService _lockService; private readonly ProviderDiscoveryService _providerDiscoveryService; public CodexSyncService() : this( new CodexHomeService(), new ConfigFileService(), new SessionRolloutService(), new SqliteStateService(), new GlobalStateService(), new LockService(), new ProviderDiscoveryService()) { } public CodexSyncService( CodexHomeService codexHomeService, ConfigFileService configFileService, SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService, GlobalStateService globalStateService, LockService lockService, ProviderDiscoveryService providerDiscoveryService) { _codexHomeService = codexHomeService; _configFileService = configFileService; _sessionRolloutService = sessionRolloutService; _sqliteStateService = sqliteStateService; _globalStateService = globalStateService; _lockService = lockService; _providerDiscoveryService = providerDiscoveryService; _backupService = new BackupService(sessionRolloutService, sqliteStateService); } public async Task GetStatusAsync(string? explicitCodexHome = null) { string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome)); CurrentProviderInfo currentProvider = _configFileService.ReadCurrentProviderFromConfigText(configText); IReadOnlyList configuredProviders = _configFileService.ListConfiguredProviderIds(configText); SessionChangeCollection rolloutInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, "__status_only__", skipLockedReads: true); StateDbLocation? stateDbLocation = _sqliteStateService.DetectStateDb(codexHome); ProviderCounts? sqliteCounts = await _sqliteStateService.ReadSqliteProviderCountsAsync(codexHome); SqliteRepairStats? sqliteRepairStats = sqliteCounts is not null && !sqliteCounts.Unreadable ? await _sqliteStateService.ReadSqliteRepairStatsAsync( codexHome, rolloutInfo.UserEventThreadIds, rolloutInfo.ThreadCwdsById) : null; IReadOnlyList projectThreadVisibility = sqliteCounts?.Unreadable == true ? [] : await _globalStateService.ReadProjectThreadVisibilityAsync(codexHome); BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome); return new StatusSnapshot { CodexHome = codexHome, CurrentProvider = currentProvider, ConfiguredProviders = configuredProviders, RolloutCounts = rolloutInfo.ProviderCounts, LockedRolloutFiles = rolloutInfo.LockedPaths, UnreadableRolloutFiles = rolloutInfo.UnreadablePaths, EncryptedContentCounts = rolloutInfo.EncryptedContentCounts, EncryptedContentWarning = BuildEncryptedContentWarning(rolloutInfo.EncryptedContentCounts, currentProvider.Provider), SqliteCounts = sqliteCounts, StateDbLocation = stateDbLocation, SqliteRepairStats = sqliteRepairStats, ProjectThreadVisibility = projectThreadVisibility, BackupRoot = _codexHomeService.BackupRoot(codexHome), BackupSummary = backupSummary }; } public IReadOnlyList BuildProviderOptions(StatusSnapshot status, AppSettings settings) { return _providerDiscoveryService.BuildProviderOptions(status, settings); } public IReadOnlyList ExtractDetectedProviderIds(StatusSnapshot status) { return _providerDiscoveryService.ExtractDetectedProviderIds(status); } public async Task RunSyncAsync( string? explicitCodexHome = null, string? provider = null, string? configBackupText = null, int keepCount = AppConstants.DefaultBackupRetentionCount, int? sqliteBusyTimeoutMs = null) { if (keepCount < 1) { throw new ArgumentOutOfRangeException(nameof(keepCount), keepCount, "keepCount must be 1 or greater for automatic cleanup."); } string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configPath = _codexHomeService.ConfigPath(codexHome); string configText = await _configFileService.ReadConfigTextAsync(configPath); CurrentProviderInfo current = _configFileService.ReadCurrentProviderFromConfigText(configText); string targetProvider = provider ?? current.Provider ?? AppConstants.DefaultProvider; await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync"); SessionChangeCollection sessionInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, targetProvider, skipLockedReads: true); IReadOnlyList workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(codexHome); string? encryptedContentWarning = BuildEncryptedContentWarning(sessionInfo.EncryptedContentCounts, targetProvider); (IReadOnlyList writableChanges, IReadOnlyList lockedChanges) = await _sessionRolloutService.SplitLockedSessionChangesAsync(sessionInfo.Changes); List skippedRolloutFiles = [.. sessionInfo.LockedPaths, .. lockedChanges.Select(static change => change.Path)]; IReadOnlyList skippedUnreadableRolloutFiles = sessionInfo.UnreadablePaths .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToList(); await _sqliteStateService.AssertSqliteWritableAsync(codexHome, sqliteBusyTimeoutMs); string backupDir = await _backupService.CreateBackupAsync(codexHome, targetProvider, writableChanges, configPath, configBackupText); bool sessionRestoreNeeded = false; List appliedSessionChanges = []; bool globalStateRestoreNeeded = false; WorkspaceRootSyncResult workspaceRootResult = new() { Present = false, Updated = false, UpdatedWorkspaceRoots = 0, SavedWorkspaceRootCount = 0 }; try { SessionApplyResult? applyResult = null; (int updatedRows, int providerRowsUpdated, int userEventRowsUpdated, int cwdRowsUpdated, bool databasePresent) = await _sqliteStateService.UpdateSqliteProviderAsync( codexHome, targetProvider, async _ => { if (writableChanges.Count > 0) { applyResult = await _sessionRolloutService.ApplySessionChangesAsync(writableChanges); 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); } workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync(codexHome, workspaceCwdStats); globalStateRestoreNeeded = workspaceRootResult.Updated; }, sqliteBusyTimeoutMs, sessionInfo.UserEventThreadIds, sessionInfo.ThreadCwdsById); skippedRolloutFiles.AddRange(applyResult?.SkippedPaths ?? []); skippedRolloutFiles = skippedRolloutFiles.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); BackupPruneResult? autoPruneResult = null; string? autoPruneWarning = null; try { autoPruneResult = await _backupService.PruneBackupsAsync(codexHome, keepCount); } catch (Exception error) { autoPruneWarning = $"Automatic backup cleanup failed: {error.Message}"; } return new SyncResult { CodexHome = codexHome, TargetProvider = targetProvider, PreviousProvider = current.Provider ?? AppConstants.DefaultProvider, BackupDir = backupDir, ChangedSessionFiles = applyResult?.AppliedCount ?? 0, SkippedLockedRolloutFiles = skippedRolloutFiles, SkippedUnreadableRolloutFiles = skippedUnreadableRolloutFiles, SqliteRowsUpdated = updatedRows, SqliteProviderRowsUpdated = providerRowsUpdated, SqliteUserEventRowsUpdated = userEventRowsUpdated, SqliteCwdRowsUpdated = cwdRowsUpdated, UpdatedWorkspaceRoots = workspaceRootResult.UpdatedWorkspaceRoots, SavedWorkspaceRootCount = workspaceRootResult.SavedWorkspaceRootCount, SqlitePresent = databasePresent, RolloutCountsBefore = sessionInfo.ProviderCounts, EncryptedContentCounts = sessionInfo.EncryptedContentCounts, EncryptedContentWarning = encryptedContentWarning, AutoPruneResult = autoPruneResult, AutoPruneWarning = autoPruneWarning }; } catch (Exception error) { List restoreFailures = []; if (sessionRestoreNeeded) { try { await _sessionRolloutService.RestoreSessionChangesAsync(appliedSessionChanges); } catch (Exception restoreError) { restoreFailures.Add($"rollout files: {restoreError.Message}"); } } if (globalStateRestoreNeeded) { try { await _backupService.RestoreGlobalStateFilesAsync(backupDir, codexHome); } catch (Exception restoreError) { restoreFailures.Add($"global state: {restoreError.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); } throw; } } public async Task RunSwitchAsync( string? explicitCodexHome, string provider, int keepCount = AppConstants.DefaultBackupRetentionCount) { if (string.IsNullOrWhiteSpace(provider)) { throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch "); } string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configPath = _codexHomeService.ConfigPath(codexHome); string originalConfigText = await _configFileService.ReadConfigTextAsync(configPath); 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); await _configFileService.WriteConfigTextAsync(configPath, nextConfigText); try { SyncResult result = await RunSyncAsync(codexHome, provider, originalConfigText, keepCount); return new SyncResult { CodexHome = result.CodexHome, TargetProvider = result.TargetProvider, PreviousProvider = result.PreviousProvider, BackupDir = result.BackupDir, ChangedSessionFiles = result.ChangedSessionFiles, SkippedLockedRolloutFiles = result.SkippedLockedRolloutFiles, SkippedUnreadableRolloutFiles = result.SkippedUnreadableRolloutFiles, SqliteRowsUpdated = result.SqliteRowsUpdated, SqliteProviderRowsUpdated = result.SqliteProviderRowsUpdated, 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, AutoPruneResult = result.AutoPruneResult, AutoPruneWarning = result.AutoPruneWarning }; } catch { await _configFileService.WriteConfigTextAsync(configPath, originalConfigText); throw; } } public async Task RunRestoreAsync(string? explicitCodexHome, string backupDir) { return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions()); } public async Task RunRestoreAsync(string? explicitCodexHome, string backupDir, RestoreBackupOptions options) { if (string.IsNullOrWhiteSpace(backupDir)) { throw new InvalidOperationException("Missing backup path. Usage: codex-provider restore "); } string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore"); return await _backupService.RestoreBackupAsync(Path.GetFullPath(backupDir), codexHome, options); } public async Task RunPruneBackupsAsync( string? explicitCodexHome = null, int keepCount = AppConstants.DefaultBackupRetentionCount) { string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "prune-backups"); return await _backupService.PruneBackupsAsync(codexHome, keepCount); } private static string? BuildEncryptedContentWarning(ProviderCounts encryptedContentCounts, string targetProvider) { int total = encryptedContentCounts.Sessions.Values.Sum() + encryptedContentCounts.ArchivedSessions.Values.Sum(); List riskyProviders = encryptedContentCounts.Sessions .Concat(encryptedContentCounts.ArchivedSessions) .Where(pair => pair.Value > 0 && !string.Equals(pair.Key, targetProvider, StringComparison.Ordinal)) .Select(static pair => pair.Key) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToList(); if (riskyProviders.Count == 0) { return null; } return $"Encrypted content warning: {total} rollout file(s) contain encrypted_content from provider(s) {string.Join(", ", riskyProviders)}. Visibility metadata can be synchronized to {targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation."; } }