authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:48:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:48:14-07:00
logcdfecb36a623816f8d032408247e29b007b043f1
treea88965b719f940f8e4afebd1985139c27541e8e1
parentf4ef356215271b47dd2acdd93590f919f548ffd6
signature Signed by PGP key B5690EEEBB952194

[codex] Structure workspace file system errors (#3274)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 707 insertions(+), 119 deletions(-)

apps/server/src/server.test.ts+73-20
...@@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4430 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),4430 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
4431 );4431 );
44324432
4433 it.effect("preserves workspace rpc failure messages", () =>4433 it.effect("preserves structured workspace rpc failures", () =>
4434 Effect.gen(function* () {4434 Effect.gen(function* () {
4435 const fs = yield* FileSystem.FileSystem;4435 const fs = yield* FileSystem.FileSystem;
4436 const path = yield* Path.Path;4436 const path = yield* Path.Path;
...@@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4443 const outsideFile = path.join(outsideDir, "outside.txt");4443 const outsideFile = path.join(outsideDir, "outside.txt");
4444 yield* fs.writeFileString(outsideFile, "outside\n");4444 yield* fs.writeFileString(outsideFile, "outside\n");
4445 yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt"));4445 yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt"));
4446 const resolvedOutsideFile = yield* fs.realPath(outsideFile);
44464447
4447 yield* buildAppUnderTest();4448 yield* buildAppUnderTest();
44484449
4449 const invalidWorkspace = path.join(workspaceDir, "missing-workspace");4450 const invalidWorkspace = path.join(workspaceDir, "missing-workspace");
4450 const missingBrowseParent = path.join(workspaceDir, "missing-browse");4451 const missingBrowseParent = path.join(workspaceDir, "missing-browse");
4452 const sensitiveQuery = "authorization: Bearer secret-token";
4451 const wsUrl = yield* getWsServerUrl("/ws");4453 const wsUrl = yield* getWsServerUrl("/ws");
4452 const results = yield* Effect.scoped(4454 const results = yield* Effect.scoped(
4453 withWsRpcClient(wsUrl, (client) =>4455 withWsRpcClient(wsUrl, (client) =>
4454 Effect.all({4456 Effect.all({
4455 search: client[WS_METHODS.projectsSearchEntries]({4457 search: client[WS_METHODS.projectsSearchEntries]({
4456 cwd: invalidWorkspace,4458 cwd: invalidWorkspace,
4457 query: "needle",4459 query: sensitiveQuery,
4458 limit: 10,4460 limit: 10,
4459 }).pipe(Effect.result),4461 }).pipe(Effect.result),
4460 list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe(4462 list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe(
...@@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4472 ),4474 ),
4473 );4475 );
44744476
4475 assertTrue(results.search._tag === "Failure");4477 if (
4476 assert.equal(4478 results.search._tag !== "Failure" ||
4477 results.search.failure.message,4479 results.search.failure._tag !== "ProjectSearchEntriesError"
4478 `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`,4480 ) {
4479 );4481 assert.fail("Expected a ProjectSearchEntriesError");
4480 assertTrue(results.list._tag === "Failure");4482 }
4483 const searchError = results.search.failure;
4481 assert.equal(4484 assert.equal(
4482 results.list.failure.message,4485 searchError.message,
4483 `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`,4486 `Failed to search workspace entries in '${invalidWorkspace}'.`,
4484 );4487 );
4485 assertTrue(results.read._tag === "Failure");4488 assert.equal(searchError.cwd, invalidWorkspace);
4489 assert.equal(searchError.queryLength, sensitiveQuery.length);
4490 assert.notProperty(searchError, "query");
4491 assert.notInclude(searchError.message, "Bearer");
4492 assert.notInclude(searchError.message, "secret-token");
4493 assert.equal(searchError.limit, 10);
4494 assert.equal(searchError.failure, "workspace_root_not_found");
4495 assert.equal(searchError.normalizedCwd, invalidWorkspace);
4496 assert.isDefined(searchError.cause);
4497
4498 if (
4499 results.list._tag !== "Failure" ||
4500 results.list.failure._tag !== "ProjectListEntriesError"
4501 ) {
4502 assert.fail("Expected a ProjectListEntriesError");
4503 }
4504 const listError = results.list.failure;
4505 assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`);
4506 assert.equal(listError.cwd, invalidWorkspace);
4507 assert.equal(listError.failure, "workspace_root_not_found");
4508 assert.equal(listError.normalizedCwd, invalidWorkspace);
4509 assert.isDefined(listError.cause);
4510
4511 if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") {
4512 assert.fail("Expected a ProjectReadFileError");
4513 }
4514 const readError = results.read.failure;
4486 assert.equal(4515 assert.equal(
4487 results.read.failure.message,4516 readError.message,
4488 "Failed to read workspace file: Workspace file path resolves outside the project root.",4517 `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`,
4489 );4518 );
4490 assertTrue(results.browse._tag === "Failure");4519 assert.equal(readError.cwd, workspaceDir);
4520 assert.equal(readError.relativePath, "linked-outside.txt");
4521 assert.equal(readError.failure, "resolved_path_outside_root");
4522 assert.equal(readError.resolvedPath, resolvedOutsideFile);
4523 assert.isDefined(readError.cause);
4524
4525 if (
4526 results.browse._tag !== "Failure" ||
4527 results.browse.failure._tag !== "FilesystemBrowseError"
4528 ) {
4529 assert.fail("Expected a FilesystemBrowseError");
4530 }
4531 const browseError = results.browse.failure;
4491 assert.equal(4532 assert.equal(
4492 results.browse.failure.message,4533 browseError.message,
4493 `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`,4534 `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`,
4494 );4535 );
4536 assert.equal(browseError.cwd, workspaceDir);
4537 assert.equal(browseError.partialPath, "./missing-browse/child");
4538 assert.equal(browseError.failure, "read_directory_failed");
4539 assert.equal(browseError.parentPath, missingBrowseParent);
4540 assert.isDefined(browseError.cause);
4495 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4541 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4496 );4542 );
44974543
...@@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4572 ).pipe(Effect.result),4618 ).pipe(Effect.result),
4573 );4619 );
45744620
4575 assertTrue(result._tag === "Failure");4621 if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") {
4576 assertTrue(result.failure._tag === "ProjectWriteFileError");4622 assert.fail("Expected a ProjectWriteFileError");
4623 }
4624 const writeError = result.failure;
4577 assert.equal(4625 assert.equal(
4578 result.failure.message,4626 writeError.message,
4579 "Workspace file path must stay within the project root.",4627 `Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`,
4580 );4628 );
4629 assert.equal(writeError.cwd, workspaceDir);
4630 assert.equal(writeError.relativePath, "../escape.txt");
4631 assert.equal(writeError.failure, "workspace_path_outside_root");
4632 assert.isDefined(writeError.cause);
4633 assert.notProperty(writeError, "contents");
4581 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4634 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4582 );4635 );
45834636
apps/server/src/workspace/WorkspaceFileSystem.test.ts+81-6
...@@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i...@@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
104 const error = yield* workspaceFileSystem104 const error = yield* workspaceFileSystem
105 .readFile({ cwd, relativePath: "linked-secret.txt" })105 .readFile({ cwd, relativePath: "linked-secret.txt" })
106 .pipe(Effect.flip);106 .pipe(Effect.flip);
107 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
108 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
107109
108 expect(error.message).toBe(110 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
109 `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`,111 expect(error).toMatchObject({
110 );112 workspaceRoot: cwd,
113 relativePath: "linked-secret.txt",
114 resolvedWorkspaceRoot,
115 resolvedPath,
116 });
117 expect("cause" in error).toBe(false);
118 }),
119 );
120
121 it.effect("rejects directories without manufacturing an I/O cause", () =>
122 Effect.gen(function* () {
123 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
124 const fileSystem = yield* FileSystem.FileSystem;
125 const path = yield* Path.Path;
126 const cwd = yield* makeTempDir;
127 yield* fileSystem.makeDirectory(path.join(cwd, "src"));
128
129 const error = yield* workspaceFileSystem
130 .readFile({ cwd, relativePath: "src" })
131 .pipe(Effect.flip);
132 const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src"));
133
134 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
135 expect(error).toMatchObject({
136 workspaceRoot: cwd,
137 relativePath: "src",
138 resolvedPath,
139 });
140 expect("cause" in error).toBe(false);
141 }),
142 );
143
144 it.effect("rejects binary files without leaking their contents into the error", () =>
145 Effect.gen(function* () {
146 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
147 const fileSystem = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;
149 const cwd = yield* makeTempDir;
150 const absolutePath = path.join(cwd, "asset.bin");
151 yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62]));
152
153 const error = yield* workspaceFileSystem
154 .readFile({ cwd, relativePath: "asset.bin" })
155 .pipe(Effect.flip);
156 const resolvedPath = yield* fileSystem.realPath(absolutePath);
157
158 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
159 expect(error).toMatchObject({
160 workspaceRoot: cwd,
161 relativePath: "asset.bin",
162 resolvedPath,
163 });
164 expect("cause" in error).toBe(false);
165 expect("contents" in error).toBe(false);
166 }),
167 );
168
169 it.effect("preserves the real cause and path for I/O failures", () =>
170 Effect.gen(function* () {
171 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
172 const path = yield* Path.Path;
173 const cwd = yield* makeTempDir;
174 const resolvedPath = path.join(cwd, "missing.txt");
175
176 const error = yield* workspaceFileSystem
177 .readFile({ cwd, relativePath: "missing.txt" })
178 .pipe(Effect.flip);
179
180 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError);
181 expect(error).toMatchObject({
182 workspaceRoot: cwd,
183 relativePath: "missing.txt",
184 resolvedPath,
185 operationPath: resolvedPath,
186 operation: "realpath-target",
187 });
111 expect(error.cause).toBeInstanceOf(Error);188 expect(error.cause).toBeInstanceOf(Error);
112 expect((error.cause as Error).message).toBe(189 expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
113 "Workspace file path resolves outside the project root.",
114 );
115 }),190 }),
116 );191 );
117 });192 });
apps/server/src/workspace/WorkspaceFileSystem.ts+177-49
...@@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts";...@@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts";
2727
28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
2929
30export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(30export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()(
31 "WorkspaceFileSystemError",31 "WorkspaceFileSystemOperationError",
32 {32 {
33 cwd: Schema.String,33 workspaceRoot: Schema.String,
34 relativePath: Schema.optional(Schema.String),34 relativePath: Schema.String,
35 resolvedPath: Schema.String,
36 operationPath: Schema.String,
35 operation: Schema.Literals([37 operation: Schema.Literals([
36 "workspaceFileSystem.readFile",38 "realpath-workspace-root",
37 "workspaceFileSystem.makeDirectory",39 "realpath-target",
38 "workspaceFileSystem.writeFile",40 "open",
41 "stat",
42 "read",
43 "close",
44 "make-directory",
45 "write-file",
39 ]),46 ]),
40 cause: Schema.Defect(),47 cause: Schema.Defect(),
41 },48 },
42) {49) {
43 override get message(): string {50 override get message(): string {
44 const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;51 return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;
45 return `Workspace file operation '${this.operation}' failed for ${target}.`;
46 }52 }
47}53}
4854
55export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass<WorkspaceFilePathEscapeError>()(
56 "WorkspaceFilePathEscapeError",
57 {
58 workspaceRoot: Schema.String,
59 relativePath: Schema.String,
60 resolvedWorkspaceRoot: Schema.String,
61 resolvedPath: Schema.String,
62 },
63) {
64 override get message(): string {
65 return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`;
66 }
67}
68
69export class WorkspacePathNotFileError extends Schema.TaggedErrorClass<WorkspacePathNotFileError>()(
70 "WorkspacePathNotFileError",
71 {
72 workspaceRoot: Schema.String,
73 relativePath: Schema.String,
74 resolvedPath: Schema.String,
75 },
76) {
77 override get message(): string {
78 return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`;
79 }
80}
81
82export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass<WorkspaceBinaryFileError>()(
83 "WorkspaceBinaryFileError",
84 {
85 workspaceRoot: Schema.String,
86 relativePath: Schema.String,
87 resolvedPath: Schema.String,
88 },
89) {
90 override get message(): string {
91 return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`;
92 }
93}
94
95export const WorkspaceFileSystemError = Schema.Union([
96 WorkspaceFileSystemOperationError,
97 WorkspaceFilePathEscapeError,
98 WorkspacePathNotFileError,
99 WorkspaceBinaryFileError,
100]);
101export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
102
49/** Service tag for workspace file operations. */103/** Service tag for workspace file operations. */
50export class WorkspaceFileSystem extends Context.Service<104export class WorkspaceFileSystem extends Context.Service<
51 WorkspaceFileSystem,105 WorkspaceFileSystem,
...@@ -86,53 +140,123 @@ export const make = Effect.gen(function* () {...@@ -86,53 +140,123 @@ export const make = Effect.gen(function* () {
86 relativePath: input.relativePath,140 relativePath: input.relativePath,
87 });141 });
88142
89 return yield* Effect.tryPromise({143 const realWorkspaceRoot = yield* Effect.tryPromise({
90 try: async () => {144 try: () => NodeFSP.realpath(input.cwd),
91 const [realWorkspaceRoot, realTargetPath] = await Promise.all([145 catch: (cause) =>
92 NodeFSP.realpath(input.cwd),146 new WorkspaceFileSystemOperationError({
93 NodeFSP.realpath(target.absolutePath),147 workspaceRoot: input.cwd,
94 ]);148 relativePath: input.relativePath,
95 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);149 resolvedPath: target.absolutePath,
96 if (150 operationPath: input.cwd,
97 relativeRealPath.startsWith(`..${path.sep}`) ||151 operation: "realpath-workspace-root",
98 relativeRealPath === ".." ||152 cause,
99 path.isAbsolute(relativeRealPath)153 }),
100 ) {154 });
101 throw new Error("Workspace file path resolves outside the project root.");155 const realTargetPath = yield* Effect.tryPromise({
102 }156 try: () => NodeFSP.realpath(target.absolutePath),
103157 catch: (cause) =>
104 const handle = await NodeFSP.open(realTargetPath, "r");158 new WorkspaceFileSystemOperationError({
105 try {159 workspaceRoot: input.cwd,
106 const stat = await handle.stat();160 relativePath: input.relativePath,
161 resolvedPath: target.absolutePath,
162 operationPath: target.absolutePath,
163 operation: "realpath-target",
164 cause,
165 }),
166 });
167 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
168 if (
169 relativeRealPath.startsWith(`..${path.sep}`) ||
170 relativeRealPath === ".." ||
171 path.isAbsolute(relativeRealPath)
172 ) {
173 return yield* new WorkspaceFilePathEscapeError({
174 workspaceRoot: input.cwd,
175 relativePath: input.relativePath,
176 resolvedWorkspaceRoot: realWorkspaceRoot,
177 resolvedPath: realTargetPath,
178 });
179 }
180
181 return yield* Effect.acquireUseRelease(
182 Effect.tryPromise({
183 try: () => NodeFSP.open(realTargetPath, "r"),
184 catch: (cause) =>
185 new WorkspaceFileSystemOperationError({
186 workspaceRoot: input.cwd,
187 relativePath: input.relativePath,
188 resolvedPath: realTargetPath,
189 operationPath: realTargetPath,
190 operation: "open",
191 cause,
192 }),
193 }),
194 (handle) =>
195 Effect.gen(function* () {
196 const stat = yield* Effect.tryPromise({
197 try: () => handle.stat(),
198 catch: (cause) =>
199 new WorkspaceFileSystemOperationError({
200 workspaceRoot: input.cwd,
201 relativePath: input.relativePath,
202 resolvedPath: realTargetPath,
203 operationPath: realTargetPath,
204 operation: "stat",
205 cause,
206 }),
207 });
107 if (!stat.isFile()) {208 if (!stat.isFile()) {
108 throw new Error("Workspace path is not a file.");209 return yield* new WorkspacePathNotFileError({
210 workspaceRoot: input.cwd,
211 relativePath: input.relativePath,
212 resolvedPath: realTargetPath,
213 });
109 }214 }
215
110 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);216 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
111 const buffer = Buffer.alloc(bytesToRead);217 const buffer = Buffer.alloc(bytesToRead);
112 const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);218 const { bytesRead } = yield* Effect.tryPromise({
219 try: () => handle.read(buffer, 0, bytesToRead, 0),
220 catch: (cause) =>
221 new WorkspaceFileSystemOperationError({
222 workspaceRoot: input.cwd,
223 relativePath: input.relativePath,
224 resolvedPath: realTargetPath,
225 operationPath: realTargetPath,
226 operation: "read",
227 cause,
228 }),
229 });
113 const fileBytes = buffer.subarray(0, bytesRead);230 const fileBytes = buffer.subarray(0, bytesRead);
114 if (fileBytes.includes(0)) {231 if (fileBytes.includes(0)) {
115 throw new Error("Binary files cannot be previewed as text.");232 return yield* new WorkspaceBinaryFileError({
233 workspaceRoot: input.cwd,
234 relativePath: input.relativePath,
235 resolvedPath: realTargetPath,
236 });
116 }237 }
117 const contents = new TextDecoder("utf-8").decode(fileBytes);238
118 return {239 return {
119 relativePath: target.relativePath,240 relativePath: target.relativePath,
120 contents,241 contents: new TextDecoder("utf-8").decode(fileBytes),
121 byteLength: stat.size,242 byteLength: stat.size,
122 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,243 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
123 };244 };
124 } finally {
125 await handle.close();
126 }
127 },
128 catch: (cause) =>
129 new WorkspaceFileSystemError({
130 cwd: input.cwd,
131 relativePath: input.relativePath,
132 operation: "workspaceFileSystem.readFile",
133 cause,
134 }),245 }),
135 });246 (handle) =>
247 Effect.tryPromise({
248 try: () => handle.close(),
249 catch: (cause) =>
250 new WorkspaceFileSystemOperationError({
251 workspaceRoot: input.cwd,
252 relativePath: input.relativePath,
253 resolvedPath: realTargetPath,
254 operationPath: realTargetPath,
255 operation: "close",
256 cause,
257 }),
258 }),
259 );
136 });260 });
137261
138 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(262 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
...@@ -146,10 +270,12 @@ export const make = Effect.gen(function* () {...@@ -146,10 +270,12 @@ export const make = Effect.gen(function* () {
146 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(270 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
147 Effect.mapError(271 Effect.mapError(
148 (cause) =>272 (cause) =>
149 new WorkspaceFileSystemError({273 new WorkspaceFileSystemOperationError({
150 cwd: input.cwd,274 workspaceRoot: input.cwd,
151 relativePath: input.relativePath,275 relativePath: input.relativePath,
152 operation: "workspaceFileSystem.makeDirectory",276 resolvedPath: target.absolutePath,
277 operationPath: path.dirname(target.absolutePath),
278 operation: "make-directory",
153 cause,279 cause,
154 }),280 }),
155 ),281 ),
...@@ -157,10 +283,12 @@ export const make = Effect.gen(function* () {...@@ -157,10 +283,12 @@ export const make = Effect.gen(function* () {
157 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(283 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
158 Effect.mapError(284 Effect.mapError(
159 (cause) =>285 (cause) =>
160 new WorkspaceFileSystemError({286 new WorkspaceFileSystemOperationError({
161 cwd: input.cwd,287 workspaceRoot: input.cwd,
162 relativePath: input.relativePath,288 relativePath: input.relativePath,
163 operation: "workspaceFileSystem.writeFile",289 resolvedPath: target.absolutePath,
290 operationPath: target.absolutePath,
291 operation: "write-file",
164 cause,292 cause,
165 }),293 }),
166 ),294 ),
apps/server/src/ws.ts+106-39
...@@ -34,6 +34,9 @@ import {...@@ -34,6 +34,9 @@ import {
34 OrchestrationGetSnapshotError,34 OrchestrationGetSnapshotError,
35 OrchestrationGetTurnDiffError,35 OrchestrationGetTurnDiffError,
36 ORCHESTRATION_WS_METHODS,36 ORCHESTRATION_WS_METHODS,
37 type ProjectEntriesFailure,
38 type ProjectFileFailure,
39 type ProjectFileOperation,
37 ProjectListEntriesError,40 ProjectListEntriesError,
38 ProjectReadFileError,41 ProjectReadFileError,
39 ProjectSearchEntriesError,42 ProjectSearchEntriesError,
...@@ -41,6 +44,7 @@ import {...@@ -41,6 +44,7 @@ import {
41 RelayClientInstallFailedError,44 RelayClientInstallFailedError,
42 type RelayClientInstallProgressEvent,45 type RelayClientInstallProgressEvent,
43 OrchestrationReplayEventsError,46 OrchestrationReplayEventsError,
47 type FilesystemBrowseFailure,
44 FilesystemBrowseError,48 FilesystemBrowseError,
45 AssetAccessError,49 AssetAccessError,
46 EnvironmentAuthorizationError,50 EnvironmentAuthorizationError,
...@@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts";...@@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts";
108import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";112import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
109import * as RelayClient from "@t3tools/shared/relayClient";113import * as RelayClient from "@t3tools/shared/relayClient";
110const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);114const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
111const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError);
112115
113const nowIso = Effect.map(DateTime.now, DateTime.formatIso);116const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
114117
...@@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never {...@@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never {
116 throw new Error(`Unhandled compatibility error: ${String(error)}`);119 throw new Error(`Unhandled compatibility error: ${String(error)}`);
117}120}
118121
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. */122/** Preserve the setup runner's broader pre-refactor message normalization. */
125function legacySetupFailureDescription(cause: unknown): string {123function legacySetupFailureDescription(cause: unknown): string {
126 if (124 if (
...@@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string {...@@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string {
134 return String(cause);132 return String(cause);
135}133}
136134
137function workspaceEntriesCompatibilityDetail(135function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): {
138 error: WorkspaceEntries.WorkspaceEntriesError,136 readonly failure: ProjectEntriesFailure;
139): string {137 readonly normalizedCwd?: string;
138 readonly timeout?: string;
139 readonly detail?: string;
140} {
140 switch (error._tag) {141 switch (error._tag) {
141 case "WorkspaceRootNotExistsError":142 case "WorkspaceRootNotExistsError":
142 return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`;143 return {
144 failure: "workspace_root_not_found",
145 normalizedCwd: error.normalizedWorkspaceRoot,
146 };
143 case "WorkspaceRootCreateFailedError":147 case "WorkspaceRootCreateFailedError":
144 return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`;148 return {
149 failure: "workspace_root_create_failed",
150 normalizedCwd: error.normalizedWorkspaceRoot,
151 };
145 case "WorkspaceRootNotDirectoryError":152 case "WorkspaceRootNotDirectoryError":
146 return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`;153 return {
154 failure: "workspace_root_not_directory",
155 normalizedCwd: error.normalizedWorkspaceRoot,
156 };
147 case "WorkspaceSearchIndexCreateFailed":157 case "WorkspaceSearchIndexCreateFailed":
148 return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`;158 return {
159 failure: "search_index_create_failed",
160 normalizedCwd: error.cwd,
161 detail: error.reason,
162 };
149 case "WorkspaceSearchIndexScanTimedOut":163 case "WorkspaceSearchIndexScanTimedOut":
150 return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`;164 return {
165 failure: "search_index_scan_timed_out",
166 normalizedCwd: error.cwd,
167 timeout: error.timeout,
168 };
151 case "WorkspaceSearchIndexSearchFailed":169 case "WorkspaceSearchIndexSearchFailed":
152 return `Workspace search failed for '${error.cwd}': ${error.reason}`;170 return {
171 failure: "search_index_search_failed",
172 normalizedCwd: error.cwd,
173 detail: error.reason,
174 };
153 default:175 default:
154 return unexpectedCompatibilityError(error);176 return unexpectedCompatibilityError(error);
155 }177 }
156}178}
157179
158function workspaceBrowseCompatibilityDetail(180function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): {
159 error: WorkspaceEntries.WorkspaceEntriesBrowseError,181 readonly failure: FilesystemBrowseFailure;
160): string {182 readonly parentPath?: string;
183 readonly platform?: string;
184} {
161 switch (error._tag) {185 switch (error._tag) {
162 case "WorkspaceEntriesWindowsPathUnsupportedError":186 case "WorkspaceEntriesWindowsPathUnsupportedError":
163 return "Windows-style paths are only supported on Windows.";187 return { failure: "windows_path_unsupported", platform: error.platform };
164 case "WorkspaceEntriesCurrentProjectRequiredError":188 case "WorkspaceEntriesCurrentProjectRequiredError":
165 return "Relative filesystem browse paths require a current project.";189 return { failure: "current_project_required" };
166 case "WorkspaceEntriesReadDirectoryError":190 case "WorkspaceEntriesReadDirectoryError":
167 return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`;191 return { failure: "read_directory_failed", parentPath: error.parentPath };
192 default:
193 return unexpectedCompatibilityError(error);
194 }
195}
196
197function projectFileFailureContext(
198 error:
199 | WorkspaceFileSystem.WorkspaceFileSystemError
200 | WorkspacePaths.WorkspacePathOutsideRootError,
201): {
202 readonly failure: ProjectFileFailure;
203 readonly resolvedPath?: string;
204 readonly resolvedWorkspaceRoot?: string;
205 readonly operation?: ProjectFileOperation;
206 readonly operationPath?: string;
207} {
208 switch (error._tag) {
209 case "WorkspacePathOutsideRootError":
210 return { failure: "workspace_path_outside_root" };
211 case "WorkspaceFileSystemOperationError":
212 return {
213 failure: "operation_failed",
214 resolvedPath: error.resolvedPath,
215 operation: error.operation,
216 operationPath: error.operationPath,
217 };
218 case "WorkspaceFilePathEscapeError":
219 return {
220 failure: "resolved_path_outside_root",
221 resolvedPath: error.resolvedPath,
222 resolvedWorkspaceRoot: error.resolvedWorkspaceRoot,
223 };
224 case "WorkspacePathNotFileError":
225 return { failure: "path_not_file", resolvedPath: error.resolvedPath };
226 case "WorkspaceBinaryFileError":
227 return { failure: "binary_file", resolvedPath: error.resolvedPath };
168 default:228 default:
169 return unexpectedCompatibilityError(error);229 return unexpectedCompatibilityError(error);
170 }230 }
...@@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1260 Effect.mapError(1320 Effect.mapError(
1261 (cause) =>1321 (cause) =>
1262 new ProjectSearchEntriesError({1322 new ProjectSearchEntriesError({
1263 message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,1323 cwd: input.cwd,
1324 queryLength: input.query.length,
1325 limit: input.limit,
1326 ...projectEntriesFailureContext(cause),
1264 cause,1327 cause,
1265 }),1328 }),
1266 ),1329 ),
...@@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1274 Effect.mapError(1337 Effect.mapError(
1275 (cause) =>1338 (cause) =>
1276 new ProjectListEntriesError({1339 new ProjectListEntriesError({
1277 message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,1340 ...input,
1341 ...projectEntriesFailureContext(cause),
1278 cause,1342 cause,
1279 }),1343 }),
1280 ),1344 ),
...@@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1285 observeRpcEffect(1349 observeRpcEffect(
1286 WS_METHODS.projectsReadFile,1350 WS_METHODS.projectsReadFile,
1287 workspaceFileSystem.readFile(input).pipe(1351 workspaceFileSystem.readFile(input).pipe(
1288 Effect.mapError((cause) => {1352 Effect.mapError(
1289 const message = isWorkspacePathOutsideRootError(cause)1353 (cause) =>
1290 ? "Workspace file path must stay within the project root."1354 new ProjectReadFileError({
1291 : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`;1355 ...input,
1292 return new ProjectReadFileError({ message, cause });1356 ...projectFileFailureContext(cause),
1293 }),1357 cause,
1358 }),
1359 ),
1294 ),1360 ),
1295 { "rpc.aggregate": "workspace" },1361 { "rpc.aggregate": "workspace" },
1296 ),1362 ),
...@@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1298 observeRpcEffect(1364 observeRpcEffect(
1299 WS_METHODS.projectsWriteFile,1365 WS_METHODS.projectsWriteFile,
1300 workspaceFileSystem.writeFile(input).pipe(1366 workspaceFileSystem.writeFile(input).pipe(
1301 Effect.mapError((cause) => {1367 Effect.mapError(
1302 const message = isWorkspacePathOutsideRootError(cause)1368 (cause) =>
1303 ? "Workspace file path must stay within the project root."1369 new ProjectWriteFileError({
1304 : "Failed to write workspace file";1370 cwd: input.cwd,
1305 return new ProjectWriteFileError({1371 relativePath: input.relativePath,
1306 message,1372 ...projectFileFailureContext(cause),
1307 cause,1373 cause,
1308 });1374 }),
1309 }),1375 ),
1310 ),1376 ),
1311 { "rpc.aggregate": "workspace" },1377 { "rpc.aggregate": "workspace" },
1312 ),1378 ),
...@@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1321 Effect.mapError(1387 Effect.mapError(
1322 (cause) =>1388 (cause) =>
1323 new FilesystemBrowseError({1389 new FilesystemBrowseError({
1324 message: workspaceBrowseCompatibilityDetail(cause),1390 ...input,
1391 ...filesystemBrowseFailureContext(cause),
1325 cause,1392 cause,
1326 }),1393 }),
1327 ),1394 ),
packages/contracts/src/filesystem.test.ts created+33
...@@ -0,0 +1,33 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { FilesystemBrowseError } from "./filesystem.ts";
5
6describe("FilesystemBrowseError", () => {
7 it("derives a stable message from browse context while retaining the cause", () => {
8 const cause = new Error("sensitive filesystem detail");
9 const error = new FilesystemBrowseError({
10 cwd: "/workspace",
11 partialPath: "./src/mai",
12 failure: "read_directory_failed",
13 parentPath: "/workspace/src",
14 cause,
15 });
16
17 expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'.");
18 expect(error.message).not.toContain(cause.message);
19 expect(error.cause).toBe(cause);
20 });
21
22 it("decodes legacy message-only errors during rolling upgrades", () => {
23 const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError);
24 const error = decodeError({
25 _tag: "FilesystemBrowseError",
26 message: "Legacy filesystem browse failure.",
27 });
28
29 expect(error.message).toBe("Legacy filesystem browse failure.");
30 expect(error.partialPath).toBeUndefined();
31 expect(error.failure).toBeUndefined();
32 });
33});
packages/contracts/src/filesystem.ts+38-1
...@@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({...@@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({
21});21});
22export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type;22export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type;
2323
24export const FilesystemBrowseFailure = Schema.Literals([
25 "windows_path_unsupported",
26 "current_project_required",
27 "read_directory_failed",
28]);
29export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type;
30
31function decodedFilesystemBrowseErrorMessage(props: object): string | undefined {
32 if (!("message" in props)) return undefined;
33 return typeof props.message === "string" ? props.message : undefined;
34}
35
24export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()(36export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()(
25 "FilesystemBrowseError",37 "FilesystemBrowseError",
26 {38 {
39 partialPath: Schema.optional(TrimmedNonEmptyString),
40 cwd: Schema.optional(TrimmedNonEmptyString),
41 failure: Schema.optional(FilesystemBrowseFailure),
42 parentPath: Schema.optional(TrimmedNonEmptyString),
43 platform: Schema.optional(TrimmedNonEmptyString),
27 message: TrimmedNonEmptyString,44 message: TrimmedNonEmptyString,
28 cause: Schema.optional(Schema.Defect()),45 cause: Schema.optional(Schema.Defect()),
29 },46 },
30) {}47) {
48 // Structured diagnostics stay optional for rolling compatibility with legacy message-only
49 // payloads, while new call sites must provide the request context and failure classification.
50 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
51 constructor(props: {
52 readonly partialPath: string;
53 readonly cwd?: string | undefined;
54 readonly failure: FilesystemBrowseFailure;
55 readonly parentPath?: string;
56 readonly platform?: string;
57 readonly cause?: unknown;
58 }) {
59 const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`;
60 super({
61 ...props,
62 message:
63 decodedFilesystemBrowseErrorMessage(props) ??
64 `Failed to browse filesystem path '${props.partialPath}'${cwd}.`,
65 } as any);
66 }
67}
packages/contracts/src/project.test.ts created+67
...@@ -0,0 +1,67 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 ProjectReadFileError,
6 ProjectSearchEntriesError,
7 ProjectWriteFileError,
8} from "./project.ts";
9
10describe("project RPC errors", () => {
11 it("derives stable messages from structured request context while retaining causes", () => {
12 const cause = new Error("sensitive platform detail");
13 const searchError = new ProjectSearchEntriesError({
14 cwd: "/workspace",
15 queryLength: "authorization: Bearer secret-token".length,
16 limit: 20,
17 failure: "search_index_search_failed",
18 normalizedCwd: "/workspace",
19 detail: "index unavailable",
20 cause,
21 });
22 const readError = new ProjectReadFileError({
23 cwd: "/workspace",
24 relativePath: "src/index.ts",
25 failure: "operation_failed",
26 operation: "read",
27 operationPath: "/workspace/src/index.ts",
28 resolvedPath: "/workspace/src/index.ts",
29 cause,
30 });
31
32 expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'.");
33 expect(searchError.message).not.toContain(cause.message);
34 expect(searchError.normalizedCwd).toBe("/workspace");
35 expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length);
36 expect(searchError).not.toHaveProperty("query");
37 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
38 expect(searchError.cause).toBe(cause);
39 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");
40 expect(readError.message).not.toContain(cause.message);
41 expect(readError.cause).toBe(cause);
42 });
43
44 it("decodes legacy message-only errors during rolling upgrades", () => {
45 const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError);
46 const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError);
47
48 const searchError = decodeSearchError({
49 _tag: "ProjectSearchEntriesError",
50 message: "Legacy project search failure.",
51 query: "legacy sensitive query",
52 });
53 const writeError = decodeWriteError({
54 _tag: "ProjectWriteFileError",
55 message: "Legacy project write failure.",
56 });
57
58 expect(searchError.message).toBe("Legacy project search failure.");
59 expect(searchError.cwd).toBeUndefined();
60 expect(searchError.queryLength).toBeUndefined();
61 expect(searchError).not.toHaveProperty("query");
62 expect(searchError.failure).toBeUndefined();
63 expect(writeError.message).toBe("Legacy project write failure.");
64 expect(writeError.relativePath).toBeUndefined();
65 expect(writeError.failure).toBeUndefined();
66 });
67});
packages/contracts/src/project.ts+132-4
...@@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({...@@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({
37});37});
38export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;38export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
3939
40export const ProjectEntriesFailure = Schema.Literals([
41 "workspace_root_not_found",
42 "workspace_root_create_failed",
43 "workspace_root_not_directory",
44 "search_index_create_failed",
45 "search_index_scan_timed_out",
46 "search_index_search_failed",
47]);
48export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
49
50type ProjectEntriesFailureContext = {
51 readonly failure: ProjectEntriesFailure;
52 readonly normalizedCwd?: string;
53 readonly timeout?: string;
54 readonly detail?: string;
55 readonly cause?: unknown;
56};
57
58function decodedProjectErrorMessage(props: object): string | undefined {
59 if (!("message" in props)) return undefined;
60 return typeof props.message === "string" ? props.message : undefined;
61}
62
40export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()(63export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()(
41 "ProjectSearchEntriesError",64 "ProjectSearchEntriesError",
42 {65 {
66 cwd: Schema.optional(TrimmedNonEmptyString),
67 queryLength: Schema.optional(NonNegativeInt),
68 limit: Schema.optional(PositiveInt),
69 failure: Schema.optional(ProjectEntriesFailure),
70 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
71 timeout: Schema.optional(TrimmedNonEmptyString),
72 detail: Schema.optional(TrimmedNonEmptyString),
43 message: TrimmedNonEmptyString,73 message: TrimmedNonEmptyString,
44 cause: Schema.optional(Schema.Defect()),74 cause: Schema.optional(Schema.Defect()),
45 },75 },
46) {}76) {
77 // The structured fields are optional on the wire so newer peers can decode legacy message-only
78 // failures. New application code must provide them through this constructor.
79 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
80 constructor(
81 props: ProjectEntriesFailureContext & {
82 readonly cwd: string;
83 readonly queryLength: number;
84 readonly limit: number;
85 },
86 ) {
87 super({
88 ...props,
89 message:
90 decodedProjectErrorMessage(props) ??
91 `Failed to search workspace entries in '${props.cwd}'.`,
92 } as any);
93 }
94}
4795
48export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()(96export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()(
49 "ProjectListEntriesError",97 "ProjectListEntriesError",
50 {98 {
99 cwd: Schema.optional(TrimmedNonEmptyString),
100 failure: Schema.optional(ProjectEntriesFailure),
101 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
102 timeout: Schema.optional(TrimmedNonEmptyString),
103 detail: Schema.optional(TrimmedNonEmptyString),
51 message: TrimmedNonEmptyString,104 message: TrimmedNonEmptyString,
52 cause: Schema.optional(Schema.Defect()),105 cause: Schema.optional(Schema.Defect()),
53 },106 },
54) {}107) {
108 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
109 constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) {
110 super({
111 ...props,
112 message:
113 decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`,
114 } as any);
115 }
116}
55117
56export const ProjectReadFileInput = Schema.Struct({118export const ProjectReadFileInput = Schema.Struct({
57 cwd: TrimmedNonEmptyString,119 cwd: TrimmedNonEmptyString,
...@@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({...@@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({
67});129});
68export type ProjectReadFileResult = typeof ProjectReadFileResult.Type;130export type ProjectReadFileResult = typeof ProjectReadFileResult.Type;
69131
132export const ProjectFileFailure = Schema.Literals([
133 "workspace_path_outside_root",
134 "resolved_path_outside_root",
135 "path_not_file",
136 "binary_file",
137 "operation_failed",
138]);
139export type ProjectFileFailure = typeof ProjectFileFailure.Type;
140
141export const ProjectFileOperation = Schema.Literals([
142 "realpath-workspace-root",
143 "realpath-target",
144 "open",
145 "stat",
146 "read",
147 "close",
148 "make-directory",
149 "write-file",
150]);
151export type ProjectFileOperation = typeof ProjectFileOperation.Type;
152
153type ProjectFileFailureContext = {
154 readonly cwd: string;
155 readonly relativePath: string;
156 readonly failure: ProjectFileFailure;
157 readonly resolvedPath?: string;
158 readonly resolvedWorkspaceRoot?: string;
159 readonly operation?: ProjectFileOperation;
160 readonly operationPath?: string;
161 readonly cause?: unknown;
162};
163
70export class ProjectReadFileError extends Schema.TaggedErrorClass<ProjectReadFileError>()(164export class ProjectReadFileError extends Schema.TaggedErrorClass<ProjectReadFileError>()(
71 "ProjectReadFileError",165 "ProjectReadFileError",
72 {166 {
167 cwd: Schema.optional(TrimmedNonEmptyString),
168 relativePath: Schema.optional(TrimmedNonEmptyString),
169 failure: Schema.optional(ProjectFileFailure),
170 resolvedPath: Schema.optional(TrimmedNonEmptyString),
171 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
172 operation: Schema.optional(ProjectFileOperation),
173 operationPath: Schema.optional(TrimmedNonEmptyString),
73 message: TrimmedNonEmptyString,174 message: TrimmedNonEmptyString,
74 cause: Schema.optional(Schema.Defect()),175 cause: Schema.optional(Schema.Defect()),
75 },176 },
76) {}177) {
178 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
179 constructor(props: ProjectFileFailureContext) {
180 super({
181 ...props,
182 message:
183 decodedProjectErrorMessage(props) ??
184 `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`,
185 } as any);
186 }
187}
77188
78export const ProjectWriteFileInput = Schema.Struct({189export const ProjectWriteFileInput = Schema.Struct({
79 cwd: TrimmedNonEmptyString,190 cwd: TrimmedNonEmptyString,
...@@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;...@@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
90export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()(201export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()(
91 "ProjectWriteFileError",202 "ProjectWriteFileError",
92 {203 {
204 cwd: Schema.optional(TrimmedNonEmptyString),
205 relativePath: Schema.optional(TrimmedNonEmptyString),
206 failure: Schema.optional(ProjectFileFailure),
207 resolvedPath: Schema.optional(TrimmedNonEmptyString),
208 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
209 operation: Schema.optional(ProjectFileOperation),
210 operationPath: Schema.optional(TrimmedNonEmptyString),
93 message: TrimmedNonEmptyString,211 message: TrimmedNonEmptyString,
94 cause: Schema.optional(Schema.Defect()),212 cause: Schema.optional(Schema.Defect()),
95 },213 },
96) {}214) {
215 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
216 constructor(props: ProjectFileFailureContext) {
217 super({
218 ...props,
219 message:
220 decodedProjectErrorMessage(props) ??
221 `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`,
222 } as any);
223 }
224}