@electric-ax/agents-server 0.4.7 → 0.4.9

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.
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
4
4
  import { createServer } from "node:http";
5
5
  import { createServerAdapter } from "@whatwg-node/server";
6
6
  import { Agent } from "undici";
7
- import { appendPathToUrl, assertTags, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
7
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
8
8
  import fs, { existsSync } from "node:fs";
9
9
  import path, { dirname, resolve } from "node:path";
10
10
  import { drizzle } from "drizzle-orm/postgres-js";
@@ -2668,6 +2668,10 @@ function extractManifestSourceUrl(manifest) {
2668
2668
  }
2669
2669
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2670
2670
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
2671
+ if (manifest.sourceType === `webhook`) {
2672
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2673
+ if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2674
+ }
2671
2675
  return void 0;
2672
2676
  }
2673
2677
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
@@ -2954,7 +2958,8 @@ var EntityManager = class {
2954
2958
  debounceMs: req.wake.debounceMs,
2955
2959
  timeoutMs: req.wake.timeoutMs,
2956
2960
  oneShot: false,
2957
- includeResponse: req.wake.includeResponse
2961
+ includeResponse: req.wake.includeResponse,
2962
+ manifestKey: req.wake.manifestKey
2958
2963
  });
2959
2964
  const contentType = `application/json`;
2960
2965
  const createdEvent = entityStateSchema.entityCreated.insert({
@@ -3848,6 +3853,35 @@ var EntityManager = class {
3848
3853
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3849
3854
  return { txid };
3850
3855
  }
3856
+ async upsertEventSourceSubscription(entityUrl, req) {
3857
+ const manifestKey = req.subscription.manifestKey;
3858
+ const txid = randomUUID();
3859
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3860
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3861
+ await this.wakeRegistry.register({
3862
+ tenantId: this.tenantId,
3863
+ subscriberUrl: entityUrl,
3864
+ sourceUrl: req.subscription.sourceUrl,
3865
+ condition: {
3866
+ on: `change`,
3867
+ collections: [`webhook_event`],
3868
+ ops: [`insert`]
3869
+ },
3870
+ oneShot: false,
3871
+ manifestKey
3872
+ });
3873
+ return {
3874
+ txid,
3875
+ subscription: req.subscription
3876
+ };
3877
+ }
3878
+ async deleteEventSourceSubscription(entityUrl, req) {
3879
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id);
3880
+ const txid = randomUUID();
3881
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3882
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3883
+ return { txid };
3884
+ }
3851
3885
  /**
3852
3886
  * Register a wake subscription from a subscriber to a source entity.
3853
3887
  */
@@ -4476,7 +4510,8 @@ const spawnBodySchema = Type.Object({
4476
4510
  condition: wakeConditionSchema,
4477
4511
  debounceMs: Type.Optional(Type.Number()),
4478
4512
  timeoutMs: Type.Optional(Type.Number()),
4479
- includeResponse: Type.Optional(Type.Boolean())
4513
+ includeResponse: Type.Optional(Type.Boolean()),
4514
+ manifestKey: Type.Optional(Type.String())
4480
4515
  }))
4481
4516
  });
