Files
2026-07-13 12:31:29 +08:00

345 lines
16 KiB
C#

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<StatusSnapshot> 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<string> 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> 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<ProviderOption> BuildProviderOptions(StatusSnapshot status, AppSettings settings)
{
return _providerDiscoveryService.BuildProviderOptions(status, settings);
}
public IReadOnlyList<string> ExtractDetectedProviderIds(StatusSnapshot status)
{
return _providerDiscoveryService.ExtractDetectedProviderIds(status);
}
public async Task<SyncResult> 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<ThreadCwdStat> workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(codexHome);
string? encryptedContentWarning = BuildEncryptedContentWarning(sessionInfo.EncryptedContentCounts, targetProvider);
(IReadOnlyList<SessionChange> writableChanges, IReadOnlyList<SessionChange> lockedChanges) =
await _sessionRolloutService.SplitLockedSessionChangesAsync(sessionInfo.Changes);
List<string> skippedRolloutFiles = [.. sessionInfo.LockedPaths, .. lockedChanges.Select(static change => change.Path)];
IReadOnlyList<string> 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<SessionChange> 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<string> 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<string> 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<SyncResult> RunSwitchAsync(
string? explicitCodexHome,
string provider,
int keepCount = AppConstants.DefaultBackupRetentionCount)
{
if (string.IsNullOrWhiteSpace(provider))
{
throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch <provider-id>");
}
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<RestoreResult> RunRestoreAsync(string? explicitCodexHome, string backupDir)
{
return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions());
}
public async Task<RestoreResult> RunRestoreAsync(string? explicitCodexHome, string backupDir, RestoreBackupOptions options)
{
if (string.IsNullOrWhiteSpace(backupDir))
{
throw new InvalidOperationException("Missing backup path. Usage: codex-provider restore <backup-dir>");
}
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<BackupPruneResult> 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<string> 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.";
}
}