@electric-ax/agents-server 0.4.7 → 0.4.11

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.
@@ -16,7 +16,7 @@ import {
16
16
  } from '../electric-agents-http.js'
17
17
  import { consumerCallbacks, subscriptionWebhooks } from '../db/schema.js'
18
18
  import {
19
- ErrCodeCallbackNotFound,
19
+ ErrCodeWakeCallbackNotFound,
20
20
  ErrCodeForkInProgress,
21
21
  ErrCodeSubscriptionNotFound,
22
22
  ErrCodeUnauthorized,
@@ -26,17 +26,20 @@ import { decodeJsonObject } from '../utils/server-utils.js'
26
26
  import { serverLog } from '../utils/log.js'
27
27
  import { applyDurableStreamsBearer } from '../stream-client.js'
28
28
  import { getDefaultWebhookSigner } from '../webhook-signing.js'
29
- import { cronRouter } from './cron-router.js'
30
29
  import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
31
30
  import { electricProxyRouter } from './electric-proxy-router.js'
32
31
  import { entitiesRouter } from './entities-router.js'
33
32
  import { entityTypesRouter } from './entity-types-router.js'
34
33
  import { getRequestSpan } from './hooks.js'
34
+ import { observationsRouter } from './observations-router.js'
35
35
  import { runnersRouter } from './runners-router.js'
36
36
  import { routeBody, validateOptionalJsonBody, withSchema } from './schema.js'
37
37
  import { withLeadingSlash } from './tenant-stream-paths.js'
38
38
  import type { IRequest, RouterType } from 'itty-router'
39
- import type { WebhookSignatureVerifierConfig } from '@electric-ax/agents-runtime'
39
+ import type {
40
+ EventSourceContract,
41
+ WebhookSignatureVerifierConfig,
42
+ } from '@electric-ax/agents-runtime'
40
43
  import type { TenantContext } from './context.js'
41
44
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
42
45
  import type { WebhookSigner } from '../webhook-signing.js'
@@ -66,7 +69,7 @@ const wakeRegistrationBodySchema = Type.Object({
66
69
  manifestKey: Type.Optional(Type.String()),
67
70
  })
68
71
 
69
- const webhookForwardBodySchema = Type.Object(
72
+ const subscriptionWebhookBodySchema = Type.Object(
70
73
  {
71
74
  subscription_id: Type.Optional(Type.String()),
72
75
  wake_id: Type.Optional(Type.String()),
@@ -84,7 +87,7 @@ const webhookForwardBodySchema = Type.Object(
84
87
  { additionalProperties: true }
85
88
  )
86
89
 
87
- const callbackForwardBodySchema = Type.Object(
90
+ const wakeCallbackBodySchema = Type.Object(
88
91
  {
89
92
  epoch: Type.Optional(Type.Number()),
90
93
  generation: Type.Optional(Type.Number()),
@@ -97,8 +100,8 @@ const callbackForwardBodySchema = Type.Object(
97
100
  )
98
101
 
99
102
  type WakeRegistrationBody = Static<typeof wakeRegistrationBodySchema>
100
- type WebhookForwardBody = Static<typeof webhookForwardBodySchema>
101
- type CallbackForwardBody = Static<typeof callbackForwardBodySchema>
103
+ type SubscriptionWebhookBody = Static<typeof subscriptionWebhookBodySchema>
104
+ type WakeCallbackBody = Static<typeof wakeCallbackBodySchema>
102
105
 
103
106
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`
104
107
 
@@ -117,18 +120,22 @@ export const internalRouter: InternalRoutes = Router<
117
120
  })
118
121
 
119
122
  internalRouter.get(`/health`, () => json({ status: `ok` }))
123
+ internalRouter.get(`/event-sources`, listEventSources)
120
124
  internalRouter.post(
121
125
  `/wake`,
122
126
  withSchema(wakeRegistrationBodySchema),
123
127
  registerWake
124
128
  )
125
- internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward)
126
- internalRouter.post(`/callback-forward/:consumerId`, callbackForward)
129
+ internalRouter.post(
130
+ `/subscription-webhooks/:subscriptionId`,
131
+ subscriptionWebhook
132
+ )
133
+ internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback)
127
134
  internalRouter.all(`/runners`, runnersRouter.fetch)
128
135
  internalRouter.all(`/runners/*`, runnersRouter.fetch)
129
136
  internalRouter.all(`/entities/*`, entitiesRouter.fetch)
130
137
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
131
- internalRouter.all(`/cron/*`, cronRouter.fetch)
138
+ internalRouter.all(`/observations/*`, observationsRouter.fetch)
132
139
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
133
140
  internalRouter.all(`*`, () => status(404))
134
141
 
@@ -179,7 +186,7 @@ function resolveWebhookSigner(ctx: TenantContext): WebhookSigner {
179
186
 
180
187
  function durableStreamsWebhookJwksUrl(ctx: TenantContext): string {
181
188
  if (!ctx.durableStreamsRouting) {
182
- return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`)
189
+ return appendPathToBackendUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`)
183
190
  }
184
191
 
185
192
  return resolveDurableStreamsRoutingAdapter(
@@ -194,6 +201,28 @@ function durableStreamsWebhookJwksUrl(ctx: TenantContext): string {
194
201
  .toString()
195
202
  }
196
203
 
204
+ function appendPathToBackendUrl(baseUrl: string, path: string): string {
205
+ const base = new URL(baseUrl)
206
+ const pathUrl = new URL(path, `http://electric-agents.local`)
207
+ const basePath =
208
+ base.pathname === `/` ? `` : base.pathname.replace(/\/+$/, ``)
209
+ const suffix = pathUrl.pathname.startsWith(`/`)
210
+ ? pathUrl.pathname
211
+ : `/${pathUrl.pathname}`
212
+ const target = new URL(base)
213
+
214
+ target.pathname = `${basePath}${suffix}`
215
+ target.search = ``
216
+ target.hash = pathUrl.hash
217
+ base.searchParams.forEach((value, key) => {
218
+ target.searchParams.append(key, value)
219
+ })
220
+ pathUrl.searchParams.forEach((value, key) => {
221
+ target.searchParams.append(key, value)
222
+ })
223
+ return target.toString()
224
+ }
225
+
197
226
  function durableStreamsJwksFetchClient(ctx: TenantContext): typeof fetch {
198
227
  return async (input, init) => {
199
228
  const headers = new Headers(init?.headers)
@@ -265,7 +294,7 @@ function claimTokenFromRequest(request: IRequest): string | undefined {
265
294
  )
266
295
  }
267
296
 
268
- function newWebhookPayload(body: WebhookForwardBody | undefined): {
297
+ function newWebhookPayload(body: SubscriptionWebhookBody | undefined): {
269
298
  wakeId: string
270
299
  generation: number
271
300
  primaryStream: string
@@ -335,13 +364,27 @@ async function registerWake(
335
364
  return status(204)
336
365
  }
337
366
 
338
- async function webhookForward(
367
+ async function listEventSources(
368
+ _request: IRequest,
369
+ ctx: TenantContext
370
+ ): Promise<Response> {
371
+ const eventSources = ctx.eventSources
372
+ ? await ctx.eventSources.listEventSources()
373
+ : []
374
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) })
375
+ }
376
+
377
+ function isAgentVisibleEventSource(source: EventSourceContract): boolean {
378
+ return source.agentVisible === true && source.status === `active`
379
+ }
380
+
381
+ async function subscriptionWebhook(
339
382
  request: IRequest,
340
383
  ctx: TenantContext
341
384
  ): Promise<Response> {
342
385
  const subscriptionId = routeParam(request, `subscriptionId`)
343
386
  const rootSpan = getRequestSpan(request)
344
- rootSpan?.updateName(`webhook-forward`)
387
+ rootSpan?.updateName(`subscription-webhook`)
345
388
  rootSpan?.setAttribute(
346
389
  `electric_agents.webhook.subscription_id`,
347
390
  subscriptionId
@@ -380,7 +423,7 @@ async function webhookForward(
380
423
  )
381
424
  }
382
425
  const parsedBodyResult = validateOptionalJsonBody(
383
- webhookForwardBodySchema,
426
+ subscriptionWebhookBodySchema,
384
427
  body,
385
428
  request.headers.get(`content-type`)
386
429
  )
@@ -388,7 +431,9 @@ async function webhookForward(
388
431
 
389
432
  let forwardBody = body
390
433
  let runningEntityUrl: string | null = null
391
- const parsedBody = parsedBodyResult.value as WebhookForwardBody | undefined
434
+ const parsedBody = parsedBodyResult.value as
435
+ | SubscriptionWebhookBody
436
+ | undefined
392
437
  const newWebhook = newWebhookPayload(parsedBody)
393
438
  const routingAdapter = resolveDurableStreamsRoutingAdapter(
394
439
  ctx.durableStreamsRouting,
@@ -467,7 +512,7 @@ async function webhookForward(
467
512
  })
468
513
  .catch((err) => {
469
514
  serverLog.warn(
470
- `[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${
515
+ `[subscription-webhook] consumerCallbacks upsert failed (non-fatal): ${
471
516
  err instanceof Error ? err.message : String(err)
472
517
  }`
473
518
  )
@@ -515,7 +560,7 @@ async function webhookForward(
515
560
  if (consumerId && callbackUrl) {
516
561
  const callback = appendPathToUrl(
517
562
  ctx.publicUrl,
518
- `/_electric/callback-forward/${encodeURIComponent(consumerId)}`
563
+ `/_electric/wake-callbacks/${encodeURIComponent(consumerId)}`
519
564
  )
520
565
  enriched.callback = callback
521
566
  if (newWebhook) {
@@ -564,7 +609,7 @@ async function webhookForward(
564
609
  }
565
610
  return apiError(
566
611
  502,
567
- `WEBHOOK_FORWARD_FAILED`,
612
+ `SUBSCRIPTION_WEBHOOK_FAILED`,
568
613
  err instanceof Error ? err.message : String(err)
569
614
  )
570
615
  }
@@ -575,7 +620,7 @@ async function webhookForward(
575
620
  return responseFromUpstream(upstream, responseBytes)
576
621
  }
577
622
 
578
- async function callbackForward(
623
+ async function wakeCallback(
579
624
  request: IRequest,
580
625
  ctx: TenantContext
581
626
  ): Promise<Response> {
@@ -600,19 +645,19 @@ async function callbackForward(
600
645
  if (!target) {
601
646
  return apiError(
602
647
  404,
603
- ErrCodeCallbackNotFound,
604
- `Unknown callback-forward consumer`
648
+ ErrCodeWakeCallbackNotFound,
649
+ `Unknown wake-callback consumer`
605
650
  )
606
651
  }
607
652
 
608
653
  const body = await readRequestBody(request as Request)
609
654
  const parsedBodyResult = validateOptionalJsonBody(
610
- callbackForwardBodySchema,
655
+ wakeCallbackBodySchema,
611
656
  body,
612
657
  request.headers.get(`content-type`)
613
658
  )
614
659
  if (!parsedBodyResult.ok) return parsedBodyResult.response
615
- const requestBody = parsedBodyResult.value as CallbackForwardBody | undefined
660
+ const requestBody = parsedBodyResult.value as WakeCallbackBody | undefined
616
661
  const isClaimRequest =
617
662
  requestBody?.wakeId !== undefined || requestBody?.wake_id !== undefined
618
663
  const isDoneRequest = requestBody?.done === true
@@ -635,7 +680,7 @@ async function callbackForward(
635
680
  return json(responseBody)
636
681
  }
637
682
 
638
- const upstreamBody = encodeCallbackForwardBody(
683
+ const upstreamBody = encodeWakeCallbackBody(
639
684
  ctx.service,
640
685
  consumerId,
641
686
  requestBody,
@@ -655,7 +700,7 @@ async function callbackForward(
655
700
  if (!token) {
656
701
  return apiError(401, `UNAUTHORIZED`, `Missing claim token`)
657
702
  }
658
- const upstreamPayload = encodeCallbackForwardPayload(
703
+ const upstreamPayload = encodeWakeCallbackPayload(
659
704
  consumerId,
660
705
  requestBody,
661
706
  (stream) => stream.replace(/^\/+/, ``)
@@ -676,7 +721,7 @@ async function callbackForward(
676
721
  } catch (err) {
677
722
  return apiError(
678
723
  502,
679
- `CALLBACK_FORWARD_FAILED`,
724
+ `WAKE_CALLBACK_FAILED`,
680
725
  err instanceof Error ? err.message : String(err)
681
726
  )
682
727
  }
@@ -715,7 +760,7 @@ async function callbackForward(
715
760
  }
716
761
  if (upstream.ok && isDoneRequest && target.primaryStream) {
717
762
  serverLog.info(
718
- `[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`
763
+ `[wake-callback] done received for stream=${target.primaryStream} consumer=${consumerId}`
719
764
  )
720
765
  const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(
721
766
  ctx.service,
@@ -770,11 +815,11 @@ async function callbackForward(
770
815
  )
771
816
  await ctx.entityBridgeManager.onEntityChanged(entity.url)
772
817
  serverLog.info(
773
- `[callback-forward] status updated after done for ${entity.url}`
818
+ `[wake-callback] status updated after done for ${entity.url}`
774
819
  )
775
820
  } else if (!entity) {
776
821
  serverLog.warn(
777
- `[callback-forward] done received but no entity found for stream=${target.primaryStream}`
822
+ `[wake-callback] done received but no entity found for stream=${target.primaryStream}`
778
823
  )
779
824
  }
780
825
 
@@ -788,19 +833,19 @@ async function callbackForward(
788
833
  )
789
834
  } else if (entity) {
790
835
  serverLog.info(
791
- `[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`
836
+ `[wake-callback] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`
792
837
  )
793
838
  }
794
839
  } else if (requestBody?.done === true) {
795
840
  serverLog.warn(
796
- `[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${
841
+ `[wake-callback] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${
797
842
  target.primaryStream ?? `null`
798
843
  } consumer=${consumerId}`
799
844
  )
800
845
  }
801
846
  } catch (err) {
802
847
  serverLog.error(
803
- `[callback-forward] error processing done for consumer=${consumerId}: ${
848
+ `[wake-callback] error processing done for consumer=${consumerId}: ${
804
849
  err instanceof Error ? err.message : String(err)
805
850
  }`
806
851
  )
@@ -820,21 +865,21 @@ async function mintClaimWriteToken(
820
865
  return ctx.runtime.claimWriteTokens.mint(ctx.service, streamPath, consumerId)
821
866
  }
822
867
 
823
- function encodeCallbackForwardBody(
868
+ function encodeWakeCallbackBody(
824
869
  service: string,
825
870
  consumerId: string,
826
- body: CallbackForwardBody | undefined,
871
+ body: WakeCallbackBody | undefined,
827
872
  routingAdapter: DurableStreamsRoutingAdapter
828
873
  ): Uint8Array {
829
- const payload = encodeCallbackForwardPayload(consumerId, body, (stream) =>
874
+ const payload = encodeWakeCallbackPayload(consumerId, body, (stream) =>
830
875
  routingAdapter.toBackendStreamPath(service, stream)
831
876
  )
832
877
  return new TextEncoder().encode(JSON.stringify(payload))
833
878
  }
834
879
 
835
- function encodeCallbackForwardPayload(
880
+ function encodeWakeCallbackPayload(
836
881
  consumerId: string,
837
- body: CallbackForwardBody | undefined,
882
+ body: WakeCallbackBody | undefined,
838
883
  mapStream: (stream: string) => string
839
884
  ): Record<string, unknown> {
840
885
  if (!body) return {}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * HTTP routes for ensuring observation backing streams.
3
+ */
4
+
5
+ import { Type, type Static } from '@sinclair/typebox'
6
+ import { Router, json } from 'itty-router'
7
+ import { routeBody, withSchema } from './schema.js'
8
+ import type { JsonRouteRequest } from './schema.js'
9
+ import type { RouterType } from 'itty-router'
10
+ import type { TenantContext } from './context.js'
11
+
12
+ const stringRecordSchema = Type.Record(Type.String(), Type.String())
13
+
14
+ const ensureEntitiesMembershipStreamBodySchema = Type.Object({
15
+ tags: Type.Optional(stringRecordSchema),
16
+ })
17
+
18
+ const ensureCronStreamBodySchema = Type.Object({
19
+ expression: Type.String(),
20
+ timezone: Type.Optional(Type.String()),
21
+ })
22
+
23
+ type EnsureEntitiesMembershipStreamBody = Static<
24
+ typeof ensureEntitiesMembershipStreamBodySchema
25
+ >
26
+ type EnsureCronStreamBody = Static<typeof ensureCronStreamBodySchema>
27
+
28
+ export type ObservationsRoutes = RouterType<
29
+ JsonRouteRequest,
30
+ [TenantContext],
31
+ Response | undefined
32
+ >
33
+
34
+ export const observationsRouter: ObservationsRoutes = Router<
35
+ JsonRouteRequest,
36
+ [TenantContext],
37
+ Response | undefined
38
+ >({
39
+ base: `/_electric/observations`,
40
+ })
41
+
42
+ observationsRouter.post(
43
+ `/entities/ensure-stream`,
44
+ withSchema(ensureEntitiesMembershipStreamBodySchema),
45
+ ensureEntitiesMembershipStream
46
+ )
47
+ observationsRouter.post(
48
+ `/cron/ensure-stream`,
49
+ withSchema(ensureCronStreamBodySchema),
50
+ ensureCronStream
51
+ )
52
+
53
+ async function ensureEntitiesMembershipStream(
54
+ request: JsonRouteRequest,
55
+ ctx: TenantContext
56
+ ): Promise<Response> {
57
+ const parsed = routeBody<EnsureEntitiesMembershipStreamBody>(request)
58
+ const result = await ctx.entityManager.ensureEntitiesMembershipStream(
59
+ parsed.tags ?? {}
60
+ )
61
+ return json(result)
62
+ }
63
+
64
+ async function ensureCronStream(
65
+ request: JsonRouteRequest,
66
+ ctx: TenantContext
67
+ ): Promise<Response> {
68
+ const parsed = routeBody<EnsureCronStreamBody>(request)
69
+ const streamPath = await ctx.entityManager.getOrCreateCronStream(
70
+ parsed.expression,
71
+ parsed.timezone
72
+ )
73
+ return json({ streamUrl: streamPath })
74
+ }
@@ -628,7 +628,7 @@ async function notificationFromClaim(
628
628
  streams,
629
629
  callback: appendPathToUrl(
630
630
  ctx.publicUrl,
631
- `/_electric/callback-forward/${encodeURIComponent(input.claim.wake_id)}`
631
+ `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`
632
632
  ),
633
633
  claimToken: input.claim.token,
634
634
  triggerEvent: `message_received`,
package/src/server.ts CHANGED
@@ -34,6 +34,7 @@ import type { Principal } from './principal.js'
34
34
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
35
35
  import type { DurableStreamsRoutingAdapter } from './routing/durable-streams-routing-adapter.js'
36
36
  import type { OssServerContext } from './routing/oss-server-router.js'
37
+ import type { EventSourceCatalog } from './routing/context.js'
37
38
  import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
38
39
  import type { DurableStreamsBearerProvider } from './stream-client.js'
39
40
  import type {
@@ -67,6 +68,8 @@ export interface ElectricAgentsServerOptions {
67
68
  request: Request
68
69
  ) => Promise<Principal | null> | Principal | null
69
70
  allowDevPrincipalFallback?: boolean
71
+ eventSources?: EventSourceCatalog
72
+ ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
70
73
  /**
71
74
  * Disabled by default. When set to a positive interval, periodically
72
75
  * recovers expired dispatch claims and stale outstanding wakes.
@@ -441,6 +444,15 @@ export class ElectricAgentsServer {
441
444
  streamClient: this.streamClient,
442
445
  runtime: this.standaloneRuntime.runtime,
443
446
  entityBridgeManager: this.entityBridgeManager,
447
+ ...(this.options.eventSources
448
+ ? { eventSources: this.options.eventSources }
449
+ : {}),
450
+ ...(this.options.ensureEventSourceWakeSource
451
+ ? {
452
+ ensureEventSourceWakeSource:
453
+ this.options.ensureEventSourceWakeSource,
454
+ }
455
+ : {}),
444
456
  isShuttingDown: () => this.shuttingDown,
445
457
  mockAgent: this.mockAgentBootstrap
446
458
  ? { runtime: this.mockAgentBootstrap.runtime }
@@ -229,7 +229,7 @@ export class WakeRegistry {
229
229
  await this.applyShapeMessage(message)
230
230
  if (
231
231
  !settled &&
232
- `control` in message.headers &&
232
+ isControlMessage(message) &&
233
233
  message.headers.control === `up-to-date`
234
234
  ) {
235
235
  settled = true
@@ -1,45 +0,0 @@
1
- /**
2
- * HTTP routes under /_electric/cron.
3
- */
4
-
5
- import { Type, type Static } from '@sinclair/typebox'
6
- import { Router, json } from 'itty-router'
7
- import { routeBody, withSchema } from './schema.js'
8
- import type { JsonRouteRequest } from './schema.js'
9
- import type { RouterType } from 'itty-router'
10
- import type { TenantContext } from './context.js'
11
-
12
- const cronRegisterBodySchema = Type.Object({
13
- expression: Type.String(),
14
- timezone: Type.Optional(Type.String()),
15
- })
16
-
17
- type CronRegisterBody = Static<typeof cronRegisterBodySchema>
18
-
19
- export type CronRoutes = RouterType<
20
- JsonRouteRequest,
21
- [TenantContext],
22
- Response | undefined
23
- >
24
-
25
- export const cronRouter: CronRoutes = Router<
26
- JsonRouteRequest,
27
- [TenantContext],
28
- Response | undefined
29
- >({
30
- base: `/_electric/cron`,
31
- })
32
-
33
- cronRouter.post(`/register`, withSchema(cronRegisterBodySchema), registerCron)
34
-
35
- async function registerCron(
36
- request: JsonRouteRequest,
37
- ctx: TenantContext
38
- ): Promise<Response> {
39
- const parsed = routeBody<CronRegisterBody>(request)
40
- const streamPath = await ctx.entityManager.getOrCreateCronStream(
41
- parsed.expression,
42
- parsed.timezone
43
- )
44
- return json({ streamUrl: streamPath })
45
- }