diff --git a/apps/server/src/atomicWrite.test.ts b/apps/server/src/atomicWrite.test.ts new file mode 100644 index 0000000000000..d3c18283c26f6 --- /dev/null +++ b/apps/server/src/atomicWrite.test.ts @@ -0,0 +1,58 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { writeFileStringAtomically } from "./atomicWrite.ts"; + +it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { + it.effect("keeps a symlinked file linked and rewrites its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + + it.effect("keeps a dangling symlink linked and creates its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "fresh"); + }), + ); + + it.effect("creates a missing file and its directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const filePath = path.join(root, "nested", "settings.json"); + + yield* writeFileStringAtomically({ filePath, contents: "fresh" }); + + assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); + }), + ); +}); diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index fcd0345fc8d60..20b2fab6374f8 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -2,6 +2,32 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +const MAX_SYMLINK_HOPS = 40; + +/** + * Follows a chain of symlinks to the file it finally names, which may not exist + * yet. Any path that is not a symlink resolves to itself. + */ +export const resolveSymlinkTarget = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(filePath); + for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { + const link = yield* fs.readLink(current).pipe(Effect.option); + if (link._tag === "None") { + return current; + } + current = path.resolve(path.dirname(current), link.value); + } + return current; + }); + +/** + * Replaces a file's contents via a sibling temp file and rename. A symlinked + * target is resolved first so the link survives and its destination is + * rewritten, since renaming over the link itself would swap it for a regular file. + */ export const writeFileStringAtomically = (input: { readonly filePath: string; readonly contents: string; @@ -10,16 +36,17 @@ export const writeFileStringAtomically = (input: { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetDirectory = path.dirname(input.filePath); + const targetPath = yield* resolveSymlinkTarget(input.filePath); + const targetDirectory = path.dirname(targetPath); yield* fs.makeDirectory(targetDirectory, { recursive: true }); const tempDirectory = yield* fs.makeTempDirectoryScoped({ directory: targetDirectory, - prefix: `${path.basename(input.filePath)}.`, + prefix: `${path.basename(targetPath)}.`, }); const tempPath = path.join(tempDirectory, "contents.tmp"); yield* fs.writeFileString(tempPath, input.contents); - yield* fs.rename(tempPath, input.filePath); + yield* fs.rename(tempPath, targetPath); }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 17a77c126b28a..51d81dc9f1eac 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -23,10 +23,12 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; @@ -131,6 +133,79 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("saves through a symlinked settings file without replacing the link", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "settings.json"); + yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + + yield* service.updateSettings({ responseStreamingMode: "paragraph" }); + + assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath); + const persisted = yield* decodeServerSettingsJson( + yield* fs.readFileString(linkedSettingsPath), + ); + assert.equal(persisted.responseStreamingMode, "paragraph"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("reloads when the destination of a symlinked settings file changes", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "settings.json"); + yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + yield* service.start; + const changes = yield* service.subscribeChanges; + + yield* writeFileStringAtomically({ + filePath: linkedSettingsPath, + contents: `{ "responseStreamingMode": "paragraph" }`, + }); + + const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("reloads when a dangling settings link gets its destination", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json"); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + yield* service.start; + const changes = yield* service.subscribeChanges; + + yield* writeFileStringAtomically({ + filePath: linkedSettingsPath, + contents: `{ "responseStreamingMode": "paragraph" }`, + }); + + const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves context when reading a provider environment secret fails", () => { const platformCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 372506e0e508b..eeb173e2e1301 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -51,7 +51,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { resolveSymlinkTarget, writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -1181,10 +1181,24 @@ const make = Effect.gen(function* () { }), ); + const watchFileChanges = (filePath: string) => { + const directory = pathService.dirname(filePath); + const fileName = pathService.basename(filePath); + const resolvedFilePath = pathService.resolve(filePath); + return fs + .watch(directory) + .pipe( + Stream.filter( + (event) => + event.path === fileName || + event.path === filePath || + pathService.resolve(directory, event.path) === resolvedFilePath, + ), + ); + }; + const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); - const settingsFile = pathService.basename(settingsPath); - const settingsPathResolved = pathService.resolve(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( @@ -1199,19 +1213,29 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + // A symlinked settings file is rewritten in its destination's directory, + // which a watch on the link's directory never sees. + const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + const isLinked = linkTargetPath !== pathService.resolve(settingsPath); + if (isLinked) { + yield* fs + .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) + .pipe(Effect.ignore({ log: true })); + } + const linkTargetEvents = isLinked + ? watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })) + : Stream.empty; + // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. - const debouncedSettingsEvents = fs.watch(settingsDir).pipe( - Stream.filter((event) => { - return ( - event.path === settingsFile || - event.path === settingsPath || - pathService.resolve(settingsDir, event.path) === settingsPathResolved - ); - }), - Stream.debounce(Duration.millis(100)), - ); + const debouncedSettingsEvents = Stream.merge( + watchFileChanges(settingsPath), + linkTargetEvents, + ).pipe(Stream.debounce(Duration.millis(100))); yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( Effect.ignoreCause({ log: true }), diff --git a/docs/fork/0027-symlinked-settings-stay-linked.md b/docs/fork/0027-symlinked-settings-stay-linked.md new file mode 100644 index 0000000000000..46905611fa1c1 --- /dev/null +++ b/docs/fork/0027-symlinked-settings-stay-linked.md @@ -0,0 +1,31 @@ +# 0027: Symlinked settings stay linked + +- PR: [TrogonStack/t3code#73](https://github.com/TrogonStack/t3code/pull/73) +- Status: active + +## What you can do now + +- Keep T3 Code's settings in a dotfiles repository and link them into the T3 + home. Changing a setting in the app writes through the link, so the + repository copy is always the one in use. +- Edit settings from the app as often as you like without the link quietly + turning into a standalone copy that the repository no longer sees. + +## Why + +Saving settings used to replace a linked file with a regular one. Nothing +failed and the app kept working, so the first sign was a later discovery that +the repository and the machine had drifted apart, with no record of which +changes happened where. Configuration that was supposed to be versioned had +stopped being versioned on the first save. + +That silence is the reason to fix it rather than document it. A setup that +breaks loudly gets fixed on the spot; one that detaches without a trace costs +a reconciliation session weeks later, after the two copies have each picked up +changes the other lacks. + +## Upstream considerations + +A clean upstream submission. Writes still land atomically, nothing changes for +anyone who does not use links, and the behavior is what a user who links a +config file expects. Once an equivalent lands upstream, this entry goes. diff --git a/docs/fork/README.md b/docs/fork/README.md index 88c92e39803b3..06f796a7635fb 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -43,3 +43,5 @@ Each entry uses these sections: active, [#38](https://github.com/TrogonStack/t3code/pull/38) - **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md) active, [#57](https://github.com/TrogonStack/t3code/pull/57) +- **0027** [Symlinked settings stay linked](./0027-symlinked-settings-stay-linked.md) + active, [#73](https://github.com/TrogonStack/t3code/pull/73)