| import { |
| ARTIFACT_KINDS, |
| ARTIFACT_SOURCES, |
| ARTIFACT_STATUSES, |
| type ArtifactRecord, |
| type ArtifactBinaryReadFailureReason, |
| type ArtifactKind, |
| type ArtifactReadFailureReason, |
| type ArtifactSource, |
| type ArtifactStatus, |
| isArtifactTurnKey, |
| isCanonicalArtifactEntityId, |
| } from '@maka/core/artifacts'; |
| import { requireCount, requireExactRecord, requireRecord } from './codec.js'; |
| import { invalidProtocolFrame } from './errors.js'; |
| import { defineOperation } from './operation-spec.js'; |
| |
| export const ARTIFACT_PAGE_MAX_ITEMS = 128; |
| export const ARTIFACT_RESULT_MAX_BYTES = 48 * 1024; |
| export const ARTIFACT_PREVIEW_MAX_BYTES = 32 * 1024; |
| export const ARTIFACT_CURSOR_MAX_BYTES = 32; |
| export const ARTIFACT_NAME_MAX_BYTES = 512; |
| export const ARTIFACT_MIME_TYPE_MAX_BYTES = 512; |
| export const ARTIFACT_SUMMARY_MAX_BYTES = 8 * 1024; |
| |
| const QUERY_ERRORS = [ |
| 'host_not_ready', |
| 'host_draining', |
| 'operation_unavailable', |
| 'internal_failure', |
| 'invalid_request', |
| 'persistence_failed', |
| ] as const; |
| const DELETE_ERRORS = [...QUERY_ERRORS, 'not_found', 'operation_conflict'] as const; |
| const ARTIFACT_REQUIRED_FIELDS = [ |
| 'id', |
| 'sessionId', |
| 'turnId', |
| 'createdAt', |
| 'name', |
| 'kind', |
| 'sizeBytes', |
| 'status', |
| ] as const; |
| const ARTIFACT_FIELDS = new Set([...ARTIFACT_REQUIRED_FIELDS, 'mimeType', 'source', 'summary']); |
| |
| export type ArtifactRevision = `sha256:${string}`; |
| |
| export interface ArtifactProjection { |
| readonly id: string; |
| readonly sessionId: string; |
| readonly turnId: string; |
| readonly createdAt: number; |
| readonly name: string; |
| readonly kind: ArtifactKind; |
| readonly sizeBytes: number; |
| readonly mimeType?: string; |
| readonly source?: ArtifactSource; |
| readonly summary?: string; |
| readonly status: ArtifactStatus; |
| } |
| |
| export type ArtifactQueryInput = |
| | { readonly kind: 'list_start'; readonly sessionId: string } |
| | { |
| readonly kind: 'list_continue'; |
| readonly sessionId: string; |
| readonly revision: ArtifactRevision; |
| readonly cursor: string; |
| } |
| | { readonly kind: 'get'; readonly sessionId: string; readonly artifactId: string } |
| | { readonly kind: 'read_text'; readonly sessionId: string; readonly artifactId: string } |
| | { readonly kind: 'read_binary'; readonly sessionId: string; readonly artifactId: string }; |
| |
| export type ArtifactTextPreview = |
| | { readonly ok: true; readonly text: string } |
| | { readonly ok: false; readonly reason: ArtifactReadFailureReason }; |
| export type ArtifactBinaryPreview = |
| | { readonly ok: true; readonly base64: string; readonly mimeType: string } |
| | { readonly ok: false; readonly reason: ArtifactBinaryReadFailureReason }; |
| |
| export type ArtifactQueryResult = |
| | { |
| readonly kind: 'page'; |
| readonly sessionId: string; |
| readonly revision: ArtifactRevision; |
| readonly artifacts: readonly ArtifactProjection[]; |
| readonly nextCursor: string | null; |
| } |
| | { |
| readonly kind: 'revision_changed'; |
| readonly expected: ArtifactRevision; |
| readonly actual: ArtifactRevision; |
| } |
| | { |
| readonly kind: 'artifact'; |
| readonly sessionId: string; |
| readonly revision: ArtifactRevision; |
| readonly artifact: ArtifactProjection | null; |
| } |
| | { |
| readonly kind: 'text'; |
| readonly sessionId: string; |
| readonly artifactId: string; |
| readonly preview: ArtifactTextPreview; |
| } |
| | { |
| readonly kind: 'binary'; |
| readonly sessionId: string; |
| readonly artifactId: string; |
| readonly preview: ArtifactBinaryPreview; |
| }; |
| |
| export interface ArtifactDeleteInput { |
| readonly sessionId: string; |
| readonly artifactId: string; |
| } |
| |
| export interface ArtifactDeleteResult { |
| readonly kind: 'deleted'; |
| readonly artifact: ArtifactProjection; |
| } |
| |
| export const ARTIFACT_OPERATION_SPECS = { |
| 'artifact.query': defineOperation< |
| ArtifactQueryInput, |
| ArtifactQueryResult, |
| (typeof QUERY_ERRORS)[number] |
| >({ |
| mode: 'query', |
| availability: 'ready', |
| errors: QUERY_ERRORS, |
| decodeInput: decodeArtifactQueryInput, |
| decodeOutput: decodeArtifactQueryResult, |
| }), |
| 'artifact.delete': defineOperation< |
| ArtifactDeleteInput, |
| ArtifactDeleteResult, |
| (typeof DELETE_ERRORS)[number] |
| >({ |
| mode: 'command', |
| availability: 'ready', |
| errors: DELETE_ERRORS, |
| decodeInput: decodeArtifactDeleteInput, |
| decodeOutput: decodeArtifactDeleteResult, |
| }), |
| } as const; |
| |
| export function decodeArtifactQueryInput(value: unknown): ArtifactQueryInput { |
| const input = requireRecord(value, 'artifact query input'); |
| if (input.kind === 'list_start') { |
| const exact = requireExactRecord(input, 'artifact list start input', ['kind', 'sessionId']); |
| return { kind: 'list_start', sessionId: artifactEntityId(exact.sessionId, 'sessionId') }; |
| } |
| if (input.kind === 'list_continue') { |
| const exact = requireExactRecord(input, 'artifact list continuation input', [ |
| 'kind', |
| 'sessionId', |
| 'revision', |
| 'cursor', |
| ]); |
| return { |
| kind: 'list_continue', |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| revision: artifactRevision(exact.revision, 'artifact revision'), |
| cursor: boundedText(exact.cursor, 'artifact cursor', ARTIFACT_CURSOR_MAX_BYTES), |
| }; |
| } |
| if (input.kind === 'get' || input.kind === 'read_text' || input.kind === 'read_binary') { |
| const exact = requireExactRecord(input, 'artifact item query input', [ |
| 'kind', |
| 'sessionId', |
| 'artifactId', |
| ]); |
| return { |
| kind: input.kind, |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| artifactId: artifactEntityId(exact.artifactId, 'artifactId'), |
| }; |
| } |
| throw invalidProtocolFrame('Invalid artifact query kind'); |
| } |
| |
| export function decodeArtifactDeleteInput(value: unknown): ArtifactDeleteInput { |
| const input = requireExactRecord(value, 'artifact delete input', ['sessionId', 'artifactId']); |
| return { |
| sessionId: artifactEntityId(input.sessionId, 'sessionId'), |
| artifactId: artifactEntityId(input.artifactId, 'artifactId'), |
| }; |
| } |
| |
| export function decodeArtifactQueryResult(value: unknown): ArtifactQueryResult { |
| const result = requireRecord(value, 'artifact query result'); |
| let decoded: ArtifactQueryResult; |
| if (result.kind === 'revision_changed') { |
| const exact = requireExactRecord(result, 'artifact revision changed result', [ |
| 'kind', |
| 'expected', |
| 'actual', |
| ]); |
| decoded = { |
| kind: 'revision_changed', |
| expected: artifactRevision(exact.expected, 'expected artifact revision'), |
| actual: artifactRevision(exact.actual, 'actual artifact revision'), |
| }; |
| } else if (result.kind === 'artifact') { |
| const exact = requireExactRecord(result, 'artifact item result', [ |
| 'kind', |
| 'sessionId', |
| 'revision', |
| 'artifact', |
| ]); |
| decoded = { |
| kind: 'artifact', |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| revision: artifactRevision(exact.revision, 'artifact revision'), |
| artifact: exact.artifact === null ? null : decodeArtifactProjection(exact.artifact), |
| }; |
| } else if (result.kind === 'page') { |
| const exact = requireExactRecord(result, 'artifact page result', [ |
| 'kind', |
| 'sessionId', |
| 'revision', |
| 'artifacts', |
| 'nextCursor', |
| ]); |
| if (!Array.isArray(exact.artifacts) || exact.artifacts.length > ARTIFACT_PAGE_MAX_ITEMS) { |
| throw invalidProtocolFrame('Artifact page exceeds item limit'); |
| } |
| decoded = { |
| kind: 'page', |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| revision: artifactRevision(exact.revision, 'artifact revision'), |
| artifacts: exact.artifacts.map(decodeArtifactProjection), |
| nextCursor: |
| exact.nextCursor === null |
| ? null |
| : boundedText(exact.nextCursor, 'artifact next cursor', ARTIFACT_CURSOR_MAX_BYTES), |
| }; |
| } else if (result.kind === 'text') { |
| const exact = requireExactRecord(result, 'artifact text result', [ |
| 'kind', |
| 'sessionId', |
| 'artifactId', |
| 'preview', |
| ]); |
| decoded = { |
| kind: 'text', |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| artifactId: artifactEntityId(exact.artifactId, 'artifactId'), |
| preview: decodeTextPreview(exact.preview), |
| }; |
| } else if (result.kind === 'binary') { |
| const exact = requireExactRecord(result, 'artifact binary result', [ |
| 'kind', |
| 'sessionId', |
| 'artifactId', |
| 'preview', |
| ]); |
| decoded = { |
| kind: 'binary', |
| sessionId: artifactEntityId(exact.sessionId, 'sessionId'), |
| artifactId: artifactEntityId(exact.artifactId, 'artifactId'), |
| preview: decodeBinaryPreview(exact.preview), |
| }; |
| } else { |
| throw invalidProtocolFrame('Invalid artifact query result kind'); |
| } |
| assertResultSize(decoded); |
| return decoded; |
| } |
| |
| export const encodeArtifactQueryResult = decodeArtifactQueryResult; |
| |
| export function decodeArtifactDeleteResult(value: unknown): ArtifactDeleteResult { |
| const result = requireExactRecord(value, 'artifact delete result', ['kind', 'artifact']); |
| if (result.kind !== 'deleted') throw invalidProtocolFrame('Invalid artifact delete result kind'); |
| const decoded = { kind: 'deleted' as const, artifact: decodeArtifactProjection(result.artifact) }; |
| assertResultSize(decoded); |
| return decoded; |
| } |
| |
| export const encodeArtifactDeleteResult = decodeArtifactDeleteResult; |
| |
| export function encodeArtifactProjection(record: ArtifactRecord): ArtifactProjection { |
| return { |
| id: record.id, |
| sessionId: record.sessionId, |
| turnId: record.turnId, |
| createdAt: record.createdAt, |
| name: projectArtifactText(record.name, ARTIFACT_NAME_MAX_BYTES), |
| kind: record.kind, |
| sizeBytes: record.sizeBytes, |
| ...(record.mimeType === undefined |
| ? {} |
| : { mimeType: projectArtifactText(record.mimeType, ARTIFACT_MIME_TYPE_MAX_BYTES) }), |
| ...(record.source === undefined ? {} : { source: record.source }), |
| ...(record.summary === undefined |
| ? {} |
| : { summary: projectArtifactText(record.summary, ARTIFACT_SUMMARY_MAX_BYTES) }), |
| status: record.status, |
| }; |
| } |
| |
| function decodeArtifactProjection(value: unknown): ArtifactProjection { |
| const record = requireRecord(value, 'artifact projection'); |
| if (Object.keys(record).some((key) => !ARTIFACT_FIELDS.has(key))) { |
| throw invalidProtocolFrame('Unknown artifact projection field'); |
| } |
| if (ARTIFACT_REQUIRED_FIELDS.some((field) => !Object.hasOwn(record, field))) { |
| throw invalidProtocolFrame('Invalid artifact projection fields'); |
| } |
| return { |
| id: artifactEntityId(record.id, 'artifact id'), |
| sessionId: artifactEntityId(record.sessionId, 'artifact sessionId'), |
| turnId: artifactTurnKey(record.turnId), |
| createdAt: requireCount(record.createdAt, 'artifact createdAt'), |
| name: boundedText(record.name, 'artifact name', ARTIFACT_NAME_MAX_BYTES), |
| kind: artifactKind(record.kind), |
| sizeBytes: requireCount(record.sizeBytes, 'artifact sizeBytes'), |
| status: artifactStatus(record.status), |
| ...(Object.hasOwn(record, 'mimeType') |
| ? { |
| mimeType: boundedText(record.mimeType, 'artifact mimeType', ARTIFACT_MIME_TYPE_MAX_BYTES), |
| } |
| : {}), |
| ...(Object.hasOwn(record, 'source') ? { source: artifactSource(record.source) } : {}), |
| ...(Object.hasOwn(record, 'summary') |
| ? { summary: boundedText(record.summary, 'artifact summary', ARTIFACT_SUMMARY_MAX_BYTES) } |
| : {}), |
| }; |
| } |
| |
| function projectArtifactText(value: string, maxBytes: number): string { |
| let bytes = 0; |
| let projected = ''; |
| for (const codePoint of value) { |
| const scalar = codePoint.codePointAt(0)!; |
| const canonical = scalar <= 0x1f || scalar === 0x7f ? '\ufffd' : codePoint; |
| const width = Buffer.byteLength(canonical, 'utf8'); |
| if (bytes + width > maxBytes) break; |
| projected += canonical; |
| bytes += width; |
| } |
| return projected || 'artifact'; |
| } |
| |
| function decodeTextPreview(value: unknown): ArtifactTextPreview { |
| const preview = requireRecord(value, 'artifact text preview'); |
| if (preview.ok === true) { |
| const exact = requireExactRecord(preview, 'artifact text preview', ['ok', 'text']); |
| return { |
| ok: true, |
| text: boundedText(exact.text, 'artifact text', ARTIFACT_PREVIEW_MAX_BYTES, true), |
| }; |
| } |
| const exact = requireExactRecord(preview, 'artifact text unavailable', ['ok', 'reason']); |
| if (exact.ok !== false) throw invalidProtocolFrame('Invalid artifact text preview outcome'); |
| return { ok: false, reason: readFailureReason(exact.reason) }; |
| } |
| |
| function decodeBinaryPreview(value: unknown): ArtifactBinaryPreview { |
| const preview = requireRecord(value, 'artifact binary preview'); |
| if (preview.ok === true) { |
| const exact = requireExactRecord(preview, 'artifact binary preview', [ |
| 'ok', |
| 'base64', |
| 'mimeType', |
| ]); |
| const base64 = boundedText(exact.base64, 'artifact binary base64', base64MaxBytes(), true); |
| if ( |
| !isCanonicalBase64(base64) || |
| Buffer.from(base64, 'base64').byteLength > ARTIFACT_PREVIEW_MAX_BYTES |
| ) { |
| throw invalidProtocolFrame('Invalid artifact binary base64'); |
| } |
| return { |
| ok: true, |
| base64, |
| mimeType: boundedText( |
| exact.mimeType, |
| 'artifact binary mimeType', |
| ARTIFACT_MIME_TYPE_MAX_BYTES, |
| ), |
| }; |
| } |
| const exact = requireExactRecord(preview, 'artifact binary unavailable', ['ok', 'reason']); |
| if (exact.ok !== false) throw invalidProtocolFrame('Invalid artifact binary preview outcome'); |
| return { ok: false, reason: binaryReadFailureReason(exact.reason) }; |
| } |
| |
| function boundedText(value: unknown, label: string, maxBytes: number, allowEmpty = false): string { |
| if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) { |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| } |
| if (Buffer.byteLength(value, 'utf8') > maxBytes) { |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| } |
| return value; |
| } |
| |
| function artifactEntityId(value: unknown, label: string): string { |
| if (!isCanonicalArtifactEntityId(value)) throw invalidProtocolFrame(`Invalid ${label}`); |
| return value; |
| } |
| |
| function artifactTurnKey(value: unknown): string { |
| if (!isArtifactTurnKey(value)) throw invalidProtocolFrame('Invalid artifact turnId'); |
| return value; |
| } |
| |
| function artifactRevision(value: unknown, label: string): ArtifactRevision { |
| if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { |
| throw invalidProtocolFrame(`Invalid ${label}`); |
| } |
| return value as ArtifactRevision; |
| } |
| |
| function artifactKind(value: unknown): ArtifactKind { |
| if (typeof value !== 'string' || !ARTIFACT_KINDS.includes(value as ArtifactKind)) { |
| throw invalidProtocolFrame('Invalid artifact kind'); |
| } |
| return value as ArtifactKind; |
| } |
| |
| function artifactSource(value: unknown): ArtifactSource { |
| if (typeof value !== 'string' || !ARTIFACT_SOURCES.includes(value as ArtifactSource)) { |
| throw invalidProtocolFrame('Invalid artifact source'); |
| } |
| return value as ArtifactSource; |
| } |
| |
| function artifactStatus(value: unknown): ArtifactStatus { |
| if (typeof value !== 'string' || !ARTIFACT_STATUSES.includes(value as ArtifactStatus)) { |
| throw invalidProtocolFrame('Invalid artifact status'); |
| } |
| return value as ArtifactStatus; |
| } |
| |
| function readFailureReason(value: unknown): ArtifactReadFailureReason { |
| if ( |
| value === 'not_found' || |
| value === 'too_large' || |
| value === 'read_failed' || |
| value === 'not_allowed' || |
| value === 'deleted' |
| ) { |
| return value; |
| } |
| throw invalidProtocolFrame('Invalid artifact read unavailable reason'); |
| } |
| |
| function binaryReadFailureReason(value: unknown): ArtifactBinaryReadFailureReason { |
| return value === 'unsupported_mime' ? value : readFailureReason(value); |
| } |
| |
| function base64MaxBytes(): number { |
| return Math.ceil(ARTIFACT_PREVIEW_MAX_BYTES / 3) * 4; |
| } |
| |
| function isCanonicalBase64(value: string): boolean { |
| return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); |
| } |
| |
| function assertResultSize(value: unknown): void { |
| if (Buffer.byteLength(JSON.stringify(value), 'utf8') > ARTIFACT_RESULT_MAX_BYTES) { |
| throw invalidProtocolFrame('Artifact result exceeds byte limit'); |
| } |
| } |