4482
4517
  const sendBodySchema = Type.Object({
@@ -4542,6 +4577,22 @@ const scheduleBodySchema = Type.Union([Type.Object({
4542
4577
  messageType: Type.Optional(Type.String()),
4543
4578
  from: Type.Optional(Type.String())
4544
4579
  })]);
4580
+ const subscriptionLifetimeSchema = Type.Union([
4581
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
4582
+ Type.Object({
4583
+ kind: Type.Literal(`expires_at`),
4584
+ at: Type.String()
4585
+ }),
4586
+ Type.Object({ kind: Type.Literal(`manual`) })
4587
+ ]);
4588
+ const eventSourceSubscriptionBodySchema = Type.Object({
4589
+ sourceKey: Type.String(),
4590
+ bucketKey: Type.Optional(Type.String()),
4591
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
4592
+ filterKey: Type.Optional(Type.String()),
4593
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
4594
+ reason: Type.Optional(Type.String())
4595
+ });
4545
4596
  const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
4546
4597
  const entitiesRouter = Router({ base: `/_electric/entities` });
4547
4598
  entitiesRouter.get(`/`, listEntities);
@@ -4559,6 +4610,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
4559
4610
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
4560
4611
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
4561
4612
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
4613
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
4614
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
4562
4615
  function entityUrlFromSegments(type, instanceId) {
4563
4616
  if (!type || !instanceId) return null;
4564
4617
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -4665,6 +4718,47 @@ async function deleteSchedule(request, ctx) {
4665
4718
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
4666
4719
  return json(result);
4667
4720
  }
4721
+ async function upsertEventSourceSubscription(request, ctx) {
4722
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
4723
+ if (principalMutationError) return principalMutationError;
4724
+ const catalog = ctx.eventSources;
4725
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
4726
+ const { entityUrl } = requireExistingEntityRoute(request);
4727
+ const parsed = routeBody(request);
4728
+ const source = await catalog.getEventSource(parsed.sourceKey);
4729
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
4730
+ if (parsed.lifetime?.kind === `expires_at`) {
4731
+ const expiresAt = new Date(parsed.lifetime.at);
4732
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
4733
+ }
4734
+ let resolved;
4735
+ try {
4736
+ resolved = resolveEventSourceSubscription({
4737
+ contract: source,
4738
+ entityUrl,
4739
+ request: {
4740
+ ...parsed,
4741
+ id: decodeURIComponent(request.params.subscriptionId)
4742
+ },
4743
+ createdBy: `tool`
4744
+ });
4745
+ } catch (error) {
4746
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
4747
+ }
4748
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
4749
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
4750
+ subscription: resolved.subscription,
4751
+ manifest: buildEventSourceManifestEntry(resolved)
4752
+ });
4753
+ return json(result);
4754
+ }
4755
+ async function deleteEventSourceSubscription(request, ctx) {
4756
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
4757
+ if (principalMutationError) return principalMutationError;
4758
+ const { entityUrl } = requireExistingEntityRoute(request);
4759
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
4760
+ return json(result);
4761
+ }
4668
4762
  async function setTag(request, ctx) {
4669
4763
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
4670
4764
  if (principalMutationError) return principalMutationError;
@@ -5379,6 +5473,7 @@ const callbackForwardBodySchema = Type.Object({
5379
5473
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
5380
5474
  const internalRouter = Router({ base: `/_electric` });
5381
5475
  internalRouter.get(`/health`, () => json({ status: `ok` }));
5476
+ internalRouter.get(`/event-sources`, listEventSources);
5382
5477
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
5383
5478
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
5384
5479
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -5482,6 +5577,13 @@ async function registerWake(request, ctx) {
5482
5577
  await ctx.entityManager.registerWake(opts);
5483
5578
  return status(204);
5484
5579
  }
5580
+ async function listEventSources(_request, ctx) {
5581
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
5582
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
5583
+ }
5584
+ function isAgentVisibleEventSource(source) {
5585
+ return source.agentVisible === true && source.status === `active`;
5586
+ }
5485
5587
  async function webhookForward(request, ctx) {
5486
5588
  const subscriptionId = routeParam(request, `subscriptionId`);
5487
5589
  const rootSpan = getRequestSpan(request);
@@ -8062,6 +8164,8 @@ var ElectricAgentsServer = class {
8062
8164
  streamClient: this.streamClient,
8063
8165
  runtime: this.standaloneRuntime.runtime,
8064
8166
  entityBridgeManager: this.entityBridgeManager,
8167
+ ...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
8168
+ ...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
8065
8169
  isShuttingDown: () => this.shuttingDown,
8066
8170
  mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0
8067
8171
  };
package/dist/index.cjs CHANGED
@@ -2653,6 +2653,10 @@ function extractManifestSourceUrl(manifest) {
2653
2653
  }
2654
2654
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2655
2655
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.sourceRef) : void 0;
2656
+ if (manifest.sourceType === `webhook`) {
2657
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2658
+ if (typeof config?.endpointKey === `string`) return (0, __electric_ax_agents_runtime.getWebhookStreamPath)(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2659
+ }
2656
2660
  return void 0;
2657
2661
  }
2658
2662
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.id) : void 0;
@@ -2939,7 +2943,8 @@ var EntityManager = class {
2939
2943
  debounceMs: req.wake.debounceMs,
2940
2944
  timeoutMs: req.wake.timeoutMs,
2941
2945
  oneShot: false,
2942
- includeResponse: req.wake.includeResponse
2946
+ includeResponse: req.wake.includeResponse,
2947
+ manifestKey: req.wake.manifestKey
2943
2948
  });
2944
2949
  const contentType = `application/json`;
2945
2950
  const createdEvent = __electric_ax_agents_runtime.entityStateSchema.entityCreated.insert({
@@ -3833,6 +3838,35 @@ var EntityManager = class {
3833
3838
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3834
3839
  return { txid };
3835
3840
  }
3841
+ async upsertEventSourceSubscription(entityUrl, req) {
3842
+ const manifestKey = req.subscription.manifestKey;
3843
+ const txid = (0, node_crypto.randomUUID)();
3844
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3845
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3846
+ await this.wakeRegistry.register({
3847
+ tenantId: this.tenantId,
3848
+ subscriberUrl: entityUrl,
3849
+ sourceUrl: req.subscription.sourceUrl,
3850
+ condition: {
3851
+ on: `change`,
3852
+ collections: [`webhook_event`],
3853
+ ops: [`insert`]
3854
+ },
3855
+ oneShot: false,
3856
+ manifestKey
3857
+ });
3858
+ return {
3859
+ txid,
3860
+ subscription: req.subscription
3861
+ };
3862
+ }
3863
+ async deleteEventSourceSubscription(entityUrl, req) {
3864
+ const manifestKey = (0, __electric_ax_agents_runtime.eventSourceSubscriptionManifestKey)(req.id);
3865
+ const txid = (0, node_crypto.randomUUID)();
3866
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3867
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3868
+ return { txid };
3869
+ }
3836
3870
  /**
3837
3871
  * Register a wake subscription from a subscriber to a source entity.
3838
3872
  */
@@ -6713,7 +6747,8 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
6713
6747
  condition: wakeConditionSchema,
6714
6748
  debounceMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
6715
6749
  timeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
6716
- includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
6750
+ includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
6751
+ manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6717
6752
  }))
6718
6753
  });
6719
6754
  const sendBodySchema = __sinclair_typebox.Type.Object({
@@ -6779,6 +6814,22 @@ const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Typ
6779
6814
  messageType: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6780
6815
  from: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6781
6816
  })]);
6817
+ const subscriptionLifetimeSchema = __sinclair_typebox.Type.Union([
6818
+ __sinclair_typebox.Type.Object({ kind: __sinclair_typebox.Type.Literal(`until_entity_stopped`) }),
6819
+ __sinclair_typebox.Type.Object({
6820
+ kind: __sinclair_typebox.Type.Literal(`expires_at`),
6821
+ at: __sinclair_typebox.Type.String()
6822
+ }),
6823
+ __sinclair_typebox.Type.Object({ kind: __sinclair_typebox.Type.Literal(`manual`) })
6824
+ ]);
6825
+ const eventSourceSubscriptionBodySchema = __sinclair_typebox.Type.Object({
6826
+ sourceKey: __sinclair_typebox.Type.String(),
6827
+ bucketKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6828
+ params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
6829
+ filterKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6830
+ lifetime: __sinclair_typebox.Type.Optional(subscriptionLifetimeSchema),
6831
+ reason: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6832
+ });
6782
6833
  const entitiesRegisterBodySchema = __sinclair_typebox.Type.Object({ tags: __sinclair_typebox.Type.Optional(stringRecordSchema) });
