@tanstack/ai-persistence 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/esm/blob-range.d.ts +51 -0
  2. package/dist/esm/blob-range.js +84 -0
  3. package/dist/esm/blob-range.js.map +1 -0
  4. package/dist/esm/capabilities.d.ts +5 -0
  5. package/dist/esm/capabilities.js +16 -0
  6. package/dist/esm/capabilities.js.map +1 -0
  7. package/dist/esm/index.d.ts +13 -0
  8. package/dist/esm/index.js +9 -0
  9. package/dist/esm/memory.d.ts +19 -0
  10. package/dist/esm/memory.js +319 -0
  11. package/dist/esm/memory.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +252 -0
  13. package/dist/esm/middleware.js +872 -0
  14. package/dist/esm/middleware.js.map +1 -0
  15. package/dist/esm/reconstruct-generation.d.ts +129 -0
  16. package/dist/esm/reconstruct-generation.js +148 -0
  17. package/dist/esm/reconstruct-generation.js.map +1 -0
  18. package/dist/esm/reconstruct.d.ts +79 -0
  19. package/dist/esm/reconstruct.js +75 -0
  20. package/dist/esm/reconstruct.js.map +1 -0
  21. package/dist/esm/retrieve.d.ts +40 -0
  22. package/dist/esm/retrieve.js +54 -0
  23. package/dist/esm/retrieve.js.map +1 -0
  24. package/dist/esm/testkit/conformance.d.ts +33 -0
  25. package/dist/esm/testkit/conformance.js +997 -0
  26. package/dist/esm/testkit/conformance.js.map +1 -0
  27. package/dist/esm/types.d.ts +554 -0
  28. package/dist/esm/types.js +103 -0
  29. package/dist/esm/types.js.map +1 -0
  30. package/package.json +71 -0
  31. package/skills/ai-persistence/SKILL.md +218 -0
  32. package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
  33. package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
  34. package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
  35. package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
  36. package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
  37. package/skills/ai-persistence/server/SKILL.md +210 -0
  38. package/skills/ai-persistence/stores/SKILL.md +485 -0
  39. package/src/blob-range.ts +101 -0
  40. package/src/capabilities.ts +18 -0
  41. package/src/index.ts +114 -0
  42. package/src/memory.ts +491 -0
  43. package/src/middleware.ts +1795 -0
  44. package/src/reconstruct-generation.ts +244 -0
  45. package/src/reconstruct.ts +149 -0
  46. package/src/retrieve.ts +77 -0
  47. package/src/testkit/conformance.ts +1288 -0
  48. package/src/types.ts +878 -0
