| 1 | import * as Schema from "effect/Schema"; |
| 2 | |
| 3 | import { NonNegativeInt, ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; |
| 4 | import { |
| 5 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 6 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 7 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, |
| 8 | } from "./chatAttachment.ts"; |
| 9 | import { ProjectFaviconPath } from "./project.ts"; |
| 10 | import { ToolActivityNativeAppReference } from "./providerRuntime.ts"; |
| 11 | |
| 12 | const ASSET_PATH_MAX_LENGTH = 1024; |
| 13 | |
| 14 | export const AssetResource = Schema.Union([ |
| 15 | Schema.TaggedStruct("workspace-file", { |
| 16 | threadId: ThreadId, |
| 17 | path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 18 | }), |
| 19 | // One file served in place from anywhere the environment host can read: |
| 20 | // images, videos, HTML, and PDF. An absolute path may lie outside the |
| 21 | // workspace; a relative one resolves against the thread's workspace. |
| 22 | Schema.TaggedStruct("media-file", { |
| 23 | threadId: ThreadId, |
| 24 | path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 25 | }), |
| 26 | // A workspace file named by a draft that has no thread yet. The draft names |
| 27 | // its workspace root explicitly instead of resolving one from a thread. |
| 28 | Schema.TaggedStruct("draft-workspace-file", { |
| 29 | cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 30 | path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 31 | }), |
| 32 | Schema.TaggedStruct("attachment", { |
| 33 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), |
| 34 | /** Display name and mime from the `ChatAttachment` the caller holds. The |
| 35 | server bakes both into the signed URL so downloads carry the real |
| 36 | filename and Content-Type. Absent on older clients, which fall back to |
| 37 | an octet-stream download without a filename. */ |
| 38 | fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), |
| 39 | mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))), |
| 40 | /** Generic attachments download by default. Document viewers opt into an |
| 41 | inline response after deciding the file type is safe to preview. */ |
| 42 | disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])), |
| 43 | /** Render an MCP App snapshot with the host theme bootstrap. */ |
| 44 | renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), |
| 45 | }), |
| 46 | // An image a tool returned inline, such as a device screenshot, by its order |
| 47 | // in the stored output. The timeline never carries these bytes. |
| 48 | Schema.TaggedStruct("tool-output-image", { |
| 49 | threadId: ThreadId, |
| 50 | itemId: TurnItemId, |
| 51 | index: NonNegativeInt, |
| 52 | }), |
| 53 | Schema.TaggedStruct("project-favicon", { |
| 54 | cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 55 | // A cache-key hint only. The server reads the authoritative path from the |
| 56 | // project projection before it issues the signed URL. |
| 57 | path: Schema.optional(ProjectFaviconPath), |
| 58 | }), |
| 59 | Schema.TaggedStruct("native-app-icon", { |
| 60 | app: ToolActivityNativeAppReference, |
| 61 | }), |
| 62 | // An upload a pull request body points at on GitHub. A private repository serves these only |
| 63 | // to a request that carries a credential, which the client has none of, so the server fetches |
| 64 | // them with the `gh` credential the repository at `cwd` authenticates with. |
| 65 | Schema.TaggedStruct("github-media", { |
| 66 | cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 67 | url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), |
| 68 | }), |
| 69 | ]); |
| 70 | export type AssetResource = typeof AssetResource.Type; |
| 71 | |
| 72 | export const AssetCreateUrlInput = Schema.Struct({ |
| 73 | resource: AssetResource, |
| 74 | }); |
| 75 | export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; |
| 76 | |
| 77 | export const AssetImageDimensions = Schema.Struct({ |
| 78 | width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), |
| 79 | height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), |
| 80 | }); |
| 81 | export type AssetImageDimensions = typeof AssetImageDimensions.Type; |
| 82 | |
| 83 | export const AssetCreateUrlResult = Schema.Struct({ |
| 84 | relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), |
| 85 | expiresAt: Schema.Number, |
| 86 | sourcePath: Schema.optional( |
| 87 | TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |
| 88 | ), |
| 89 | /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */ |
| 90 | imageDimensions: Schema.optional(AssetImageDimensions), |
| 91 | }); |
| 92 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; |
| 93 | |
| 94 | export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; |
| 95 | |
| 96 | const ImageAttachmentCreateUploadUrlInput = Schema.Struct({ |
| 97 | type: Schema.optionalKey(Schema.Literal("image")), |
| 98 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), |
| 99 | mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES), |
| 100 | sizeBytes: NonNegativeInt.check( |
| 101 | Schema.isGreaterThanOrEqualTo(1), |
| 102 | Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), |
| 103 | ), |
| 104 | }); |
| 105 | |
| 106 | const FileAttachmentCreateUploadUrlInput = Schema.Struct({ |
| 107 | type: Schema.Literal("file"), |
| 108 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), |
| 109 | mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), |
| 110 | sizeBytes: NonNegativeInt.check( |
| 111 | Schema.isGreaterThanOrEqualTo(1), |
| 112 | Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), |
| 113 | ), |
| 114 | }); |
| 115 | |
| 116 | export const AttachmentCreateUploadUrlInput = Schema.Union([ |
| 117 | ImageAttachmentCreateUploadUrlInput, |
| 118 | FileAttachmentCreateUploadUrlInput, |
| 119 | ]); |
| 120 | export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; |
| 121 | |
| 122 | export const AttachmentCreateUploadUrlResult = Schema.Struct({ |
| 123 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), |
| 124 | relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), |
| 125 | expiresAt: Schema.Number, |
| 126 | }); |
| 127 | export type AttachmentCreateUploadUrlResult = typeof AttachmentCreateUploadUrlResult.Type; |
| 128 | |
| 129 | export const AttachmentDeleteInput = Schema.Struct({ |
| 130 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), |
| 131 | }); |
| 132 | export type AttachmentDeleteInput = typeof AttachmentDeleteInput.Type; |
| 133 | |
| 134 | export class AttachmentUploadSigningKeyError extends Schema.TaggedError<AttachmentUploadSigningKeyError>()( |
| 135 | "AttachmentUploadSigningKeyError", |
| 136 | { |
| 137 | cause: Schema.Defect(), |
| 138 | }, |
| 139 | ) { |
| 140 | override get message(): string { |
| 141 | return "Failed to load the attachment upload signing key."; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | export class AssetWorkspaceContextNotFoundError extends Schema.TaggedError<AssetWorkspaceContextNotFoundError>()( |
| 146 | "AssetWorkspaceContextNotFoundError", |
| 147 | { |
| 148 | resource: AssetResource, |
| 149 | }, |
| 150 | ) { |
| 151 | override get message(): string { |
| 152 | return "Workspace context was not found."; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | export class AssetWorkspaceContextResolutionError extends Schema.TaggedError<AssetWorkspaceContextResolutionError>()( |
| 157 | "AssetWorkspaceContextResolutionError", |
| 158 | { |
| 159 | resource: AssetResource, |
| 160 | cause: Schema.Defect(), |
| 161 | }, |
| 162 | ) { |
| 163 | override get message(): string { |
| 164 | return "Failed to resolve workspace context."; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | export class AssetWorkspaceRootNormalizationError extends Schema.TaggedError<AssetWorkspaceRootNormalizationError>()( |
| 169 | "AssetWorkspaceRootNormalizationError", |
| 170 | { |
| 171 | resource: AssetResource, |
| 172 | cause: Schema.Defect(), |
| 173 | }, |
| 174 | ) { |
| 175 | override get message(): string { |
| 176 | return "Failed to normalize the workspace root."; |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | export class AssetWorkspacePathValidationError extends Schema.TaggedError<AssetWorkspacePathValidationError>()( |
| 181 | "AssetWorkspacePathValidationError", |
| 182 | { |
| 183 | resource: AssetResource, |
| 184 | cause: Schema.Defect(), |
| 185 | }, |
| 186 | ) { |
| 187 | override get message(): string { |
| 188 | return "Workspace file path must be relative to the project root."; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | export class AssetPreviewTypeValidationError extends Schema.TaggedError<AssetPreviewTypeValidationError>()( |
| 193 | "AssetPreviewTypeValidationError", |
| 194 | { |
| 195 | resource: AssetResource, |
| 196 | }, |
| 197 | ) { |
| 198 | override get message(): string { |
| 199 | // Draft resources serve absolute paths through the same host-media |
| 200 | // validation as media files, so they share its message. |
| 201 | return this.resource._tag === "media-file" || this.resource._tag === "draft-workspace-file" |
| 202 | ? "Only images, videos, audio, HTML, and PDF files can be previewed." |
| 203 | : "Only browser documents and images can be previewed."; |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | export class AssetWorkspaceAssetInspectionError extends Schema.TaggedError<AssetWorkspaceAssetInspectionError>()( |
| 208 | "AssetWorkspaceAssetInspectionError", |
| 209 | { |
| 210 | resource: AssetResource, |
| 211 | cause: Schema.Defect(), |
| 212 | }, |
| 213 | ) { |
| 214 | override get message(): string { |
| 215 | return this.resource._tag === "media-file" |
| 216 | ? "Failed to inspect the media file." |
| 217 | : "Failed to inspect the workspace asset."; |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedError<AssetWorkspaceAssetNotFoundError>()( |
| 222 | "AssetWorkspaceAssetNotFoundError", |
| 223 | { |
| 224 | resource: AssetResource, |
| 225 | }, |
| 226 | ) { |
| 227 | override get message(): string { |
| 228 | return this.resource._tag === "media-file" |
| 229 | ? "Media file was not found." |
| 230 | : this.resource._tag === "tool-output-image" |
| 231 | ? "Tool output image was not found." |
| 232 | : "Workspace asset was not found."; |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | export class AssetWorkspaceResolutionError extends Schema.TaggedError<AssetWorkspaceResolutionError>()( |
| 237 | "AssetWorkspaceResolutionError", |
| 238 | { |
| 239 | resource: AssetResource, |
| 240 | cause: Schema.Defect(), |
| 241 | }, |
| 242 | ) { |
| 243 | override get message(): string { |
| 244 | return "Failed to resolve workspace."; |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | export class AssetAttachmentNotFoundError extends Schema.TaggedError<AssetAttachmentNotFoundError>()( |
| 249 | "AssetAttachmentNotFoundError", |
| 250 | { |
| 251 | resource: AssetResource, |
| 252 | }, |
| 253 | ) { |
| 254 | override get message(): string { |
| 255 | return "Attachment was not found."; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | export class AssetProjectFaviconResolutionError extends Schema.TaggedError<AssetProjectFaviconResolutionError>()( |
| 260 | "AssetProjectFaviconResolutionError", |
| 261 | { |
| 262 | resource: AssetResource, |
| 263 | cause: Schema.Defect(), |
| 264 | }, |
| 265 | ) { |
| 266 | override get message(): string { |
| 267 | return "Failed to resolve project favicon."; |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | export class AssetProjectFaviconInspectionError extends Schema.TaggedError<AssetProjectFaviconInspectionError>()( |
| 272 | "AssetProjectFaviconInspectionError", |
| 273 | { |
| 274 | resource: AssetResource, |
| 275 | cause: Schema.Defect(), |
| 276 | }, |
| 277 | ) { |
| 278 | override get message(): string { |
| 279 | return "Failed to inspect the project favicon."; |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | export class AssetProjectFaviconNotFoundError extends Schema.TaggedError<AssetProjectFaviconNotFoundError>()( |
| 284 | "AssetProjectFaviconNotFoundError", |
| 285 | { |
| 286 | resource: AssetResource, |
| 287 | }, |
| 288 | ) { |
| 289 | override get message(): string { |
| 290 | return "Project favicon was not found."; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | export class AssetSigningKeyLoadError extends Schema.TaggedError<AssetSigningKeyLoadError>()( |
| 295 | "AssetSigningKeyLoadError", |
| 296 | { |
| 297 | resource: AssetResource, |
| 298 | cause: Schema.Defect(), |
| 299 | }, |
| 300 | ) { |
| 301 | override get message(): string { |
| 302 | return "Failed to load the asset signing key."; |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | export class AssetGitHubMediaUrlValidationError extends Schema.TaggedError<AssetGitHubMediaUrlValidationError>()( |
| 307 | "AssetGitHubMediaUrlValidationError", |
| 308 | {}, |
| 309 | ) { |
| 310 | override get message(): string { |
| 311 | return "Only media hosted by GitHub can be fetched with a GitHub credential."; |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | export const AssetAccessError = Schema.Union([ |
| 316 | AssetWorkspaceContextNotFoundError, |
| 317 | AssetWorkspaceContextResolutionError, |
| 318 | AssetWorkspaceRootNormalizationError, |
| 319 | AssetWorkspacePathValidationError, |
| 320 | AssetPreviewTypeValidationError, |
| 321 | AssetWorkspaceAssetInspectionError, |
| 322 | AssetWorkspaceAssetNotFoundError, |
| 323 | AssetWorkspaceResolutionError, |
| 324 | AssetAttachmentNotFoundError, |
| 325 | AssetProjectFaviconResolutionError, |
| 326 | AssetProjectFaviconInspectionError, |
| 327 | AssetProjectFaviconNotFoundError, |
| 328 | AssetGitHubMediaUrlValidationError, |
| 329 | AssetSigningKeyLoadError, |
| 330 | ]); |
| 331 | export type AssetAccessError = typeof AssetAccessError.Type; |