| author | |
| committer | |
| log | f58b6da2308bda9e1c977dba5280747d0e4e70e6 |
| tree | ddc2f2e8eea36d52fa1d386f91a3151bc7dc54be |
| parent | 9a1c4875a37ea7eeb77db189e2e45db9404d7a47 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 147 insertions(+), 70 deletions(-)
apps/server/src/project/ProjectSetupScriptRunner.ts+1-1| ... | ... | @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl |
| 59 | 59 | }, |
| 60 | 60 | ) { |
| 61 | 61 | override get message(): string { |
| 62 | return `Project setup script project was not found for thread '${this.threadId}'.`; | |
| 62 | return "Project was not found for setup script execution."; | |
| 63 | 63 | } |
| 64 | 64 | } |
| 65 | 65 |
apps/server/src/server.test.ts+56-13| ... | ... | @@ -4430,24 +4430,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4430 | 4430 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), |
| 4431 | 4431 | ); |
| 4432 | 4432 | |
| 4433 | it.effect("routes websocket rpc projects.searchEntries errors", () => | |
| 4433 | it.effect("preserves workspace rpc failure messages", () => | |
| 4434 | 4434 | Effect.gen(function* () { |
| 4435 | const fs = yield* FileSystem.FileSystem; | |
| 4436 | const path = yield* Path.Path; | |
| 4437 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ | |
| 4438 | prefix: "t3-ws-workspace-errors-", | |
| 4439 | }); | |
| 4440 | const outsideDir = yield* fs.makeTempDirectoryScoped({ | |
| 4441 | prefix: "t3-ws-workspace-errors-outside-", | |
| 4442 | }); | |
| 4443 | const outsideFile = path.join(outsideDir, "outside.txt"); | |
| 4444 | yield* fs.writeFileString(outsideFile, "outside\n"); | |
| 4445 | yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); | |
| 4446 | ||
| 4435 | 4447 | yield* buildAppUnderTest(); |
| 4436 | 4448 | |
| 4449 | const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); | |
| 4450 | const missingBrowseParent = path.join(workspaceDir, "missing-browse"); | |
| 4437 | 4451 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 4438 | const result = yield* Effect.scoped( | |
| 4452 | const results = yield* Effect.scoped( | |
| 4439 | 4453 | withWsRpcClient(wsUrl, (client) => |
| 4440 | client[WS_METHODS.projectsSearchEntries]({ | |
| 4441 | cwd: "/definitely/not/a/real/workspace/path", | |
| 4442 | query: "needle", | |
| 4443 | limit: 10, | |
| 4454 | Effect.all({ | |
| 4455 | search: client[WS_METHODS.projectsSearchEntries]({ | |
| 4456 | cwd: invalidWorkspace, | |
| 4457 | query: "needle", | |
| 4458 | limit: 10, | |
| 4459 | }).pipe(Effect.result), | |
| 4460 | list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( | |
| 4461 | Effect.result, | |
| 4462 | ), | |
| 4463 | read: client[WS_METHODS.projectsReadFile]({ | |
| 4464 | cwd: workspaceDir, | |
| 4465 | relativePath: "linked-outside.txt", | |
| 4466 | }).pipe(Effect.result), | |
| 4467 | browse: client[WS_METHODS.filesystemBrowse]({ | |
| 4468 | cwd: workspaceDir, | |
| 4469 | partialPath: "./missing-browse/child", | |
| 4470 | }).pipe(Effect.result), | |
| 4444 | 4471 | }), |
| 4445 | ).pipe(Effect.result), | |
| 4472 | ), | |
| 4446 | 4473 | ); |
| 4447 | 4474 | |
| 4448 | assertTrue(result._tag === "Failure"); | |
| 4449 | assertTrue(result.failure._tag === "ProjectSearchEntriesError"); | |
| 4450 | assert.equal(result.failure.message, "Failed to search workspace entries."); | |
| 4475 | assertTrue(results.search._tag === "Failure"); | |
| 4476 | assert.equal( | |
| 4477 | results.search.failure.message, | |
| 4478 | `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, | |
| 4479 | ); | |
| 4480 | assertTrue(results.list._tag === "Failure"); | |
| 4481 | assert.equal( | |
| 4482 | results.list.failure.message, | |
| 4483 | `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, | |
| 4484 | ); | |
| 4485 | assertTrue(results.read._tag === "Failure"); | |
| 4486 | assert.equal( | |
| 4487 | results.read.failure.message, | |
| 4488 | "Failed to read workspace file: Workspace file path resolves outside the project root.", | |
| 4489 | ); | |
| 4490 | assertTrue(results.browse._tag === "Failure"); | |
| 4491 | assert.equal( | |
| 4492 | results.browse.failure.message, | |
| 4493 | `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, | |
| 4494 | ); | |
| 4451 | 4495 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4452 | 4496 | ); |
| 4453 | 4497 | |
| ... | ... | @@ -6102,7 +6146,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6102 | 6146 | threadId: input.threadId, |
| 6103 | 6147 | worktreePath: input.worktreePath, |
| 6104 | 6148 | operation: "openTerminal", |
| 6105 | cause: new Error("pty unavailable"), | |
| 6149 | cause: { message: "pty unavailable" }, | |
| 6106 | 6150 | }), |
| 6107 | 6151 | ), |
| 6108 | 6152 | ); |
| ... | ... | @@ -6177,8 +6221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6177 | 6221 | ); |
| 6178 | 6222 | assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); |
| 6179 | 6223 | assert.deepEqual(setupFailureActivity?.activity.payload, { |
| 6180 | detail: | |
| 6181 | "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.", | |
| 6224 | detail: "pty unavailable", | |
| 6182 | 6225 | worktreePath: "/tmp/bootstrap-worktree", |
| 6183 | 6226 | }); |
| 6184 | 6227 | assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); |
apps/server/src/workspace/WorkspaceEntries.ts+13-49| ... | ... | @@ -23,23 +23,6 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p |
| 23 | 23 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 24 | 24 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 25 | 25 | |
| 26 | export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()( | |
| 27 | "WorkspaceEntriesError", | |
| 28 | { | |
| 29 | cwd: Schema.String, | |
| 30 | operation: Schema.Literals([ | |
| 31 | "workspaceEntries.normalizeWorkspaceRoot", | |
| 32 | "workspaceEntries.search", | |
| 33 | "workspaceEntries.list", | |
| 34 | ]), | |
| 35 | cause: Schema.Defect(), | |
| 36 | }, | |
| 37 | ) { | |
| 38 | override get message(): string { | |
| 39 | return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`; | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | 26 | export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass<WorkspaceEntriesWindowsPathUnsupportedError>()( |
| 44 | 27 | "WorkspaceEntriesWindowsPathUnsupportedError", |
| 45 | 28 | { |
| ... | ... | @@ -87,6 +70,16 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ |
| 87 | 70 | ]); |
| 88 | 71 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; |
| 89 | 72 | |
| 73 | export const WorkspaceEntriesError = Schema.Union([ | |
| 74 | WorkspacePaths.WorkspaceRootNotExistsError, | |
| 75 | WorkspacePaths.WorkspaceRootCreateFailedError, | |
| 76 | WorkspacePaths.WorkspaceRootNotDirectoryError, | |
| 77 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, | |
| 78 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, | |
| 79 | WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed, | |
| 80 | ]); | |
| 81 | export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; | |
| 82 | ||
| 90 | 83 | export class WorkspaceEntries extends Context.Service< |
| 91 | 84 | WorkspaceEntries, |
| 92 | 85 | { |
| ... | ... | @@ -146,16 +139,7 @@ export const make = Effect.gen(function* () { |
| 146 | 139 | const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( |
| 147 | 140 | cwd: string, |
| 148 | 141 | ): Effect.fn.Return<string, WorkspaceEntriesError> { |
| 149 | return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( | |
| 150 | Effect.mapError( | |
| 151 | (cause) => | |
| 152 | new WorkspaceEntriesError({ | |
| 153 | cwd, | |
| 154 | operation: "workspaceEntries.normalizeWorkspaceRoot", | |
| 155 | cause, | |
| 156 | }), | |
| 157 | ), | |
| 158 | ); | |
| 142 | return yield* workspacePaths.normalizeWorkspaceRoot(cwd); | |
| 159 | 143 | }); |
| 160 | 144 | |
| 161 | 145 | const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( |
| ... | ... | @@ -243,17 +227,7 @@ export const make = Effect.gen(function* () { |
| 243 | 227 | return yield* Effect.gen(function* () { |
| 244 | 228 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 245 | 229 | return yield* searchIndex.search(normalizedQuery, input.limit); |
| 246 | }).pipe( | |
| 247 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 248 | Effect.mapError( | |
| 249 | (cause) => | |
| 250 | new WorkspaceEntriesError({ | |
| 251 | cwd: input.cwd, | |
| 252 | operation: "workspaceEntries.search", | |
| 253 | cause, | |
| 254 | }), | |
| 255 | ), | |
| 256 | ); | |
| 230 | }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); | |
| 257 | 231 | }, |
| 258 | 232 | ); |
| 259 | 233 | |
| ... | ... | @@ -263,17 +237,7 @@ export const make = Effect.gen(function* () { |
| 263 | 237 | return yield* Effect.gen(function* () { |
| 264 | 238 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 265 | 239 | return yield* searchIndex.list(); |
| 266 | }).pipe( | |
| 267 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 268 | Effect.mapError( | |
| 269 | (cause) => | |
| 270 | new WorkspaceEntriesError({ | |
| 271 | cwd: input.cwd, | |
| 272 | operation: "workspaceEntries.list", | |
| 273 | cause, | |
| 274 | }), | |
| 275 | ), | |
| 276 | ); | |
| 240 | }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); | |
| 277 | 241 | }, |
| 278 | 242 | ); |
| 279 | 243 |
apps/server/src/ws.ts+77-7| ... | ... | @@ -112,6 +112,77 @@ const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOu |
| 112 | 112 | |
| 113 | 113 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 114 | 114 | |
| 115 | function unexpectedCompatibilityError(error: never): never { | |
| 116 | throw new Error(`Unhandled compatibility error: ${String(error)}`); | |
| 117 | } | |
| 118 | ||
| 119 | /** Preserve pre-structured-error display behavior at the RPC boundary. */ | |
| 120 | function legacyPlatformFailureDescription(cause: unknown): string { | |
| 121 | return cause instanceof Error ? cause.message : String(cause); | |
| 122 | } | |
| 123 | ||
| 124 | /** Preserve the setup runner's broader pre-refactor message normalization. */ | |
| 125 | function legacySetupFailureDescription(cause: unknown): string { | |
| 126 | if ( | |
| 127 | typeof cause === "object" && | |
| 128 | cause !== null && | |
| 129 | "message" in cause && | |
| 130 | typeof cause.message === "string" | |
| 131 | ) { | |
| 132 | return cause.message; | |
| 133 | } | |
| 134 | return String(cause); | |
| 135 | } | |
| 136 | ||
| 137 | function workspaceEntriesCompatibilityDetail( | |
| 138 | error: WorkspaceEntries.WorkspaceEntriesError, | |
| 139 | ): string { | |
| 140 | switch (error._tag) { | |
| 141 | case "WorkspaceRootNotExistsError": | |
| 142 | return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; | |
| 143 | case "WorkspaceRootCreateFailedError": | |
| 144 | return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; | |
| 145 | case "WorkspaceRootNotDirectoryError": | |
| 146 | return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; | |
| 147 | case "WorkspaceSearchIndexCreateFailed": | |
| 148 | return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; | |
| 149 | case "WorkspaceSearchIndexScanTimedOut": | |
| 150 | return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; | |
| 151 | case "WorkspaceSearchIndexSearchFailed": | |
| 152 | return `Workspace search failed for '${error.cwd}': ${error.reason}`; | |
| 153 | default: | |
| 154 | return unexpectedCompatibilityError(error); | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | function workspaceBrowseCompatibilityDetail( | |
| 159 | error: WorkspaceEntries.WorkspaceEntriesBrowseError, | |
| 160 | ): string { | |
| 161 | switch (error._tag) { | |
| 162 | case "WorkspaceEntriesWindowsPathUnsupportedError": | |
| 163 | return "Windows-style paths are only supported on Windows."; | |
| 164 | case "WorkspaceEntriesCurrentProjectRequiredError": | |
| 165 | return "Relative filesystem browse paths require a current project."; | |
| 166 | case "WorkspaceEntriesReadDirectoryError": | |
| 167 | return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; | |
| 168 | default: | |
| 169 | return unexpectedCompatibilityError(error); | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | function projectSetupScriptCompatibilityDetail( | |
| 174 | error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, | |
| 175 | ): string { | |
| 176 | switch (error._tag) { | |
| 177 | case "ProjectSetupScriptOperationError": | |
| 178 | return legacySetupFailureDescription(error.cause); | |
| 179 | case "ProjectSetupScriptProjectNotFoundError": | |
| 180 | return "Project was not found for setup script execution."; | |
| 181 | default: | |
| 182 | return unexpectedCompatibilityError(error); | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 115 | 186 | function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< |
| 116 | 187 | OrchestrationEvent, |
| 117 | 188 | { |
| ... | ... | @@ -561,12 +632,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 561 | 632 | : Effect.void; |
| 562 | 633 | |
| 563 | 634 | const recordSetupScriptLaunchFailure = (input: { |
| 564 | readonly error: unknown; | |
| 635 | readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; | |
| 565 | 636 | readonly requestedAt: string; |
| 566 | 637 | readonly worktreePath: string; |
| 567 | 638 | }) => { |
| 568 | const detail = | |
| 569 | input.error instanceof Error ? input.error.message : "Unknown setup failure."; | |
| 639 | const detail = projectSetupScriptCompatibilityDetail(input.error); | |
| 570 | 640 | return appendSetupScriptActivity({ |
| 571 | 641 | threadId: command.threadId, |
| 572 | 642 | kind: "setup-script.failed", |
| ... | ... | @@ -1190,7 +1260,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1190 | 1260 | Effect.mapError( |
| 1191 | 1261 | (cause) => |
| 1192 | 1262 | new ProjectSearchEntriesError({ |
| 1193 | message: "Failed to search workspace entries.", | |
| 1263 | message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, | |
| 1194 | 1264 | cause, |
| 1195 | 1265 | }), |
| 1196 | 1266 | ), |
| ... | ... | @@ -1204,7 +1274,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1204 | 1274 | Effect.mapError( |
| 1205 | 1275 | (cause) => |
| 1206 | 1276 | new ProjectListEntriesError({ |
| 1207 | message: "Failed to list workspace entries.", | |
| 1277 | message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, | |
| 1208 | 1278 | cause, |
| 1209 | 1279 | }), |
| 1210 | 1280 | ), |
| ... | ... | @@ -1218,7 +1288,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1218 | 1288 | Effect.mapError((cause) => { |
| 1219 | 1289 | const message = isWorkspacePathOutsideRootError(cause) |
| 1220 | 1290 | ? "Workspace file path must stay within the project root." |
| 1221 | : "Failed to read workspace file."; | |
| 1291 | : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; | |
| 1222 | 1292 | return new ProjectReadFileError({ message, cause }); |
| 1223 | 1293 | }), |
| 1224 | 1294 | ), |
| ... | ... | @@ -1251,7 +1321,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1251 | 1321 | Effect.mapError( |
| 1252 | 1322 | (cause) => |
| 1253 | 1323 | new FilesystemBrowseError({ |
| 1254 | message: "Failed to browse the filesystem.", | |
| 1324 | message: workspaceBrowseCompatibilityDetail(cause), | |
| 1255 | 1325 | cause, |
| 1256 | 1326 | }), |
| 1257 | 1327 | ), |