Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ If the output says Windows cannot safely access SQLite through a WSL UNC path:
If sync reports `Skipped locked rollout files`:

- treat the sync as mostly successful
- explain that the active session still holds one or more rollout files open
- explain that an active session either still holds one or more rollout files open, or appended to one while it was being scanned
- tell the user to rerun `codex-provider sync` after that session ends if they want a full rewrite

If `switch <provider-id>` fails because the provider is missing:
Expand Down
64 changes: 64 additions & 0 deletions desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,70 @@ private static void PerformLayoutRecursively(Control control)
}
}

[Fact]
public void OpenBackupFolder_ReportsNoError_WhenShellReusesAnExistingProcess()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-ui-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
// Explorer commonly satisfies a folder request through a window it
// already owns and then returns no Process handle. The boundary must
// treat that as a successful open, not a failure.
RecordingPlatformBoundary boundary = new();
using MainForm form = new(new ExecutionLogService(root), platformBoundary: boundary);
SetField(form, "_currentStatus", StatusWithBackupRoot(root));

Invoke(form, "OpenBackupFolder");

Assert.Equal(root, Assert.Single(boundary.OpenedPaths));
Assert.DoesNotContain("打开备份目录失败", Field<TextBox>(form, "_logBox").Text);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

// The failure branch of OpenBackupFolder is deliberately not covered here:
// its catch reports through MessageBox.Show, which has no injection seam in
// MainForm, so a test driving it would block the runner on a modal dialog.

private static StatusSnapshot StatusWithBackupRoot(string backupRoot) => new()
{
CodexHome = @"C:\Users\user\.codex",
SqliteAccess = new SqliteAccessInfo(true, "windows", null),
CurrentProvider = new CurrentProviderInfo("openai", false),
ConfiguredProviders = ["openai"],
RolloutCounts = new ProviderCounts(),
LockedRolloutFiles = [],
UnreadableRolloutFiles = [],
EncryptedContentCounts = new ProviderCounts(),
SqliteCounts = null,
BackupRoot = backupRoot,
BackupSummary = new BackupSummary { Count = 0, TotalBytes = 0 }
};

private sealed class RecordingPlatformBoundary : IAppPlatformBoundary
{
public List<string> OpenedPaths { get; } = [];

public bool UpdatesEnabled => false;

public string? PickFolder(IWin32Window owner, FolderPickerRequest request) => null;

public Task<UpdateCheckResult> CheckForUpdateAsync(
UpdateService updateService,
Version currentVersion,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

public void OpenPath(string path) => OpenedPaths.Add(path);

public void StartUpdate(string downloadedExePath, string targetExePath, string expectedSha256) =>
throw new NotSupportedException();
}

private static T Field<T>(MainForm form, string name) where T : class
{
return typeof(MainForm)
Expand Down
7 changes: 6 additions & 1 deletion desktop/CodexProviderSync.App/AppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,16 @@ public Task<UpdateCheckResult> CheckForUpdateAsync(

public void OpenPath(string path)
{
// With UseShellExecute the shell may satisfy the request through a
// process it already owns - Explorer reusing an open window is the
// common case - and then returns no Process handle even though the
// path opened. A null result therefore carries no failure information;
// a genuine failure such as a missing path surfaces as Win32Exception.
_ = Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true
}) ?? throw new InvalidOperationException($"Unable to open {path}.");
});
}

public void StartUpdate(string downloadedExePath, string targetExePath, string expectedSha256) =>
Expand Down
18 changes: 16 additions & 2 deletions desktop/CodexProviderSync.App/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,9 @@ await RunBusyAsync("执行中...", async () =>
result,
request is SwitchProviderRequest ? "已切换并同步" : "已同步",
TextFormatter.ChineseSimplified));
AppendLog(TextFormatter.FormatPerformanceMetrics(
result.PerformanceMetrics,
TextFormatter.ChineseSimplified));
AppendLog(FormatModelSyncOutcome(result.ModelSync));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(request.CodexHome, request.SqliteHomeOverride, provider);
Expand Down Expand Up @@ -1076,8 +1079,16 @@ private void OpenBackupFolder()
{
string path = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome());
EnsureAutomationPath(path, GuiAutomationCatalog.Ids.OpenBackupDirectory);
Directory.CreateDirectory(path);
_platformBoundary.OpenPath(path);
try
{
Directory.CreateDirectory(path);
_platformBoundary.OpenPath(path);
}
catch (Exception error)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 打开备份目录失败: {error}");
MessageBox.Show(this, $"无法打开备份目录。{Environment.NewLine}{Environment.NewLine}{error.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}

private void OpenLogFolder()
Expand Down Expand Up @@ -1591,6 +1602,9 @@ private async Task ApplyControllerRefreshAsync(AppSnapshot snapshot)
ReloadProviderList();
_codexHomeCombo.Text = _currentStatus.CodexHome;
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已刷新: {_currentStatus.CodexHome}");
AppendLog(TextFormatter.FormatPerformanceMetrics(
_currentStatus.PerformanceMetrics,
TextFormatter.ChineseSimplified));
}

