From 7aaa01df02da10d376e1e710dc3cd545903350d9 Mon Sep 17 00:00:00 2001 From: Francois Rossouw Date: Sat, 22 Aug 2026 19:04:34 +0800 Subject: [PATCH 1/5] fix(files): refresh the open file when it changes on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open file tab kept showing whatever it read when it was opened. The SWR query atom behind projects.readFile had no refresh interval and no revalidation trigger, so once mounted nothing ever recomputed it: its staleTime meant "30s old at the next read", not "refetch every 30s". An agent editing files while you watch left the viewer stale indefinitely, and only a window reload cleared it. The server now watches the open file and pushes a change signal over a new subscribeProjectFileChanges subscription, following the existing FileSystem.watch pattern in serverSettings.ts and keybindings.ts. The client treats that signal as a query dependency, so the read re-runs outright rather than being answered from cache — staleTime only gates SWR's own background revalidation, so a change landing inside the stale window still reaches the UI. No polling, and no focus gating, which would have missed the common edits-while-focused case. The watch is on the containing directory, not the file: an atomic rename-over-temp swaps the inode and a file-level watch would follow the discarded one. The atom is shared, so web, desktop and mobile all stop serving stale contents together. Co-Authored-By: Claude Opus 5 --- apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/server.test.ts | 47 +++++++ .../src/workspace/WorkspaceFileSystem.test.ts | 85 ++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 59 +++++++- apps/server/src/ws.ts | 15 ++ packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/projectCommands.ts | 6 + .../client-runtime/src/state/runtime.test.ts | 92 +++++++++++++ packages/client-runtime/src/state/runtime.ts | 130 ++++++++++++++++-- packages/contracts/src/project.ts | 12 ++ packages/contracts/src/rpc.ts | 14 ++ 11 files changed, 447 insertions(+), 16 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..ade44837e327 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -78,6 +78,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + // Watching a file you are already allowed to read is still reading. + [WS_METHODS.subscribeProjectFileChanges]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index de3f5101f53e..f77bb6fd8485 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4784,6 +4784,53 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("routes websocket rpc subscribeProjectFileChanges for edits made outside the app", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-watch-" }); + const filePath = path.join(workspaceDir, "package.json"); + const before = '{ "description": "An awesome horse platform" }\n'; + const after = '{ "description": "An awesome course platform" }\n'; + yield* fs.writeFileString(filePath, before); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const watcher = yield* client[WS_METHODS.subscribeProjectFileChanges]({ + cwd: workspaceDir, + relativePath: "package.json", + }).pipe(Stream.runHead, Effect.forkChild()); + // `fs.watch` registration is not observable, so rewrite until the + // subscription reports rather than racing a fixed startup delay. + // The pause outlasts the watcher's debounce window. + const writer = yield* fs + .writeFileString(filePath, after) + .pipe( + Effect.orDie, + Effect.delay(Duration.millis(300)), + Effect.forever, + Effect.forkChild(), + ); + const event = yield* Fiber.join(watcher).pipe(Effect.timeout(Duration.seconds(10))); + yield* Fiber.interrupt(writer); + const file = yield* client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "package.json", + }); + return { event, file }; + }), + ), + ); + + assert.deepEqual(result.event, Option.some({ relativePath: "package.json" })); + assert.equal(result.file.contents, after); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993d..f653bc7f04a0 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,9 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -265,4 +269,85 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); }); + + describe("watchFile", () => { + /** + * `fs.watch` registration is not observable, so the writer keeps rewriting + * until the watcher reports instead of racing a fixed startup delay. It + * pauses longer than the debounce window between rewrites so the stream + * gets the quiet period it waits for. + */ + const awaitFirstChange = Effect.fn("awaitFirstChange")(function* ( + cwd: string, + relativePath: string, + rewrite: Effect.Effect, + ) { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const watcher = yield* workspaceFileSystem + .watchFile({ cwd, relativePath }) + .pipe(Stream.runHead, Effect.forkChild()); + const writer = yield* rewrite.pipe( + Effect.delay(Duration.millis(300)), + Effect.forever, + Effect.forkChild(), + ); + const event = yield* Fiber.join(watcher).pipe(Effect.timeout(Duration.seconds(10))); + yield* Fiber.interrupt(writer); + return event; + }); + + it.effect("reports a plain on-disk write", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "package.json", + '{ "description": "An awesome horse platform" }\n', + ); + + const event = yield* awaitFirstChange( + cwd, + "package.json", + writeTextFile(cwd, "package.json", '{ "description": "An awesome course platform" }\n'), + ); + + expect(event).toEqual(Option.some({ relativePath: "package.json" })); + }), + ); + + it.effect("reports an atomic replace that swaps the file's inode", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const target = path.join(cwd, "src/index.ts"); + const temp = path.join(cwd, "src/index.ts.tmp"); + const replace = Effect.gen(function* () { + yield* fileSystem.writeFileString(temp, "export const answer = 43;\n").pipe(Effect.orDie); + yield* fileSystem.rename(temp, target).pipe(Effect.orDie); + }); + + const event = yield* awaitFirstChange(cwd, "src/index.ts", replace); + + expect(event).toEqual(Option.some({ relativePath: "src/index.ts" })); + }), + ); + + it.effect("rejects watches outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .watchFile({ cwd, relativePath: "../escape.md" }) + .pipe(Stream.runHead, Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb390..1ee33b201360 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -10,17 +10,20 @@ import * as NodeFSP from "node:fs/promises"; import type { + ProjectFileChangedEvent, ProjectReadFileInput, ProjectReadFileResult, ProjectWriteFileInput, ProjectWriteFileResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -43,6 +46,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass; + /** + * Emit a change event every time the file changes on disk. + * + * The stream is a signal only: subscribers re-read through `readFile`, so + * every size, binary and error rule stays in one place. Events are + * debounced because a single save is several `fs.watch` events. + */ + readonly watchFile: ( + input: ProjectReadFileInput, + ) => Stream.Stream< + ProjectFileChangedEvent, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; } >()("t3/workspace/WorkspaceFileSystem") {} @@ -297,7 +314,47 @@ export const make = Effect.gen(function* () { return { relativePath: target.relativePath }; }); - return WorkspaceFileSystem.of({ readFile, writeFile }); + /** + * Watches the containing directory rather than the file itself: saves that + * land as rename-over-temp (git, most editors, atomic writers) replace the + * inode, and a file-level watch would follow the discarded one. + */ + const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const directory = path.dirname(target.absolutePath); + const fileName = path.basename(target.absolutePath); + + return fileSystem.watch(directory).pipe( + Stream.filter( + (event) => + event.path === fileName || + event.path === target.absolutePath || + path.resolve(directory, event.path) === target.absolutePath, + ), + // Debounce so the file is fully written before subscribers re-read it. + Stream.debounce(Duration.millis(100)), + Stream.map(() => ({ relativePath: target.relativePath })), + Stream.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: directory, + operation: "watch", + cause, + }), + ), + ); + }), + ); + + return WorkspaceFileSystem.of({ readFile, writeFile, watchFile }); }); export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..809809bd15a4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1899,6 +1899,21 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.subscribeProjectFileChanges]: (input) => + observeRpcStream( + WS_METHODS.subscribeProjectFileChanges, + workspaceFileSystem.watchFile(input).pipe( + Stream.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..849273a0d0be 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -52,6 +52,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.subscribeProjectFileChanges | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..b02a372d54f6 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -8,6 +8,7 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, } from "./runtime.ts"; +import { subscribe } from "../rpc/client.ts"; import { type CreateProjectInput, type DeleteProjectInput, @@ -66,11 +67,16 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + // The server watches the open file and says when it moved, so an agent (or + // anything else) editing on disk lands in the viewer without polling and + // without waiting for a focus change. Web, desktop and mobile all read this + // atom, so they all stop serving stale contents together. readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, + invalidate: (input) => subscribe(WS_METHODS.subscribeProjectFileChanges, input), }), optimisticFile: (target: OptimisticProjectFileTarget) => optimisticFileFamily(optimisticProjectFileKey(target)), diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index f36087ebf66a..c7c8f772c438 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -12,7 +12,9 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { environmentRpcKey, createAtomCommandScheduler, + createInvalidatableQueryAtom, createRuntimeCommand, + createStreamRevisionAtom, scheduleAtomCommandEffect, executeAtomCommand, executeAtomQuery, @@ -498,3 +500,93 @@ describe("runtime command runner", () => { registry.dispose(); }); }); + +describe("createInvalidatableQueryAtom", () => { + it("refetches when the revision changes, and only then, inside the stale window", async () => { + const runtime = Atom.runtime(Layer.empty); + const revisions = Atom.make(0); + let reads = 0; + const query = createInvalidatableQueryAtom(runtime, { + label: "test.invalidatable", + staleTimeMs: 30_000, + idleTtlMs: 60_000, + revisions, + execute: () => Effect.sync(() => (reads += 1)), + }); + const registry = AtomRegistry.make(); + + expect(await executeAtomQuery(registry, query)).toMatchObject({ + _tag: "Success", + value: 1, + }); + + // Fresh within `staleTime`: reading again must not hit the source. + expect(await executeAtomQuery(registry, query)).toMatchObject({ + _tag: "Success", + value: 1, + }); + expect(reads).toBe(1); + + // A revision bump means the underlying data moved, so the stale window + // stops applying — this is the stale-file-viewer fix. + registry.set(revisions, 1); + + expect(await executeAtomQuery(registry, query)).toMatchObject({ + _tag: "Success", + value: 2, + }); + expect(reads).toBe(2); + registry.dispose(); + }); + + it("keeps serving the stale window when no revisions are supplied", async () => { + const runtime = Atom.runtime(Layer.empty); + let reads = 0; + const query = createInvalidatableQueryAtom(runtime, { + label: "test.plain", + staleTimeMs: 30_000, + idleTtlMs: 60_000, + execute: () => Effect.sync(() => (reads += 1)), + }); + const registry = AtomRegistry.make(); + + await executeAtomQuery(registry, query); + await executeAtomQuery(registry, query); + + expect(reads).toBe(1); + registry.dispose(); + }); +}); + +describe("createStreamRevisionAtom", () => { + it("counts emissions and ignores everything else", async () => { + const runtime = Atom.runtime(Layer.empty); + const latch = Latch.makeUnsafe(); + const revisions = createStreamRevisionAtom( + runtime, + Stream.fromArray(["changed", "changed"]).pipe(Stream.concat(Stream.fromEffect(latch.await))), + { label: "test.revisions", idleTtlMs: 60_000 }, + ); + const registry = AtomRegistry.make(); + const observed: Array = []; + let unsubscribe = () => {}; + const secondRevision = new Promise((resolve) => { + unsubscribe = registry.subscribe( + revisions, + (revision) => { + observed.push(revision); + if (revision === 2) resolve(revision); + }, + { immediate: true }, + ); + }); + + // Two emissions, two revisions — identical payloads still count as two, + // because the emission is the signal, not its contents. + expect(await secondRevision).toBe(2); + // Waiting states never rewind the revision. + expect(observed).toEqual([...observed].toSorted((a, b) => a - b)); + unsubscribe(); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 2404feb82b22..3db1c5fc3327 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -52,6 +52,14 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; + /** + * Server-pushed staleness signal. Every emission recomputes the query, which + * re-runs `execute` outright — `staleTimeMs` only gates SWR's own background + * revalidation, so a change that lands inside the stale window still lands in + * the UI. Use this instead of polling when the server can say when the + * underlying data moved. + */ + readonly invalidate?: (input: Input) => Stream.Stream; } interface EnvironmentSubscriptionAtomOptions { @@ -505,11 +513,27 @@ export function createEnvironmentQueryAtomFamily( { initialValue: null }, ), ); + const invalidate = options.invalidate; const family = Atom.family((key: string) => { const target = parseEnvironmentRpcKey(key); const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; - const queryAtom = runtime - .atom((get) => { + const revisions = + invalidate === undefined + ? undefined + : createStreamRevisionAtom( + runtime, + followStreamInEnvironment(target.environmentId, invalidate(target.input)), + { label: `${options.label}:invalidate:${key}`, idleTtlMs }, + ); + return createInvalidatableQueryAtom(runtime, { + label: `${options.label}:${key}`, + idleTtlMs, + staleTimeMs: options.staleTimeMs ?? 30_000, + ...(options.refreshIntervalMs === undefined + ? {} + : { refreshIntervalMs: options.refreshIntervalMs }), + ...(revisions === undefined ? {} : { revisions }), + execute: (get) => { const generation = Option.getOrNull( AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), ); @@ -517,23 +541,82 @@ export function createEnvironmentQueryAtomFamily( return Effect.never; } return runInEnvironment(target.environmentId, options.execute(target.input)); - }) - .pipe( - Atom.swr({ - staleTime: options.staleTimeMs ?? 30_000, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - ); - return ( - options.refreshIntervalMs === undefined - ? queryAtom - : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); + }, + }); }); return (target) => family(environmentRpcKey(target)); } +/** + * Counts emissions of `stream` as a plain revision number. + * + * Counting on the client keeps the revision insensitive to everything a query + * should not refetch for: waiting states, reconnects and repeated payloads all + * map back to the same number, and the atom's `Object.is` equality drops them. + */ +export function createStreamRevisionAtom( + runtime: Atom.AtomRuntime, + stream: Stream.Stream, + options: { readonly label: string; readonly idleTtlMs: number }, +): Atom.Atom { + return runtime + .atom( + stream.pipe( + Stream.mapAccum( + () => 0, + (revision: number) => [revision + 1, [revision + 1]] as const, + ), + ), + { initialValue: 0 }, + ) + .pipe( + Atom.map((result: AsyncResult.AsyncResult) => { + const revision = AsyncResult.value(result); + return Option.isSome(revision) && typeof revision.value === "number" ? revision.value : 0; + }), + Atom.setIdleTTL(options.idleTtlMs), + Atom.withLabel(options.label), + ); +} + +/** + * Stale-while-revalidate query atom that also re-executes whenever `revisions` + * changes. + * + * `staleTimeMs` only gates SWR's own background revalidation, so a revision + * bump refetches even inside the stale window. That is the point: a push that + * says the data moved must not be answered with a cached value. + */ +export function createInvalidatableQueryAtom( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (get: Atom.AtomContext) => Effect.Effect; + readonly revisions?: Atom.Atom; + readonly staleTimeMs: number; + readonly idleTtlMs: number; + readonly refreshIntervalMs?: number; + }, +): Atom.Atom> { + const revisions = options.revisions; + const queryAtom = runtime + .atom((get: Atom.AtomContext) => { + if (revisions !== undefined) { + get(revisions); + } + return options.execute(get); + }) + .pipe( + Atom.swr({ staleTime: options.staleTimeMs, revalidateOnMount: true }), + Atom.setIdleTTL(options.idleTtlMs), + ); + return ( + options.refreshIntervalMs === undefined + ? queryAtom + : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) + ).pipe(Atom.setIdleTTL(options.idleTtlMs), Atom.withLabel(options.label)); +} + export function createEnvironmentSubscriptionAtomFamily( runtime: Atom.AtomRuntime, options: EnvironmentSubscriptionAtomOptions, @@ -598,8 +681,17 @@ export function createEnvironmentRpcQueryAtomFamily, + ) => Stream.Stream; }, ) { + const invalidate = options.invalidate; return createEnvironmentQueryAtomFamily(runtime, { label: options.label, ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), @@ -607,6 +699,14 @@ export function createEnvironmentRpcQueryAtomFamily) => + invalidate(input).pipe(Stream.catchCause(() => Stream.empty)), + }), execute: (input: EnvironmentRpcInput) => request(options.tag, input), }); } diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065a..1a3245215391 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -205,6 +205,17 @@ export const ProjectReadFileResult = Schema.Struct({ }); export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; +/** + * Emitted by `subscribeProjectFileChanges` whenever a watched workspace file + * changes on disk. It is a signal, not a payload: clients re-read the file + * through `projects.readFile` so size limits, binary detection and error + * mapping keep living in one place. + */ +export const ProjectFileChangedEvent = Schema.Struct({ + relativePath: TrimmedNonEmptyString, +}); +export type ProjectFileChangedEvent = typeof ProjectFileChangedEvent.Type; + export const ProjectFileFailure = Schema.Literals([ "workspace_path_outside_root", "resolved_path_outside_root", @@ -223,6 +234,7 @@ export const ProjectFileOperation = Schema.Literals([ "close", "make-directory", "write-file", + "watch", ]); export type ProjectFileOperation = typeof ProjectFileOperation.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..c0c46385684e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -99,6 +99,7 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectFileChangedEvent, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, @@ -304,6 +305,7 @@ export const WS_METHODS = { // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", + subscribeProjectFileChanges: "subscribeProjectFileChanges", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", @@ -642,6 +644,17 @@ export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); +/** + * Watches one workspace file and emits whenever it changes on disk, so an open + * viewer re-reads instead of pinning whatever it read when it was opened. + */ +export const WsSubscribeProjectFileChangesRpc = Rpc.make(WS_METHODS.subscribeProjectFileChanges, { + payload: ProjectReadFileInput, + success: ProjectFileChangedEvent, + error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), + stream: true, +}); + export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -1028,6 +1041,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsSubscribeProjectFileChangesRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, From cfdcfd49a85c4712892756170f58569d2de5de90 Mon Sep 17 00:00:00 2001 From: Francois Rossouw Date: Sat, 22 Aug 2026 19:38:57 +0800 Subject: [PATCH 2/5] fix(files): contain the file watcher to the real workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watchFile only did the lexical containment check, so a symlink inside the workspace pointing outward — `link/secret.txt` where `link` targets an external directory — started an fs.watch on that external directory and emitted change signals about it. No contents leaked, since the event carries only a path and readFile still refuses the read, but it is a containment gap and an existence side channel. readFile already resolved both ends with realpath and compared them physically. That check is now a shared helper both paths go through, so they cannot drift apart again. Reported by Macroscope on #7896. Co-Authored-By: Claude Opus 5 --- .../src/workspace/WorkspaceFileSystem.test.ts | 22 ++++ .../src/workspace/WorkspaceFileSystem.ts | 116 ++++++++++-------- 2 files changed, 87 insertions(+), 51 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index f653bc7f04a0..8a3803bf3f5d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -335,6 +335,28 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("rejects a watch that escapes the workspace through a symlink", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outside = yield* makeTempDir; + yield* fileSystem + .writeFileString(path.join(outside, "secret.txt"), "secret\n") + .pipe(Effect.orDie); + // Lexically `link/secret.txt` sits inside the workspace; physically it + // does not, so no watcher may be pointed at it. + yield* fileSystem.symlink(outside, path.join(cwd, "link")).pipe(Effect.orDie); + + const error = yield* workspaceFileSystem + .watchFile({ cwd, relativePath: "link/secret.txt" }) + .pipe(Stream.runHead, Effect.flip); + + expect(error._tag).toBe("WorkspaceFilePathEscapeError"); + }), + ); + it.effect("rejects watches outside the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 1ee33b201360..7ffb49299a6d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -149,51 +149,66 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( - "WorkspaceFileSystem.readFile", - )(function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - const realWorkspaceRoot = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(input.cwd), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: input.cwd, - operation: "realpath-workspace-root", - cause, - }), - }); - const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(target.absolutePath), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "realpath-target", - cause, - }), - }); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { - return yield* new WorkspaceFilePathEscapeError({ + /** + * Resolve a workspace-relative path to a real path proven to sit inside the + * workspace root. Lexical containment is not enough: a symlink inside the + * workspace can point anywhere, so both ends are resolved with `realpath` + * and compared physically. Every operation that touches a workspace path + * goes through here so the checks cannot drift apart. + */ + const resolveContainedRealPath = Effect.fn("WorkspaceFileSystem.resolveContainedRealPath")( + function* (input: ProjectReadFileInput) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedWorkspaceRoot: realWorkspaceRoot, - resolvedPath: realTargetPath, }); - } + + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + }); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + + return { target, realTargetPath }; + }, + ); + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const { target, realTargetPath } = yield* resolveContainedRealPath(input); return yield* Effect.acquireUseRelease( Effect.tryPromise({ @@ -322,19 +337,18 @@ export const make = Effect.gen(function* () { const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) => Stream.unwrap( Effect.gen(function* () { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - const directory = path.dirname(target.absolutePath); - const fileName = path.basename(target.absolutePath); + // Physical containment, not just lexical: a symlink inside the + // workspace must not get a watcher pointed at an external directory. + const { target, realTargetPath } = yield* resolveContainedRealPath(input); + const directory = path.dirname(realTargetPath); + const fileName = path.basename(realTargetPath); return fileSystem.watch(directory).pipe( Stream.filter( (event) => event.path === fileName || - event.path === target.absolutePath || - path.resolve(directory, event.path) === target.absolutePath, + event.path === realTargetPath || + path.resolve(directory, event.path) === realTargetPath, ), // Debounce so the file is fully written before subscribers re-read it. Stream.debounce(Duration.millis(100)), @@ -344,7 +358,7 @@ export const make = Effect.gen(function* () { new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: target.absolutePath, + resolvedPath: realTargetPath, operationPath: directory, operation: "watch", cause, From c34df4501ac51ccadebb2ce62caf0383a96b5c10 Mon Sep 17 00:00:00 2001 From: Francois Rossouw Date: Sat, 22 Aug 2026 19:49:55 +0800 Subject: [PATCH 3/5] fix(files): contain the watched directory, not the watched file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containing the leaf meant realpath had to resolve the file itself, so a watch could not attach to a path that was momentarily absent — mid atomic-replace, or not yet created. The client turns that failure into an empty invalidate stream, so such a tab would silently lose live refresh until something remounted it. Contain the directory instead. That is what fs.watch is pointed at, so it is the thing that has to be inside the workspace, and it closes the symlink escape just as well. A symlinked leaf stays safe because the re-read still goes through readFile, which contains the file it opens. Reported by Cursor Bugbot on #7896. Co-Authored-By: Claude Opus 5 --- .../src/workspace/WorkspaceFileSystem.test.ts | 15 +++ .../src/workspace/WorkspaceFileSystem.ts | 121 +++++++++--------- 2 files changed, 79 insertions(+), 57 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 8a3803bf3f5d..19bab1bbea80 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -335,6 +335,21 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reports a file that does not exist yet when it appears", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir; + // No file at this path: the watcher must still attach, so a tab whose + // file is momentarily absent keeps its live refresh. + const event = yield* awaitFirstChange( + cwd, + "later.txt", + writeTextFile(cwd, "later.txt", "created\n"), + ); + + expect(event).toEqual(Option.some({ relativePath: "later.txt" })); + }), + ); + it.effect("rejects a watch that escapes the workspace through a symlink", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 7ffb49299a6d..333ef10c2020 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -150,65 +150,65 @@ export const make = Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; /** - * Resolve a workspace-relative path to a real path proven to sit inside the + * Resolve one absolute path with `realpath` and prove it sits inside the * workspace root. Lexical containment is not enough: a symlink inside the - * workspace can point anywhere, so both ends are resolved with `realpath` - * and compared physically. Every operation that touches a workspace path - * goes through here so the checks cannot drift apart. + * workspace can point anywhere, so both ends are resolved physically and + * compared. Callers pick what they need contained — `readFile` contains the + * file it opens, `watchFile` the directory it watches. */ - const resolveContainedRealPath = Effect.fn("WorkspaceFileSystem.resolveContainedRealPath")( - function* (input: ProjectReadFileInput) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + const realPathWithinRoot = Effect.fn("WorkspaceFileSystem.realPathWithinRoot")(function* ( + input: ProjectReadFileInput, + absolutePath: string, + ) { + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + const realPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: absolutePath, + operationPath: absolutePath, + operation: "realpath-target", + cause, + }), + }); + const relativeRealPath = path.relative(realWorkspaceRoot, realPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError({ workspaceRoot: input.cwd, relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realPath, }); + } - const realWorkspaceRoot = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(input.cwd), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: input.cwd, - operation: "realpath-workspace-root", - cause, - }), - }); - const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(target.absolutePath), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "realpath-target", - cause, - }), - }); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { - return yield* new WorkspaceFilePathEscapeError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedWorkspaceRoot: realWorkspaceRoot, - resolvedPath: realTargetPath, - }); - } - - return { target, realTargetPath }; - }, - ); + return realPath; + }); const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { - const { target, realTargetPath } = yield* resolveContainedRealPath(input); + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const realTargetPath = yield* realPathWithinRoot(input, target.absolutePath); return yield* Effect.acquireUseRelease( Effect.tryPromise({ @@ -337,18 +337,25 @@ export const make = Effect.gen(function* () { const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) => Stream.unwrap( Effect.gen(function* () { - // Physical containment, not just lexical: a symlink inside the - // workspace must not get a watcher pointed at an external directory. - const { target, realTargetPath } = yield* resolveContainedRealPath(input); - const directory = path.dirname(realTargetPath); - const fileName = path.basename(realTargetPath); + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + // Contain the directory rather than the file: the directory is what + // gets watched, and requiring the leaf to resolve would refuse to watch + // a path that is momentarily absent — mid atomic-replace, or not yet + // created. A symlinked leaf stays safe because the re-read still goes + // through `readFile`, which contains the file it opens. + const directory = yield* realPathWithinRoot(input, path.dirname(target.absolutePath)); + const fileName = path.basename(target.absolutePath); + const watchedPath = path.join(directory, fileName); return fileSystem.watch(directory).pipe( Stream.filter( (event) => event.path === fileName || - event.path === realTargetPath || - path.resolve(directory, event.path) === realTargetPath, + event.path === watchedPath || + path.resolve(directory, event.path) === watchedPath, ), // Debounce so the file is fully written before subscribers re-read it. Stream.debounce(Duration.millis(100)), @@ -358,7 +365,7 @@ export const make = Effect.gen(function* () { new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: realTargetPath, + resolvedPath: watchedPath, operationPath: directory, operation: "watch", cause, From e056ae8793b9f15e1e25acbc4ce61917c2d28edf Mon Sep 17 00:00:00 2001 From: Francois Rossouw Date: Sat, 22 Aug 2026 20:02:30 +0800 Subject: [PATCH 4/5] fix(files): follow a symlinked file to its canonical path when watching Resolving only the parent meant a watch on an in-workspace file symlink was pointed at the alias's directory and filtered on the alias's name, so an edit made through the target's own path was missed. Prefer the canonical leaf and fall back to the parent only when the leaf does not resolve, which keeps the not-yet-created case working. Either way the directory that gets watched is proven inside the workspace, and an escaping symlink still fails rather than falling back. The added test discriminates on inotify platforms. It does not on macOS, where FSEvents reports the alias entry as touched when its target is written; the test says so rather than implying more than it proves. Reported by Cursor Bugbot on #7896. Co-Authored-By: Claude Opus 5 --- .../src/workspace/WorkspaceFileSystem.test.ts | 27 +++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 27 +++++++++++++------ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 19bab1bbea80..1eb916616efa 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -350,6 +350,33 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("follows a symlinked file to edits made through its target", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "real/config.json", '{ "value": "before" }\n'); + yield* fileSystem + .symlink(path.join(cwd, "real", "config.json"), path.join(cwd, "alias.json")) + .pipe(Effect.orDie); + + // Watching through the alias must see a write to the canonical path, + // which lives in a different directory under a different name. + // + // This discriminates on inotify platforms, where a watch on the alias's + // own directory would never hear about the target. It does not on + // macOS: FSEvents reports the alias entry as touched when its target is + // written, so this passes there even with the parent-only resolve. + const event = yield* awaitFirstChange( + cwd, + "alias.json", + writeTextFile(cwd, "real/config.json", '{ "value": "after" }\n'), + ); + + expect(event).toEqual(Option.some({ relativePath: "alias.json" })); + }), + ); + it.effect("rejects a watch that escapes the workspace through a symlink", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 333ef10c2020..10ce53922083 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -341,14 +341,25 @@ export const make = Effect.gen(function* () { workspaceRoot: input.cwd, relativePath: input.relativePath, }); - // Contain the directory rather than the file: the directory is what - // gets watched, and requiring the leaf to resolve would refuse to watch - // a path that is momentarily absent — mid atomic-replace, or not yet - // created. A symlinked leaf stays safe because the re-read still goes - // through `readFile`, which contains the file it opens. - const directory = yield* realPathWithinRoot(input, path.dirname(target.absolutePath)); - const fileName = path.basename(target.absolutePath); - const watchedPath = path.join(directory, fileName); + // Prefer the canonical leaf, so a symlinked file follows edits made + // through its target's own path. Fall back to the parent when the leaf + // does not resolve, so a watch still attaches to a path that is + // momentarily absent — mid atomic-replace, or not yet created. Either + // way the directory that gets watched is proven inside the workspace, + // and an escaping symlink still fails rather than falling back. + const canonical = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + const watchedPath = + canonical === null + ? path.join( + yield* realPathWithinRoot(input, path.dirname(target.absolutePath)), + path.basename(target.absolutePath), + ) + : yield* realPathWithinRoot(input, canonical); + const directory = path.dirname(watchedPath); + const fileName = path.basename(watchedPath); return fileSystem.watch(directory).pipe( Stream.filter( From f426631580a1a0d00c126da33affe5aa3643de98 Mon Sep 17 00:00:00 2001 From: Francois Rossouw Date: Sat, 22 Aug 2026 20:12:09 +0800 Subject: [PATCH 5/5] fix(files): watch both ends of a symlinked file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symlinked file can be changed from either end, and the two land in different directories. An edit through the target's own path fires beside the target; an atomic rename-over-temp save on the alias — the usual editor and git pattern — fires beside the alias. Watching only one end leaves the tab stale for saves made at the other. Watch both, deduplicated so the ordinary case collapses back to a single watcher, and debounced together so one save stays one event. Both directories are proven inside the workspace, and an escaping symlink still fails rather than quietly falling back to the alias-only watch. Neither symlink test discriminates on macOS: FSEvents coalesces to directory granularity and delivers a matching event either way. They guard the behaviour on inotify, and both say so. Reported by Cursor Bugbot on #7896. Co-Authored-By: Claude Opus 5 --- .../src/workspace/WorkspaceFileSystem.test.ts | 28 +++++++ .../src/workspace/WorkspaceFileSystem.ts | 82 +++++++++++-------- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 1eb916616efa..ac2d77dbe29a 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -377,6 +377,34 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reports an atomic replace of the symlink's own path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "real/config.json", '{ "value": "before" }\n'); + const alias = path.join(cwd, "alias.json"); + yield* fileSystem.symlink(path.join(cwd, "real", "config.json"), alias).pipe(Effect.orDie); + + // The editor/git pattern: write a temp file beside the alias and rename + // over it. That fires in the alias's directory, not the target's, so a + // watch that only followed the canonical path would miss it. + // + // Like the sibling symlink test, this discriminates on inotify and not + // on macOS, where FSEvents coalesces to directory granularity and + // delivers a matching event either way. + const temp = path.join(cwd, "alias.json.tmp"); + const replaceAlias = Effect.gen(function* () { + yield* fileSystem.writeFileString(temp, '{ "value": "after" }\n').pipe(Effect.orDie); + yield* fileSystem.rename(temp, alias).pipe(Effect.orDie); + }); + + const event = yield* awaitFirstChange(cwd, "alias.json", replaceAlias); + + expect(event).toEqual(Option.some({ relativePath: "alias.json" })); + }), + ); + it.effect("rejects a watch that escapes the workspace through a symlink", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 10ce53922083..3612757495d5 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -341,47 +341,63 @@ export const make = Effect.gen(function* () { workspaceRoot: input.cwd, relativePath: input.relativePath, }); - // Prefer the canonical leaf, so a symlinked file follows edits made - // through its target's own path. Fall back to the parent when the leaf - // does not resolve, so a watch still attaches to a path that is - // momentarily absent — mid atomic-replace, or not yet created. Either - // way the directory that gets watched is proven inside the workspace, - // and an escaping symlink still fails rather than falling back. + + // A symlinked file can be changed from either end, and the two land in + // different directories: an edit through the target's own path fires + // beside the target, while an atomic rename-over-temp save on the alias + // fires beside the alias. Watch both, and the ordinary case collapses + // back to one watcher because the two coincide. + const aliasDirectory = yield* realPathWithinRoot(input, path.dirname(target.absolutePath)); + const aliasWatch = { + directory: aliasDirectory, + fileName: path.basename(target.absolutePath), + }; + const watched: Array = [aliasWatch]; + + // Missing leaf is not an error: a watch must still attach to a path + // that is momentarily absent, mid atomic-replace or not yet created. const canonical = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(target.absolutePath), catch: () => null, }).pipe(Effect.orElseSucceed(() => null)); - const watchedPath = - canonical === null - ? path.join( - yield* realPathWithinRoot(input, path.dirname(target.absolutePath)), - path.basename(target.absolutePath), - ) - : yield* realPathWithinRoot(input, canonical); - const directory = path.dirname(watchedPath); - const fileName = path.basename(watchedPath); + if (canonical !== null) { + // Contained, not merely resolved: an escaping symlink fails here + // rather than quietly falling back to the alias-only watch. + const canonicalPath = yield* realPathWithinRoot(input, canonical); + const directory = path.dirname(canonicalPath); + const fileName = path.basename(canonicalPath); + if (directory !== aliasWatch.directory || fileName !== aliasWatch.fileName) { + watched.push({ directory, fileName }); + } + } - return fileSystem.watch(directory).pipe( - Stream.filter( - (event) => - event.path === fileName || - event.path === watchedPath || - path.resolve(directory, event.path) === watchedPath, + const changes = watched.map(({ directory, fileName }) => + fileSystem.watch(directory).pipe( + Stream.filter( + (event) => + event.path === fileName || + path.resolve(directory, event.path) === path.join(directory, fileName), + ), + Stream.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: path.join(directory, fileName), + operationPath: directory, + operation: "watch", + cause, + }), + ), ), - // Debounce so the file is fully written before subscribers re-read it. + ); + + const [first, second] = changes; + return (second === undefined ? first! : Stream.merge(first!, second)).pipe( + // Debounce so the file is fully written before subscribers re-read it, + // and so both watchers reporting one save stay a single event. Stream.debounce(Duration.millis(100)), Stream.map(() => ({ relativePath: target.relativePath })), - Stream.mapError( - (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: watchedPath, - operationPath: directory, - operation: "watch", - cause, - }), - ), ); }), );