Skip to content
Open
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
17 changes: 17 additions & 0 deletions desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
52 changes: 34 additions & 18 deletions desktop/CodexProviderSync.Core/SessionRolloutService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,26 +226,32 @@ private async Task<bool> 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))
Expand All @@ -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)
{
Expand Down Expand Up @@ -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<List<string>> FindLockedFilesAsync(IEnumerable<string> filePaths)
{
List<string> lockedPaths = [];
Expand Down
43 changes: 41 additions & 2 deletions src/session-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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" });
Expand Down Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
15 changes: 15 additions & 0 deletions test/sync-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
Expand Down