From bb81525260e74e75f3a013ba3c3865ec7a884c76 Mon Sep 17 00:00:00 2001 From: DraftMan Date: Thu, 20 Aug 2026 04:51:34 +0200 Subject: [PATCH 1/2] fix(server): name the missing file instead of a generic read failure Opening a file that is not in the thread's workspace reports: Failed to read workspace file 'components/selectors/Foo.vue' in '/repo'. which reads as an internal error rather than the plain fact that the path is not there. The server already knows: the read fails with ENOENT and the structured `failure`/`operation` fields are carried on the error. Only the message was generic. Chat file links make this easy to hit. They are built from the text of a reply, so any path an agent mentions becomes a clickable chip whether or not it exists under the active workspace root, and `ChatView` always resolves it against that root. The existing basename lookup only rescues paths with no separator, so a multi-segment path goes straight to a failed read. A missing workspace root raises ENOENT through the same call, so `realpath-workspace-root` is excluded rather than blaming the file for it. Every other failure keeps its current message; `message` was already honored by the error constructor and is now expressible on the failure-context type. --- apps/server/src/server.test.ts | 62 +++++++++++++++++++++++++++++++ apps/server/src/ws.ts | 19 ++++++++++ packages/contracts/src/project.ts | 1 + 3 files changed, 82 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..ef0fa143b250 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4943,6 +4943,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("names a missing workspace file instead of reporting a generic read failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-missing-file-", + }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "components/selectors/Missing.vue", + }).pipe(Effect.result), + ), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + const readError = result.failure; + assert.equal( + readError.message, + `Workspace file 'components/selectors/Missing.vue' does not exist in '${workspaceDir}'.`, + ); + assert.equal(readError.cwd, workspaceDir); + assert.equal(readError.relativePath, "components/selectors/Missing.vue"); + assert.equal(readError.failure, "operation_failed"); + assert.isDefined(readError.cause); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not blame the file when the workspace root itself is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-missing-root-", + }); + const missingWorkspace = path.join(parentDir, "gone"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsReadFile]({ + cwd: missingWorkspace, + relativePath: "anything.txt", + }).pipe(Effect.result), + ), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + assert.notInclude(result.failure.message, "does not exist in"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("reports workspace root stat failures without relabeling them as missing", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..abc5ca9dbc82 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -258,6 +258,24 @@ function projectFileFailureContext( } } +// Only ENOENT means the file is genuinely absent. A permission error on a parent +// directory fails the same operation, and relabeling that as "does not exist" +// would send people looking for the wrong problem. +function missingWorkspaceFileMessage( + input: { readonly cwd: string; readonly relativePath: string }, + error: + | WorkspaceFileSystem.WorkspaceFileSystemError + | WorkspacePaths.WorkspacePathOutsideRootError, +): { readonly message?: string } { + if (error._tag !== "WorkspaceFileSystemOperationError") return {}; + // A missing workspace root raises ENOENT too, and blaming the file for it + // would point at the wrong thing entirely. + if (error.operation === "realpath-workspace-root") return {}; + const code = (error.cause as { readonly code?: unknown } | undefined)?.code; + if (code !== "ENOENT") return {}; + return { message: `Workspace file '${input.relativePath}' does not exist in '${input.cwd}'.` }; +} + function projectSetupScriptCompatibilityDetail( error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, ): string { @@ -1813,6 +1831,7 @@ const makeWsRpcLayer = ( new ProjectReadFileError({ ...input, ...projectFileFailureContext(cause), + ...missingWorkspaceFileMessage(input, cause), cause, }), ), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065a..b5907f9db70e 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -230,6 +230,7 @@ type ProjectFileFailureContext = { readonly cwd: string; readonly relativePath: string; readonly failure: ProjectFileFailure; + readonly message?: string; readonly resolvedPath?: string; readonly resolvedWorkspaceRoot?: string; readonly operation?: ProjectFileOperation; From 46ccbf91bab11a7cd5dc84a6f52ff6ea05f28ead Mon Sep 17 00:00:00 2001 From: DraftMan Date: Thu, 20 Aug 2026 05:03:12 +0200 Subject: [PATCH 2/2] fix(server): model the missing file as a structural failure Review feedback: the not-found wording was handed to ProjectReadFileError as a prebuilt `message`, so the distinction lived in text while `failure` stayed `"operation_failed"`. The `decodedProjectErrorMessage` path exists to decode legacy message-only payloads, not to override messages from new code. Classify instead: `projectFileFailureContext` returns a new `"file_not_found"` member of `ProjectFileFailure`, and `ProjectReadFileError` derives the message from `failure`, `relativePath`, and `cwd`. `message` is gone from `ProjectFileFailureContext`, so callers can no longer inject one. The guard is unchanged in substance: a missing workspace root raises ENOENT through the same operation, so `realpath-workspace-root` still classifies as `operation_failed`. Nothing reads these literals for control flow today. --- apps/server/src/server.test.ts | 3 ++- apps/server/src/ws.ts | 28 ++++++++-------------------- packages/contracts/src/project.ts | 6 ++++-- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ef0fa143b250..d3052ac35730 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4972,7 +4972,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(readError.cwd, workspaceDir); assert.equal(readError.relativePath, "components/selectors/Missing.vue"); - assert.equal(readError.failure, "operation_failed"); + assert.equal(readError.failure, "file_not_found"); assert.isDefined(readError.cause); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5001,6 +5001,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { if (result._tag !== "Failure" || result.failure._tag !== "ProjectReadFileError") { assert.fail("Expected a ProjectReadFileError"); } + assert.equal(result.failure.failure, "operation_failed"); assert.notInclude(result.failure.message, "does not exist in"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index abc5ca9dbc82..20040a6b1d21 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -238,7 +238,14 @@ function projectFileFailureContext( return { failure: "workspace_path_outside_root" }; case "WorkspaceFileSystemOperationError": return { - failure: "operation_failed", + // Only ENOENT means the target is genuinely absent, and a missing + // workspace root raises it through the same operation, so neither a + // permission error nor a missing root is reported as a missing file. + failure: + error.operation !== "realpath-workspace-root" && + (error.cause as { readonly code?: unknown } | undefined)?.code === "ENOENT" + ? "file_not_found" + : "operation_failed", resolvedPath: error.resolvedPath, operation: error.operation, operationPath: error.operationPath, @@ -258,24 +265,6 @@ function projectFileFailureContext( } } -// Only ENOENT means the file is genuinely absent. A permission error on a parent -// directory fails the same operation, and relabeling that as "does not exist" -// would send people looking for the wrong problem. -function missingWorkspaceFileMessage( - input: { readonly cwd: string; readonly relativePath: string }, - error: - | WorkspaceFileSystem.WorkspaceFileSystemError - | WorkspacePaths.WorkspacePathOutsideRootError, -): { readonly message?: string } { - if (error._tag !== "WorkspaceFileSystemOperationError") return {}; - // A missing workspace root raises ENOENT too, and blaming the file for it - // would point at the wrong thing entirely. - if (error.operation === "realpath-workspace-root") return {}; - const code = (error.cause as { readonly code?: unknown } | undefined)?.code; - if (code !== "ENOENT") return {}; - return { message: `Workspace file '${input.relativePath}' does not exist in '${input.cwd}'.` }; -} - function projectSetupScriptCompatibilityDetail( error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, ): string { @@ -1831,7 +1820,6 @@ const makeWsRpcLayer = ( new ProjectReadFileError({ ...input, ...projectFileFailureContext(cause), - ...missingWorkspaceFileMessage(input, cause), cause, }), ), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index b5907f9db70e..0cf94b6c7d04 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -209,6 +209,7 @@ export const ProjectFileFailure = Schema.Literals([ "workspace_path_outside_root", "resolved_path_outside_root", "path_not_file", + "file_not_found", "binary_file", "operation_failed", ]); @@ -230,7 +231,6 @@ type ProjectFileFailureContext = { readonly cwd: string; readonly relativePath: string; readonly failure: ProjectFileFailure; - readonly message?: string; readonly resolvedPath?: string; readonly resolvedWorkspaceRoot?: string; readonly operation?: ProjectFileOperation; @@ -258,7 +258,9 @@ export class ProjectReadFileError extends Schema.TaggedErrorClass