@electric-ax/agents-server 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -104,7 +104,7 @@ const entities = (0, drizzle_orm_pg_core.pgTable)(`entities`, {
104
104
  (0, drizzle_orm_pg_core.index)(`idx_entities_parent`).on(table.tenantId, table.parent),
105
105
  (0, drizzle_orm_pg_core.index)(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
106
106
  (0, drizzle_orm_pg_core.index)(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
107
- (0, drizzle_orm_pg_core.check)(`chk_entities_status`, drizzle_orm.sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
107
+ (0, drizzle_orm_pg_core.check)(`chk_entities_status`, drizzle_orm.sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
108
108
  ]);
109
109
  const users = (0, drizzle_orm_pg_core.pgTable)(`users`, {
110
110
  tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
@@ -358,12 +358,25 @@ async function runMigrations(postgresUrl) {
358
358
 
359
359
  //#endregion
360
360
  //#region src/electric-agents-types.ts
361
+ const ENTITY_SIGNALS = [
362
+ `SIGINT`,
363
+ `SIGHUP`,
364
+ `SIGTERM`,
365
+ `SIGKILL`,
366
+ `SIGSTOP`,
367
+ `SIGCONT`,
368
+ `SIGUSR`
369
+ ];
361
370
  const VALID_ENTITY_STATUSES = new Set([
362
371
  `spawning`,
363
372
  `running`,
364
373
  `idle`,
365
- `stopped`
374
+ `paused`,
375
+ `stopping`,
376
+ `stopped`,
377
+ `killed`
366
378
  ]);
379
+ const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
367
380
  function assertEntityStatus(s) {
368
381
  if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
369
382
  return s;
@@ -384,6 +397,27 @@ function assertRunnerAdminStatus(s) {
384
397
  if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
385
398
  return s;
386
399
  }
400
+ function assertEntitySignal(s) {
401
+ if (!VALID_ENTITY_SIGNALS.has(s)) throw new Error(`Invalid entity signal: "${s}"`);
402
+ return s;
403
+ }
404
+ function isTerminalEntityStatus(status$4) {
405
+ return status$4 === `stopped` || status$4 === `killed`;
406
+ }
407
+ function rejectsNormalWrites(status$4) {
408
+ return status$4 === `stopping` || isTerminalEntityStatus(status$4);
409
+ }
410
+ function expectedSignalStatus(status$4, signal) {
411
+ switch (signal) {
412
+ case `SIGKILL`: return `killed`;
413
+ case `SIGTERM`: return status$4 === `idle` ? `stopped` : `stopping`;
414
+ case `SIGSTOP`: return status$4 === `idle` ? `paused` : status$4;
415
+ case `SIGCONT`: return status$4 === `paused` ? `idle` : status$4;
416
+ case `SIGINT`:
417
+ case `SIGHUP`:
418
+ case `SIGUSR`: return status$4;
419
+ }
420
+ }
387
421
  /** Strip internal fields (write_token, subscription_id) from an entity. */
388
422
  function toPublicEntity(entity) {
389
423
  return {
@@ -405,6 +439,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
405
439
  const ErrCodeNotFound = `NOT_FOUND`;
406
440
  const ErrCodeNotRunning = `NOT_RUNNING`;
407
441
  const ErrCodeInvalidRequest = `INVALID_REQUEST`;
442
+ const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
408
443
  const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
409
444
  const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
410
445
  const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
@@ -600,7 +635,7 @@ var PostgresRegistry = class {
600
635
  const heartbeatAt = input.heartbeatAt ?? new Date();
601
636
  await this.db.update(consumerClaims).set({
602
637
  lastHeartbeatAt: heartbeatAt,
603
- leaseExpiresAt: input.leaseExpiresAt ?? null,
638
+ ...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
604
639
  updatedAt: heartbeatAt
605
640
  }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerClaims.tenantId, this.tenantId), (0, drizzle_orm.eq)(consumerClaims.consumerId, input.consumerId), (0, drizzle_orm.eq)(consumerClaims.epoch, input.epoch)));
606
641
  }
@@ -613,17 +648,24 @@ var PostgresRegistry = class {
613
648
  updatedAt: releasedAt
614
649
  }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerClaims.tenantId, this.tenantId), (0, drizzle_orm.eq)(consumerClaims.consumerId, input.consumerId), (0, drizzle_orm.eq)(consumerClaims.epoch, input.epoch))).returning();
615
650
  const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null;
616
- if (claim) await this.db.update(entityDispatchState).set({
617
- activeConsumerId: null,
618
- activeRunnerId: null,
619
- activeEpoch: null,
620
- activeClaimedAt: null,
621
- activeLeaseExpiresAt: null,
622
- lastReleasedAt: releasedAt,
623
- lastCompletedAt: releasedAt,
624
- updatedAt: releasedAt
625
- }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityDispatchState.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityDispatchState.entityUrl, claim.entity_url), (0, drizzle_orm.eq)(entityDispatchState.activeConsumerId, input.consumerId), (0, drizzle_orm.eq)(entityDispatchState.activeEpoch, input.epoch)));
626
- return claim;
651
+ let entityCleared = false;
652
+ if (claim) {
653
+ const cleared = await this.db.update(entityDispatchState).set({
654
+ activeConsumerId: null,
655
+ activeRunnerId: null,
656
+ activeEpoch: null,
657
+ activeClaimedAt: null,
658
+ activeLeaseExpiresAt: null,
659
+ lastReleasedAt: releasedAt,
660
+ lastCompletedAt: releasedAt,
661
+ updatedAt: releasedAt
662
+ }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityDispatchState.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityDispatchState.entityUrl, claim.entity_url), (0, drizzle_orm.eq)(entityDispatchState.activeConsumerId, input.consumerId), (0, drizzle_orm.eq)(entityDispatchState.activeEpoch, input.epoch))).returning({ entityUrl: entityDispatchState.entityUrl });
663
+ entityCleared = cleared.length > 0;
664
+ }
665
+ return {
666
+ claim,
667
+ entityCleared
668
+ };
627
669
  }
