@electric-ax/agents-server 0.4.6 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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];
@@ -2611,6 +2653,10 @@ function extractManifestSourceUrl(manifest) {
2611
2653
  }
2612
2654
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2613
2655
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.sourceRef) : void 0;
2656
+ if (manifest.sourceType === `webhook`) {
2657
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2658
+ if (typeof config?.endpointKey === `string`) return (0, __electric_ax_agents_runtime.getWebhookStreamPath)(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2659
+ }
2614
2660
  return void 0;
2615
2661
  }
2616
2662
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.id) : void 0;
@@ -2672,6 +2718,7 @@ function createInitialQueuePosition(date) {
2672
2718
  }
2673
2719
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2674
2720
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2721
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2675
2722
  function sleep(ms) {
2676
2723
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2677
2724
  }
@@ -2896,7 +2943,8 @@ var EntityManager = class {
2896
2943
  debounceMs: req.wake.debounceMs,
2897
2944
  timeoutMs: req.wake.timeoutMs,
2898
2945
  oneShot: false,
2899
- includeResponse: req.wake.includeResponse
2946
+ includeResponse: req.wake.includeResponse,
2947
+ manifestKey: req.wake.manifestKey
2900
2948
  });
2901
2949
  const contentType = `application/json`;
2902
2950
  const createdEvent = __electric_ax_agents_runtime.entityStateSchema.entityCreated.insert({
@@ -3123,16 +3171,16 @@ var EntityManager = class {
3123
3171
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3124
3172
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3125
3173
  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`);
3174
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3175
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3176
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3129
3177
  if (active.length === 0) {
3130
3178
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3131
3179
  const lockedRoot = await this.registry.getEntity(rootUrl);
3132
3180
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3133
3181
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3134
3182
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3135
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3183
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3136
3184
  if (lockedActive.length === 0) return lockedSubtree;
3137
3185
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3138
3186
  active = lockedActive;
@@ -3588,6 +3636,11 @@ var EntityManager = class {
3588
3636
  if (req.position) value.position = req.position;
3589
3637
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3590
3638
  if (value.status === `processed`) value.processed_at = now;
3639
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3640
+ if (wakePausedEntity) {
3641
+ await this.registry.updateStatus(entityUrl, `idle`);
3642
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3643
+ }
3591
3644
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
3592
3645
  key,
3593
3646
  value
@@ -3615,7 +3668,7 @@ var EntityManager = class {
3615
3668
  async updateInboxMessage(entityUrl, key, req) {
3616
3669
  const entity = await this.registry.getEntity(entityUrl);
3617
3670
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3618
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3671
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3619
3672
  const now = new Date().toISOString();
3620
3673
  const value = {};
3621
3674
  if (`payload` in req) value.payload = req.payload;
@@ -3636,7 +3689,7 @@ var EntityManager = class {
3636
3689
  async deleteInboxMessage(entityUrl, key) {
3637
3690
  const entity = await this.registry.getEntity(entityUrl);
3638
3691
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3639
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3692
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3640
3693
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({ key });
3641
3694
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3642
3695
  }
@@ -3644,7 +3697,7 @@ var EntityManager = class {
3644
3697
  const entity = await this.registry.getEntity(entityUrl);
3645
3698
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3646
3699
  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);
3700
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3648
3701
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3649
3702
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3650
3703
  const updated = result.entity;
@@ -3656,7 +3709,7 @@ var EntityManager = class {
3656
3709
  const entity = await this.registry.getEntity(entityUrl);
3657
3710
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3658
3711
  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);
3712
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3660
3713
  const result = await this.registry.removeEntityTag(entityUrl, key);
3661
3714
  const updated = result.entity;
3662
3715
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3785,6 +3838,35 @@ var EntityManager = class {
3785
3838
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3786
3839
  return { txid };
3787
3840
  }
3841
+ async upsertEventSourceSubscription(entityUrl, req) {
3842
+ const manifestKey = req.subscription.manifestKey;
3843
+ const txid = (0, node_crypto.randomUUID)();
3844
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3845
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3846
+ await this.wakeRegistry.register({
3847
+ tenantId: this.tenantId,
3848
+ subscriberUrl: entityUrl,
3849
+ sourceUrl: req.subscription.sourceUrl,
3850
+ condition: {
3851
+ on: `change`,
3852
+ collections: [`webhook_event`],
3853
+ ops: [`insert`]
3854
+ },
3855
+ oneShot: false,
3856
+ manifestKey
3857
+ });
3858
+ return {
3859
+ txid,
3860
+ subscription: req.subscription
3861
+ };
3862
+ }
3863
+ async deleteEventSourceSubscription(entityUrl, req) {
3864
+ const manifestKey = (0, __electric_ax_agents_runtime.eventSourceSubscriptionManifestKey)(req.id);
3865
+ const txid = (0, node_crypto.randomUUID)();
3866
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3867
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3868
+ return { txid };
3869
+ }
3788
3870
  /**
3789
3871
  * Register a wake subscription from a subscriber to a source entity.
3790
3872
  */
@@ -3909,26 +3991,131 @@ var EntityManager = class {
3909
3991
  }
3910
3992
  };
3911
3993
  }
3912
- async kill(entityUrl) {
3994
+ async signal(entityUrl, req) {
3913
3995
  const entity = await this.registry.getEntity(entityUrl);
3914
3996
  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() }
3997
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3998
+ const now = new Date();
3999
+ const previousState = entity.status;
4000
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
4001
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
4002
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
4003
+ const key = `sig-${now.getTime()}-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
4004
+ const signalValue = {
4005
+ signal: req.signal,
4006
+ status: handling.handled ? `handled` : `unhandled`,
4007
+ sender: SERVER_SIGNAL_SENDER,
4008
+ timestamp: now.toISOString()
4009
+ };
4010
+ if (req.reason !== void 0) signalValue.reason = req.reason;
4011
+ if (req.payload !== void 0) signalValue.payload = req.payload;
4012
+ if (handling.handled) {
4013
+ signalValue.handled_at = now.toISOString();
4014
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
4015
+ signalValue.outcome = handling.outcome;
4016
+ signalValue.previous_state = previousState;
4017
+ signalValue.new_state = handling.status;
4018
+ }
4019
+ const signalEvent = {
4020
+ type: `signal`,
4021
+ key,
4022
+ value: signalValue,
4023
+ headers: {
4024
+ operation: `insert`,
4025
+ timestamp: now.toISOString(),
4026
+ txid: String(txid)
4027
+ }
4028
+ };
4029
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
4030
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
4031
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
4032
+ if (handling.unregisterWakes) {
4033
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
4034
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
4035
+ }
4036
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4037
+ return {
4038
+ url: entityUrl,
4039
+ signal: req.signal,
4040
+ previous_state: previousState,
4041
+ new_state: handling.status,
4042
+ created_at: now.getTime(),
4043
+ txid
4044
+ };
4045
+ }
4046
+ async kill(entityUrl) {
4047
+ const response = await this.signal(entityUrl, {
4048
+ signal: `SIGKILL`,
4049
+ reason: `Legacy kill command`
3922
4050
  });
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 });
4051
+ return { txid: response.txid };
4052
+ }
4053
+ serverHandlingForSignal(status$4, signal) {
4054
+ if (signal === `SIGKILL`) return {
4055
+ status: `killed`,
4056
+ handled: true,
4057
+ outcome: `transitioned`,
4058
+ unregisterWakes: true
4059
+ };
4060
+ if (signal === `SIGTERM`) {
4061
+ if (status$4 === `idle` || status$4 === `paused`) return {
4062
+ status: `stopped`,
4063
+ handled: true,
4064
+ outcome: `transitioned`,
4065
+ unregisterWakes: true
4066
+ };
4067
+ if (status$4 === `running`) return {
4068
+ status: `stopping`,
4069
+ handled: false,
4070
+ outcome: `transitioned`,
4071
+ unregisterWakes: false
4072
+ };
4073
+ }
4074
+ if (status$4 === `paused` && signal !== `SIGCONT`) return {
4075
+ status: status$4,
4076
+ handled: true,
4077
+ outcome: `ignored`,
4078
+ unregisterWakes: false
4079
+ };
4080
+ if (signal === `SIGSTOP` && (status$4 === `idle` || status$4 === `running`)) return {
4081
+ status: `paused`,
4082
+ handled: status$4 === `idle`,
4083
+ outcome: `transitioned`,
4084
+ unregisterWakes: false
4085
+ };
4086
+ if (signal === `SIGCONT` && status$4 === `paused`) return {
4087
+ status: `idle`,
4088
+ handled: false,
4089
+ outcome: `transitioned`,
4090
+ unregisterWakes: false
4091
+ };
4092
+ return {
4093
+ status: status$4,
4094
+ handled: false,
4095
+ outcome: `ignored`,
4096
+ unregisterWakes: false
4097
+ };
4098
+ }
4099
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4100
+ const signalData = this.encodeChangeEvent(signalEvent);
4101
+ if (!closeStreams) {
4102
+ await this.streamClient.append(entity.streams.main, signalData);
4103
+ return;
4104
+ }
4105
+ const errorCloseEvent = {
4106
+ type: `signal`,
4107
+ key: signalEvent.key,
4108
+ value: signalEvent.value,
4109
+ headers: signalEvent.headers
4110
+ };
4111
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4112
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4113
+ await this.streamClient.append(streamPath, data, { close: true });
3926
4114
  } catch (err) {
3927
4115
  const message = err instanceof Error ? err.message : String(err);
3928
4116
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3929
4117
  throw err;
3930
4118
  }
