diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index 9f300a6..7bb34d2 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -60,6 +60,23 @@ await fixture.WriteStateDbAsync( Assert.Contains("\"model_provider\":\"newapi\"", restoredArchived); } + [Fact] + public async Task RunSync_PreservesRolloutModifiedTimeAfterRewrite() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather"); + DateTime beforeLastWriteTimeUtc = new(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(sessionPath, beforeLastWriteTimeUtc); + + CodexSyncService service = new(); + SyncResult result = await service.RunSyncAsync(fixture.CodexHome); + + Assert.Equal(1, result.ChangedSessionFiles); + Assert.Equal(beforeLastWriteTimeUtc, File.GetLastWriteTimeUtc(sessionPath)); + } + [Fact] public async Task RunSwitch_UpdatesConfigAndSyncsProviderMetadata() { diff --git a/desktop/CodexProviderSync.Core/SessionRolloutService.cs b/desktop/CodexProviderSync.Core/SessionRolloutService.cs index 9ddacc8..46c8173 100644 --- a/desktop/CodexProviderSync.Core/SessionRolloutService.cs +++ b/desktop/CodexProviderSync.Core/SessionRolloutService.cs @@ -226,26 +226,32 @@ private async Task TryRewriteCollectedSessionChangeAsync(SessionChange cha { try { - await using FileStream sourceStream = OpenExclusiveRewriteStream(change.Path); - if (sourceStream.Length != change.OriginalFileLength) + FileSnapshot beforeSnapshot = GetFileSnapshot(change.Path); + if (beforeSnapshot.Length != change.OriginalFileLength + || beforeSnapshot.LastWriteTimeUtcTicks != change.OriginalLastWriteTimeUtcTicks) { return false; } - FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream); - if (!string.Equals(current.FirstLine, change.OriginalFirstLine, StringComparison.Ordinal) - || current.Offset != change.OriginalOffset) + await using (FileStream sourceStream = OpenExclusiveRewriteStream(change.Path)) { - return false; + FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream); + if (!string.Equals(current.FirstLine, change.OriginalFirstLine, StringComparison.Ordinal) + || current.Offset != change.OriginalOffset) + { + return false; + } + + await RewriteFirstLineAsync( + sourceStream, + change.Path, + change.UpdatedFirstLine, + change.OriginalSeparator, + change.OriginalOffset, + headerOnly: change.OriginalOffset >= change.OriginalFileLength); } - await RewriteFirstLineAsync( - sourceStream, - change.Path, - change.UpdatedFirstLine, - change.OriginalSeparator, - change.OriginalOffset, - headerOnly: change.OriginalOffset >= change.OriginalFileLength); + RestoreLastWriteTimeUtc(change.Path, change.OriginalLastWriteTimeUtcTicks); return true; } catch (Exception error) when (IsRolloutFileBusyError(error)) @@ -258,11 +264,16 @@ private async Task RewriteFirstLineAsync(string filePath, string nextFirstLine, { try { - await using FileStream sourceStream = OpenExclusiveRewriteStream(filePath); - FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream); - bool headerOnly = string.IsNullOrEmpty(current.Separator) - && current.Offset == Encoding.UTF8.GetByteCount(current.FirstLine); - await RewriteFirstLineAsync(sourceStream, filePath, nextFirstLine, separator, current.Offset, headerOnly); + FileSnapshot snapshot = GetFileSnapshot(filePath); + await using (FileStream sourceStream = OpenExclusiveRewriteStream(filePath)) + { + FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream); + bool headerOnly = string.IsNullOrEmpty(current.Separator) + && current.Offset == Encoding.UTF8.GetByteCount(current.FirstLine); + await RewriteFirstLineAsync(sourceStream, filePath, nextFirstLine, separator, current.Offset, headerOnly); + } + + RestoreLastWriteTimeUtc(filePath, snapshot.LastWriteTimeUtcTicks); } catch (Exception error) { @@ -399,6 +410,11 @@ private static FileSnapshot GetFileSnapshot(string filePath) return new FileSnapshot(fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks); } + private static void RestoreLastWriteTimeUtc(string filePath, long lastWriteTimeUtcTicks) + { + File.SetLastWriteTimeUtc(filePath, new DateTime(lastWriteTimeUtcTicks, DateTimeKind.Utc)); + } + private static async Task> FindLockedFilesAsync(IEnumerable filePaths) { List lockedPaths = []; diff --git a/src/session-files.js b/src/session-files.js index ee7e10a..979b1bc 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -35,6 +35,30 @@ async function getFileSnapshot(filePath) { }; } +async function restoreFileMtime(filePath, snapshot) { + if (!snapshot || typeof snapshot.mtimeMs !== "number") { + return; + } + + const stat = await fsp.stat(filePath); + await fsp.utimes(filePath, stat.atimeMs / 1000, snapshot.mtimeMs / 1000); +} + +async function overwriteFileInPlace(filePath, tmpPath, snapshot) { + const replacement = await fsp.readFile(tmpPath); + const handle = await fsp.open(filePath, "r+"); + + try { + await handle.truncate(0); + await handle.writeFile(replacement); + await handle.sync(); + } finally { + await handle.close(); + } + + await restoreFileMtime(filePath, snapshot); +} + function snapshotMatches(change, snapshot) { return change.originalSize === snapshot.size && change.originalMtimeMs === snapshot.mtimeMs; @@ -307,6 +331,7 @@ async function invokeWindowsExclusiveRewrite(change, options) { async function rewriteFirstLine(filePath, nextFirstLine, separator) { if (process.platform === "win32") { + const snapshot = await getFileSnapshot(filePath); const result = await invokeWindowsExclusiveRewrite( { path: filePath, @@ -322,9 +347,11 @@ async function rewriteFirstLine(filePath, nextFirstLine, separator) { ); } + await restoreFileMtime(filePath, snapshot); return; } + const snapshot = await getFileSnapshot(filePath); const current = await readFirstLineRecord(filePath); const tmpPath = `${filePath}.provider-sync.${process.pid}.${Date.now()}.tmp`; const writer = fs.createWriteStream(tmpPath, { encoding: "utf8" }); @@ -354,10 +381,12 @@ async function rewriteFirstLine(filePath, nextFirstLine, separator) { reader.pipe(writer, { end: false }); }); - await fsp.rename(tmpPath, filePath); + await overwriteFileInPlace(filePath, tmpPath, snapshot); } catch (error) { await fsp.rm(tmpPath, { force: true }); throw wrapRolloutFileBusyError(error, filePath, "rewrite"); + } finally { + await fsp.rm(tmpPath, { force: true }); } } @@ -403,11 +432,13 @@ async function tryRewriteCollectedFirstLine(change) { return false; } - await fsp.rename(tmpPath, change.path); + await overwriteFileInPlace(change.path, tmpPath, beforeSnapshot); return true; } catch (error) { await fsp.rm(tmpPath, { force: true }); throw wrapRolloutFileBusyError(error, change.path, "rewrite"); + } finally { + await fsp.rm(tmpPath, { force: true }); } } @@ -523,6 +554,7 @@ export async function applySessionChanges(changes) { if (results[index] === "APPLIED") { appliedChanges += 1; appliedPaths.push(normalizedChanges[index].path); + await restoreFileMtime(normalizedChanges[index].path, normalizedChanges[index]); } else { skippedPaths.push(normalizedChanges[index].path); } @@ -608,6 +640,10 @@ export async function restoreSessionChanges(manifestEntries) { separator: entry.originalSeparator ?? "\n", updatedFirstLine: entry.originalFirstLine })); + const snapshots = await Promise.all(changes.map(async (change) => ({ + path: change.path, + snapshot: await getFileSnapshot(change.path) + }))); const results = await invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch: false }); const firstFailureIndex = results.findIndex((result) => result !== "APPLIED"); if (firstFailureIndex !== -1) { @@ -616,6 +652,9 @@ export async function restoreSessionChanges(manifestEntries) { `Unable to rewrite rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${filePath}` ); } + await Promise.all( + snapshots.map(({ path: targetPath, snapshot }) => restoreFileMtime(targetPath, snapshot)) + ); return; } diff --git a/test/sync-service.test.js b/test/sync-service.test.js index 8548405..43e202b 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -236,6 +236,21 @@ test("runSync rewrites rollout files and sqlite, then restore reverts both", asy assert.match(restoredArchived, /"model_provider":"newapi"/); }); +test("runSync preserves rollout modified time after rewriting provider metadata", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await fs.utimes(sessionPath, 1700000000, 1700000000); + const beforeStat = await fs.stat(sessionPath); + + const result = await runSync({ codexHome }); + + assert.equal(result.changedSessionFiles, 1); + const afterStat = await fs.stat(sessionPath); + assert.equal(afterStat.mtimeMs, beforeStat.mtimeMs); +}); + test("runSync reports stage progress and backup duration", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"');