diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0cbe9176582..7df2e3361c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -85,7 +86,58 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }).pipe(Effect.flip); - expect(error.message).toContain("relative to the project root"); + expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + operation: "validate-workspace-path", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-permission-root-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "
report
"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: htmlPath, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + realPath: () => Effect.fail(cause), + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); + + expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + operation: "inspect-workspace-asset", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); @@ -186,4 +238,37 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 873e9fc3d37..f7be262b41a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { @@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null { } } -const failAccess = (message: string, cause?: unknown) => - new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect