@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.js CHANGED
@@ -7,8 +7,8 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
7
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
- import { createHash, randomUUID } from "node:crypto";
11
- import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags } from "@electric-ax/agents-runtime";
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";
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";
@@ -75,7 +75,7 @@ const entities = pgTable(`entities`, {
75
75
  index(`idx_entities_parent`).on(table.tenantId, table.parent),
76
76
  index(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
77
77
  index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
78
- check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
78
+ check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
79
79
  ]);
80
80
  const users = pgTable(`users`, {
81
81
  tenantId: text(`tenant_id`).notNull().default(`default`),
@@ -329,12 +329,25 @@ async function runMigrations(postgresUrl) {
329
329
 
330
330
  //#endregion
331
331
  //#region src/electric-agents-types.ts
332
+ const ENTITY_SIGNALS = [
333
+ `SIGINT`,
334
+ `SIGHUP`,
335
+ `SIGTERM`,
336
+ `SIGKILL`,
337
+ `SIGSTOP`,
338
+ `SIGCONT`,
339
+ `SIGUSR`
340
+ ];
332
341
  const VALID_ENTITY_STATUSES = new Set([
333
342
  `spawning`,
334
343
  `running`,
335
344
  `idle`,
336
- `stopped`
345
+ `paused`,
346
+ `stopping`,
347
+ `stopped`,
348
+ `killed`
337
349
  ]);
350
+ const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
338
351
  function assertEntityStatus(s) {
339
352
  if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
340
353
  return s;
@@ -355,6 +368,27 @@ function assertRunnerAdminStatus(s) {
355
368
  if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
356
369
  return s;
357
370
  }
371
+ function assertEntitySignal(s) {
372
+ if (!VALID_ENTITY_SIGNALS.has(s)) throw new Error(`Invalid entity signal: "${s}"`);
373
+ return s;
374
+ }
375
+ function isTerminalEntityStatus(status$1) {
376
+ return status$1 === `stopped` || status$1 === `killed`;
377
+ }
378
+ function rejectsNormalWrites(status$1) {
379
+ return status$1 === `stopping` || isTerminalEntityStatus(status$1);
380
+ }
381
+ function expectedSignalStatus(status$1, signal) {
382
+ switch (signal) {
383
+ case `SIGKILL`: return `killed`;
384
+ case `SIGTERM`: return status$1 === `idle` ? `stopped` : `stopping`;
385
+ case `SIGSTOP`: return status$1 === `idle` ? `paused` : status$1;
386
+ case `SIGCONT`: return status$1 === `paused` ? `idle` : status$1;
387
+ case `SIGINT`:
388
+ case `SIGHUP`:
389
+ case `SIGUSR`: return status$1;
390
+ }
391
+ }
358
392
  /** Strip internal fields (write_token, subscription_id) from an entity. */
359
393
  function toPublicEntity(entity) {
360
394
  return {
@@ -376,6 +410,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
376
410
  const ErrCodeNotFound = `NOT_FOUND`;
377
411
  const ErrCodeNotRunning = `NOT_RUNNING`;
378
412
  const ErrCodeInvalidRequest = `INVALID_REQUEST`;
413
+ const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
379
414
  const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
380
415
  const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
381
416
  const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
@@ -571,7 +606,7 @@ var PostgresRegistry = class {
571
606
  const heartbeatAt = input.heartbeatAt ?? new Date();
572
607
  await this.db.update(consumerClaims).set({
573
608
  lastHeartbeatAt: heartbeatAt,
574
- leaseExpiresAt: input.leaseExpiresAt ?? null,
609
+ ...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
575
610
  updatedAt: heartbeatAt
576
611
  }).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch)));
577
612
  }
@@ -584,17 +619,24 @@ var PostgresRegistry = class {
584
619
  updatedAt: releasedAt
585
620
  }).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch))).returning();
586
621
  const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null;
