@ouraihub/aether-worker 0.1.0 → 0.1.2

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.
@@ -8,10 +8,12 @@ import type {
8
8
  } from '@ouraihub/aether-contracts'
9
9
  import type { RunInput, RunSummary } from '@ouraihub/aether-core/types'
10
10
  import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
11
- import { canonicalJson, isRecord } from '@ouraihub/aether-core/guard'
11
+ import { canonicalJson } from '@ouraihub/aether-core/guard'
12
12
  import { decodeImage, parseImage } from '@ouraihub/aether-core/images'
13
13
  import type { Actor, WorkerContext } from '../context.ts'
14
- import type { DocumentStore, StoredDocument } from '../storage.ts'
14
+ import { handleConversationRequest } from './routes.ts'
15
+ import { retryCas } from '../storage.ts'
16
+ import type { DocumentStore, RetryOptions, StoredDocument } from '../storage.ts'
15
17
  import { authoritative, fingerprint, resolveAction } from './action.ts'
16
18
  import type { PreparedAction, ReservationInput } from './action.ts'
17
19
  import {
@@ -51,17 +53,28 @@ export interface ConversationOptions {
51
53
  store: DocumentStore
52
54
  now?: () => number
53
55
  newId?: () => string
56
+ /** CAS retry schedule; tests inject sleep/random to make contention deterministic. */
57
+ retry?: RetryOptions
54
58
  }
55
59
  export class ConversationService {
56
60
  private readonly store: DocumentStore
57
61
  private readonly now: () => number
58
62
  private readonly newId: () => string
63
+ private readonly retry: RetryOptions
59
64
  constructor(options: ConversationOptions) {
60
65
  this.store = options.store
61
66
  this.now = options.now ?? Date.now
62
67
  this.newId = options.newId ?? (() => crypto.randomUUID())
68
+ this.retry = options.retry ?? {}
63
69
  }
64
- private async load(id: string, actor: Actor): Promise<{ revision: number; document: ConversationDocument }> {
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 }> {
65
78
  checkId('conversationId', id)
66
79
  const stored = await this.store.get(actor, COLLECTION, id)
67
80
  if (!stored) throw notFound('conversation')
@@ -70,7 +83,10 @@ export class ConversationService {
70
83
  private async *documents(actor: Actor): AsyncGenerator<StoredDocument> {
71
84
  let after: string | undefined
72
85
  while (true) {
73
- const page = await this.store.list(actor, COLLECTION, { ...(after === undefined ? {} : { after }), limit: 200 })
86
+ const page = await this.store.list(actor, COLLECTION, {
87
+ ...(after === undefined ? {} : { after }),
88
+ limit: 200,
89
+ })
74
90
  for (const stored of page) yield stored
75
91
  if (page.length < 200) return
76
92
  const last = page.at(-1)?.key
@@ -86,14 +102,18 @@ export class ConversationService {
86
102
  const summary = parseDocument(stored.value, stored.key).summary
87
103
  if (scope === null || summary.scope === scope) summaries.push(summary)
88
104
  }
89
- return summaries.sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)).slice(0, 200)
105
+ return summaries
106
+ .sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
107
+ .slice(0, 200)
90
108
  }
91
109
  async get(id: string, actor: Actor): Promise<Conversation> {
92
110
  return project((await this.load(id, actor)).document)
93
111
  }
94
112
  async resolve(request: RunRequest, actor: Actor): Promise<PreparedAction> {
95
113
  const snapshot = structuredClone(request)
96
- const document = snapshot.conversationId ? (await this.load(snapshot.conversationId, actor)).document : null
114
+ const document = snapshot.conversationId
115
+ ? (await this.load(snapshot.conversationId, actor)).document
116
+ : null
97
117
  return resolveAction(snapshot, document)
98
118
  }
99
119
  plan(prepared: PreparedAction, runId: string): AcceptedHandle {
@@ -110,7 +130,7 @@ export class ConversationService {
110
130
  validate?: (input: ReservationInput, execution: RunInput) => RunInput,
111
131
  ): Promise<TurnReservation> {
112
132
  const handle = this.plan(prepared, runId)
113
- for (let attempt = 0; attempt < 8; attempt++) {
133
+ return this.update(async (cas) => {
114
134
  const stored = await this.store.get(actor, COLLECTION, handle.conversationId)
115
135
  if (prepared.request.conversationId && !stored) throw conflict('preparation_changed')
116
136
  if (!prepared.request.conversationId && stored) throw conflict('run_identity_exists')
@@ -123,8 +143,13 @@ export class ConversationService {
123
143
  throw conflict('preparation_changed')
124
144
  }
125
145
  if (
126
- (await fingerprint(prepared.request, prepared.action, prepared.selectionGuard, document, input)) !==
127
- prepared.expected
146
+ (await fingerprint(
147
+ prepared.request,
148
+ prepared.action,
149
+ prepared.selectionGuard,
150
+ document,
151
+ input,
152
+ )) !== prepared.expected
128
153
  )
129
154
  throw conflict('preparation_changed')
130
155
  const runInput = validate ? validate(input, validatedInput) : validatedInput
@@ -140,9 +165,14 @@ export class ConversationService {
140
165
  runInput.connection.endpointId !== input.sourceModel.endpointId ||
141
166
  runInput.connection.modelId !== input.sourceModel.modelId)
142
167
  )
143
- throw new KitFailure('needs_config', 'Source model unavailable', { reason: 'source_model_unavailable' })
168
+ throw new KitFailure('needs_config', 'Source model unavailable', {
169
+ reason: 'source_model_unavailable',
170
+ })
144
171
  const now = this.now(),
145
- source = document && prepared.action.kind !== 'reply' ? target(document, prepared.action.sourceId) : null
172
+ source =
173
+ document && prepared.action.kind !== 'reply'
174
+ ? target(document, prepared.action.sourceId)
175
+ : null
146
176
  const parentId =
147
177
  prepared.action.kind === 'reply'
148
178
  ? prepared.action.parentId
@@ -191,30 +221,33 @@ export class ConversationService {
191
221
  next.turns.push(turn)
192
222
  next.summary.updatedAt = now
193
223
  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
- }
224
+ await cas(() =>
225
+ this.store.compareAndSet(
226
+ actor,
227
+ COLLECTION,
228
+ handle.conversationId,
229
+ stored?.revision ?? null,
230
+ checked,
231
+ ),
232
+ )
200
233
  return {
201
234
  handle,
202
235
  accepted: { runId, conversationId: handle.conversationId, turnId: handle.turnId },
203
236
  input,
204
237
  runInput,
205
238
  }
206
- }
207
- throw conflict('conversation_contended')
239
+ })
208
240
  }
209
241
  private checkHandle(document: ConversationDocument, handle: AcceptedHandle): StoredTurn {
210
242
  if (document.summary.id !== handle.conversationId) throw notFound('turn')
211
243
  const turn = target(document, handle.turnId)
212
- if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId) throw notFound('turn')
244
+ if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId)
245
+ throw notFound('turn')
213
246
  if (turn.status !== 'running') throw conflict('turn_finished')
214
247
  return turn
215
248
  }
216
249
  async finish(handle: AcceptedHandle, summary: RunSummary, actor: Actor): Promise<Conversation> {
217
- for (let attempt = 0; attempt < 8; attempt++) {
250
+ return this.update(async (cas) => {
218
251
  const { document, revision } = await this.load(handle.conversationId, actor),
219
252
  turn = this.checkHandle(document, handle)
220
253
  turn.status = summary.status
@@ -224,88 +257,101 @@ export class ConversationService {
224
257
  turn.contextUsage = structuredClone(summary.contextUsage)
225
258
  turn.errorCode = summary.status === 'failed' ? (summary.error?.code ?? 'internal') : null
226
259
  document.summary.updatedAt = this.now()
227
- if (summary.status === 'completed' && turn.supersedesId === null) document.summary.selectedTurnId = turn.id
260
+ if (summary.status === 'completed' && turn.supersedesId === null)
261
+ document.summary.selectedTurnId = turn.id
228
262
  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
- }
263
+ await cas(() =>
264
+ this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, checked),
265
+ )
235
266
  return project(checked)
236
- }
237
- throw conflict('conversation_contended')
267
+ })
238
268
  }