6783
6834
  const entitiesRouter = (0, itty_router.Router)({ base: `/_electric/entities` });
6784
6835
  entitiesRouter.get(`/`, listEntities);
@@ -6796,6 +6847,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6796
6847
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
6797
6848
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6798
6849
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6850
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6851
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
6799
6852
  function entityUrlFromSegments(type, instanceId) {
6800
6853
  if (!type || !instanceId) return null;
6801
6854
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -6902,6 +6955,47 @@ async function deleteSchedule(request, ctx) {
6902
6955
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6903
6956
  return (0, itty_router.json)(result);
6904
6957
  }
6958
+ async function upsertEventSourceSubscription(request, ctx) {
6959
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6960
+ if (principalMutationError) return principalMutationError;
6961
+ const catalog = ctx.eventSources;
6962
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6963
+ const { entityUrl } = requireExistingEntityRoute(request);
6964
+ const parsed = routeBody(request);
6965
+ const source = await catalog.getEventSource(parsed.sourceKey);
6966
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6967
+ if (parsed.lifetime?.kind === `expires_at`) {
6968
+ const expiresAt = new Date(parsed.lifetime.at);
6969
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6970
+ }
6971
+ let resolved;
6972
+ try {
6973
+ resolved = (0, __electric_ax_agents_runtime.resolveEventSourceSubscription)({
6974
+ contract: source,
6975
+ entityUrl,
6976
+ request: {
6977
+ ...parsed,
6978
+ id: decodeURIComponent(request.params.subscriptionId)
6979
+ },
6980
+ createdBy: `tool`
6981
+ });
6982
+ } catch (error) {
6983
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6984
+ }
6985
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6986
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6987
+ subscription: resolved.subscription,
6988
+ manifest: (0, __electric_ax_agents_runtime.buildEventSourceManifestEntry)(resolved)
6989
+ });
6990
+ return (0, itty_router.json)(result);
6991
+ }
6992
+ async function deleteEventSourceSubscription(request, ctx) {
6993
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6994
+ if (principalMutationError) return principalMutationError;
6995
+ const { entityUrl } = requireExistingEntityRoute(request);
6996
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6997
+ return (0, itty_router.json)(result);
6998
+ }
6905
6999
  async function setTag(request, ctx) {
6906
7000
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
6907
7001
  if (principalMutationError) return principalMutationError;
@@ -7616,6 +7710,7 @@ const callbackForwardBodySchema = __sinclair_typebox.Type.Object({
7616
7710
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7617
7711
  const internalRouter = (0, itty_router.Router)({ base: `/_electric` });
7618
7712
  internalRouter.get(`/health`, () => (0, itty_router.json)({ status: `ok` }));
7713
+ internalRouter.get(`/event-sources`, listEventSources);
7619
7714
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7620
7715
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
7621
7716
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -7719,6 +7814,13 @@ async function registerWake(request, ctx) {
7719
7814
  await ctx.entityManager.registerWake(opts);
7720
7815
  return (0, itty_router.status)(204);
7721
7816
  }
7817
+ async function listEventSources(_request, ctx) {
7818
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7819
+ return (0, itty_router.json)({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7820
+ }
7821
+ function isAgentVisibleEventSource(source) {
7822
+ return source.agentVisible === true && source.status === `active`;
7823
+ }
7722
7824
  async function webhookForward(request, ctx) {
7723
7825
  const subscriptionId = routeParam(request, `subscriptionId`);
7724
7826
  const rootSpan = getRequestSpan(request);
package/dist/index.d.cts CHANGED
@@ -187,7 +187,7 @@ import * as drizzle_orm_pg_core238 from "drizzle-orm/pg-core";
187
187
  import * as drizzle_orm_pg_core239 from "drizzle-orm/pg-core";
188
188
  import * as drizzle_orm73 from "drizzle-orm";
189
189
  import * as drizzle_orm74 from "drizzle-orm";
190
- import { EntityTags, WebhookNotification, WebhookSignatureVerifierConfig } from "@electric-ax/agents-runtime";
190
+ import { EntityTags, EventSourceBucket, EventSourceContract, EventSourceContract as EventSourceContract$1, EventSourceFilter, EventSourceSubscription, EventSourceSubscription as EventSourceSubscription$1, EventSourceSubscriptionInput, SubscriptionLifetime, WebhookNotification, WebhookSignatureVerifierConfig } from "@electric-ax/agents-runtime";
191
191
  import "@sinclair/typebox";
192
192
  import { MaybePromise } from "@durable-streams/client";
193
193
  import { AutoRouterType, IRequest } from "itty-router";
@@ -3441,6 +3441,7 @@ interface TypedSpawnRequest {
3441
3441
  debounceMs?: number;
3442
3442
  timeoutMs?: number;
3443
3443
  includeResponse?: boolean;
3444
+ manifestKey?: string;
3444
3445
  };
3445
3446
  }
3446
3447
  interface SendRequest {
@@ -4205,6 +4206,18 @@ declare class EntityManager {
4205
4206
  }): Promise<{
4206
4207
  txid: string;
4207
4208
  }>;
4209
+ upsertEventSourceSubscription(entityUrl: string, req: {
4210
+ subscription: EventSourceSubscription$1;
4211
+ manifest: Record<string, unknown>;
4212
+ }): Promise<{
4213
+ txid: string;
4214
+ subscription: EventSourceSubscription$1;
4215
+ }>;
4216
+ deleteEventSourceSubscription(entityUrl: string, req: {
4217
+ id: string;
4218
+ }): Promise<{
4219
+ txid: string;
4220
+ }>;
4208
4221
  /**
4209
4222
  * Register a wake subscription from a subscriber to a source entity.
4210
4223
  */
@@ -4454,6 +4467,10 @@ declare function webhookSigningMetadata(signer: WebhookSigner, streamRootUrl: st
4454
4467
 
4455
4468
  //#endregion
4456
4469
  //#region src/routing/context.d.ts
4470
+ interface EventSourceCatalog {
4471
+ listEventSources: () => Array<EventSourceContract$1> | Promise<Array<EventSourceContract$1>>;
4472
+ getEventSource: (sourceKey: string) => EventSourceContract$1 | undefined | Promise<EventSourceContract$1 | undefined>;
4473
+ }
4457
4474
  /**
4458
4475
  * Per-request tenant context passed through every router and handler.
4459
4476
  *
@@ -4480,6 +4497,8 @@ interface TenantContext {
4480
4497
  streamClient: StreamClient;
4481
4498
  runtime: ElectricAgentsTenantRuntime;
4482
4499
  entityBridgeManager: EntityBridgeCoordinator;
4500
+ eventSources?: EventSourceCatalog;
4501
+ ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void;
4483
4502
  isShuttingDown: () => boolean;
4484
4503
  }
4485
4504
 
@@ -4499,4 +4518,4 @@ declare class UnregisteredTenantError extends Error {
4499
4518
  declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
4500
4519
 
4501
4520
  //#endregion
4502
- export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
4521
+ export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, EventSourceBucket, EventSourceCatalog, EventSourceContract, EventSourceFilter, EventSourceSubscription, EventSourceSubscriptionInput, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionLifetime, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
package/dist/index.d.ts CHANGED
@@ -189,7 +189,7 @@ import * as drizzle_orm_pg_core237 from "drizzle-orm/pg-core";
189
189
  import * as drizzle_orm_pg_core238 from "drizzle-orm/pg-core";
190
190
  import * as drizzle_orm_pg_core239 from "drizzle-orm/pg-core";
191
191
  import { JsonWebKey, KeyObject } from "node:crypto";
192
- import { EntityTags, WebhookNotification, WebhookSignatureVerifierConfig } from "@electric-ax/agents-runtime";
192
+ import { EntityTags, EventSourceBucket, EventSourceContract, EventSourceContract as EventSourceContract$1, EventSourceFilter, EventSourceSubscription, EventSourceSubscription as EventSourceSubscription$1, EventSourceSubscriptionInput, SubscriptionLifetime, WebhookNotification, WebhookSignatureVerifierConfig } from "@electric-ax/agents-runtime";
193
193
  import { MaybePromise } from "@durable-streams/client";
194
194
  import "@sinclair/typebox";
195
195
  import { AutoRouterType, IRequest } from "itty-router";
@@ -3442,6 +3442,7 @@ interface TypedSpawnRequest {
3442
3442
  debounceMs?: number;
3443
3443
  timeoutMs?: number;
3444
3444
  includeResponse?: boolean;
3445
+ manifestKey?: string;
3445
3446
  };
3446
3447
  }
3447
3448
  interface SendRequest {
@@ -4206,6 +4207,18 @@ declare class EntityManager {
4206
4207
  }): Promise<{
4207
4208
  txid: string;
4208
4209
  }>;
4210
+ upsertEventSourceSubscription(entityUrl: string, req: {
4211
+ subscription: EventSourceSubscription$1;
4212
+ manifest: Record<string, unknown>;
4213
+ }): Promise<{
4214
+ txid: string;
4215
+ subscription: EventSourceSubscription$1;
4216
+ }>;
4217
+ deleteEventSourceSubscription(entityUrl: string, req: {
4218
+ id: string;
4219
+ }): Promise<{
4220
+ txid: string;
4221
+ }>;
4209
4222
  /**
4210
4223
  * Register a wake subscription from a subscriber to a source entity.
4211
4224
  */
@@ -4455,6 +4468,10 @@ declare function webhookSigningMetadata(signer: WebhookSigner, streamRootUrl: st
4455
4468
 
4456
4469
  //#endregion
4457
4470
  //#region src/routing/context.d.ts
4471
+ interface EventSourceCatalog {
4472
+ listEventSources: () => Array<EventSourceContract$1> | Promise<Array<EventSourceContract$1>>;
4473
+ getEventSource: (sourceKey: string) => EventSourceContract$1 | undefined | Promise<EventSourceContract$1 | undefined>;
4474
+ }
4458
4475
  /**
4459
4476
  * Per-request tenant context passed through every router and handler.
4460
4477
  *
@@ -4481,6 +4498,8 @@ interface TenantContext {
4481
4498
  streamClient: StreamClient;
4482
4499
  runtime: ElectricAgentsTenantRuntime;
4483
4500
  entityBridgeManager: EntityBridgeCoordinator;
4501
+ eventSources?: EventSourceCatalog;
4502
+ ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void;
4484
4503
  isShuttingDown: () => boolean;
4485
4504
  }
4486
4505
 
@@ -4500,4 +4519,4 @@ declare class UnregisteredTenantError extends Error {
4500
4519
  declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
4501
4520
 
4502
4521
  //#endregion
4503
- export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
4522
+ export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, EventSourceBucket, EventSourceCatalog, EventSourceContract, EventSourceFilter, EventSourceSubscription, EventSourceSubscriptionInput, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionLifetime, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import postgres from "postgres";
8
8
  import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
9
9
  import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
10
10
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
11
- import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
11
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
@@ -2624,6 +2624,10 @@ function extractManifestSourceUrl(manifest) {
2624
2624
  }
2625
2625
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2626
2626
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
2627
+ if (manifest.sourceType === `webhook`) {
2628
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2629
+ if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2630
+ }
2627
2631
  return void 0;
2628
2632
  }
2629
2633
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
@@ -2910,7 +2914,8 @@ var EntityManager = class {
2910
2914
  debounceMs: req.wake.debounceMs,
2911
2915
  timeoutMs: req.wake.timeoutMs,
2912
2916
  oneShot: false,
2913
- includeResponse: req.wake.includeResponse
2917
+ includeResponse: req.wake.includeResponse,
2918
+ manifestKey: req.wake.manifestKey
2914
2919
  });
2915
2920
  const contentType = `application/json`;
2916
2921
  const createdEvent = entityStateSchema.entityCreated.insert({
@@ -3804,6 +3809,35 @@ var EntityManager = class {
3804
3809
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3805
3810
  return { txid };
3806
3811
  }
3812
+ async upsertEventSourceSubscription(entityUrl, req) {
3813
+ const manifestKey = req.subscription.manifestKey;
3814
+ const txid = randomUUID();
3815
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3816
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3817
+ await this.wakeRegistry.register({
3818
+ tenantId: this.tenantId,
3819
+ subscriberUrl: entityUrl,
3820
+ sourceUrl: req.subscription.sourceUrl,
3821
+ condition: {
3822
+ on: `change`,
3823
+ collections: [`webhook_event`],
3824
+ ops: [`insert`]
3825
+ },
3826
+ oneShot: false,
3827
+ manifestKey
3828
+ });
3829
+ return {
3830
+ txid,
3831
+ subscription: req.subscription
3832
+ };
3833
+ }
3834
+ async deleteEventSourceSubscription(entityUrl, req) {
3835
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id);
3836
+ const txid = randomUUID();
3837
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3838
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3839
+ return { txid };
3840
+ }
3807
3841
  /**
3808
3842
  * Register a wake subscription from a subscriber to a source entity.
3809
3843
  */
@@ -6684,7 +6718,8 @@ const spawnBodySchema = Type.Object({
6684
6718
  condition: wakeConditionSchema,
6685
6719
  debounceMs: Type.Optional(Type.Number()),
6686
6720
  timeoutMs: Type.Optional(Type.Number()),
6687
- includeResponse: Type.Optional(Type.Boolean())
6721
+ includeResponse: Type.Optional(Type.Boolean()),
6722
+ manifestKey: Type.Optional(Type.String())
6688
6723
  }))
6689
6724
  });
6690
6725
  const sendBodySchema = Type.Object({
@@ -6750,6 +6785,22 @@ const scheduleBodySchema = Type.Union([Type.Object({
6750
6785
  messageType: Type.Optional(Type.String()),
6751
6786
  from: Type.Optional(Type.String())
6752
6787
  })]);
6788
+ const subscriptionLifetimeSchema = Type.Union([
6789
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
6790
+ Type.Object({
6791
+ kind: Type.Literal(`expires_at`),
6792
+ at: Type.String()
6793
+ }),
6794
+ Type.Object({ kind: Type.Literal(`manual`) })
6795
+ ]);
6796
+ const eventSourceSubscriptionBodySchema = Type.Object({
6797
+ sourceKey: Type.String(),
6798
+ bucketKey: Type.Optional(Type.String()),
6799
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6800
+ filterKey: Type.Optional(Type.String()),
6801
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
6802
+ reason: Type.Optional(Type.String())
6803
+ });
6753
6804
  const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
6754
6805
  const entitiesRouter = Router({ base: `/_electric/entities` });
6755
6806
  entitiesRouter.get(`/`, listEntities);
@@ -6767,6 +6818,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6767
6818
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
6768
6819
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6769
6820
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6821
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6822
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
6770
6823
  function entityUrlFromSegments(type, instanceId) {
6771
6824
  if (!type || !instanceId) return null;
6772
6825
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -6873,6 +6926,47 @@ async function deleteSchedule(request, ctx) {
6873
6926
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6874
6927
  return json(result);
6875
6928
  }
6929
+ async function upsertEventSourceSubscription(request, ctx) {
6930
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6931
+ if (principalMutationError) return principalMutationError;
6932
+ const catalog = ctx.eventSources;
6933
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6934
+ const { entityUrl } = requireExistingEntityRoute(request);
6935
+ const parsed = routeBody(request);
6936
+ const source = await catalog.getEventSource(parsed.sourceKey);
6937
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6938
+ if (parsed.lifetime?.kind === `expires_at`) {
6939
+ const expiresAt = new Date(parsed.lifetime.at);
6940
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6941
+ }
6942
+ let resolved;
6943
+ try {
6944
+ resolved = resolveEventSourceSubscription({
6945
+ contract: source,
6946
+ entityUrl,
6947
+ request: {
6948
+ ...parsed,
6949
+ id: decodeURIComponent(request.params.subscriptionId)
6950
+ },
6951
+ createdBy: `tool`
6952
+ });
6953
+ } catch (error) {
6954
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6955
+ }
6956
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6957
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6958
+ subscription: resolved.subscription,
6959
+ manifest: buildEventSourceManifestEntry(resolved)
6960
+ });
6961
+ return json(result);
6962
+ }
6963
+ async function deleteEventSourceSubscription(request, ctx) {
6964
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6965
+ if (principalMutationError) return principalMutationError;
6966
+ const { entityUrl } = requireExistingEntityRoute(request);
6967
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6968
+ return json(result);
6969
+ }
6876
6970
  async function setTag(request, ctx) {
6877
6971
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
6878
6972
  if (principalMutationError) return principalMutationError;
@@ -7587,6 +7681,7 @@ const callbackForwardBodySchema = Type.Object({
7587
7681
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7588
7682
  const internalRouter = Router({ base: `/_electric` });
7589
7683
  internalRouter.get(`/health`, () => json({ status: `ok` }));
7684
+ internalRouter.get(`/event-sources`, listEventSources);
7590
7685
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7591
7686
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
7592
7687
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -7690,6 +7785,13 @@ async function registerWake(request, ctx) {
7690
7785
  await ctx.entityManager.registerWake(opts);
7691
7786
  return status(204);
7692
7787
  }
7788
+ async function listEventSources(_request, ctx) {
7789
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7790
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7791
+ }
7792
+ function isAgentVisibleEventSource(source) {
7793
+ return source.agentVisible === true && source.status === `active`;
7794
+ }
7693
7795
  async function webhookForward(request, ctx) {
7694
7796
  const subscriptionId = routeParam(request, `subscriptionId`);
7695
7797
  const rootSpan = getRequestSpan(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
40
  "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@eac712f",
41
41
  "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
- "@electric-sql/client": "^1.5.18",
42
+ "@electric-sql/client": "^1.5.19",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
45
45
  "@sinclair/typebox": "^0.34.48",
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.2"
57
+ "@electric-ax/agents-runtime": "0.3.4"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
+ "@electric-ax/agents": "0.4.8",
68
69
  "@electric-ax/agents-server-conformance-tests": "0.1.7",
69
- "@electric-ax/agents": "0.4.6",
70
- "@electric-ax/agents-server-ui": "0.4.7"
70
+ "@electric-ax/agents-server-ui": "0.4.9"
71
71
  },
72
72
  "files": [
73
73
  "dist",
@@ -400,6 +400,7 @@ export interface TypedSpawnRequest {
400
400
  debounceMs?: number
401
401
  timeoutMs?: number
402
402
  includeResponse?: boolean
403
+ manifestKey?: string
403
404
  }
404
405
  }
405
406
 
@@ -6,6 +6,7 @@ import {
6
6
  getCronStreamPath,
7
7
  getSharedStateStreamPath,
8
8
  getNextCronFireAt,
9
+ eventSourceSubscriptionManifestKey,
9
10
  manifestChildKey,
10
11
  manifestSharedStateKey,
11
12
  manifestSourceKey,
@@ -50,6 +51,7 @@ import type { queueAsPromised } from 'fastq'
50
51
  import type { SchedulerClient } from './scheduler.js'
51
52
  import type { WakeEvalResult, WakeRegistry } from './wake-registry.js'
52
53
  import type { WakeMessage } from '@electric-ax/agents-runtime'
54
+ import type { EventSourceSubscription } from '@electric-ax/agents-runtime'
53
55
  import type { PostgresRegistry } from './entity-registry.js'
54
56
  import type { SchemaValidator } from './electric-agents/schema-validator.js'
55
57
  import type { StreamClient } from './stream-client.js'
@@ -554,6 +556,7 @@ export class EntityManager {
554
556
  timeoutMs: req.wake.timeoutMs,
555
557
  oneShot: false,
556
558
  includeResponse: req.wake.includeResponse,
559
+ manifestKey: req.wake.manifestKey,
557
560
  })
558
561
  }
559
562
 
@@ -2162,6 +2165,67 @@ export class EntityManager {
2162
2165
  return { txid }
2163
2166
  }
2164
2167
 
2168
+ async upsertEventSourceSubscription(
2169
+ entityUrl: string,
2170
+ req: {
2171
+ subscription: EventSourceSubscription
2172
+ manifest: Record<string, unknown>
2173
+ }
2174
+ ): Promise<{ txid: string; subscription: EventSourceSubscription }> {
2175
+ const manifestKey = req.subscription.manifestKey
2176
+ const txid = randomUUID()
2177
+ await this.writeManifestEntry(
2178
+ entityUrl,
2179
+ manifestKey,
2180
+ `upsert`,
2181
+ req.manifest,
2182
+ {
2183
+ txid,
2184
+ }
2185
+ )
2186
+
2187
+ // The manifest is the durable source of truth. Register side effects after
2188
+ // it is appended so failures can be repaired by manifest replay.
2189
+ await this.wakeRegistry.unregisterByManifestKey(
2190
+ entityUrl,
2191
+ manifestKey,
2192
+ this.tenantId
2193
+ )
2194
+ await this.wakeRegistry.register({
2195
+ tenantId: this.tenantId,
2196
+ subscriberUrl: entityUrl,
2197
+ sourceUrl: req.subscription.sourceUrl,
2198
+ condition: {
2199
+ on: `change`,
2200
+ collections: [`webhook_event`],
2201
+ ops: [`insert`],
2202
+ },
2203
+ oneShot: false,
2204
+ manifestKey,
2205
+ })
2206
+
2207
+ return { txid, subscription: req.subscription }
2208
+ }
2209
+
2210
+ async deleteEventSourceSubscription(
2211
+ entityUrl: string,
2212
+ req: { id: string }
2213
+ ): Promise<{ txid: string }> {
2214
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id)
2215
+ const txid = randomUUID()
2216
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, undefined, {
2217
+ txid,
2218
+ })
2219
+
2220
+ await this.wakeRegistry.unregisterByManifestKey(
2221
+ entityUrl,
2222
+ manifestKey,
2223
+ this.tenantId
2224
+ )
2225
+
2226
+ return { txid }
2227
+ }
2228
+
2165
2229
  // ==========================================================================
2166
2230
  // Wake Evaluation
2167
2231
  // ==========================================================================
package/src/index.ts CHANGED
@@ -53,10 +53,18 @@ export type {
53
53
  SignalResponse,
54
54
  TypedSpawnRequest,
55
55
  } from './electric-agents-types.js'
56
+ export type {
57
+ EventSourceBucket,
58
+ EventSourceContract,
59
+ EventSourceFilter,
60
+ EventSourceSubscription,
61
+ EventSourceSubscriptionInput,
62
+ SubscriptionLifetime,
63
+ } from '@electric-ax/agents-runtime'
56
64
  export type { Principal, PrincipalKind } from './principal.js'
57
65
  export { globalRouter } from './routing/global-router.js'
58
66
  export type { GlobalRoutes } from './routing/global-router.js'
59
- export type { TenantContext } from './routing/context.js'
67
+ export type { EventSourceCatalog, TenantContext } from './routing/context.js'
60
68
  export {
61
69
  streamRootDurableStreamsRoutingAdapter,
62
70
  pathPrefixedSingleTenantDurableStreamsRoutingAdapter,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getCronStreamPathFromSpec,
3
+ getWebhookStreamPath,
3
4
  getSharedStateStreamPath,
4
5
  resolveCronScheduleSpec,
5
6
  } from '@electric-ax/agents-runtime'
@@ -56,6 +57,16 @@ export function extractManifestSourceUrl(
56
57
  : undefined
57
58
  }
58
59
 
60
+ if (manifest.sourceType === `webhook`) {
61
+ if (typeof config?.streamUrl === `string`) return config.streamUrl
62
+ if (typeof config?.endpointKey === `string`) {
63
+ return getWebhookStreamPath(
64
+ config.endpointKey,
65
+ typeof config.bucket === `string` ? config.bucket : undefined
66
+ )
67
+ }
68
+ }
69
+
59
70
  return undefined
60
71
  }
61
72
 
@@ -1,5 +1,8 @@
1
1
  import type { Agent } from 'undici'
2
- import type { WebhookSignatureVerifierConfig } from '@electric-ax/agents-runtime'
2
+ import type {
3
+ EventSourceContract,
4
+ WebhookSignatureVerifierConfig,
5
+ } from '@electric-ax/agents-runtime'
3
6
  import type { DrizzleDB } from '../db/index.js'
4
7
  import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
5
8
  import type { EntityManager } from '../entity-manager.js'
@@ -10,6 +13,18 @@ import type { Principal } from '../principal.js'
10
13
  import type { DurableStreamsBearerProvider } from '../stream-client.js'
11
14
  import type { WebhookSigner } from '../webhook-signing.js'
12
15
 
16
+ export interface EventSourceCatalog {
17
+ listEventSources: () =>
18
+ | Array<EventSourceContract>
19
+ | Promise<Array<EventSourceContract>>
20
+ getEventSource: (
21
+ sourceKey: string
22
+ ) =>
23
+ | EventSourceContract
24
+ | undefined
25
+ | Promise<EventSourceContract | undefined>
26
+ }
27
+
13
28
  /**
14
29
  * Per-request tenant context passed through every router and handler.
15
30
  *
@@ -38,5 +53,7 @@ export interface TenantContext {
38
53
  streamClient: StreamClient
39
54
  runtime: ElectricAgentsTenantRuntime
40
55
  entityBridgeManager: EntityBridgeCoordinator
56
+ eventSources?: EventSourceCatalog
57
+ ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
41
58
  isShuttingDown: () => boolean
42
59
  }
@@ -3,6 +3,10 @@
3
3
  */
4
4
 
5
5
  import { Type, type Static } from '@sinclair/typebox'
6
+ import {
7
+ buildEventSourceManifestEntry,
8
+ resolveEventSourceSubscription,
9
+ } from '@electric-ax/agents-runtime'
6
10
  import { Router, json, status } from 'itty-router'
7
11
  import { apiError } from '../electric-agents-http.js'
8
12
  import { parsePrincipalKey, principalUrl } from '../principal.js'
@@ -26,6 +30,7 @@ import type { ElectricAgentsEntity } from '../electric-agents-types.js'
26
30
  import type { JsonRouteRequest } from './schema.js'
27
31
  import type { RouterType } from 'itty-router'
28
32
  import type { TenantContext } from './context.js'
33
+ import type { EventSourceSubscriptionInput } from '@electric-ax/agents-runtime'
29
34
 
30
35
  interface AgentsRouteRequest extends JsonRouteRequest {
31
36
  entityRoute?: ExistingEntityRoute
@@ -84,6 +89,7 @@ const spawnBodySchema = Type.Object({
84
89
  debounceMs: Type.Optional(Type.Number()),
85
90
  timeoutMs: Type.Optional(Type.Number()),
86
91
  includeResponse: Type.Optional(Type.Boolean()),
92
+ manifestKey: Type.Optional(Type.String()),
87
93
  })
88
94
  ),
89
95
  })
@@ -169,6 +175,24 @@ const scheduleBodySchema = Type.Union([
169
175
  }),
170
176
  ])
171
177
 
178
+ const subscriptionLifetimeSchema = Type.Union([
179
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
180
+ Type.Object({
181
+ kind: Type.Literal(`expires_at`),
182
+ at: Type.String(),
183
+ }),
184
+ Type.Object({ kind: Type.Literal(`manual`) }),
185
+ ])
186
+
187
+ const eventSourceSubscriptionBodySchema = Type.Object({
188
+ sourceKey: Type.String(),
189
+ bucketKey: Type.Optional(Type.String()),
190
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
191
+ filterKey: Type.Optional(Type.String()),
192
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
193
+ reason: Type.Optional(Type.String()),
194
+ })
195
+
172
196
  const entitiesRegisterBodySchema = Type.Object({
173
197
  tags: Type.Optional(stringRecordSchema),
174
198
  })
@@ -180,6 +204,9 @@ type ForkBody = Static<typeof forkBodySchema>
180
204
  type SetTagBody = Static<typeof setTagBodySchema>
181
205
  type SignalBody = Static<typeof signalBodySchema>
182
206
  type ScheduleBody = Static<typeof scheduleBodySchema>
207
+ type EventSourceSubscriptionBody = Static<
208
+ typeof eventSourceSubscriptionBodySchema
209
+ >
183
210
  type EntitiesRegisterBody = Static<typeof entitiesRegisterBodySchema>
184
211
 
185
212
  export const entitiesRouter: EntitiesRoutes = Router<
@@ -256,6 +283,17 @@ entitiesRouter.delete(
256
283
  withExistingEntity,
257
284
  deleteSchedule
258
285
  )
286
+ entitiesRouter.put(
287
+ `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
288
+ withExistingEntity,
289
+ withSchema(eventSourceSubscriptionBodySchema),
290
+ upsertEventSourceSubscription
291
+ )
292
+ entitiesRouter.delete(
293
+ `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
294
+ withExistingEntity,
295
+ deleteEventSourceSubscription
296
+ )
259
297
 
260
298
  function entityUrlFromSegments(
261
299
  type: string,
@@ -467,6 +505,98 @@ async function deleteSchedule(
467
505
  return json(result)
468
506
  }
469
507
 
508
+ async function upsertEventSourceSubscription(
509
+ request: AgentsRouteRequest,
510
+ ctx: TenantContext
511
+ ): Promise<Response> {
512
+ const principalMutationError = rejectPrincipalEntityMutation(
513
+ request,
514
+ `subscribed to event sources`
515
+ )
516
+ if (principalMutationError) return principalMutationError
517
+
518
+ const catalog = ctx.eventSources
519
+ if (!catalog) {
520
+ return apiError(
521
+ 404,
522
+ ErrCodeNotFound,
523
+ `No event source catalog is configured`
524
+ )
525
+ }
526
+
527
+ const { entityUrl } = requireExistingEntityRoute(request)
528
+ const parsed = routeBody<EventSourceSubscriptionBody>(request)
529
+ const source = await catalog.getEventSource(parsed.sourceKey)
530
+ if (!source) {
531
+ return apiError(
532
+ 404,
533
+ ErrCodeNotFound,
534
+ `Event source "${parsed.sourceKey}" not found`
535
+ )
536
+ }
537
+
538
+ if (parsed.lifetime?.kind === `expires_at`) {
539
+ const expiresAt = new Date(parsed.lifetime.at)
540
+ if (Number.isNaN(expiresAt.getTime())) {
541
+ return apiError(
542
+ 400,
543
+ ErrCodeInvalidRequest,
544
+ `Invalid expires_at lifetime timestamp`
545
+ )
546
+ }
547
+ }
548
+
549
+ let resolved: ReturnType<typeof resolveEventSourceSubscription>
550
+ try {
551
+ resolved = resolveEventSourceSubscription({
552
+ contract: source,
553
+ entityUrl,
554
+ request: {
555
+ ...(parsed as EventSourceSubscriptionInput),
556
+ id: decodeURIComponent(request.params.subscriptionId),
557
+ },
558
+ createdBy: `tool`,
559
+ })
560
+ } catch (error) {
561
+ return apiError(
562
+ 400,
563
+ ErrCodeInvalidRequest,
564
+ error instanceof Error ? error.message : String(error)
565
+ )
566
+ }
567
+
568
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl)
569
+
570
+ const result = await ctx.entityManager.upsertEventSourceSubscription(
571
+ entityUrl,
572
+ {
573
+ subscription: resolved.subscription,
574
+ manifest: buildEventSourceManifestEntry(resolved),
575
+ }
576
+ )
577
+ return json(result)
578
+ }
579
+
580
+ async function deleteEventSourceSubscription(
581
+ request: AgentsRouteRequest,
582
+ ctx: TenantContext
583
+ ): Promise<Response> {
584
+ const principalMutationError = rejectPrincipalEntityMutation(
585
+ request,
586
+ `unsubscribed from event sources`
587
+ )
588
+ if (principalMutationError) return principalMutationError
589
+
590
+ const { entityUrl } = requireExistingEntityRoute(request)
591
+ const result = await ctx.entityManager.deleteEventSourceSubscription(
592
+ entityUrl,
593
+ {
594
+ id: decodeURIComponent(request.params.subscriptionId),
595
+ }
596
+ )
597
+ return json(result)
598
+ }
599
+
470
600
  async function setTag(
471
601
  request: AgentsRouteRequest,
472
602
  ctx: TenantContext
@@ -36,7 +36,10 @@ 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'
@@ -117,6 +120,7 @@ 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),
@@ -335,6 +339,20 @@ async function registerWake(
335
339
  return status(204)
336
340
  }
337
341
 
342
+ async function listEventSources(
343
+ _request: IRequest,
344
+ ctx: TenantContext
345
+ ): Promise<Response> {
346
+ const eventSources = ctx.eventSources
347
+ ? await ctx.eventSources.listEventSources()
348
+ : []
349
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) })
350
+ }
351
+
352
+ function isAgentVisibleEventSource(source: EventSourceContract): boolean {
353
+ return source.agentVisible === true && source.status === `active`
354
+ }
355
+
338
356
  async function webhookForward(
339
357
  request: IRequest,
340
358
  ctx: TenantContext
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 }