From 6ab8b611bbff67501898d168515f754cd6ae1286 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:21:16 -0700 Subject: [PATCH] Preserve workspace RPC error messages Co-authored-by: codex --- .../src/project/ProjectSetupScriptRunner.ts | 2 +- apps/server/src/server.test.ts | 69 ++++++++++++--- apps/server/src/workspace/WorkspaceEntries.ts | 62 +++----------- apps/server/src/ws.ts | 84 +++++++++++++++++-- 4 files changed, 147 insertions(+), 70 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 57540088128..dc97da51f24 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return `Project setup script project was not found for thread '${this.threadId}'.`; + return "Project was not found for setup script execution."; } } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fd69c610df4..19988b20213 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4430,24 +4430,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc projects.searchEntries errors", () => + it.effect("preserves workspace rpc failure messages", () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-", + }); + const outsideDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-outside-", + }); + const outsideFile = path.join(outsideDir, "outside.txt"); + yield* fs.writeFileString(outsideFile, "outside\n"); + yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); + yield* buildAppUnderTest(); + const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); + const missingBrowseParent = path.join(workspaceDir, "missing-browse"); const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( + const results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: "/definitely/not/a/real/workspace/path", - query: "needle", - limit: 10, + Effect.all({ + search: client[WS_METHODS.projectsSearchEntries]({ + cwd: invalidWorkspace, + query: "needle", + limit: 10, + }).pipe(Effect.result), + list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( + Effect.result, + ), + read: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "linked-outside.txt", + }).pipe(Effect.result), + browse: client[WS_METHODS.filesystemBrowse]({ + cwd: workspaceDir, + partialPath: "./missing-browse/child", + }).pipe(Effect.result), }), - ).pipe(Effect.result), + ), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assert.equal(result.failure.message, "Failed to search workspace entries."); + assertTrue(results.search._tag === "Failure"); + assert.equal( + results.search.failure.message, + `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + ); + assertTrue(results.list._tag === "Failure"); + assert.equal( + results.list.failure.message, + `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + ); + assertTrue(results.read._tag === "Failure"); + assert.equal( + results.read.failure.message, + "Failed to read workspace file: Workspace file path resolves outside the project root.", + ); + assertTrue(results.browse._tag === "Failure"); + assert.equal( + results.browse.failure.message, + `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6102,7 +6146,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { threadId: input.threadId, worktreePath: input.worktreePath, operation: "openTerminal", - cause: new Error("pty unavailable"), + cause: { message: "pty unavailable" }, }), ), ); @@ -6177,8 +6221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: - "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.", + detail: "pty unavailable", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index aafd6ffd75a..398b3d951b3 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -23,23 +23,6 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", - { - cwd: Schema.String, - operation: Schema.Literals([ - "workspaceEntries.normalizeWorkspaceRoot", - "workspaceEntries.search", - "workspaceEntries.list", - ]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`; - } -} - export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( "WorkspaceEntriesWindowsPathUnsupportedError", { @@ -87,6 +70,16 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ ]); export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; +export const WorkspaceEntriesError = Schema.Union([ + WorkspacePaths.WorkspaceRootNotExistsError, + WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootNotDirectoryError, + WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, + WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, + WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed, +]); +export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; + export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { @@ -146,16 +139,7 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, ): Effect.fn.Return { - return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.normalizeWorkspaceRoot", - cause, - }), - ), - ); + return yield* workspacePaths.normalizeWorkspaceRoot(cwd); }); const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( @@ -243,17 +227,7 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.search", - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); @@ -263,17 +237,7 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.list", - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 935dd47cc85..e76b3f63d7a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -112,6 +112,77 @@ const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOu const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function unexpectedCompatibilityError(error: never): never { + throw new Error(`Unhandled compatibility error: ${String(error)}`); +} + +/** Preserve pre-structured-error display behavior at the RPC boundary. */ +function legacyPlatformFailureDescription(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** Preserve the setup runner's broader pre-refactor message normalization. */ +function legacySetupFailureDescription(cause: unknown): string { + if ( + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" + ) { + return cause.message; + } + return String(cause); +} + +function workspaceEntriesCompatibilityDetail( + error: WorkspaceEntries.WorkspaceEntriesError, +): string { + switch (error._tag) { + case "WorkspaceRootNotExistsError": + return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceRootCreateFailedError": + return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceRootNotDirectoryError": + return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceSearchIndexCreateFailed": + return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; + case "WorkspaceSearchIndexScanTimedOut": + return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; + case "WorkspaceSearchIndexSearchFailed": + return `Workspace search failed for '${error.cwd}': ${error.reason}`; + default: + return unexpectedCompatibilityError(error); + } +} + +function workspaceBrowseCompatibilityDetail( + error: WorkspaceEntries.WorkspaceEntriesBrowseError, +): string { + switch (error._tag) { + case "WorkspaceEntriesWindowsPathUnsupportedError": + return "Windows-style paths are only supported on Windows."; + case "WorkspaceEntriesCurrentProjectRequiredError": + return "Relative filesystem browse paths require a current project."; + case "WorkspaceEntriesReadDirectoryError": + return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; + default: + return unexpectedCompatibilityError(error); + } +} + +function projectSetupScriptCompatibilityDetail( + error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, +): string { + switch (error._tag) { + case "ProjectSetupScriptOperationError": + return legacySetupFailureDescription(error.cause); + case "ProjectSetupScriptProjectNotFoundError": + return "Project was not found for setup script execution."; + default: + return unexpectedCompatibilityError(error); + } +} + function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { @@ -561,12 +632,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => : Effect.void; const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; + readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; readonly requestedAt: string; readonly worktreePath: string; }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; + const detail = projectSetupScriptCompatibilityDetail(input.error); return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -1190,7 +1260,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: "Failed to search workspace entries.", + message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, cause, }), ), @@ -1204,7 +1274,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: "Failed to list workspace entries.", + message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, cause, }), ), @@ -1218,7 +1288,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError((cause) => { const message = isWorkspacePathOutsideRootError(cause) ? "Workspace file path must stay within the project root." - : "Failed to read workspace file."; + : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; return new ProjectReadFileError({ message, cause }); }), ), @@ -1251,7 +1321,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: "Failed to browse the filesystem.", + message: workspaceBrowseCompatibilityDetail(cause), cause, }), ),