@electric-ax/agents-server 0.4.6 → 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`;
@@ -806,7 +841,7 @@ var PostgresRegistry = class {
806
841
  };
807
842
  }
808
843
  async updateStatus(entityUrl, status$4) {
809
- 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`));
810
845
  await this.db.update(entities).set({
811
846
  status: status$4,
812
847
  updatedAt: Date.now()
@@ -814,13 +849,17 @@ var PostgresRegistry = class {
814
849
  }
815
850
  async updateStatusWithTxid(entityUrl, status$4) {
816
851
  return await this.db.transaction(async (tx) => {
817
- const whereClause = status$4 === `stopped` ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`));
818
- await tx.update(entities).set({
852
+ const rows = await tx.update(entities).set({
819
853
  status: status$4,
820
854
  updatedAt: Date.now()
821
- }).where(whereClause);
822
- const result = await tx.execute(drizzle_orm.sql`SELECT pg_current_xact_id()::xid::text AS txid`);
823
- 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;
824
863
  });
825
864
  }
826
865
  async setEntityTag(url, key, value) {
@@ -2447,6 +2486,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
2447
2486
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
2448
2487
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
2449
2488
  }
2489
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
2490
+ return policy?.targets[0] !== void 0;
2491
+ }
2450
2492
  async function linkEntityDispatchSubscription(ctx, entity) {
2451
2493
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
2452
2494
  const target = dispatchPolicy?.targets[0];
@@ -2672,6 +2714,7 @@ function createInitialQueuePosition(date) {
2672
2714
  }
2673
2715
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2674
2716
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2717
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2675
2718
  function sleep(ms) {
2676
2719
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2677
2720
  }
@@ -3123,16 +3166,16 @@ var EntityManager = class {
3123
3166
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3124
3167
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3125
3168
  const subtree = await this.listEntitySubtree(root);
3126
- const stopped = subtree.find((entity) => entity.status === `stopped`);
3127
- if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork stopped entity "${stopped.url}"`, 409);
3128
- 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`);
3129
3172
  if (active.length === 0) {
3130
3173
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3131
3174
  const lockedRoot = await this.registry.getEntity(rootUrl);
3132
3175
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3133
3176
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3134
3177
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3135
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3178
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3136
3179
  if (lockedActive.length === 0) return lockedSubtree;
3137
3180
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3138
3181
  active = lockedActive;
@@ -3588,6 +3631,11 @@ var EntityManager = class {
3588
3631
  if (req.position) value.position = req.position;
3589
3632
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3590
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
+ }
3591
3639
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
3592
3640
  key,
3593
3641
  value
@@ -3615,7 +3663,7 @@ var EntityManager = class {
3615
3663
  async updateInboxMessage(entityUrl, key, req) {
3616
3664
  const entity = await this.registry.getEntity(entityUrl);
3617
3665
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3618
- 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);
3619
3667
  const now = new Date().toISOString();
3620
3668
  const value = {};
3621
3669
  if (`payload` in req) value.payload = req.payload;
@@ -3636,7 +3684,7 @@ var EntityManager = class {
3636
3684
  async deleteInboxMessage(entityUrl, key) {
3637
3685
  const entity = await this.registry.getEntity(entityUrl);
3638
3686
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3639
- 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);
3640
3688
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({ key });
3641
3689
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3642
3690
  }
@@ -3644,7 +3692,7 @@ var EntityManager = class {
3644
3692
  const entity = await this.registry.getEntity(entityUrl);
3645
3693
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3646
3694
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3647
- 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);
3648
3696
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3649
3697
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3650
3698
  const updated = result.entity;
@@ -3656,7 +3704,7 @@ var EntityManager = class {
3656
3704
  const entity = await this.registry.getEntity(entityUrl);
3657
3705
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3658
3706
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3659
- 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);
3660
3708
  const result = await this.registry.removeEntityTag(entityUrl, key);
3661
3709
  const updated = result.entity;
3662
3710
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3909,26 +3957,131 @@ var EntityManager = class {
3909
3957
  }
3910
3958
  };
3911
3959
  }
3912
- async kill(entityUrl) {
3960
+ async signal(entityUrl, req) {
3913
3961
  const entity = await this.registry.getEntity(entityUrl);
3914
3962
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3915
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3916
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3917
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`);
3918
- if (this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3919
- const stoppedEvent = __electric_ax_agents_runtime.entityStateSchema.entityStopped.insert({
3920
- key: `stopped`,
3921
- 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`
3922
4016
  });
3923
- const eofData = this.encodeChangeEvent(stoppedEvent);
3924
- for (const streamPath of [entity.streams.main, entity.streams.error]) try {
3925
- 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 });
3926
4080
  } catch (err) {
3927
4081
  const message = err instanceof Error ? err.message : String(err);
3928
4082
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3929
4083
  throw err;
3930
4084
  }
3931
- return { txid };
3932
4085
  }
3933
4086
  async validateWriteEvent(entity, event) {
3934
4087
  if (!entity.type) return null;
@@ -4044,7 +4197,7 @@ var EntityManager = class {
4044
4197
  async validateSendRequest(entityUrl, req) {
4045
4198
  const entity = await this.registry.getEntity(entityUrl);
4046
4199
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4047
- 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);
4048
4201
  if (req.type && entity.type) {
4049
4202
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4050
4203
  if (inboxSchemas) {
@@ -5009,7 +5162,8 @@ var ElectricAgentsTenantRuntime = class {
5009
5162
  const primaryStream = `${entityUrl}/main`;
5010
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);
5011
5164
  if (callbacks.length > 0) return;
5012
- 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`);
5013
5167
  await this.entityBridgeManager.onEntityChanged(entityUrl);
5014
5168
  }
5015
5169
  };
@@ -6596,6 +6750,20 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
6596
6750
  waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
6597
6751
  });
6598
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
+ });
6599
6767
  const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Object({
6600
6768
  scheduleType: __sinclair_typebox.Type.Literal(`cron`),
6601
6769
  expression: __sinclair_typebox.Type.String(),
@@ -6619,6 +6787,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6619
6787
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6620
6788
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6621
6789
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6790
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6622
6791
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6623
6792
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6624
6793
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6822,11 +6991,13 @@ async function spawnEntity(request, ctx) {
6822
6991
  wake: parsed.wake,
6823
6992
  created_by: principal.url
6824
6993
  });
6825
- await linkEntityDispatchSubscription(ctx, entity);
6994
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
6995
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6826
6996
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6827
6997
  from: principal.url,
6828
6998
  payload: parsed.initialMessage
6829
6999
  });
