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