@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.js CHANGED
@@ -8,7 +8,7 @@ import postgres from "postgres";
8
8
  import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
9
9
  import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
10
10
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
11
- import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
11
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
@@ -75,7 +75,7 @@ const entities = pgTable(`entities`, {
75
75
  index(`idx_entities_parent`).on(table.tenantId, table.parent),
76
76
  index(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
77
77
  index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
78
- check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
78
+ check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
79
79
  ]);
80
80
  const users = pgTable(`users`, {
81
81
  tenantId: text(`tenant_id`).notNull().default(`default`),
@@ -329,12 +329,25 @@ async function runMigrations(postgresUrl) {
329
329
 
330
330
  //#endregion
331
331
  //#region src/electric-agents-types.ts
332
+ const ENTITY_SIGNALS = [
333
+ `SIGINT`,
334
+ `SIGHUP`,
335
+ `SIGTERM`,
336
+ `SIGKILL`,
337
+ `SIGSTOP`,
338
+ `SIGCONT`,
339
+ `SIGUSR`
340
+ ];
332
341
  const VALID_ENTITY_STATUSES = new Set([
333
342
  `spawning`,
334
343
  `running`,
335
344
  `idle`,
336
- `stopped`
345
+ `paused`,
346
+ `stopping`,
347
+ `stopped`,
348
+ `killed`
337
349
  ]);
350
+ const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
338
351
  function assertEntityStatus(s) {
339
352
  if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
340
353
  return s;
@@ -355,6 +368,27 @@ function assertRunnerAdminStatus(s) {
355
368
  if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
356
369
  return s;
357
370
  }
371
+ function assertEntitySignal(s) {
372
+ if (!VALID_ENTITY_SIGNALS.has(s)) throw new Error(`Invalid entity signal: "${s}"`);
373
+ return s;
374
+ }
375
+ function isTerminalEntityStatus(status$1) {
376
+ return status$1 === `stopped` || status$1 === `killed`;
377
+ }
378
+ function rejectsNormalWrites(status$1) {
379
+ return status$1 === `stopping` || isTerminalEntityStatus(status$1);
380
+ }
381
+ function expectedSignalStatus(status$1, signal) {
382
+ switch (signal) {
383
+ case `SIGKILL`: return `killed`;
384
+ case `SIGTERM`: return status$1 === `idle` ? `stopped` : `stopping`;
385
+ case `SIGSTOP`: return status$1 === `idle` ? `paused` : status$1;
386
+ case `SIGCONT`: return status$1 === `paused` ? `idle` : status$1;
387
+ case `SIGINT`:
388
+ case `SIGHUP`:
389
+ case `SIGUSR`: return status$1;
390
+ }
391
+ }
358
392
  /** Strip internal fields (write_token, subscription_id) from an entity. */
359
393
  function toPublicEntity(entity) {
360
394
  return {
@@ -376,6 +410,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
376
410
  const ErrCodeNotFound = `NOT_FOUND`;
377
411
  const ErrCodeNotRunning = `NOT_RUNNING`;
378
412
  const ErrCodeInvalidRequest = `INVALID_REQUEST`;
413
+ const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
379
414
  const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
380
415
  const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
381
416
  const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
@@ -777,7 +812,7 @@ var PostgresRegistry = class {
777
812
  };
778
813
  }
779
814
  async updateStatus(entityUrl, status$1) {
780
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
815
+ const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
781
816
  await this.db.update(entities).set({
782
817
  status: status$1,
783
818
  updatedAt: Date.now()
@@ -785,13 +820,17 @@ var PostgresRegistry = class {
785
820
  }
786
821
  async updateStatusWithTxid(entityUrl, status$1) {
787
822
  return await this.db.transaction(async (tx) => {
788
- const whereClause = status$1 === `stopped` ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`));
789
- await tx.update(entities).set({
823
+ const rows = await tx.update(entities).set({
790
824
  status: status$1,
791
825
  updatedAt: Date.now()
792
- }).where(whereClause);
793
- const result = await tx.execute(sql`SELECT pg_current_xact_id()::xid::text AS txid`);
794
- return parseInt(result[0].txid);
826
+ }).where(and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
827
+ return rows[0] ? parseInt(rows[0].txid) : null;
828
+ });
829
+ }
830
+ async touchEntityWithTxid(entityUrl) {
831
+ return await this.db.transaction(async (tx) => {
832
+ const rows = await tx.update(entities).set({ updatedAt: Date.now() }).where(and(eq(entities.url, entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
833
+ return rows[0] ? parseInt(rows[0].txid) : null;
795
834
  });
796
835
  }
797
836
  async setEntityTag(url, key, value) {
@@ -2418,6 +2457,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
2418
2457
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
2419
2458
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
2420
2459
  }
2460
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
2461
+ return policy?.targets[0] !== void 0;
2462
+ }
2421
2463
  async function linkEntityDispatchSubscription(ctx, entity) {
2422
2464
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
2423
2465
  const target = dispatchPolicy?.targets[0];
@@ -2582,6 +2624,10 @@ function extractManifestSourceUrl(manifest) {
2582
2624
  }
2583
2625
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
2584
2626
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
2627
+ if (manifest.sourceType === `webhook`) {
2628
+ if (typeof config?.streamUrl === `string`) return config.streamUrl;
2629
+ if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
2630
+ }
2585
2631
  return void 0;
2586
2632
  }
2587
2633
  if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
@@ -2643,6 +2689,7 @@ function createInitialQueuePosition(date) {
2643
2689
  }
2644
2690
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2645
2691
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2692
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2646
2693
  function sleep(ms) {
2647
2694
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2648
2695
  }
@@ -2867,7 +2914,8 @@ var EntityManager = class {
2867
2914
  debounceMs: req.wake.debounceMs,
2868
2915
  timeoutMs: req.wake.timeoutMs,
2869
2916
  oneShot: false,
2870
- includeResponse: req.wake.includeResponse
2917
+ includeResponse: req.wake.includeResponse,
2918
+ manifestKey: req.wake.manifestKey
2871
2919
  });
2872
2920
  const contentType = `application/json`;
2873
2921
  const createdEvent = entityStateSchema.entityCreated.insert({
@@ -3094,16 +3142,16 @@ var EntityManager = class {
3094
3142
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3095
3143
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3096
3144
  const subtree = await this.listEntitySubtree(root);
3097
- const stopped = subtree.find((entity) => entity.status === `stopped`);
3098
- if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork stopped entity "${stopped.url}"`, 409);
3099
- let active = subtree.filter((entity) => entity.status !== `idle`);
3145
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3146
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3147
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3100
3148
  if (active.length === 0) {
3101
3149
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3102
3150
  const lockedRoot = await this.registry.getEntity(rootUrl);
3103
3151
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3104
3152
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3105
3153
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3106
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3154
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3107
3155
  if (lockedActive.length === 0) return lockedSubtree;
3108
3156
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3109
3157
  active = lockedActive;
@@ -3559,6 +3607,11 @@ var EntityManager = class {
3559
3607
  if (req.position) value.position = req.position;
3560
3608
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3561
3609
  if (value.status === `processed`) value.processed_at = now;
3610
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3611
+ if (wakePausedEntity) {
3612
+ await this.registry.updateStatus(entityUrl, `idle`);
3613
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3614
+ }
3562
3615
  const envelope = entityStateSchema.inbox.insert({
3563
3616
  key,
3564
3617
  value
@@ -3586,7 +3639,7 @@ var EntityManager = class {
3586
3639
  async updateInboxMessage(entityUrl, key, req) {
3587
3640
  const entity = await this.registry.getEntity(entityUrl);
3588
3641
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3589
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3642
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3590
3643
  const now = new Date().toISOString();
3591
3644
  const value = {};
3592
3645
  if (`payload` in req) value.payload = req.payload;
@@ -3607,7 +3660,7 @@ var EntityManager = class {
3607
3660
  async deleteInboxMessage(entityUrl, key) {
3608
3661
  const entity = await this.registry.getEntity(entityUrl);
3609
3662
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3610
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3663
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3611
3664
  const envelope = entityStateSchema.inbox.delete({ key });
3612
3665
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3613
3666
  }
@@ -3615,7 +3668,7 @@ var EntityManager = class {
3615
3668
  const entity = await this.registry.getEntity(entityUrl);
3616
3669
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3617
3670
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
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
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3620
3673
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3621
3674
  const updated = result.entity;
@@ -3627,7 +3680,7 @@ var EntityManager = class {
3627
3680
  const entity = await this.registry.getEntity(entityUrl);
3628
3681
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3629
3682
  if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
3630
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3683
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3631
3684
  const result = await this.registry.removeEntityTag(entityUrl, key);
3632
3685
  const updated = result.entity;
3633
3686
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3756,6 +3809,35 @@ var EntityManager = class {
3756
3809
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3757
3810
  return { txid };
3758
3811
  }
3812
+ async upsertEventSourceSubscription(entityUrl, req) {
3813
+ const manifestKey = req.subscription.manifestKey;
3814
+ const txid = randomUUID();
3815
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
3816
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3817
+ await this.wakeRegistry.register({
3818
+ tenantId: this.tenantId,
3819
+ subscriberUrl: entityUrl,
3820
+ sourceUrl: req.subscription.sourceUrl,
3821
+ condition: {
3822
+ on: `change`,
3823
+ collections: [`webhook_event`],
3824
+ ops: [`insert`]
3825
+ },
3826
+ oneShot: false,
3827
+ manifestKey
3828
+ });
3829
+ return {
3830
+ txid,
3831
+ subscription: req.subscription
3832
+ };
3833
+ }
3834
+ async deleteEventSourceSubscription(entityUrl, req) {
3835
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id);
3836
+ const txid = randomUUID();
3837
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
3838
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
3839
+ return { txid };
3840
+ }
3759
3841
  /**
3760
3842
  * Register a wake subscription from a subscriber to a source entity.
3761
3843
  */
@@ -3880,26 +3962,131 @@ var EntityManager = class {
3880
3962
  }
3881
3963
  };
3882
3964
  }
3883
- async kill(entityUrl) {
3965
+ async signal(entityUrl, req) {
3884
3966
  const entity = await this.registry.getEntity(entityUrl);
3885
3967
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3886
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3887
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3888
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`);
3889
- if (this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3890
- const stoppedEvent = entityStateSchema.entityStopped.insert({
3891
- key: `stopped`,
3892
- value: { timestamp: new Date().toISOString() }
3968
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3969
+ const now = new Date();
3970
+ const previousState = entity.status;
3971
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
3972
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
3973
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
3974
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
3975
+ const signalValue = {
3976
+ signal: req.signal,
3977
+ status: handling.handled ? `handled` : `unhandled`,
3978
+ sender: SERVER_SIGNAL_SENDER,
3979
+ timestamp: now.toISOString()
3980
+ };
3981
+ if (req.reason !== void 0) signalValue.reason = req.reason;
3982
+ if (req.payload !== void 0) signalValue.payload = req.payload;
3983
+ if (handling.handled) {
3984
+ signalValue.handled_at = now.toISOString();
3985
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
3986
+ signalValue.outcome = handling.outcome;
3987
+ signalValue.previous_state = previousState;
3988
+ signalValue.new_state = handling.status;
3989
+ }
3990
+ const signalEvent = {
3991
+ type: `signal`,
3992
+ key,
3993
+ value: signalValue,
3994
+ headers: {
3995
+ operation: `insert`,
3996
+ timestamp: now.toISOString(),
3997
+ txid: String(txid)
3998
+ }
3999
+ };
4000
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
4001
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
4002
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
4003
+ if (handling.unregisterWakes) {
4004
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
4005
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
4006
+ }
4007
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4008
+ return {
4009
+ url: entityUrl,
4010
+ signal: req.signal,
4011
+ previous_state: previousState,
4012
+ new_state: handling.status,
4013
+ created_at: now.getTime(),
4014
+ txid
4015
+ };
4016
+ }
4017
+ async kill(entityUrl) {
4018
+ const response = await this.signal(entityUrl, {
4019
+ signal: `SIGKILL`,
4020
+ reason: `Legacy kill command`
3893
4021
  });
3894
- const eofData = this.encodeChangeEvent(stoppedEvent);
3895
- for (const streamPath of [entity.streams.main, entity.streams.error]) try {
3896
- await this.streamClient.append(streamPath, eofData, { close: true });
4022
+ return { txid: response.txid };
4023
+ }
4024
+ serverHandlingForSignal(status$1, signal) {
4025
+ if (signal === `SIGKILL`) return {
4026
+ status: `killed`,
4027
+ handled: true,
4028
+ outcome: `transitioned`,
4029
+ unregisterWakes: true
4030
+ };
4031
+ if (signal === `SIGTERM`) {
4032
+ if (status$1 === `idle` || status$1 === `paused`) return {
4033
+ status: `stopped`,
4034
+ handled: true,
4035
+ outcome: `transitioned`,
4036
+ unregisterWakes: true
4037
+ };
4038
+ if (status$1 === `running`) return {
4039
+ status: `stopping`,
4040
+ handled: false,
4041
+ outcome: `transitioned`,
4042
+ unregisterWakes: false
4043
+ };
4044
+ }
4045
+ if (status$1 === `paused` && signal !== `SIGCONT`) return {
4046
+ status: status$1,
4047
+ handled: true,
4048
+ outcome: `ignored`,
4049
+ unregisterWakes: false
4050
+ };
4051
+ if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
4052
+ status: `paused`,
4053
+ handled: status$1 === `idle`,
4054
+ outcome: `transitioned`,
4055
+ unregisterWakes: false
4056
+ };
4057
+ if (signal === `SIGCONT` && status$1 === `paused`) return {
4058
+ status: `idle`,
4059
+ handled: false,
4060
+ outcome: `transitioned`,
4061
+ unregisterWakes: false
4062
+ };
4063
+ return {
4064
+ status: status$1,
4065
+ handled: false,
4066
+ outcome: `ignored`,
4067
+ unregisterWakes: false
4068
+ };
4069
+ }
4070
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4071
+ const signalData = this.encodeChangeEvent(signalEvent);
4072
+ if (!closeStreams) {
4073
+ await this.streamClient.append(entity.streams.main, signalData);
4074
+ return;
4075
+ }
4076
+ const errorCloseEvent = {
4077
+ type: `signal`,
4078
+ key: signalEvent.key,
4079
+ value: signalEvent.value,
4080
+ headers: signalEvent.headers
4081
+ };
4082
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4083
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4084
+ await this.streamClient.append(streamPath, data, { close: true });
3897
4085
  } catch (err) {
3898
4086
  const message = err instanceof Error ? err.message : String(err);
3899
4087
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3900
4088
  throw err;
3901
4089
  }
3902
- return { txid };
3903
4090
  }
3904
4091
  async validateWriteEvent(entity, event) {
3905
4092
  if (!entity.type) return null;
@@ -4015,7 +4202,7 @@ var EntityManager = class {
4015
4202
  async validateSendRequest(entityUrl, req) {
4016
4203
  const entity = await this.registry.getEntity(entityUrl);
4017
4204
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4018
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4205
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4019
4206
  if (req.type && entity.type) {
4020
4207
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4021
4208
  if (inboxSchemas) {
@@ -4980,7 +5167,8 @@ var ElectricAgentsTenantRuntime = class {
4980
5167
  const primaryStream = `${entityUrl}/main`;
4981
5168
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
4982
5169
  if (callbacks.length > 0) return;
4983
- await this.manager.registry.updateStatus(entityUrl, `idle`);
5170
+ const entity = await this.manager.registry.getEntity(entityUrl);
5171
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
4984
5172
  await this.entityBridgeManager.onEntityChanged(entityUrl);
4985
5173
  }
4986
5174
  };
@@ -6530,7 +6718,8 @@ const spawnBodySchema = Type.Object({
6530
6718
  condition: wakeConditionSchema,
6531
6719
  debounceMs: Type.Optional(Type.Number()),
6532
6720
  timeoutMs: Type.Optional(Type.Number()),
6533
- includeResponse: Type.Optional(Type.Boolean())
6721
+ includeResponse: Type.Optional(Type.Boolean()),
6722
+ manifestKey: Type.Optional(Type.String())
6534
6723
  }))
6535
6724
  });
6536
6725
  const sendBodySchema = Type.Object({
@@ -6567,6 +6756,20 @@ const forkBodySchema = Type.Object({
6567
6756
  waitTimeoutMs: Type.Optional(Type.Number())
6568
6757
  });
6569
6758
  const setTagBodySchema = Type.Object({ value: Type.String() });
6759
+ const entitySignalSchema = Type.Union([
6760
+ Type.Literal(`SIGINT`),
6761
+ Type.Literal(`SIGHUP`),
6762
+ Type.Literal(`SIGTERM`),
6763
+ Type.Literal(`SIGKILL`),
6764
+ Type.Literal(`SIGSTOP`),
6765
+ Type.Literal(`SIGCONT`),
6766
+ Type.Literal(`SIGUSR`)
6767
+ ]);
6768
+ const signalBodySchema = Type.Object({
6769
+ signal: entitySignalSchema,
6770
+ reason: Type.Optional(Type.String()),
6771
+ payload: Type.Optional(Type.Unknown())
6772
+ });
6570
6773
  const scheduleBodySchema = Type.Union([Type.Object({
6571
6774
  scheduleType: Type.Literal(`cron`),
6572
6775
  expression: Type.String(),
@@ -6582,6 +6785,22 @@ const scheduleBodySchema = Type.Union([Type.Object({
6582
6785
  messageType: Type.Optional(Type.String()),
6583
6786
  from: Type.Optional(Type.String())
6584
6787
  })]);
6788
+ const subscriptionLifetimeSchema = Type.Union([
6789
+ Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
6790
+ Type.Object({
6791
+ kind: Type.Literal(`expires_at`),
6792
+ at: Type.String()
6793
+ }),
6794
+ Type.Object({ kind: Type.Literal(`manual`) })
6795
+ ]);
6796
+ const eventSourceSubscriptionBodySchema = Type.Object({
6797
+ sourceKey: Type.String(),
6798
+ bucketKey: Type.Optional(Type.String()),
6799
+ params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6800
+ filterKey: Type.Optional(Type.String()),
6801
+ lifetime: Type.Optional(subscriptionLifetimeSchema),
6802
+ reason: Type.Optional(Type.String())
6803
+ });
6585
6804
  const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
6586
6805
  const entitiesRouter = Router({ base: `/_electric/entities` });
6587
6806
  entitiesRouter.get(`/`, listEntities);
@@ -6590,6 +6809,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6590
6809
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6591
6810
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6592
6811
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6812
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6593
6813
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6594
6814
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6595
6815
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6598,6 +6818,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6598
6818
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
6599
6819
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6600
6820
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6821
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6822
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
6601
6823
  function entityUrlFromSegments(type, instanceId) {
6602
6824
  if (!type || !instanceId) return null;
6603
6825
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -6704,6 +6926,47 @@ async function deleteSchedule(request, ctx) {
6704
6926
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6705
6927
  return json(result);
6706
6928
  }
6929
+ async function upsertEventSourceSubscription(request, ctx) {
6930
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6931
+ if (principalMutationError) return principalMutationError;
6932
+ const catalog = ctx.eventSources;
6933
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6934
+ const { entityUrl } = requireExistingEntityRoute(request);
6935
+ const parsed = routeBody(request);
6936
+ const source = await catalog.getEventSource(parsed.sourceKey);
6937
+ if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6938
+ if (parsed.lifetime?.kind === `expires_at`) {
6939
+ const expiresAt = new Date(parsed.lifetime.at);
6940
+ if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6941
+ }
6942
+ let resolved;
6943
+ try {
6944
+ resolved = resolveEventSourceSubscription({
6945
+ contract: source,
6946
+ entityUrl,
6947
+ request: {
6948
+ ...parsed,
6949
+ id: decodeURIComponent(request.params.subscriptionId)
6950
+ },
6951
+ createdBy: `tool`
6952
+ });
6953
+ } catch (error) {
6954
+ return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6955
+ }
6956
+ await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6957
+ const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6958
+ subscription: resolved.subscription,
6959
+ manifest: buildEventSourceManifestEntry(resolved)
6960
+ });
6961
+ return json(result);
6962
+ }
6963
+ async function deleteEventSourceSubscription(request, ctx) {
6964
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6965
+ if (principalMutationError) return principalMutationError;
6966
+ const { entityUrl } = requireExistingEntityRoute(request);
6967
+ const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6968
+ return json(result);
6969
+ }
6707
6970
  async function setTag(request, ctx) {
6708
6971
  const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
6709
6972
  if (principalMutationError) return principalMutationError;
@@ -6793,11 +7056,13 @@ async function spawnEntity(request, ctx) {
6793
7056
  wake: parsed.wake,
6794
7057
  created_by: principal.url
6795
7058
  });
6796
- await linkEntityDispatchSubscription(ctx, entity);
7059
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
7060
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6797
7061
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6798
7062
  from: principal.url,
6799
7063
  payload: parsed.initialMessage
6800
7064
  });
7065
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6801
7066
  return json({
6802
7067
  ...toPublicEntity(entity),
6803
7068
  txid: entity.txid
@@ -6821,6 +7086,22 @@ async function killEntity(request, ctx) {
6821
7086
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6822
7087
  return json(result);
6823
7088
  }
7089
+ async function signalEntity(request, ctx) {
7090
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
7091
+ if (principalMutationError) return principalMutationError;
7092
+ const parsed = routeBody(request);
7093
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
7094
+ const result = await ctx.entityManager.signal(entityUrl, {
7095
+ signal: parsed.signal,
7096
+ reason: parsed.reason,
7097
+ payload: parsed.payload
7098
+ });
7099
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
7100
+ await unlinkEntityDispatchSubscription(ctx, entity);
7101
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
7102
+ }
7103
+ return json(result);
7104
+ }
6824
7105
 
6825
7106
  //#endregion
6826
7107
  //#region src/routing/entity-types-router.ts
@@ -7301,7 +7582,7 @@ async function notificationFromClaim(ctx, input) {
7301
7582
  const primaryStream = withLeadingSlash(primary.path);
7302
7583
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7303
7584
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7304
- if (entity.status === `stopped`) {
7585
+ if (entity.status === `stopped` || entity.status === `paused`) {
7305
7586
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7306
7587
  wake_id: input.claim.wake_id,
7307
7588
  generation: input.claim.generation
@@ -7400,6 +7681,7 @@ const callbackForwardBodySchema = Type.Object({
7400
7681
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7401
7682
  const internalRouter = Router({ base: `/_electric` });
7402
7683
  internalRouter.get(`/health`, () => json({ status: `ok` }));
7684
+ internalRouter.get(`/event-sources`, listEventSources);
7403
7685
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7404
7686
  internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
7405
7687
  internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
@@ -7503,6 +7785,13 @@ async function registerWake(request, ctx) {
7503
7785
  await ctx.entityManager.registerWake(opts);
7504
7786
  return status(204);
7505
7787
  }
7788
+ async function listEventSources(_request, ctx) {
7789
+ const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7790
+ return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7791
+ }
7792
+ function isAgentVisibleEventSource(source) {
7793
+ return source.agentVisible === true && source.status === `active`;
7794
+ }
7506
7795
  async function webhookForward(request, ctx) {
7507
7796
  const subscriptionId = routeParam(request, `subscriptionId`);
7508
7797
  const rootSpan = getRequestSpan(request);
@@ -7569,7 +7858,7 @@ async function webhookForward(request, ctx) {
7569
7858
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7570
7859
  }) : void 0;
7571
7860
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7572
- if (entity?.status === `stopped`) {
7861
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7573
7862
  if (upsertPromise) await upsertPromise;
7574
7863
  return json({ done: true });
7575
7864
  }
@@ -7712,9 +8001,9 @@ async function callbackForward(request, ctx) {
7712
8001
  entityCleared = result?.entityCleared ?? false;
7713
8002
  }
7714
8003
  if (entity && (entityCleared || stillOwnsClaim)) {
7715
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
8004
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7716
8005
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7717
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
8006
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7718
8007
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7719
8008
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7720
8009
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -7770,4 +8059,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
7770
8059
  globalRouter.all(`*`, durableStreamsRouter.fetch);
7771
8060
 
7772
8061
  //#endregion
7773
- export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
8062
+ export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "entities" DROP CONSTRAINT "chk_entities_status";
2
+ --> statement-breakpoint
3
+ ALTER TABLE "entities" ADD CONSTRAINT "chk_entities_status" CHECK ("entities"."status" IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed'));
@@ -64,6 +64,13 @@
64
64
  "when": 1778976000000,
65
65
  "tag": "0008_runner_runtime_diagnostics",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1778540000000,
72
+ "tag": "0009_entity_signal_statuses",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
76
  }