private WindowBoundsState CaptureWindowBounds()
Expand Down
79 changes: 79 additions & 0 deletions desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ await File.WriteAllTextAsync(
Assert.False(globalStateFiles.GetProperty(AppConstants.GlobalStateBackupFileBasename).GetBoolean());
Assert.True(metadataRoot.GetProperty("globalStateFilePresent").GetBoolean());
Assert.False(metadataRoot.GetProperty("globalStateBackupFilePresent").GetBoolean());
Assert.True(metadataRoot.GetProperty("sizeBytes").GetInt64() > 0);
Assert.True(metadataRoot.GetProperty("fileCount").GetInt32() > 0);
Assert.Equal(
Directory.EnumerateFiles(backupDir, "*", SearchOption.AllDirectories)
.Sum(static path => new FileInfo(path).Length),
metadataRoot.GetProperty("sizeBytes").GetInt64());

using JsonDocument manifest = JsonDocument.Parse(
await File.ReadAllTextAsync(Path.Combine(backupDir, "session-meta-backup.json")));
Expand All @@ -48,6 +54,79 @@ await File.WriteAllTextAsync(
entry.GetProperty("originalLastWriteTimeUtcTicks").GetString());
}

[Fact]
public async Task BackupSummary_UsesCachedInventoryAndFallsBackForLegacyMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string cached = fixture.BackupPath("20260708T091011111Z");
string legacy = fixture.BackupPath("20260708T091011110Z");
Directory.CreateDirectory(cached);
Directory.CreateDirectory(legacy);
await File.WriteAllTextAsync(
Path.Combine(cached, "metadata.json"),
JsonSerializer.Serialize(new
{
version = 2,
@namespace = AppConstants.BackupNamespace,
codexHome = fixture.CodexHome,
targetProvider = "openai",
createdAt = DateTimeOffset.UtcNow,
dbFiles = Array.Empty<string>(),
changedSessionFiles = 0,
sizeBytes = 123L,
fileCount = 1
}));
await File.WriteAllTextAsync(Path.Combine(cached, "added-later.bin"), new string('x', 4096));
await File.WriteAllTextAsync(
Path.Combine(legacy, "metadata.json"),
JsonSerializer.Serialize(new
{
version = 1,
@namespace = AppConstants.BackupNamespace,
codexHome = fixture.CodexHome,
targetProvider = "openai",
createdAt = DateTimeOffset.UtcNow,
dbFiles = Array.Empty<string>(),
changedSessionFiles = 0
}));
await File.WriteAllTextAsync(Path.Combine(legacy, "payload.bin"), new string('y', 17));

List<string> fallbacks = [];
BackupService backups = new(new SessionRolloutService(), new SqliteStateService())
{
DirectoryInventoryFallbackObserver = fallbacks.Add
};
BackupSummary summary = await backups.GetBackupSummaryAsync(fixture.CodexHome);

long legacyBytes = Directory.EnumerateFiles(legacy, "*", SearchOption.AllDirectories)
.Sum(static path => new FileInfo(path).Length);
Assert.Equal(2, summary.Count);
Assert.Equal(123L + legacyBytes, summary.TotalBytes);
Assert.Single(fallbacks);
Assert.Equal(Path.GetFullPath(legacy), Path.GetFullPath(fallbacks[0]));
}

[Fact]
public async Task IncompleteMetadata_IsNotManagedOrPrunedEvenWithProviderSyncNamespace()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string incomplete = fixture.BackupPath("20260708T091011109Z");
Directory.CreateDirectory(incomplete);
string sentinel = Path.Combine(incomplete, "keep.txt");
await File.WriteAllTextAsync(
Path.Combine(incomplete, "metadata.json"),
"{\"namespace\":\"provider-sync\",\"sizeBytes\":1,\"fileCount\":1}");
await File.WriteAllTextAsync(sentinel, "must remain");

BackupService backups = new(new SessionRolloutService(), new SqliteStateService());
BackupSummary summary = await backups.GetBackupSummaryAsync(fixture.CodexHome);
BackupPruneResult pruned = await backups.PruneBackupsAsync(fixture.CodexHome, 0);

Assert.Equal(0, summary.Count);
Assert.Equal(0, pruned.DeletedCount);
Assert.True(File.Exists(sentinel));
}

[Fact]
public async Task RestoreBackup_AcceptsNodeStyleV2MetadataAndMillisecondSessionTimestamp()
{
Expand Down
Loading