diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3bc430db956..32a7cc17944 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("preserves workspace rpc failure messages", () => + it.effect("preserves structured workspace rpc failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4449,13 +4449,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); const missingBrowseParent = path.join(workspaceDir, "missing-browse"); + const sensitiveQuery = "authorization: Bearer secret-token"; const wsUrl = yield* getWsServerUrl("/ws"); const results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => Effect.all({ search: client[WS_METHODS.projectsSearchEntries]({ cwd: invalidWorkspace, - query: "needle", + query: sensitiveQuery, limit: 10, }).pipe(Effect.result), list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( @@ -4473,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assertTrue(results.search._tag === "Failure"); - assert.equal( - results.search.failure.message, - `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, - ); - assertTrue(results.list._tag === "Failure"); + if ( + results.search._tag !== "Failure" || + results.search.failure._tag !== "ProjectSearchEntriesError" + ) { + assert.fail("Expected a ProjectSearchEntriesError"); + } + const searchError = results.search.failure; assert.equal( - results.list.failure.message, - `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + searchError.message, + `Failed to search workspace entries in '${invalidWorkspace}'.`, ); - assertTrue(results.read._tag === "Failure"); + assert.equal(searchError.cwd, invalidWorkspace); + assert.equal(searchError.queryLength, sensitiveQuery.length); + assert.notProperty(searchError, "query"); + assert.notInclude(searchError.message, "Bearer"); + assert.notInclude(searchError.message, "secret-token"); + assert.equal(searchError.limit, 10); + assert.equal(searchError.failure, "workspace_root_not_found"); + assert.equal(searchError.normalizedCwd, invalidWorkspace); + assert.isDefined(searchError.cause); + + if ( + results.list._tag !== "Failure" || + results.list.failure._tag !== "ProjectListEntriesError" + ) { + assert.fail("Expected a ProjectListEntriesError"); + } + const listError = results.list.failure; + assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`); + assert.equal(listError.cwd, invalidWorkspace); + assert.equal(listError.failure, "workspace_root_not_found"); + assert.equal(listError.normalizedCwd, invalidWorkspace); + assert.isDefined(listError.cause); + + if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + const readError = results.read.failure; assert.equal( - results.read.failure.message, - `Failed to read workspace file: Workspace file 'linked-outside.txt' resolves outside workspace root '${workspaceDir}': ${resolvedOutsideFile}`, + readError.message, + `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, ); - assertTrue(results.browse._tag === "Failure"); + assert.equal(readError.cwd, workspaceDir); + assert.equal(readError.relativePath, "linked-outside.txt"); + assert.equal(readError.failure, "resolved_path_outside_root"); + assert.equal(readError.resolvedPath, resolvedOutsideFile); + assert.isDefined(readError.cause); + + if ( + results.browse._tag !== "Failure" || + results.browse.failure._tag !== "FilesystemBrowseError" + ) { + assert.fail("Expected a FilesystemBrowseError"); + } + const browseError = results.browse.failure; assert.equal( - results.browse.failure.message, - `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, + browseError.message, + `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`, ); + assert.equal(browseError.cwd, workspaceDir); + assert.equal(browseError.partialPath, "./missing-browse/child"); + assert.equal(browseError.failure, "read_directory_failed"); + assert.equal(browseError.parentPath, missingBrowseParent); + assert.isDefined(browseError.cause); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4573,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ).pipe(Effect.result), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectWriteFileError"); + if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") { + assert.fail("Expected a ProjectWriteFileError"); + } + const writeError = result.failure; assert.equal( - result.failure.message, - "Workspace file path must stay within the project root.", + writeError.message, + `Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`, ); + assert.equal(writeError.cwd, workspaceDir); + assert.equal(writeError.relativePath, "../escape.txt"); + assert.equal(writeError.failure, "workspace_path_outside_root"); + assert.isDefined(writeError.cause); + assert.notProperty(writeError, "contents"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9e0a08b7ca0..7c45d0b58b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,9 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectEntriesFailure, + type ProjectFileFailure, + type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, @@ -41,6 +44,7 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + type FilesystemBrowseFailure, FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, @@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); -const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } -/** Preserve pre-structured-error display behavior at the RPC boundary. */ -function legacyPlatformFailureDescription(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - /** Preserve the setup runner's broader pre-refactor message normalization. */ function legacySetupFailureDescription(cause: unknown): string { if ( @@ -134,52 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { return String(cause); } -function workspaceEntriesCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesError, -): string { +function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; +} { switch (error._tag) { case "WorkspaceRootNotExistsError": - return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_found", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootCreateFailedError": - return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_create_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootNotDirectoryError": - return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_directory", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceSearchIndexCreateFailed": - return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_create_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; case "WorkspaceSearchIndexScanTimedOut": - return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; + return { + failure: "search_index_scan_timed_out", + normalizedCwd: error.cwd, + timeout: error.timeout, + }; case "WorkspaceSearchIndexSearchFailed": - return `Workspace search failed for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_search_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; default: return unexpectedCompatibilityError(error); } } -function workspaceBrowseCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesBrowseError, -): string { +function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; +} { switch (error._tag) { case "WorkspaceEntriesWindowsPathUnsupportedError": - return "Windows-style paths are only supported on Windows."; + return { failure: "windows_path_unsupported", platform: error.platform }; case "WorkspaceEntriesCurrentProjectRequiredError": - return "Relative filesystem browse paths require a current project."; + return { failure: "current_project_required" }; case "WorkspaceEntriesReadDirectoryError": - return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; + return { failure: "read_directory_failed", parentPath: error.parentPath }; default: return unexpectedCompatibilityError(error); } } -function workspaceFileReadCompatibilityDetail( - error: WorkspaceFileSystem.WorkspaceFileSystemError, -): string { +function projectFileFailureContext( + error: + | WorkspaceFileSystem.WorkspaceFileSystemError + | WorkspacePaths.WorkspacePathOutsideRootError, +): { + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; +} { switch (error._tag) { + case "WorkspacePathOutsideRootError": + return { failure: "workspace_path_outside_root" }; case "WorkspaceFileSystemOperationError": - return legacyPlatformFailureDescription(error.cause); + return { + failure: "operation_failed", + resolvedPath: error.resolvedPath, + operation: error.operation, + operationPath: error.operationPath, + }; case "WorkspaceFilePathEscapeError": + return { + failure: "resolved_path_outside_root", + resolvedPath: error.resolvedPath, + resolvedWorkspaceRoot: error.resolvedWorkspaceRoot, + }; case "WorkspacePathNotFileError": + return { failure: "path_not_file", resolvedPath: error.resolvedPath }; case "WorkspaceBinaryFileError": - return error.message; + return { failure: "binary_file", resolvedPath: error.resolvedPath }; default: return unexpectedCompatibilityError(error); } @@ -1275,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1289,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + ...input, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1300,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${workspaceFileReadCompatibilityDetail(cause)}`; - return new ProjectReadFileError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1313,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1336,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: workspaceBrowseCompatibilityDetail(cause), + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts new file mode 100644 index 00000000000..45355b73edc --- /dev/null +++ b/packages/contracts/src/filesystem.test.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { FilesystemBrowseError } from "./filesystem.ts"; + +describe("FilesystemBrowseError", () => { + it("derives a stable message from browse context while retaining the cause", () => { + const cause = new Error("sensitive filesystem detail"); + const error = new FilesystemBrowseError({ + cwd: "/workspace", + partialPath: "./src/mai", + failure: "read_directory_failed", + parentPath: "/workspace/src", + cause, + }); + + expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError); + const error = decodeError({ + _tag: "FilesystemBrowseError", + message: "Legacy filesystem browse failure.", + }); + + expect(error.message).toBe("Legacy filesystem browse failure."); + expect(error.partialPath).toBeUndefined(); + expect(error.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 511f8ee19a3..ca4519b4c8b 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ }); export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; +export const FilesystemBrowseFailure = Schema.Literals([ + "windows_path_unsupported", + "current_project_required", + "read_directory_failed", +]); +export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; + +function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class FilesystemBrowseError extends Schema.TaggedErrorClass()( "FilesystemBrowseError", { + partialPath: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(FilesystemBrowseFailure), + parentPath: Schema.optional(TrimmedNonEmptyString), + platform: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // Structured diagnostics stay optional for rolling compatibility with legacy message-only + // payloads, while new call sites must provide the request context and failure classification. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly partialPath: string; + readonly cwd?: string | undefined; + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; + readonly cause?: unknown; + }) { + const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; + super({ + ...props, + message: + decodedFilesystemBrowseErrorMessage(props) ?? + `Failed to browse filesystem path '${props.partialPath}'${cwd}.`, + } as any); + } +} diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts new file mode 100644 index 00000000000..ea9d5a90e7c --- /dev/null +++ b/packages/contracts/src/project.test.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ProjectReadFileError, + ProjectSearchEntriesError, + ProjectWriteFileError, +} from "./project.ts"; + +describe("project RPC errors", () => { + it("derives stable messages from structured request context while retaining causes", () => { + const cause = new Error("sensitive platform detail"); + const searchError = new ProjectSearchEntriesError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 20, + failure: "search_index_search_failed", + normalizedCwd: "/workspace", + detail: "index unavailable", + cause, + }); + const readError = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "src/index.ts", + failure: "operation_failed", + operation: "read", + operationPath: "/workspace/src/index.ts", + resolvedPath: "/workspace/src/index.ts", + cause, + }); + + expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); + expect(searchError.message).not.toContain(cause.message); + expect(searchError.normalizedCwd).toBe("/workspace"); + expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBe(cause); + expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); + expect(readError.message).not.toContain(cause.message); + expect(readError.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError); + const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError); + + const searchError = decodeSearchError({ + _tag: "ProjectSearchEntriesError", + message: "Legacy project search failure.", + query: "legacy sensitive query", + }); + const writeError = decodeWriteError({ + _tag: "ProjectWriteFileError", + message: "Legacy project write failure.", + }); + + expect(searchError.message).toBe("Legacy project search failure."); + expect(searchError.cwd).toBeUndefined(); + expect(searchError.queryLength).toBeUndefined(); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.failure).toBeUndefined(); + expect(writeError.message).toBe("Legacy project write failure."); + expect(writeError.relativePath).toBeUndefined(); + expect(writeError.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 29610845288..338b87096d9 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({ }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; +export const ProjectEntriesFailure = Schema.Literals([ + "workspace_root_not_found", + "workspace_root_create_failed", + "workspace_root_not_directory", + "search_index_create_failed", + "search_index_scan_timed_out", + "search_index_search_failed", +]); +export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; + +type ProjectEntriesFailureContext = { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; + readonly cause?: unknown; +}; + +function decodedProjectErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( "ProjectSearchEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // The structured fields are optional on the wire so newer peers can decode legacy message-only + // failures. New application code must provide them through this constructor. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace entries in '${props.cwd}'.`, + } as any); + } +} export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`, + } as any); + } +} export const ProjectReadFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ }); export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; +export const ProjectFileFailure = Schema.Literals([ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed", +]); +export type ProjectFileFailure = typeof ProjectFileFailure.Type; + +export const ProjectFileOperation = Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", +]); +export type ProjectFileOperation = typeof ProjectFileOperation.Type; + +type ProjectFileFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; + readonly cause?: unknown; +}; + export class ProjectReadFileError extends Schema.TaggedErrorClass()( "ProjectReadFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; export class ProjectWriteFileError extends Schema.TaggedErrorClass()( "ProjectWriteFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +}