| author | |
| committer | |
| log | cdfecb36a623816f8d032408247e29b007b043f1 |
| tree | a88965b719f940f8e4afebd1985139c27541e8e1 |
| parent | f4ef356215271b47dd2acdd93590f919f548ffd6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4430 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), |
| 4431 | 4431 | ); |
| 4432 | 4432 | |
| 4433 | it.effect("preserves workspace rpc failure messages", () => | |
| 4433 | it.effect("preserves structured workspace rpc failures", () => | |
| 4434 | 4434 | Effect.gen(function* () { |
| 4435 | 4435 | const fs = yield* FileSystem.FileSystem; |
| 4436 | 4436 | const path = yield* Path.Path; |
| ... | ... | @@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4443 | 4443 | const outsideFile = path.join(outsideDir, "outside.txt"); |
| 4444 | 4444 | yield* fs.writeFileString(outsideFile, "outside\n"); |
| 4445 | 4445 | yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); |
| 4446 | const resolvedOutsideFile = yield* fs.realPath(outsideFile); | |
| 4446 | 4447 | |
| 4447 | 4448 | yield* buildAppUnderTest(); |
| 4448 | 4449 | |
| 4449 | 4450 | const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); |
| 4450 | 4451 | const missingBrowseParent = path.join(workspaceDir, "missing-browse"); |
| 4452 | const sensitiveQuery = "authorization: Bearer secret-token"; | |
| 4451 | 4453 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 4452 | 4454 | const results = yield* Effect.scoped( |
| 4453 | 4455 | withWsRpcClient(wsUrl, (client) => |
| 4454 | 4456 | Effect.all({ |
| 4455 | 4457 | search: client[WS_METHODS.projectsSearchEntries]({ |
| 4456 | 4458 | cwd: invalidWorkspace, |
| 4457 | query: "needle", | |
| 4459 | query: sensitiveQuery, | |
| 4458 | 4460 | limit: 10, |
| 4459 | 4461 | }).pipe(Effect.result), |
| 4460 | 4462 | list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( |
| ... | ... | @@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4472 | 4474 | ), |
| 4473 | 4475 | ); |
| 4474 | 4476 | |
| 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"); | |
| 4477 | if ( | |
| 4478 | results.search._tag !== "Failure" || | |
| 4479 | results.search.failure._tag !== "ProjectSearchEntriesError" | |
| 4480 | ) { | |
| 4481 | assert.fail("Expected a ProjectSearchEntriesError"); | |
| 4482 | } | |
| 4483 | const searchError = results.search.failure; | |
| 4481 | 4484 | assert.equal( |
| 4482 | results.list.failure.message, | |
| 4483 | `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, | |
| 4485 | searchError.message, | |
| 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 | 4515 | assert.equal( |
| 4487 | results.read.failure.message, | |
| 4488 | "Failed to read workspace file: Workspace file path resolves outside the project root.", | |
| 4516 | readError.message, | |
| 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 | 4532 | assert.equal( |
| 4492 | results.browse.failure.message, | |
| 4493 | `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, | |
| 4533 | browseError.message, | |
| 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 | 4541 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4496 | 4542 | ); |
| 4497 | 4543 | |
| ... | ... | @@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4572 | 4618 | ).pipe(Effect.result), |
| 4573 | 4619 | ); |
| 4574 | 4620 | |
| 4575 | assertTrue(result._tag === "Failure"); | |
| 4576 | assertTrue(result.failure._tag === "ProjectWriteFileError"); | |
| 4621 | if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") { | |
| 4622 | assert.fail("Expected a ProjectWriteFileError"); | |
| 4623 | } | |
| 4624 | const writeError = result.failure; | |
| 4577 | 4625 | assert.equal( |
| 4578 | result.failure.message, | |
| 4579 | "Workspace file path must stay within the project root.", | |
| 4626 | writeError.message, | |
| 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 | 4634 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4582 | 4635 | ); |
| 4583 | 4636 |
apps/server/src/workspace/WorkspaceFileSystem.test.ts+81-6| ... | ... | @@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i |
| 104 | 104 | const error = yield* workspaceFileSystem |
| 105 | 105 | .readFile({ cwd, relativePath: "linked-secret.txt" }) |
| 106 | 106 | .pipe(Effect.flip); |
| 107 | const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); | |
| 108 | const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); | |
| 107 | 109 | |
| 108 | expect(error.message).toBe( | |
| 109 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, | |
| 110 | ); | |
| 110 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); | |
| 111 | expect(error).toMatchObject({ | |
| 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 | 188 | expect(error.cause).toBeInstanceOf(Error); |
| 112 | expect((error.cause as Error).message).toBe( | |
| 113 | "Workspace file path resolves outside the project root.", | |
| 114 | ); | |
| 189 | expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); | |
| 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 | 27 | |
| 28 | 28 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| 29 | 29 | |
| 30 | export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()( | |
| 31 | "WorkspaceFileSystemError", | |
| 30 | export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()( | |
| 31 | "WorkspaceFileSystemOperationError", | |
| 32 | 32 | { |
| 33 | cwd: Schema.String, | |
| 34 | relativePath: Schema.optional(Schema.String), | |
| 33 | workspaceRoot: Schema.String, | |
| 34 | relativePath: Schema.String, | |
| 35 | resolvedPath: Schema.String, | |
| 36 | operationPath: Schema.String, | |
| 35 | 37 | operation: Schema.Literals([ |
| 36 | "workspaceFileSystem.readFile", | |
| 37 | "workspaceFileSystem.makeDirectory", | |
| 38 | "workspaceFileSystem.writeFile", | |
| 38 | "realpath-workspace-root", | |
| 39 | "realpath-target", | |
| 40 | "open", | |
| 41 | "stat", | |
| 42 | "read", | |
| 43 | "close", | |
| 44 | "make-directory", | |
| 45 | "write-file", | |
| 39 | 46 | ]), |
| 40 | 47 | cause: Schema.Defect(), |
| 41 | 48 | }, |
| 42 | 49 | ) { |
| 43 | 50 | override get message(): string { |
| 44 | const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; | |
| 45 | return `Workspace file operation '${this.operation}' failed for ${target}.`; | |
| 51 | return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; | |
| 46 | 52 | } |
| 47 | 53 | } |
| 48 | 54 | |
| 55 | export 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 | ||
| 69 | export 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 | ||
| 82 | export 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 | ||
| 95 | export const WorkspaceFileSystemError = Schema.Union([ | |
| 96 | WorkspaceFileSystemOperationError, | |
| 97 | WorkspaceFilePathEscapeError, | |
| 98 | WorkspacePathNotFileError, | |
| 99 | WorkspaceBinaryFileError, | |
| 100 | ]); | |
| 101 | export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; | |
| 102 | ||
| 49 | 103 | /** Service tag for workspace file operations. */ |
| 50 | 104 | export class WorkspaceFileSystem extends Context.Service< |
| 51 | 105 | WorkspaceFileSystem, |
| ... | ... | @@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { |
| 86 | 140 | relativePath: input.relativePath, |
| 87 | 141 | }); |
| 88 | 142 | |
| 89 | return yield* Effect.tryPromise({ | |
| 90 | try: async () => { | |
| 91 | const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | |
| 92 | NodeFSP.realpath(input.cwd), | |
| 93 | NodeFSP.realpath(target.absolutePath), | |
| 94 | ]); | |
| 95 | const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | |
| 96 | if ( | |
| 97 | relativeRealPath.startsWith(`..${path.sep}`) || | |
| 98 | relativeRealPath === ".." || | |
| 99 | path.isAbsolute(relativeRealPath) | |
| 100 | ) { | |
| 101 | throw new Error("Workspace file path resolves outside the project root."); | |
| 102 | } | |
| 103 | ||
| 104 | const handle = await NodeFSP.open(realTargetPath, "r"); | |
| 105 | try { | |
| 106 | const stat = await handle.stat(); | |
| 143 | const realWorkspaceRoot = yield* Effect.tryPromise({ | |
| 144 | try: () => NodeFSP.realpath(input.cwd), | |
| 145 | catch: (cause) => | |
| 146 | new WorkspaceFileSystemOperationError({ | |
| 147 | workspaceRoot: input.cwd, | |
| 148 | relativePath: input.relativePath, | |
| 149 | resolvedPath: target.absolutePath, | |
| 150 | operationPath: input.cwd, | |
| 151 | operation: "realpath-workspace-root", | |
| 152 | cause, | |
| 153 | }), | |
| 154 | }); | |
| 155 | const realTargetPath = yield* Effect.tryPromise({ | |
| 156 | try: () => NodeFSP.realpath(target.absolutePath), | |
| 157 | catch: (cause) => | |
| 158 | new WorkspaceFileSystemOperationError({ | |
| 159 | workspaceRoot: input.cwd, | |
| 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 | 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 | 216 | const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); |
| 111 | 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 | 230 | const fileBytes = buffer.subarray(0, bytesRead); |
| 114 | 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 | 239 | return { |
| 119 | 240 | relativePath: target.relativePath, |
| 120 | contents, | |
| 241 | contents: new TextDecoder("utf-8").decode(fileBytes), | |
| 121 | 242 | byteLength: stat.size, |
| 122 | 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 | }); |
| 137 | 261 | |
| 138 | 262 | const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( |
| ... | ... | @@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { |
| 146 | 270 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( |
| 147 | 271 | Effect.mapError( |
| 148 | 272 | (cause) => |
| 149 | new WorkspaceFileSystemError({ | |
| 150 | cwd: input.cwd, | |
| 273 | new WorkspaceFileSystemOperationError({ | |
| 274 | workspaceRoot: input.cwd, | |
| 151 | 275 | relativePath: input.relativePath, |
| 152 | operation: "workspaceFileSystem.makeDirectory", | |
| 276 | resolvedPath: target.absolutePath, | |
| 277 | operationPath: path.dirname(target.absolutePath), | |
| 278 | operation: "make-directory", | |
| 153 | 279 | cause, |
| 154 | 280 | }), |
| 155 | 281 | ), |
| ... | ... | @@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { |
| 157 | 283 | yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( |
| 158 | 284 | Effect.mapError( |
| 159 | 285 | (cause) => |
| 160 | new WorkspaceFileSystemError({ | |
| 161 | cwd: input.cwd, | |
| 286 | new WorkspaceFileSystemOperationError({ | |
| 287 | workspaceRoot: input.cwd, | |
| 162 | 288 | relativePath: input.relativePath, |
| 163 | operation: "workspaceFileSystem.writeFile", | |
| 289 | resolvedPath: target.absolutePath, | |
| 290 | operationPath: target.absolutePath, | |
| 291 | operation: "write-file", | |
| 164 | 292 | cause, |
| 165 | 293 | }), |
| 166 | 294 | ), |
apps/server/src/ws.ts+106-39| ... | ... | @@ -34,6 +34,9 @@ import { |
| 34 | 34 | OrchestrationGetSnapshotError, |
| 35 | 35 | OrchestrationGetTurnDiffError, |
| 36 | 36 | ORCHESTRATION_WS_METHODS, |
| 37 | type ProjectEntriesFailure, | |
| 38 | type ProjectFileFailure, | |
| 39 | type ProjectFileOperation, | |
| 37 | 40 | ProjectListEntriesError, |
| 38 | 41 | ProjectReadFileError, |
| 39 | 42 | ProjectSearchEntriesError, |
| ... | ... | @@ -41,6 +44,7 @@ import { |
| 41 | 44 | RelayClientInstallFailedError, |
| 42 | 45 | type RelayClientInstallProgressEvent, |
| 43 | 46 | OrchestrationReplayEventsError, |
| 47 | type FilesystemBrowseFailure, | |
| 44 | 48 | FilesystemBrowseError, |
| 45 | 49 | AssetAccessError, |
| 46 | 50 | EnvironmentAuthorizationError, |
| ... | ... | @@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts"; |
| 108 | 112 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 109 | 113 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 110 | 114 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 111 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); | |
| 112 | 115 | |
| 113 | 116 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 114 | 117 | |
| ... | ... | @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { |
| 116 | 119 | throw new Error(`Unhandled compatibility error: ${String(error)}`); |
| 117 | 120 | } |
| 118 | 121 | |
| 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 | 122 | /** Preserve the setup runner's broader pre-refactor message normalization. */ |
| 125 | 123 | function legacySetupFailureDescription(cause: unknown): string { |
| 126 | 124 | if ( |
| ... | ... | @@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { |
| 134 | 132 | return String(cause); |
| 135 | 133 | } |
| 136 | 134 | |
| 137 | function workspaceEntriesCompatibilityDetail( | |
| 138 | error: WorkspaceEntries.WorkspaceEntriesError, | |
| 139 | ): string { | |
| 135 | function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { | |
| 136 | readonly failure: ProjectEntriesFailure; | |
| 137 | readonly normalizedCwd?: string; | |
| 138 | readonly timeout?: string; | |
| 139 | readonly detail?: string; | |
| 140 | } { | |
| 140 | 141 | switch (error._tag) { |
| 141 | 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 | 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 | 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 | 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 | 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 | 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 | 175 | default: |
| 154 | 176 | return unexpectedCompatibilityError(error); |
| 155 | 177 | } |
| 156 | 178 | } |
| 157 | 179 | |
| 158 | function workspaceBrowseCompatibilityDetail( | |
| 159 | error: WorkspaceEntries.WorkspaceEntriesBrowseError, | |
| 160 | ): string { | |
| 180 | function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { | |
| 181 | readonly failure: FilesystemBrowseFailure; | |
| 182 | readonly parentPath?: string; | |
| 183 | readonly platform?: string; | |
| 184 | } { | |
| 161 | 185 | switch (error._tag) { |
| 162 | 186 | case "WorkspaceEntriesWindowsPathUnsupportedError": |
| 163 | return "Windows-style paths are only supported on Windows."; | |
| 187 | return { failure: "windows_path_unsupported", platform: error.platform }; | |
| 164 | 188 | case "WorkspaceEntriesCurrentProjectRequiredError": |
| 165 | return "Relative filesystem browse paths require a current project."; | |
| 189 | return { failure: "current_project_required" }; | |
| 166 | 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 | ||
| 197 | function 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 | 228 | default: |
| 169 | 229 | return unexpectedCompatibilityError(error); |
| 170 | 230 | } |
| ... | ... | @@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1260 | 1320 | Effect.mapError( |
| 1261 | 1321 | (cause) => |
| 1262 | 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 | 1327 | cause, |
| 1265 | 1328 | }), |
| 1266 | 1329 | ), |
| ... | ... | @@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1274 | 1337 | Effect.mapError( |
| 1275 | 1338 | (cause) => |
| 1276 | 1339 | new ProjectListEntriesError({ |
| 1277 | message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, | |
| 1340 | ...input, | |
| 1341 | ...projectEntriesFailureContext(cause), | |
| 1278 | 1342 | cause, |
| 1279 | 1343 | }), |
| 1280 | 1344 | ), |
| ... | ... | @@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1285 | 1349 | observeRpcEffect( |
| 1286 | 1350 | WS_METHODS.projectsReadFile, |
| 1287 | 1351 | workspaceFileSystem.readFile(input).pipe( |
| 1288 | Effect.mapError((cause) => { | |
| 1289 | const message = isWorkspacePathOutsideRootError(cause) | |
| 1290 | ? "Workspace file path must stay within the project root." | |
| 1291 | : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; | |
| 1292 | return new ProjectReadFileError({ message, cause }); | |
| 1293 | }), | |
| 1352 | Effect.mapError( | |
| 1353 | (cause) => | |
| 1354 | new ProjectReadFileError({ | |
| 1355 | ...input, | |
| 1356 | ...projectFileFailureContext(cause), | |
| 1357 | cause, | |
| 1358 | }), | |
| 1359 | ), | |
| 1294 | 1360 | ), |
| 1295 | 1361 | { "rpc.aggregate": "workspace" }, |
| 1296 | 1362 | ), |
| ... | ... | @@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1298 | 1364 | observeRpcEffect( |
| 1299 | 1365 | WS_METHODS.projectsWriteFile, |
| 1300 | 1366 | workspaceFileSystem.writeFile(input).pipe( |
| 1301 | Effect.mapError((cause) => { | |
| 1302 | const message = isWorkspacePathOutsideRootError(cause) | |
| 1303 | ? "Workspace file path must stay within the project root." | |
| 1304 | : "Failed to write workspace file"; | |
| 1305 | return new ProjectWriteFileError({ | |
| 1306 | message, | |
| 1307 | cause, | |
| 1308 | }); | |
| 1309 | }), | |
| 1367 | Effect.mapError( | |
| 1368 | (cause) => | |
| 1369 | new ProjectWriteFileError({ | |
| 1370 | cwd: input.cwd, | |
| 1371 | relativePath: input.relativePath, | |
| 1372 | ...projectFileFailureContext(cause), | |
| 1373 | cause, | |
| 1374 | }), | |
| 1375 | ), | |
| 1310 | 1376 | ), |
| 1311 | 1377 | { "rpc.aggregate": "workspace" }, |
| 1312 | 1378 | ), |
| ... | ... | @@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1321 | 1387 | Effect.mapError( |
| 1322 | 1388 | (cause) => |
| 1323 | 1389 | new FilesystemBrowseError({ |
| 1324 | message: workspaceBrowseCompatibilityDetail(cause), | |
| 1390 | ...input, | |
| 1391 | ...filesystemBrowseFailureContext(cause), | |
| 1325 | 1392 | cause, |
| 1326 | 1393 | }), |
| 1327 | 1394 | ), |
packages/contracts/src/filesystem.test.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { FilesystemBrowseError } from "./filesystem.ts"; | |
| 5 | ||
| 6 | describe("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 | 21 | }); |
| 22 | 22 | export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; |
| 23 | 23 | |
| 24 | export const FilesystemBrowseFailure = Schema.Literals([ | |
| 25 | "windows_path_unsupported", | |
| 26 | "current_project_required", | |
| 27 | "read_directory_failed", | |
| 28 | ]); | |
| 29 | export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; | |
| 30 | ||
| 31 | function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { | |
| 32 | if (!("message" in props)) return undefined; | |
| 33 | return typeof props.message === "string" ? props.message : undefined; | |
| 34 | } | |
| 35 | ||
| 24 | 36 | export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()( |
| 25 | 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 | 44 | message: TrimmedNonEmptyString, |
| 28 | 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 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | ProjectReadFileError, | |
| 6 | ProjectSearchEntriesError, | |
| 7 | ProjectWriteFileError, | |
| 8 | } from "./project.ts"; | |
| 9 | ||
| 10 | describe("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 | 37 | }); |
| 38 | 38 | export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; |
| 39 | 39 | |
| 40 | export 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 | ]); | |
| 48 | export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; | |
| 49 | ||
| 50 | type ProjectEntriesFailureContext = { | |
| 51 | readonly failure: ProjectEntriesFailure; | |
| 52 | readonly normalizedCwd?: string; | |
| 53 | readonly timeout?: string; | |
| 54 | readonly detail?: string; | |
| 55 | readonly cause?: unknown; | |
| 56 | }; | |
| 57 | ||
| 58 | function decodedProjectErrorMessage(props: object): string | undefined { | |
| 59 | if (!("message" in props)) return undefined; | |
| 60 | return typeof props.message === "string" ? props.message : undefined; | |
| 61 | } | |
| 62 | ||
| 40 | 63 | export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()( |
| 41 | 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 | 73 | message: TrimmedNonEmptyString, |
| 44 | 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 | } | |
| 47 | 95 | |
| 48 | 96 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( |
| 49 | 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 | 104 | message: TrimmedNonEmptyString, |
| 52 | 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 | } | |
| 55 | 117 | |
| 56 | 118 | export const ProjectReadFileInput = Schema.Struct({ |
| 57 | 119 | cwd: TrimmedNonEmptyString, |
| ... | ... | @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ |
| 67 | 129 | }); |
| 68 | 130 | export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; |
| 69 | 131 | |
| 132 | export 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 | ]); | |
| 139 | export type ProjectFileFailure = typeof ProjectFileFailure.Type; | |
| 140 | ||
| 141 | export 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 | ]); | |
| 151 | export type ProjectFileOperation = typeof ProjectFileOperation.Type; | |
| 152 | ||
| 153 | type 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 | ||
| 70 | 164 | export class ProjectReadFileError extends Schema.TaggedErrorClass<ProjectReadFileError>()( |
| 71 | 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 | 174 | message: TrimmedNonEmptyString, |
| 74 | 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 | } | |
| 77 | 188 | |
| 78 | 189 | export const ProjectWriteFileInput = Schema.Struct({ |
| 79 | 190 | cwd: TrimmedNonEmptyString, |
| ... | ... | @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; |
| 90 | 201 | export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()( |
| 91 | 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 | 211 | message: TrimmedNonEmptyString, |
| 94 | 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 | } |