628
670
  async getActiveClaimsForRunner(runnerId) {
629
671
  const rows = await this.db.select().from(consumerClaims).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerClaims.tenantId, this.tenantId), (0, drizzle_orm.eq)(consumerClaims.runnerId, runnerId), (0, drizzle_orm.eq)(consumerClaims.status, `active`)));
@@ -799,7 +841,7 @@ var PostgresRegistry = class {
799
841
  };
800
842
  }
801
843
  async updateStatus(entityUrl, status$4) {
802
- const whereClause = status$4 === `stopped` ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`));
844
+ const whereClause = isTerminalEntityStatus(status$4) ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`));
803
845
  await this.db.update(entities).set({
804
846
  status: status$4,
805
847
  updatedAt: Date.now()
@@ -807,13 +849,17 @@ var PostgresRegistry = class {
807
849
  }
808
850
  async updateStatusWithTxid(entityUrl, status$4) {
809
851
  return await this.db.transaction(async (tx) => {
810
- const whereClause = status$4 === `stopped` ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`));
811
- await tx.update(entities).set({
852
+ const rows = await tx.update(entities).set({
812
853
  status: status$4,
813
854
  updatedAt: Date.now()
814
- }).where(whereClause);
815
- const result = await tx.execute(drizzle_orm.sql`SELECT pg_current_xact_id()::xid::text AS txid`);
816
- return parseInt(result[0].txid);
855
+ }).where((0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`))).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
856
+ return rows[0] ? parseInt(rows[0].txid) : null;
857
+ });
858
+ }
859
+ async touchEntityWithTxid(entityUrl) {
860
+ return await this.db.transaction(async (tx) => {
861
+ const rows = await tx.update(entities).set({ updatedAt: Date.now() }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entities.url, entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`))).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
862
+ return rows[0] ? parseInt(rows[0].txid) : null;
817
863
  });
818
864
  }
819
865
  async setEntityTag(url, key, value) {
@@ -2440,6 +2486,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
2440
2486
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
2441
2487
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
2442
2488
  }
2489
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
2490
+ return policy?.targets[0] !== void 0;
2491
+ }
2443
2492
  async function linkEntityDispatchSubscription(ctx, entity) {
2444
2493
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
2445
2494
  const target = dispatchPolicy?.targets[0];
@@ -2665,6 +2714,7 @@ function createInitialQueuePosition(date) {
2665
2714
  }
2666
2715
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2667
2716
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2717
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2668
2718
  function sleep(ms) {
2669
2719
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2670
2720
  }
@@ -3116,16 +3166,16 @@ var EntityManager = class {
3116
3166
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3117
3167
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3118
3168
  const subtree = await this.listEntitySubtree(root);
3119
- const stopped = subtree.find((entity) => entity.status === `stopped`);
3120
- if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork stopped entity "${stopped.url}"`, 409);
3121
- let active = subtree.filter((entity) => entity.status !== `idle`);
3169
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3170
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3171
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3122
3172
  if (active.length === 0) {
3123
3173
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3124
3174
  const lockedRoot = await this.registry.getEntity(rootUrl);
3125
3175
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3126
3176
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3127
3177
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3128
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3178
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3129
3179
  if (lockedActive.length === 0) return lockedSubtree;
3130
3180
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3131
3181
  active = lockedActive;
@@ -3581,6 +3631,11 @@ var EntityManager = class {
3581
3631
  if (req.position) value.position = req.position;
3582
3632
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3583
3633
  if (value.status === `processed`) value.processed_at = now;
3634
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3635
+ if (wakePausedEntity) {
3636
+ await this.registry.updateStatus(entityUrl, `idle`);
3637
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3638
+ }
3584
3639
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
3585
3640
  key,
3586
3641
  value
@@ -3608,7 +3663,7 @@ var EntityManager = class {
3608
3663
  async updateInboxMessage(entityUrl, key, req) {
3609
3664
  const entity = await this.registry.getEntity(entityUrl);
3610
3665
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3611
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3666
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3612
3667
  const now = new Date().toISOString();
3613
3668
  const value = {};
3614
3669
  if (`payload` in req) value.payload = req.payload;
@@ -3629,7 +3684,7 @@ var EntityManager = class {
3629
3684
  async deleteInboxMessage(entityUrl, key) {
3630
3685
  const entity = await this.registry.getEntity(entityUrl);
3631
3686
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3632
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3687
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3633
3688
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({ key });
3634
3689
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3635
3690
  }
@@ -3637,7 +3692,7 @@ var EntityManager = class {
3637
3692
  const entity = await this.registry.getEntity(entityUrl);
3638
3693
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3639
3694
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3640
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3695
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3641
3696
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3642
3697
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3643
3698
  const updated = result.entity;
@@ -3649,7 +3704,7 @@ var EntityManager = class {
3649
3704
  const entity = await this.registry.getEntity(entityUrl);
3650
3705
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3651
3706
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3652
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3707
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3653
3708
  const result = await this.registry.removeEntityTag(entityUrl, key);
3654
3709
  const updated = result.entity;
3655
3710
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3902,26 +3957,131 @@ var EntityManager = class {
3902
3957
  }
3903
3958
  };
3904
3959
  }
3905
- async kill(entityUrl) {
3960
+ async signal(entityUrl, req) {
3906
3961
  const entity = await this.registry.getEntity(entityUrl);
3907
3962
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3908
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3909
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3910
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`);
3911
- if (this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3912
- const stoppedEvent = __electric_ax_agents_runtime.entityStateSchema.entityStopped.insert({
3913
- key: `stopped`,
3914
- value: { timestamp: new Date().toISOString() }
3963
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3964
+ const now = new Date();
3965
+ const previousState = entity.status;
3966
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
3967
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
3968
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
3969
+ const key = `sig-${now.getTime()}-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
3970
+ const signalValue = {
3971
+ signal: req.signal,
3972
+ status: handling.handled ? `handled` : `unhandled`,
3973
+ sender: SERVER_SIGNAL_SENDER,
3974
+ timestamp: now.toISOString()
3975
+ };
3976
+ if (req.reason !== void 0) signalValue.reason = req.reason;
3977
+ if (req.payload !== void 0) signalValue.payload = req.payload;
3978
+ if (handling.handled) {
3979
+ signalValue.handled_at = now.toISOString();
3980
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
3981
+ signalValue.outcome = handling.outcome;
3982
+ signalValue.previous_state = previousState;
3983
+ signalValue.new_state = handling.status;
3984
+ }
3985
+ const signalEvent = {
3986
+ type: `signal`,
3987
+ key,
3988
+ value: signalValue,
3989
+ headers: {
3990
+ operation: `insert`,
3991
+ timestamp: now.toISOString(),
3992
+ txid: String(txid)
3993
+ }
3994
+ };
3995
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
3996
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
3997
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
3998
+ if (handling.unregisterWakes) {
3999
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
4000
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
4001
+ }
4002
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4003
+ return {
4004
+ url: entityUrl,
4005
+ signal: req.signal,
4006
+ previous_state: previousState,
4007
+ new_state: handling.status,
4008
+ created_at: now.getTime(),
4009
+ txid
4010
+ };
4011
+ }
4012
+ async kill(entityUrl) {
4013
+ const response = await this.signal(entityUrl, {
4014
+ signal: `SIGKILL`,
4015
+ reason: `Legacy kill command`
3915
4016
  });
3916
- const eofData = this.encodeChangeEvent(stoppedEvent);
3917
- for (const streamPath of [entity.streams.main, entity.streams.error]) try {
3918
- await this.streamClient.append(streamPath, eofData, { close: true });
4017
+ return { txid: response.txid };
4018
+ }
4019
+ serverHandlingForSignal(status$4, signal) {
4020
+ if (signal === `SIGKILL`) return {
4021
+ status: `killed`,
4022
+ handled: true,
4023
+ outcome: `transitioned`,
4024
+ unregisterWakes: true
4025
+ };
4026
+ if (signal === `SIGTERM`) {
4027
+ if (status$4 === `idle` || status$4 === `paused`) return {
4028
+ status: `stopped`,
4029
+ handled: true,
4030
+ outcome: `transitioned`,
4031
+ unregisterWakes: true
4032
+ };
4033
+ if (status$4 === `running`) return {
4034
+ status: `stopping`,
4035
+ handled: false,
4036
+ outcome: `transitioned`,
4037
+ unregisterWakes: false
4038
+ };
4039
+ }
4040
+ if (status$4 === `paused` && signal !== `SIGCONT`) return {
4041
+ status: status$4,
4042
+ handled: true,
4043
+ outcome: `ignored`,
4044
+ unregisterWakes: false
4045
+ };
4046
+ if (signal === `SIGSTOP` && (status$4 === `idle` || status$4 === `running`)) return {
4047
+ status: `paused`,
4048
+ handled: status$4 === `idle`,
4049
+ outcome: `transitioned`,
4050
+ unregisterWakes: false
4051
+ };
4052
+ if (signal === `SIGCONT` && status$4 === `paused`) return {
4053
+ status: `idle`,
4054
+ handled: false,
4055
+ outcome: `transitioned`,
4056
+ unregisterWakes: false
4057
+ };
4058
+ return {
4059
+ status: status$4,
4060
+ handled: false,
4061
+ outcome: `ignored`,
4062
+ unregisterWakes: false
4063
+ };
4064
+ }
4065
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4066
+ const signalData = this.encodeChangeEvent(signalEvent);
4067
+ if (!closeStreams) {
4068
+ await this.streamClient.append(entity.streams.main, signalData);
4069
+ return;
4070
+ }
4071
+ const errorCloseEvent = {
4072
+ type: `signal`,
4073
+ key: signalEvent.key,
4074
+ value: signalEvent.value,
4075
+ headers: signalEvent.headers
4076
+ };
4077
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4078
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4079
+ await this.streamClient.append(streamPath, data, { close: true });
3919
4080
  } catch (err) {
3920
4081
  const message = err instanceof Error ? err.message : String(err);
3921
4082
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3922
4083
  throw err;
3923
4084
  }
3924
- return { txid };
3925
4085
  }
3926
4086
  async validateWriteEvent(entity, event) {
3927
4087
  if (!entity.type) return null;
@@ -4037,7 +4197,7 @@ var EntityManager = class {
4037
4197
  async validateSendRequest(entityUrl, req) {
4038
4198
  const entity = await this.registry.getEntity(entityUrl);
4039
4199
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4040
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4200
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4041
4201
  if (req.type && entity.type) {
4042
4202
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4043
4203
  if (inboxSchemas) {
@@ -5002,7 +5162,8 @@ var ElectricAgentsTenantRuntime = class {
5002
5162
  const primaryStream = `${entityUrl}/main`;
5003
5163
  const callbacks = await this.db.select().from(consumerCallbacks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerCallbacks.tenantId, this.serviceId), (0, drizzle_orm.eq)(consumerCallbacks.primaryStream, primaryStream))).limit(1);
5004
5164
  if (callbacks.length > 0) return;
5005
- await this.manager.registry.updateStatus(entityUrl, `idle`);
5165
+ const entity = await this.manager.registry.getEntity(entityUrl);
5166
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
5006
5167
  await this.entityBridgeManager.onEntityChanged(entityUrl);
5007
5168
  }
5008
5169
  };
@@ -6144,6 +6305,87 @@ function sqlStringLiteral(value) {
6144
6305
  return `'${value.replace(/'/g, `''`)}'`;
6145
6306
  }
6146
6307
 
6308
+ //#endregion
6309
+ //#region src/webhook-signing.ts
6310
+ const encoder = new TextEncoder();
6311
+ const defaultWebhookSigner = createEd25519WebhookSigner();
6312
+ function createEd25519WebhookSigner(options = {}) {
6313
+ const privateKey = options.privateKey ? importPrivateKey(options.privateKey) : (0, node_crypto.generateKeyPairSync)(`ed25519`).privateKey;
6314
+ if (privateKey.asymmetricKeyType !== `ed25519`) throw new Error(`Webhook signing key must be an Ed25519 private key`);
6315
+ const publicJwk = buildPublicJwk(privateKey, options.kid);
6316
+ return {
6317
+ sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
6318
+ jwks: () => ({ keys: [{ ...publicJwk }] })
6319
+ };
6320
+ }
6321
+ function getDefaultWebhookSigner() {
6322
+ return defaultWebhookSigner;
6323
+ }
6324
+ async function webhookSigningMetadata(signer, streamRootUrl) {
6325
+ const jwks = await signer.jwks();
6326
+ const key = jwks.keys[0];
6327
+ if (!key) throw new Error(`Webhook signer did not provide any public keys`);
6328
+ return {
6329
+ alg: `ed25519`,
6330
+ kid: key.kid,
6331
+ jwks_url: (0, __electric_ax_agents_runtime.appendPathToUrl)(streamRootUrl, `/__ds/jwks.json`)
6332
+ };
6333
+ }
6334
+ function signWebhookBody(privateKey, kid, body) {
6335
+ const timestamp$1 = Math.floor(Date.now() / 1e3);
6336
+ const payload = bytesWithTimestamp(timestamp$1, body);
6337
+ const signature = (0, node_crypto.sign)(null, payload, privateKey).toString(`base64url`);
6338
+ return `t=${timestamp$1},kid=${kid},ed25519=${signature}`;
6339
+ }
6340
+ function bytesWithTimestamp(timestamp$1, body) {
6341
+ const prefix = encoder.encode(`${timestamp$1}.`);
6342
+ const bodyBytes = typeof body === `string` ? encoder.encode(body) : body;
6343
+ return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)]);
6344
+ }
6345
+ function importPrivateKey(input) {
6346
+ if (isKeyObject(input)) return input;
6347
+ if (typeof input === `string`) {
6348
+ const trimmed = input.trim();
6349
+ if (trimmed.startsWith(`{`)) return (0, node_crypto.createPrivateKey)({
6350
+ key: JSON.parse(trimmed),
6351
+ format: `jwk`
6352
+ });
6353
+ return (0, node_crypto.createPrivateKey)(trimmed.replace(/\\n/g, `\n`));
6354
+ }
6355
+ if (Buffer.isBuffer(input)) return (0, node_crypto.createPrivateKey)(input);
6356
+ return (0, node_crypto.createPrivateKey)({
6357
+ key: input,
6358
+ format: `jwk`
6359
+ });
6360
+ }
6361
+ function isKeyObject(input) {
6362
+ return typeof input === `object` && `type` in input && input.type === `private`;
6363
+ }
6364
+ function buildPublicJwk(privateKey, kid) {
6365
+ const exported = (0, node_crypto.createPublicKey)(privateKey).export({ format: `jwk` });
6366
+ if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) throw new Error(`Failed to export Ed25519 webhook signing key`);
6367
+ return {
6368
+ kty: `OKP`,
6369
+ crv: `Ed25519`,
6370
+ x: exported.x,
6371
+ kid: kid ?? deriveKeyId({
6372
+ kty: exported.kty,
6373
+ crv: exported.crv,
6374
+ x: exported.x
6375
+ }),
6376
+ use: `sig`,
6377
+ alg: `EdDSA`
6378
+ };
6379
+ }
6380
+ function deriveKeyId(jwk) {
6381
+ const thumbprintInput = JSON.stringify({
6382
+ crv: jwk.crv,
6383
+ kty: jwk.kty,
6384
+ x: jwk.x
6385
+ });
6386
+ return `ds_${(0, node_crypto.createHash)(`sha256`).update(thumbprintInput).digest(`base64url`)}`;
6387
+ }
6388
+
6147
6389
  //#endregion
6148
6390
  //#region src/routing/durable-streams-router.ts
6149
6391
  const subscriptionProxyBodySchema = __sinclair_typebox.Type.Object({ webhook: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({ url: __sinclair_typebox.Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
@@ -6160,6 +6402,7 @@ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscri
6160
6402
  durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
6161
6403
  durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
6162
6404
  for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
6405
+ durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks);
6163
6406
  durableStreamsRouter.all(`/__ds`, controlPassThrough);
6164
6407
  durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
6165
6408
  durableStreamsRouter.post(`*`, streamAppend);
@@ -6168,12 +6411,16 @@ function bodyFromBytes$1(body) {
6168
6411
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
6169
6412
  }
6170
6413
  function responseFromUpstream$1(response, body) {
6171
- return new Response(body ? bodyFromBytes$1(body) : response.body, {
6414
+ const responseBody = forbidsResponseBody$1(response.status) ? null : body !== void 0 ? bodyFromBytes$1(body) : response.body;
6415
+ return new Response(responseBody, {
6172
6416
  status: response.status,
6173
6417
  statusText: response.statusText,
6174
6418
  headers: responseHeaders(response)
6175
6419
  });
6176
6420
  }
6421
+ function forbidsResponseBody$1(status$4) {
6422
+ return status$4 === 204 || status$4 === 205 || status$4 === 304;
6423
+ }
6177
6424
  async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
6178
6425
  const headers = new Headers(request.headers);
6179
6426
  headers.delete(`host`);
@@ -6207,28 +6454,32 @@ function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
6207
6454
  return next;
6208
6455
  });
6209
6456
  }
6210
- function rewriteSubscriptionResponseForClient(bytes, response, service, routingAdapter) {
6457
+ async function rewriteSubscriptionResponseForClient(bytes, response, ctx, routingAdapter) {
6211
6458
  if (!response.headers.get(`content-type`)?.includes(`application/json`)) return bytes;
6212
6459
  const payload = decodeJson(bytes);
6213
6460
  if (!payload) return bytes;
6214
- if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(service, payload.pattern);
6461
+ if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(ctx.service, payload.pattern);
6215
6462
  if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => {
6216
- if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(service, stream);
6463
+ if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(ctx.service, stream);
6217
6464
  if (stream && typeof stream === `object` && typeof stream.path === `string`) return {
6218
6465
  ...stream,
6219
- path: routingAdapter.toRuntimeStreamPath(service, stream.path)
6466
+ path: routingAdapter.toRuntimeStreamPath(ctx.service, stream.path)
6220
6467
  };
6221
6468
  return stream;
6222
6469
  });
6223
- if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(service, payload.wake_stream);
6224
- if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream);
6470
+ if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.wake_stream);
6471
+ if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.stream);
6225
6472
  if (Array.isArray(payload.acks)) payload.acks = payload.acks.map((ack) => {
6226
6473
  if (!ack || typeof ack !== `object`) return ack;
6227
6474
  const next = { ...ack };
6228
- if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream);
6229
- if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(service, next.path);
6475
+ if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(ctx.service, next.stream);
6476
+ if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path);
6230
6477
  return next;
6231
6478
  });
6479
+ if (payload.webhook && typeof payload.webhook === `object` && !Array.isArray(payload.webhook)) {
6480
+ const webhook = payload.webhook;
6481
+ webhook.signing = await webhookSigningMetadata(resolveWebhookSigner$1(ctx), ctx.publicUrl);
6482
+ }
6232
6483
  return new TextEncoder().encode(JSON.stringify(payload));
6233
6484
  }
6234
6485
  function decodeJson(bytes) {
@@ -6247,6 +6498,9 @@ function routeParam$2(request, name) {
6247
6498
  function subscriptionRoutingAdapter(ctx) {
6248
6499
  return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
6249
6500
  }
6501
+ function resolveWebhookSigner$1(ctx) {
6502
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
6503
+ }
6250
6504
  async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
6251
6505
  const body = await readRequestBody(request);
6252
6506
  if (body.length === 0) return {
@@ -6275,7 +6529,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
6275
6529
  async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
6276
6530
  const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
6277
6531
  let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
6278
- responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx.service, routingAdapter);
6532
+ responseBytes = await rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx, routingAdapter);
6279
6533
  return {
6280
6534
  upstream,
6281
6535
  response: responseFromUpstream$1(upstream, responseBytes)
@@ -6348,6 +6602,15 @@ async function controlPassThrough(request, ctx) {
6348
6602
  const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
6349
6603
  return responseFromUpstream$1(upstream);
6350
6604
  }
6605
+ async function webhookJwks(_request, ctx) {
6606
+ return new Response(JSON.stringify(await resolveWebhookSigner$1(ctx).jwks()), {
6607
+ status: 200,
6608
+ headers: {
6609
+ "content-type": `application/jwk-set+json`,
6610
+ "cache-control": `public, max-age=300`
6611
+ }
6612
+ });
6613
+ }
6351
6614
  async function streamAppend(request, ctx) {
6352
6615
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
6353
6616
  request: {
@@ -6487,6 +6750,20 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
6487
6750
  waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
6488
6751
  });
6489
6752
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
6753
+ const entitySignalSchema = __sinclair_typebox.Type.Union([
6754
+ __sinclair_typebox.Type.Literal(`SIGINT`),
6755
+ __sinclair_typebox.Type.Literal(`SIGHUP`),
6756
+ __sinclair_typebox.Type.Literal(`SIGTERM`),
6757
+ __sinclair_typebox.Type.Literal(`SIGKILL`),
6758
+ __sinclair_typebox.Type.Literal(`SIGSTOP`),
6759
+ __sinclair_typebox.Type.Literal(`SIGCONT`),
6760
+ __sinclair_typebox.Type.Literal(`SIGUSR`)
6761
+ ]);
6762
+ const signalBodySchema = __sinclair_typebox.Type.Object({
6763
+ signal: entitySignalSchema,
6764
+ reason: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6765
+ payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown())
6766
+ });
6490
6767
  const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Object({
6491
6768
  scheduleType: __sinclair_typebox.Type.Literal(`cron`),
6492
6769
  expression: __sinclair_typebox.Type.String(),
@@ -6510,6 +6787,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6510
6787
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6511
6788
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6512
6789
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6790
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6513
6791
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6514
6792
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6515
6793
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6713,11 +6991,13 @@ async function spawnEntity(request, ctx) {
6713
6991
  wake: parsed.wake,
6714
6992
  created_by: principal.url
6715
6993
  });
6716
- await linkEntityDispatchSubscription(ctx, entity);
6994
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
6995
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6717
6996
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6718
6997
  from: principal.url,
6719
6998
  payload: parsed.initialMessage
6720
6999
  });
7000
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6721
7001
  return (0, itty_router.json)({
6722
7002
  ...toPublicEntity(entity),
6723
7003
  txid: entity.txid
@@ -6741,6 +7021,22 @@ async function killEntity(request, ctx) {
6741
7021
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6742
7022
  return (0, itty_router.json)(result);
6743
7023
  }
7024
+ async function signalEntity(request, ctx) {
7025
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
7026
+ if (principalMutationError) return principalMutationError;
7027
+ const parsed = routeBody(request);
7028
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
7029
+ const result = await ctx.entityManager.signal(entityUrl, {
7030
+ signal: parsed.signal,
7031
+ reason: parsed.reason,
7032
+ payload: parsed.payload
7033
+ });
7034
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
7035
+ await unlinkEntityDispatchSubscription(ctx, entity);
7036
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
7037
+ }
7038
+ return (0, itty_router.json)(result);
7039
+ }
6744
7040
 
6745
7041
  //#endregion
6746
7042
  //#region src/routing/entity-types-router.ts
@@ -7221,7 +7517,7 @@ async function notificationFromClaim(ctx, input) {
7221
7517
  const primaryStream = withLeadingSlash(primary.path);
7222
7518
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7223
7519
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7224
- if (entity.status === `stopped`) {
7520
+ if (entity.status === `stopped` || entity.status === `paused`) {
7225
7521
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7226
7522
  wake_id: input.claim.wake_id,
7227
7523
  generation: input.claim.generation
@@ -7338,12 +7634,16 @@ function bodyFromBytes(body) {
7338
7634
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
7339
7635
  }
7340
7636
  function responseFromUpstream(response, body) {
7341
- return new Response(body ? bodyFromBytes(body) : response.body, {
7637
+ const responseBody = forbidsResponseBody(response.status) ? null : body !== void 0 ? bodyFromBytes(body) : response.body;
7638
+ return new Response(responseBody, {
7342
7639
  status: response.status,
7343
7640
  statusText: response.statusText,
7344
7641
  headers: responseHeaders(response)
7345
7642
  });
7346
7643
  }
7644
+ function forbidsResponseBody(status$4) {
7645
+ return status$4 === 204 || status$4 === 205 || status$4 === 304;
7646
+ }
7347
7647
  function forwardHeadersFromRequest(request) {
7348
7648
  const headers = new Headers(request.headers);
7349
7649
  headers.delete(`host`);
@@ -7352,6 +7652,45 @@ function forwardHeadersFromRequest(request) {
7352
7652
  function durableStreamsSubscriptionCallback(value) {
7353
7653
  return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX) ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length) : null;
7354
7654
  }
7655
+ function resolveWebhookSigner(ctx) {
7656
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
7657
+ }
7658
+ function durableStreamsWebhookJwksUrl(ctx) {
7659
+ if (!ctx.durableStreamsRouting) return (0, __electric_ax_agents_runtime.appendPathToUrl)(ctx.durableStreamsUrl, `/__ds/jwks.json`);
7660
+ return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
7661
+ durableStreamsUrl: ctx.durableStreamsUrl,
7662
+ serviceId: ctx.service,
7663
+ requestUrl: (0, __electric_ax_agents_runtime.appendPathToUrl)(ctx.publicUrl, `/__ds/jwks.json`)
7664
+ }).toString();
7665
+ }
7666
+ function durableStreamsJwksFetchClient(ctx) {
7667
+ return async (input, init) => {
7668
+ const headers = new Headers(init?.headers);
7669
+ await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, { overwrite: false });
7670
+ const nextInit = {
7671
+ ...init ?? {},
7672
+ headers
7673
+ };
7674
+ if (ctx.durableStreamsDispatcher) nextInit.dispatcher = ctx.durableStreamsDispatcher;
7675
+ return await fetch(input, nextInit);
7676
+ };
7677
+ }
7678
+ function resolveDurableStreamsWebhookSignature(ctx) {
7679
+ if (ctx.durableStreamsWebhookSignature === false) return false;
7680
+ return {
7681
+ jwksUrl: ctx.durableStreamsWebhookSignature?.jwksUrl ?? durableStreamsWebhookJwksUrl(ctx),
7682
+ toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
7683
+ cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
7684
+ fetchClient: ctx.durableStreamsWebhookSignature?.fetchClient ?? durableStreamsJwksFetchClient(ctx)
7685
+ };
7686
+ }
7687
+ async function verifyDurableStreamsWebhook(request, ctx, body) {
7688
+ const config = resolveDurableStreamsWebhookSignature(ctx);
7689
+ if (config === false) return null;
7690
+ const verification = await (0, __electric_ax_agents_runtime.verifyWebhookSignature)(body, request.headers.get(`webhook-signature`), config);
7691
+ if (verification.ok) return null;
7692
+ return apiError(verification.status, verification.status === 401 ? ErrCodeUnauthorized : `WEBHOOK_SIGNATURE_UNAVAILABLE`, verification.error);
7693
+ }
7355
7694
  function claimTokenFromRequest(request) {
7356
7695
  const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
7357
7696
  if (electricClaimToken) return electricClaimToken;
@@ -7385,7 +7724,10 @@ async function webhookForward(request, ctx) {
7385
7724
  const rootSpan = getRequestSpan(request);
7386
7725
  rootSpan?.updateName(`webhook-forward`);
7387
7726
  rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
7388
- const lookupPromise = tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7727
+ const body = await readRequestBody(request);
7728
+ const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
7729
+ if (signatureError) return signatureError;
7730
+ const targetWebhookUrl = await tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7389
7731
  try {
7390
7732
  const rows = await ctx.pgDb.select().from(subscriptionWebhooks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(subscriptionWebhooks.tenantId, ctx.service), (0, drizzle_orm.eq)(subscriptionWebhooks.subscriptionId, subscriptionId))).limit(1);
7391
7733
  return rows[0]?.webhookUrl ?? null;
@@ -7393,7 +7735,6 @@ async function webhookForward(request, ctx) {
7393
7735
  span.end();
7394
7736
  }
7395
7737
  });
7396
- const [targetWebhookUrl, body] = await Promise.all([lookupPromise, readRequestBody(request)]);
7397
7738
  if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
7398
7739
  const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
7399
7740
  if (!parsedBodyResult.ok) return parsedBodyResult.response;
@@ -7444,7 +7785,7 @@ async function webhookForward(request, ctx) {
7444
7785
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7445
7786
  }) : void 0;
7446
7787
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7447
- if (entity?.status === `stopped`) {
7788
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7448
7789
  if (upsertPromise) await upsertPromise;
7449
7790
  return (0, itty_router.json)({ done: true });
7450
7791
  }
@@ -7482,6 +7823,7 @@ async function webhookForward(request, ctx) {
7482
7823
  const headers = forwardHeadersFromRequest(request);
7483
7824
  headers.set(`content-type`, `application/json`);
7484
7825
  headers.delete(`content-length`);
7826
+ headers.set(`webhook-signature`, await resolveWebhookSigner(ctx).sign(forwardBody));
7485
7827
  let upstream;
7486
7828
  try {
7487
7829
  upstream = await tracer.startActiveSpan(`fetch.agent-handler`, async (span) => {
@@ -7569,8 +7911,9 @@ async function callbackForward(request, ctx) {
7569
7911
  serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
7570
7912
  const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
7571
7913
  const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
7572
- if (entity && stillOwnsClaim) {
7573
- if (epoch !== void 0) await ctx.entityManager.registry.materializeReleasedClaim?.({
7914
+ let entityCleared = false;
7915
+ if (epoch !== void 0) {
7916
+ const result = await ctx.entityManager.registry.materializeReleasedClaim?.({
7574
7917
  consumerId,
7575
7918
  epoch,
7576
7919
  ackedStreams: Array.isArray(requestBody?.acks) ? requestBody.acks.flatMap((ack) => {
@@ -7582,13 +7925,15 @@ async function callbackForward(request, ctx) {
7582
7925
  }] : [];
7583
7926
  }) : void 0
7584
7927
  });
7585
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
7586
- ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7928
+ entityCleared = result?.entityCleared ?? false;
7929
+ }
7930
+ if (entity && (entityCleared || stillOwnsClaim)) {
7931
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7587
7932
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7588
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
7589
- } else if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7590
- else if (entity) serverLog.info(`[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`);
7591
- else serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7933
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7934
+ } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7935
+ if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7936
+ else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
7592
7937
  } else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
7593
7938
  } catch (err) {
7594
7939
  serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -7645,10 +7990,19 @@ exports.AgentsHost = AgentsHost
7645
7990
  exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID
7646
7991
  exports.StreamClient = StreamClient
7647
7992
  exports.UnregisteredTenantError = UnregisteredTenantError
7993
+ exports.assertEntitySignal = assertEntitySignal
7994
+ exports.assertEntityStatus = assertEntityStatus
7648
7995
  exports.createDb = createDb
7996
+ exports.createEd25519WebhookSigner = createEd25519WebhookSigner
7997
+ exports.expectedSignalStatus = expectedSignalStatus
7998
+ exports.getDefaultWebhookSigner = getDefaultWebhookSigner
7649
7999
  exports.globalRouter = globalRouter
8000
+ exports.isTerminalEntityStatus = isTerminalEntityStatus
7650
8001
  exports.isUnregisteredTenantError = isUnregisteredTenantError
7651
8002
  exports.pathPrefixedSingleTenantDurableStreamsRoutingAdapter = pathPrefixedSingleTenantDurableStreamsRoutingAdapter
8003
+ exports.rejectsNormalWrites = rejectsNormalWrites
7652
8004
  exports.runMigrations = runMigrations
7653
8005
  exports.streamRootDurableStreamsRoutingAdapter = streamRootDurableStreamsRoutingAdapter
7654
- exports.tenantRootDurableStreamsRoutingAdapter = tenantRootDurableStreamsRoutingAdapter
8006
+ exports.tenantRootDurableStreamsRoutingAdapter = tenantRootDurableStreamsRoutingAdapter
8007
+ exports.toPublicEntity = toPublicEntity
8008
+ exports.webhookSigningMetadata = webhookSigningMetadata