diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 03c717abb14d..314b9e16c6e5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -210,6 +210,57 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect("keeps a SQLite family local when Codex creates it between passes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + for (const suffix of ["", "-wal", "-shm"]) { + yield* writeTextFile(path.join(sharedHome, `queue_1.sqlite${suffix}`), `shared${suffix}`); + yield* writeTextFile(path.join(shadowHome, `queue_1.sqlite${suffix}`), `shadow${suffix}`); + yield* writeTextFile( + path.join(sharedHome, `future_runtime.sqlite${suffix}`), + `future-shared${suffix}`, + ); + } + yield* writeTextFile(path.join(shadowHome, "future_runtime.sqlite"), "future-shadow"); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + for (const suffix of ["", "-wal", "-shm"]) { + const entryName = `queue_1.sqlite${suffix}`; + const sharedPath = path.join(sharedHome, entryName); + const shadowPath = path.join(shadowHome, entryName); + + expect(yield* fileSystem.readFileString(sharedPath)).toBe(`shared${suffix}`); + expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow${suffix}`); + expect((yield* fileSystem.readLink(shadowPath).pipe(Effect.result))._tag).toBe("Failure"); + } + + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "future_runtime.sqlite")), + ).toBe("future-shadow"); + expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-wal"))).toBe( + false, + ); + expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-shm"))).toBe( + false, + ); + }), + ); + it.effect("rejects shadow homes that point at the shared home", () => Effect.gen(function* () { const sharedHome = yield* makeTempDir("t3code-codex-shared-"); @@ -235,6 +286,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { it.effect("rejects shared entries that already exist in the shadow home as real files", () => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sharedHome = yield* makeTempDir("t3code-codex-shared-"); const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); @@ -262,6 +314,19 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(error.message).toBe( `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "config.toml"))).toBe( + 'model = "gpt-5-codex"\n', + ); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "config.toml"))).toBe( + 'model = "local"\n', + ); + + yield* fileSystem.remove(path.join(shadowHome, "config.toml")); + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), + ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 92e923c0b669..a70a524e06dc 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -32,6 +32,12 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +const SQLITE_FAMILY_SUFFIXES = ["", "-wal", "-shm"] as const; + +function sqliteFamilyBaseName(entryName: string): string | undefined { + const baseName = entryName.replace(/-(?:wal|shm)$/, ""); + return baseName.endsWith(".sqlite") ? baseName : undefined; +} function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -184,27 +190,27 @@ const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (inpu ); }); -const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { +const removeSymlink = Effect.fn("CodexHomeLayout.removeSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const privatePath = path.join(input.effectiveHomePath, input.entryName); + const entryPath = path.join(input.effectiveHomePath, input.entryName); const state = yield* readLinkState({ ...input, - linkPath: privatePath, + linkPath: entryPath, }); if (state._tag === "Symlink") { - yield* input.fileSystem.remove(privatePath).pipe( + yield* input.fileSystem.remove(entryPath).pipe( Effect.catchTags({ PlatformError: (cause) => new CodexShadowHomeFileSystemError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, operation: "remove", - path: privatePath, + path: entryPath, entryName: input.entryName, cause, }), @@ -377,12 +383,44 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" } } + const shadowLocalSqliteFamilies = new Set(); + for (const entryName of entries) { + const familyBaseName = sqliteFamilyBaseName(entryName); + if (familyBaseName === undefined || shadowLocalSqliteFamilies.has(familyBaseName)) { + continue; + } + + const familyBaseState = yield* readLinkState({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName: familyBaseName, + linkPath: path.join(effectiveHomePath, familyBaseName), + }); + if (familyBaseState._tag !== "NotSymlink") { + continue; + } + + shadowLocalSqliteFamilies.add(familyBaseName); + yield* Effect.forEach( + SQLITE_FAMILY_SUFFIXES, + (suffix) => + removeSymlink({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName: `${familyBaseName}${suffix}`, + }), + { discard: true }, + ); + } + yield* Effect.forEach( PRIVATE_ENTRY_NAMES, (entryName) => entryName === "auth.json" ? Effect.void - : removePrivateSymlink({ + : removeSymlink({ fileSystem, sharedHomePath: layout.sharedHomePath, effectiveHomePath, @@ -397,6 +435,10 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" if (PRIVATE_ENTRY_NAMES.has(entryName)) { return Effect.void; } + const familyBaseName = sqliteFamilyBaseName(entryName); + if (familyBaseName !== undefined && shadowLocalSqliteFamilies.has(familyBaseName)) { + return Effect.void; + } return ensureSymlink({ fileSystem, sharedHomePath: layout.sharedHomePath,