blob: d91f4d91913096108269d89cb44c869060e794d4 [file]
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');
}
}