239
269
  /** Only the coordinator, having established interruption, may recover a planned run. */
240
270
  async recover(handle: AcceptedHandle, actor: Actor): Promise<Conversation> {
241
- for (let attempt = 0; attempt < 8; attempt++) {
271
+ return this.update(async (cas) => {
242
272
  const { document, revision } = await this.load(handle.conversationId, actor),
243
273
  turn = this.checkHandle(document, handle)
244
274
  turn.status = 'failed'
245
275
  turn.errorCode = 'interrupted'
246
276
  turn.contextUsage = null
247
277
  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
- }
278
+ await cas(() =>
279
+ this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
280
+ )
254
281
  return project(document)
255
- }
256
- throw conflict('conversation_contended')
282
+ })
257
283
  }
258
- async addGenerated(handle: AcceptedHandle, images: readonly ImagePart[], actor: Actor): Promise<ImageRef[]> {
284
+ async addGenerated(
285
+ handle: AcceptedHandle,
286
+ images: readonly ImagePart[],
287
+ actor: Actor,
288
+ ): Promise<ImageRef[]> {
259
289
  for (const image of images) parseImage(image)
260
- for (let attempt = 0; attempt < 8; attempt++) {
290
+ return this.update(async (cas) => {
261
291
  const { document, revision } = await this.load(handle.conversationId, actor),
262
292
  turn = this.checkHandle(document, handle),
263
293
  first = turn.generated.length
264
294
  const now = this.now()
265
295
  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')
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
+ })
275
303
  }
276
304
  async purgeImages(beforeMs: number, actor: Actor): Promise<number> {
277
305
  if (!Number.isSafeInteger(beforeMs) || beforeMs < 0) throw badRequest('beforeMs', 'invalid')
278
306
  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
- }
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
+ })
292
330
  }
