authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 00:39:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 00:39:04-07:00
logf58b6da2308bda9e1c977dba5280747d0e4e70e6
treeddc2f2e8eea36d52fa1d386f91a3151bc7dc54be
parent9a1c4875a37ea7eeb77db189e2e45db9404d7a47
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve workspace RPC error messages (#3222)

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
5959 },
6060) {
6161 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.";
6363 }
6464}
6565
apps/server/src/server.test.ts+56-13
......@@ -4430,24 +4430,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
44304430 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
44314431 );
44324432
4433 it.effect("routes websocket rpc projects.searchEntries errors", () =>
4433 it.effect("preserves workspace rpc failure messages", () =>
44344434 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
44354447 yield* buildAppUnderTest();
44364448
4449 const invalidWorkspace = path.join(workspaceDir, "missing-workspace");
4450 const missingBrowseParent = path.join(workspaceDir, "missing-browse");
44374451 const wsUrl = yield* getWsServerUrl("/ws");
4438 const result = yield* Effect.scoped(
4452 const results = yield* Effect.scoped(
44394453 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),
44444471 }),
4445 ).pipe(Effect.result),
4472 ),
44464473 );
44474474
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 );
44514495 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
44524496 );
44534497
......@@ -6102,7 +6146,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
61026146 threadId: input.threadId,
61036147 worktreePath: input.worktreePath,
61046148 operation: "openTerminal",
6105 cause: new Error("pty unavailable"),
6149 cause: { message: "pty unavailable" },
61066150 }),
61076151 ),
61086152 );
......@@ -6177,8 +6221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
61776221 );
61786222 assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed");
61796223 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",
61826225 worktreePath: "/tmp/bootstrap-worktree",
61836226 });
61846227 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
2323import * as WorkspacePaths from "./WorkspacePaths.ts";
2424import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
2525
26export 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
4326export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass<WorkspaceEntriesWindowsPathUnsupportedError>()(
4427 "WorkspaceEntriesWindowsPathUnsupportedError",
4528 {
......@@ -87,6 +70,16 @@ export const WorkspaceEntriesBrowseError = Schema.Union([
8770]);
8871export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
8972
73export const WorkspaceEntriesError = Schema.Union([
74 WorkspacePaths.WorkspaceRootNotExistsError,
75 WorkspacePaths.WorkspaceRootCreateFailedError,
76 WorkspacePaths.WorkspaceRootNotDirectoryError,
77 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
78 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
79 WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed,
80]);
81export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type;
82
9083export class WorkspaceEntries extends Context.Service<
9184 WorkspaceEntries,
9285 {
......@@ -146,16 +139,7 @@ export const make = Effect.gen(function* () {
146139 const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
147140 cwd: string,
148141 ): 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);
159143 });
160144
161145 const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")(
......@@ -243,17 +227,7 @@ export const make = Effect.gen(function* () {
243227 return yield* Effect.gen(function* () {
244228 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
245229 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)));
257231 },
258232 );
259233
......@@ -263,17 +237,7 @@ export const make = Effect.gen(function* () {
263237 return yield* Effect.gen(function* () {
264238 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
265239 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)));
277241 },
278242 );
279243
apps/server/src/ws.ts+77-7
......@@ -112,6 +112,77 @@ const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOu
112112
113113const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
114114
115function 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. */
120function 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. */
125function 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
137function 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
158function 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
173function 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
115186function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
116187 OrchestrationEvent,
117188 {
......@@ -561,12 +632,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
561632 : Effect.void;
562633
563634 const recordSetupScriptLaunchFailure = (input: {
564 readonly error: unknown;
635 readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError;
565636 readonly requestedAt: string;
566637 readonly worktreePath: string;
567638 }) => {
568 const detail =
569 input.error instanceof Error ? input.error.message : "Unknown setup failure.";
639 const detail = projectSetupScriptCompatibilityDetail(input.error);
570640 return appendSetupScriptActivity({
571641 threadId: command.threadId,
572642 kind: "setup-script.failed",
......@@ -1190,7 +1260,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
11901260 Effect.mapError(
11911261 (cause) =>
11921262 new ProjectSearchEntriesError({
1193 message: "Failed to search workspace entries.",
1263 message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
11941264 cause,
11951265 }),
11961266 ),
......@@ -1204,7 +1274,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12041274 Effect.mapError(
12051275 (cause) =>
12061276 new ProjectListEntriesError({
1207 message: "Failed to list workspace entries.",
1277 message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
12081278 cause,
12091279 }),
12101280 ),
......@@ -1218,7 +1288,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12181288 Effect.mapError((cause) => {
12191289 const message = isWorkspacePathOutsideRootError(cause)
12201290 ? "Workspace file path must stay within the project root."
1221 : "Failed to read workspace file.";
1291 : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`;
12221292 return new ProjectReadFileError({ message, cause });
12231293 }),
12241294 ),
......@@ -1251,7 +1321,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12511321 Effect.mapError(
12521322 (cause) =>
12531323 new FilesystemBrowseError({
1254 message: "Failed to browse the filesystem.",
1324 message: workspaceBrowseCompatibilityDetail(cause),
12551325 cause,
12561326 }),
12571327 ),