@electric-ax/agents-server 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Sub-router for /_electric/* control-plane routes.
3
+ */
4
+
5
+ import { appendPathToUrl } from '@electric-ax/agents-runtime'
6
+ import { Type, type Static } from '@sinclair/typebox'
7
+ import { and, eq } from 'drizzle-orm'
8
+ import { Router, json, status } from 'itty-router'
9
+ import {
10
+ apiError,
11
+ readRequestBody,
12
+ responseHeaders,
13
+ } from '../electric-agents-http.js'
14
+ import { consumerCallbacks, subscriptionWebhooks } from '../db/schema.js'
15
+ import {
16
+ ErrCodeCallbackNotFound,
17
+ ErrCodeForkInProgress,
18
+ ErrCodeSubscriptionNotFound,
19
+ } from '../electric-agents-types.js'
20
+ import { ATTR, tracer } from '../tracing.js'
21
+ import { decodeJsonObject } from '../utils/server-utils.js'
22
+ import { serverLog } from '../utils/log.js'
23
+ import { cronRouter } from './cron-router.js'
24
+ import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
25
+ import { electricProxyRouter } from './electric-proxy-router.js'
26
+ import { entitiesRouter } from './entities-router.js'
27
+ import { entityTypesRouter } from './entity-types-router.js'
28
+ import { getRequestSpan } from './hooks.js'
29
+ import { runnersRouter } from './runners-router.js'
30
+ import { routeBody, validateOptionalJsonBody, withSchema } from './schema.js'
31
+ import { withLeadingSlash } from './tenant-stream-paths.js'
32
+ import type { IRequest, RouterType } from 'itty-router'
33
+ import type { TenantContext } from './context.js'
34
+ import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
35
+
36
+ const wakeRegistrationBodySchema = Type.Object({
37
+ subscriberUrl: Type.String(),
38
+ sourceUrl: Type.String(),
39
+ condition: Type.Union([
40
+ Type.Literal(`runFinished`),
41
+ Type.Object({
42
+ on: Type.Literal(`change`),
43
+ collections: Type.Optional(Type.Array(Type.String())),
44
+ ops: Type.Optional(
45
+ Type.Array(
46
+ Type.Union([
47
+ Type.Literal(`insert`),
48
+ Type.Literal(`update`),
49
+ Type.Literal(`delete`),
50
+ ])
51
+ )
52
+ ),
53
+ }),
54
+ ]),
55
+ debounceMs: Type.Optional(Type.Number()),
56
+ timeoutMs: Type.Optional(Type.Number()),
57
+ includeResponse: Type.Optional(Type.Boolean()),
58
+ manifestKey: Type.Optional(Type.String()),
59
+ })
60
+
61
+ const webhookForwardBodySchema = Type.Object(
62
+ {
63
+ subscription_id: Type.Optional(Type.String()),
64
+ wake_id: Type.Optional(Type.String()),
65
+ generation: Type.Optional(Type.Number()),
66
+ streams: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()))),
67
+ callback_url: Type.Optional(Type.String()),
68
+ callback_token: Type.Optional(Type.String()),
69
+ primary_stream: Type.Optional(Type.String()),
70
+ primaryStream: Type.Optional(Type.String()),
71
+ streamPath: Type.Optional(Type.String()),
72
+ consumerId: Type.Optional(Type.String()),
73
+ consumer_id: Type.Optional(Type.String()),
74
+ callback: Type.Optional(Type.String()),
75
+ },
76
+ { additionalProperties: true }
77
+ )
78
+
79
+ const callbackForwardBodySchema = Type.Object(
80
+ {
81
+ epoch: Type.Optional(Type.Number()),
82
+ generation: Type.Optional(Type.Number()),
83
+ wakeId: Type.Optional(Type.String()),
84
+ wake_id: Type.Optional(Type.String()),
85
+ acks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()))),
86
+ done: Type.Optional(Type.Boolean()),
87
+ },
88
+ { additionalProperties: true }
89
+ )
90
+
91
+ type WakeRegistrationBody = Static<typeof wakeRegistrationBodySchema>
92
+ type WebhookForwardBody = Static<typeof webhookForwardBodySchema>
93
+ type CallbackForwardBody = Static<typeof callbackForwardBodySchema>
94
+
95
+ const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`
96
+
97
+ export type InternalRoutes = RouterType<
98
+ IRequest,
99
+ [TenantContext],
100
+ Response | undefined
101
+ >
102
+
103
+ export const internalRouter: InternalRoutes = Router<
104
+ IRequest,
105
+ [TenantContext],
106
+ Response | undefined
107
+ >({
108
+ base: `/_electric`,
109
+ })
110
+
111
+ internalRouter.get(`/health`, () => json({ status: `ok` }))
112
+ internalRouter.post(
113
+ `/wake`,
114
+ withSchema(wakeRegistrationBodySchema),
115
+ registerWake
116
+ )
117
+ internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward)
118
+ internalRouter.post(`/callback-forward/:consumerId`, callbackForward)
119
+ internalRouter.all(`/runners`, runnersRouter.fetch)
120
+ internalRouter.all(`/runners/*`, runnersRouter.fetch)
121
+ internalRouter.all(`/entities/*`, entitiesRouter.fetch)
122
+ internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
123
+ internalRouter.all(`/cron/*`, cronRouter.fetch)
124
+ internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
125
+ internalRouter.all(`*`, () => status(404))
126
+
127
+ function routeParam(request: IRequest, name: string): string {
128
+ const value = request.params[name]
129
+ return decodeURIComponent(Array.isArray(value) ? value[0]! : value)
130
+ }
131
+
132
+ function bodyFromBytes(body: Uint8Array): ArrayBuffer {
133
+ return body.buffer.slice(
134
+ body.byteOffset,
135
+ body.byteOffset + body.byteLength
136
+ ) as ArrayBuffer
137
+ }
138
+
139
+ function responseFromUpstream(response: Response, body?: Uint8Array): Response {
140
+ return new Response(body ? bodyFromBytes(body) : response.body, {
141
+ status: response.status,
142
+ statusText: response.statusText,
143
+ headers: responseHeaders(response),
144
+ })
145
+ }
146
+
147
+ function forwardHeadersFromRequest(request: IRequest): Headers {
148
+ const headers = new Headers(request.headers)
149
+ headers.delete(`host`)
150
+ return headers
151
+ }
152
+
153
+ function durableStreamsSubscriptionCallback(value: string): string | null {
154
+ return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX)
155
+ ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length)
156
+ : null
157
+ }
158
+
159
+ function claimTokenFromRequest(request: IRequest): string | undefined {
160
+ const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim()
161
+ if (electricClaimToken) return electricClaimToken
162
+ return (
163
+ request.headers
164
+ .get(`authorization`)
165
+ ?.replace(/^Bearer\s+/i, ``)
166
+ .trim() || undefined
167
+ )
168
+ }
169
+
170
+ function newWebhookPayload(body: WebhookForwardBody | undefined): {
171
+ wakeId: string
172
+ generation: number
173
+ primaryStream: string
174
+ tailOffset: string
175
+ callbackUrl: string
176
+ callbackToken: string
177
+ } | null {
178
+ if (
179
+ !body ||
180
+ typeof body.subscription_id !== `string` ||
181
+ typeof body.wake_id !== `string` ||
182
+ typeof body.generation !== `number` ||
183
+ typeof body.callback_url !== `string` ||
184
+ typeof body.callback_token !== `string` ||
185
+ !Array.isArray(body.streams)
186
+ ) {
187
+ return null
188
+ }
189
+
190
+ const streamInfos = body.streams as Array<
191
+ | {
192
+ path?: unknown
193
+ tail_offset?: unknown
194
+ has_pending?: unknown
195
+ }
196
+ | undefined
197
+ >
198
+ const firstStream =
199
+ streamInfos.find((stream) => stream?.has_pending === true) ?? streamInfos[0]
200
+ const selectedStream = firstStream as
201
+ | {
202
+ path?: unknown
203
+ tail_offset?: unknown
204
+ }
205
+ | undefined
206
+ if (
207
+ typeof selectedStream?.path !== `string` ||
208
+ typeof selectedStream.tail_offset !== `string`
209
+ ) {
210
+ return null
211
+ }
212
+
213
+ return {
214
+ wakeId: body.wake_id,
215
+ generation: body.generation,
216
+ primaryStream: withLeadingSlash(selectedStream.path),
217
+ tailOffset: selectedStream.tail_offset,
218
+ callbackUrl: body.callback_url,
219
+ callbackToken: body.callback_token,
220
+ }
221
+ }
222
+
223
+ function toRuntimeStreamPath(
224
+ path: string,
225
+ service: string,
226
+ routingAdapter: DurableStreamsRoutingAdapter
227
+ ): string {
228
+ return withLeadingSlash(routingAdapter.toRuntimeStreamPath(service, path))
229
+ }
230
+
231
+ async function registerWake(
232
+ request: IRequest,
233
+ ctx: TenantContext
234
+ ): Promise<Response> {
235
+ const opts = routeBody<WakeRegistrationBody>(request)
236
+ await ctx.entityManager.registerWake(opts)
237
+ return status(204)
238
+ }
239
+
240
+ async function webhookForward(
241
+ request: IRequest,
242
+ ctx: TenantContext
243
+ ): Promise<Response> {
244
+ const subscriptionId = routeParam(request, `subscriptionId`)
245
+ const rootSpan = getRequestSpan(request)
246
+ rootSpan?.updateName(`webhook-forward`)
247
+ rootSpan?.setAttribute(
248
+ `electric_agents.webhook.subscription_id`,
249
+ subscriptionId
250
+ )
251
+
252
+ const lookupPromise: Promise<string | null> = tracer.startActiveSpan(
253
+ `db.lookupSubscription`,
254
+ async (span) => {
255
+ try {
256
+ const rows = await ctx.pgDb
257
+ .select()
258
+ .from(subscriptionWebhooks)
259
+ .where(
260
+ and(
261
+ eq(subscriptionWebhooks.tenantId, ctx.service),
262
+ eq(subscriptionWebhooks.subscriptionId, subscriptionId)
263
+ )
264
+ )
265
+ .limit(1)
266
+ return rows[0]?.webhookUrl ?? null
267
+ } finally {
268
+ span.end()
269
+ }
270
+ }
271
+ )
272
+
273
+ const [targetWebhookUrl, body] = await Promise.all([
274
+ lookupPromise,
275
+ readRequestBody(request as Request),
276
+ ])
277
+
278
+ if (!targetWebhookUrl) {
279
+ return apiError(
280
+ 404,
281
+ ErrCodeSubscriptionNotFound,
282
+ `Unknown webhook subscription`
283
+ )
284
+ }
285
+ const parsedBodyResult = validateOptionalJsonBody(
286
+ webhookForwardBodySchema,
287
+ body,
288
+ request.headers.get(`content-type`)
289
+ )
290
+ if (!parsedBodyResult.ok) return parsedBodyResult.response
291
+
292
+ let forwardBody = body
293
+ let runningEntityUrl: string | null = null
294
+ const parsedBody = parsedBodyResult.value as WebhookForwardBody | undefined
295
+ const newWebhook = newWebhookPayload(parsedBody)
296
+ const routingAdapter = resolveDurableStreamsRoutingAdapter(
297
+ ctx.durableStreamsRouting
298
+ )
299
+
300
+ if (parsedBody) {
301
+ const rawPrimaryStream =
302
+ newWebhook?.primaryStream ??
303
+ parsedBody.primary_stream ??
304
+ parsedBody.primaryStream ??
305
+ parsedBody.streamPath ??
306
+ null
307
+ const primaryStream =
308
+ typeof rawPrimaryStream === `string`
309
+ ? toRuntimeStreamPath(rawPrimaryStream, ctx.service, routingAdapter)
310
+ : null
311
+ const consumerId =
312
+ newWebhook?.wakeId ??
313
+ parsedBody.consumerId ??
314
+ parsedBody.consumer_id ??
315
+ null
316
+ const callbackUrl = newWebhook?.callbackUrl ?? parsedBody.callback ?? null
317
+
318
+ if (primaryStream) {
319
+ rootSpan?.setAttribute(ATTR.STREAM_PATH, primaryStream)
320
+
321
+ const entityPromise = tracer.startActiveSpan(
322
+ `db.getEntityByStream`,
323
+ async (span) => {
324
+ try {
325
+ return await ctx.entityManager.registry.getEntityByStream(
326
+ primaryStream
327
+ )
328
+ } finally {
329
+ span.end()
330
+ }
331
+ }
332
+ )
333
+ const enrichPromise = tracer.startActiveSpan(
334
+ `electric_agents.enrichPayload`,
335
+ async (span) => {
336
+ try {
337
+ return await ctx.entityManager.enrichPayload(parsedBody, {
338
+ primary_stream: primaryStream,
339
+ })
340
+ } finally {
341
+ span.end()
342
+ }
343
+ }
344
+ )
345
+
346
+ const upsertPromise =
347
+ consumerId && callbackUrl
348
+ ? tracer
349
+ .startActiveSpan(`db.upsertConsumerCallback`, async (span) => {
350
+ try {
351
+ await ctx.pgDb
352
+ .insert(consumerCallbacks)
353
+ .values({
354
+ tenantId: ctx.service,
355
+ consumerId,
356
+ callbackUrl,
357
+ primaryStream,
358
+ })
359
+ .onConflictDoUpdate({
360
+ target: [
361
+ consumerCallbacks.tenantId,
362
+ consumerCallbacks.consumerId,
363
+ ],
364
+ set: { callbackUrl, primaryStream },
365
+ })
366
+ } finally {
367
+ span.end()
368
+ }
369
+ })
370
+ .catch((err) => {
371
+ serverLog.warn(
372
+ `[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${
373
+ err instanceof Error ? err.message : String(err)
374
+ }`
375
+ )
376
+ })
377
+ : undefined
378
+
379
+ const [entity, enriched] = await Promise.all([
380
+ entityPromise,
381
+ enrichPromise,
382
+ ])
383
+
384
+ if (entity?.status === `stopped`) {
385
+ if (upsertPromise) await upsertPromise
386
+ return json({ done: true })
387
+ }
388
+
389
+ if (upsertPromise) await upsertPromise
390
+
391
+ if (entity && ctx.entityManager.isForkWorkLockedEntity(entity.url)) {
392
+ return apiError(
393
+ 409,
394
+ ErrCodeForkInProgress,
395
+ `Entity subtree is being forked`
396
+ )
397
+ }
398
+
399
+ if (entity) {
400
+ rootSpan?.setAttribute(ATTR.ENTITY_URL, entity.url)
401
+ await tracer.startActiveSpan(
402
+ `db.updateStatus.running`,
403
+ async (span) => {
404
+ try {
405
+ await ctx.entityManager.registry.updateStatus(
406
+ entity.url,
407
+ `running`
408
+ )
409
+ } finally {
410
+ span.end()
411
+ }
412
+ }
413
+ )
414
+ runningEntityUrl = entity.url
415
+ }
416
+
417
+ if (consumerId && callbackUrl) {
418
+ const callback = appendPathToUrl(
419
+ ctx.publicUrl,
420
+ `/_electric/callback-forward/${encodeURIComponent(consumerId)}`
421
+ )
422
+ enriched.callback = callback
423
+ if (newWebhook) {
424
+ enriched.consumerId = newWebhook.wakeId
425
+ enriched.epoch = newWebhook.generation
426
+ enriched.wakeId = newWebhook.wakeId
427
+ enriched.streamPath = primaryStream
428
+ enriched.streams = [
429
+ { path: primaryStream, offset: newWebhook.tailOffset },
430
+ ]
431
+ enriched.claimToken = newWebhook.callbackToken
432
+ }
433
+ }
434
+ forwardBody = new TextEncoder().encode(JSON.stringify(enriched))
435
+ }
436
+ }
437
+
438
+ const headers = forwardHeadersFromRequest(request)
439
+ headers.set(`content-type`, `application/json`)
440
+ headers.delete(`content-length`)
441
+
442
+ let upstream: Response
443
+ try {
444
+ upstream = await tracer.startActiveSpan(
445
+ `fetch.agent-handler`,
446
+ async (span) => {
447
+ span.setAttribute(`http.url`, targetWebhookUrl)
448
+ try {
449
+ return await fetch(targetWebhookUrl, {
450
+ method: request.method,
451
+ headers,
452
+ body: bodyFromBytes(forwardBody),
453
+ })
454
+ } finally {
455
+ span.end()
456
+ }
457
+ }
458
+ )
459
+ } catch (err) {
460
+ if (runningEntityUrl) {
461
+ await ctx.entityManager.registry.updateStatus(runningEntityUrl, `idle`)
462
+ }
463
+ return apiError(
464
+ 502,
465
+ `WEBHOOK_FORWARD_FAILED`,
466
+ err instanceof Error ? err.message : String(err)
467
+ )
468
+ }
469
+
470
+ const responseBytes = upstream.body
471
+ ? new Uint8Array(await upstream.arrayBuffer())
472
+ : new Uint8Array()
473
+ return responseFromUpstream(upstream, responseBytes)
474
+ }
475
+
476
+ async function callbackForward(
477
+ request: IRequest,
478
+ ctx: TenantContext
479
+ ): Promise<Response> {
480
+ const consumerId = routeParam(request, `consumerId`)
481
+ const rows = await ctx.pgDb
482
+ .select()
483
+ .from(consumerCallbacks)
484
+ .where(
485
+ and(
486
+ eq(consumerCallbacks.tenantId, ctx.service),
487
+ eq(consumerCallbacks.consumerId, consumerId)
488
+ )
489
+ )
490
+ .limit(1)
491
+ const target = rows[0]
492
+ ? {
493
+ callbackUrl: rows[0].callbackUrl,
494
+ primaryStream: rows[0].primaryStream,
495
+ }
496
+ : undefined
497
+
498
+ if (!target) {
499
+ return apiError(
500
+ 404,
501
+ ErrCodeCallbackNotFound,
502
+ `Unknown callback-forward consumer`
503
+ )
504
+ }
505
+
506
+ const body = await readRequestBody(request as Request)
507
+ const parsedBodyResult = validateOptionalJsonBody(
508
+ callbackForwardBodySchema,
509
+ body,
510
+ request.headers.get(`content-type`)
511
+ )
512
+ if (!parsedBodyResult.ok) return parsedBodyResult.response
513
+ const requestBody = parsedBodyResult.value as CallbackForwardBody | undefined
514
+ const isClaimRequest =
515
+ requestBody?.wakeId !== undefined || requestBody?.wake_id !== undefined
516
+ const isDoneRequest = requestBody?.done === true
517
+
518
+ const headers = forwardHeadersFromRequest(request)
519
+ headers.delete(`content-length`)
520
+
521
+ if (isClaimRequest && !isDoneRequest) {
522
+ let responseBody: Record<string, unknown> = { ok: true }
523
+ if (target.primaryStream) {
524
+ const writeToken = await mintClaimWriteToken(
525
+ ctx,
526
+ target.primaryStream,
527
+ consumerId
528
+ )
529
+ if (writeToken) {
530
+ responseBody = { ...responseBody, writeToken }
531
+ }
532
+ }
533
+ return json(responseBody)
534
+ }
535
+
536
+ const upstreamBody = encodeCallbackForwardBody(
537
+ ctx.service,
538
+ consumerId,
539
+ requestBody,
540
+ resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting)
541
+ )
542
+
543
+ let upstream: Response
544
+ try {
545
+ const subscriptionId = durableStreamsSubscriptionCallback(
546
+ target.callbackUrl
547
+ )
548
+ if (subscriptionId) {
549
+ const token = claimTokenFromRequest(request)
550
+ if (!token) {
551
+ return apiError(401, `UNAUTHORIZED`, `Missing claim token`)
552
+ }
553
+ const upstreamPayload = encodeCallbackForwardPayload(
554
+ consumerId,
555
+ requestBody,
556
+ (stream) => stream.replace(/^\/+/, ``)
557
+ )
558
+ const result = await ctx.streamClient.ackSubscription(
559
+ subscriptionId,
560
+ token,
561
+ upstreamPayload
562
+ )
563
+ upstream = json(result)
564
+ } else {
565
+ upstream = await fetch(target.callbackUrl, {
566
+ method: request.method,
567
+ headers,
568
+ body: bodyFromBytes(upstreamBody),
569
+ })
570
+ }
571
+ } catch (err) {
572
+ return apiError(
573
+ 502,
574
+ `CALLBACK_FORWARD_FAILED`,
575
+ err instanceof Error ? err.message : String(err)
576
+ )
577
+ }
578
+
579
+ let responseBytes: Uint8Array = upstream.body
580
+ ? new Uint8Array(await upstream.arrayBuffer())
581
+ : new Uint8Array()
582
+
583
+ if (isClaimRequest && upstream.ok && target.primaryStream) {
584
+ const responseBody = decodeJsonObject(responseBytes)
585
+ if (responseBody?.ok === true) {
586
+ const writeToken = await mintClaimWriteToken(
587
+ ctx,
588
+ target.primaryStream,
589
+ consumerId
590
+ )
591
+ if (writeToken) {
592
+ responseBody.writeToken = writeToken
593
+ responseBytes = new TextEncoder().encode(JSON.stringify(responseBody))
594
+ }
595
+ }
596
+ }
597
+
598
+ try {
599
+ const epoch = requestBody?.generation ?? requestBody?.epoch
600
+ if (
601
+ upstream.ok &&
602
+ !isDoneRequest &&
603
+ epoch !== undefined &&
604
+ target.primaryStream
605
+ ) {
606
+ await ctx.entityManager.registry.materializeHeartbeatClaim?.({
607
+ consumerId,
608
+ epoch,
609
+ })
610
+ }
611
+ if (upstream.ok && isDoneRequest && target.primaryStream) {
612
+ serverLog.info(
613
+ `[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`
614
+ )
615
+ const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(
616
+ ctx.service,
617
+ target.primaryStream,
618
+ consumerId
619
+ )
620
+ const entity = await ctx.entityManager.registry.getEntityByStream(
621
+ target.primaryStream
622
+ )
623
+ if (entity && stillOwnsClaim) {
624
+ if (epoch !== undefined) {
625
+ await ctx.entityManager.registry.materializeReleasedClaim?.({
626
+ consumerId,
627
+ epoch,
628
+ ackedStreams: Array.isArray(requestBody?.acks)
629
+ ? requestBody.acks.flatMap((ack) => {
630
+ const stream =
631
+ typeof ack.stream === `string`
632
+ ? ack.stream
633
+ : typeof ack.path === `string`
634
+ ? ack.path
635
+ : undefined
636
+ const offset =
637
+ typeof ack.offset === `string` ? ack.offset : undefined
638
+ return stream && offset ? [{ path: stream, offset }] : []
639
+ })
640
+ : undefined,
641
+ })
642
+ }
643
+ await ctx.entityManager.registry.updateStatus(entity.url, `idle`)
644
+ ctx.runtime.claimWriteTokens.clearStream(
645
+ ctx.service,
646
+ target.primaryStream
647
+ )
648
+ await ctx.entityBridgeManager.onEntityChanged(entity.url)
649
+ serverLog.info(
650
+ `[callback-forward] status updated to idle for ${entity.url}`
651
+ )
652
+ } else if (stillOwnsClaim) {
653
+ ctx.runtime.claimWriteTokens.clearStream(
654
+ ctx.service,
655
+ target.primaryStream
656
+ )
657
+ } else if (entity) {
658
+ serverLog.info(
659
+ `[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`
660
+ )
661
+ } else {
662
+ serverLog.warn(
663
+ `[callback-forward] done received but no entity found for stream=${target.primaryStream}`
664
+ )
665
+ }
666
+ } else if (requestBody?.done === true) {
667
+ serverLog.warn(
668
+ `[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${
669
+ target.primaryStream ?? `null`
670
+ } consumer=${consumerId}`
671
+ )
672
+ }
673
+ } catch (err) {
674
+ serverLog.error(
675
+ `[callback-forward] error processing done for consumer=${consumerId}: ${
676
+ err instanceof Error ? err.message : String(err)
677
+ }`
678
+ )
679
+ }
680
+
681
+ return responseFromUpstream(upstream, responseBytes)
682
+ }
683
+
684
+ async function mintClaimWriteToken(
685
+ ctx: TenantContext,
686
+ streamPath: string,
687
+ consumerId: string
688
+ ): Promise<string | undefined> {
689
+ const entity = await ctx.entityManager.registry.getEntityByStream(streamPath)
690
+ if (!entity) return undefined
691
+
692
+ return ctx.runtime.claimWriteTokens.mint(ctx.service, streamPath, consumerId)
693
+ }
694
+
695
+ function encodeCallbackForwardBody(
696
+ service: string,
697
+ consumerId: string,
698
+ body: CallbackForwardBody | undefined,
699
+ routingAdapter: DurableStreamsRoutingAdapter
700
+ ): Uint8Array {
701
+ const payload = encodeCallbackForwardPayload(consumerId, body, (stream) =>
702
+ routingAdapter.toBackendStreamPath(service, stream)
703
+ )
704
+ return new TextEncoder().encode(JSON.stringify(payload))
705
+ }
706
+
707
+ function encodeCallbackForwardPayload(
708
+ consumerId: string,
709
+ body: CallbackForwardBody | undefined,
710
+ mapStream: (stream: string) => string
711
+ ): Record<string, unknown> {
712
+ if (!body) return {}
713
+ const generation = body.generation ?? body.epoch
714
+ const wakeId = body.wake_id ?? body.wakeId ?? consumerId
715
+ const acks = Array.isArray(body.acks)
716
+ ? body.acks.map((ack) => {
717
+ const input = ack as {
718
+ path?: unknown
719
+ stream?: unknown
720
+ offset?: unknown
721
+ }
722
+ const stream =
723
+ typeof input.stream === `string`
724
+ ? input.stream
725
+ : typeof input.path === `string`
726
+ ? input.path
727
+ : ``
728
+ return {
729
+ stream: mapStream(stream),
730
+ offset: typeof input.offset === `string` ? input.offset : ``,
731
+ }
732
+ })
733
+ : []
734
+
735
+ return {
736
+ wake_id: wakeId,
737
+ ...(generation !== undefined ? { generation } : {}),
738
+ acks,
739
+ ...(body.done !== undefined ? { done: body.done } : {}),
740
+ }
741
+ }