@@ -0,0 +1,1795 @@
1
+ import {
2
+ defineChatMiddleware,
3
+ getDetachableRun,
4
+ wasCancelRequested,
5
+ } from '@tanstack/ai'
6
+ import { providePendingTurn } from '@tanstack/ai/adapter-internals'
7
+ import { base64ToUint8Array } from '@tanstack/ai-utils'
8
+ import {
9
+ InterruptsCapability,
10
+ PersistenceCapability,
11
+ provideInterrupts,
12
+ providePersistence,
13
+ } from './capabilities'
14
+ import {
15
+ validateChatPersistenceStores,
16
+ validateGenerationPersistenceStores,
17
+ } from './types'
18
+ import type {
19
+ AbortInfo,
20
+ ChatMiddleware,
21
+ ChatMiddlewareConfig,
22
+ ChatMiddlewareContext,
23
+ ChatResumeToolState,
24
+ ErrorInfo,
25
+ FinishInfo,
26
+ GenerationAbortInfo,
27
+ GenerationErrorInfo,
28
+ GenerationFinishInfo,
29
+ GenerationMiddleware,
30
+ GenerationMiddlewareContext,
31
+ ModelMessage,
32
+ PersistedArtifactActivity,
33
+ PersistedArtifactRef,
34
+ PersistedArtifactRole,
35
+ RunAgentResumeItem,
36
+ StreamChunk,
37
+ ToolApprovalResolution,
38
+ TokenUsage,
39
+ } from '@tanstack/ai'
40
+ import type {
41
+ AIPersistence,
42
+ AIPersistenceStores,
43
+ ArtifactRecord,
44
+ BlobBody,
45
+ ChatTranscriptStores,
46
+ InterruptRecord,
47
+ RunStore,
48
+ } from './types'
49
+ import { artifactBlobKey } from './retrieve'
50
+
51
+ /**
52
+ * How generated media is turned into durable artifacts: which pieces of a
53
+ * result become artifacts, what they are named, where their bytes land, and how
54
+ * the bytes are fetched when the provider returns a URL rather than inline data.
55
+ *
56
+ * Consumed by {@link withGenerationPersistence} through
57
+ * {@link WithGenerationPersistenceOptions}. Chat persistence has no artifacts —
58
+ * its options are {@link WithPersistenceOptions}.
59
+ */
60
+ export interface ArtifactPersistenceOptions {
61
+ extractArtifacts?: (
62
+ input: GenerationArtifactExtractionInput,
63
+ ) =>
64
+ | Array<GenerationArtifactDescriptor | PersistedArtifactRef>
65
+ | Promise<Array<GenerationArtifactDescriptor | PersistedArtifactRef>>
66
+ nameArtifact?: (input: GenerationArtifactNameInput) => string
67
+ /**
68
+ * Map a freshly-persisted artifact ref to the durable app-origin URL that
69
+ * serves its bytes (your `GET` route around `retrieveArtifact` /
70
+ * `retrieveBlob`). The returned URL is stamped onto `ref.url` and written into
71
+ * the result's media field, so both the live and the restored result render
72
+ * durable media from your own origin instead of the provider's expiring link.
73
+ * Return `undefined` to leave a ref without a durable URL.
74
+ */
75
+ artifactUrl?: (ref: PersistedArtifactRef) => string | undefined
76
+ /**
77
+ * Choose the blob-store key each artifact's bytes are written under, so
78
+ * generated media can land in your own folder structure rather than the
79
+ * default `artifacts/<runId>/<artifactId>`.
80
+ *
81
+ * ```ts
82
+ * storageKey: ({ runId, artifactId, mimeType }) =>
83
+ * `video/${videoId}/frames/${runId}-${artifactId}.png`
84
+ * ```
85
+ *
86
+ * Server-side only, and deliberately so: a key supplied by the browser would
87
+ * be a path-traversal and cross-tenant-write vector.
88
+ *
89
+ * The resolved key is recorded on `ArtifactRecord.blobKey`, because once the
90
+ * path is arbitrary a reader can no longer recompute it. Returning a
91
+ * non-unique key overwrites — include `artifactId` (or something equally
92
+ * unique) unless you intend that.
93
+ */
94
+ storageKey?: (input: {
95
+ artifactId: string
96
+ runId: string
97
+ threadId: string
98
+ role: PersistedArtifactRole
99
+ activity: PersistedArtifactActivity
100
+ path: string
101
+ mimeType: string
102
+ name: string
103
+ }) => string
104
+ /**
105
+ * Opt in to fetching prompt media referenced by URL (`role: 'input'`).
106
+ *
107
+ * Off by default, and deliberately expressed as a predicate rather than a
108
+ * boolean: input URLs come from the caller, so fetching them server-side
109
+ * turns your server into a proxy for whatever the caller names — cloud
110
+ * metadata endpoints, `localhost` admin services, anything your network can
111
+ * reach. The bytes are also redundant in the common case, since the client
112
+ * already had the media it referenced.
113
+ *
114
+ * Enable this only when you genuinely need a durable copy of caller-supplied
115
+ * media (a "paste an image URL" input box, say), and validate the target:
116
+ *
117
+ * ```ts
118
+ * allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com')
119
+ * ```
120
+ *
121
+ * Requests are additionally forced through the same baseline checks every
122
+ * artifact fetch gets (http/https only, timeout, size cap), plus — because
123
+ * the target is untrusted — a loopback/private/link-local host block and
124
+ * `redirect: 'manual'` so a 302 cannot hop to an internal address. Those are
125
+ * a backstop, not a substitute for a narrow predicate: a hostname that
126
+ * resolves to a private address still passes a literal-IP check.
127
+ */
128
+ allowInputUrl?: (input: {
129
+ url: URL
130
+ descriptor: GenerationArtifactDescriptor
131
+ }) => boolean | Promise<boolean>
132
+ /** Abort an artifact fetch after this many ms. Default 30_000. */
133
+ artifactFetchTimeoutMs?: number
134
+ /**
135
+ * Refuse an artifact body larger than this many bytes. Default 1 GiB.
136
+ *
137
+ * This is a bound on TRANSFER, not on memory: the URL path streams into the
138
+ * blob store and never buffers, so a 1 GiB artifact costs a streaming store
139
+ * (R2, S3, filesystem) flat memory. What the cap buys is a ceiling on what a
140
+ * broken or hostile origin can make you pull and store — `content-length` is
141
+ * advisory, so without it an artifact fetch is an unbounded transfer billed
142
+ * to you.
143
+ *
144
+ * Pass `false` to remove the ceiling entirely. That also removes the
145
+ * cap-enforcing `TransformStream` wrapper, so the fetched body reaches your
146
+ * store exactly as `fetch` produced it — on workerd that means it keeps its
147
+ * native declared length and `R2Bucket.put` can single-shot it with no hint,
148
+ * no multipart, and nothing buffered. Do that when you trust the origins you
149
+ * fetch from (your provider's CDN); keep the cap when `allowInputUrl` lets
150
+ * callers name the URL.
151
+ */
152
+ maxArtifactBytes?: number | false
153
+ /**
154
+ * `fetch` used to download artifact bytes. Defaults to the global. Inject to
155
+ * route downloads through a proxy or an egress-restricted agent — the most
156
+ * robust SSRF control available here, since it can resolve and check the
157
+ * address actually connected to.
158
+ */
159
+ artifactFetch?: typeof globalThis.fetch
160
+ }
161
+
162
+ /**
163
+ * Options for {@link withGenerationPersistence}: everything in
164
+ * {@link ArtifactPersistenceOptions}, plus an optional scope override.
165
+ */
166
+ export interface WithGenerationPersistenceOptions extends ArtifactPersistenceOptions {
167
+ /**
168
+ * Override the scope runs are filed under. Defaults to the `threadId` you
169
+ * passed the activity, which is normally what you want, so leave this unset
170
+ * unless the record belongs somewhere other than the activity's own scope.
171
+ */
172
+ threadId?: string
173
+ }
174
+
175
+ /**
176
+ * The slot this generation's runs are filed under: `ctx.threadId` (the
177
+ * `threadId` the caller passed the activity), or the option when it overrides.
178
+ *
179
+ * Throws when neither supplies one. A run filed under no scope can never be
180
+ * hydrated by one, so `persistence: true` would restore nothing, forever. That
181
+ * is worth failing loudly for, since the alternative is a silent hole a reader
182
+ * cannot diagnose from behavior.
183
+ */
184
+ function generationScope(
185
+ ctx: GenerationMiddlewareContext,
186
+ opts: WithGenerationPersistenceOptions,
187
+ ): string {
188
+ const threadId = opts.threadId ?? ctx.threadId
189
+ if (threadId === undefined || threadId.length === 0) {
190
+ throw new Error(
191
+ 'Generation persistence requires a `threadId`, the stable scope successive ' +
192
+ 'runs are filed under. Pass it to the activity, e.g. ' +
193
+ '`generateImage({ threadId, middleware: [withGenerationPersistence(p)] })`, ' +
194
+ 'or override it with `withGenerationPersistence(p, { threadId })`.',
195
+ )
196
+ }
197
+ return threadId
198
+ }
199
+
200
+ const DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 30_000
201
+ // 1 GiB, because generated video clips routinely run to a few hundred MB and
202
+ // the old 100 MiB default silently failed them. The cap is a drain-time
203
+ // counter, not a buffer: the URL path streams into the store, so raising it
204
+ // costs a streaming store nothing in memory. It still earns its keep as the
205
+ // only ceiling on what a runaway or hostile origin can make you transfer and
206
+ // store (`content-length` is advisory, and on a compressed reply it measures
207
+ // the compressed body). `maxArtifactBytes: false` removes it — and the wrapper
208
+ // with it, which is the zero-copy path onto workerd + R2.
209
+ const DEFAULT_MAX_ARTIFACT_BYTES = 1024 * 1024 * 1024
210
+
211
+ export interface GenerationArtifactDescriptor {
212
+ role: PersistedArtifactRole
213
+ path: string
214
+ mediaType?: PersistedArtifactRef['source']['mediaType']
215
+ mimeType?: string
216
+ bytes?: BlobBody
217
+ url?: string
218
+ json?: unknown
219
+ name?: string
220
+ jobId?: string
221
+ expiresAt?: string | Date
222
+ }
223
+
224
+ export interface GenerationArtifactExtractionInput {
225
+ activity: PersistedArtifactActivity
226
+ provider: string
227
+ model: string
228
+ threadId: string
229
+ runId: string
230
+ inputs: unknown
231
+ result: unknown
232
+ }
233
+
234
+ export interface GenerationArtifactNameInput {
235
+ descriptor: GenerationArtifactDescriptor
236
+ activity: PersistedArtifactActivity
237
+ provider: string
238
+ model: string
239
+ threadId: string
240
+ runId: string
241
+ index: number
242
+ }
243
+
244
+ interface RunStateEntry {
245
+ merged: boolean
246
+ interrupted: boolean
247
+ /**
248
+ * Resumes accepted in `onConfig` but not yet committed to the interrupt
249
+ * store. They are applied (resolve/cancel) only once the run reaches a
250
+ * successful boundary — see {@link commitPendingResumes}. Left uncommitted
251
+ * (still pending in the store) if the run fails or aborts first.
252
+ */
253
+ pendingResumes?: {
254
+ pending: Array<InterruptRecord>
255
+ resumeByInterruptId: Map<string, RunAgentResumeItem>
256
+ }
257
+ /** Accumulated terminal-turn text, for throttled streaming snapshots (B). */
258
+ streamingText?: string
259
+ /** Epoch ms of the last streaming snapshot, to throttle writes (B). */
260
+ lastSnapshotAt?: number
261
+ /**
262
+ * The current assistant turn's stream messageId, captured from
263
+ * `TEXT_MESSAGE_START`. Persisted onto the assistant message so its identity
264
+ * survives the persist → hydrate round-trip and a reload can resume the same
265
+ * bubble in place.
266
+ */
267
+ streamingMessageId?: string
268
+ }
269
+
270
+ const runState = new WeakMap<object, RunStateEntry>()
271
+
272
+ const validResumeStatuses = new Set(['resolved', 'cancelled'])
273
+
274
+ function validatePendingResumes(
275
+ pending: Array<InterruptRecord>,
276
+ resume: Array<RunAgentResumeItem> | undefined,
277
+ ): Map<string, RunAgentResumeItem> {
278
+ const pendingInterruptIds = new Set(
279
+ pending.map((interrupt) => interrupt.interruptId),
280
+ )
281
+ const resumeByInterruptId = new Map(
282
+ (resume ?? []).map((entry) => [entry.interruptId, entry]),
283
+ )
284
+ if (pending.length === 0) {
285
+ const staleEntry = resume?.[0]
286
+ if (staleEntry) {
287
+ throw new Error(
288
+ `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`,
289
+ )
290
+ }
291
+ return resumeByInterruptId
292
+ }
293
+ if (!resume || resume.length === 0) {
294
+ throw new Error(
295
+ `Thread has pending interrupts; resume is required before accepting new input.`,
296
+ )
297
+ }
298
+
299
+ for (const interrupt of pending) {
300
+ const entry = resumeByInterruptId.get(interrupt.interruptId)
301
+ if (!entry) {
302
+ throw new Error(
303
+ `Missing resume entry for pending interrupt ${interrupt.interruptId}.`,
304
+ )
305
+ }
306
+ if (!validResumeStatuses.has(entry.status)) {
307
+ throw new Error(
308
+ `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`,
309
+ )
310
+ }
311
+ }
312
+ for (const entry of resume) {
313
+ if (!pendingInterruptIds.has(entry.interruptId)) {
314
+ throw new Error(
315
+ `Resume entry references non-pending interrupt ${entry.interruptId}.`,
316
+ )
317
+ }
318
+ }
319
+ return resumeByInterruptId
320
+ }
321
+
322
+ async function applyPendingResumes(
323
+ pending: Array<InterruptRecord>,
324
+ resumeByInterruptId: Map<string, RunAgentResumeItem>,
325
+ interrupts: NonNullable<AIPersistence['stores']['interrupts']>,
326
+ ): Promise<void> {
327
+ for (const interrupt of pending) {
328
+ const entry = resumeByInterruptId.get(interrupt.interruptId)
329
+ if (!entry) continue
330
+ if (entry.status === 'resolved') {
331
+ await interrupts.resolve(interrupt.interruptId, entry.payload)
332
+ } else {
333
+ await interrupts.cancel(interrupt.interruptId)
334
+ }
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Commit the resumes stashed in `onConfig`, marking each resumed interrupt
340
+ * resolved/cancelled. Called only from success boundaries (`onFinish`, and the
341
+ * `onChunk` interrupt boundary) so a provider failure or abort between accepting
342
+ * the resume and reaching a boundary leaves the interrupts pending — the
343
+ * approval is not consumed and a retry with the same resume succeeds. Idempotent
344
+ * and a no-op when nothing is stashed.
345
+ */
346
+ async function commitPendingResumes(
347
+ state: RunStateEntry | undefined,
348
+ interrupts: AIPersistence['stores']['interrupts'],
349
+ ): Promise<void> {
350
+ if (!state?.pendingResumes || !interrupts) return
351
+ const { pending, resumeByInterruptId } = state.pendingResumes
352
+ // Apply first; only clear the in-memory stash after every resolve/cancel
353
+ // succeeds so a mid-loop store failure can still re-drive remaining ids
354
+ // if the hook is retried (or a later boundary re-enters commit).
355
+ await applyPendingResumes(pending, resumeByInterruptId, interrupts)
356
+ state.pendingResumes = undefined
357
+ }
358
+
359
+ function objectValue(value: unknown): Record<string, unknown> | null {
360
+ return value && typeof value === 'object'
361
+ ? (value as Record<string, unknown>)
362
+ : null
363
+ }
364
+
365
+ function stringField(
366
+ value: Record<string, unknown>,
367
+ key: string,
368
+ ): string | undefined {
369
+ return typeof value[key] === 'string' ? value[key] : undefined
370
+ }
371
+
372
+ function interruptKind(interrupt: InterruptRecord): string | undefined {
373
+ const metadata = objectValue(interrupt.payload.metadata)
374
+ return metadata ? stringField(metadata, 'kind') : undefined
375
+ }
376
+
377
+ function resolvedApprovalDecision(entry: RunAgentResumeItem): boolean {
378
+ if (entry.status === 'cancelled') return false
379
+ const payload = objectValue(entry.payload)
380
+ // Fail closed: persisted resume payloads may be malformed or truncated, so a
381
+ // missing/non-boolean `approved` denies the tool rather than running it.
382
+ return typeof payload?.approved === 'boolean' ? payload.approved : false
383
+ }
384
+
385
+ /**
386
+ * Translate the persisted pending interrupts + the resume batch into the
387
+ * `ChatResumeToolState` the chat engine consumes. This is the server-authoritative
388
+ * counterpart to the engine's ephemeral (client-history) reconstruction: because
389
+ * the persistence flow sends empty client messages, the engine has no history to
390
+ * rebuild from, so persistence supplies the resume state directly (and clears
391
+ * `config.resume` so the ephemeral path is skipped — see `onConfig`).
392
+ */
393
+ function resumeToolStateFromPending(
394
+ pending: Array<InterruptRecord>,
395
+ resumeByInterruptId: Map<string, RunAgentResumeItem>,
396
+ ): ChatResumeToolState | undefined {
397
+ const approvals = new Map<string, ToolApprovalResolution>()
398
+ const clientToolResults = new Map<string, unknown>()
399
+
400
+ for (const interrupt of pending) {
401
+ const entry = resumeByInterruptId.get(interrupt.interruptId)
402
+ if (!entry) continue
403
+
404
+ const kind = interruptKind(interrupt)
405
+ const reason = stringField(interrupt.payload, 'reason')
406
+ const toolCallId = stringField(interrupt.payload, 'toolCallId')
407
+
408
+ if (kind === 'approval' || reason === 'approval_required') {
409
+ approvals.set(interrupt.interruptId, resolvedApprovalDecision(entry))
410
+ continue
411
+ }
412
+
413
+ if (
414
+ entry.status === 'resolved' &&
415
+ toolCallId &&
416
+ (kind === 'client_tool' || reason === 'client_tool_input')
417
+ ) {
418
+ clientToolResults.set(toolCallId, entry.payload)
419
+ }
420
+ }
421
+
422
+ if (approvals.size === 0 && clientToolResults.size === 0) return undefined
423
+ return { approvals, clientToolResults }
424
+ }
425
+
426
+ /**
427
+ * Build the transcript to persist when a run finishes successfully.
428
+ *
429
+ * The chat engine appends an assistant message to the middleware message list
430
+ * only when that turn carries tool calls (to feed the agent loop); a run's
431
+ * terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is
432
+ * missing the assistant's final answer. Reattach it from the finish info —
433
+ * `info.content` is the last turn's accumulated text (reset each cycle) — so
434
+ * the stored thread is the complete conversation a server-authoritative client
435
+ * hydrates on load. A guard avoids duplicating a terminal assistant turn should
436
+ * the engine ever start appending it itself.
437
+ */
438
+ function finishedTranscript(
439
+ messages: ReadonlyArray<ModelMessage>,
440
+ info: FinishInfo,
441
+ messageId: string | undefined,
442
+ ): Array<ModelMessage> {
443
+ const transcript = [...messages]
444
+ const last = transcript[transcript.length - 1]
445
+ const alreadyPresent =
446
+ last?.role === 'assistant' &&
447
+ last.toolCalls === undefined &&
448
+ last.content === info.content
449
+ if (info.content && !alreadyPresent) {
450
+ // Stamp the terminal turn with its stream messageId so a hydrated bubble
451
+ // keeps the same identity as the live stream (in-place resume on reload).
452
+ transcript.push({
453
+ role: 'assistant',
454
+ content: info.content,
455
+ ...(messageId ? { id: messageId } : {}),
456
+ })
457
+ }
458
+ return transcript
459
+ }
460
+
461
+ function interruptPayload(interrupt: unknown): Record<string, unknown> {
462
+ return interrupt && typeof interrupt === 'object'
463
+ ? { ...(interrupt as Record<string, unknown>) }
464
+ : { value: interrupt }
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Generation artifact extraction / persistence
469
+ // ---------------------------------------------------------------------------
470
+
471
+ function isArtifactRef(value: unknown): value is PersistedArtifactRef {
472
+ const record = objectValue(value)
473
+ return !!record && typeof record.artifactId === 'string'
474
+ }
475
+
476
+ function mediaActivity(
477
+ activity: GenerationMiddlewareContext['activity'],
478
+ ): PersistedArtifactActivity | undefined {
479
+ return activity === 'image' ||
480
+ activity === 'audio' ||
481
+ activity === 'tts' ||
482
+ activity === 'video' ||
483
+ activity === 'transcription'
484
+ ? activity
485
+ : undefined
486
+ }
487
+
488
+ function parseDataUrl(
489
+ value: string,
490
+ ): { mimeType: string; bytes: Uint8Array } | undefined {
491
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(value)
492
+ if (!match) return undefined
493
+ const mimeType = match[1] || 'application/octet-stream'
494
+ const raw = match[3] ?? ''
495
+ // A plain (non-base64) data URL may carry a bare `%` (`data:text/plain,100%`),
496
+ // which makes `decodeURIComponent` throw. Fall back to the literal payload so
497
+ // a malformed escape doesn't fail the whole generation.
498
+ let payload: string
499
+ try {
500
+ payload = decodeURIComponent(raw)
501
+ } catch {
502
+ payload = raw
503
+ }
504
+ return {
505
+ mimeType,
506
+ bytes: match[2]
507
+ ? base64ToUint8Array(payload)
508
+ : new TextEncoder().encode(payload),
509
+ }
510
+ }
511
+
512
+ function extensionForMime(mimeType: string | undefined): string {
513
+ if (mimeType === undefined) return 'bin'
514
+
515
+ switch (mimeType) {
516
+ case 'image/png':
517
+ return 'png'
518
+ case 'image/jpeg':
519
+ return 'jpg'
520
+ case 'audio/wav':
521
+ return 'wav'
522
+ case 'audio/mpeg':
523
+ return 'mp3'
524
+ case 'audio/mp3':
525
+ return 'mp3'
526
+ case 'video/mp4':
527
+ return 'mp4'
528
+ case 'application/json':
529
+ return 'json'
530
+ default:
531
+ return 'bin'
532
+ }
533
+ }
534
+
535
+ function defaultArtifactName(
536
+ descriptor: GenerationArtifactDescriptor,
537
+ activity: PersistedArtifactActivity,
538
+ index: number,
539
+ ): string {
540
+ const ext = extensionForMime(descriptor.mimeType)
541
+ return `${activity}-${descriptor.role}-${descriptor.mediaType ?? 'artifact'}-${index}.${ext}`
542
+ }
543
+
544
+ function sourcePartDescriptors(
545
+ part: unknown,
546
+ role: PersistedArtifactRole,
547
+ path: string,
548
+ ): Array<GenerationArtifactDescriptor> {
549
+ const record = objectValue(part)
550
+ const type = stringField(record ?? {}, 'type')
551
+ const source = objectValue(record?.source)
552
+ if (
553
+ !record ||
554
+ !source ||
555
+ (type !== 'image' && type !== 'audio' && type !== 'video')
556
+ ) {
557
+ return []
558
+ }
559
+ const sourceType = stringField(source, 'type')
560
+ const mimeType = stringField(source, 'mimeType') ?? `${type}/mpeg`
561
+ if (sourceType === 'data') {
562
+ const value = stringField(source, 'value')
563
+ if (!value) return []
564
+ return [
565
+ {
566
+ role,
567
+ path,
568
+ mediaType: type,
569
+ mimeType,
570
+ bytes: base64ToUint8Array(value),
571
+ },
572
+ ]
573
+ }
574
+ if (sourceType === 'url') {
575
+ const value = stringField(source, 'value')
576
+ if (!value) return []
577
+ return [{ role, path, mediaType: type, mimeType, url: value }]
578
+ }
579
+ return []
580
+ }
581
+
582
+ function promptInputDescriptors(
583
+ inputs: unknown,
584
+ ): Array<GenerationArtifactDescriptor> {
585
+ const prompt = objectValue(inputs)?.prompt
586
+ if (!Array.isArray(prompt)) return []
587
+
588
+ const counts: Record<string, number> = { image: 0, audio: 0, video: 0 }
589
+ const descriptors: Array<GenerationArtifactDescriptor> = []
590
+ for (const part of prompt) {
591
+ const type = stringField(objectValue(part) ?? {}, 'type')
592
+ if (type !== 'image' && type !== 'audio' && type !== 'video') continue
593
+ const index = counts[type] ?? 0
594
+ counts[type] = index + 1
595
+ descriptors.push(
596
+ ...sourcePartDescriptors(part, 'input', `prompt.${type}s.${index}`),
597
+ )
598
+ }
599
+ return descriptors
600
+ }
601
+
602
+ function generatedMediaDescriptor(args: {
603
+ role: PersistedArtifactRole
604
+ path: string
605
+ mediaType: 'image' | 'audio' | 'video'
606
+ mimeType: string
607
+ media: unknown
608
+ jobId?: string
609
+ expiresAt?: string | Date
610
+ }): GenerationArtifactDescriptor | undefined {
611
+ const media = objectValue(args.media)
612
+ if (!media) return undefined
613
+ const b64Json = stringField(media, 'b64Json')
614
+ if (b64Json) {
615
+ return {
616
+ role: args.role,
617
+ path: args.path,
618
+ mediaType: args.mediaType,
619
+ mimeType: stringField(media, 'contentType') ?? args.mimeType,
620
+ bytes: base64ToUint8Array(b64Json),
621
+ jobId: args.jobId,
622
+ expiresAt: args.expiresAt,
623
+ }
624
+ }
625
+ const url = stringField(media, 'url')
626
+ if (url) {
627
+ return {
628
+ role: args.role,
629
+ path: args.path,
630
+ mediaType: args.mediaType,
631
+ mimeType: stringField(media, 'contentType') ?? args.mimeType,
632
+ url,
633
+ jobId: args.jobId,
634
+ expiresAt: args.expiresAt,
635
+ }
636
+ }
637
+ return undefined
638
+ }
639
+
640
+ function builtInArtifactDescriptors(
641
+ activity: PersistedArtifactActivity,
642
+ inputs: unknown,
643
+ result: unknown,
644
+ ): Array<GenerationArtifactDescriptor> {
645
+ const descriptors = promptInputDescriptors(inputs)
646
+ const output = objectValue(result)
647
+ if (!output) return descriptors
648
+
649
+ if (activity === 'image' && Array.isArray(output.images)) {
650
+ output.images.forEach((image, index) => {
651
+ const descriptor = generatedMediaDescriptor({
652
+ role: 'output',
653
+ path: `images.${index}`,
654
+ mediaType: 'image',
655
+ mimeType: 'image/png',
656
+ media: image,
657
+ })
658
+ if (descriptor) descriptors.push(descriptor)
659
+ })
660
+ }
661
+
662
+ if (activity === 'audio') {
663
+ const descriptor = generatedMediaDescriptor({
664
+ role: 'output',
665
+ path: 'audio',
666
+ mediaType: 'audio',
667
+ mimeType: 'audio/mpeg',
668
+ media: output.audio,
669
+ })
670
+ if (descriptor) descriptors.push(descriptor)
671
+ }
672
+
673
+ if (activity === 'tts') {
674
+ const audio = stringField(output, 'audio')
675
+ if (audio) {
676
+ const format = stringField(output, 'format')
677
+ descriptors.push({
678
+ role: 'output',
679
+ path: 'audio',
680
+ mediaType: 'audio',
681
+ mimeType:
682
+ stringField(output, 'contentType') ??
683
+ (format ? `audio/${format}` : 'audio/mpeg'),
684
+ bytes: base64ToUint8Array(audio),
685
+ })
686
+ }
687
+ }
688
+
689
+ if (activity === 'video' && typeof output.url === 'string') {
690
+ descriptors.push({
691
+ role: 'output',
692
+ path: 'video',
693
+ mediaType: 'video',
694
+ mimeType: 'video/mp4',
695
+ url: output.url,
696
+ jobId: stringField(output, 'jobId'),
697
+ expiresAt:
698
+ output.expiresAt instanceof Date ? output.expiresAt : undefined,
699
+ })
700
+ }
701
+
702
+ if (activity === 'transcription') {
703
+ const audio = objectValue(inputs)?.audio
704
+ if (typeof audio === 'string') {
705
+ const data = parseDataUrl(audio)
706
+ descriptors.push({
707
+ role: 'input',
708
+ path: 'audio',
709
+ mediaType: 'audio',
710
+ mimeType: data?.mimeType ?? 'audio/mpeg',
711
+ bytes: data?.bytes ?? base64ToUint8Array(audio),
712
+ })
713
+ } else if (audio instanceof ArrayBuffer) {
714
+ descriptors.push({
715
+ role: 'input',
716
+ path: 'audio',
717
+ mediaType: 'audio',
718
+ mimeType: 'audio/mpeg',
719
+ bytes: audio.slice(0),
720
+ })
721
+ } else if (typeof Blob !== 'undefined' && audio instanceof Blob) {
722
+ descriptors.push({
723
+ role: 'input',
724
+ path: 'audio',
725
+ mediaType: 'audio',
726
+ mimeType: audio.type || 'audio/mpeg',
727
+ bytes: audio,
728
+ })
729
+ }
730
+ if (Array.isArray(output.segments) || Array.isArray(output.words)) {
731
+ descriptors.push({
732
+ role: 'output',
733
+ path: 'transcription',
734
+ mediaType: 'json',
735
+ mimeType: 'application/json',
736
+ json: output,
737
+ })
738
+ }
739
+ }
740
+
741
+ return descriptors
742
+ }
743
+
744
+ /**
745
+ * Reject hosts that only make sense as an SSRF target: loopback, link-local
746
+ * (including the cloud metadata address), private, and unique-local ranges.
747
+ *
748
+ * Applied to caller-supplied input URLs only. Provider result URLs skip it on
749
+ * purpose — a self-hosted or local provider legitimately returns a `localhost`
750
+ * URL, and those live inside the same trust boundary as the adapter itself.
751
+ *
752
+ * This checks IP *literals*. A hostname that resolves to a private address
753
+ * passes, which is why `allowInputUrl` is required rather than optional.
754
+ */
755
+ function isBlockedInputHost(hostname: string): boolean {
756
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
757
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
758
+
759
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
760
+ if (ipv4) {
761
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]
762
+ if (a === 127 || a === 0 || a === 10) return true
763
+ if (a === 169 && b === 254) return true // link-local + cloud metadata
764
+ if (a === 172 && b >= 16 && b <= 31) return true
765
+ if (a === 192 && b === 168) return true
766
+ return false
767
+ }
768
+
769
+ if (host === '::' || host === '::1') return true
770
+ if (host.startsWith('fe80:')) return true // link-local
771
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return true // unique-local
772
+ // IPv4-mapped IPv6 — re-check the embedded address. `new URL()` normalizes
773
+ // `::ffff:127.0.0.1` to the hex form `::ffff:7f00:1`, so accept both.
774
+ const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(
775
+ host,
776
+ )
777
+ if (mappedDotted?.[1]) return isBlockedInputHost(mappedDotted[1])
778
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host)
779
+ if (mappedHex?.[1] && mappedHex[2]) {
780
+ const high = Number.parseInt(mappedHex[1], 16)
781
+ const low = Number.parseInt(mappedHex[2], 16)
782
+ return isBlockedInputHost(
783
+ `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
784
+ )
785
+ }
786
+ return false
787
+ }
788
+
789
+ /**
790
+ * Fail the stream once more than `maxBytes` have passed through, so an
791
+ * unexpectedly huge artifact can't fill the blob store.
792
+ *
793
+ * Only used when the response does NOT already bound itself — a chunked reply,
794
+ * or a content-encoded one whose declared length describes the compressed
795
+ * bytes. When `content-length` describes the body the store will drain, HTTP
796
+ * framing is the bound and wrapping would only cost the caller the declared
797
+ * length: a `TransformStream`'s readable side carries none, which is what
798
+ * pushes a length-strict runtime (workerd + R2) onto a multipart upload.
799
+ */
800
+ function capBodySize(
801
+ body: ReadableStream<Uint8Array>,
802
+ maxBytes: number,
803
+ url: string,
804
+ ): ReadableStream<Uint8Array> {
805
+ let seen = 0
806
+ return body.pipeThrough(
807
+ new TransformStream<Uint8Array, Uint8Array>({
808
+ transform(chunk, controller) {
809
+ seen += chunk.byteLength
810
+ if (seen > maxBytes) {
811
+ controller.error(
812
+ new Error(
813
+ `Artifact at ${url} exceeds maxArtifactBytes (${maxBytes}).`,
814
+ ),
815
+ )
816
+ return
817
+ }
818
+ controller.enqueue(chunk)
819
+ },
820
+ }),
821
+ )
822
+ }
823
+
824
+ /**
825
+ * Resolve a descriptor to the bytes to store. Returns `undefined` when the
826
+ * descriptor is deliberately not persisted — today that means a caller-supplied
827
+ * input URL without an `allowInputUrl` opt-in.
828
+ */
829
+ async function descriptorBody(
830
+ descriptor: GenerationArtifactDescriptor,
831
+ opts: ArtifactPersistenceOptions | undefined,
832
+ ): Promise<
833
+ | {
834
+ body: BlobBody
835
+ size: number
836
+ /**
837
+ * Exact byte length of a streamed body, when the origin declared one
838
+ * that survives decoding — forwarded to `BlobStore.put` as
839
+ * `BlobPutOptions.expectedLength`. Undefined when unknown.
840
+ */
841
+ expectedLength?: number
842
+ mimeType: string
843
+ sourceUrl?: string
844
+ }
845
+ | undefined
846
+ > {
847
+ if (descriptor.json !== undefined) {
848
+ const body = JSON.stringify(descriptor.json)
849
+ return {
850
+ body,
851
+ size: new TextEncoder().encode(body).byteLength,
852
+ mimeType: descriptor.mimeType ?? 'application/json',
853
+ }
854
+ }
855
+
856
+ if (descriptor.bytes !== undefined) {
857
+ const body = descriptor.bytes
858
+ let size: number
859
+ if (typeof body === 'string') {
860
+ size = new TextEncoder().encode(body).byteLength
861
+ } else if (body instanceof ArrayBuffer) {
862
+ size = body.byteLength
863
+ } else if (ArrayBuffer.isView(body)) {
864
+ size = body.byteLength
865
+ } else if (typeof Blob !== 'undefined' && body instanceof Blob) {
866
+ size = body.size
867
+ } else {
868
+ size = 0
869
+ }
870
+ return {
871
+ body,
872
+ size,
873
+ mimeType: descriptor.mimeType ?? 'application/octet-stream',
874
+ }
875
+ }
876
+
877
+ if (descriptor.url) {
878
+ const data = parseDataUrl(descriptor.url)
879
+ if (data) {
880
+ return {
881
+ body: data.bytes,
882
+ size: data.bytes.byteLength,
883
+ mimeType: descriptor.mimeType ?? data.mimeType,
884
+ }
885
+ }
886
+ // A caller-controlled input URL is never fetched unless the app opted in
887
+ // with a validating predicate. Skipped, not thrown: not mirroring someone
888
+ // else's URL is the intended default, and the run itself is fine.
889
+ const isCallerSupplied = descriptor.role === 'input'
890
+ const allowInputUrl = opts?.allowInputUrl
891
+ if (isCallerSupplied && !allowInputUrl) return undefined
892
+
893
+ let target: URL
894
+ try {
895
+ target = new URL(descriptor.url)
896
+ } catch {
897
+ throw new Error(
898
+ `Failed to persist artifact: ${descriptor.url} is not a valid URL.`,
899
+ )
900
+ }
901
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
902
+ throw new Error(
903
+ `Refusing to fetch artifact over ${target.protocol} (${descriptor.path}).`,
904
+ )
905
+ }
906
+ if (allowInputUrl && isCallerSupplied) {
907
+ if (isBlockedInputHost(target.hostname)) {
908
+ throw new Error(
909
+ `Refusing to fetch input artifact from internal host ${target.hostname}.`,
910
+ )
911
+ }
912
+ if (!(await allowInputUrl({ url: target, descriptor }))) {
913
+ throw new Error(
914
+ `Refusing to fetch input artifact from ${target.hostname}: rejected by allowInputUrl.`,
915
+ )
916
+ }
917
+ }
918
+
919
+ const maxBytes = opts?.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES
920
+ const fetchArtifact = opts?.artifactFetch ?? globalThis.fetch
921
+ const response = await fetchArtifact(target, {
922
+ // Provider CDNs redirect routinely, so output fetches follow. An input
923
+ // fetch must not: a 302 would land on a host neither check ever saw.
924
+ redirect: isCallerSupplied ? 'manual' : 'follow',
925
+ signal: AbortSignal.timeout(
926
+ opts?.artifactFetchTimeoutMs ?? DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS,
927
+ ),
928
+ })
929
+ if (isCallerSupplied && response.status >= 300 && response.status < 400) {
930
+ throw new Error(
931
+ `Refusing to follow a redirect for input artifact ${descriptor.path}.`,
932
+ )
933
+ }
934
+ if (!response.ok) {
935
+ throw new Error(
936
+ `Failed to persist artifact from ${descriptor.url}: HTTP ${response.status}`,
937
+ )
938
+ }
939
+ // `headers.get` returns null when the header is absent, and
940
+ // `Number(null) === 0` — parse only a present header, or a chunked reply
941
+ // would read as a declared length of 0 (harmless, but the early-reject
942
+ // below would silently never be reachable for it).
943
+ const contentLength = response.headers.get('content-length')
944
+ const declaredLength =
945
+ contentLength === null ? undefined : Number(contentLength)
946
+ if (
947
+ maxBytes !== false &&
948
+ declaredLength !== undefined &&
949
+ Number.isFinite(declaredLength) &&
950
+ declaredLength > maxBytes
951
+ ) {
952
+ throw new Error(
953
+ `Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`,
954
+ )
955
+ }
956
+ const mimeType =
957
+ descriptor.mimeType ??
958
+ response.headers.get('content-type') ??
959
+ 'application/octet-stream'
960
+ // A declared length is the DECODED body's length only when the response is
961
+ // not content-encoded: fetch transparently decompresses, so on a gzipped
962
+ // reply `content-length` measures the compressed bytes and the decoded
963
+ // stream can be arbitrarily longer. Only trust it when it provably
964
+ // describes what the store will drain.
965
+ const encoding = response.headers.get('content-encoding')
966
+ const decodedLengthIsKnown =
967
+ declaredLength !== undefined &&
968
+ Number.isFinite(declaredLength) &&
969
+ (encoding === null || encoding === 'identity')
970
+ const expectedLength = decodedLengthIsKnown ? declaredLength : undefined
971
+ // Stream the body straight into the blob store instead of buffering the
972
+ // whole artifact in memory. `size` is left 0 (unknown up front); the store
973
+ // records the actual byte length as it drains the stream. Fall back to
974
+ // buffering only when the response has no body to stream.
975
+ if (response.body) {
976
+ return {
977
+ // Wrap ONLY when the response does not already bound itself. A
978
+ // trustworthy `content-length` was checked against the cap above, and
979
+ // HTTP framing holds the origin to it — a body cannot exceed a length
980
+ // it declared — so the counter would add nothing and cost everything:
981
+ // it is a TransformStream, whose readable side has no declared length,
982
+ // and that missing length is precisely what breaks `R2Bucket.put`.
983
+ // Unwrapped, the runtime's own length rides along and R2 single-shots
984
+ // the stream. What still needs the counter: a chunked reply (no
985
+ // declared length at all) and a content-encoded one (declared length
986
+ // measures the compressed bytes, so the decoded stream is a
987
+ // decompression bomb waiting to happen).
988
+ body:
989
+ maxBytes === false || decodedLengthIsKnown
990
+ ? response.body
991
+ : capBodySize(response.body, maxBytes, descriptor.url),
992
+ size: 0,
993
+ expectedLength,
994
+ mimeType,
995
+ sourceUrl: descriptor.url,
996
+ }
997
+ }
998
+ const body = await response.arrayBuffer()
999
+ if (maxBytes !== false && body.byteLength > maxBytes) {
1000
+ throw new Error(
1001
+ `Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`,
1002
+ )
1003
+ }
1004
+ return {
1005
+ body,
1006
+ size: body.byteLength,
1007
+ mimeType,
1008
+ sourceUrl: descriptor.url,
1009
+ }
1010
+ }
1011
+
1012
+ throw new Error(
1013
+ `Artifact descriptor ${descriptor.path} has no bytes, url, or json.`,
1014
+ )
1015
+ }
1016
+
1017
+ async function persistGenerationArtifacts(
1018
+ persistence: AIPersistence,
1019
+ opts: WithGenerationPersistenceOptions,
1020
+ ctx: GenerationMiddlewareContext,
1021
+ result: unknown,
1022
+ ): Promise<Array<PersistedArtifactRef>> {
1023
+ const activity = mediaActivity(ctx.activity)
1024
+ if (!activity) return []
1025
+
1026
+ // Resolved the same way the run record is, so an artifact always lands in the
1027
+ // same slot as the run that produced it.
1028
+ const threadId = generationScope(ctx, opts)
1029
+ const runId = ctx.runId ?? ctx.requestId
1030
+ const extractionInput: GenerationArtifactExtractionInput = {
1031
+ activity,
1032
+ provider: ctx.provider,
1033
+ model: ctx.model,
1034
+ threadId,
1035
+ runId,
1036
+ inputs: ctx.artifactInputs,
1037
+ result,
1038
+ }
1039
+ const extracted =
1040
+ opts?.extractArtifacts !== undefined
1041
+ ? await opts.extractArtifacts(extractionInput)
1042
+ : builtInArtifactDescriptors(activity, ctx.artifactInputs, result)
1043
+
1044
+ if (extracted.length === 0) return []
1045
+
1046
+ const existingRefs = extracted.filter(isArtifactRef)
1047
+ const descriptors = extracted.filter(
1048
+ (item): item is GenerationArtifactDescriptor => !isArtifactRef(item),
1049
+ )
1050
+ if (descriptors.length === 0) return existingRefs
1051
+
1052
+ if (!persistence.stores.artifacts || !persistence.stores.blobs) {
1053
+ throw new Error(
1054
+ 'Generation artifact persistence requires stores.artifacts and stores.blobs.',
1055
+ )
1056
+ }
1057
+
1058
+ const refs: Array<PersistedArtifactRef> = [...existingRefs]
1059
+ for (const [index, descriptor] of descriptors.entries()) {
1060
+ const artifactId = ctx.createId('artifact')
1061
+ const resolved = await descriptorBody(descriptor, opts)
1062
+ // Deliberately not persisted (an input URL with no `allowInputUrl` opt-in):
1063
+ // no blob, no record, no ref — the rest of the run is unaffected.
1064
+ if (!resolved) continue
1065
+ const { body, size, expectedLength, mimeType, sourceUrl } = resolved
1066
+ // Resolved before the blob write so `storageKey` can build a path from the
1067
+ // final filename (extensions, slugs) rather than guessing at one.
1068
+ const name =
1069
+ opts?.nameArtifact?.({
1070
+ descriptor: { ...descriptor, mimeType },
1071
+ activity,
1072
+ provider: ctx.provider,
1073
+ model: ctx.model,
1074
+ threadId,
1075
+ runId,
1076
+ index,
1077
+ }) ??
1078
+ descriptor.name ??
1079
+ defaultArtifactName({ ...descriptor, mimeType }, activity, index)
1080
+ const key =
1081
+ opts?.storageKey?.({
1082
+ artifactId,
1083
+ runId,
1084
+ threadId,
1085
+ role: descriptor.role,
1086
+ activity,
1087
+ path: descriptor.path,
1088
+ mimeType,
1089
+ name,
1090
+ }) ?? artifactBlobKey({ runId, artifactId })
1091
+ const stored = await persistence.stores.blobs.put(key, body, {
1092
+ contentType: mimeType,
1093
+ // Exact decoded length when the origin declared one — lets a store
1094
+ // single-shot the stream (e.g. R2 via FixedLengthStream) instead of
1095
+ // buffering or going multipart. Absent when unknown.
1096
+ ...(expectedLength !== undefined ? { expectedLength } : {}),
1097
+ customMetadata: {
1098
+ runId,
1099
+ threadId,
1100
+ role: descriptor.role,
1101
+ activity,
1102
+ path: descriptor.path,
1103
+ },
1104
+ })
1105
+ // For streamed downloads the descriptor size is unknown (0); the store
1106
+ // reports the real byte length once it has drained the stream.
1107
+ const resolvedSize = size || stored.size || 0
1108
+ const createdAtMs = Date.now()
1109
+ const record: ArtifactRecord = {
1110
+ artifactId,
1111
+ runId,
1112
+ threadId,
1113
+ // Always recorded: with a custom `storageKey` the path is no longer
1114
+ // derivable from the record, so the reader has to be told where it went.
1115
+ blobKey: key,
1116
+ name,
1117
+ mimeType,
1118
+ size: resolvedSize,
1119
+ sourceUrl,
1120
+ createdAt: createdAtMs,
1121
+ }
1122
+ await persistence.stores.artifacts.save(record)
1123
+ refs.push({
1124
+ role: descriptor.role,
1125
+ artifactId,
1126
+ threadId,
1127
+ runId,
1128
+ name,
1129
+ mimeType,
1130
+ size: resolvedSize,
1131
+ createdAt: new Date(createdAtMs).toISOString(),
1132
+ ...(sourceUrl ? { sourceUrl } : {}),
1133
+ source: {
1134
+ activity,
1135
+ path: descriptor.path,
1136
+ provider: ctx.provider,
1137
+ model: ctx.model,
1138
+ mediaType: descriptor.mediaType,
1139
+ jobId: descriptor.jobId,
1140
+ expiresAt:
1141
+ descriptor.expiresAt instanceof Date
1142
+ ? descriptor.expiresAt.toISOString()
1143
+ : descriptor.expiresAt,
1144
+ },
1145
+ })
1146
+ }
1147
+
1148
+ // Stamp the durable app-origin serve URL onto every ref that lacks one, so
1149
+ // clients render + restore media from your own origin, not the provider link.
1150
+ if (opts?.artifactUrl) {
1151
+ for (let i = 0; i < refs.length; i++) {
1152
+ const ref = refs[i]
1153
+ if (ref && !ref.url) {
1154
+ const url = opts.artifactUrl(ref)
1155
+ if (url) refs[i] = { ...ref, url }
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ return refs
1161
+ }
1162
+
1163
+ /**
1164
+ * Rewrite the live result's media fields to each output ref's durable serve URL
1165
+ * (`ref.url`), so the live result matches what a reload restores. Keyed off the
1166
+ * ref's `source.path`: `images.<i>` → `result.images[i].url`, `video` →
1167
+ * `result.url`, `audio` (object) → `result.audio.url`. tts (a base64 string) and
1168
+ * transcription (json) have no media-URL field, so they are left as-is; their
1169
+ * durable bytes are reachable via `result.artifacts`. A no-op when no ref has a
1170
+ * `url`.
1171
+ */
1172
+ function applyDurableMediaUrls(
1173
+ result: Record<string, unknown>,
1174
+ refs: Array<PersistedArtifactRef>,
1175
+ ): Record<string, unknown> {
1176
+ let next = result
1177
+ for (const ref of refs) {
1178
+ if (ref.role !== 'output' || !ref.url) continue
1179
+ const path = ref.source.path
1180
+ if (path.startsWith('images.')) {
1181
+ const index = Number(path.slice('images.'.length))
1182
+ const images = next.images
1183
+ if (Array.isArray(images) && objectValue(images[index])) {
1184
+ const cloned = [...images]
1185
+ cloned[index] = { ...objectValue(images[index]), url: ref.url }
1186
+ next = { ...next, images: cloned }
1187
+ }
1188
+ } else if (path === 'video') {
1189
+ next = { ...next, url: ref.url }
1190
+ } else if (path === 'audio' && objectValue(next.audio)) {
1191
+ next = { ...next, audio: { ...objectValue(next.audio), url: ref.url } }
1192
+ }
1193
+ }
1194
+ return next
1195
+ }
1196
+
1197
+ // ---------------------------------------------------------------------------
1198
+ // Shared store / feature plan
1199
+ // ---------------------------------------------------------------------------
1200
+
1201
+ interface PersistencePlan {
1202
+ wantsInterrupts: boolean
1203
+ wantsArtifactPersistence: boolean
1204
+ runs: AIPersistence['stores']['runs']
1205
+ }
1206
+
1207
+ function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan {
1208
+ return {
1209
+ wantsInterrupts: persistence.stores.interrupts !== undefined,
1210
+ wantsArtifactPersistence:
1211
+ persistence.stores.artifacts !== undefined &&
1212
+ persistence.stores.blobs !== undefined,
1213
+ runs: persistence.stores.runs,
1214
+ }
1215
+ }
1216
+
1217
+ type StoreIsDefinitelyPresent<
1218
+ TStores extends AIPersistenceStores,
1219
+ TKey extends keyof AIPersistenceStores,
1220
+ > = TKey extends keyof TStores
1221
+ ? object extends Pick<TStores, TKey>
1222
+ ? false
1223
+ : [Exclude<TStores[TKey], undefined>] extends [never]
1224
+ ? false
1225
+ : true
1226
+ : false
1227
+
1228
+ type StoreIsDefinitelyAbsent<
1229
+ TStores extends AIPersistenceStores,
1230
+ TKey extends keyof AIPersistenceStores,
1231
+ > = TKey extends keyof TStores
1232
+ ? [Exclude<TStores[TKey], undefined>] extends [never]
1233
+ ? true
1234
+ : false
1235
+ : true
1236
+
1237
+ /**
1238
+ * Chat entrypoint invalid when:
1239
+ * - `messages` is known-absent, or
1240
+ * - `interrupts` is known-present without `runs`.
1241
+ *
1242
+ * Fully optional bags (`AIPersistence` with all `?` keys) stay assignable and
1243
+ * are checked at runtime by {@link validateChatPersistenceStores}.
1244
+ */
1245
+ type InvalidChatPersistence<TStores extends AIPersistenceStores> =
1246
+ StoreIsDefinitelyAbsent<TStores, 'messages'> extends true
1247
+ ? true
1248
+ : StoreIsDefinitelyPresent<TStores, 'interrupts'> extends true
1249
+ ? StoreIsDefinitelyAbsent<TStores, 'runs'>
1250
+ : false
1251
+
1252
+ /**
1253
+ * Generation entrypoint invalid when `generationRuns` is known-absent, or when
1254
+ * exactly one of `artifacts` / `blobs` is present (artifact persistence needs
1255
+ * both).
1256
+ */
1257
+ type InvalidGenerationPersistence<TStores extends AIPersistenceStores> =
1258
+ StoreIsDefinitelyAbsent<TStores, 'generationRuns'> extends true
1259
+ ? true
1260
+ : StoreIsDefinitelyPresent<TStores, 'artifacts'> extends true
1261
+ ? StoreIsDefinitelyAbsent<TStores, 'blobs'>
1262
+ : StoreIsDefinitelyPresent<TStores, 'blobs'> extends true
1263
+ ? StoreIsDefinitelyAbsent<TStores, 'artifacts'>
1264
+ : false
1265
+
1266
+ type ValidChatPersistence<TStores extends AIPersistenceStores> =
1267
+ InvalidChatPersistence<TStores> extends true ? never : unknown
1268
+
1269
+ type ValidGenerationPersistence<TStores extends AIPersistenceStores> =
1270
+ InvalidGenerationPersistence<TStores> extends true ? never : unknown
1271
+
1272
+ async function createOrResumeRun(
1273
+ runs: RunStore | undefined,
1274
+ runId: string,
1275
+ threadId: string,
1276
+ ): Promise<void> {
1277
+ await runs?.createOrResume({
1278
+ runId,
1279
+ threadId,
1280
+ startedAt: Date.now(),
1281
+ })
1282
+ }
1283
+
1284
+ async function completeRun(
1285
+ runs: RunStore | undefined,
1286
+ runId: string,
1287
+ usage?: TokenUsage,
1288
+ ): Promise<void> {
1289
+ await runs?.update(runId, {
1290
+ status: 'completed',
1291
+ finishedAt: Date.now(),
1292
+ ...(usage ? { usage } : {}),
1293
+ })
1294
+ }
1295
+
1296
+ async function failRun(
1297
+ runs: RunStore | undefined,
1298
+ runId: string,
1299
+ error: unknown,
1300
+ ): Promise<void> {
1301
+ // `RunRecord.error` is a structured `RunError`. Only `message` is filled in
1302
+ // here: the middleware sees an opaque thrown value, and inventing a `code`
1303
+ // from it would fabricate the stable classification consumers branch on. A
1304
+ // provider-supplied code reaches the record through the adapter layer.
1305
+ await runs?.update(runId, {
1306
+ status: 'failed',
1307
+ finishedAt: Date.now(),
1308
+ error: { message: error instanceof Error ? error.message : String(error) },
1309
+ })
1310
+ }
1311
+
1312
+ /**
1313
+ * Record a human-in-the-loop PAUSE.
1314
+ *
1315
+ * Deliberately writes NO `finishedAt`: `'interrupted'` is not a terminal status
1316
+ * (`isTerminalRunStatus('interrupted')` is `false`), and stamping a terminal
1317
+ * timestamp on it told every reader the run was over while it was in fact
1318
+ * waiting for a human. Only `abortRun`/`completeRun`/`failRun` finish a run.
1319
+ */
1320
+ export async function interruptRun(
1321
+ runs: RunStore | undefined,
1322
+ runId: string,
1323
+ ): Promise<void> {
1324
+ await runs?.update(runId, {
1325
+ status: 'interrupted',
1326
+ })
1327
+ }
1328
+
1329
+ /**
1330
+ * Record that the run has ended for good — an explicit cancel, or a disconnect
1331
+ * on a run that has nothing to reattach to. Terminal, so it carries
1332
+ * `finishedAt`.
1333
+ */
1334
+ export async function abortRun(
1335
+ runs: RunStore | undefined,
1336
+ runId: string,
1337
+ ): Promise<void> {
1338
+ await runs?.update(runId, {
1339
+ status: 'aborted',
1340
+ finishedAt: Date.now(),
1341
+ })
1342
+ }
1343
+
1344
+ /**
1345
+ * Whether some middleware has declared this run detachable — i.e. it has a
1346
+ * durable event log and a run store, so a disconnect can be survived and the
1347
+ * run picked back up rather than destroyed.
1348
+ *
1349
+ * The capability is read from CORE, never from `@tanstack/ai-sandbox`: sandbox
1350
+ * provides it, persistence consumes it, and a persistence → sandbox import
1351
+ * would invert the layering.
1352
+ */
1353
+ function detachableRun(ctx: ChatMiddlewareContext): boolean {
1354
+ return getDetachableRun(ctx, { optional: true }) === true
1355
+ }
1356
+
1357
+ // ---------------------------------------------------------------------------
1358
+ // Chat middleware
1359
+ // ---------------------------------------------------------------------------
1360
+
1361
+ /**
1362
+ * Chat-only **state** persistence middleware. Provides durable transcript,
1363
+ * run records, and interrupts for `chat()`. Does **not** provide locks —
1364
+ * use `withLocks` from `@tanstack/ai` for multi-instance coordination.
1365
+ *
1366
+ * This middleware never mutates the chunk stream; delivery durability
1367
+ * (replaying a disconnected/reloaded stream) is a separate transport-layer
1368
+ * concern (see the resumable-streams docs).
1369
+ *
1370
+ * Requires `stores.messages`. When `stores.interrupts` is present,
1371
+ * `stores.runs` is also required.
1372
+ *
1373
+ * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty
1374
+ * `messages` array it is treated as the FULL conversation history and, on
1375
+ * finish, **overwrites** the entire stored thread. Post only the complete
1376
+ * transcript, never a delta — sending just the newest message(s) will replace
1377
+ * (and thereby destroy) the stored thread. To continue a stored thread without
1378
+ * resending history, pass an empty `messages` array and the stored transcript
1379
+ * is loaded and used.
1380
+ */
1381
+ export interface WithPersistenceOptions {
1382
+ /**
1383
+ * Also persist a throttled snapshot of the in-progress assistant reply while
1384
+ * it streams. Off by default — the transcript is otherwise persisted at the
1385
+ * pending turn (`onStart`), interrupt boundaries, and completion (`onFinish`).
1386
+ * Enable it to recover partial output if the process dies mid-generation, at
1387
+ * the cost of extra writes. Snapshots are throttled to at most one per
1388
+ * {@link WithPersistenceOptions.snapshotIntervalMs}.
1389
+ */
1390
+ snapshotStreaming?: boolean
1391
+ /**
1392
+ * Minimum milliseconds between streaming snapshots when `snapshotStreaming`
1393
+ * is on. Defaults to 1000.
1394
+ */
1395
+ snapshotIntervalMs?: number
1396
+ }
1397
+
1398
+ /**
1399
+ * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages
1400
+ * required). Known-absent `messages` or `interrupts` without `runs` fail at
1401
+ * compile time; fully dynamic bags are checked at runtime.
1402
+ */
1403
+ export function withPersistence<TStores extends ChatTranscriptStores>(
1404
+ persistence: AIPersistence<TStores> & ValidChatPersistence<TStores>,
1405
+ options: WithPersistenceOptions = {},
1406
+ ): ChatMiddleware {
1407
+ // Runtime validation covers dynamic bags that bypass the generic constraint.
1408
+ validateChatPersistenceStores(persistence)
1409
+ const snapshotStreaming = options.snapshotStreaming ?? false
1410
+ const snapshotIntervalMs = options.snapshotIntervalMs ?? 1000
1411
+ const plan = resolvePersistencePlan(persistence)
1412
+ const { wantsInterrupts, runs } = plan
1413
+ const messageStore = persistence.stores.messages
1414
+ if (!messageStore) {
1415
+ // validateChatPersistenceStores already throws; this narrows for TypeScript.
1416
+ throw new Error('Chat persistence requires stores.messages.')
1417
+ }
1418
+
1419
+ const provides = [
1420
+ PersistenceCapability,
1421
+ ...(wantsInterrupts ? [InterruptsCapability] : []),
1422
+ ]
1423
+
1424
+ return defineChatMiddleware({
1425
+ name: 'chat-persistence',
1426
+ provides,
1427
+ setup(ctx: ChatMiddlewareContext) {
1428
+ providePersistence(ctx, persistence)
1429
+
1430
+ runState.set(ctx, {
1431
+ merged: false,
1432
+ interrupted: false,
1433
+ })
1434
+
1435
+ if (wantsInterrupts && persistence.stores.interrupts) {
1436
+ provideInterrupts(ctx, persistence.stores.interrupts)
1437
+ }
1438
+
1439
+ // Offer the pending-turn seam so a middleware that is about to be SLOW can
1440
+ // have the user's turn stored before it starts. Only `onStart` stores the
1441
+ // turn otherwise, and `onStart` runs after every middleware `setup` — which
1442
+ // is milliseconds for a normal run and MINUTES for one that builds a sandbox.
1443
+ // For that whole window the thread reads as empty, so a reload or a second
1444
+ // device shows no sign of the message the user just sent.
1445
+ //
1446
+ // Offering it changes nothing on its own: a run whose middleware never calls
1447
+ // it behaves exactly as before. See `PendingTurnCapability`.
1448
+ providePendingTurn(ctx, {
1449
+ snapshot: async () => {
1450
+ const stored = await messageStore.loadThread(ctx.threadId)
1451
+ // The SAME rule `onConfig` applies when it merges. Kept here, in the
1452
+ // owner, because `saveThread` REPLACES the thread: a caller that stored
1453
+ // only the newly-sent list would delete the history.
1454
+ const list = ctx.messages.length > 0 ? [...ctx.messages] : stored
1455
+ await messageStore.saveThread(ctx.threadId, list)
1456
+ },
1457
+ })
1458
+ },
1459
+
1460
+ async onConfig(ctx: ChatMiddlewareContext, config: ChatMiddlewareConfig) {
1461
+ if (ctx.phase !== 'init') return
1462
+
1463
+ const patch: Partial<ChatMiddlewareConfig> = {}
1464
+
1465
+ if (wantsInterrupts && persistence.stores.interrupts) {
1466
+ const pending = await persistence.stores.interrupts.listPending(
1467
+ ctx.threadId,
1468
+ )
1469
+ // Gate: a thread with pending interrupts must carry a resume batch that
1470
+ // references them.
1471
+ const resumeByInterruptId = validatePendingResumes(
1472
+ pending,
1473
+ config.resume,
1474
+ )
1475
+ // Persistence is the server-authoritative resume path: translate the
1476
+ // persisted interrupts into the engine's resume tool state and CLEAR
1477
+ // `config.resume`, so the engine skips its ephemeral reconstruction
1478
+ // (which needs a parentRunId and the client message history the
1479
+ // persistence flow deliberately omits).
1480
+ if ((config.resume?.length ?? 0) > 0) {
1481
+ const resumeToolState = resumeToolStateFromPending(
1482
+ pending,
1483
+ resumeByInterruptId,
1484
+ )
1485
+ patch.resume = []
1486
+ if (resumeToolState) patch.resumeToolState = resumeToolState
1487
+ }
1488
+ // Defer marking these interrupts resolved/cancelled until the run
1489
+ // succeeds (see commitPendingResumes). Committing here would consume the
1490
+ // approval even if the run then failed, breaking a retry.
1491
+ const state = runState.get(ctx)
1492
+ if (state && pending.length > 0) {
1493
+ state.pendingResumes = { pending, resumeByInterruptId }
1494
+ }
1495
+ }
1496
+
1497
+ await createOrResumeRun(runs, ctx.runId, ctx.threadId)
1498
+
1499
+ {
1500
+ const state = runState.get(ctx)
1501
+ if (!state?.merged) {
1502
+ if (state) state.merged = true
1503
+ const stored = await messageStore.loadThread(ctx.threadId)
1504
+ patch.messages = config.messages.length > 0 ? config.messages : stored
1505
+ }
1506
+ }
1507
+
1508
+ return Object.keys(patch).length > 0 ? patch : undefined
1509
+ },
1510
+
1511
+ async onStart(ctx: ChatMiddlewareContext) {
1512
+ // (A) Persist the pending turn (the just-submitted user message plus any
1513
+ // prior history) as soon as the run starts, so a reload mid-run rehydrates
1514
+ // it before the assistant reply exists. Best-effort: a failed eager
1515
+ // snapshot must not abort the run — the authoritative save is `onFinish`.
1516
+ try {
1517
+ await messageStore.saveThread(ctx.threadId, [...ctx.messages])
1518
+ } catch {
1519
+ // Eager pre-save is best-effort; the run continues and onFinish saves.
1520
+ }
1521
+ },
1522
+
1523
+ async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) {
1524
+ // Always capture the current assistant turn's stream messageId (cheap),
1525
+ // regardless of snapshotStreaming — it's persisted onto the assistant
1526
+ // message so its identity survives hydrate and a reload resumes the same
1527
+ // bubble in place.
1528
+ if (chunk.type === 'TEXT_MESSAGE_START') {
1529
+ const s = runState.get(ctx)
1530
+ if (s) {
1531
+ s.streamingMessageId = chunk.messageId
1532
+ s.streamingText = ''
1533
+ }
1534
+ }
1535
+
1536
+ // (B) Optional throttled snapshot of the in-progress assistant reply, so
1537
+ // partial output survives a crash/reload before onFinish. Off unless
1538
+ // `snapshotStreaming` is set. We accumulate the terminal turn's text here
1539
+ // (the engine only appends assistant turns with tool calls to
1540
+ // `ctx.messages`, never a streaming text reply), then persist
1541
+ // `ctx.messages` + that partial assistant message (tagged with its id).
1542
+ if (
1543
+ snapshotStreaming &&
1544
+ chunk.type === 'TEXT_MESSAGE_CONTENT' &&
1545
+ typeof chunk.delta === 'string'
1546
+ ) {
1547
+ const snapshotState = runState.get(ctx)
1548
+ if (snapshotState) {
1549
+ snapshotState.streamingText =
1550
+ (snapshotState.streamingText ?? '') + chunk.delta
1551
+ const now = Date.now()
1552
+ if (now - (snapshotState.lastSnapshotAt ?? 0) >= snapshotIntervalMs) {
1553
+ snapshotState.lastSnapshotAt = now
1554
+ try {
1555
+ await messageStore.saveThread(ctx.threadId, [
1556
+ ...ctx.messages,
1557
+ {
1558
+ role: 'assistant',
1559
+ content: snapshotState.streamingText,
1560
+ ...(snapshotState.streamingMessageId
1561
+ ? { id: snapshotState.streamingMessageId }
1562
+ : {}),
1563
+ },
1564
+ ])
1565
+ } catch {
1566
+ // Streaming snapshots are best-effort; onFinish persists final.
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ // State-only: react to the interrupt boundary (create interrupt records,
1573
+ // mark the run interrupted, snapshot thread messages). The chunk stream is
1574
+ // never mutated — delivery durability is a transport-layer concern.
1575
+ if (
1576
+ chunk.type !== 'RUN_FINISHED' ||
1577
+ chunk.outcome?.type !== 'interrupt'
1578
+ ) {
1579
+ return
1580
+ }
1581
+ const state = runState.get(ctx)
1582
+ if (!state) return
1583
+
1584
+ if (wantsInterrupts && persistence.stores.interrupts) {
1585
+ // The run reached a new interrupt boundary, so the resumes it consumed
1586
+ // are committed before the fresh interrupts are recorded.
1587
+ await commitPendingResumes(state, persistence.stores.interrupts)
1588
+ for (const interrupt of chunk.outcome.interrupts) {
1589
+ await persistence.stores.interrupts.create({
1590
+ interruptId: interrupt.id,
1591
+ runId: ctx.runId,
1592
+ threadId: ctx.threadId,
1593
+ requestedAt: Date.now(),
1594
+ payload: interruptPayload(interrupt),
1595
+ })
1596
+ }
1597
+ }
1598
+ await interruptRun(runs, ctx.runId)
1599
+ await messageStore.saveThread(ctx.threadId, [...ctx.messages])
1600
+ state.interrupted = true
1601
+ },
1602
+
1603
+ async onFinish(ctx: ChatMiddlewareContext, info: FinishInfo) {
1604
+ const state = runState.get(ctx)
1605
+ if (state?.interrupted) return
1606
+ // Transcript first: if saveThread fails the run stays non-completed and
1607
+ // resumes stay pending so a retry can re-apply them. Completing the run
1608
+ // or consuming approvals before the durable history lands leaves a
1609
+ // "finished" run whose transcript is missing the terminal turn.
1610
+ await messageStore.saveThread(
1611
+ ctx.threadId,
1612
+ finishedTranscript(ctx.messages, info, state?.streamingMessageId),
1613
+ )
1614
+ await completeRun(runs, ctx.runId, info.usage)
1615
+ await commitPendingResumes(state, persistence.stores.interrupts)
1616
+ },
1617
+
1618
+ async onError(ctx: ChatMiddlewareContext, info: ErrorInfo) {
1619
+ await failRun(runs, ctx.runId, info.error)
1620
+ },
1621
+
1622
+ async onAbort(ctx: ChatMiddlewareContext, info: AbortInfo) {
1623
+ // A user pressing Stop and a user closing the tab produce the IDENTICAL
1624
+ // connection close, so intent is not inferable from the abort. It arrives
1625
+ // out of band in two bands, and either is authoritative: in-process
1626
+ // (`info.cancelRequested`, set when the cancel aborted this host's signal)
1627
+ // and durable (`RunRecord.cancelRequested`, the only channel that reaches
1628
+ // a run being driven elsewhere).
1629
+ const cancelled =
1630
+ info.cancelRequested === true ||
1631
+ (runs !== undefined && (await wasCancelRequested(runs, ctx.runId)))
1632
+
1633
+ // A run paused at an interrupt boundary is waiting for a HUMAN, not for
1634
+ // this socket. `chat()` skips its terminal hook at an actionable-wait
1635
+ // boundary, so its `finally` routes the disconnect here — and
1636
+ // terminalizing then produced a record claiming the run finished while
1637
+ // the interrupt rows stayed `'pending'` and `validatePendingResumes`
1638
+ // still threw on the next request. An explicit cancel is different: the
1639
+ // user gave up on the approval, so the cancel band stays authoritative.
1640
+ const state = runState.get(ctx)
1641
+ if (cancelled || (!detachableRun(ctx) && state?.interrupted !== true)) {
1642
+ await abortRun(runs, ctx.runId)
1643
+ return
1644
+ }
1645
+ // A plain disconnect on a detachable or interrupted run: write NOTHING.
1646
+ // Either the agent is still running and a later attach can take it over
1647
+ // (the record stays `'running'`; the detach path records `detachedSince`
1648
+ // for the reaper), or the run is paused at an interrupt and the record
1649
+ // must stay `'interrupted'` so the pending resumes can still be applied.
1650
+ },
1651
+ })
1652
+ }
1653
+
1654
+ // ---------------------------------------------------------------------------
1655
+ // Generation middleware
1656
+ // ---------------------------------------------------------------------------
1657
+
1658
+ /**
1659
+ * Generation-only persistence middleware. Tracks generation run status (run
1660
+ * records keyed by `runId`) and, when `stores.artifacts` + `stores.blobs` are
1661
+ * both provided, persists the generated media for image, audio, TTS, video, and
1662
+ * transcription activities.
1663
+ *
1664
+ * Requires `stores.generationRuns`. A generation activity has no conversation,
1665
+ * so the run is keyed on its own `runId` (`ctx.runId ?? ctx.requestId`), which
1666
+ * is never faked from anything else.
1667
+ *
1668
+ * A `threadId` is REQUIRED alongside it — not as a link to a chat, but as the
1669
+ * stable app-chosen slot successive runs of the same thing fill
1670
+ * (`product-123-hero`, `video-9-start-frame`). It is what
1671
+ * `stores.generationRuns.findLatestForThread` keys on, and therefore the only
1672
+ * way a run is ever hydrated again. It comes from the `threadId` passed to the
1673
+ * activity, or from {@link WithGenerationPersistenceOptions.threadId} when that
1674
+ * overrides it; supplying neither throws at `onStart` rather than filing a run
1675
+ * nothing can find.
1676
+ *
1677
+ * On success the terminal result metadata (ids, urls — never media bytes) and,
1678
+ * when artifact persistence is on, the persisted artifact refs are captured onto
1679
+ * the run record so a server-authoritative client can hydrate the last
1680
+ * generation for a thread via {@link reconstructGeneration}.
1681
+ */
1682
+ export function withGenerationPersistence<TStores extends AIPersistenceStores>(
1683
+ persistence: AIPersistence<TStores> & ValidGenerationPersistence<TStores>,
1684
+ opts?: WithGenerationPersistenceOptions,
1685
+ ): GenerationMiddleware
1686
+ export function withGenerationPersistence(
1687
+ persistence: AIPersistence,
1688
+ opts: WithGenerationPersistenceOptions = {},
1689
+ ): GenerationMiddleware {
1690
+ validateGenerationPersistenceStores(persistence)
1691
+ const { wantsArtifactPersistence } = resolvePersistencePlan(persistence)
1692
+ const generationRuns = persistence.stores.generationRuns
1693
+ if (!generationRuns) {
1694
+ // validateGenerationPersistenceStores already throws; this narrows for TypeScript.
1695
+ throw new Error('Generation persistence requires stores.generationRuns.')
1696
+ }
1697
+
1698
+ const runIdOf = (ctx: GenerationMiddlewareContext): string =>
1699
+ ctx.runId ?? ctx.requestId
1700
+
1701
+ return {
1702
+ name: 'generation-persistence',
1703
+
1704
+ async onStart(ctx: GenerationMiddlewareContext) {
1705
+ const runId = runIdOf(ctx)
1706
+ await generationRuns.createOrResume({
1707
+ runId,
1708
+ activity: ctx.activity,
1709
+ provider: ctx.provider,
1710
+ model: ctx.model,
1711
+ startedAt: Date.now(),
1712
+ threadId: generationScope(ctx, opts),
1713
+ })
1714
+
1715
+ // Extract + persist artifact bytes (media → blobs, metadata → artifacts)
1716
+ // and merge the resulting refs onto the result. Gated on artifact stores.
1717
+ if (wantsArtifactPersistence) {
1718
+ ctx.resultTransforms?.push(async (result) => {
1719
+ const refs = await persistGenerationArtifacts(
1720
+ persistence,
1721
+ opts,
1722
+ ctx,
1723
+ result,
1724
+ )
1725
+ if (refs.length === 0) return undefined
1726
+ const base = objectValue(result) ?? {}
1727
+ const existing = base.artifacts
1728
+ const withArtifacts = {
1729
+ ...base,
1730
+ artifacts: [...(Array.isArray(existing) ? existing : []), ...refs],
1731
+ }
1732
+ // Point the live result's media at the durable serve URL (when
1733
+ // `artifactUrl` stamped one), so live and restored results match.
1734
+ return applyDurableMediaUrls(withArtifacts, refs)
1735
+ })
1736
+ }
1737
+
1738
+ // Always capture the terminal result metadata + any artifact refs onto the
1739
+ // run record. Registered AFTER the artifact transform so it observes the
1740
+ // fully-merged result (with the artifact refs attached). `result` is
1741
+ // metadata/urls only — the media bytes already live in the blob store.
1742
+ ctx.resultTransforms?.push(async (result) => {
1743
+ const rawArtifacts = objectValue(result)?.artifacts
1744
+ const artifacts = Array.isArray(rawArtifacts)
1745
+ ? rawArtifacts.filter(isArtifactRef)
1746
+ : []
1747
+ await generationRuns.update(runId, {
1748
+ result,
1749
+ ...(artifacts.length > 0 ? { artifacts } : {}),
1750
+ })
1751
+ return undefined
1752
+ })
1753
+ },
1754
+
1755
+ async onFinish(
1756
+ ctx: GenerationMiddlewareContext,
1757
+ info: GenerationFinishInfo,
1758
+ ) {
1759
+ await generationRuns.update(runIdOf(ctx), {
1760
+ status: 'completed',
1761
+ finishedAt: Date.now(),
1762
+ ...(info.usage ? { usage: info.usage } : {}),
1763
+ })
1764
+ },
1765
+
1766
+ async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) {
1767
+ await generationRuns.update(runIdOf(ctx), {
1768
+ status: 'failed',
1769
+ finishedAt: Date.now(),
1770
+ error: {
1771
+ message:
1772
+ info.error instanceof Error
1773
+ ? info.error.message
1774
+ : String(info.error),
1775
+ },
1776
+ })
1777
+ },
1778
+
1779
+ async onAbort(
1780
+ ctx: GenerationMiddlewareContext,
1781
+ _info: GenerationAbortInfo,
1782
+ ) {
1783
+ // Unconditional, unlike chat's: a generation job has no journal and no
1784
+ // agent loop, so there is nothing to reattach to. An aborted generation is
1785
+ // over, full stop — hence `'aborted'` (terminal) rather than
1786
+ // `'interrupted'`, which now means "parked, waiting for a human" and is
1787
+ // deliberately NOT terminal-shaped, so pairing it with `finishedAt` would
1788
+ // leave the run looking permanently active.
1789
+ await generationRuns.update(runIdOf(ctx), {
1790
+ status: 'aborted',
1791
+ finishedAt: Date.now(),
1792
+ })
1793
+ },
1794
+ }
1795
+ }