@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/entrypoint.js +314 -38
- package/dist/index.cjs +332 -37
- package/dist/index.d.cts +72 -5
- package/dist/index.d.ts +72 -5
- package/dist/index.js +328 -39
- package/drizzle/0009_entity_signal_statuses.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +1 -1
- package/src/electric-agents-types.ts +77 -1
- package/src/entity-manager.ts +320 -33
- package/src/entity-registry.ts +40 -16
- package/src/index.ts +29 -1
- package/src/manifest-side-effects.ts +11 -0
- package/src/routing/context.ts +18 -1
- package/src/routing/dispatch-policy.ts +6 -0
- package/src/routing/entities-router.ts +187 -1
- package/src/routing/internal-router.ts +25 -4
- package/src/routing/runners-router.ts +1 -1
- package/src/runtime.ts +5 -1
- package/src/server.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents-server",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
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.
|
|
69
|
-
"@electric-ax/agents-server-conformance-tests": "0.1.
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.4.
|
|
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 =
|
|
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`
|
package/src/entity-manager.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|
978
|
+
`Cannot fork terminal entity "${stopped.url}"`,
|
|
936
979
|
409
|
|
937
980
|
)
|
|
938
981
|
}
|
|
939
982
|
|
|
940
|
-
let active = subtree.filter(
|
|
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
|
|
1729
|
-
throw new ElectricAgentsError(
|
|
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
|
|
1767
|
-
throw new ElectricAgentsError(
|
|
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
|
|
1800
|
-
throw new ElectricAgentsError(
|
|
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
|
|
1846
|
-
throw new ElectricAgentsError(
|
|
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
|
-
//
|
|
2485
|
+
// Signals
|
|
2358
2486
|
// ==========================================================================
|
|
2359
2487
|
|
|
2360
|
-
async
|
|
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
|
-
|
|
2367
|
-
|
|
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
|
|
2370
|
-
|
|
2371
|
-
|
|
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
|
-
|
|
2375
|
-
const
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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
|
-
}
|
|
2381
|
-
|
|
2382
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
2631
|
-
throw new ElectricAgentsError(
|
|
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) {
|