3931
- return { txid };
3932
4119
  }
3933
4120
  async validateWriteEvent(entity, event) {
3934
4121
  if (!entity.type) return null;
@@ -4044,7 +4231,7 @@ var EntityManager = class {
4044
4231
  async validateSendRequest(entityUrl, req) {
4045
4232
  const entity = await this.registry.getEntity(entityUrl);
4046
4233
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4047
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4234
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4048
4235
  if (req.type && entity.type) {
4049
4236
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4050
4237
  if (inboxSchemas) {
@@ -5009,7 +5196,8 @@ var ElectricAgentsTenantRuntime = class {
5009
5196
  const primaryStream = `${entityUrl}/main`;
5010
5197
  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
5198
  if (callbacks.length > 0) return;
5012
- await this.manager.registry.updateStatus(entityUrl, `idle`);
5199
+ const entity = await this.manager.registry.getEntity(entityUrl);
5200
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
5013
5201
  await this.entityBridgeManager.onEntityChanged(entityUrl);
5014
5202
  }
5015
5203
  };
@@ -6559,7 +6747,8 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
6559
6747
  condition: wakeConditionSchema,
6560
6748
  debounceMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
6561
6749
  timeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
6562
- includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
6750
+ includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
6751
+ manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6563
6752
  }))
6564
6753
  });