587
- if (claim) await this.db.update(entityDispatchState).set({
588
- activeConsumerId: null,
589
- activeRunnerId: null,
590
- activeEpoch: null,
591
- activeClaimedAt: null,
592
- activeLeaseExpiresAt: null,
593
- lastReleasedAt: releasedAt,
594
- lastCompletedAt: releasedAt,
595
- updatedAt: releasedAt
596
- }).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch)));
597
- return claim;
622
+ let entityCleared = false;
623
+ if (claim) {
624
+ const cleared = await this.db.update(entityDispatchState).set({
625
+ activeConsumerId: null,
626
+ activeRunnerId: null,
627
+ activeEpoch: null,
628
+ activeClaimedAt: null,
629
+ activeLeaseExpiresAt: null,
630
+ lastReleasedAt: releasedAt,
631
+ lastCompletedAt: releasedAt,
632
+ updatedAt: releasedAt
633
+ }).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch))).returning({ entityUrl: entityDispatchState.entityUrl });
634
+ entityCleared = cleared.length > 0;
635
+ }
636
+ return {
637
+ claim,
638
+ entityCleared
639
+ };
598
640
  }
599
641
  async getActiveClaimsForRunner(runnerId) {
600
642
  const rows = await this.db.select().from(consumerClaims).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.runnerId, runnerId), eq(consumerClaims.status, `active`)));
@@ -770,7 +812,7 @@ var PostgresRegistry = class {
770
812
  };
771
813
  }
772
814
  async updateStatus(entityUrl, status$1) {
773
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
815
+ const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
774
816
  await this.db.update(entities).set({
775
817
  status: status$1,
776
818
  updatedAt: Date.now()
@@ -778,13 +820,17 @@ var PostgresRegistry = class {
778
820
  }
779
821
  async updateStatusWithTxid(entityUrl, status$1) {
780
822
  return await this.db.transaction(async (tx) => {
781
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
782
- await tx.update(entities).set({
823
+ const rows = await tx.update(entities).set({
783
824
  status: status$1,
784
825
  updatedAt: Date.now()
785
- }).where(whereClause);
786
- const result = await tx.execute(sql`SELECT pg_current_xact_id()::xid::text AS txid`);
787
- return parseInt(result[0].txid);
826
+ }).where(and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
827
+ return rows[0] ? parseInt(rows[0].txid) : null;
828
+ });
829
+ }
830
+ async touchEntityWithTxid(entityUrl) {
831
+ return await this.db.transaction(async (tx) => {
832
+ const rows = await tx.update(entities).set({ updatedAt: Date.now() }).where(and(eq(entities.url, entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
833
+ return rows[0] ? parseInt(rows[0].txid) : null;
788
834
  });
789
835
  }
790
836
  async setEntityTag(url, key, value) {
@@ -2411,6 +2457,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
2411
2457
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
2412
2458
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
2413
2459
  }
2460
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
2461
+ return policy?.targets[0] !== void 0;
2462
+ }
2414
2463
  async function linkEntityDispatchSubscription(ctx, entity) {
2415
2464
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
2416
2465
  const target = dispatchPolicy?.targets[0];
@@ -2636,6 +2685,7 @@ function createInitialQueuePosition(date) {
2636
2685
  }
2637
2686
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2638
2687
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2688
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2639
2689
  function sleep(ms) {
2640
2690
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2641
2691
  }
@@ -3087,16 +3137,16 @@ var EntityManager = class {
3087
3137
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3088
3138
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3089
3139
  const subtree = await this.listEntitySubtree(root);
3090
- const stopped = subtree.find((entity) => entity.status === `stopped`);
3091
- if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork stopped entity "${stopped.url}"`, 409);
3092
- let active = subtree.filter((entity) => entity.status !== `idle`);
3140
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3141
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3142
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3093
3143
  if (active.length === 0) {
3094
3144
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3095
3145
  const lockedRoot = await this.registry.getEntity(rootUrl);
3096
3146
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3097
3147
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3098
3148
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3099
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3149
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3100
3150
  if (lockedActive.length === 0) return lockedSubtree;
3101
3151
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3102
3152
  active = lockedActive;
@@ -3552,6 +3602,11 @@ var EntityManager = class {
3552
3602
  if (req.position) value.position = req.position;
3553
3603
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3554
3604
  if (value.status === `processed`) value.processed_at = now;
3605
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3606
+ if (wakePausedEntity) {
3607
+ await this.registry.updateStatus(entityUrl, `idle`);
3608
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3609
+ }
3555
3610
  const envelope = entityStateSchema.inbox.insert({
3556
3611
  key,
3557
3612
  value
@@ -3579,7 +3634,7 @@ var EntityManager = class {
3579
3634
  async updateInboxMessage(entityUrl, key, req) {
3580
3635
  const entity = await this.registry.getEntity(entityUrl);
3581
3636
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3582
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3637
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3583
3638
  const now = new Date().toISOString();
3584
3639
  const value = {};
3585
3640
  if (`payload` in req) value.payload = req.payload;
@@ -3600,7 +3655,7 @@ var EntityManager = class {
3600
3655
  async deleteInboxMessage(entityUrl, key) {
3601
3656
  const entity = await this.registry.getEntity(entityUrl);
3602
3657
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3603
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3658
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3604
3659
  const envelope = entityStateSchema.inbox.delete({ key });
3605
3660
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3606
3661
  }
@@ -3608,7 +3663,7 @@ var EntityManager = class {
3608
3663
  const entity = await this.registry.getEntity(entityUrl);
3609
3664
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3610
3665
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
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
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3613
3668
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3614
3669
  const updated = result.entity;
@@ -3620,7 +3675,7 @@ var EntityManager = class {
3620
3675
  const entity = await this.registry.getEntity(entityUrl);
3621
3676
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3622
3677
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3623
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3678
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3624
3679
  const result = await this.registry.removeEntityTag(entityUrl, key);
3625
3680
  const updated = result.entity;
3626
3681
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3873,26 +3928,131 @@ var EntityManager = class {
3873
3928
  }
3874
3929
  };
3875
3930
  }
3876
- async kill(entityUrl) {
3931
+ async signal(entityUrl, req) {
3877
3932
  const entity = await this.registry.getEntity(entityUrl);
3878
3933
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3879
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3880
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3881
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`);
3882
- if (this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3883
- const stoppedEvent = entityStateSchema.entityStopped.insert({
3884
- key: `stopped`,
3885
- value: { timestamp: new Date().toISOString() }
3934
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3935
+ const now = new Date();
3936
+ const previousState = entity.status;
3937
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
3938
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
3939
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
3940
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
3941
+ const signalValue = {
3942
+ signal: req.signal,
3943
+ status: handling.handled ? `handled` : `unhandled`,
3944
+ sender: SERVER_SIGNAL_SENDER,
3945
+ timestamp: now.toISOString()
3946
+ };
3947
+ if (req.reason !== void 0) signalValue.reason = req.reason;
3948
+ if (req.payload !== void 0) signalValue.payload = req.payload;
3949
+ if (handling.handled) {
3950
+ signalValue.handled_at = now.toISOString();
3951
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
3952
+ signalValue.outcome = handling.outcome;
3953
+ signalValue.previous_state = previousState;
3954
+ signalValue.new_state = handling.status;
3955
+ }
3956
+ const signalEvent = {
3957
+ type: `signal`,
3958
+ key,
3959
+ value: signalValue,
3960
+ headers: {
3961
+ operation: `insert`,
3962
+ timestamp: now.toISOString(),
3963
+ txid: String(txid)
3964
+ }
3965
+ };
3966
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
3967
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
3968
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
3969
+ if (handling.unregisterWakes) {
3970
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3971
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3972
+ }
3973
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3974
+ return {
3975
+ url: entityUrl,
3976
+ signal: req.signal,
3977
+ previous_state: previousState,
3978
+ new_state: handling.status,
3979
+ created_at: now.getTime(),
3980
+ txid
3981
+ };
3982
+ }
3983
+ async kill(entityUrl) {
3984
+ const response = await this.signal(entityUrl, {
3985
+ signal: `SIGKILL`,
3986
+ reason: `Legacy kill command`
3886
3987
  });
3887
- const eofData = this.encodeChangeEvent(stoppedEvent);
3888
- for (const streamPath of [entity.streams.main, entity.streams.error]) try {
3889
- await this.streamClient.append(streamPath, eofData, { close: true });
3988
+ return { txid: response.txid };
3989
+ }
3990
+ serverHandlingForSignal(status$1, signal) {
3991
+ if (signal === `SIGKILL`) return {
3992
+ status: `killed`,
3993
+ handled: true,
3994
+ outcome: `transitioned`,
3995
+ unregisterWakes: true
3996
+ };
3997
+ if (signal === `SIGTERM`) {
3998
+ if (status$1 === `idle` || status$1 === `paused`) return {
3999
+ status: `stopped`,
4000
+ handled: true,
4001
+ outcome: `transitioned`,
4002
+ unregisterWakes: true
4003
+ };
4004
+ if (status$1 === `running`) return {
4005
+ status: `stopping`,
4006
+ handled: false,
4007
+ outcome: `transitioned`,
4008
+ unregisterWakes: false
4009
+ };
4010
+ }
4011
+ if (status$1 === `paused` && signal !== `SIGCONT`) return {
4012
+ status: status$1,
4013
+ handled: true,
4014
+ outcome: `ignored`,
4015
+ unregisterWakes: false
4016
+ };
4017
+ if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
4018
+ status: `paused`,
4019
+ handled: status$1 === `idle`,
4020
+ outcome: `transitioned`,
4021
+ unregisterWakes: false
4022
+ };
4023
+ if (signal === `SIGCONT` && status$1 === `paused`) return {
4024
+ status: `idle`,
4025
+ handled: false,
4026
+ outcome: `transitioned`,
4027
+ unregisterWakes: false
4028
+ };
4029
+ return {
4030
+ status: status$1,
4031
+ handled: false,
4032
+ outcome: `ignored`,
4033
+ unregisterWakes: false
4034
+ };
4035
+ }
4036
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4037
+ const signalData = this.encodeChangeEvent(signalEvent);
4038
+ if (!closeStreams) {
4039
+ await this.streamClient.append(entity.streams.main, signalData);
4040
+ return;
4041
+ }
4042
+ const errorCloseEvent = {
4043
+ type: `signal`,
4044
+ key: signalEvent.key,
4045
+ value: signalEvent.value,
4046
+ headers: signalEvent.headers
4047
+ };
4048
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4049
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4050
+ await this.streamClient.append(streamPath, data, { close: true });
3890
4051
  } catch (err) {
3891
4052
  const message = err instanceof Error ? err.message : String(err);
3892
4053
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3893
4054
  throw err;
3894
4055
  }
3895
- return { txid };
3896
4056
  }
3897
4057
  async validateWriteEvent(entity, event) {
3898
4058
  if (!entity.type) return null;
@@ -4008,7 +4168,7 @@ var EntityManager = class {
4008
4168
  async validateSendRequest(entityUrl, req) {
4009
4169
  const entity = await this.registry.getEntity(entityUrl);
4010
4170
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4011
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4171
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4012
4172
  if (req.type && entity.type) {
4013
4173
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4014
4174
  if (inboxSchemas) {
@@ -4973,7 +5133,8 @@ var ElectricAgentsTenantRuntime = class {
4973
5133
  const primaryStream = `${entityUrl}/main`;
4974
5134
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
4975
5135
  if (callbacks.length > 0) return;
4976
- await this.manager.registry.updateStatus(entityUrl, `idle`);
5136
+ const entity = await this.manager.registry.getEntity(entityUrl);
5137
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
4977
5138
  await this.entityBridgeManager.onEntityChanged(entityUrl);
4978
5139
  }
4979
5140
  };
@@ -6115,6 +6276,87 @@ function sqlStringLiteral(value) {
6115
6276
  return `'${value.replace(/'/g, `''`)}'`;
6116
6277
  }
6117
6278
 
6279
+ //#endregion
6280
+ //#region src/webhook-signing.ts
6281
+ const encoder = new TextEncoder();
6282
+ const defaultWebhookSigner = createEd25519WebhookSigner();
6283
+ function createEd25519WebhookSigner(options = {}) {
6284
+ const privateKey = options.privateKey ? importPrivateKey(options.privateKey) : generateKeyPairSync(`ed25519`).privateKey;
6285
+ if (privateKey.asymmetricKeyType !== `ed25519`) throw new Error(`Webhook signing key must be an Ed25519 private key`);
6286
+ const publicJwk = buildPublicJwk(privateKey, options.kid);
6287
+ return {
6288
+ sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
6289
+ jwks: () => ({ keys: [{ ...publicJwk }] })
6290
+ };
6291
+ }
6292
+ function getDefaultWebhookSigner() {
6293
+ return defaultWebhookSigner;
6294
+ }
6295
+ async function webhookSigningMetadata(signer, streamRootUrl) {
6296
+ const jwks = await signer.jwks();
6297
+ const key = jwks.keys[0];
6298
+ if (!key) throw new Error(`Webhook signer did not provide any public keys`);
6299
+ return {
6300
+ alg: `ed25519`,
6301
+ kid: key.kid,
6302
+ jwks_url: appendPathToUrl(streamRootUrl, `/__ds/jwks.json`)
6303
+ };
6304
+ }
6305
+ function signWebhookBody(privateKey, kid, body) {
6306
+ const timestamp$1 = Math.floor(Date.now() / 1e3);
6307
+ const payload = bytesWithTimestamp(timestamp$1, body);
6308
+ const signature = sign(null, payload, privateKey).toString(`base64url`);
6309
+ return `t=${timestamp$1},kid=${kid},ed25519=${signature}`;
6310
+ }
6311
+ function bytesWithTimestamp(timestamp$1, body) {
6312
+ const prefix = encoder.encode(`${timestamp$1}.`);
6313
+ const bodyBytes = typeof body === `string` ? encoder.encode(body) : body;
6314
+ return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)]);
6315
+ }
6316
+ function importPrivateKey(input) {
6317
+ if (isKeyObject(input)) return input;
6318
+ if (typeof input === `string`) {
6319
+ const trimmed = input.trim();
6320
+ if (trimmed.startsWith(`{`)) return createPrivateKey({
6321
+ key: JSON.parse(trimmed),
6322
+ format: `jwk`
6323
+ });
6324
+ return createPrivateKey(trimmed.replace(/\\n/g, `\n`));
6325
+ }
6326
+ if (Buffer.isBuffer(input)) return createPrivateKey(input);
6327
+ return createPrivateKey({
6328
+ key: input,
6329
+ format: `jwk`
6330
+ });
6331
+ }
6332
+ function isKeyObject(input) {
6333
+ return typeof input === `object` && `type` in input && input.type === `private`;
6334
+ }
6335
+ function buildPublicJwk(privateKey, kid) {
6336
+ const exported = createPublicKey(privateKey).export({ format: `jwk` });
6337
+ if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) throw new Error(`Failed to export Ed25519 webhook signing key`);
6338
+ return {
6339
+ kty: `OKP`,
6340
+ crv: `Ed25519`,
6341
+ x: exported.x,
6342
+ kid: kid ?? deriveKeyId({
6343
+ kty: exported.kty,
6344
+ crv: exported.crv,
6345
+ x: exported.x
6346
+ }),
6347
+ use: `sig`,
6348
+ alg: `EdDSA`
6349
+ };
6350
+ }
6351
+ function deriveKeyId(jwk) {
6352
+ const thumbprintInput = JSON.stringify({
6353
+ crv: jwk.crv,
6354
+ kty: jwk.kty,
6355
+ x: jwk.x
6356
+ });
6357
+ return `ds_${createHash(`sha256`).update(thumbprintInput).digest(`base64url`)}`;
6358
+ }
6359
+
6118
6360
  //#endregion
6119
6361
  //#region src/routing/durable-streams-router.ts
6120
6362
  const subscriptionProxyBodySchema = Type.Object({ webhook: Type.Optional(Type.Object({ url: Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
@@ -6131,6 +6373,7 @@ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscri
6131
6373
  durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
6132
6374
  durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
6133
6375
  for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
6376
+ durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks);
6134
6377
  durableStreamsRouter.all(`/__ds`, controlPassThrough);
6135
6378
  durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
6136
6379
  durableStreamsRouter.post(`*`, streamAppend);
@@ -6139,12 +6382,16 @@ function bodyFromBytes$1(body) {
6139
6382
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
6140
6383
  }
6141
6384
  function responseFromUpstream$1(response, body) {
6142
- return new Response(body ? bodyFromBytes$1(body) : response.body, {
6385
+ const responseBody = forbidsResponseBody$1(response.status) ? null : body !== void 0 ? bodyFromBytes$1(body) : response.body;
6386
+ return new Response(responseBody, {
6143
6387
  status: response.status,
6144
6388
  statusText: response.statusText,
6145
6389
  headers: responseHeaders(response)
6146
6390
  });
6147
6391
  }
6392
+ function forbidsResponseBody$1(status$1) {
6393
+ return status$1 === 204 || status$1 === 205 || status$1 === 304;
6394
+ }
6148
6395
  async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
6149
6396
  const headers = new Headers(request.headers);
6150
6397
  headers.delete(`host`);
@@ -6178,28 +6425,32 @@ function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
6178
6425
  return next;
6179
6426
  });
6180
6427
  }
6181
- function rewriteSubscriptionResponseForClient(bytes, response, service, routingAdapter) {
6428
+ async function rewriteSubscriptionResponseForClient(bytes, response, ctx, routingAdapter) {
6182
6429
  if (!response.headers.get(`content-type`)?.includes(`application/json`)) return bytes;
6183
6430
  const payload = decodeJson(bytes);
6184
6431
  if (!payload) return bytes;
6185
- if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(service, payload.pattern);
6432
+ if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(ctx.service, payload.pattern);
6186
6433
  if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => {
6187
- if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(service, stream);
6434
+ if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(ctx.service, stream);
6188
6435
  if (stream && typeof stream === `object` && typeof stream.path === `string`) return {
6189
6436
  ...stream,
6190
- path: routingAdapter.toRuntimeStreamPath(service, stream.path)
6437
+ path: routingAdapter.toRuntimeStreamPath(ctx.service, stream.path)
6191
6438
  };
6192
6439
  return stream;
6193
6440
  });
6194
- if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(service, payload.wake_stream);
6195
- if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream);
6441
+ if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.wake_stream);
6442
+ if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.stream);
6196
6443
  if (Array.isArray(payload.acks)) payload.acks = payload.acks.map((ack) => {
6197
6444
  if (!ack || typeof ack !== `object`) return ack;
6198
6445
  const next = { ...ack };
6199
- if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream);
6200
- if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(service, next.path);
6446
+ if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(ctx.service, next.stream);
6447
+ if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path);
6201
6448
  return next;
6202
6449
  });
6450
+ if (payload.webhook && typeof payload.webhook === `object` && !Array.isArray(payload.webhook)) {
6451
+ const webhook = payload.webhook;
6452
+ webhook.signing = await webhookSigningMetadata(resolveWebhookSigner$1(ctx), ctx.publicUrl);
6453
+ }
6203
6454
  return new TextEncoder().encode(JSON.stringify(payload));
6204
6455
  }
6205
6456
  function decodeJson(bytes) {
@@ -6218,6 +6469,9 @@ function routeParam$2(request, name) {
6218
6469
  function subscriptionRoutingAdapter(ctx) {
6219
6470
  return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
6220
6471
  }
6472
+ function resolveWebhookSigner$1(ctx) {
6473
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
6474
+ }
6221
6475
  async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
6222
6476
  const body = await readRequestBody(request);
6223
6477
  if (body.length === 0) return {
@@ -6246,7 +6500,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
6246
6500
  async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
6247
6501
  const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
6248
6502
  let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
6249
- responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx.service, routingAdapter);
6503
+ responseBytes = await rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx, routingAdapter);
6250
6504
  return {
6251
6505
  upstream,
6252
6506
  response: responseFromUpstream$1(upstream, responseBytes)
@@ -6319,6 +6573,15 @@ async function controlPassThrough(request, ctx) {
6319
6573
  const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
6320
6574
  return responseFromUpstream$1(upstream);
6321
6575
  }
6576
+ async function webhookJwks(_request, ctx) {
6577
+ return new Response(JSON.stringify(await resolveWebhookSigner$1(ctx).jwks()), {
6578
+ status: 200,
6579
+ headers: {
6580
+ "content-type": `application/jwk-set+json`,
6581
+ "cache-control": `public, max-age=300`
6582
+ }
6583
+ });
6584
+ }
6322
6585
  async function streamAppend(request, ctx) {
6323
6586
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
6324
6587
  request: {
@@ -6458,6 +6721,20 @@ const forkBodySchema = Type.Object({
6458
6721
  waitTimeoutMs: Type.Optional(Type.Number())
6459
6722
  });
6460
6723
  const setTagBodySchema = Type.Object({ value: Type.String() });
6724
+ const entitySignalSchema = Type.Union([
6725
+ Type.Literal(`SIGINT`),
6726
+ Type.Literal(`SIGHUP`),
6727
+ Type.Literal(`SIGTERM`),
6728
+ Type.Literal(`SIGKILL`),
6729
+ Type.Literal(`SIGSTOP`),
6730
+ Type.Literal(`SIGCONT`),
6731
+ Type.Literal(`SIGUSR`)
6732
+ ]);
6733
+ const signalBodySchema = Type.Object({
6734
+ signal: entitySignalSchema,
6735
+ reason: Type.Optional(Type.String()),
6736
+ payload: Type.Optional(Type.Unknown())
6737
+ });
6461
6738
  const scheduleBodySchema = Type.Union([Type.Object({
6462
6739
  scheduleType: Type.Literal(`cron`),
6463
6740
  expression: Type.String(),
@@ -6481,6 +6758,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6481
6758
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6482
6759
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6483
6760
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6761
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6484
6762
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6485
6763
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6486
6764
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6684,11 +6962,13 @@ async function spawnEntity(request, ctx) {
6684
6962
  wake: parsed.wake,
6685
6963
  created_by: principal.url
6686
6964
  });
6687
- await linkEntityDispatchSubscription(ctx, entity);
6965
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
6966
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6688
6967
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6689
6968
  from: principal.url,
6690
6969
  payload: parsed.initialMessage
6691
6970
  });
6971
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6692
6972
  return json({
6693
6973
  ...toPublicEntity(entity),
6694
6974
  txid: entity.txid
@@ -6712,6 +6992,22 @@ async function killEntity(request, ctx) {
6712
6992
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6713
6993
  return json(result);
6714
6994
  }
6995
+ async function signalEntity(request, ctx) {
6996
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
6997
+ if (principalMutationError) return principalMutationError;
6998
+ const parsed = routeBody(request);
6999
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
7000
+ const result = await ctx.entityManager.signal(entityUrl, {
7001
+ signal: parsed.signal,
7002
+ reason: parsed.reason,
7003
+ payload: parsed.payload
7004
+ });
7005
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
7006
+ await unlinkEntityDispatchSubscription(ctx, entity);
7007
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
7008
+ }
7009
+ return json(result);
7010
+ }
6715
7011
 
6716
7012
  //#endregion
6717
7013
  //#region src/routing/entity-types-router.ts
@@ -7192,7 +7488,7 @@ async function notificationFromClaim(ctx, input) {
7192
7488
  const primaryStream = withLeadingSlash(primary.path);
7193
7489
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7194
7490
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7195
- if (entity.status === `stopped`) {
7491
+ if (entity.status === `stopped` || entity.status === `paused`) {
7196
7492
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7197
7493
  wake_id: input.claim.wake_id,
7198
7494
  generation: input.claim.generation
@@ -7309,12 +7605,16 @@ function bodyFromBytes(body) {
7309
7605
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
7310
7606
  }
7311
7607
  function responseFromUpstream(response, body) {
7312
- return new Response(body ? bodyFromBytes(body) : response.body, {
7608
+ const responseBody = forbidsResponseBody(response.status) ? null : body !== void 0 ? bodyFromBytes(body) : response.body;
7609
+ return new Response(responseBody, {
7313
7610
  status: response.status,
7314
7611
  statusText: response.statusText,
7315
7612
  headers: responseHeaders(response)
7316
7613
  });
7317
7614
  }
7615
+ function forbidsResponseBody(status$1) {
7616
+ return status$1 === 204 || status$1 === 205 || status$1 === 304;
7617
+ }
7318
7618
  function forwardHeadersFromRequest(request) {
7319
7619
  const headers = new Headers(request.headers);
7320
7620
  headers.delete(`host`);
@@ -7323,6 +7623,45 @@ function forwardHeadersFromRequest(request) {
7323
7623
  function durableStreamsSubscriptionCallback(value) {
7324
7624
  return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX) ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length) : null;
7325
7625
  }
7626
+ function resolveWebhookSigner(ctx) {
7627
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
7628
+ }
7629
+ function durableStreamsWebhookJwksUrl(ctx) {
7630
+ if (!ctx.durableStreamsRouting) return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
7631
+ return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
7632
+ durableStreamsUrl: ctx.durableStreamsUrl,
7633
+ serviceId: ctx.service,
7634
+ requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`)
7635
+ }).toString();
7636
+ }
7637
+ function durableStreamsJwksFetchClient(ctx) {
7638
+ return async (input, init) => {
7639
+ const headers = new Headers(init?.headers);
7640
+ await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, { overwrite: false });
7641
+ const nextInit = {
7642
+ ...init ?? {},
7643
+ headers
7644
+ };
7645
+ if (ctx.durableStreamsDispatcher) nextInit.dispatcher = ctx.durableStreamsDispatcher;
7646
+ return await fetch(input, nextInit);
7647
+ };
7648
+ }
7649
+ function resolveDurableStreamsWebhookSignature(ctx) {
7650
+ if (ctx.durableStreamsWebhookSignature === false) return false;
7651
+ return {
7652
+ jwksUrl: ctx.durableStreamsWebhookSignature?.jwksUrl ?? durableStreamsWebhookJwksUrl(ctx),
7653
+ toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
7654
+ cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
7655
+ fetchClient: ctx.durableStreamsWebhookSignature?.fetchClient ?? durableStreamsJwksFetchClient(ctx)
7656
+ };
7657
+ }
7658
+ async function verifyDurableStreamsWebhook(request, ctx, body) {
7659
+ const config = resolveDurableStreamsWebhookSignature(ctx);
7660
+ if (config === false) return null;
7661
+ const verification = await verifyWebhookSignature(body, request.headers.get(`webhook-signature`), config);
7662
+ if (verification.ok) return null;
7663
+ return apiError(verification.status, verification.status === 401 ? ErrCodeUnauthorized : `WEBHOOK_SIGNATURE_UNAVAILABLE`, verification.error);
7664
+ }
7326
7665
  function claimTokenFromRequest(request) {
7327
7666
  const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
7328
7667
  if (electricClaimToken) return electricClaimToken;
@@ -7356,7 +7695,10 @@ async function webhookForward(request, ctx) {
7356
7695
  const rootSpan = getRequestSpan(request);
7357
7696
  rootSpan?.updateName(`webhook-forward`);
7358
7697
  rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
7359
- const lookupPromise = tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7698
+ const body = await readRequestBody(request);
7699
+ const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
7700
+ if (signatureError) return signatureError;
7701
+ const targetWebhookUrl = await tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7360
7702
  try {
7361
7703
  const rows = await ctx.pgDb.select().from(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId))).limit(1);
7362
7704
  return rows[0]?.webhookUrl ?? null;
@@ -7364,7 +7706,6 @@ async function webhookForward(request, ctx) {
7364
7706
  span.end();
7365
7707
  }
7366
7708
  });
7367
- const [targetWebhookUrl, body] = await Promise.all([lookupPromise, readRequestBody(request)]);
7368
7709
  if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
7369
7710
  const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
7370
7711
  if (!parsedBodyResult.ok) return parsedBodyResult.response;
@@ -7415,7 +7756,7 @@ async function webhookForward(request, ctx) {
7415
7756
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7416
7757
  }) : void 0;
7417
7758
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7418
- if (entity?.status === `stopped`) {
7759
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7419
7760
  if (upsertPromise) await upsertPromise;
7420
7761
  return json({ done: true });
7421
7762
  }
@@ -7453,6 +7794,7 @@ async function webhookForward(request, ctx) {
7453
7794
  const headers = forwardHeadersFromRequest(request);
7454
7795
  headers.set(`content-type`, `application/json`);
7455
7796
  headers.delete(`content-length`);
7797
+ headers.set(`webhook-signature`, await resolveWebhookSigner(ctx).sign(forwardBody));
7456
7798
  let upstream;
7457
7799
  try {
7458
7800
  upstream = await tracer.startActiveSpan(`fetch.agent-handler`, async (span) => {
@@ -7540,8 +7882,9 @@ async function callbackForward(request, ctx) {
7540
7882
  serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
7541
7883
  const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
7542
7884
  const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
7543
- if (entity && stillOwnsClaim) {
7544
- if (epoch !== void 0) await ctx.entityManager.registry.materializeReleasedClaim?.({
7885
+ let entityCleared = false;
7886
+ if (epoch !== void 0) {
7887
+ const result = await ctx.entityManager.registry.materializeReleasedClaim?.({
7545
7888
  consumerId,
7546
7889
  epoch,
7547
7890
  ackedStreams: Array.isArray(requestBody?.acks) ? requestBody.acks.flatMap((ack) => {
@@ -7553,13 +7896,15 @@ async function callbackForward(request, ctx) {
7553
7896
  }] : [];
7554
7897
  }) : void 0
7555
7898
  });
7556
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
7557
- ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7899
+ entityCleared = result?.entityCleared ?? false;
7900
+ }
7901
+ if (entity && (entityCleared || stillOwnsClaim)) {
7902
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7558
7903
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7559
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
7560
- } else if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7561
- else if (entity) serverLog.info(`[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`);
7562
- else serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7904
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7905
+ } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7906
+ if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7907
+ else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
7563
7908
  } else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
7564
7909
  } catch (err) {
7565
7910
  serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -7612,4 +7957,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
7612
7957
  globalRouter.all(`*`, durableStreamsRouter.fetch);
7613
7958
 
7614
7959
  //#endregion
7615
- export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter };
7960
+ export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };