@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.
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
4
4
  import { createServer } from "node:http";
5
5
  import { createServerAdapter } from "@whatwg-node/server";
6
6
  import { Agent } from "undici";
7
- import { appendPathToUrl, assertTags, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
7
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
8
8
  import fs, { existsSync } from "node:fs";
9
9
  import path, { dirname, resolve } from "node:path";
10
10
  import { drizzle } from "drizzle-orm/postgres-js";
@@ -90,7 +90,7 @@ const entities = pgTable(`entities`, {
90
90
  index(`idx_entities_parent`).on(table.tenantId, table.parent),
91
91
  index(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
92
92
  index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
93
- check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
93
+ check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
94
94
  ]);
95
95
  const users = pgTable(`users`, {
96
96
  tenantId: text(`tenant_id`).notNull().default(`default`),
@@ -367,12 +367,25 @@ function responseHeaders(response) {
367
367
 
368
368
  //#endregion
369
369
  //#region src/electric-agents-types.ts
370
+ const ENTITY_SIGNALS = [
371
+ `SIGINT`,
372
+ `SIGHUP`,
373
+ `SIGTERM`,
374
+ `SIGKILL`,
375
+ `SIGSTOP`,
376
+ `SIGCONT`,
377
+ `SIGUSR`
378
+ ];
370
379
  const VALID_ENTITY_STATUSES = new Set([
371
380
  `spawning`,
372
381
  `running`,
373
382
  `idle`,
374
- `stopped`
383
+ `paused`,
384
+ `stopping`,
385
+ `stopped`,
386
+ `killed`
375
387
  ]);
388
+ const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
376
389
  function assertEntityStatus(s) {
377
390
  if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
378
391
  return s;
@@ -393,6 +406,12 @@ function assertRunnerAdminStatus(s) {
393
406
  if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
394
407
  return s;
395
408
  }
409
+ function isTerminalEntityStatus(status$1) {
410
+ return status$1 === `stopped` || status$1 === `killed`;
411
+ }
412
+ function rejectsNormalWrites(status$1) {
413
+ return status$1 === `stopping` || isTerminalEntityStatus(status$1);
414
+ }
396
415
  /** Strip internal fields (write_token, subscription_id) from an entity. */
397
416
  function toPublicEntity(entity) {
398
417
  return {
@@ -414,6 +433,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
414
433
  const ErrCodeNotFound = `NOT_FOUND`;
415
434
  const ErrCodeNotRunning = `NOT_RUNNING`;
416
435
  const ErrCodeInvalidRequest = `INVALID_REQUEST`;
436
+ const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
417
437
  const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
418
438
  const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
419
439
  const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
@@ -2274,7 +2294,7 @@ var PostgresRegistry = class {
2274
2294
  };
2275
2295
  }
2276
2296
  async updateStatus(entityUrl, status$1) {
2277
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
2297
+ const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
2278
2298
  await this.db.update(entities).set({
2279
2299
  status: status$1,
2280
2300
  updatedAt: Date.now()
@@ -2282,13 +2302,17 @@ var PostgresRegistry = class {
2282
2302
  }
2283
2303
  async updateStatusWithTxid(entityUrl, status$1) {
2284
2304
  return await this.db.transaction(async (tx) => {
2285
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
2286
- await tx.update(entities).set({
2305
+ const rows = await tx.update(entities).set({
2287
2306
  status: status$1,
2288
2307
  updatedAt: Date.now()
2289
- }).where(whereClause);
2290
- const result = await tx.execute(sql`SELECT pg_current_xact_id()::xid::text AS txid`);
2291
- return parseInt(result[0].txid);
2308
+ }).where(and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
2309
+ return rows[0] ? parseInt(rows[0].txid) : null;
2310
+ });
2311
+ }
2312
+ async touchEntityWithTxid(entityUrl) {
2313
+ return await this.db.transaction(async (tx) => {
2314
+ 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` });
2315
+ return rows[0] ? parseInt(rows[0].txid) : null;
2292
2316
  });
2293
2317
  }
2294
2318
  async setEntityTag(url, key, value) {
@@ -2644,6 +2668,10 @@ function extractManifestSourceUrl(manifest) {
2644
2668
  }
2645
2669
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2646
2670
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
2671
+ if (manifest.sourceType === `webhook`) {
2672
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2673
+ if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2674
+ }
2647
2675
  return void 0;
2648
2676
  }
2649
2677
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
@@ -2705,6 +2733,7 @@ function createInitialQueuePosition(date) {
2705
2733
  }
2706
2734
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2707
2735
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2736
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2708
2737
  function sleep(ms) {
2709
2738
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2710
2739
  }
@@ -2929,7 +2958,8 @@ var EntityManager = class {
2929
2958
  debounceMs: req.wake.debounceMs,
2930
2959
  timeoutMs: req.wake.timeoutMs,
2931
2960
  oneShot: false,
2932
- includeResponse: req.wake.includeResponse
2961
+ includeResponse: req.wake.includeResponse,
2962
+ manifestKey: req.wake.manifestKey
2933
2963
  });
2934
2964
  const contentType = `application/json`;
2935
2965
  const createdEvent = entityStateSchema.entityCreated.insert({
@@ -3156,16 +3186,16 @@ var EntityManager = class {
3156
3186
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3157
3187
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3158
3188
  const subtree = await this.listEntitySubtree(root);
3159
- const stopped = subtree.find((entity) => entity.status === `stopped`);
3160
- if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork stopped entity "${stopped.url}"`, 409);
3161
- let active = subtree.filter((entity) => entity.status !== `idle`);
3189
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3190
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3191
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3162
3192
  if (active.length === 0) {
3163
3193
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3164
3194
  const lockedRoot = await this.registry.getEntity(rootUrl);
3165
3195
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3166
3196
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3167
3197
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3168
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3198
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3169
3199
  if (lockedActive.length === 0) return lockedSubtree;
3170
3200
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3171
3201
  active = lockedActive;
@@ -3621,6 +3651,11 @@ var EntityManager = class {
3621
3651
  if (req.position) value.position = req.position;
3622
3652
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3623
3653
  if (value.status === `processed`) value.processed_at = now;
3654
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3655
+ if (wakePausedEntity) {
3656
+ await this.registry.updateStatus(entityUrl, `idle`);
3657
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3658
+ }
3624
3659
  const envelope = entityStateSchema.inbox.insert({
3625
3660
  key,
3626
3661
  value
@@ -3648,7 +3683,7 @@ var EntityManager = class {
3648
3683
  async updateInboxMessage(entityUrl, key, req) {
3649
3684
  const entity = await this.registry.getEntity(entityUrl);
3650
3685
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3651
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3686
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3652
3687
  const now = new Date().toISOString();
3653
3688
  const value = {};
3654
3689
  if (`payload` in req) value.payload = req.payload;
@@ -3669,7 +3704,7 @@ var EntityManager = class {
3669
3704
  async deleteInboxMessage(entityUrl, key) {
3670
3705
  const entity = await this.registry.getEntity(entityUrl);
3671
3706
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3672
- 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);
3673
3708
  const envelope = entityStateSchema.inbox.delete({ key });
3674
3709
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3675
3710
  }
@@ -3677,7 +3712,7 @@ var EntityManager = class {
3677
3712
  const entity = await this.registry.getEntity(entityUrl);
3678
3713
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3679
3714
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3680
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3715
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3681
3716
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3682
3717
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3683
3718
  const updated = result.entity;
@@ -3689,7 +3724,7 @@ var EntityManager = class {
3689
3724
  const entity = await this.registry.getEntity(entityUrl);
3690
3725
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3691
3726
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3692
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3727
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3693
3728
  const result = await this.registry.removeEntityTag(entityUrl, key);
3694
3729
  const updated = result.entity;
3695
3730
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3818,6 +3853,35 @@ var EntityManager = class {
3818
3853
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3819
3854
  return { txid };
3820
3855
  }
3856
+ async upsertEventSourceSubscription(entityUrl, req) {
3857
+ const manifestKey = req.subscription.manifestKey;
3858
+ const txid = randomUUID();
3859
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3860
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3861
+ await this.wakeRegistry.register({
3862
+ tenantId: this.tenantId,
3863
+ subscriberUrl: entityUrl,
3864
+ sourceUrl: req.subscription.sourceUrl,
3865
+ condition: {
3866
+ on: `change`,
3867
+ collections: [`webhook_event`],
3868
+ ops: [`insert`]
3869
+ },
3870
+ oneShot: false,
3871
+ manifestKey
3872
+ });
3873
+ return {
3874
+ txid,
3875
+ subscription: req.subscription
3876
+ };
3877
+ }
3878
+ async deleteEventSourceSubscription(entityUrl, req) {
3879
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id);
3880
+ const txid = randomUUID();
3881
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3882
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3883
+ return { txid };
3884
+ }
3821
3885
  /**
3822
3886
  * Register a wake subscription from a subscriber to a source entity.
3823
3887
  */
@@ -3942,26 +4006,131 @@ var EntityManager = class {
3942
4006
  }
3943
4007
  };
3944
4008
  }
3945
- async kill(entityUrl) {
4009
+ async signal(entityUrl, req) {
3946
4010
  const entity = await this.registry.getEntity(entityUrl);
3947
4011
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3948
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3949
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3950
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`);
3951
- if (this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3952
- const stoppedEvent = entityStateSchema.entityStopped.insert({
3953
- key: `stopped`,
3954
- value: { timestamp: new Date().toISOString() }
4012
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
4013
+ const now = new Date();
4014
+ const previousState = entity.status;
4015
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
4016
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
4017
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
4018
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
4019
+ const signalValue = {
4020
+ signal: req.signal,
4021
+ status: handling.handled ? `handled` : `unhandled`,
4022
+ sender: SERVER_SIGNAL_SENDER,
4023
+ timestamp: now.toISOString()
4024
+ };
4025
+ if (req.reason !== void 0) signalValue.reason = req.reason;
4026
+ if (req.payload !== void 0) signalValue.payload = req.payload;
4027
+ if (handling.handled) {
4028
+ signalValue.handled_at = now.toISOString();
4029
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
4030
+ signalValue.outcome = handling.outcome;
4031
+ signalValue.previous_state = previousState;
4032
+ signalValue.new_state = handling.status;
4033
+ }
4034
+ const signalEvent = {
4035
+ type: `signal`,
4036
+ key,
4037
+ value: signalValue,
4038
+ headers: {
4039
+ operation: `insert`,
4040
+ timestamp: now.toISOString(),
4041
+ txid: String(txid)
4042
+ }
4043
+ };
4044
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
4045
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
4046
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
4047
+ if (handling.unregisterWakes) {
4048
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
4049
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
4050
+ }
4051
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4052
+ return {
4053
+ url: entityUrl,
4054
+ signal: req.signal,
4055
+ previous_state: previousState,
4056
+ new_state: handling.status,
4057
+ created_at: now.getTime(),
4058
+ txid
4059
+ };
4060
+ }
4061
+ async kill(entityUrl) {
4062
+ const response = await this.signal(entityUrl, {
4063
+ signal: `SIGKILL`,
4064
+ reason: `Legacy kill command`
3955
4065
  });
3956
- const eofData = this.encodeChangeEvent(stoppedEvent);
3957
- for (const streamPath of [entity.streams.main, entity.streams.error]) try {
3958
- await this.streamClient.append(streamPath, eofData, { close: true });
4066
+ return { txid: response.txid };
4067
+ }
4068
+ serverHandlingForSignal(status$1, signal) {
4069
+ if (signal === `SIGKILL`) return {
4070
+ status: `killed`,
4071
+ handled: true,
4072
+ outcome: `transitioned`,
4073
+ unregisterWakes: true
4074
+ };
4075
+ if (signal === `SIGTERM`) {
4076
+ if (status$1 === `idle` || status$1 === `paused`) return {
4077
+ status: `stopped`,
4078
+ handled: true,
4079
+ outcome: `transitioned`,
4080
+ unregisterWakes: true
4081
+ };
4082
+ if (status$1 === `running`) return {
4083
+ status: `stopping`,
4084
+ handled: false,
4085
+ outcome: `transitioned`,
4086
+ unregisterWakes: false
4087
+ };
4088
+ }
4089
+ if (status$1 === `paused` && signal !== `SIGCONT`) return {
4090
+ status: status$1,
4091
+ handled: true,
4092
+ outcome: `ignored`,
4093
+ unregisterWakes: false
4094
+ };
4095
+ if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
4096
+ status: `paused`,
4097
+ handled: status$1 === `idle`,
4098
+ outcome: `transitioned`,
4099
+ unregisterWakes: false
4100
+ };
4101
+ if (signal === `SIGCONT` && status$1 === `paused`) return {
4102
+ status: `idle`,
4103
+ handled: false,
4104
+ outcome: `transitioned`,
4105
+ unregisterWakes: false
4106
+ };
4107
+ return {
4108
+ status: status$1,
4109
+ handled: false,
4110
+ outcome: `ignored`,
4111
+ unregisterWakes: false
4112
+ };
4113
+ }
4114
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4115
+ const signalData = this.encodeChangeEvent(signalEvent);
4116
+ if (!closeStreams) {
4117
+ await this.streamClient.append(entity.streams.main, signalData);
4118
+ return;
4119
+ }
4120
+ const errorCloseEvent = {
4121
+ type: `signal`,
4122
+ key: signalEvent.key,
4123
+ value: signalEvent.value,
4124
+ headers: signalEvent.headers
4125
+ };
4126
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4127
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4128
+ await this.streamClient.append(streamPath, data, { close: true });
3959
4129
  } catch (err) {
3960
4130
  const message = err instanceof Error ? err.message : String(err);
3961
4131
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3962
4132
  throw err;
3963
4133
  }
3964
- return { txid };
3965
4134
  }
3966
4135
  async validateWriteEvent(entity, event) {
3967
4136
  if (!entity.type) return null;
@@ -4077,7 +4246,7 @@ var EntityManager = class {
4077
4246
  async validateSendRequest(entityUrl, req) {
4078
4247
  const entity = await this.registry.getEntity(entityUrl);
4079
4248
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4080
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4249
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4081
4250
  if (req.type && entity.type) {
4082
4251
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4083
4252
  if (inboxSchemas) {
@@ -4250,6 +4419,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
4250
4419
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
4251
4420
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
4252
4421
  }
4422
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
4423
+ return policy?.targets[0] !== void 0;
4424
+ }
4253
4425
  async function linkEntityDispatchSubscription(ctx, entity) {
4254
4426
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
4255
4427
  const target = dispatchPolicy?.targets[0];
@@ -4338,7 +4510,8 @@ const spawnBodySchema = Type.Object({
4338
4510
  condition: wakeConditionSchema,
4339
4511
  debounceMs: Type.Optional(Type.Number()),
4340
4512
  timeoutMs: Type.Optional(Type.Number()),
4341
- includeResponse: Type.Optional(Type.Boolean())
4513
+ includeResponse: Type.Optional(Type.Boolean()),
4514
+ manifestKey: Type.Optional(Type.String())
4342
4515
  }))
4343
4516
  });
4344
4517
  const sendBodySchema = Type.Object({
@@ -4375,6 +4548,20 @@ const forkBodySchema = Type.Object({
4375
4548
  waitTimeoutMs: Type.Optional(Type.Number())
4376
4549
  });
4377
4550
  const setTagBodySchema = Type.Object({ value: Type.String() });
4551
+ const entitySignalSchema = Type.Union([
4552
+ Type.Literal(`SIGINT`),
4553
+ Type.Literal(`SIGHUP`),
4554
+ Type.Literal(`SIGTERM`),
4555
+ Type.Literal(`SIGKILL`),
4556
+ Type.Literal(`SIGSTOP`),
4557
+ Type.Literal(`SIGCONT`),
4558
+ Type.Literal(`SIGUSR`)
4559
+ ]);
4560
+ const signalBodySchema = Type.Object({
4561
+ signal: entitySignalSchema,
4562
+ reason: Type.Optional(Type.String()),
4563
+ payload: Type.Optional(Type.Unknown())
4564
+ });
4378
4565
  const scheduleBodySchema = Type.Union([Type.Object({
4379
4566
  scheduleType: Type.Literal(`cron`),
4380
4567
  expression: Type.String(),
@@ -4390,6 +4577,22 @@ const scheduleBodySchema = Type.Union([Type.Object({
4390
4577
  messageType: Type.Optional(Type.String()),
4391
4578
  from: Type.Optional(Type.String())
4392
4579
  })]);
4580
+ const subscriptionLifetimeSchema = Type.Union([
4581
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
4582
+ Type.Object({
4583
+ kind: Type.Literal(`expires_at`),
4584
+ at: Type.String()
4585
+ }),
4586
+ Type.Object({ kind: Type.Literal(`manual`) })
4587
+ ]);
4588
+ const eventSourceSubscriptionBodySchema = Type.Object({
4589
+ sourceKey: Type.String(),
4590
+ bucketKey: Type.Optional(Type.String()),
4591
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
4592
+ filterKey: Type.Optional(Type.String()),
4593
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
4594
+ reason: Type.Optional(Type.String())
4595
+ });
4393
4596
  const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
4394
4597
  const entitiesRouter = Router({ base: `/_electric/entities` });
4395
4598
  entitiesRouter.get(`/`, listEntities);
@@ -4398,6 +4601,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
4398
4601
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
4399
4602
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
4400
4603
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
4604
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
4401
4605
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
4402
4606
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
4403
4607
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -4406,6 +4610,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
4406
4610
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
4407
4611
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
4408
4612
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
4613
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
4614
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
4409
4615
  function entityUrlFromSegments(type, instanceId) {
4410
4616
  if (!type || !instanceId) return null;
4411
4617
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -4512,6 +4718,47 @@ async function deleteSchedule(request, ctx) {
4512
4718
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
4513
4719
  return json(result);
4514
4720
  }
4721
+ async function upsertEventSourceSubscription(request, ctx) {
4722
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
4723
+ if (principalMutationError) return principalMutationError;
4724
+ const catalog = ctx.eventSources;
4725
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
4726
+ const { entityUrl } = requireExistingEntityRoute(request);
4727
+ const parsed = routeBody(request);
4728
+ const source = await catalog.getEventSource(parsed.sourceKey);
4729
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
4730
+ if (parsed.lifetime?.kind === `expires_at`) {
4731
+ const expiresAt = new Date(parsed.lifetime.at);
4732
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
4733
+ }
4734
+ let resolved;
4735
+ try {
4736
+ resolved = resolveEventSourceSubscription({
4737
+ contract: source,
4738
+ entityUrl,
4739
+ request: {
4740
+ ...parsed,
4741
+ id: decodeURIComponent(request.params.subscriptionId)
4742
+ },
4743
+ createdBy: `tool`
4744
+ });
4745
+ } catch (error) {
4746
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
4747
+ }
4748
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
4749
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
4750
+ subscription: resolved.subscription,
4751
+ manifest: buildEventSourceManifestEntry(resolved)
4752
+ });
4753
+ return json(result);
4754
+ }
4755
+ async function deleteEventSourceSubscription(request, ctx) {
4756
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
4757
+ if (principalMutationError) return principalMutationError;
4758
+ const { entityUrl } = requireExistingEntityRoute(request);
4759
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
4760
+ return json(result);
4761
+ }
4515
4762
  async function setTag(request, ctx) {
4516
4763
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
4517
4764
  if (principalMutationError) return principalMutationError;
@@ -4601,11 +4848,13 @@ async function spawnEntity(request, ctx) {
4601
4848
  wake: parsed.wake,
4602
4849
  created_by: principal.url
4603
4850
  });
4604
- await linkEntityDispatchSubscription(ctx, entity);
4851
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
4852
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
4605
4853
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
4606
4854
  from: principal.url,
4607
4855
  payload: parsed.initialMessage
4608
4856
  });
4857
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
4609
4858
  return json({
4610
4859
  ...toPublicEntity(entity),
4611
4860
  txid: entity.txid
@@ -4629,6 +4878,22 @@ async function killEntity(request, ctx) {
4629
4878
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
4630
4879
  return json(result);
4631
4880
  }
4881
+ async function signalEntity(request, ctx) {
4882
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
4883
+ if (principalMutationError) return principalMutationError;
4884
+ const parsed = routeBody(request);
4885
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
4886
+ const result = await ctx.entityManager.signal(entityUrl, {
4887
+ signal: parsed.signal,
4888
+ reason: parsed.reason,
4889
+ payload: parsed.payload
4890
+ });
4891
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
4892
+ await unlinkEntityDispatchSubscription(ctx, entity);
4893
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
4894
+ }
4895
+ return json(result);
4896
+ }
4632
4897
 
4633
4898
  //#endregion
4634
4899
  //#region src/routing/entity-types-router.ts
@@ -5109,7 +5374,7 @@ async function notificationFromClaim(ctx, input) {
5109
5374
  const primaryStream = withLeadingSlash(primary.path);
5110
5375
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
5111
5376
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
5112
- if (entity.status === `stopped`) {
5377
+ if (entity.status === `stopped` || entity.status === `paused`) {
5113
5378
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
5114
5379
  wake_id: input.claim.wake_id,
5115
5380
  generation: input.claim.generation
@@ -5208,6 +5473,7 @@ const callbackForwardBodySchema = Type.Object({
5208
5473
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
5209
5474
  const internalRouter = Router({ base: `/_electric` });
5210
5475
  internalRouter.get(`/health`, () => json({ status: `ok` }));
5476
+ internalRouter.get(`/event-sources`, listEventSources);
5211
5477
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
5212
5478
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
5213
5479
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -5311,6 +5577,13 @@ async function registerWake(request, ctx) {
5311
5577
  await ctx.entityManager.registerWake(opts);
5312
5578
  return status(204);
5313
5579
  }
5580
+ async function listEventSources(_request, ctx) {
5581
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
5582
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
5583
+ }
5584
+ function isAgentVisibleEventSource(source) {
5585
+ return source.agentVisible === true && source.status === `active`;
5586
+ }
5314
5587
  async function webhookForward(request, ctx) {
5315
5588
  const subscriptionId = routeParam(request, `subscriptionId`);
5316
5589
  const rootSpan = getRequestSpan(request);
@@ -5377,7 +5650,7 @@ async function webhookForward(request, ctx) {
5377
5650
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
5378
5651
  }) : void 0;
5379
5652
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
5380
- if (entity?.status === `stopped`) {
5653
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
5381
5654
  if (upsertPromise) await upsertPromise;
5382
5655
  return json({ done: true });
5383
5656
  }
@@ -5520,9 +5793,9 @@ async function callbackForward(request, ctx) {
5520
5793
  entityCleared = result?.entityCleared ?? false;
5521
5794
  }
5522
5795
  if (entity && (entityCleared || stillOwnsClaim)) {
5523
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
5796
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
5524
5797
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
5525
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
5798
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
5526
5799
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
5527
5800
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
5528
5801
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -6875,7 +7148,8 @@ var ElectricAgentsTenantRuntime = class {
6875
7148
  const primaryStream = `${entityUrl}/main`;
6876
7149
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
6877
7150
  if (callbacks.length > 0) return;
6878
- await this.manager.registry.updateStatus(entityUrl, `idle`);
7151
+ const entity = await this.manager.registry.getEntity(entityUrl);
7152
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
6879
7153
  await this.entityBridgeManager.onEntityChanged(entityUrl);
6880
7154
  }
6881
7155
  };
@@ -7890,6 +8164,8 @@ var ElectricAgentsServer = class {
7890
8164
  streamClient: this.streamClient,
7891
8165
  runtime: this.standaloneRuntime.runtime,
7892
8166
  entityBridgeManager: this.entityBridgeManager,
8167
+ ...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
8168
+ ...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
7893
8169
  isShuttingDown: () => this.shuttingDown,
7894
8170
  mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0
7895
8171
  };