@electric-ax/agents-server 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -3227,7 +3227,10 @@ interface Principal {
3227
3227
  type WakeNotification = WebhookNotification;
3228
3228
  type RequestPrincipal = Principal;
3229
3229
  type AuthenticateRequest = (request: Request) => Promise<Principal | null> | Principal | null;
3230
- type EntityStatus = `spawning` | `running` | `idle` | `stopped`;
3230
+ type EntityStatus = `spawning` | `running` | `idle` | `paused` | `stopping` | `stopped` | `killed`;
3231
+ declare const ENTITY_SIGNALS: readonly ["SIGINT", "SIGHUP", "SIGTERM", "SIGKILL", "SIGSTOP", "SIGCONT", "SIGUSR"];
3232
+ type EntitySignal = (typeof ENTITY_SIGNALS)[number];
3233
+ declare function assertEntityStatus(s: string): EntityStatus;
3231
3234
  type DispatchTarget = {
3232
3235
  type: `webhook`;
3233
3236
  url: string;
@@ -3354,6 +3357,10 @@ interface ConsumerClaim {
3354
3357
  acked_streams?: Array<SourceStreamOffset>;
3355
3358
  updated_at: string;
3356
3359
  }
3360
+ declare function assertEntitySignal(s: string): EntitySignal;
3361
+ declare function isTerminalEntityStatus(status: EntityStatus): boolean;
3362
+ declare function rejectsNormalWrites(status: EntityStatus): boolean;
3363
+ declare function expectedSignalStatus(status: EntityStatus, signal: EntitySignal): EntityStatus;
3357
3364
  interface ElectricAgentsEntity {
3358
3365
  url: string;
3359
3366
  type: string;
@@ -3376,7 +3383,26 @@ interface ElectricAgentsEntity {
3376
3383
  updated_at: number;
3377
3384
  }
3378
3385
  /** Public-facing entity — internal fields stripped. Standalone type so new internal fields don't silently leak. */
3379
-
3386
+ interface PublicElectricAgentsEntity {
3387
+ url: string;
3388
+ type: string;
3389
+ status: EntityStatus;
3390
+ streams: {
3391
+ main: string;
3392
+ error: string;
3393
+ };
3394
+ dispatch_policy?: DispatchPolicy;
3395
+ tags: Record<string, string>;
3396
+ spawn_args?: Record<string, unknown>;
3397
+ parent?: string;
3398
+ created_by?: string;
3399
+ created_at: number;
3400
+ updated_at: number;
3401
+ }
3402
+ /** Entity row as stored in Postgres / returned by Electric shapes (no derived `streams` field). */
3403
+ type ElectricAgentsEntityRow = Omit<PublicElectricAgentsEntity, `streams`>;
3404
+ /** Strip internal fields (write_token, subscription_id) from an entity. */
3405
+ declare function toPublicEntity(entity: ElectricAgentsEntity): PublicElectricAgentsEntity;
3380
3406
  interface ElectricAgentsEntityType {
3381
3407
  name: string;
3382
3408
  description: string;
@@ -3426,9 +3452,27 @@ interface SendRequest {
3426
3452
  mode?: `immediate` | `queued` | `paused` | `steer`;
3427
3453
  position?: string;
3428
3454
  }
3455
+ interface SignalRequest {
3456
+ signal: EntitySignal;
3457
+ reason?: string;
3458
+ payload?: unknown;
3459
+ }
3460
+ interface SignalResponse {
3461
+ url: string;
3462
+ signal: EntitySignal;
3463
+ previous_state: EntityStatus;
3464
+ new_state: EntityStatus;
3465
+ created_at: number;
3466
+ txid: number;
3467
+ }
3429
3468
  interface SetTagRequest {
3430
3469
  value: string;
3431
3470
  }
3471
+ interface EntityListFilter {
3472
+ type?: string;
3473
+ status?: EntityStatus;
3474
+ created_by?: string;
3475
+ }
3432
3476
 
3433
3477
  //#endregion
3434
3478
  //#region src/entity-registry.d.ts
@@ -3560,7 +3604,8 @@ declare class PostgresRegistry {
3560
3604
  total: number;
3561
3605
  }>;
3562
3606
  updateStatus(entityUrl: string, status: EntityStatus): Promise<void>;
3563
- updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number>;
3607
+ updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number | null>;
3608
+ touchEntityWithTxid(entityUrl: string): Promise<number | null>;
3564
3609
  setEntityTag(url: string, key: string, value: string): Promise<{
3565
3610
  entity: ElectricAgentsEntity | null;
3566
3611
  changed: boolean;
@@ -4194,9 +4239,12 @@ declare class EntityManager {
4194
4239
  */
4195
4240
  private extractRunResponse;
4196
4241
  private buildWakeMessage;
4242
+ signal(entityUrl: string, req: SignalRequest): Promise<SignalResponse>;
4197
4243
  kill(entityUrl: string): Promise<{
4198
4244
  txid: number;
4199
4245
  }>;
4246
+ private serverHandlingForSignal;
4247
+ private appendSignalEvent;
4200
4248
  validateWriteEvent(entity: ElectricAgentsEntity, event: Record<string, unknown>): Promise<{
4201
4249
  code: string;
4202
4250
  message: string;
@@ -4452,4 +4500,4 @@ declare class UnregisteredTenantError extends Error {
4452
4500
  declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
4453
4501
 
4454
4502
  //#endregion
4455
- export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicWakeNotification, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
4503
+ export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
package/dist/index.js CHANGED
@@ -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];
@@ -2643,6 +2685,7 @@ function createInitialQueuePosition(date) {
2643
2685
  }
2644
2686
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2645
2687
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2688
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2646
2689
  function sleep(ms) {
2647
2690
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2648
2691
  }
@@ -3094,16 +3137,16 @@ var EntityManager = class {
3094
3137
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3095
3138
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3096
3139
  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`);
3140
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3141
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3142
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3100
3143
  if (active.length === 0) {
3101
3144
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3102
3145
  const lockedRoot = await this.registry.getEntity(rootUrl);
3103
3146
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3104
3147
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3105
3148
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3106
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3149
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3107
3150
  if (lockedActive.length === 0) return lockedSubtree;
3108
3151
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3109
3152
  active = lockedActive;
@@ -3559,6 +3602,11 @@ var EntityManager = class {
3559
3602
  if (req.position) value.position = req.position;
3560
3603
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3561
3604
  if (value.status === `processed`) value.processed_at = now;
3605
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3606
+ if (wakePausedEntity) {
3607
+ await this.registry.updateStatus(entityUrl, `idle`);
3608
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3609
+ }
3562
3610
  const envelope = entityStateSchema.inbox.insert({
3563
3611
  key,
3564
3612
  value
@@ -3586,7 +3634,7 @@ var EntityManager = class {
3586
3634
  async updateInboxMessage(entityUrl, key, req) {
3587
3635
  const entity = await this.registry.getEntity(entityUrl);
3588
3636
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3589
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3637
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3590
3638
  const now = new Date().toISOString();
3591
3639
  const value = {};
3592
3640
  if (`payload` in req) value.payload = req.payload;
@@ -3607,7 +3655,7 @@ var EntityManager = class {
3607
3655
  async deleteInboxMessage(entityUrl, key) {
3608
3656
  const entity = await this.registry.getEntity(entityUrl);
3609
3657
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3610
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3658
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3611
3659
  const envelope = entityStateSchema.inbox.delete({ key });
3612
3660
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3613
3661
  }
@@ -3615,7 +3663,7 @@ var EntityManager = class {
3615
3663
  const entity = await this.registry.getEntity(entityUrl);
3616
3664
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3617
3665
  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);
3666
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3619
3667
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3620
3668
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3621
3669
  const updated = result.entity;
@@ -3627,7 +3675,7 @@ var EntityManager = class {
3627
3675
  const entity = await this.registry.getEntity(entityUrl);
3628
3676
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3629
3677
  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);
3678
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3631
3679
  const result = await this.registry.removeEntityTag(entityUrl, key);
3632
3680
  const updated = result.entity;
3633
3681
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3880,26 +3928,131 @@ var EntityManager = class {
3880
3928
  }
3881
3929
  };
3882
3930
  }
3883
- async kill(entityUrl) {
3931
+ async signal(entityUrl, req) {
3884
3932
  const entity = await this.registry.getEntity(entityUrl);
3885
3933
  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() }
3934
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3935
+ const now = new Date();
3936
+ const previousState = entity.status;
3937
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
3938
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
3939
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
3940
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
3941
+ const signalValue = {
3942
+ signal: req.signal,
3943
+ status: handling.handled ? `handled` : `unhandled`,
3944
+ sender: SERVER_SIGNAL_SENDER,
3945
+ timestamp: now.toISOString()
3946
+ };
3947
+ if (req.reason !== void 0) signalValue.reason = req.reason;
3948
+ if (req.payload !== void 0) signalValue.payload = req.payload;
3949
+ if (handling.handled) {
3950
+ signalValue.handled_at = now.toISOString();
3951
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
3952
+ signalValue.outcome = handling.outcome;
3953
+ signalValue.previous_state = previousState;
3954
+ signalValue.new_state = handling.status;
3955
+ }
3956
+ const signalEvent = {
3957
+ type: `signal`,
3958
+ key,
3959
+ value: signalValue,
3960
+ headers: {
3961
+ operation: `insert`,
3962
+ timestamp: now.toISOString(),
3963
+ txid: String(txid)
3964
+ }
3965
+ };
3966
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
3967
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
3968
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
3969
+ if (handling.unregisterWakes) {
3970
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
3971
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
3972
+ }
3973
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3974
+ return {
3975
+ url: entityUrl,
3976
+ signal: req.signal,
3977
+ previous_state: previousState,
3978
+ new_state: handling.status,
3979
+ created_at: now.getTime(),
3980
+ txid
3981
+ };
3982
+ }
3983
+ async kill(entityUrl) {
3984
+ const response = await this.signal(entityUrl, {
3985
+ signal: `SIGKILL`,
3986
+ reason: `Legacy kill command`
3893
3987
  });
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 });
3988
+ return { txid: response.txid };
3989
+ }
3990
+ serverHandlingForSignal(status$1, signal) {
3991
+ if (signal === `SIGKILL`) return {
3992
+ status: `killed`,
3993
+ handled: true,
3994
+ outcome: `transitioned`,
3995
+ unregisterWakes: true
3996
+ };
3997
+ if (signal === `SIGTERM`) {
3998
+ if (status$1 === `idle` || status$1 === `paused`) return {
3999
+ status: `stopped`,
4000
+ handled: true,
4001
+ outcome: `transitioned`,
4002
+ unregisterWakes: true
4003
+ };
4004
+ if (status$1 === `running`) return {
4005
+ status: `stopping`,
4006
+ handled: false,
4007
+ outcome: `transitioned`,
4008
+ unregisterWakes: false
4009
+ };
4010
+ }
4011
+ if (status$1 === `paused` && signal !== `SIGCONT`) return {
4012
+ status: status$1,
4013
+ handled: true,
4014
+ outcome: `ignored`,
4015
+ unregisterWakes: false
4016
+ };
4017
+ if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
4018
+ status: `paused`,
4019
+ handled: status$1 === `idle`,
4020
+ outcome: `transitioned`,
4021
+ unregisterWakes: false
4022
+ };
4023
+ if (signal === `SIGCONT` && status$1 === `paused`) return {
4024
+ status: `idle`,
4025
+ handled: false,
4026
+ outcome: `transitioned`,
4027
+ unregisterWakes: false
4028
+ };
4029
+ return {
4030
+ status: status$1,
4031
+ handled: false,
4032
+ outcome: `ignored`,
4033
+ unregisterWakes: false
4034
+ };
4035
+ }
4036
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4037
+ const signalData = this.encodeChangeEvent(signalEvent);
4038
+ if (!closeStreams) {
4039
+ await this.streamClient.append(entity.streams.main, signalData);
4040
+ return;
4041
+ }
4042
+ const errorCloseEvent = {
4043
+ type: `signal`,
4044
+ key: signalEvent.key,
4045
+ value: signalEvent.value,
4046
+ headers: signalEvent.headers
4047
+ };
4048
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4049
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4050
+ await this.streamClient.append(streamPath, data, { close: true });
3897
4051
  } catch (err) {
3898
4052
  const message = err instanceof Error ? err.message : String(err);
3899
4053
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3900
4054
  throw err;
3901
4055
  }
3902
- return { txid };
3903
4056
  }
3904
4057
  async validateWriteEvent(entity, event) {
3905
4058
  if (!entity.type) return null;
@@ -4015,7 +4168,7 @@ var EntityManager = class {
4015
4168
  async validateSendRequest(entityUrl, req) {
4016
4169
  const entity = await this.registry.getEntity(entityUrl);
4017
4170
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4018
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4171
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4019
4172
  if (req.type && entity.type) {
4020
4173
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4021
4174
  if (inboxSchemas) {
@@ -4980,7 +5133,8 @@ var ElectricAgentsTenantRuntime = class {
4980
5133
  const primaryStream = `${entityUrl}/main`;
4981
5134
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
4982
5135
  if (callbacks.length > 0) return;
4983
- await this.manager.registry.updateStatus(entityUrl, `idle`);
5136
+ const entity = await this.manager.registry.getEntity(entityUrl);
5137
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
4984
5138
  await this.entityBridgeManager.onEntityChanged(entityUrl);
4985
5139
  }
4986
5140
  };
@@ -6567,6 +6721,20 @@ const forkBodySchema = Type.Object({
6567
6721
  waitTimeoutMs: Type.Optional(Type.Number())
6568
6722
  });
6569
6723
  const setTagBodySchema = Type.Object({ value: Type.String() });
6724
+ const entitySignalSchema = Type.Union([
6725
+ Type.Literal(`SIGINT`),
6726
+ Type.Literal(`SIGHUP`),
6727
+ Type.Literal(`SIGTERM`),
6728
+ Type.Literal(`SIGKILL`),
6729
+ Type.Literal(`SIGSTOP`),
6730
+ Type.Literal(`SIGCONT`),
6731
+ Type.Literal(`SIGUSR`)
6732
+ ]);
6733
+ const signalBodySchema = Type.Object({
6734
+ signal: entitySignalSchema,
6735
+ reason: Type.Optional(Type.String()),
6736
+ payload: Type.Optional(Type.Unknown())
6737
+ });
6570
6738
  const scheduleBodySchema = Type.Union([Type.Object({
6571
6739
  scheduleType: Type.Literal(`cron`),
6572
6740
  expression: Type.String(),
@@ -6590,6 +6758,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
6590
6758
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6591
6759
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6592
6760
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6761
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6593
6762
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6594
6763
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6595
6764
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -6793,11 +6962,13 @@ async function spawnEntity(request, ctx) {
6793
6962
  wake: parsed.wake,
6794
6963
  created_by: principal.url
6795
6964
  });
6796
- await linkEntityDispatchSubscription(ctx, entity);
6965
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
6966
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6797
6967
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
6798
6968
  from: principal.url,
6799
6969
  payload: parsed.initialMessage
6800
6970
  });
6971
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
6801
6972
  return json({
6802
6973
  ...toPublicEntity(entity),
6803
6974
  txid: entity.txid
@@ -6821,6 +6992,22 @@ async function killEntity(request, ctx) {
6821
6992
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
6822
6993
  return json(result);
6823
6994
  }
6995
+ async function signalEntity(request, ctx) {
6996
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
6997
+ if (principalMutationError) return principalMutationError;
6998
+ const parsed = routeBody(request);
6999
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
7000
+ const result = await ctx.entityManager.signal(entityUrl, {
7001
+ signal: parsed.signal,
7002
+ reason: parsed.reason,
7003
+ payload: parsed.payload
7004
+ });
7005
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
7006
+ await unlinkEntityDispatchSubscription(ctx, entity);
7007
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
7008
+ }
7009
+ return json(result);
7010
+ }
6824
7011
 
6825
7012
  //#endregion
6826
7013
  //#region src/routing/entity-types-router.ts
@@ -7301,7 +7488,7 @@ async function notificationFromClaim(ctx, input) {
7301
7488
  const primaryStream = withLeadingSlash(primary.path);
7302
7489
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
7303
7490
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
7304
- if (entity.status === `stopped`) {
7491
+ if (entity.status === `stopped` || entity.status === `paused`) {
7305
7492
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
7306
7493
  wake_id: input.claim.wake_id,
7307
7494
  generation: input.claim.generation
@@ -7569,7 +7756,7 @@ async function webhookForward(request, ctx) {
7569
7756
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
7570
7757
  }) : void 0;
7571
7758
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
7572
- if (entity?.status === `stopped`) {
7759
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
7573
7760
  if (upsertPromise) await upsertPromise;
7574
7761
  return json({ done: true });
7575
7762
  }
@@ -7712,9 +7899,9 @@ async function callbackForward(request, ctx) {
7712
7899
  entityCleared = result?.entityCleared ?? false;
7713
7900
  }
7714
7901
  if (entity && (entityCleared || stillOwnsClaim)) {
7715
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
7902
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
7716
7903
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7717
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
7904
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
7718
7905
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7719
7906
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7720
7907
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -7770,4 +7957,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
7770
7957
  globalRouter.all(`*`, durableStreamsRouter.fetch);
7771
7958
 
7772
7959
  //#endregion
7773
- export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
7960
+ 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.1"
57
+ "@electric-ax/agents-runtime": "0.3.2"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.5",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.6",
70
- "@electric-ax/agents-server-ui": "0.4.6"
68
+ "@electric-ax/agents-server-conformance-tests": "0.1.7",
69
+ "@electric-ax/agents": "0.4.6",
70
+ "@electric-ax/agents-server-ui": "0.4.7"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -66,7 +66,7 @@ export const entities = pgTable(
66
66
  index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
67
67
  check(
68
68
  `chk_entities_status`,
69
- sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`
69
+ sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`
70
70
  ),
71
71
  ]
72
72
  )