293
331
  return total
294
332
  }
295
333
  async rename(id: string, title: string, actor: Actor): Promise<ConversationSummary> {
296
334
  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
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
+ })
302
342
  }
303
343
  async delete(id: string, actor: Actor): Promise<void> {
304
344
  const { document, revision } = await this.load(id, actor)
305
345
  idle(document)
306
346
  await this.store.remove(actor, COLLECTION, id, revision)
307
347
  }
308
- async editTurn(id: string, turnId: string, text: string, expectedText: string, actor: Actor): Promise<Conversation> {
348
+ async editTurn(
349
+ id: string,
350
+ turnId: string,
351
+ text: string,
352
+ expectedText: string,
353
+ actor: Actor,
354
+ ): Promise<Conversation> {
309
355
  checkText('text', text, 24_000, true)
310
356
  const { document, revision } = await this.load(id, actor)
311
357
  idle(document)
@@ -377,7 +423,8 @@ export class ConversationService {
377
423
  ids = new Map(path.map((t) => [t.id, this.newId()])),
378
424
  questions = new Map<string, string>()
379
425
  for (const turn of path)
380
- if (!questions.has(turn.questionId)) questions.set(turn.questionId, ids.get(turn.questionId) ?? this.newId())
426
+ if (!questions.has(turn.questionId))
427
+ questions.set(turn.questionId, ids.get(turn.questionId) ?? this.newId())
381
428
  const now = this.now(),
382
429
  selected = ids.get(turnId)
383
430
  if (!selected) throw notFound('turn')
@@ -396,82 +443,38 @@ export class ConversationService {
396
443
  })
397
444
  const fork: ConversationDocument = {
398
445
  version: 1,
399
- summary: { ...document.summary, id: forkId, selectedTurnId: selected, createdAt: now, updatedAt: now },
446
+ summary: {
447
+ ...document.summary,
448
+ id: forkId,
449
+ selectedTurnId: selected,
450
+ createdAt: now,
451
+ updatedAt: now,
452
+ },
400
453
  turns,
401
454
  }
402
455
  await this.store.compareAndSet(actor, COLLECTION, forkId, null, fork)
403
456
  return project(fork)
404
457
  }
405
458
  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')
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
+ )
476
479
  }
477
480
  }
@@ -53,7 +53,8 @@ export interface ConversationDocument {
53
53
  }
54
54
  export const conflict = (reason: string): KitFailure =>
55
55
  new KitFailure('conflict', 'Conversation changed or unavailable', { reason })
56
- export const notFound = (entity: string): KitFailure => new KitFailure('not_found', 'Resource not found', { entity })
56
+ export const notFound = (entity: string): KitFailure =>
57
+ new KitFailure('not_found', 'Resource not found', { entity })
57
58
  const corrupt = (): never => {
58
59
  throw new KitFailure('incompatible_db', 'Invalid stored conversation')
59
60
  }
