@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +37 -3
- package/src/app.ts +327 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +49 -0
- package/src/conversations/action.ts +234 -0
- package/src/conversations/index.ts +477 -0
- package/src/conversations/model.ts +402 -0
- package/src/coordinator.ts +560 -0
- package/src/credentials.ts +153 -0
- package/src/files.ts +105 -0
- package/src/host.ts +17 -0
- package/src/identity.ts +198 -0
- package/src/images/index.ts +46 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +87 -0
- package/src/mcp/auth.ts +350 -0
- package/src/mcp/client.ts +426 -0
- package/src/mcp/common.ts +120 -0
- package/src/mcp/index.ts +239 -0
- package/src/mcp/oauth.ts +284 -0
- package/src/mcp/types.ts +74 -0
- package/src/outbound.ts +305 -0
- package/src/providers/index.ts +505 -0
- package/src/runtime.ts +635 -0
- package/src/search/index.ts +431 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +140 -0
- package/src/skills/service.ts +203 -0
- package/src/storage.ts +198 -0
- package/README.md +0 -3
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { isContract, type EndStatus, type KitError, type RunEvent, type RunSnapshot } from '@ouraihub/aether-contracts'
|
|
2
|
+
import { KitFailure, type Approver } from '@ouraihub/aether-core'
|
|
3
|
+
import type { DurableObjectState } from './cloudflare.js'
|
|
4
|
+
import { immutableActor, isActor, ownerKey, type Actor, type WorkerContext } from './context.js'
|
|
5
|
+
import type { AcceptedHandle } from './conversations/index.js'
|
|
6
|
+
import type { PreparedExecution, WorkerRuntime } from './runtime.js'
|
|
7
|
+
|
|
8
|
+
type Runtime = Pick<WorkerRuntime, 'prepare' | 'execute' | 'abandon' | 'recover'>
|
|
9
|
+
type Phase = 'creating' | 'accepted' | 'running' | EndStatus
|
|
10
|
+
interface Metadata {
|
|
11
|
+
phase: Phase
|
|
12
|
+
runId: string
|
|
13
|
+
actor: Actor
|
|
14
|
+
handle: AcceptedHandle | null
|
|
15
|
+
snapshot: RunSnapshot
|
|
16
|
+
revision: number
|
|
17
|
+
acceptedUntil: number | null
|
|
18
|
+
pending: { callId: string; revision: number } | null
|
|
19
|
+
}
|
|
20
|
+
interface Pending {
|
|
21
|
+
callId: string
|
|
22
|
+
revision: number
|
|
23
|
+
decision: boolean | undefined
|
|
24
|
+
resolve: ((decision: boolean) => void) | null
|
|
25
|
+
}
|
|
26
|
+
const storageKey = 'run'
|
|
27
|
+
const encoder = new TextEncoder()
|
|
28
|
+
const conflict = (reason: string): KitFailure => new KitFailure('conflict', 'Run state conflict', { reason })
|
|
29
|
+
const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
|
|
30
|
+
const interrupted = (): KitError => ({
|
|
31
|
+
code: 'internal',
|
|
32
|
+
message: 'Run interrupted',
|
|
33
|
+
details: { reason: 'interrupted', errorId: crypto.randomUUID() },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** No arbitrary messages/details cross this boundary, even from host KitFailures. */
|
|
37
|
+
function publicError(error: unknown): KitError {
|
|
38
|
+
const code = error instanceof KitFailure ? error.code : isContract('KitError', error) ? error.code : 'internal'
|
|
39
|
+
const messages: Record<KitError['code'], string> = {
|
|
40
|
+
bad_request: 'Invalid request',
|
|
41
|
+
not_found: 'Run not found',
|
|
42
|
+
conflict: 'Run state conflict',
|
|
43
|
+
unsupported: 'Unsupported operation',
|
|
44
|
+
cancelled: 'Cancelled',
|
|
45
|
+
unauthorized: 'Authentication required',
|
|
46
|
+
forbidden: 'Access denied',
|
|
47
|
+
rate_limited: 'Rate limit exceeded',
|
|
48
|
+
upstream: 'Upstream execution failed',
|
|
49
|
+
timeout: 'Execution timed out',
|
|
50
|
+
network: 'Network execution failed',
|
|
51
|
+
needs_config: 'Configuration required',
|
|
52
|
+
budget_exceeded: 'Execution budget exceeded',
|
|
53
|
+
incompatible_db: 'Incompatible database',
|
|
54
|
+
internal: 'Internal execution failure',
|
|
55
|
+
}
|
|
56
|
+
// Error IDs are generated here, never copied from a potentially secret-bearing host error.
|
|
57
|
+
const reason = error instanceof KitFailure ? error.details?.reason : undefined
|
|
58
|
+
const safeReason =
|
|
59
|
+
reason === 'event_replay_unavailable' || reason === 'subscriber_already_active' || reason === 'approval_not_pending'
|
|
60
|
+
? reason
|
|
61
|
+
: undefined
|
|
62
|
+
return {
|
|
63
|
+
code,
|
|
64
|
+
message: messages[code],
|
|
65
|
+
...(code === 'internal' || safeReason
|
|
66
|
+
? {
|
|
67
|
+
details: {
|
|
68
|
+
...(safeReason ? { reason: safeReason } : {}),
|
|
69
|
+
...(code === 'internal' ? { errorId: crypto.randomUUID() } : {}),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function statusCode(error: KitError): number {
|
|
76
|
+
switch (error.code) {
|
|
77
|
+
case 'bad_request':
|
|
78
|
+
return 400
|
|
79
|
+
case 'unauthorized':
|
|
80
|
+
return 401
|
|
81
|
+
case 'forbidden':
|
|
82
|
+
return 403
|
|
83
|
+
case 'not_found':
|
|
84
|
+
return 404
|
|
85
|
+
case 'conflict':
|
|
86
|
+
return 409
|
|
87
|
+
case 'rate_limited':
|
|
88
|
+
return 429
|
|
89
|
+
case 'internal':
|
|
90
|
+
case 'incompatible_db':
|
|
91
|
+
return 500
|
|
92
|
+
default:
|
|
93
|
+
return 422
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function actorHeader(request: Request): Actor {
|
|
97
|
+
try {
|
|
98
|
+
const encoded = request.headers.get('x-aether-actor')
|
|
99
|
+
if (!encoded || encoded.length > 131072) throw missing()
|
|
100
|
+
const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
|
|
101
|
+
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
102
|
+
if (!isActor(value)) throw missing()
|
|
103
|
+
return immutableActor(value)
|
|
104
|
+
} catch {
|
|
105
|
+
throw missing()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function terminal(phase: Phase): phase is EndStatus {
|
|
109
|
+
return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
|
|
110
|
+
}
|
|
111
|
+
function endEvent(snapshot: RunSnapshot): RunEvent {
|
|
112
|
+
if (snapshot.status === 'running') throw conflict('run_not_terminal')
|
|
113
|
+
return {
|
|
114
|
+
kind: 'end',
|
|
115
|
+
status: snapshot.status,
|
|
116
|
+
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
117
|
+
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The router alone constructs this private protocol after authentication. */
|
|
122
|
+
export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime) {
|
|
123
|
+
return class RunCoordinator {
|
|
124
|
+
private readonly state: DurableObjectState
|
|
125
|
+
private readonly runtime: Runtime
|
|
126
|
+
private readonly ready: Promise<void>
|
|
127
|
+
private queue: Promise<void> = Promise.resolve()
|
|
128
|
+
private metadata: Metadata | null = null
|
|
129
|
+
private prepared: PreparedExecution | null = null
|
|
130
|
+
private readonly abort = new AbortController()
|
|
131
|
+
private operation: Promise<void> | null = null
|
|
132
|
+
private pending: Pending | null = null
|
|
133
|
+
private controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
134
|
+
private detach: (() => void) | null = null
|
|
135
|
+
private sequence = 0
|
|
136
|
+
private subscribed = false
|
|
137
|
+
|
|
138
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
139
|
+
this.state = state
|
|
140
|
+
this.runtime = createRuntime(env)
|
|
141
|
+
this.ready = state.blockConcurrencyWhile(async () => {
|
|
142
|
+
this.metadata = (await state.storage.get<Metadata>(storageKey)) ?? null
|
|
143
|
+
const record = this.metadata
|
|
144
|
+
if (!record || terminal(record.phase)) return
|
|
145
|
+
let recoveryFailure: unknown
|
|
146
|
+
if (record.handle) {
|
|
147
|
+
try {
|
|
148
|
+
await this.runtime.recover(record.handle, this.context(record.actor))
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (!(record.phase === 'creating' && error instanceof KitFailure && error.code === 'not_found'))
|
|
151
|
+
recoveryFailure = error
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
record.phase = 'failed'
|
|
155
|
+
record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted() }
|
|
156
|
+
record.pending = null
|
|
157
|
+
record.acceptedUntil = null
|
|
158
|
+
await this.persist()
|
|
159
|
+
await state.storage.deleteAlarm()
|
|
160
|
+
if (recoveryFailure !== undefined) throw recoveryFailure
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private context(actor: Actor): WorkerContext {
|
|
165
|
+
return { actor, signal: this.abort.signal, requestId: crypto.randomUUID(), now: Date.now() }
|
|
166
|
+
}
|
|
167
|
+
private serialize<T>(action: () => Promise<T>): Promise<T> {
|
|
168
|
+
const result = this.queue.then(action)
|
|
169
|
+
this.queue = result.then(
|
|
170
|
+
() => undefined,
|
|
171
|
+
() => undefined,
|
|
172
|
+
)
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
private async persist(): Promise<void> {
|
|
176
|
+
if (!this.metadata) return
|
|
177
|
+
this.metadata.revision++
|
|
178
|
+
await this.state.storage.put(storageKey, this.metadata)
|
|
179
|
+
}
|
|
180
|
+
private owns(actor: Actor): boolean {
|
|
181
|
+
return (
|
|
182
|
+
this.metadata !== null &&
|
|
183
|
+
ownerKey(actor) === ownerKey(this.metadata.actor) &&
|
|
184
|
+
actor.sessionId === this.metadata.actor.sessionId
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
private publish(event: RunEvent): void {
|
|
188
|
+
const controller = this.controller
|
|
189
|
+
if (!controller) return
|
|
190
|
+
try {
|
|
191
|
+
// Bound an unread subscriber rather than retaining an unbounded transcript in memory.
|
|
192
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) throw new Error('Subscriber stalled')
|
|
193
|
+
controller.enqueue(
|
|
194
|
+
encoder.encode(`event: run_event\nid: ${++this.sequence}\ndata: ${JSON.stringify(event)}\n\n`),
|
|
195
|
+
)
|
|
196
|
+
} catch {
|
|
197
|
+
this.close()
|
|
198
|
+
this.state.waitUntil(this.cancelRun())
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
private close(): void {
|
|
202
|
+
const controller = this.controller
|
|
203
|
+
this.controller = null
|
|
204
|
+
this.detach?.()
|
|
205
|
+
this.detach = null
|
|
206
|
+
try {
|
|
207
|
+
controller?.close()
|
|
208
|
+
} catch {
|
|
209
|
+
/* The consumer may already have cancelled. */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
private receive = (event: RunEvent): void => {
|
|
213
|
+
if (event.kind === 'end' || this.abort.signal.aborted) return
|
|
214
|
+
let approval: Pending | null = null
|
|
215
|
+
if (event.kind === 'approval_required') {
|
|
216
|
+
if (this.pending) throw conflict('approval_already_pending')
|
|
217
|
+
approval = {
|
|
218
|
+
callId: event.callId,
|
|
219
|
+
revision: (this.metadata?.revision ?? 0) + 1,
|
|
220
|
+
decision: undefined,
|
|
221
|
+
resolve: null,
|
|
222
|
+
}
|
|
223
|
+
this.pending = approval
|
|
224
|
+
}
|
|
225
|
+
const registered = approval
|
|
226
|
+
const write = this.serialize(async () => {
|
|
227
|
+
if (!this.metadata || terminal(this.metadata.phase) || this.abort.signal.aborted) return
|
|
228
|
+
if (registered) {
|
|
229
|
+
this.metadata.pending = { callId: registered.callId, revision: registered.revision }
|
|
230
|
+
await this.persist()
|
|
231
|
+
}
|
|
232
|
+
this.publish(event)
|
|
233
|
+
})
|
|
234
|
+
this.state.waitUntil(write.catch(() => this.cancelRun()))
|
|
235
|
+
}
|
|
236
|
+
private readonly approver: Approver = {
|
|
237
|
+
wait: async (runId, call, signal) => {
|
|
238
|
+
const pending = this.pending
|
|
239
|
+
if (!pending || pending.callId !== call.id || this.metadata?.runId !== runId)
|
|
240
|
+
throw conflict('approval_not_pending')
|
|
241
|
+
if (signal.aborted || this.abort.signal.aborted) return false
|
|
242
|
+
let decision = pending.decision
|
|
243
|
+
if (decision === undefined) {
|
|
244
|
+
const { promise, resolve } = Promise.withResolvers<boolean>()
|
|
245
|
+
const abort = (): void => resolve(false)
|
|
246
|
+
pending.resolve = resolve
|
|
247
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
248
|
+
this.abort.signal.addEventListener('abort', abort, { once: true })
|
|
249
|
+
if (signal.aborted || this.abort.signal.aborted) resolve(false)
|
|
250
|
+
try {
|
|
251
|
+
decision = await promise
|
|
252
|
+
} finally {
|
|
253
|
+
signal.removeEventListener('abort', abort)
|
|
254
|
+
this.abort.signal.removeEventListener('abort', abort)
|
|
255
|
+
pending.resolve = null
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
await this.serialize(async () => {
|
|
259
|
+
if (this.pending !== pending || !this.metadata) return
|
|
260
|
+
this.pending = null
|
|
261
|
+
this.metadata.pending = null
|
|
262
|
+
await this.persist()
|
|
263
|
+
})
|
|
264
|
+
return decision
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
private async finish(snapshot: RunSnapshot): Promise<void> {
|
|
268
|
+
await this.serialize(async () => {
|
|
269
|
+
const record = this.metadata
|
|
270
|
+
if (!record || terminal(record.phase)) return
|
|
271
|
+
if (snapshot.status === 'running') throw conflict('runtime_not_terminal')
|
|
272
|
+
// Rebuild the DTO: no runtime-private additions or unsanitized error details persist.
|
|
273
|
+
record.snapshot = {
|
|
274
|
+
runId: record.runId,
|
|
275
|
+
conversationId: record.snapshot.conversationId,
|
|
276
|
+
turnId: record.snapshot.turnId,
|
|
277
|
+
status: snapshot.status,
|
|
278
|
+
cancelRequested: record.snapshot.cancelRequested || snapshot.cancelRequested,
|
|
279
|
+
...(snapshot.error ? { error: publicError(snapshot.error) } : {}),
|
|
280
|
+
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
281
|
+
}
|
|
282
|
+
record.phase = snapshot.status
|
|
283
|
+
record.pending = null
|
|
284
|
+
record.acceptedUntil = null
|
|
285
|
+
this.pending?.resolve?.(false)
|
|
286
|
+
this.pending = null
|
|
287
|
+
await this.persist()
|
|
288
|
+
await this.state.storage.deleteAlarm()
|
|
289
|
+
this.publish(endEvent(record.snapshot))
|
|
290
|
+
this.close()
|
|
291
|
+
this.prepared = null
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
private async fail(error: unknown): Promise<void> {
|
|
295
|
+
const record = this.metadata
|
|
296
|
+
if (!record || terminal(record.phase)) return
|
|
297
|
+
if (record.handle) {
|
|
298
|
+
try {
|
|
299
|
+
await this.runtime.recover(record.handle, this.context(record.actor))
|
|
300
|
+
} catch (recoveryError) {
|
|
301
|
+
if (!(
|
|
302
|
+
record.phase === 'creating' &&
|
|
303
|
+
recoveryError instanceof KitFailure &&
|
|
304
|
+
recoveryError.code === 'not_found'
|
|
305
|
+
))
|
|
306
|
+
throw recoveryError
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error) })
|
|
310
|
+
}
|
|
311
|
+
private async run(prepared: PreparedExecution, execute: boolean): Promise<void> {
|
|
312
|
+
try {
|
|
313
|
+
const snapshot = execute
|
|
314
|
+
? await this.runtime.execute(prepared, this.abort.signal, this.receive, this.approver)
|
|
315
|
+
: await this.runtime.abandon(prepared, 'cancelled')
|
|
316
|
+
await this.finish(snapshot)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
await this.fail(error)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
private async cancelRun(): Promise<void> {
|
|
322
|
+
await this.serialize(async () => {
|
|
323
|
+
const record = this.metadata
|
|
324
|
+
if (!record || terminal(record.phase)) return
|
|
325
|
+
this.abort.abort()
|
|
326
|
+
this.pending?.resolve?.(false)
|
|
327
|
+
record.snapshot.cancelRequested = true
|
|
328
|
+
await this.persist()
|
|
329
|
+
if ((record.phase === 'accepted' || !this.operation) && this.prepared) {
|
|
330
|
+
const prepared = this.prepared
|
|
331
|
+
record.phase = 'running'
|
|
332
|
+
record.acceptedUntil = null
|
|
333
|
+
await this.persist()
|
|
334
|
+
this.operation = Promise.resolve().then(() => this.run(prepared, false))
|
|
335
|
+
this.state.waitUntil(this.operation)
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
await this.operation
|
|
339
|
+
}
|
|
340
|
+
private async accept(request: Request): Promise<Response> {
|
|
341
|
+
let value: unknown
|
|
342
|
+
try {
|
|
343
|
+
value = await request.json()
|
|
344
|
+
} catch {
|
|
345
|
+
throw new KitFailure('bad_request', 'Invalid JSON')
|
|
346
|
+
}
|
|
347
|
+
if (
|
|
348
|
+
typeof value !== 'object' ||
|
|
349
|
+
value === null ||
|
|
350
|
+
!('actor' in value) ||
|
|
351
|
+
!isActor(value.actor) ||
|
|
352
|
+
!('runId' in value) ||
|
|
353
|
+
typeof value.runId !== 'string' ||
|
|
354
|
+
!/^[A-Za-z0-9_-]{1,128}$/u.test(value.runId) ||
|
|
355
|
+
!('request' in value) ||
|
|
356
|
+
!isContract('RunRequest', value.request)
|
|
357
|
+
)
|
|
358
|
+
throw new KitFailure('bad_request', 'Invalid run request')
|
|
359
|
+
const actor = immutableActor(value.actor),
|
|
360
|
+
runId = value.runId,
|
|
361
|
+
input = value.request
|
|
362
|
+
await this.serialize(async () => {
|
|
363
|
+
if (this.metadata) throw conflict('run_already_exists')
|
|
364
|
+
this.metadata = {
|
|
365
|
+
phase: 'creating',
|
|
366
|
+
actor,
|
|
367
|
+
runId,
|
|
368
|
+
handle: null,
|
|
369
|
+
revision: 0,
|
|
370
|
+
pending: null,
|
|
371
|
+
acceptedUntil: null,
|
|
372
|
+
snapshot: { runId, conversationId: null, turnId: null, status: 'running', cancelRequested: false },
|
|
373
|
+
}
|
|
374
|
+
await this.persist()
|
|
375
|
+
})
|
|
376
|
+
const abort = (): void => {
|
|
377
|
+
this.state.waitUntil(this.cancelRun())
|
|
378
|
+
}
|
|
379
|
+
request.signal.addEventListener('abort', abort, { once: true })
|
|
380
|
+
if (request.signal.aborted) this.abort.abort()
|
|
381
|
+
let accepted = false
|
|
382
|
+
const preparation = async (): Promise<void> => {
|
|
383
|
+
try {
|
|
384
|
+
const prepared = await this.runtime.prepare(runId, input, this.context(actor), (handle) =>
|
|
385
|
+
this.serialize(async () => {
|
|
386
|
+
if (!this.metadata || this.abort.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
|
|
387
|
+
this.metadata.handle = handle
|
|
388
|
+
this.metadata.snapshot.conversationId = handle?.conversationId ?? null
|
|
389
|
+
this.metadata.snapshot.turnId = handle?.turnId ?? null
|
|
390
|
+
await this.persist()
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
this.prepared = prepared
|
|
394
|
+
if (this.abort.signal.aborted) {
|
|
395
|
+
await this.run(prepared, false)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
await this.serialize(async () => {
|
|
399
|
+
if (!this.metadata) throw missing()
|
|
400
|
+
if (this.abort.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
|
|
401
|
+
this.metadata.phase = 'accepted'
|
|
402
|
+
this.metadata.snapshot.conversationId = prepared.accepted.conversationId
|
|
403
|
+
this.metadata.snapshot.turnId = prepared.accepted.turnId
|
|
404
|
+
this.metadata.acceptedUntil = Date.now() + 30000
|
|
405
|
+
await this.state.storage.setAlarm(this.metadata.acceptedUntil)
|
|
406
|
+
await this.persist()
|
|
407
|
+
})
|
|
408
|
+
accepted = true
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (this.prepared) await this.run(this.prepared, false)
|
|
411
|
+
else await this.fail(error)
|
|
412
|
+
throw error
|
|
413
|
+
} finally {
|
|
414
|
+
request.signal.removeEventListener('abort', abort)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const work = preparation()
|
|
418
|
+
this.operation = work
|
|
419
|
+
this.state.waitUntil(work.catch(() => undefined))
|
|
420
|
+
await work
|
|
421
|
+
if (!accepted || !this.prepared) throw new KitFailure('cancelled', 'Cancelled')
|
|
422
|
+
return Response.json(this.prepared.accepted, { status: 202 })
|
|
423
|
+
}
|
|
424
|
+
private async events(request: Request, actor: Actor): Promise<Response> {
|
|
425
|
+
try {
|
|
426
|
+
return await this.serialize(async () => {
|
|
427
|
+
const record = this.metadata
|
|
428
|
+
if (!record || !this.owns(actor)) throw missing()
|
|
429
|
+
if (request.headers.has('Last-Event-ID')) throw conflict('event_replay_unavailable')
|
|
430
|
+
if (request.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
|
|
431
|
+
if (!terminal(record.phase) && (record.phase !== 'accepted' || this.subscribed))
|
|
432
|
+
throw conflict('subscriber_already_active')
|
|
433
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
434
|
+
{
|
|
435
|
+
start: (controller) => {
|
|
436
|
+
this.controller = controller
|
|
437
|
+
},
|
|
438
|
+
cancel: async () => {
|
|
439
|
+
this.close()
|
|
440
|
+
await this.cancelRun()
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{ highWaterMark: 1024 * 1024, size: (chunk) => chunk.byteLength },
|
|
444
|
+
)
|
|
445
|
+
if (terminal(record.phase)) {
|
|
446
|
+
this.publish(endEvent(record.snapshot))
|
|
447
|
+
this.close()
|
|
448
|
+
} else {
|
|
449
|
+
if (!this.prepared) throw conflict('prepared_execution_unavailable')
|
|
450
|
+
this.subscribed = true
|
|
451
|
+
record.phase = 'running'
|
|
452
|
+
this.operation = null
|
|
453
|
+
record.acceptedUntil = null
|
|
454
|
+
await this.persist()
|
|
455
|
+
await this.state.storage.deleteAlarm()
|
|
456
|
+
const abort = (): void => {
|
|
457
|
+
this.close()
|
|
458
|
+
this.state.waitUntil(this.cancelRun())
|
|
459
|
+
}
|
|
460
|
+
request.signal.addEventListener('abort', abort, { once: true })
|
|
461
|
+
this.detach = () => request.signal.removeEventListener('abort', abort)
|
|
462
|
+
const prepared = this.prepared
|
|
463
|
+
this.operation = Promise.resolve().then(() => this.run(prepared, true))
|
|
464
|
+
this.state.waitUntil(this.operation)
|
|
465
|
+
if (request.signal.aborted) abort()
|
|
466
|
+
}
|
|
467
|
+
return new Response(stream, {
|
|
468
|
+
headers: {
|
|
469
|
+
'Content-Type': 'text/event-stream',
|
|
470
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
471
|
+
'X-Accel-Buffering': 'no',
|
|
472
|
+
'X-Content-Type-Options': 'nosniff',
|
|
473
|
+
},
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
} catch (error) {
|
|
477
|
+
// A rejected competing subscriber must never cancel the actual owner stream.
|
|
478
|
+
if (
|
|
479
|
+
this.owns(actor) &&
|
|
480
|
+
((this.subscribed && !this.operation) || (this.metadata?.phase === 'accepted' && request.signal.aborted))
|
|
481
|
+
) {
|
|
482
|
+
this.close()
|
|
483
|
+
await this.cancelRun()
|
|
484
|
+
}
|
|
485
|
+
throw error
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async fetch(request: Request): Promise<Response> {
|
|
489
|
+
try {
|
|
490
|
+
await this.ready
|
|
491
|
+
const path = new URL(request.url).pathname
|
|
492
|
+
if (request.method === 'POST' && path === '/accept') return await this.accept(request)
|
|
493
|
+
const actor = actorHeader(request)
|
|
494
|
+
if (request.method === 'POST' && path === '/cancel') {
|
|
495
|
+
if (this.owns(actor)) await this.cancelRun()
|
|
496
|
+
return Response.json({ ok: true })
|
|
497
|
+
}
|
|
498
|
+
if (!this.owns(actor)) throw missing()
|
|
499
|
+
if (request.method === 'GET' && path === '/events') return await this.events(request, actor)
|
|
500
|
+
if (request.method === 'GET' && path === '/snapshot')
|
|
501
|
+
return await this.serialize(async () => Response.json(this.metadata?.snapshot))
|
|
502
|
+
if (request.method === 'POST' && path === '/approval') {
|
|
503
|
+
let value: unknown
|
|
504
|
+
try {
|
|
505
|
+
value = await request.json()
|
|
506
|
+
} catch {
|
|
507
|
+
throw new KitFailure('bad_request', 'Invalid JSON')
|
|
508
|
+
}
|
|
509
|
+
if (
|
|
510
|
+
typeof value !== 'object' ||
|
|
511
|
+
value === null ||
|
|
512
|
+
!('callId' in value) ||
|
|
513
|
+
typeof value.callId !== 'string' ||
|
|
514
|
+
!('decision' in value) ||
|
|
515
|
+
(value.decision !== 'allow' && value.decision !== 'deny')
|
|
516
|
+
)
|
|
517
|
+
throw new KitFailure('bad_request', 'Invalid approval')
|
|
518
|
+
const callId = value.callId,
|
|
519
|
+
allowed = value.decision === 'allow'
|
|
520
|
+
return await this.serialize(async () => {
|
|
521
|
+
const pending = this.pending,
|
|
522
|
+
stored = this.metadata?.pending
|
|
523
|
+
if (
|
|
524
|
+
!pending ||
|
|
525
|
+
!stored ||
|
|
526
|
+
pending.callId !== callId ||
|
|
527
|
+
stored.callId !== pending.callId ||
|
|
528
|
+
stored.revision !== pending.revision ||
|
|
529
|
+
pending.decision !== undefined ||
|
|
530
|
+
this.abort.signal.aborted ||
|
|
531
|
+
this.metadata?.phase !== 'running'
|
|
532
|
+
)
|
|
533
|
+
throw conflict('approval_not_pending')
|
|
534
|
+
pending.decision = allowed
|
|
535
|
+
this.metadata.pending = null
|
|
536
|
+
await this.persist()
|
|
537
|
+
pending.resolve?.(pending.decision)
|
|
538
|
+
return Response.json({ ok: true })
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
throw missing()
|
|
542
|
+
} catch (error) {
|
|
543
|
+
const safe = publicError(error)
|
|
544
|
+
return Response.json(safe, { status: statusCode(safe) })
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async alarm(): Promise<void> {
|
|
548
|
+
await this.ready
|
|
549
|
+
const record = this.metadata
|
|
550
|
+
if (record?.phase === 'accepted' && record.acceptedUntil !== null) {
|
|
551
|
+
if (Date.now() >= record.acceptedUntil) await this.cancelRun()
|
|
552
|
+
else
|
|
553
|
+
await this.serialize(async () => {
|
|
554
|
+
if (this.metadata?.phase === 'accepted' && this.metadata.acceptedUntil !== null)
|
|
555
|
+
await this.state.storage.setAlarm(this.metadata.acceptedUntil)
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|