authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 17:35:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 17:35:07-07:00
log0eba548282b96ee420ad0b1a3f5d9d4487376b90
treed831a177719d88b2ce1fd2c161b870ad90f7199c
parent68c0dd3b7095cae054a9db5e48157e89f65c0664
signature Signed by PGP key B5690EEEBB952194

[codex] Model asset access failures with distinct errors (#3448)

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

4 files changed, 200 insertions(+), 79 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+3-2
......@@ -88,7 +88,7 @@ describe("AssetAccess", () => {
8888 }).pipe(Effect.flip);
8989 expect(error.message).toBe("Workspace file path must be relative to the project root.");
9090 expect(error).toMatchObject({
91 operation: "validate-workspace-path",
91 _tag: "AssetWorkspacePathValidationError",
9292 resource: {
9393 _tag: "workspace-file",
9494 threadId: "thread-1",
......@@ -130,7 +130,7 @@ describe("AssetAccess", () => {
130130
131131 expect(error.message).toBe("Failed to inspect the workspace asset.");
132132 expect(error).toMatchObject({
133 operation: "inspect-workspace-asset",
133 _tag: "AssetWorkspaceAssetInspectionError",
134134 resource: {
135135 _tag: "workspace-file",
136136 threadId: "thread-1",
......@@ -268,6 +268,7 @@ describe("AssetAccess", () => {
268268 );
269269
270270 expect(error.message).toBe("Failed to resolve project favicon.");
271 expect(error._tag).toBe("AssetProjectFaviconResolutionError");
271272 expect(error.cause).toBe(resolutionCause);
272273 }).pipe(Effect.provide(testLayer)),
273274 );
apps/server/src/assets/AssetAccess.ts+28-43
......@@ -1,5 +1,18 @@
11import type { AssetResource } from "@t3tools/contracts";
2import { AssetAccessError } from "@t3tools/contracts";
2import {
3 AssetAttachmentNotFoundError,
4 AssetPreviewTypeValidationError,
5 AssetProjectFaviconInspectionError,
6 AssetProjectFaviconNotFoundError,
7 AssetProjectFaviconResolutionError,
8 AssetSigningKeyLoadError,
9 AssetWorkspaceAssetInspectionError,
10 AssetWorkspaceAssetNotFoundError,
11 AssetWorkspaceContextNotFoundError,
12 AssetWorkspacePathValidationError,
13 AssetWorkspaceResolutionError,
14 AssetWorkspaceRootNormalizationError,
15} from "@t3tools/contracts";
316import {
417 isWorkspaceImagePreviewPath,
518 isWorkspacePreviewEntryPath,
......@@ -165,19 +178,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
165178 switch (input.resource._tag) {
166179 case "workspace-file": {
167180 if (!input.workspaceRoot) {
168 return yield* new AssetAccessError({
169 operation: "resolve-workspace-context",
181 return yield* new AssetWorkspaceContextNotFoundError({
170182 resource: input.resource,
171 message: "Workspace context was not found.",
172183 });
173184 }
174185 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
175186 Effect.mapError(
176187 (cause) =>
177 new AssetAccessError({
178 operation: "normalize-workspace-root",
188 new AssetWorkspaceRootNormalizationError({
179189 resource: input.resource,
180 message: "Failed to normalize the workspace root.",
181190 cause,
182191 }),
183192 ),
......@@ -190,19 +199,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
190199 .pipe(
191200 Effect.mapError(
192201 (cause) =>
193 new AssetAccessError({
194 operation: "validate-workspace-path",
202 new AssetWorkspacePathValidationError({
195203 resource: input.resource,
196 message: "Workspace file path must be relative to the project root.",
197204 cause,
198205 }),
199206 ),
200207 );
201208 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
202 return yield* new AssetAccessError({
203 operation: "validate-preview-type",
209 return yield* new AssetPreviewTypeValidationError({
204210 resource: input.resource,
205 message: "Only browser documents and images can be previewed.",
206211 });
207212 }
208213 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
......@@ -211,28 +216,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
211216 }).pipe(
212217 Effect.mapError(
213218 (cause) =>
214 new AssetAccessError({
215 operation: "inspect-workspace-asset",
219 new AssetWorkspaceAssetInspectionError({
216220 resource: input.resource,
217 message: "Failed to inspect the workspace asset.",
218221 cause,
219222 }),
220223 ),
221224 );
222225 if (!canonicalFile) {
223 return yield* new AssetAccessError({
224 operation: "locate-workspace-asset",
226 return yield* new AssetWorkspaceAssetNotFoundError({
225227 resource: input.resource,
226 message: "Workspace asset was not found.",
227228 });
228229 }
229230 const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(
230231 Effect.mapError(
231232 (cause) =>
232 new AssetAccessError({
233 operation: "resolve-workspace",
233 new AssetWorkspaceResolutionError({
234234 resource: input.resource,
235 message: "Failed to resolve workspace.",
236235 cause,
237236 }),
238237 ),
......@@ -262,10 +261,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
262261 attachmentId: input.resource.attachmentId,
263262 });
264263 if (!attachmentPath) {
265 return yield* new AssetAccessError({
266 operation: "locate-attachment",
264 return yield* new AssetAttachmentNotFoundError({
267265 resource: input.resource,
268 message: "Attachment was not found.",
269266 });
270267 }
271268 claims = {
......@@ -281,10 +278,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
281278 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
282279 Effect.mapError(
283280 (cause) =>
284 new AssetAccessError({
285 operation: "normalize-workspace-root",
281 new AssetWorkspaceRootNormalizationError({
286282 resource: input.resource,
287 message: "Failed to normalize the workspace root.",
288283 cause,
289284 }),
290285 ),
......@@ -293,10 +288,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
293288 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
294289 Effect.mapError(
295290 (cause) =>
296 new AssetAccessError({
297 operation: "resolve-project-favicon",
291 new AssetProjectFaviconResolutionError({
298292 resource: input.resource,
299 message: "Failed to resolve project favicon.",
300293 cause,
301294 }),
302295 ),
......@@ -307,19 +300,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
307300 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
308301 Effect.mapError(
309302 (cause) =>
310 new AssetAccessError({
311 operation: "inspect-project-favicon",
303 new AssetProjectFaviconInspectionError({
312304 resource: input.resource,
313 message: "Failed to inspect the project favicon.",
314305 cause,
315306 }),
316307 ),
317308 ))
318309 ) {
319 return yield* new AssetAccessError({
320 operation: "locate-project-favicon",
310 return yield* new AssetProjectFaviconNotFoundError({
321311 resource: input.resource,
322 message: "Project favicon was not found.",
323312 });
324313 }
325314 claims = {
......@@ -328,10 +317,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
328317 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
329318 Effect.mapError(
330319 (cause) =>
331 new AssetAccessError({
332 operation: "resolve-workspace",
320 new AssetWorkspaceResolutionError({
333321 resource: input.resource,
334 message: "Failed to resolve workspace.",
335322 cause,
336323 }),
337324 ),
......@@ -348,10 +335,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
348335 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
349336 Effect.mapError(
350337 (cause) =>
351 new AssetAccessError({
352 operation: "load-signing-key",
338 new AssetSigningKeyLoadError({
353339 resource: input.resource,
354 message: "Failed to load the asset signing key.",
355340 cause,
356341 }),
357342 ),
apps/server/src/ws.ts+6-13
......@@ -46,7 +46,8 @@ import {
4646 OrchestrationReplayEventsError,
4747 type FilesystemBrowseFailure,
4848 FilesystemBrowseError,
49 AssetAccessError,
49 AssetWorkspaceContextNotFoundError,
50 AssetWorkspaceContextResolutionError,
5051 EnvironmentAuthorizationError,
5152 ThreadId,
5253 type TerminalAttachStreamEvent,
......@@ -1413,19 +1414,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14131414 .pipe(
14141415 Effect.mapError(
14151416 (cause) =>
1416 new AssetAccessError({
1417 operation: "resolve-workspace-context",
1417 new AssetWorkspaceContextResolutionError({
14181418 resource: input.resource,
1419 message: "Failed to resolve workspace context.",
14201419 cause,
14211420 }),
14221421 ),
14231422 );
14241423 if (Option.isNone(thread)) {
1425 return yield* new AssetAccessError({
1426 operation: "resolve-workspace-context",
1424 return yield* new AssetWorkspaceContextNotFoundError({
14271425 resource: input.resource,
1428 message: "Workspace context was not found.",
14291426 });
14301427 }
14311428 const project = yield* projectionSnapshotQuery
......@@ -1433,19 +1430,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14331430 .pipe(
14341431 Effect.mapError(
14351432 (cause) =>
1436 new AssetAccessError({
1437 operation: "resolve-workspace-context",
1433 new AssetWorkspaceContextResolutionError({
14381434 resource: input.resource,
1439 message: "Failed to resolve workspace context.",
14401435 cause,
14411436 }),
14421437 ),
14431438 );
14441439 if (Option.isNone(project)) {
1445 return yield* new AssetAccessError({
1446 operation: "resolve-workspace-context",
1440 return yield* new AssetWorkspaceContextNotFoundError({
14471441 resource: input.resource,
1448 message: "Workspace context was not found.",
14491442 });
14501443 }
14511444 return yield* issueAssetUrl({
packages/contracts/src/assets.ts+163-21
......@@ -29,28 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({
2929});
3030export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3131
32export const AssetAccessOperation = Schema.Literals([
33 "resolve-workspace-context",
34 "normalize-workspace-root",
35 "validate-workspace-path",
36 "validate-preview-type",
37 "inspect-workspace-asset",
38 "locate-workspace-asset",
39 "resolve-workspace",
40 "locate-attachment",
41 "resolve-project-favicon",
42 "inspect-project-favicon",
43 "locate-project-favicon",
44 "load-signing-key",
45]);
46export type AssetAccessOperation = typeof AssetAccessOperation.Type;
32export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()(
33 "AssetWorkspaceContextNotFoundError",
34 {
35 resource: AssetResource,
36 },
37) {
38 override get message(): string {
39 return "Workspace context was not found.";
40 }
41}
42
43export class AssetWorkspaceContextResolutionError extends Schema.TaggedErrorClass<AssetWorkspaceContextResolutionError>()(
44 "AssetWorkspaceContextResolutionError",
45 {
46 resource: AssetResource,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return "Failed to resolve workspace context.";
52 }
53}
54
55export class AssetWorkspaceRootNormalizationError extends Schema.TaggedErrorClass<AssetWorkspaceRootNormalizationError>()(
56 "AssetWorkspaceRootNormalizationError",
57 {
58 resource: AssetResource,
59 cause: Schema.Defect(),
60 },
61) {
62 override get message(): string {
63 return "Failed to normalize the workspace root.";
64 }
65}
66
67export class AssetWorkspacePathValidationError extends Schema.TaggedErrorClass<AssetWorkspacePathValidationError>()(
68 "AssetWorkspacePathValidationError",
69 {
70 resource: AssetResource,
71 cause: Schema.Defect(),
72 },
73) {
74 override get message(): string {
75 return "Workspace file path must be relative to the project root.";
76 }
77}
78
79export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<AssetPreviewTypeValidationError>()(
80 "AssetPreviewTypeValidationError",
81 {
82 resource: AssetResource,
83 },
84) {
85 override get message(): string {
86 return "Only browser documents and images can be previewed.";
87 }
88}
89
90export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass<AssetWorkspaceAssetInspectionError>()(
91 "AssetWorkspaceAssetInspectionError",
92 {
93 resource: AssetResource,
94 cause: Schema.Defect(),
95 },
96) {
97 override get message(): string {
98 return "Failed to inspect the workspace asset.";
99 }
100}
101
102export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceAssetNotFoundError>()(
103 "AssetWorkspaceAssetNotFoundError",
104 {
105 resource: AssetResource,
106 },
107) {
108 override get message(): string {
109 return "Workspace asset was not found.";
110 }
111}
112
113export class AssetWorkspaceResolutionError extends Schema.TaggedErrorClass<AssetWorkspaceResolutionError>()(
114 "AssetWorkspaceResolutionError",
115 {
116 resource: AssetResource,
117 cause: Schema.Defect(),
118 },
119) {
120 override get message(): string {
121 return "Failed to resolve workspace.";
122 }
123}
47124
48export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(
49 "AssetAccessError",
125export class AssetAttachmentNotFoundError extends Schema.TaggedErrorClass<AssetAttachmentNotFoundError>()(
126 "AssetAttachmentNotFoundError",
50127 {
51 operation: AssetAccessOperation,
52128 resource: AssetResource,
53 message: TrimmedNonEmptyString,
54 cause: Schema.optional(Schema.Defect()),
55129 },
56) {}
130) {
131 override get message(): string {
132 return "Attachment was not found.";
133 }
134}
135
136export class AssetProjectFaviconResolutionError extends Schema.TaggedErrorClass<AssetProjectFaviconResolutionError>()(
137 "AssetProjectFaviconResolutionError",
138 {
139 resource: AssetResource,
140 cause: Schema.Defect(),
141 },
142) {
143 override get message(): string {
144 return "Failed to resolve project favicon.";
145 }
146}
147
148export class AssetProjectFaviconInspectionError extends Schema.TaggedErrorClass<AssetProjectFaviconInspectionError>()(
149 "AssetProjectFaviconInspectionError",
150 {
151 resource: AssetResource,
152 cause: Schema.Defect(),
153 },
154) {
155 override get message(): string {
156 return "Failed to inspect the project favicon.";
157 }
158}
159
160export class AssetProjectFaviconNotFoundError extends Schema.TaggedErrorClass<AssetProjectFaviconNotFoundError>()(
161 "AssetProjectFaviconNotFoundError",
162 {
163 resource: AssetResource,
164 },
165) {
166 override get message(): string {
167 return "Project favicon was not found.";
168 }
169}
170
171export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass<AssetSigningKeyLoadError>()(
172 "AssetSigningKeyLoadError",
173 {
174 resource: AssetResource,
175 cause: Schema.Defect(),
176 },
177) {
178 override get message(): string {
179 return "Failed to load the asset signing key.";
180 }
181}
182
183export const AssetAccessError = Schema.Union([
184 AssetWorkspaceContextNotFoundError,
185 AssetWorkspaceContextResolutionError,
186 AssetWorkspaceRootNormalizationError,
187 AssetWorkspacePathValidationError,
188 AssetPreviewTypeValidationError,
189 AssetWorkspaceAssetInspectionError,
190 AssetWorkspaceAssetNotFoundError,
191 AssetWorkspaceResolutionError,
192 AssetAttachmentNotFoundError,
193 AssetProjectFaviconResolutionError,
194 AssetProjectFaviconInspectionError,
195 AssetProjectFaviconNotFoundError,
196 AssetSigningKeyLoadError,
197]);
198export type AssetAccessError = typeof AssetAccessError.Type;