@@ -164,9 +165,18 @@ export function validateImages(images: readonly ImagePart[]): void {
164
165
  function reasoning(value: unknown): ReasoningBlock {
165
166
  const v = record(value),
166
167
  protocol = v.protocol
167
- if (protocol !== 'openai-chat' && protocol !== 'openai-responses' && protocol !== 'anthropic-messages')
168
+ if (
169
+ protocol !== 'openai-chat' &&
170
+ protocol !== 'openai-responses' &&
171
+ protocol !== 'anthropic-messages'
172
+ )
168
173
  return corrupt()
169
- return { protocol, endpointId: nullableText(v.endpointId), modelId: nullableText(v.modelId), value: json(v.value) }
174
+ return {
175
+ protocol,
176
+ endpointId: nullableText(v.endpointId),
177
+ modelId: nullableText(v.modelId),
178
+ value: json(v.value),
179
+ }
170
180
  }
171
181
  function json(value: unknown, depth = 0): unknown {
172
182
  if (depth > 64) return corrupt()
@@ -179,7 +189,9 @@ function json(value: unknown, depth = 0): unknown {
179
189
  return value
180
190
  if (Array.isArray(value)) return value.map((item: unknown) => json(item, depth + 1))
181
191
  if (isRecord(value))
182
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item, depth + 1)]))
192
+ return Object.fromEntries(
193
+ Object.entries(value).map(([key, item]) => [key, json(item, depth + 1)]),
194
+ )
183
195
  return corrupt()
184
196
  }
185
197
  function call(value: unknown): ToolCall {
@@ -192,7 +204,8 @@ function jsonRecord(value: unknown): Record<string, unknown> {
192
204
  }
193
205
  export function parseMessage(value: unknown): Message {
194
206
  const v = record(value)
195
- if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1) return corrupt()
207
+ if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1)
208
+ return corrupt()
196
209
  if (typeof v.user === 'string') {
197
210
  if (v.images === undefined) return { user: v.user }
198
211
  return {
@@ -243,7 +256,10 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
243
256
  status = t.status,
244
257
  kind = t.kind
245
258
  if (
246
- (status !== 'running' && status !== 'completed' && status !== 'failed' && status !== 'cancelled') ||
259
+ (status !== 'running' &&
260
+ status !== 'completed' &&
261
+ status !== 'failed' &&
262
+ status !== 'cancelled') ||
247
263
  (kind !== 'reply' && kind !== 'translation')
248
264
  )
249
265
  return corrupt()
@@ -280,7 +296,8 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
280
296
  let running = 0,
281
297
  last = 0
282
298
  for (const t of turns) {
283
- if (!t.id.trim() || charCount(t.id) > 128 || ids.has(t.id) || seqs.has(t.seq) || t.seq <= last) return corrupt()
299
+ if (!t.id.trim() || charCount(t.id) > 128 || ids.has(t.id) || seqs.has(t.seq) || t.seq <= last)
300
+ return corrupt()
284
301
  if (
285
302
  !t.questionId.trim() ||
286
303
  charCount(t.questionId) > 128 ||
@@ -296,7 +313,8 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
296
313
  last = t.seq
297
314
  if (t.status === 'running') running++
298
315
  }
299
- if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId))) return corrupt()
316
+ if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId)))
317
+ return corrupt()
300
318
  for (const t of turns) {
301
319
  if (
302
320
  (t.parentId !== null && !ids.has(t.parentId)) ||
@@ -323,7 +341,9 @@ export function target(document: ConversationDocument, id: string): StoredTurn {
323
341
  return turn
324
342
  }
325
343
  export function visibleText(turn: StoredTurn): string {
326
- return turn.messages.flatMap((m) => ('assistant' in m && m.assistant.text ? [m.assistant.text] : [])).join('\n\n')
344
+ return turn.messages
345
+ .flatMap((m) => ('assistant' in m && m.assistant.text ? [m.assistant.text] : []))
346
+ .join('\n\n')
327
347
  }
328
348
  export function reasoningText(turn: StoredTurn): string {
329
349
  let text = ''
@@ -338,9 +358,14 @@ export function reasoningText(turn: StoredTurn): string {
338
358
  typeof block.value.thinking === 'string'
339
359
  )
340
360
  text += block.value.thinking
341
- else if (block.protocol === 'openai-responses' && isRecord(block.value) && Array.isArray(block.value.summary)) {
361
+ else if (
362
+ block.protocol === 'openai-responses' &&
363
+ isRecord(block.value) &&
364
+ Array.isArray(block.value.summary)
365
+ ) {
342
366
  for (const part of block.value.summary)
343
- if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string') text += part.text
367
+ if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string')
368
+ text += part.text
344
369
  }
345
370
  }
346
371
  }
@@ -0,0 +1,101 @@
1
+ import type { ConversationService } from './index.ts'
2
+ import { badRequest, KitFailure } from '@ouraihub/aether-core/error'
3
+ import type { Actor, WorkerContext } from '../context.ts'
4
+ import { isRecord } from '@ouraihub/aether-core/guard'
5
+
6
+ const notFound = (entity: string): KitFailure =>
7
+ new KitFailure('not_found', 'Resource not found', { entity })
8
+
9
+ type ConversationRouteService = Pick<
10
+ ConversationService,
11
+ 'list' | 'get' | 'delete' | 'rename' | 'editTurn' | 'removeTurn' | 'selectTurn' | 'fork'
12
+ >
13
+ type ImageResponse = (
14
+ id: string,
15
+ turnId: string,
16
+ kind: 'input' | 'generated',
17
+ index: number,
18
+ actor: Actor,
19
+ ) => Promise<Response>
20
+
21
+ export async function handleConversationRequest(
22
+ service: ConversationRouteService,
23
+ request: Request,
24
+ context: WorkerContext,
25
+ imageResponse: ImageResponse,
26
+ ): Promise<Response | null> {
27
+ const url = new URL(request.url),
28
+ base = '/api/v1/conversations',
29
+ actor = context.actor
30
+ if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) return null
31
+ const parts = url.pathname
32
+ .slice(base.length)
33
+ .split('/')
34
+ .filter(Boolean)
35
+ .map((part) => {
36
+ try {
37
+ return decodeURIComponent(part)
38
+ } catch {
39
+ throw badRequest('path', 'encoding')
40
+ }
41
+ })
42
+ const json = (value: unknown): Response => Response.json(value)
43
+ if (parts.length === 0 && request.method === 'GET')
44
+ return json(await service.list(url.searchParams.get('scope'), actor))
45
+ const id = parts[0]
46
+ if (!id) throw notFound('route')
47
+ if (parts.length === 1 && request.method === 'GET') return json(await service.get(id, actor))
48
+ if (parts.length === 1 && request.method === 'DELETE') {
49
+ await service.delete(id, actor)
50
+ return json({ ok: true })
51
+ }
52
+ if (
53
+ parts.length === 6 &&
54
+ parts[1] === 'turns' &&
55
+ parts[3] === 'images' &&
56
+ request.method === 'GET'
57
+ ) {
58
+ const turnId = parts[2],
59
+ kind = parts[4],
60
+ index = parts[5]
61
+ if (
62
+ !turnId ||
63
+ (kind !== 'input' && kind !== 'generated') ||
64
+ !index ||
65
+ !/^(0|[1-9]\d*)$/u.test(index)
66
+ )
67
+ throw notFound('image')
68
+ return imageResponse(id, turnId, kind, Number(index), actor)
69
+ }
70
+ if (parts.length !== 2 || request.method !== 'POST') throw notFound('route')
71
+ let body: unknown
72
+ try {
73
+ body = await request.json()
74
+ } catch {
75
+ throw badRequest('body', 'json')
76
+ }
77
+ if (!isRecord(body)) throw badRequest('body', 'object')
78
+ const action = parts[1],
79
+ fields =
80
+ action === 'rename'
81
+ ? ['title']
82
+ : action === 'edit-turn'
83
+ ? ['turnId', 'text', 'expectedText']
84
+ : ['turnId']
85
+ if (Object.keys(body).some((key) => !fields.includes(key)))
86
+ throw badRequest('body', 'unknown_field')
87
+ if (action === 'rename') {
88
+ if (typeof body.title !== 'string') throw badRequest('title', 'required')
89
+ return json(await service.rename(id, body.title, actor))
90
+ }
91
+ if (typeof body.turnId !== 'string') throw badRequest('turnId', 'required')
92
+ if (action === 'edit-turn') {
93
+ if (typeof body.text !== 'string' || typeof body.expectedText !== 'string')
94
+ throw badRequest('text', 'required')
95
+ return json(await service.editTurn(id, body.turnId, body.text, body.expectedText, actor))
96
+ }
97
+ if (action === 'remove-turn') return json(await service.removeTurn(id, body.turnId, actor))
98
+ if (action === 'select-turn') return json(await service.selectTurn(id, body.turnId, actor))
99
+ if (action === 'fork') return json(await service.fork(id, body.turnId, actor))
100
+ throw notFound('route')
101
+ }