6565
6754
  const sendBodySchema = __sinclair_typebox.Type.Object({
@@ -6596,6 +6785,20 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
6596
6785
  waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
6597
6786
  });
6598
6787
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
6788
+ const entitySignalSchema = __sinclair_typebox.Type.Union([
6789
+ __sinclair_typebox.Type.Literal(`SIGINT`),
6790
+ __sinclair_typebox.Type.Literal(`SIGHUP`),
6791
+ __sinclair_typebox.Type.Literal(`SIGTERM`),
6792
+ __sinclair_typebox.Type.Literal(`SIGKILL`),
6793
+ __sinclair_typebox.Type.Literal(`SIGSTOP`),
6794
+ __sinclair_typebox.Type.Literal(`SIGCONT`),
6795
+ __sinclair_typebox.Type.Literal(`SIGUSR`)
6796
+ ]);
6797
+ const signalBodySchema = __sinclair_typebox.Type.Object({
6798
+ signal: entitySignalSchema,
6799
+ reason: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6800
+ payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown())
6801
+ });
6599
6802
  const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Object({
6600
6803
  scheduleType: __sinclair_typebox.Type.Literal(`cron`),
6601
6804
  expression: __sinclair_typebox.Type.String(),
@@ -6611,6 +6814,22 @@ const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Typ
6611
6814
  messageType: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6612
6815
  from: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6613
6816
  })]);
6817
+ const subscriptionLifetimeSchema = __sinclair_typebox.Type.Union([
6818
+ __sinclair_typebox.Type.Object({ kind: __sinclair_typebox.Type.Literal(`until_entity_stopped`) }),
6819
+ __sinclair_typebox.Type.Object({
6820
+ kind: __sinclair_typebox.Type.Literal(`expires_at`),
6821
+ at: __sinclair_typebox.Type.String()
6822
+ }),
6823
+ __sinclair_typebox.Type.Object({ kind: __sinclair_typebox.Type.Literal(`manual`) })
6824
+ ]);
6825
+ const eventSourceSubscriptionBodySchema = __sinclair_typebox.Type.Object({
6826
+ sourceKey: __sinclair_typebox.Type.String(),
6827
+ bucketKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6828
+ params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
6829
+ filterKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6830
+ lifetime: __sinclair_typebox.Type.Optional(subscriptionLifetimeSchema),
6831
+ reason: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6832
+ });
6614
6833
  const entitiesRegisterBodySchema = __sinclair_typebox.Type.Object({ tags: __sinclair_typebox.Type.Optional(stringRecordSchema) });
6615
6834
  const entitiesRouter = (0, itty_router.Router)({ base: `/_electric/entities` });
6616
6835
  entitiesRouter.get(`/`, listEntities);
@@ -6619,6 +6838,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6619
6838
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6620
6839
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6621
6840
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6841
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6622
6842
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6623
6843
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6624
6844
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6627,6 +6847,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6627
6847
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
6628
6848
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6629
6849
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6850
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6851
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
6630
6852
  function entityUrlFromSegments(type, instanceId) {
6631
6853
  if (!type || !instanceId) return null;
6632
6854
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -6733,6 +6955,47 @@ async function deleteSchedule(request, ctx) {
6733
6955
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6734
6956
  return (0, itty_router.json)(result);
6735
6957
  }
6958
+ async function upsertEventSourceSubscription(request, ctx) {
6959
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6960
+ if (principalMutationError) return principalMutationError;
6961
+ const catalog = ctx.eventSources;
6962
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6963
+ const { entityUrl } = requireExistingEntityRoute(request);
6964
+ const parsed = routeBody(request);
6965
+ const source = await catalog.getEventSource(parsed.sourceKey);
6966
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6967
+ if (parsed.lifetime?.kind === `expires_at`) {
6968
+ const expiresAt = new Date(parsed.lifetime.at);
6969
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6970
+ }
6971
+ let resolved;
6972
+ try {
6973
+ resolved = (0, __electric_ax_agents_runtime.resolveEventSourceSubscription)({
6974
+ contract: source,
6975
+ entityUrl,
6976
+ request: {
6977
+ ...parsed,
6978
+ id: decodeURIComponent(request.params.subscriptionId)
6979
+ },
6980
+ createdBy: `tool`
6981
+ });
6982
+ } catch (error) {
6983
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6984
+ }
6985
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6986
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6987
+ subscription: resolved.subscription,
6988
+ manifest: (0, __electric_ax_agents_runtime.buildEventSourceManifestEntry)(resolved)
6989
+ });
6990
+ return (0, itty_router.json)(result);
6991
+ }
6992
+ async function deleteEventSourceSubscription(request, ctx) {
6993
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6994
+ if (principalMutationError) return principalMutationError;
6995
+ const { entityUrl } = requireExistingEntityRoute(request);
6996
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6997
+ return (0, itty_router.json)(result);
6998
+ }
6736
6999
  async function setTag(request, ctx) {
6737
7000
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
6738
7001
  if (principalMutationError) return principalMutationError;
@@ -6822,11 +7085,13 @@ async function spawnEntity(request, ctx) {
6822
7085
  wake: parsed.wake,
6823
7086
  created_by: principal.url
6824
7087
  });
6825
- await linkEntityDispatchSubscription(ctx, entity);
7088
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
7089
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6826
7090
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6827
7091
  from: principal.url,
6828
7092
  payload: parsed.initialMessage
6829
7093
  });
7094
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6830
7095
  return (0, itty_router.json)({
6831
7096
  ...toPublicEntity(entity),
6832
7097
  txid: entity.txid
@@ -6850,6 +7115,22 @@ async function killEntity(request, ctx) {
6850
7115
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6851
7116
  return (0, itty_router.json)(result);
6852
7117
  }
7118
+ async function signalEntity(request, ctx) {
7119
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
7120
+ if (principalMutationError) return principalMutationError;
7121
+ const parsed = routeBody(request);
7122
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
7123
+ const result = await ctx.entityManager.signal(entityUrl, {
7124
+ signal: parsed.signal,
7125
+ reason: parsed.reason,
7126
+ payload: parsed.payload
7127
+ });
7128
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
7129
+ await unlinkEntityDispatchSubscription(ctx, entity);
7130
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
7131
+ }
7132
+ return (0, itty_router.json)(result);
7133
+ }
6853
7134
 
6854
7135
  //#endregion
6855
7136
  //#region src/routing/entity-types-router.ts
@@ -7330,7 +7611,7 @@ async function notificationFromClaim(ctx, input) {
7330
7611
  const primaryStream = withLeadingSlash(primary.path);
7331
7612
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7332
7613
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7333
- if (entity.status === `stopped`) {
7614
+ if (entity.status === `stopped` || entity.status === `paused`) {
7334
7615
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7335
7616
  wake_id: input.claim.wake_id,
7336
7617
  generation: input.claim.generation
@@ -7429,6 +7710,7 @@ const callbackForwardBodySchema = __sinclair_typebox.Type.Object({
7429
7710
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7430
7711
  const internalRouter = (0, itty_router.Router)({ base: `/_electric` });
7431
7712
  internalRouter.get(`/health`, () => (0, itty_router.json)({ status: `ok` }));
7713
+ internalRouter.get(`/event-sources`, listEventSources);
7432
7714
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7433
7715
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
7434
7716
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -7532,6 +7814,13 @@ async function registerWake(request, ctx) {
7532
7814
  await ctx.entityManager.registerWake(opts);
7533
7815
  return (0, itty_router.status)(204);
7534
7816
  }
7817
+ async function listEventSources(_request, ctx) {
7818
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7819
+ return (0, itty_router.json)({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7820
+ }
7821
+ function isAgentVisibleEventSource(source) {
7822
+ return source.agentVisible === true && source.status === `active`;
7823
+ }
7535
7824
  async function webhookForward(request, ctx) {
7536
7825
  const subscriptionId = routeParam(request, `subscriptionId`);
7537
7826
  const rootSpan = getRequestSpan(request);
@@ -7598,7 +7887,7 @@ async function webhookForward(request, ctx) {
7598
7887
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7599
7888
  }) : void 0;
7600
7889
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7601
- if (entity?.status === `stopped`) {
7890
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7602
7891
  if (upsertPromise) await upsertPromise;
7603
7892
  return (0, itty_router.json)({ done: true });
7604
7893
  }
@@ -7741,9 +8030,9 @@ async function callbackForward(request, ctx) {
7741
8030
  entityCleared = result?.entityCleared ?? false;
7742
8031
  }
7743
8032
  if (entity && (entityCleared || stillOwnsClaim)) {
7744
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
8033
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7745
8034
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7746
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
8035
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7747
8036
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7748
8037
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7749
8038
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -7803,13 +8092,19 @@ exports.AgentsHost = AgentsHost
7803
8092
  exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID
7804
8093
  exports.StreamClient = StreamClient
7805
8094
  exports.UnregisteredTenantError = UnregisteredTenantError
8095
+ exports.assertEntitySignal = assertEntitySignal
8096
+ exports.assertEntityStatus = assertEntityStatus
7806
8097
  exports.createDb = createDb
7807
8098
  exports.createEd25519WebhookSigner = createEd25519WebhookSigner
8099
+ exports.expectedSignalStatus = expectedSignalStatus
7808
8100
  exports.getDefaultWebhookSigner = getDefaultWebhookSigner
7809
8101
  exports.globalRouter = globalRouter
8102
+ exports.isTerminalEntityStatus = isTerminalEntityStatus
7810
8103
  exports.isUnregisteredTenantError = isUnregisteredTenantError
7811
8104
  exports.pathPrefixedSingleTenantDurableStreamsRoutingAdapter = pathPrefixedSingleTenantDurableStreamsRoutingAdapter
8105
+ exports.rejectsNormalWrites = rejectsNormalWrites
7812
8106
  exports.runMigrations = runMigrations
7813
8107
  exports.streamRootDurableStreamsRoutingAdapter = streamRootDurableStreamsRoutingAdapter
7814
8108
  exports.tenantRootDurableStreamsRoutingAdapter = tenantRootDurableStreamsRoutingAdapter
8109
+ exports.toPublicEntity = toPublicEntity
7815
8110
  exports.webhookSigningMetadata = webhookSigningMetadata