7000
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6830
7001
  return (0, itty_router.json)({
6831
7002
  ...toPublicEntity(entity),
6832
7003
  txid: entity.txid
@@ -6850,6 +7021,22 @@ async function killEntity(request, ctx) {
6850
7021
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6851
7022
  return (0, itty_router.json)(result);
6852
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
+ }
6853
7040
 
6854
7041
  //#endregion
6855
7042
  //#region src/routing/entity-types-router.ts
@@ -7330,7 +7517,7 @@ async function notificationFromClaim(ctx, input) {
7330
7517
  const primaryStream = withLeadingSlash(primary.path);
7331
7518
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7332
7519
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7333
- if (entity.status === `stopped`) {
7520
+ if (entity.status === `stopped` || entity.status === `paused`) {
7334
7521
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7335
7522
  wake_id: input.claim.wake_id,
7336
7523
  generation: input.claim.generation
@@ -7598,7 +7785,7 @@ async function webhookForward(request, ctx) {
7598
7785
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7599
7786
  }) : void 0;
7600
7787
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7601
- if (entity?.status === `stopped`) {
7788
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7602
7789
  if (upsertPromise) await upsertPromise;
7603
7790
  return (0, itty_router.json)({ done: true });
7604
7791
  }
@@ -7741,9 +7928,9 @@ async function callbackForward(request, ctx) {
7741
7928
  entityCleared = result?.entityCleared ?? false;
7742
7929
  }
7743
7930
  if (entity && (entityCleared || stillOwnsClaim)) {
7744
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
7931
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7745
7932
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7746
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
7933
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7747
7934
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7748
7935
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7749
7936
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -7803,13 +7990,19 @@ exports.AgentsHost = AgentsHost
7803
7990
  exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID
7804
7991
  exports.StreamClient = StreamClient
7805
7992
  exports.UnregisteredTenantError = UnregisteredTenantError
7993
+ exports.assertEntitySignal = assertEntitySignal
7994
+ exports.assertEntityStatus = assertEntityStatus
7806
7995
  exports.createDb = createDb
7807
7996
  exports.createEd25519WebhookSigner = createEd25519WebhookSigner
7997
+ exports.expectedSignalStatus = expectedSignalStatus
7808
7998
  exports.getDefaultWebhookSigner = getDefaultWebhookSigner
7809
7999
  exports.globalRouter = globalRouter
8000
+ exports.isTerminalEntityStatus = isTerminalEntityStatus
7810
8001
  exports.isUnregisteredTenantError = isUnregisteredTenantError
7811
8002
  exports.pathPrefixedSingleTenantDurableStreamsRoutingAdapter = pathPrefixedSingleTenantDurableStreamsRoutingAdapter
8003
+ exports.rejectsNormalWrites = rejectsNormalWrites
7812
8004
  exports.runMigrations = runMigrations
7813
8005
  exports.streamRootDurableStreamsRoutingAdapter = streamRootDurableStreamsRoutingAdapter
7814
8006
  exports.tenantRootDurableStreamsRoutingAdapter = tenantRootDurableStreamsRoutingAdapter
8007
+ exports.toPublicEntity = toPublicEntity
7815
8008
  exports.webhookSigningMetadata = webhookSigningMetadata
package/dist/index.d.cts CHANGED
@@ -3226,7 +3226,10 @@ interface Principal {
3226
3226
  type WakeNotification = WebhookNotification;
3227
3227
  type RequestPrincipal = Principal;
3228
3228
  type AuthenticateRequest = (request: Request) => Promise<Principal | null> | Principal | null;
3229
- type EntityStatus = `spawning` | `running` | `idle` | `stopped`;
3229
+ type EntityStatus = `spawning` | `running` | `idle` | `paused` | `stopping` | `stopped` | `killed`;
3230
+ declare const ENTITY_SIGNALS: readonly ["SIGINT", "SIGHUP", "SIGTERM", "SIGKILL", "SIGSTOP", "SIGCONT", "SIGUSR"];
3231
+ type EntitySignal = (typeof ENTITY_SIGNALS)[number];
3232
+ declare function assertEntityStatus(s: string): EntityStatus;
3230
3233
  type DispatchTarget = {
3231
3234
  type: `webhook`;
3232
3235
  url: string;
@@ -3353,6 +3356,10 @@ interface ConsumerClaim {
3353
3356
  acked_streams?: Array<SourceStreamOffset>;
3354
3357
  updated_at: string;
3355
3358
  }
3359
+ declare function assertEntitySignal(s: string): EntitySignal;
3360
+ declare function isTerminalEntityStatus(status: EntityStatus): boolean;
3361
+ declare function rejectsNormalWrites(status: EntityStatus): boolean;
3362
+ declare function expectedSignalStatus(status: EntityStatus, signal: EntitySignal): EntityStatus;
3356
3363
  interface ElectricAgentsEntity {
3357
3364
  url: string;
3358
3365
  type: string;
@@ -3375,7 +3382,26 @@ interface ElectricAgentsEntity {
3375
3382
  updated_at: number;
3376
3383
  }
3377
3384
  /** Public-facing entity — internal fields stripped. Standalone type so new internal fields don't silently leak. */
3378
-
3385
+ interface PublicElectricAgentsEntity {
3386
+ url: string;
3387
+ type: string;
3388
+ status: EntityStatus;
3389
+ streams: {
3390
+ main: string;
3391
+ error: string;
3392
+ };
3393
+ dispatch_policy?: DispatchPolicy;
3394
+ tags: Record<string, string>;
3395
+ spawn_args?: Record<string, unknown>;
3396
+ parent?: string;
3397
+ created_by?: string;
3398
+ created_at: number;
3399
+ updated_at: number;
3400
+ }
3401
+ /** Entity row as stored in Postgres / returned by Electric shapes (no derived `streams` field). */
3402
+ type ElectricAgentsEntityRow = Omit<PublicElectricAgentsEntity, `streams`>;
3403
+ /** Strip internal fields (write_token, subscription_id) from an entity. */
3404
+ declare function toPublicEntity(entity: ElectricAgentsEntity): PublicElectricAgentsEntity;
3379
3405
  interface ElectricAgentsEntityType {
3380
3406
  name: string;
3381
3407
  description: string;
@@ -3425,9 +3451,27 @@ interface SendRequest {
3425
3451
  mode?: `immediate` | `queued` | `paused` | `steer`;
3426
3452
  position?: string;
3427
3453
  }
3454
+ interface SignalRequest {
3455
+ signal: EntitySignal;
3456
+ reason?: string;
3457
+ payload?: unknown;
3458
+ }
3459
+ interface SignalResponse {
3460
+ url: string;
3461
+ signal: EntitySignal;
3462
+ previous_state: EntityStatus;
3463
+ new_state: EntityStatus;
3464
+ created_at: number;
3465
+ txid: number;
3466
+ }
3428
3467
  interface SetTagRequest {
3429
3468
  value: string;
3430
3469
  }
3470
+ interface EntityListFilter {
3471
+ type?: string;
3472
+ status?: EntityStatus;
3473
+ created_by?: string;
3474
+ }
3431
3475
 
3432
3476
  //#endregion
3433
3477
  //#region src/entity-registry.d.ts
@@ -3559,7 +3603,8 @@ declare class PostgresRegistry {
3559
3603
  total: number;
3560
3604
  }>;
3561
3605
  updateStatus(entityUrl: string, status: EntityStatus): Promise<void>;
3562
- updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number>;
3606
+ updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number | null>;
3607
+ touchEntityWithTxid(entityUrl: string): Promise<number | null>;
3563
3608
  setEntityTag(url: string, key: string, value: string): Promise<{
3564
3609
  entity: ElectricAgentsEntity | null;
3565
3610
  changed: boolean;
@@ -4193,9 +4238,12 @@ declare class EntityManager {
4193
4238
  */
4194
4239
  private extractRunResponse;
4195
4240
  private buildWakeMessage;
4241
+ signal(entityUrl: string, req: SignalRequest): Promise<SignalResponse>;
4196
4242
  kill(entityUrl: string): Promise<{
4197
4243
  txid: number;
4198
4244
  }>;
4245
+ private serverHandlingForSignal;
4246
+ private appendSignalEvent;
4199
4247
  validateWriteEvent(entity: ElectricAgentsEntity, event: Record<string, unknown>): Promise<{
4200
4248
  code: string;
4201
4249
  message: string;
@@ -4451,4 +4499,4 @@ declare class UnregisteredTenantError extends Error {
4451
4499
  declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
4452
4500
 
4453
4501
  //#endregion
4454
- export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicWakeNotification, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
4502
+ export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };