diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..22fe3efc9e991 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -1,19 +1,24 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { - CodexShadowHomeEntryConflictError, + CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); +const isCodexShadowHomeFileSystemError = Schema.is(CodexShadowHomeFileSystemError); +const CONFLICTS_DIRECTORY = ".t3-shadow-conflicts"; const decodeCodexSettings = (input: { readonly enabled?: boolean; @@ -38,6 +43,16 @@ const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* yield* fileSystem.writeFileString(filePath, contents); }); +const listConflictRuns = Effect.fn("CodexHomeLayout.test.listConflictRuns")(function* ( + shadowHome: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const conflictsPath = path.join(shadowHome, CONFLICTS_DIRECTORY); + if (!(yield* fileSystem.exists(conflictsPath))) return []; + return yield* fileSystem.readDirectory(conflictsPath); +}); + it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { describe("resolveCodexHomeLayout", () => { it.effect("uses the shared home for continuation and the shadow home for runtime", () => @@ -252,6 +267,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* materializeCodexShadowHome(layout); expect(yield* fileSystem.exists(threadLock)).toBe(true); + expect(yield* listConflictRuns(shadowHome)).toEqual([]); yield* fileSystem.remove(threadLock); yield* materializeCodexShadowHome(layout); @@ -307,6 +323,101 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "finishes replacing a SQLite family when setup is interrupted mid-move", + () => + 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"); + for (const home of [sharedHome, shadowHome]) { + yield* writeTextFile(path.join(home, "queue_1.sqlite"), "db"); + yield* writeTextFile(path.join(home, "queue_1.sqlite-wal"), "wal"); + } + const baseRenameStarted = yield* Deferred.make(); + const releaseBaseRename = yield* Deferred.make(); + // The WAL has already moved when the base rename blocks. + const blockingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") + ? Deferred.succeed(baseRenameStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBaseRename)), + Effect.andThen(fileSystem.rename(fromPath, toPath)), + ) + : fileSystem.rename(fromPath, toPath), + }); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + const setup = yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, blockingFileSystem), + Effect.forkChild, + ); + yield* Deferred.await(baseRenameStarted); + setup.interruptUnsafe(); + yield* Deferred.succeed(releaseBaseRename, undefined); + yield* Fiber.await(setup); + + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "fails setup instead of starting Codex on a SQLite family it could not restore", + () => + 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"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite"), "shared db"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite-wal"), "shared wal"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + const failure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + description: "Forced failure.", + }); + // The base cannot move, and the WAL that already moved cannot come back. + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") || + fromPath.includes(CONFLICTS_DIRECTORY) + ? Effect.fail(failure) + : fileSystem.rename(fromPath, toPath), + }); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + Effect.flip, + ); + + expect(error).toBeInstanceOf(CodexShadowHomeFileSystemError); + expect(error).toMatchObject({ + operation: "rename", + entryName: "queue_1.sqlite-wal", + targetPath: path.join(shadowHome, "queue_1.sqlite-wal"), + }); + if (!isCodexShadowHomeFileSystemError(error)) return; + expect(yield* fileSystem.readFileString(error.path)).toBe("shadow wal"); + }), + ); + it.effect("rejects shadow homes that point at the shared home", () => Effect.gen(function* () { const sharedHome = yield* makeTempDir("t3code-codex-shared-"); @@ -331,35 +442,169 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); it.effect.skipIf(!symlinksSupported)( - "rejects shared entries that already exist in the shadow home as real files", + "moves conflicting shadow files and directories aside, then links the shared entry", () => 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"); - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); - yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + yield* materializeCodexShadowHome(layout); + // Codex adds new entries to both homes after the first setup. + yield* writeTextFile(path.join(sharedHome, "memories_v2.lock"), "shared lock"); + yield* writeTextFile(path.join(shadowHome, "memories_v2.lock"), "shadow lock"); + yield* writeTextFile(path.join(sharedHome, "memories_v2", ".git", "HEAD"), "shared"); + yield* writeTextFile(path.join(shadowHome, "memories_v2", ".git", "HEAD"), "shadow"); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + const runs = yield* listConflictRuns(shadowHome); + expect(runs).toHaveLength(1); + const backup = path.join(shadowHome, CONFLICTS_DIRECTORY, runs[0]!); + expect(yield* fileSystem.readDirectory(backup)).toEqual( + expect.arrayContaining(["memories_v2", "memories_v2.lock"]), + ); + expect(yield* fileSystem.readFileString(path.join(backup, "memories_v2.lock"))).toBe( + "shadow lock", + ); + expect( + yield* fileSystem.readFileString(path.join(backup, "memories_v2", ".git", "HEAD")), + ).toBe("shadow"); + for (const entryName of ["memories_v2.lock", "memories_v2"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + expect(yield* fileSystem.readFileString(path.join(sharedHome, "memories_v2.lock"))).toBe( + "shared lock", + ); + expect( + yield* fileSystem.readFileString(path.join(sharedHome, "memories_v2", ".git", "HEAD")), + ).toBe("shared"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "moves a conflicting SQLite database aside together with its sidecars", + () => + 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"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite"), "shared db"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite-wal"), "shared wal"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + // The shared home has no -shm, so nothing would link it, but it must not stay behind. + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-shm"), "shadow shm"); + // Shadow-only databases have nothing to share and stay where they are. + yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite"), "shadow goals"); + yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite-wal"), "shadow goals wal"); const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), ); - const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + const runs = yield* listConflictRuns(shadowHome); + expect(runs).toHaveLength(1); + const backup = path.join(shadowHome, CONFLICTS_DIRECTORY, runs[0]!); + for (const [entryName, contents] of [ + ["queue_1.sqlite", "shadow db"], + ["queue_1.sqlite-wal", "shadow wal"], + ["queue_1.sqlite-shm", "shadow shm"], + ] as const) { + expect(yield* fileSystem.readFileString(path.join(backup, entryName))).toBe(contents); + } + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + expect(yield* fileSystem.exists(path.join(shadowHome, "queue_1.sqlite-shm"))).toBe(false); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "queue_1.sqlite"))).toBe( + "shared db", + ); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite"))).toBe( + "shadow goals", + ); + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite-wal")), + ).toBe("shadow goals wal"); + }), + ); - expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); - expect(error).toMatchObject({ - sharedHomePath: sharedHome, - effectiveHomePath: shadowHome, - entryName: "config.toml", - linkPath: path.join(shadowHome, "config.toml"), - targetPath: path.join(sharedHome, "config.toml"), + it.effect.skipIf(!symlinksSupported)( + "restores a conflict group and links the rest when inspecting, moving, or linking fails", + () => + 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"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"]) { + yield* writeTextFile(path.join(sharedHome, entryName), `shared ${entryName}`); + } + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + yield* writeTextFile(path.join(sharedHome, "cache.json"), "shared"); + yield* writeTextFile(path.join(shadowHome, "cache.json"), "shadow"); + yield* writeTextFile(path.join(sharedHome, "logs_2.sqlite"), "shared logs"); + const failure = (method: string) => + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + description: "Forced failure.", + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readLink: (linkPath) => + linkPath === path.join(shadowHome, "logs_2.sqlite-shm") + ? Effect.fail(failure("readLink")) + : fileSystem.readLink(linkPath), + // The base fails after its WAL has moved, as a file held open on Windows would. + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") + ? Effect.fail(failure("rename")) + : fileSystem.rename(fromPath, toPath), + symlink: (target, link) => + link === path.join(shadowHome, "cache.json") + ? Effect.fail(failure("symlink")) + : fileSystem.symlink(target, link), }); - 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.`, + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + ); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite"))).toBe( + "shadow db", + ); + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite-wal")), + ).toBe("shadow wal"); + expect(yield* fileSystem.exists(path.join(shadowHome, "queue_1.sqlite-shm"))).toBe(false); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "cache.json"))).toBe( + "shadow", + ); + expect(yield* fileSystem.exists(path.join(shadowHome, "logs_2.sqlite"))).toBe(false); + 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 f6d2ba9d44cf5..529a9cbdb596c 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,4 +1,5 @@ import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -31,8 +32,15 @@ const KNOWN_SHARED_DIRECTORIES = [ // auth.lock guards the shadow home's private auth.json. const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); +const SHADOW_CONFLICTS_DIRECTORY = ".t3-shadow-conflicts"; // Finder maintains metadata separately for each directory, including shadow homes. -const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); +const SHADOW_LOCAL_ENTRY_NAMES = new Set([ + "log", + "memories", + "tmp", + ".DS_Store", + SHADOW_CONFLICTS_DIRECTORY, +]); // Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ "mcp-oauth-locks", @@ -80,7 +88,14 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError()( - "CodexShadowHomeEntryConflictError", - { - ...CodexShadowHomeContext, - entryName: Schema.String, - linkPath: Schema.String, - targetPath: Schema.String, - }, -) { - override get message(): string { - return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; - } -} - export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError()( "CodexShadowHomePrivateEntrySymlinkError", { @@ -132,7 +133,6 @@ export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError< export const CodexShadowHomeError = Schema.Union([ CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, - CodexShadowHomeEntryConflictError, CodexShadowHomePrivateEntrySymlinkError, ]); export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; @@ -219,15 +219,178 @@ const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(f } }); +const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"] as const; + +function sqliteBaseName(entryName: string): string | undefined { + const suffix = SQLITE_SIDECAR_SUFFIXES.find((candidate) => entryName.endsWith(candidate)); + const baseName = suffix === undefined ? entryName : entryName.slice(0, -suffix.length); + return baseName.endsWith(".sqlite") ? baseName : undefined; +} + +// Sidecars go first, so the base is the last thing to leave the shadow home. +function conflictGroupMembers(entryName: string): ReadonlyArray { + const baseName = sqliteBaseName(entryName); + if (baseName === undefined) return [entryName]; + return [...SQLITE_SIDECAR_SUFFIXES.map((suffix) => baseName + suffix), baseName]; +} + +type ResolveConflictGroup = ( + entryName: string, +) => Effect.Effect; + +// A SQLite base and its sidecars are one unit: move every real copy aside and link what the +// shared home has, or restore all of it, so a base never pairs with another database's journal. +const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + readonly entryName: string; + readonly sharedEntryNames: ReadonlySet; + readonly backupDirectory: Effect.Effect; +}): Effect.fn.Return { + const path = yield* Path.Path; + const inspected = yield* Effect.forEach(conflictGroupMembers(input.entryName), (name) => + readLinkState({ + ...input, + entryName: name, + linkPath: path.join(input.effectiveHomePath, name), + }).pipe(Effect.map((state) => ({ name, state }))), + ).pipe(Effect.result); + if (inspected._tag === "Failure") { + yield* Effect.logWarning( + `Could not inspect Codex shadow home entry '${input.entryName}'; it stays local until a later setup.`, + { entryName: input.entryName, cause: inspected.failure }, + ); + return false; + } + const members = inspected.success; + const conflicts = members.filter((member) => member.state._tag === "NotSymlink"); + if (conflicts.length === 0) return true; + const baseName = sqliteBaseName(input.entryName); + const baseIsLocalOnly = + baseName !== undefined && + !input.sharedEntryNames.has(baseName) && + conflicts.some((member) => member.name === baseName); + if (baseIsLocalOnly) return false; + + const moved: Array<{ + readonly name: string; + readonly path: string; + readonly backupPath: string; + }> = []; + const linked: Array = []; + const replace = Effect.gen(function* () { + for (const { name } of conflicts) { + const memberPath = path.join(input.effectiveHomePath, name); + const backupPath = path.join(yield* input.backupDirectory, name); + yield* input.fileSystem.rename(memberPath, backupPath); + moved.push({ name, path: memberPath, backupPath }); + } + for (const { name, state } of members) { + if (state._tag === "Symlink" || !input.sharedEntryNames.has(name)) continue; + const link = path.join(input.effectiveHomePath, name); + yield* input.fileSystem.symlink(path.join(input.sharedHomePath, name), link); + linked.push(link); + } + }); + const fileSystemError = ( + operation: "remove" | "rename", + failedPath: string, + cause: PlatformError.PlatformError, + entryName?: string, + targetPath?: string, + ) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation, + path: failedPath, + ...(targetPath === undefined ? {} : { targetPath }), + ...(entryName === undefined ? {} : { entryName }), + cause, + }); + const restore = Effect.gen(function* () { + const failures: Array = []; + for (const link of [...linked].reverse()) { + yield* input.fileSystem + .remove(link) + .pipe( + Effect.catch((cause) => + Effect.sync(() => failures.push(fileSystemError("remove", link, cause))), + ), + ); + } + for (const entry of [...moved].reverse()) { + yield* input.fileSystem + .rename(entry.backupPath, entry.path) + .pipe( + Effect.catch((cause) => + Effect.sync(() => + failures.push( + fileSystemError("rename", entry.backupPath, cause, entry.name, entry.path), + ), + ), + ), + ); + } + return failures; + }); + + // Interrupting between moves would skip the restore and split the family. + return yield* replace.pipe( + Effect.andThen( + Effect.forEach( + moved, + (entry) => + Effect.logWarning( + `Moved Codex shadow home entry '${entry.name}' to '${entry.backupPath}' so the shared copy can be linked.`, + { entryName: entry.name, path: entry.path, backupPath: entry.backupPath }, + ), + { discard: true }, + ), + ), + Effect.as(true), + Effect.catch((cause) => + Effect.gen(function* () { + const restoreFailures = yield* restore; + const annotations = { + entryName: input.entryName, + path: path.join(input.effectiveHomePath, input.entryName), + cause, + }; + // A half-restored SQLite family is unsafe to open, so stop instead of starting Codex on it. + if (restoreFailures.length > 0) { + yield* Effect.logError( + `Could not restore Codex shadow home entry '${input.entryName}' after a failed replacement.`, + { ...annotations, restoreFailures }, + ); + return yield* restoreFailures[0]!; + } + yield* Effect.logWarning( + `Could not replace Codex shadow home entry '${input.entryName}' with a shared link; it stays local until a later setup.`, + annotations, + ); + return false; + }), + ), + Effect.uninterruptible, + ); +}); + const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; readonly effectiveHomePath: string; readonly entryName: string; + readonly resolveConflictGroup: ResolveConflictGroup; }): Effect.fn.Return { const path = yield* Path.Path; const target = path.join(input.sharedHomePath, input.entryName); const link = path.join(input.effectiveHomePath, input.entryName); + // Resolve a SQLite family before linking any member of it. + if (sqliteBaseName(input.entryName) !== undefined) { + if (!(yield* input.resolveConflictGroup(input.entryName))) return; + } const state = yield* readLinkState({ ...input, linkPath: link, @@ -250,13 +413,8 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu if (state._tag === "NotSymlink") { if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); + yield* input.resolveConflictGroup(input.entryName); + return; } if (input.entryName === "thread-writer-locks") { @@ -400,6 +558,33 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" }), }), ); + // Created only when a conflict needs it; the random suffix keeps runs from colliding. + const backupDirectory = yield* Effect.cached( + Effect.gen(function* () { + const conflictsPath = path.join(effectiveHomePath, SHADOW_CONFLICTS_DIRECTORY); + yield* fileSystem.makeDirectory(conflictsPath, { recursive: true }); + const timestamp = DateTime.formatIso(yield* DateTime.now).replace(/[:.]/g, "-"); + return yield* fileSystem.makeTempDirectory({ + directory: conflictsPath, + prefix: `${timestamp}-`, + }); + }), + ); + const sharedEntryNameSet = new Set(sharedEntryNames); + const conflictGroupOutcomes = new Map(); + const resolveConflictGroup: ResolveConflictGroup = (entryName) => { + const groupKey = sqliteBaseName(entryName) ?? entryName; + const outcome = conflictGroupOutcomes.get(groupKey); + if (outcome !== undefined) return Effect.succeed(outcome); + return replaceConflictGroup({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + sharedEntryNames: sharedEntryNameSet, + backupDirectory, + }).pipe(Effect.tap((result) => Effect.sync(() => conflictGroupOutcomes.set(groupKey, result)))); + }; const entries = new Set(KNOWN_SHARED_DIRECTORIES); for (const entryName of sharedEntryNames) { if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { @@ -432,6 +617,7 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" sharedHomePath: layout.sharedHomePath, effectiveHomePath, entryName, + resolveConflictGroup, }); }, { discard: true }, diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 3384831aa87b9..e98199d8237fd 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -81,8 +81,9 @@ not move the conversation into a separate Codex home. If the account is missing from the picker, compare the home paths in provider settings. If two instances show the same unexpected account or models, check their reported accounts, refresh provider status, and confirm the second instance has -its own shadow path and login. A shadow-home conflict usually means the directory -contains a copied Codex setup. Use a fresh shadow directory and sign in again. +its own shadow path and login. When the shadow directory has its own copy of a +shared file, T3 Code moves that copy into the directory's `.t3-shadow-conflicts` +folder and links the shared one in its place. ## Answer questions while Codex works