@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.
@@ -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) {
@@ -2705,6 +2729,7 @@ function createInitialQueuePosition(date) {
2705
2729
  }
2706
2730
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2707
2731
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2732
+ const SERVER_SIGNAL_SENDER = `/_electric/server`;
2708
2733
  function sleep(ms) {
2709
2734
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2710
2735
  }
@@ -3156,16 +3181,16 @@ var EntityManager = class {
3156
3181
  if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3157
3182
  if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3158
3183
  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`);
3184
+ const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
3185
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3186
+ let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3162
3187
  if (active.length === 0) {
3163
3188
  this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
3164
3189
  const lockedRoot = await this.registry.getEntity(rootUrl);
3165
3190
  if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3166
3191
  const lockedSubtree = await this.listEntitySubtree(lockedRoot);
3167
3192
  this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
3168
- const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
3193
+ const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3169
3194
  if (lockedActive.length === 0) return lockedSubtree;
3170
3195
  this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3171
3196
  active = lockedActive;
@@ -3621,6 +3646,11 @@ var EntityManager = class {
3621
3646
  if (req.position) value.position = req.position;
3622
3647
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
3623
3648
  if (value.status === `processed`) value.processed_at = now;
3649
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
3650
+ if (wakePausedEntity) {
3651
+ await this.registry.updateStatus(entityUrl, `idle`);
3652
+ await this.entityBridgeManager?.onEntityChanged(entityUrl);
3653
+ }
3624
3654
  const envelope = entityStateSchema.inbox.insert({
3625
3655
  key,
3626
3656
  value
@@ -3648,7 +3678,7 @@ var EntityManager = class {
3648
3678
  async updateInboxMessage(entityUrl, key, req) {
3649
3679
  const entity = await this.registry.getEntity(entityUrl);
3650
3680
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3651
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3681
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3652
3682
  const now = new Date().toISOString();
3653
3683
  const value = {};
3654
3684
  if (`payload` in req) value.payload = req.payload;
@@ -3669,7 +3699,7 @@ var EntityManager = class {
3669
3699
  async deleteInboxMessage(entityUrl, key) {
3670
3700
  const entity = await this.registry.getEntity(entityUrl);
3671
3701
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3672
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
3702
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3673
3703
  const envelope = entityStateSchema.inbox.delete({ key });
3674
3704
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3675
3705
  }
@@ -3677,7 +3707,7 @@ var EntityManager = class {
3677
3707
  const entity = await this.registry.getEntity(entityUrl);
3678
3708
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3679
3709
  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);
3710
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3681
3711
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
3682
3712
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
3683
3713
  const updated = result.entity;
@@ -3689,7 +3719,7 @@ var EntityManager = class {
3689
3719
  const entity = await this.registry.getEntity(entityUrl);
3690
3720
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3691
3721
  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);
3722
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3693
3723
  const result = await this.registry.removeEntityTag(entityUrl, key);
3694
3724
  const updated = result.entity;
3695
3725
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
@@ -3942,26 +3972,131 @@ var EntityManager = class {
3942
3972
  }
3943
3973
  };
3944
3974
  }
3945
- async kill(entityUrl) {
3975
+ async signal(entityUrl, req) {
3946
3976
  const entity = await this.registry.getEntity(entityUrl);
3947
3977
  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() }
3978
+ if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
3979
+ const now = new Date();
3980
+ const previousState = entity.status;
3981
+ const handling = this.serverHandlingForSignal(previousState, req.signal);
3982
+ const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
3983
+ if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
3984
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
3985
+ const signalValue = {
3986
+ signal: req.signal,
3987
+ status: handling.handled ? `handled` : `unhandled`,
3988
+ sender: SERVER_SIGNAL_SENDER,
3989
+ timestamp: now.toISOString()
3990
+ };
3991
+ if (req.reason !== void 0) signalValue.reason = req.reason;
3992
+ if (req.payload !== void 0) signalValue.payload = req.payload;
3993
+ if (handling.handled) {
3994
+ signalValue.handled_at = now.toISOString();
3995
+ signalValue.handled_by = SERVER_SIGNAL_SENDER;
3996
+ signalValue.outcome = handling.outcome;
3997
+ signalValue.previous_state = previousState;
3998
+ signalValue.new_state = handling.status;
3999
+ }
4000
+ const signalEvent = {
4001
+ type: `signal`,
4002
+ key,
4003
+ value: signalValue,
4004
+ headers: {
4005
+ operation: `insert`,
4006
+ timestamp: now.toISOString(),
4007
+ txid: String(txid)
4008
+ }
4009
+ };
4010
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status);
4011
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
4012
+ if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
4013
+ if (handling.unregisterWakes) {
4014
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
4015
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
4016
+ }
4017
+ if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4018
+ return {
4019
+ url: entityUrl,
4020
+ signal: req.signal,
4021
+ previous_state: previousState,
4022
+ new_state: handling.status,
4023
+ created_at: now.getTime(),
4024
+ txid
4025
+ };
4026
+ }
4027
+ async kill(entityUrl) {
4028
+ const response = await this.signal(entityUrl, {
4029
+ signal: `SIGKILL`,
4030
+ reason: `Legacy kill command`
3955
4031
  });
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 });
4032
+ return { txid: response.txid };
4033
+ }
4034
+ serverHandlingForSignal(status$1, signal) {
4035
+ if (signal === `SIGKILL`) return {
4036
+ status: `killed`,
4037
+ handled: true,
4038
+ outcome: `transitioned`,
4039
+ unregisterWakes: true
4040
+ };
4041
+ if (signal === `SIGTERM`) {
4042
+ if (status$1 === `idle` || status$1 === `paused`) return {
4043
+ status: `stopped`,
4044
+ handled: true,
4045
+ outcome: `transitioned`,
4046
+ unregisterWakes: true
4047
+ };
4048
+ if (status$1 === `running`) return {
4049
+ status: `stopping`,
4050
+ handled: false,
4051
+ outcome: `transitioned`,
4052
+ unregisterWakes: false
4053
+ };
4054
+ }
4055
+ if (status$1 === `paused` && signal !== `SIGCONT`) return {
4056
+ status: status$1,
4057
+ handled: true,
4058
+ outcome: `ignored`,
4059
+ unregisterWakes: false
4060
+ };
4061
+ if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
4062
+ status: `paused`,
4063
+ handled: status$1 === `idle`,
4064
+ outcome: `transitioned`,
4065
+ unregisterWakes: false
4066
+ };
4067
+ if (signal === `SIGCONT` && status$1 === `paused`) return {
4068
+ status: `idle`,
4069
+ handled: false,
4070
+ outcome: `transitioned`,
4071
+ unregisterWakes: false
4072
+ };
4073
+ return {
4074
+ status: status$1,
4075
+ handled: false,
4076
+ outcome: `ignored`,
4077
+ unregisterWakes: false
4078
+ };
4079
+ }
4080
+ async appendSignalEvent(entity, signalEvent, closeStreams) {
4081
+ const signalData = this.encodeChangeEvent(signalEvent);
4082
+ if (!closeStreams) {
4083
+ await this.streamClient.append(entity.streams.main, signalData);
4084
+ return;
4085
+ }
4086
+ const errorCloseEvent = {
4087
+ type: `signal`,
4088
+ key: signalEvent.key,
4089
+ value: signalEvent.value,
4090
+ headers: signalEvent.headers
4091
+ };
4092
+ const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4093
+ for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4094
+ await this.streamClient.append(streamPath, data, { close: true });
3959
4095
  } catch (err) {
3960
4096
  const message = err instanceof Error ? err.message : String(err);
3961
4097
  if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
3962
4098
  throw err;
3963
4099
  }
3964
- return { txid };
3965
4100
  }
3966
4101
  async validateWriteEvent(entity, event) {
3967
4102
  if (!entity.type) return null;
@@ -4077,7 +4212,7 @@ var EntityManager = class {
4077
4212
  async validateSendRequest(entityUrl, req) {
4078
4213
  const entity = await this.registry.getEntity(entityUrl);
4079
4214
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4080
- if (entity.status === `stopped`) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4215
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4081
4216
  if (req.type && entity.type) {
4082
4217
  const { inboxSchemas } = await this.getEffectiveSchemas(entity);
4083
4218
  if (inboxSchemas) {
@@ -4250,6 +4385,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
4250
4385
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
4251
4386
  if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
4252
4387
  }
4388
+ function shouldLinkDispatchBeforeInitialMessage(policy) {
4389
+ return policy?.targets[0] !== void 0;
4390
+ }
4253
4391
  async function linkEntityDispatchSubscription(ctx, entity) {
4254
4392
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
4255
4393
  const target = dispatchPolicy?.targets[0];
@@ -4375,6 +4513,20 @@ const forkBodySchema = Type.Object({
4375
4513
  waitTimeoutMs: Type.Optional(Type.Number())
4376
4514
  });
4377
4515
  const setTagBodySchema = Type.Object({ value: Type.String() });
4516
+ const entitySignalSchema = Type.Union([
4517
+ Type.Literal(`SIGINT`),
4518
+ Type.Literal(`SIGHUP`),
4519
+ Type.Literal(`SIGTERM`),
4520
+ Type.Literal(`SIGKILL`),
4521
+ Type.Literal(`SIGSTOP`),
4522
+ Type.Literal(`SIGCONT`),
4523
+ Type.Literal(`SIGUSR`)
4524
+ ]);
4525
+ const signalBodySchema = Type.Object({
4526
+ signal: entitySignalSchema,
4527
+ reason: Type.Optional(Type.String()),
4528
+ payload: Type.Optional(Type.Unknown())
4529
+ });
4378
4530
  const scheduleBodySchema = Type.Union([Type.Object({
4379
4531
  scheduleType: Type.Literal(`cron`),
4380
4532
  expression: Type.String(),
@@ -4398,6 +4550,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
4398
4550
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
4399
4551
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
4400
4552
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
4553
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
4401
4554
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
4402
4555
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
4403
4556
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
@@ -4601,11 +4754,13 @@ async function spawnEntity(request, ctx) {
4601
4754
  wake: parsed.wake,
4602
4755
  created_by: principal.url
4603
4756
  });
4604
- await linkEntityDispatchSubscription(ctx, entity);
4757
+ const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
4758
+ if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
4605
4759
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
4606
4760
  from: principal.url,
4607
4761
  payload: parsed.initialMessage
4608
4762
  });
4763
+ if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
4609
4764
  return json({
4610
4765
  ...toPublicEntity(entity),
4611
4766
  txid: entity.txid
@@ -4629,6 +4784,22 @@ async function killEntity(request, ctx) {
4629
4784
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
4630
4785
  return json(result);
4631
4786
  }
4787
+ async function signalEntity(request, ctx) {
4788
+ const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
4789
+ if (principalMutationError) return principalMutationError;
4790
+ const parsed = routeBody(request);
4791
+ const { entityUrl, entity } = requireExistingEntityRoute(request);
4792
+ const result = await ctx.entityManager.signal(entityUrl, {
4793
+ signal: parsed.signal,
4794
+ reason: parsed.reason,
4795
+ payload: parsed.payload
4796
+ });
4797
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
4798
+ await unlinkEntityDispatchSubscription(ctx, entity);
4799
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
4800
+ }
4801
+ return json(result);
4802
+ }
4632
4803
 
4633
4804
  //#endregion
4634
4805
  //#region src/routing/entity-types-router.ts
@@ -5109,7 +5280,7 @@ async function notificationFromClaim(ctx, input) {
5109
5280
  const primaryStream = withLeadingSlash(primary.path);
5110
5281
  const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
5111
5282
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
5112
- if (entity.status === `stopped`) {
5283
+ if (entity.status === `stopped` || entity.status === `paused`) {
5113
5284
  await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
5114
5285
  wake_id: input.claim.wake_id,
5115
5286
  generation: input.claim.generation
@@ -5377,7 +5548,7 @@ async function webhookForward(request, ctx) {
5377
5548
  serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
5378
5549
  }) : void 0;
5379
5550
  const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
5380
- if (entity?.status === `stopped`) {
5551
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
5381
5552
  if (upsertPromise) await upsertPromise;
5382
5553
  return json({ done: true });
5383
5554
  }
@@ -5520,9 +5691,9 @@ async function callbackForward(request, ctx) {
5520
5691
  entityCleared = result?.entityCleared ?? false;
5521
5692
  }
5522
5693
  if (entity && (entityCleared || stillOwnsClaim)) {
5523
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
5694
+ await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
5524
5695
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
5525
- serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
5696
+ serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
5526
5697
  } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
5527
5698
  if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
5528
5699
  else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
@@ -6875,7 +7046,8 @@ var ElectricAgentsTenantRuntime = class {
6875
7046
  const primaryStream = `${entityUrl}/main`;
6876
7047
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
6877
7048
  if (callbacks.length > 0) return;
6878
- await this.manager.registry.updateStatus(entityUrl, `idle`);
7049
+ const entity = await this.manager.registry.getEntity(entityUrl);
7050
+ await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
6879
7051
  await this.entityBridgeManager.onEntityChanged(entityUrl);
6880
7052
  }
6881
7053
  };