@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/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.9",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
40
  "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@eac712f",
41
41
  "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
- "@electric-sql/client": "^1.5.18",
42
+ "@electric-sql/client": "^1.5.19",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
45
45
  "@sinclair/typebox": "^0.34.48",
@@ -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.4"
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": "0.4.8",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.7",
70
+ "@electric-ax/agents-server-ui": "0.4.9"
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
  )
@@ -15,15 +15,39 @@ export type AuthenticateRequest = (
15
15
  request: Request
16
16
  ) => Promise<Principal | null> | Principal | null
17
17
 
18
- export type EntityStatus = `spawning` | `running` | `idle` | `stopped`
18
+ export type EntityStatus =
19
+ | `spawning`
20
+ | `running`
21
+ | `idle`
22
+ | `paused`
23
+ | `stopping`
24
+ | `stopped`
25
+ | `killed`
26
+
27
+ export const ENTITY_SIGNALS = [
28
+ `SIGINT`,
29
+ `SIGHUP`,
30
+ `SIGTERM`,
31
+ `SIGKILL`,
32
+ `SIGSTOP`,
33
+ `SIGCONT`,
34
+ `SIGUSR`,
35
+ ] as const
36
+
37
+ export type EntitySignal = (typeof ENTITY_SIGNALS)[number]
19
38
 
20
39
  const VALID_ENTITY_STATUSES = new Set<string>([
21
40
  `spawning`,
22
41
  `running`,
23
42
  `idle`,
43
+ `paused`,
44
+ `stopping`,
24
45
  `stopped`,
46
+ `killed`,
25
47
  ])
26
48
 
