@ouraihub/aether-worker 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,480 @@
1
+ import type {
2
+ Conversation,
3
+ ConversationSummary,
4
+ ImagePart,
5
+ ImageRef,
6
+ RunAccepted,
7
+ RunRequest,
8
+ } from '@ouraihub/aether-contracts'
9
+ import type { RunInput, RunSummary } from '@ouraihub/aether-core/types'
10
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
11
+ import { canonicalJson } from '@ouraihub/aether-core/guard'
12
+ import { decodeImage, parseImage } from '@ouraihub/aether-core/images'
13
+ import type { Actor, WorkerContext } from '../context.ts'
14
+ import { handleConversationRequest } from './routes.ts'
15
+ import { retryCas } from '../storage.ts'
16
+ import type { DocumentStore, RetryOptions, StoredDocument } from '../storage.ts'
17
+ import { authoritative, fingerprint, resolveAction } from './action.ts'
18
+ import type { PreparedAction, ReservationInput } from './action.ts'
19
+ import {
20
+ COLLECTION,
21
+ checkId,
22
+ checkText,
23
+ conflict,
24
+ emptyUsage,
25
+ historyPath,
26
+ idle,
27
+ imageRefs,
28
+ notFound,
29
+ parseDocument,
30
+ project,
31
+ target,
32
+ visibleText,
33
+ } from './model.ts'
34
+ import type { ConversationDocument, StoredTurn } from './model.ts'
35
+ export type { PreparedAction, ReservationInput } from './action.ts'
36
+ export type { ConversationDocument, StoredImage, StoredTurn } from './model.ts'
37
+ export { IMAGE_TTL_DAYS } from './model.ts'
38
+
39
+ /** Persist this before reservation; it contains identity only, never provider credentials. */
40
+ export interface AcceptedHandle {
41
+ runId: string
42
+ conversationId: string
43
+ turnId: string
44
+ reservationId: string
45
+ }
46
+ export interface TurnReservation {
47
+ handle: AcceptedHandle
48
+ accepted: RunAccepted
49
+ input: ReservationInput
50
+ runInput: RunInput
51
+ }
52
+ export interface ConversationOptions {
53
+ store: DocumentStore
54
+ now?: () => number
55
+ newId?: () => string
56
+ /** CAS retry schedule; tests inject sleep/random to make contention deterministic. */
57
+ retry?: RetryOptions
58
+ }
59
+ export class ConversationService {
60
+ private readonly store: DocumentStore
61
+ private readonly now: () => number
62
+ private readonly newId: () => string
63
+ private readonly retry: RetryOptions
64
+ constructor(options: ConversationOptions) {
65
+ this.store = options.store
66
+ this.now = options.now ?? Date.now
67
+ this.newId = options.newId ?? (() => crypto.randomUUID())
68
+ this.retry = options.retry ?? {}
69
+ }
70
+ /** Reloads and re-applies `attempt` on revision conflicts; see `retryCas`. */
71
+ private update<T>(attempt: Parameters<typeof retryCas<T>>[0]): Promise<T> {
72
+ return retryCas(attempt, () => conflict('conversation_contended'), this.retry)
73
+ }
74
+ private async load(
75
+ id: string,
76
+ actor: Actor,
77
+ ): Promise<{ revision: number; document: ConversationDocument }> {
78
+ checkId('conversationId', id)
79
+ const stored = await this.store.get(actor, COLLECTION, id)
80
+ if (!stored) throw notFound('conversation')
81
+ return { revision: stored.revision, document: parseDocument(stored.value, id) }
82
+ }
83
+ private async *documents(actor: Actor): AsyncGenerator<StoredDocument> {
84
+ let after: string | undefined
85
+ while (true) {
86
+ const page = await this.store.list(actor, COLLECTION, {
87
+ ...(after === undefined ? {} : { after }),
88
+ limit: 200,
89
+ })
90
+ for (const stored of page) yield stored
91
+ if (page.length < 200) return
92
+ const last = page.at(-1)?.key
93
+ if (last === undefined || (after !== undefined && last <= after))
94
+ throw new KitFailure('internal', 'Invalid document pagination')
95
+ after = last
96
+ }
97
+ }
98
+ async list(scope: string | null, actor: Actor): Promise<ConversationSummary[]> {
99
+ if (scope !== null) checkText('scope', scope, 200)
100
+ const summaries: ConversationSummary[] = []
101
+ for await (const stored of this.documents(actor)) {
102
+ const summary = parseDocument(stored.value, stored.key).summary
103
+ if (scope === null || summary.scope === scope) summaries.push(summary)
104
+ }
105
+ return summaries
106
+ .sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
107
+ .slice(0, 200)
108
+ }
109
+ async get(id: string, actor: Actor): Promise<Conversation> {
110
+ return project((await this.load(id, actor)).document)
111
+ }
112
+ async resolve(request: RunRequest, actor: Actor): Promise<PreparedAction> {
113
+ const snapshot = structuredClone(request)
114
+ const document = snapshot.conversationId
115
+ ? (await this.load(snapshot.conversationId, actor)).document
116
+ : null
117
+ return resolveAction(snapshot, document)
118
+ }
119
+ plan(prepared: PreparedAction, runId: string): AcceptedHandle {
120
+ checkId('runId', runId)
121
+ const conversationId = prepared.request.conversationId ?? `${runId}-conversation`
122
+ checkId('conversationId', conversationId)
123
+ return { runId, conversationId, turnId: runId, reservationId: runId }
124
+ }
125
+ async reserve(
126
+ prepared: PreparedAction,
127
+ validatedInput: RunInput,
128
+ runId: string,
129
+ actor: Actor,
130
+ validate?: (input: ReservationInput, execution: RunInput) => RunInput,
131
+ ): Promise<TurnReservation> {
132
+ const handle = this.plan(prepared, runId)
133
+ return this.update(async (cas) => {
134
+ const stored = await this.store.get(actor, COLLECTION, handle.conversationId)
135
+ if (prepared.request.conversationId && !stored) throw conflict('preparation_changed')
136
+ if (!prepared.request.conversationId && stored) throw conflict('run_identity_exists')
137
+ const document = stored ? parseDocument(stored.value, stored.key) : null
138
+ let input: ReservationInput
139
+ try {
140
+ input = authoritative(prepared.request, prepared.action, prepared.selectionGuard, document)
141
+ } catch (error) {
142
+ if (error instanceof KitFailure && error.details?.reason === 'turn_running') throw error
143
+ throw conflict('preparation_changed')
144
+ }
145
+ if (
146
+ (await fingerprint(
147
+ prepared.request,
148
+ prepared.action,
149
+ prepared.selectionGuard,
150
+ document,
151
+ input,
152
+ )) !== prepared.expected
153
+ )
154
+ throw conflict('preparation_changed')
155
+ const runInput = validate ? validate(input, validatedInput) : validatedInput
156
+ if (
157
+ runInput.runId !== runId ||
158
+ runInput.input !== input.input ||
159
+ canonicalJson(runInput.images) !== canonicalJson(input.images)
160
+ )
161
+ throw conflict('validated_input_changed')
162
+ if (
163
+ prepared.requiresSourceModel &&
164
+ (!input.sourceModel ||
165
+ runInput.connection.endpointId !== input.sourceModel.endpointId ||
166
+ runInput.connection.modelId !== input.sourceModel.modelId)
167
+ )
168
+ throw new KitFailure('needs_config', 'Source model unavailable', {
169
+ reason: 'source_model_unavailable',
170
+ })
171
+ const now = this.now(),
172
+ source =
173
+ document && prepared.action.kind !== 'reply'
174
+ ? target(document, prepared.action.sourceId)
175
+ : null
176
+ const parentId =
177
+ prepared.action.kind === 'reply'
178
+ ? prepared.action.parentId
179
+ : prepared.action.kind === 'translation'
180
+ ? (source?.id ?? null)
181
+ : (source?.parentId ?? null)
182
+ const turn: StoredTurn = {
183
+ id: handle.turnId,
184
+ seq: (document?.turns.at(-1)?.seq ?? 0) + 1,
185
+ runId,
186
+ reservationId: handle.reservationId,
187
+ parentId,
188
+ questionId: prepared.action.kind === 'retry' && source ? source.questionId : handle.turnId,
189
+ supersedesId: prepared.action.kind === 'retry' ? (source?.id ?? null) : null,
190
+ kind: prepared.action.kind === 'translation' ? 'translation' : (source?.kind ?? 'reply'),
191
+ status: 'running',
192
+ input: input.input,
193
+ messages: [],
194
+ modelId: runInput.connection.modelId,
195
+ endpointId: runInput.connection.endpointId,
196
+ skills: structuredClone(prepared.request.skills),
197
+ errorCode: null,
198
+ usage: emptyUsage(),
199
+ contextUsage: null,
200
+ reasoningEffort: runInput.reasoningEffort,
201
+ cost: null,
202
+ createdAt: now,
203
+ images:
204
+ prepared.action.kind === 'retry' && source
205
+ ? structuredClone(source.images)
206
+ : input.images.map((image) => ({ ...image, createdAt: now })),
207
+ generated: [],
208
+ }
209
+ const next: ConversationDocument = document ?? {
210
+ version: 1,
211
+ summary: {
212
+ id: handle.conversationId,
213
+ title: [...input.input.replace(/[\r\n]/gu, '')].slice(0, 40).join(''),
214
+ scope: prepared.request.scope ?? null,
215
+ selectedTurnId: null,
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ },
219
+ turns: [],
220
+ }
221
+ next.turns.push(turn)
222
+ next.summary.updatedAt = now
223
+ const checked = parseDocument(next, handle.conversationId)
224
+ await cas(() =>
225
+ this.store.compareAndSet(
226
+ actor,
227
+ COLLECTION,
228
+ handle.conversationId,
229
+ stored?.revision ?? null,
230
+ checked,
231
+ ),
232
+ )
233
+ return {
234
+ handle,
235
+ accepted: { runId, conversationId: handle.conversationId, turnId: handle.turnId },
236
+ input,
237
+ runInput,
238
+ }
239
+ })
240
+ }
241
+ private checkHandle(document: ConversationDocument, handle: AcceptedHandle): StoredTurn {
242
+ if (document.summary.id !== handle.conversationId) throw notFound('turn')
243
+ const turn = target(document, handle.turnId)
244
+ if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId)
245
+ throw notFound('turn')
246
+ if (turn.status !== 'running') throw conflict('turn_finished')
247
+ return turn
248
+ }
249
+ async finish(handle: AcceptedHandle, summary: RunSummary, actor: Actor): Promise<Conversation> {
250
+ return this.update(async (cas) => {
251
+ const { document, revision } = await this.load(handle.conversationId, actor),
252
+ turn = this.checkHandle(document, handle)
253
+ turn.status = summary.status
254
+ turn.messages = structuredClone(summary.messages)
255
+ turn.usage = structuredClone(summary.usage)
256
+ turn.cost = structuredClone(summary.cost)
257
+ turn.contextUsage = structuredClone(summary.contextUsage)
258
+ turn.errorCode = summary.status === 'failed' ? (summary.error?.code ?? 'internal') : null
259
+ document.summary.updatedAt = this.now()
260
+ if (summary.status === 'completed' && turn.supersedesId === null)
261
+ document.summary.selectedTurnId = turn.id
262
+ const checked = parseDocument(document, document.summary.id)
263
+ await cas(() =>
264
+ this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, checked),
265
+ )
266
+ return project(checked)
267
+ })
268
+ }
269
+ /** Only the coordinator, having established interruption, may recover a planned run. */
270
+ async recover(handle: AcceptedHandle, actor: Actor): Promise<Conversation> {
271
+ return this.update(async (cas) => {
272
+ const { document, revision } = await this.load(handle.conversationId, actor),
273
+ turn = this.checkHandle(document, handle)
274
+ turn.status = 'failed'
275
+ turn.errorCode = 'interrupted'
276
+ turn.contextUsage = null
277
+ document.summary.updatedAt = this.now()
278
+ await cas(() =>
279
+ this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
280
+ )
281
+ return project(document)
282
+ })
283
+ }
284
+ async addGenerated(
285
+ handle: AcceptedHandle,
286
+ images: readonly ImagePart[],
287
+ actor: Actor,
288
+ ): Promise<ImageRef[]> {
289
+ for (const image of images) parseImage(image)
290
+ return this.update(async (cas) => {
291
+ const { document, revision } = await this.load(handle.conversationId, actor),
292
+ turn = this.checkHandle(document, handle),
293
+ first = turn.generated.length
294
+ const now = this.now()
295
+ turn.generated.push(...images.map((image) => ({ ...image, createdAt: now })))
296
+ await cas(() =>
297
+ this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
298
+ )
299
+ return imageRefs(handle.conversationId, handle.turnId, 'generated', turn.generated).slice(
300
+ first,
301
+ )
302
+ })
303
+ }
304
+ async purgeImages(beforeMs: number, actor: Actor): Promise<number> {
305
+ if (!Number.isSafeInteger(beforeMs) || beforeMs < 0) throw badRequest('beforeMs', 'invalid')
306
+ let total = 0
307
+ for await (const listed of this.documents(actor)) {
308
+ // The listed revision is tried first; after a conflict the document is reloaded, and one
309
+ // deleted meanwhile has nothing left to purge.
310
+ let current: StoredDocument | null = listed
311
+ total += await this.update(async (cas) => {
312
+ current ??= await this.store.get(actor, COLLECTION, listed.key)
313
+ const stored = current
314
+ current = null
315
+ if (stored === null) return 0
316
+ const document = parseDocument(stored.value, stored.key)
317
+ let count = 0
318
+ for (const turn of document.turns)
319
+ for (const image of [...turn.images, ...turn.generated])
320
+ if (image.data !== null && image.createdAt < beforeMs) {
321
+ image.data = null
322
+ count++
323
+ }
324
+ if (count)
325
+ await cas(() =>
326
+ this.store.compareAndSet(actor, COLLECTION, stored.key, stored.revision, document),
327
+ )
328
+ return count
329
+ })
330
+ }
331
+ return total
332
+ }
333
+ async rename(id: string, title: string, actor: Actor): Promise<ConversationSummary> {
334
+ checkText('title', title, 200, true)
335
+ return this.update(async (cas) => {
336
+ const { document, revision } = await this.load(id, actor)
337
+ idle(document)
338
+ document.summary.title = title.trim()
339
+ await cas(() => this.store.compareAndSet(actor, COLLECTION, id, revision, document))
340
+ return document.summary
341
+ })
342
+ }
343
+ async delete(id: string, actor: Actor): Promise<void> {
344
+ const { document, revision } = await this.load(id, actor)
345
+ idle(document)
346
+ await this.store.remove(actor, COLLECTION, id, revision)
347
+ }
348
+ async editTurn(
349
+ id: string,
350
+ turnId: string,
351
+ text: string,
352
+ expectedText: string,
353
+ actor: Actor,
354
+ ): Promise<Conversation> {
355
+ checkText('text', text, 24_000, true)
356
+ const { document, revision } = await this.load(id, actor)
357
+ idle(document)
358
+ const turn = target(document, turnId)
359
+ if (document.turns.some((t) => t.parentId === turnId || t.supersedesId === turnId))
360
+ throw conflict('turn_has_dependents')
361
+ if (visibleText(turn) !== expectedText) throw conflict('text_changed')
362
+ turn.messages = [{ assistant: { text: text.trim(), tool_calls: [] } }]
363
+ turn.contextUsage = null
364
+ document.summary.updatedAt = this.now()
365
+ await this.store.compareAndSet(actor, COLLECTION, id, revision, document)
366
+ return project(document)
367
+ }
368
+ async selectTurn(id: string, turnId: string, actor: Actor): Promise<Conversation> {
369
+ const { document, revision } = await this.load(id, actor)
370
+ idle(document)
371
+ if (target(document, turnId).status !== 'completed') throw conflict('turn_not_completed')
372
+ document.summary.selectedTurnId = turnId
373
+ document.summary.updatedAt = this.now()
374
+ await this.store.compareAndSet(actor, COLLECTION, id, revision, document)
375
+ return project(document)
376
+ }
377
+ async removeTurn(id: string, turnId: string, actor: Actor): Promise<Conversation> {
378
+ const { document, revision } = await this.load(id, actor)
379
+ idle(document)
380
+ const removed = target(document, turnId)
381
+ const siblings = document.turns.filter(
382
+ (t) => t.id !== turnId && t.kind === 'reply' && t.questionId === removed.questionId,
383
+ )
384
+ const sibling = siblings.find((t) => t.seq > removed.seq) ?? siblings.at(-1),
385
+ successor = sibling?.id ?? removed.parentId
386
+ const deleted = new Set(
387
+ document.turns
388
+ .filter((t) => t.id === turnId || (t.kind === 'translation' && t.parentId === turnId))
389
+ .map((t) => t.id),
390
+ )
391
+ document.turns = document.turns.filter((t) => !deleted.has(t.id))
392
+ for (const turn of document.turns) {
393
+ if (turn.parentId !== null && deleted.has(turn.parentId))
394
+ turn.parentId = successor === turn.id ? removed.parentId : successor
395
+ if (turn.supersedesId !== null && deleted.has(turn.supersedesId))
396
+ turn.supersedesId = sibling && sibling.id !== turn.id ? sibling.id : null
397
+ }
398
+ if (document.summary.selectedTurnId !== null && deleted.has(document.summary.selectedTurnId)) {
399
+ let next =
400
+ successor === null
401
+ ? document.turns.findLast((t) => t.kind === 'reply' && t.parentId === null)
402
+ : document.turns.find((t) => t.id === successor)
403
+ document.summary.selectedTurnId = null
404
+ const seen = new Set<string>()
405
+ while (next) {
406
+ if (seen.has(next.id)) throw conflict('history_cycle')
407
+ seen.add(next.id)
408
+ const head = next.id
409
+ document.summary.selectedTurnId = head
410
+ next = document.turns.findLast((t) => t.kind === 'reply' && t.parentId === head)
411
+ }
412
+ }
413
+ document.summary.updatedAt = this.now()
414
+ const checked = parseDocument(document, id)
415
+ await this.store.compareAndSet(actor, COLLECTION, id, revision, checked)
416
+ return project(checked)
417
+ }
418
+ async fork(id: string, turnId: string, actor: Actor): Promise<Conversation> {
419
+ const { document } = await this.load(id, actor)
420
+ idle(document)
421
+ const path = historyPath(document, turnId),
422
+ forkId = this.newId(),
423
+ ids = new Map(path.map((t) => [t.id, this.newId()])),
424
+ questions = new Map<string, string>()
425
+ for (const turn of path)
426
+ if (!questions.has(turn.questionId))
427
+ questions.set(turn.questionId, ids.get(turn.questionId) ?? this.newId())
428
+ const now = this.now(),
429
+ selected = ids.get(turnId)
430
+ if (!selected) throw notFound('turn')
431
+ const turns = path.map((turn, index): StoredTurn => {
432
+ const newId = ids.get(turn.id),
433
+ questionId = questions.get(turn.questionId)
434
+ if (!newId || !questionId) throw conflict('history_changed')
435
+ return {
436
+ ...structuredClone(turn),
437
+ id: newId,
438
+ seq: index + 1,
439
+ parentId: turn.parentId ? (ids.get(turn.parentId) ?? null) : null,
440
+ questionId,
441
+ supersedesId: turn.supersedesId ? (ids.get(turn.supersedesId) ?? null) : null,
442
+ }
443
+ })
444
+ const fork: ConversationDocument = {
445
+ version: 1,
446
+ summary: {
447
+ ...document.summary,
448
+ id: forkId,
449
+ selectedTurnId: selected,
450
+ createdAt: now,
451
+ updatedAt: now,
452
+ },
453
+ turns,
454
+ }
455
+ await this.store.compareAndSet(actor, COLLECTION, forkId, null, fork)
456
+ return project(fork)
457
+ }
458
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
459
+ return handleConversationRequest(
460
+ this,
461
+ request,
462
+ context,
463
+ async (id, turnId, kind, index, actor) => {
464
+ const { document } = await this.load(id, actor),
465
+ turn = target(document, turnId),
466
+ image = (kind === 'input' ? turn.images : turn.generated)[index]
467
+ if (!image || image.data === null) throw notFound('image')
468
+ const bytes = decodeImage({ mime: image.mime, data: image.data })
469
+ return new Response(bytes, {
470
+ headers: {
471
+ 'Content-Type': image.mime,
472
+ 'Cache-Control': 'private, no-store',
473
+ 'X-Content-Type-Options': 'nosniff',
474
+ 'Content-Security-Policy': "default-src 'none'",
475
+ },
476
+ })
477
+ },
478
+ )
479
+ }
480
+ }