/** * Artifact storage for v0.4. * * KV layout: * key: `.` * value: UTF-8 JSON of the `Artifact` envelope below * * Bucket choice is the caller's concern — adapters open the KV handle at * startup (typically `sesh_artifacts__`) and pass it * in. The SDK never opens its own KV or Object Store. * * Raw bytes >1 MB on any `file`-variant part are auto-offloaded to the * provided Object Store, mirroring `messages.materializePart`. The on-disk * envelope holds an `obj://` URL in place of the raw bytes; `getArtifact` * rehydrates them on read. */ import type { KV } from "@nats-io/kv"; import type { ObjectStore } from "@nats-io/obj"; import { ulid } from "ulid"; import { MessageIDConflict } from "./errors.ts"; import { PART_INLINE_MAX_BYTES } from "./messages.ts"; import { getObject, objectKey, objectURL, parseObjectURL, putObject, } from "./objects.ts"; import type { ScopeKind, ScopeRef } from "./stream.ts"; import type { Part } from "./types.ts"; /** * Re-export the inline threshold under the `artifacts` namespace. The * authoritative constant lives in `messages.ts` — keeping a single source * of truth avoids drift between Messages and Artifacts. */ export { PART_INLINE_MAX_BYTES } from "./messages.ts"; /** * Envelope stored at `.`. `parts` follows the flat * `Part` shape from `types.ts` — same as Messages. After auto-offload, * each raw-bearing part is rewritten as a URL-bearing part. */ export interface Artifact { artifactId: string; taskId: string; parts: Part[]; name?: string; description?: string; metadata?: Record; createdAt: string; } /** * Caller-supplied draft. `artifactId` is optional — if omitted we mint a * fresh ULID. `createdAt` is always set by `putArtifact` (callers cannot * forge timestamps via this API). */ export interface ArtifactDraft { artifactId?: string; parts: Part[]; name?: string; description?: string; metadata?: Record; } export interface PutArtifactOpts { /** Override the auto-offload threshold (default: `PART_INLINE_MAX_BYTES`). */ inlineMaxBytes?: number; } /** * Write an Artifact envelope to KV. Any `raw`-bearing part whose payload * exceeds the inline threshold is offloaded to Object Store; the envelope * stores an `obj://` URL pointing at it. Returns the artifactId so callers * can immediately reference it (e.g. in a subsequent `artifactUpdate` * stream event) without re-deriving it. * * Throws `MessageIDConflict` (re-used here) on duplicate-key — the artifact * id collided with one already present. The CAS-create posture mirrors * `messages.appendMessage`; if an adapter needs the byte-equal-tolerant * variant, mirror `idempotentAppend` in a later slice. */ export async function putArtifact( kv: KV, os: ObjectStore, scope: ScopeRef, taskId: string, draft: ArtifactDraft, opts: PutArtifactOpts = {}, ): Promise { const artifactId = draft.artifactId ?? ulid(); const threshold = opts.inlineMaxBytes ?? PART_INLINE_MAX_BYTES; const parts: Part[] = []; for (const part of draft.parts) { parts.push(await offloadIfLarge(os, scope, taskId, part, threshold)); } const envelope: Artifact = { artifactId, taskId, parts, createdAt: new Date().toISOString(), }; if (draft.name !== undefined) envelope.name = draft.name; if (draft.description !== undefined) envelope.description = draft.description; if (draft.metadata !== undefined) envelope.metadata = draft.metadata; const key = artifactKey(taskId, artifactId); const bytes = new TextEncoder().encode(JSON.stringify(envelope, replacer)); try { await kv.create(key, bytes); } catch { // Surface as a conflict regardless of why create failed — get the // existing value so the caller can diff if they care. const existing = await kv.get(key); throw new MessageIDConflict( taskId, artifactId, existing?.value ?? new Uint8Array(), bytes, ); } return artifactId; } /** * Read an Artifact. Any `obj://` URL part whose scope matches the passed * Object Store is rehydrated back to a `raw`-bearing part. Other URL * schemes (http/https/file/etc.) pass through unchanged — the caller is * responsible for fetching them. * * Returns `null` if no envelope exists for the (taskId, artifactId) pair. */ export async function getArtifact( kv: KV, os: ObjectStore, taskId: string, artifactId: string, ): Promise { const entry = await kv.get(artifactKey(taskId, artifactId)); if (!entry) return null; if (entry.operation === "DEL" || entry.operation === "PURGE") return null; const raw = JSON.parse(new TextDecoder().decode(entry.value)) as Artifact; const parts: Part[] = []; for (const part of raw.parts) { parts.push(await rehydratePart(os, part)); } return { ...raw, parts }; } /** * Enumerate artifact IDs for a task. Iterates KV keys under the * `.*` prefix and yields the artifactId suffix for each. * * Returns `AsyncIterable` rather than `string[]` so callers can * stop early without paying memory cost for unread tail entries. Pagination * is intentionally absent — KV's filter iterator already supplies it. */ export function listArtifacts(kv: KV, taskId: string): AsyncIterable { const prefix = `${taskId}.`; return { async *[Symbol.asyncIterator]() { const iter = await kv.keys(`${taskId}.*`); for await (const key of iter) { if (key.startsWith(prefix)) yield key.slice(prefix.length); } }, }; } /** Compose the KV key for an Artifact: `.`. */ export function artifactKey(taskId: string, artifactId: string): string { return `${taskId}.${artifactId}`; } // ── internals ────────────────────────────────────────────────────────── async function offloadIfLarge( os: ObjectStore, scope: ScopeRef, taskId: string, part: Part, threshold: number, ): Promise { if (!part.raw) return part; if (part.raw.byteLength <= threshold) return part; if (!part.mediaType) { throw new Error("artifacts: raw Part requires mediaType to offload to Object Store"); } // Mint a per-part artifactId for the obj:// URL. The Artifact's own // artifactId names the envelope; this names the bytes blob. They're // distinct because one Artifact may hold N offloaded parts. const partArtifactId = ulid(); const key = objectKey({ taskId, artifactId: partArtifactId }); await putObject(os, key, part.raw); const url = objectURL({ scopeKind: scope.scopeKind, scopeId: scope.scopeId, taskId, artifactId: partArtifactId, }); const out: Part = { url, mediaType: part.mediaType }; if (part.filename !== undefined) out.filename = part.filename; if (part.metadata !== undefined) out.metadata = part.metadata; return out; } async function rehydratePart(os: ObjectStore, part: Part): Promise { // Inline raw — JSON.parse rehydrates Uint8Array as `number[]` (because // putArtifact's replacer encoded it as Array.from(...)). Restore the // Uint8Array typing so callers can treat .raw uniformly across inline // and offloaded cases. if (Array.isArray((part as { raw?: unknown }).raw)) { const rawArr = (part as unknown as { raw: number[] }).raw; const out: Part = { raw: Uint8Array.from(rawArr) }; if (part.mediaType !== undefined) out.mediaType = part.mediaType; if (part.filename !== undefined) out.filename = part.filename; if (part.metadata !== undefined) out.metadata = part.metadata; return out; } if (!part.url || !part.url.startsWith("obj://")) return part; let ref: { taskId: string; artifactId: string; scopeKind: ScopeKind }; try { ref = parseObjectURL(part.url); } catch { // Malformed obj:// URL — surface as-is rather than swallowing. return part; } const key = objectKey({ taskId: ref.taskId, artifactId: ref.artifactId }); const bytes = await getObject(os, key); if (!bytes) return part; const out: Part = { raw: bytes }; if (part.mediaType !== undefined) out.mediaType = part.mediaType; if (part.filename !== undefined) out.filename = part.filename; if (part.metadata !== undefined) out.metadata = part.metadata; return out; } // Uint8Array → array-of-bytes for stable JSON; symmetric with envelope.ts. function replacer(_key: string, value: unknown): unknown { if (value instanceof Uint8Array) return Array.from(value); return value; }