49
+ const VALID_ENTITY_SIGNALS = new Set<string>(ENTITY_SIGNALS)
50
+
27
51
  export function assertEntityStatus(s: string): EntityStatus {
28
52
  if (!VALID_ENTITY_STATUSES.has(s)) {
29
53
  throw new Error(`Invalid entity status: "${s}"`)
@@ -236,6 +260,41 @@ export interface ConsumerClaim {
236
260
  updated_at: string
237
261
  }
238
262
 
263
+ export function assertEntitySignal(s: string): EntitySignal {
264
+ if (!VALID_ENTITY_SIGNALS.has(s)) {
265
+ throw new Error(`Invalid entity signal: "${s}"`)
266
+ }
267
+ return s as EntitySignal
268
+ }
269
+
270
+ export function isTerminalEntityStatus(status: EntityStatus): boolean {
271
+ return status === `stopped` || status === `killed`
272
+ }
273
+
274
+ export function rejectsNormalWrites(status: EntityStatus): boolean {
275
+ return status === `stopping` || isTerminalEntityStatus(status)
276
+ }
277
+
278
+ export function expectedSignalStatus(
279
+ status: EntityStatus,
280
+ signal: EntitySignal
281
+ ): EntityStatus {
282
+ switch (signal) {
283
+ case `SIGKILL`:
284
+ return `killed`
285
+ case `SIGTERM`:
286
+ return status === `idle` ? `stopped` : `stopping`
287
+ case `SIGSTOP`:
288
+ return status === `idle` ? `paused` : status
289
+ case `SIGCONT`:
290
+ return status === `paused` ? `idle` : status
291
+ case `SIGINT`:
292
+ case `SIGHUP`:
293
+ case `SIGUSR`:
294
+ return status
295
+ }
296
+ }
297
+
239
298
  export interface ElectricAgentsEntity {
240
299
  url: string
241
300
  type: string
@@ -341,6 +400,7 @@ export interface TypedSpawnRequest {
341
400
  debounceMs?: number
342
401
  timeoutMs?: number
343
402
  includeResponse?: boolean
403
+ manifestKey?: string
344
404
  }
345
405
  }
346
406
 
@@ -353,6 +413,21 @@ export interface SendRequest {
353
413
  position?: string
354
414
  }
355
415
 
416
+ export interface SignalRequest {
417
+ signal: EntitySignal
418
+ reason?: string
419
+ payload?: unknown
420
+ }
421
+
422
+ export interface SignalResponse {
423
+ url: string
424
+ signal: EntitySignal
425
+ previous_state: EntityStatus
426
+ new_state: EntityStatus
427
+ created_at: number
428
+ txid: number
429
+ }
430
+
356
431
  export interface SetTagRequest {
357
432
  value: string
358
433
  }
@@ -369,6 +444,7 @@ export const ErrCodeNoSubscription = `NO_SUBSCRIPTION`
369
444
  export const ErrCodeNotFound = `NOT_FOUND`
370
445
  export const ErrCodeNotRunning = `NOT_RUNNING`
371
446
  export const ErrCodeInvalidRequest = `INVALID_REQUEST`
447
+ export const ErrCodeInvalidSignal = `INVALID_SIGNAL`
372
448
  export const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`
373
449
  export const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`
374
450
  export const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`
@@ -6,6 +6,7 @@ import {
6
6
  getCronStreamPath,
7
7
  getSharedStateStreamPath,
8
8
  getNextCronFireAt,
9
+ eventSourceSubscriptionManifestKey,
9
10
  manifestChildKey,
10
11
  manifestSharedStateKey,
11
12
  manifestSourceKey,
@@ -16,6 +17,7 @@ import {
16
17
  ErrCodeEntityPersistFailed,
17
18
  ErrCodeForkInProgress,
18
19
  ErrCodeForkWaitTimeout,
20
+ ErrCodeInvalidSignal,
19
21
  ErrCodeInvalidRequest,
20
22
  ErrCodeNotFound,
21
23
  ErrCodeNotRunning,
@@ -25,6 +27,8 @@ import {
25
27
  ErrCodeUnknownEntityType,
26
28
  ErrCodeUnknownEventType,
27
29
  ErrCodeUnknownMessageType,
30
+ isTerminalEntityStatus,
31
+ rejectsNormalWrites,
28
32
  } from './electric-agents-types.js'
29
33
  import { parseDispatchPolicy } from './dispatch-policy-schema.js'
30
34
  import { applyTypeDefaultSubscriptionScope } from './routing/dispatch-policy.js'
@@ -47,6 +51,7 @@ import type { queueAsPromised } from 'fastq'
47
51
  import type { SchedulerClient } from './scheduler.js'
48
52
  import type { WakeEvalResult, WakeRegistry } from './wake-registry.js'
49
53
  import type { WakeMessage } from '@electric-ax/agents-runtime'
54
+ import type { EventSourceSubscription } from '@electric-ax/agents-runtime'
50
55
  import type { PostgresRegistry } from './entity-registry.js'
51
56
  import type { SchemaValidator } from './electric-agents/schema-validator.js'
52
57
  import type { StreamClient } from './stream-client.js'
@@ -54,9 +59,12 @@ import type {
54
59
  DispatchPolicy,
55
60
  ElectricAgentsEntity,
56
61
  ElectricAgentsEntityType,
62
+ EntitySignal,
57
63
  RegisterEntityTypeRequest,
58
64
  SendRequest,
59
65
  SetTagRequest,
66
+ SignalRequest,
67
+ SignalResponse,
60
68
  TypedSpawnRequest,
61
69
  } from './electric-agents-types.js'
62
70
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
@@ -72,6 +80,36 @@ type WriteTokenValidator = (
72
80
  entity: ElectricAgentsEntity,
73
81
  token: string
74
82
  ) => boolean
83
+ type ServerSignalOutcome = `transitioned` | `ignored`
84
+ type ServerSignalHandling = {
85
+ status: ElectricAgentsEntity[`status`]
86
+ handled: boolean
87
+ outcome: ServerSignalOutcome
88
+ unregisterWakes: boolean
89
+ }
90
+ type ServerSignalValue = {
91
+ signal: EntitySignal
92
+ status: `handled` | `unhandled`
93
+ sender: typeof SERVER_SIGNAL_SENDER
94
+ timestamp: string
95
+ reason?: string
96
+ payload?: unknown
97
+ handled_at?: string
98
+ handled_by?: typeof SERVER_SIGNAL_SENDER
99
+ outcome?: ServerSignalOutcome
100
+ previous_state?: ElectricAgentsEntity[`status`]
101
+ new_state?: ElectricAgentsEntity[`status`]
102
+ }
103
+ type ServerSignalEvent = {
104
+ type: `signal`
105
+ key: string
106
+ value: ServerSignalValue
107
+ headers: {
108
+ operation: `insert`
109
+ timestamp: string
110
+ txid: string
111
+ }
112
+ }
75
113
 
76
114
  function createInitialQueuePosition(date: Date): string {
77
115
  return `${String(date.getTime()).padStart(16, `0`)}:a0`
@@ -103,6 +141,8 @@ type ForkResult = {
103
141
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 120_000
104
142
  const DEFAULT_FORK_WAIT_POLL_MS = 250
105
143
 
144
+ const SERVER_SIGNAL_SENDER = `/_electric/server`
145
+
106
146
  function sleep(ms: number): Promise<void> {
107
147
  return new Promise((resolve) => setTimeout(resolve, ms))
108
148
  }
@@ -516,6 +556,7 @@ export class EntityManager {
516
556
  timeoutMs: req.wake.timeoutMs,
517
557
  oneShot: false,
518
558
  includeResponse: req.wake.includeResponse,
559
+ manifestKey: req.wake.manifestKey,
519
560
  })
520
561
  }
521
562
 
@@ -928,16 +969,20 @@ export class EntityManager {
928
969
  }
929
970
 
930
971
  const subtree = await this.listEntitySubtree(root)
931
- const stopped = subtree.find((entity) => entity.status === `stopped`)
972
+ const stopped = subtree.find((entity) =>
973
+ isTerminalEntityStatus(entity.status)
974
+ )
932
975
  if (stopped) {
933
976
  throw new ElectricAgentsError(
934
977
  ErrCodeNotRunning,
935
- `Cannot fork stopped entity "${stopped.url}"`,
978
+ `Cannot fork terminal entity "${stopped.url}"`,
936
979
  409
937
980
  )
938
981
  }
939
982
 
940
- let active = subtree.filter((entity) => entity.status !== `idle`)
983
+ let active = subtree.filter(
984
+ (entity) => entity.status !== `idle` && entity.status !== `paused`
985
+ )
941
986
  if (active.length === 0) {
942
987
  this.addForkLocks(
943
988
  this.forkWorkLockedEntities,
@@ -959,7 +1004,7 @@ export class EntityManager {
959
1004
  workLocks
960
1005
  )
961
1006
  const lockedActive = lockedSubtree.filter(
962
- (entity) => entity.status !== `idle`
1007
+ (entity) => entity.status !== `idle` && entity.status !== `paused`
963
1008
  )
964
1009
  if (lockedActive.length === 0) {
965
1010
  return lockedSubtree
@@ -1673,6 +1718,12 @@ export class EntityManager {
1673
1718
  value.processed_at = now
1674
1719
  }
1675
1720
 
1721
+ const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`
1722
+ if (wakePausedEntity) {
1723
+ await this.registry.updateStatus(entityUrl, `idle`)
1724
+ await this.entityBridgeManager?.onEntityChanged(entityUrl)
1725
+ }
1726
+
1676
1727
  const envelope = entityStateSchema.inbox.insert({
1677
1728
  key,
1678
1729
  value,
@@ -1725,8 +1776,12 @@ export class EntityManager {
1725
1776
  if (!entity) {
1726
1777
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
1727
1778
  }
1728
- if (entity.status === `stopped`) {
1729
- throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409)
1779
+ if (rejectsNormalWrites(entity.status)) {
1780
+ throw new ElectricAgentsError(
1781
+ ErrCodeNotRunning,
1782
+ `Entity is not accepting writes`,
1783
+ 409
1784
+ )
1730
1785
  }
1731
1786
 
1732
1787
  const now = new Date().toISOString()
@@ -1763,8 +1818,12 @@ export class EntityManager {
1763
1818
  if (!entity) {
1764
1819
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
1765
1820
  }
1766
- if (entity.status === `stopped`) {
1767
- throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409)
1821
+ if (rejectsNormalWrites(entity.status)) {
1822
+ throw new ElectricAgentsError(
1823
+ ErrCodeNotRunning,
1824
+ `Entity is not accepting writes`,
1825
+ 409
1826
+ )
1768
1827
  }
1769
1828
 
1770
1829
  const envelope = entityStateSchema.inbox.delete({ key } as any)
@@ -1796,8 +1855,12 @@ export class EntityManager {
1796
1855
  401
1797
1856
  )
1798
1857
  }
1799
- if (entity.status === `stopped`) {
1800
- throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409)
1858
+ if (rejectsNormalWrites(entity.status)) {
1859
+ throw new ElectricAgentsError(
1860
+ ErrCodeNotRunning,
1861
+ `Entity is not accepting writes`,
1862
+ 409
1863
+ )
1801
1864
  }
1802
1865
 
1803
1866
  if (typeof req.value !== `string`) {
@@ -1842,8 +1905,12 @@ export class EntityManager {
1842
1905
  401
1843
1906
  )
1844
1907
  }
1845
- if (entity.status === `stopped`) {
1846
- throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409)
1908
+ if (rejectsNormalWrites(entity.status)) {
1909
+ throw new ElectricAgentsError(
1910
+ ErrCodeNotRunning,
1911
+ `Entity is not accepting writes`,
1912
+ 409
1913
+ )
1847
1914
  }
1848
1915
 
1849
1916
  const result = await this.registry.removeEntityTag(entityUrl, key)
@@ -2098,6 +2165,67 @@ export class EntityManager {
2098
2165
  return { txid }
2099
2166
  }
2100
2167
 
2168
+ async upsertEventSourceSubscription(
2169
+ entityUrl: string,
2170
+ req: {
2171
+ subscription: EventSourceSubscription
2172
+ manifest: Record<string, unknown>
2173
+ }
2174
+ ): Promise<{ txid: string; subscription: EventSourceSubscription }> {
2175
+ const manifestKey = req.subscription.manifestKey
2176
+ const txid = randomUUID()
2177
+ await this.writeManifestEntry(
2178
+ entityUrl,
2179
+ manifestKey,
2180
+ `upsert`,
2181
+ req.manifest,
2182
+ {
2183
+ txid,
2184
+ }
2185
+ )
2186
+
2187
+ // The manifest is the durable source of truth. Register side effects after
2188
+ // it is appended so failures can be repaired by manifest replay.
2189
+ await this.wakeRegistry.unregisterByManifestKey(
2190
+ entityUrl,
2191
+ manifestKey,
2192
+ this.tenantId
2193
+ )
2194
+ await this.wakeRegistry.register({
2195
+ tenantId: this.tenantId,
2196
+ subscriberUrl: entityUrl,
2197
+ sourceUrl: req.subscription.sourceUrl,
2198
+ condition: {
2199
+ on: `change`,
2200
+ collections: [`webhook_event`],
2201
+ ops: [`insert`],
2202
+ },
2203
+ oneShot: false,
2204
+ manifestKey,
2205
+ })
2206
+
2207
+ return { txid, subscription: req.subscription }
2208
+ }
2209
+
2210
+ async deleteEventSourceSubscription(
2211
+ entityUrl: string,
2212
+ req: { id: string }
2213
+ ): Promise<{ txid: string }> {
2214
+ const manifestKey = eventSourceSubscriptionManifestKey(req.id)
2215
+ const txid = randomUUID()
2216
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, undefined, {
2217
+ txid,
2218
+ })
2219
+
2220
+ await this.wakeRegistry.unregisterByManifestKey(
2221
+ entityUrl,
2222
+ manifestKey,
2223
+ this.tenantId
2224
+ )
2225
+
2226
+ return { txid }
2227
+ }
2228
+
2101
2229
  // ==========================================================================
2102
2230
  // Wake Evaluation
2103
2231
  // ==========================================================================
@@ -2354,37 +2482,194 @@ export class EntityManager {
2354
2482
  }
2355
2483
 
2356
2484
  // ==========================================================================
2357
- // Kill
2485
+ // Signals
2358
2486
  // ==========================================================================
2359
2487
 
2360
- async kill(entityUrl: string): Promise<{ txid: number }> {
2488
+ async signal(entityUrl: string, req: SignalRequest): Promise<SignalResponse> {
2361
2489
  const entity = await this.registry.getEntity(entityUrl)
2362
2490
  if (!entity) {
2363
2491
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2364
2492
  }
2365
2493
 
2366
- await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId)
2367
- await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId)
2494
+ if (isTerminalEntityStatus(entity.status)) {
2495
+ throw new ElectricAgentsError(
2496
+ ErrCodeInvalidSignal,
2497
+ `Cannot signal a ${entity.status} entity`,
2498
+ 409
2499
+ )
2500
+ }
2368
2501
 
2369
- const txid = await this.registry.updateStatusWithTxid(entityUrl, `stopped`)
2370
- if (this.entityBridgeManager) {
2371
- await this.entityBridgeManager.onEntityChanged(entityUrl)
2502
+ const now = new Date()
2503
+ const previousState = entity.status
2504
+ const handling = this.serverHandlingForSignal(previousState, req.signal)
2505
+ const txid =
2506
+ handling.status === previousState
2507
+ ? await this.registry.touchEntityWithTxid(entityUrl)
2508
+ : await this.registry.updateStatusWithTxid(entityUrl, handling.status)
2509
+ if (txid === null) {
2510
+ throw new ElectricAgentsError(
2511
+ ErrCodeInvalidSignal,
2512
+ `Cannot signal entity because it is already terminal`,
2513
+ 409
2514
+ )
2372
2515
  }
2373
2516
 
2374
- // Append entity_stopped to main/error streams and close them.
2375
- const stoppedEvent = entityStateSchema.entityStopped.insert({
2376
- key: `stopped`,
2377
- value: {
2378
- timestamp: new Date().toISOString(),
2517
+ const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`
2518
+ const signalValue: ServerSignalValue = {
2519
+ signal: req.signal,
2520
+ status: handling.handled ? `handled` : `unhandled`,
2521
+ sender: SERVER_SIGNAL_SENDER,
2522
+ timestamp: now.toISOString(),
2523
+ }
2524
+ if (req.reason !== undefined) signalValue.reason = req.reason
2525
+ if (req.payload !== undefined) signalValue.payload = req.payload
2526
+ if (handling.handled) {
2527
+ signalValue.handled_at = now.toISOString()
2528
+ signalValue.handled_by = SERVER_SIGNAL_SENDER
2529
+ signalValue.outcome = handling.outcome
2530
+ signalValue.previous_state = previousState
2531
+ signalValue.new_state = handling.status
2532
+ }
2533
+
2534
+ const signalEvent: ServerSignalEvent = {
2535
+ type: `signal`,
2536
+ key,
2537
+ value: signalValue,
2538
+ headers: {
2539
+ operation: `insert`,
2540
+ timestamp: now.toISOString(),
2541
+ txid: String(txid),
2379
2542
  },
2380
- } as any)
2381
- const eofData = this.encodeChangeEvent(
2382
- stoppedEvent as Record<string, unknown>
2543
+ }
2544
+
2545
+ const shouldCloseStreams = isTerminalEntityStatus(handling.status)
2546
+ await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams)
2547
+ if (!shouldCloseStreams) {
2548
+ await this.evaluateWakes(
2549
+ entityUrl,
2550
+ signalEvent as unknown as Record<string, unknown>
2551
+ )
2552
+ }
2553
+
2554
+ if (handling.unregisterWakes) {
2555
+ await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId)
2556
+ await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId)
2557
+ }
2558
+
2559
+ if (handling.status !== previousState && this.entityBridgeManager) {
2560
+ await this.entityBridgeManager.onEntityChanged(entityUrl)
2561
+ }
2562
+
2563
+ return {
2564
+ url: entityUrl,
2565
+ signal: req.signal,
2566
+ previous_state: previousState,
2567
+ new_state: handling.status,
2568
+ created_at: now.getTime(),
2569
+ txid,
2570
+ }
2571
+ }
2572
+
2573
+ async kill(entityUrl: string): Promise<{ txid: number }> {
2574
+ const response = await this.signal(entityUrl, {
2575
+ signal: `SIGKILL`,
2576
+ reason: `Legacy kill command`,
2577
+ })
2578
+ return { txid: response.txid }
2579
+ }
2580
+
2581
+ private serverHandlingForSignal(
2582
+ status: ElectricAgentsEntity[`status`],
2583
+ signal: EntitySignal
2584
+ ): ServerSignalHandling {
2585
+ if (signal === `SIGKILL`) {
2586
+ return {
2587
+ status: `killed`,
2588
+ handled: true,
2589
+ outcome: `transitioned`,
2590
+ unregisterWakes: true,
2591
+ }
2592
+ }
2593
+ if (signal === `SIGTERM`) {
2594
+ if (status === `idle` || status === `paused`) {
2595
+ return {
2596
+ status: `stopped`,
2597
+ handled: true,
2598
+ outcome: `transitioned`,
2599
+ unregisterWakes: true,
2600
+ }
2601
+ }
2602
+ if (status === `running`) {
2603
+ return {
2604
+ status: `stopping`,
2605
+ handled: false,
2606
+ outcome: `transitioned`,
2607
+ unregisterWakes: false,
2608
+ }
2609
+ }
2610
+ }
2611
+ if (status === `paused` && signal !== `SIGCONT`) {
2612
+ return {
2613
+ status,
2614
+ handled: true,
2615
+ outcome: `ignored`,
2616
+ unregisterWakes: false,
2617
+ }
2618
+ }
2619
+ if (signal === `SIGSTOP` && (status === `idle` || status === `running`)) {
2620
+ return {
2621
+ status: `paused`,
2622
+ handled: status === `idle`,
2623
+ outcome: `transitioned`,
2624
+ unregisterWakes: false,
2625
+ }
2626
+ }
2627
+ if (signal === `SIGCONT` && status === `paused`) {
2628
+ return {
2629
+ status: `idle`,
2630
+ handled: false,
2631
+ outcome: `transitioned`,
2632
+ unregisterWakes: false,
2633
+ }
2634
+ }
2635
+
2636
+ return {
2637
+ status,
2638
+ handled: false,
2639
+ outcome: `ignored`,
2640
+ unregisterWakes: false,
2641
+ }
2642
+ }
2643
+
2644
+ private async appendSignalEvent(
2645
+ entity: ElectricAgentsEntity,
2646
+ signalEvent: ServerSignalEvent,
2647
+ closeStreams: boolean
2648
+ ): Promise<void> {
2649
+ const signalData = this.encodeChangeEvent(
2650
+ signalEvent as unknown as Record<string, unknown>
2383
2651
  )
2652
+ if (!closeStreams) {
2653
+ await this.streamClient.append(entity.streams.main, signalData)
2654
+ return
2655
+ }
2384
2656
 
2385
- for (const streamPath of [entity.streams.main, entity.streams.error]) {
2657
+ const errorCloseEvent = {
2658
+ type: `signal`,
2659
+ key: signalEvent.key,
2660
+ value: signalEvent.value,
2661
+ headers: signalEvent.headers,
2662
+ }
2663
+ const errorSignalData = this.encodeChangeEvent(
2664
+ errorCloseEvent as unknown as Record<string, unknown>
2665
+ )
2666
+
2667
+ for (const [streamPath, data] of [
2668
+ [entity.streams.main, signalData],
2669
+ [entity.streams.error, errorSignalData],
2670
+ ] as const) {
2386
2671
  try {
2387
- await this.streamClient.append(streamPath, eofData, { close: true })
2672
+ await this.streamClient.append(streamPath, data, { close: true })
2388
2673
  } catch (err) {
2389
2674
  const message = err instanceof Error ? err.message : String(err)
2390
2675
  if (
@@ -2398,8 +2683,6 @@ export class EntityManager {
2398
2683
  throw err
2399
2684
  }
2400
2685
  }
2401
-
2402
- return { txid }
2403
2686
  }
2404
2687
 
2405
2688
  // ==========================================================================
@@ -2627,8 +2910,12 @@ export class EntityManager {
2627
2910
  if (!entity) {
2628
2911
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2629
2912
  }
2630
- if (entity.status === `stopped`) {
2631
- throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409)
2913
+ if (rejectsNormalWrites(entity.status)) {
2914
+ throw new ElectricAgentsError(
2915
+ ErrCodeNotRunning,
2916
+ `Entity is not accepting writes`,
2917
+ 409
2918
+ )
2632
2919
  }
2633
2920
 
2634
2921
  if (req.type && entity.type) {