@electric-ax/agents-server 0.4.13 → 0.4.15
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 +394 -51
- package/dist/index.cjs +396 -51
- package/dist/index.d.cts +332 -164
- package/dist/index.d.ts +332 -164
- package/dist/index.js +396 -51
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +2 -0
- package/src/electric-agents-types.ts +71 -0
- package/src/entity-manager.ts +337 -6
- package/src/entity-projector.ts +3 -0
- package/src/entity-registry.ts +73 -0
- package/src/routing/entities-router.ts +18 -0
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/log.ts +63 -52
- package/src/utils/server-utils.ts +2 -2
- package/src/wake-registry.ts +22 -11
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.15",
|
|
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": "^0.2.6",
|
|
40
40
|
"@durable-streams/server": "^0.3.5",
|
|
41
41
|
"@durable-streams/state": "^0.2.9",
|
|
42
|
-
"@electric-sql/client": "^1.5.
|
|
42
|
+
"@electric-sql/client": "^1.5.20",
|
|
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.8"
|
|
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.12",
|
|
69
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.10",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.15"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/db/schema.ts
CHANGED
|
@@ -49,6 +49,7 @@ export const entities = pgTable(
|
|
|
49
49
|
.notNull()
|
|
50
50
|
.default(sql`'{}'::text[]`),
|
|
51
51
|
spawnArgs: jsonb(`spawn_args`).default({}),
|
|
52
|
+
sandbox: jsonb(`sandbox`),
|
|
52
53
|
parent: text(`parent`),
|
|
53
54
|
createdBy: text(`created_by`),
|
|
54
55
|
typeRevision: integer(`type_revision`),
|
|
@@ -111,6 +112,7 @@ export const runners = pgTable(
|
|
|
111
112
|
kind: text(`kind`).notNull().default(`local`),
|
|
112
113
|
adminStatus: text(`admin_status`).notNull().default(`enabled`),
|
|
113
114
|
wakeStream: text(`wake_stream`).notNull(),
|
|
115
|
+
sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
|
|
114
116
|
createdAt: timestamp(`created_at`, { withTimezone: true })
|
|
115
117
|
.notNull()
|
|
116
118
|
.defaultNow(),
|
|
@@ -130,6 +130,19 @@ export interface RunnerActiveClaim {
|
|
|
130
130
|
leaseExpiresAt?: string
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export interface SandboxProfileAdvertisement {
|
|
134
|
+
name: string
|
|
135
|
+
label: string
|
|
136
|
+
description?: string
|
|
137
|
+
/**
|
|
138
|
+
* True for off-host (remote-provider) profiles, reachable from any runner.
|
|
139
|
+
* Absent/false means the sandbox is host-local, so a shared sandbox on this
|
|
140
|
+
* profile requires its collaborators to be pinned to a single runner. Set
|
|
141
|
+
* by the runtime per profile (see SandboxProfile.remote).
|
|
142
|
+
*/
|
|
143
|
+
remote?: boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
export interface ElectricAgentsRunner {
|
|
134
147
|
id: string
|
|
135
148
|
owner_principal: string
|
|
@@ -143,6 +156,7 @@ export interface ElectricAgentsRunner {
|
|
|
143
156
|
wake_stream: string
|
|
144
157
|
wake_stream_offset?: string
|
|
145
158
|
diagnostics?: Record<string, unknown>
|
|
159
|
+
sandbox_profiles: Array<SandboxProfileAdvertisement>
|
|
146
160
|
created_at: string
|
|
147
161
|
updated_at: string
|
|
148
162
|
}
|
|
@@ -295,6 +309,47 @@ export function expectedSignalStatus(
|
|
|
295
309
|
}
|
|
296
310
|
}
|
|
297
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Resolved sandbox selection stored on an entity and replayed to the runtime at
|
|
314
|
+
* wake. Only an explicit / inherited cross-entity `key` is persisted here;
|
|
315
|
+
* `scope`-derived keys are computed at wake time (and so left unstored, keeping
|
|
316
|
+
* the co-location guard keyed on genuine cross-entity sharing). `persistent`
|
|
317
|
+
* defaults by scope at wake time when unset.
|
|
318
|
+
*/
|
|
319
|
+
export interface EntitySandboxSelection {
|
|
320
|
+
profile: string
|
|
321
|
+
key?: string
|
|
322
|
+
scope?: `entity` | `wake`
|
|
323
|
+
persistent?: boolean
|
|
324
|
+
/**
|
|
325
|
+
* Whether the entity owns the sandbox (create + govern teardown) or only
|
|
326
|
+
* attaches to an owner's. Stored as `false` for an attacher (e.g. an
|
|
327
|
+
* `inherit` spawn); omitted ⇒ owner (the default).
|
|
328
|
+
*/
|
|
329
|
+
owner?: boolean
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Spawn-time sandbox CHOICE — the request input, before resolution. Resolved
|
|
334
|
+
* into an {@link EntitySandboxSelection} by the spawn path. The wire schema for
|
|
335
|
+
* this shape lives in `sandbox-choice-schema.ts` (mirrors how `DispatchPolicy`
|
|
336
|
+
* pairs with `dispatch-policy-schema.ts`).
|
|
337
|
+
*/
|
|
338
|
+
export interface SandboxChoice {
|
|
339
|
+
/** Profile name advertised by the target runner. */
|
|
340
|
+
profile?: string
|
|
341
|
+
/** Explicit cross-entity key to join (or start) a shared sandbox. */
|
|
342
|
+
key?: string
|
|
343
|
+
/** Identity scope when no explicit `key`: per-entity (default) or per-wake. */
|
|
344
|
+
scope?: `entity` | `wake`
|
|
345
|
+
/** Idle-teardown durability; defaults by scope when unset. */
|
|
346
|
+
persistent?: boolean
|
|
347
|
+
/** Whether this entity owns the sandbox (default) or only attaches to one. */
|
|
348
|
+
owner?: boolean
|
|
349
|
+
/** Reuse the parent entity's resolved sandbox (attach-only). */
|
|
350
|
+
inherit?: boolean
|
|
351
|
+
}
|
|
352
|
+
|
|
298
353
|
export interface ElectricAgentsEntity {
|
|
299
354
|
url: string
|
|
300
355
|
type: string
|
|
@@ -308,6 +363,14 @@ export interface ElectricAgentsEntity {
|
|
|
308
363
|
write_token: string
|
|
309
364
|
tags: Record<string, string>
|
|
310
365
|
spawn_args?: Record<string, unknown>
|
|
366
|
+
/**
|
|
367
|
+
* Resolved sandbox selection. An explicit `key` lets entities collaborate on
|
|
368
|
+
* one workspace and is the only key form persisted (it's cross-entity, so the
|
|
369
|
+
* co-location guard applies); a `scope` ('entity' default / 'wake') instead
|
|
370
|
+
* derives the key at wake time, so it's left unstored. `persistent` chooses
|
|
371
|
+
* idle durability.
|
|
372
|
+
*/
|
|
373
|
+
sandbox?: EntitySandboxSelection
|
|
311
374
|
parent?: string
|
|
312
375
|
type_revision?: number
|
|
313
376
|
inbox_schemas?: Record<string, Record<string, unknown>>
|
|
@@ -326,6 +389,7 @@ export interface PublicElectricAgentsEntity {
|
|
|
326
389
|
dispatch_policy?: DispatchPolicy
|
|
327
390
|
tags: Record<string, string>
|
|
328
391
|
spawn_args?: Record<string, unknown>
|
|
392
|
+
sandbox?: EntitySandboxSelection
|
|
329
393
|
parent?: string
|
|
330
394
|
created_by?: string
|
|
331
395
|
created_at: number
|
|
@@ -350,6 +414,7 @@ export function toPublicEntity(
|
|
|
350
414
|
dispatch_policy: entity.dispatch_policy,
|
|
351
415
|
tags: entity.tags,
|
|
352
416
|
spawn_args: entity.spawn_args,
|
|
417
|
+
sandbox: entity.sandbox,
|
|
353
418
|
parent: entity.parent,
|
|
354
419
|
created_by: entity.created_by,
|
|
355
420
|
created_at: entity.created_at,
|
|
@@ -386,6 +451,12 @@ export interface TypedSpawnRequest {
|
|
|
386
451
|
tags?: Record<string, string>
|
|
387
452
|
parent?: string
|
|
388
453
|
dispatch_policy?: DispatchPolicy
|
|
454
|
+
/**
|
|
455
|
+
* Sandbox selection: `profile` for a sandbox (optionally with `scope` /
|
|
456
|
+
* `persistent`), `key` to join (or start) an explicit shared one, or
|
|
457
|
+
* `inherit: true` to reuse the parent's resolved sandbox.
|
|
458
|
+
*/
|
|
459
|
+
sandbox?: SandboxChoice
|
|
389
460
|
initialMessage?: unknown
|
|
390
461
|
created_by?: string
|
|
391
462
|
wake?: {
|
package/src/entity-manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
manifestSourceKey,
|
|
13
13
|
resolveCronScheduleSpec,
|
|
14
14
|
} from '@electric-ax/agents-runtime'
|
|
15
|
+
import type { EventPointer } from '@electric-ax/agents-runtime'
|
|
15
16
|
import {
|
|
16
17
|
ErrCodeDuplicateURL,
|
|
17
18
|
ErrCodeEntityPersistFailed,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
} from './electric-agents-types.js'
|
|
33
34
|
import { parseDispatchPolicy } from './dispatch-policy-schema.js'
|
|
34
35
|
import { applyTypeDefaultSubscriptionScope } from './routing/dispatch-policy.js'
|
|
36
|
+
import { resolveSandboxForSpawn } from './routing/sandbox.js'
|
|
35
37
|
import {
|
|
36
38
|
isBuiltInSystemPrincipalUrl,
|
|
37
39
|
principalFromCreatedBy,
|
|
@@ -161,6 +163,16 @@ type ForkSubtreeOptions = {
|
|
|
161
163
|
rootInstanceId?: string
|
|
162
164
|
waitTimeoutMs?: number
|
|
163
165
|
waitPollMs?: number
|
|
166
|
+
/**
|
|
167
|
+
* Optional anchor pointing at an event on the source root's `main` stream.
|
|
168
|
+
* When set: only events at or before the pointer are kept on the root's
|
|
169
|
+
* forked `main`, and the root's manifest is filtered so that descendants
|
|
170
|
+
* spawned after the pointer are dropped from the fork (their now-orphan
|
|
171
|
+
* subtrees are not forked). The pointer applies only to the root's
|
|
172
|
+
* `main` stream — `error` and shared-state streams clone at HEAD
|
|
173
|
+
* regardless.
|
|
174
|
+
*/
|
|
175
|
+
forkPointer?: EventPointer
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
type ForkEntityPlan = {
|
|
@@ -650,6 +662,13 @@ export class EntityManager {
|
|
|
650
662
|
)
|
|
651
663
|
: entityType.default_dispatch_policy
|
|
652
664
|
|
|
665
|
+
const sandbox = await resolveSandboxForSpawn(
|
|
666
|
+
this.registry,
|
|
667
|
+
dispatchPolicy,
|
|
668
|
+
req.sandbox,
|
|
669
|
+
parentEntity
|
|
670
|
+
)
|
|
671
|
+
|
|
653
672
|
const now = Date.now()
|
|
654
673
|
const entityData: ElectricAgentsEntity = {
|
|
655
674
|
type: typeName,
|
|
@@ -664,6 +683,7 @@ export class EntityManager {
|
|
|
664
683
|
write_token: writeToken,
|
|
665
684
|
tags: initialTags,
|
|
666
685
|
spawn_args: req.args,
|
|
686
|
+
sandbox,
|
|
667
687
|
type_revision: entityType.revision,
|
|
668
688
|
inbox_schemas: entityType.inbox_schemas,
|
|
669
689
|
state_schemas: entityType.state_schemas,
|
|
@@ -873,7 +893,36 @@ export class EntityManager {
|
|
|
873
893
|
const writeStreamLocks = new Set<string>()
|
|
874
894
|
|
|
875
895
|
try {
|
|
876
|
-
|
|
896
|
+
// For pointer-forks we read the source root HISTORICALLY at a
|
|
897
|
+
// frozen offset, so concurrent activity on the root past the
|
|
898
|
+
// pointer can't tear our snapshot — we don't need to wait for
|
|
899
|
+
// the root to be idle (which would block the "click fork right
|
|
900
|
+
// after the response landed" case, since the runtime keeps the
|
|
901
|
+
// worker warm for `idleTimeout`). We still wait+lock any kept
|
|
902
|
+
// descendants below (after `computeEffectiveSubtree` runs), since
|
|
903
|
+
// those are HEAD-cloned and need a stable snapshot. For HEAD-forks
|
|
904
|
+
// the old all-idle requirement still applies.
|
|
905
|
+
let sourceTree: Array<ElectricAgentsEntity>
|
|
906
|
+
if (opts.forkPointer) {
|
|
907
|
+
const rootEntity = await this.registry.getEntity(rootUrl)
|
|
908
|
+
if (!rootEntity) {
|
|
909
|
+
throw new ElectricAgentsError(
|
|
910
|
+
ErrCodeNotFound,
|
|
911
|
+
`Entity not found`,
|
|
912
|
+
404
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
if (isTerminalEntityStatus(rootEntity.status)) {
|
|
916
|
+
throw new ElectricAgentsError(
|
|
917
|
+
ErrCodeNotRunning,
|
|
918
|
+
`Cannot fork terminal entity "${rootEntity.url}"`,
|
|
919
|
+
409
|
|
920
|
+
)
|
|
921
|
+
}
|
|
922
|
+
sourceTree = await this.listEntitySubtree(rootEntity)
|
|
923
|
+
} else {
|
|
924
|
+
sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks)
|
|
925
|
+
}
|
|
877
926
|
const sourceRoot = sourceTree[0]!
|
|
878
927
|
if (sourceRoot.parent) {
|
|
879
928
|
throw new ElectricAgentsError(
|
|
@@ -883,9 +932,107 @@ export class EntityManager {
|
|
|
883
932
|
)
|
|
884
933
|
}
|
|
885
934
|
|
|
886
|
-
|
|
935
|
+
// When forking at a pointer, pre-read the root's main, validate the
|
|
936
|
+
// pointer against the source's true history, and materialise the
|
|
937
|
+
// root-at-pointer snapshot fragments. The pointer only applies to
|
|
938
|
+
// the root's `main` stream. Descendants kept by the manifest filter
|
|
939
|
+
// are forked at HEAD.
|
|
940
|
+
//
|
|
941
|
+
// Pointer→position translation: the runtime mints pointers as
|
|
942
|
+
// `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
|
|
943
|
+
// the anchor offset is the END of the delivery batch that
|
|
944
|
+
// PRECEDED the targeted event. The durable-streams server
|
|
945
|
+
// interprets `{ X, N }` as "from offset X, take N flattened
|
|
946
|
+
// messages forward" — independent of how delivery is chunked. We
|
|
947
|
+
// mirror that interpretation here by translating the pointer to a
|
|
948
|
+
// 1-indexed CUMULATIVE position in the source's flattened
|
|
949
|
+
// history, then filtering events with position ≤ that target.
|
|
950
|
+
let preFilteredRoot:
|
|
951
|
+
| {
|
|
952
|
+
manifests: Map<string, Record<string, unknown>>
|
|
953
|
+
childStatuses: Map<string, Record<string, unknown>>
|
|
954
|
+
replayWatermarks: Map<string, Record<string, unknown>>
|
|
955
|
+
sharedStateIds: Set<string>
|
|
956
|
+
}
|
|
957
|
+
| undefined
|
|
958
|
+
if (opts.forkPointer) {
|
|
959
|
+
const sourceEvents = await this.streamClient.readJson<
|
|
960
|
+
Record<string, unknown>
|
|
961
|
+
>(sourceRoot.streams.main)
|
|
962
|
+
const flat = sourceEvents.flatMap((item) =>
|
|
963
|
+
Array.isArray(item) ? item : [item]
|
|
964
|
+
) as Array<Record<string, unknown>>
|
|
965
|
+
const target = this.resolveForkPointerTarget(
|
|
966
|
+
flat,
|
|
967
|
+
opts.forkPointer,
|
|
968
|
+
sourceRoot.streams.main
|
|
969
|
+
)
|
|
970
|
+
const filteredEvents = flat.slice(0, target)
|
|
971
|
+
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`)
|
|
972
|
+
const sharedStateIds = new Set<string>()
|
|
973
|
+
for (const manifest of rootManifests.values()) {
|
|
974
|
+
this.collectSharedStateIds(manifest, sharedStateIds)
|
|
975
|
+
}
|
|
976
|
+
preFilteredRoot = {
|
|
977
|
+
manifests: rootManifests,
|
|
978
|
+
childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
|
|
979
|
+
replayWatermarks: this.reduceStateRows(
|
|
980
|
+
filteredEvents,
|
|
981
|
+
`replay_watermark`
|
|
982
|
+
),
|
|
983
|
+
sharedStateIds,
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const effectiveSubtree = preFilteredRoot
|
|
988
|
+
? this.computeEffectiveSubtree(
|
|
989
|
+
sourceTree,
|
|
990
|
+
sourceRoot.url,
|
|
991
|
+
preFilteredRoot.manifests
|
|
992
|
+
)
|
|
993
|
+
: sourceTree
|
|
994
|
+
|
|
995
|
+
// For pointer-forks, kept descendants (everything in the effective
|
|
996
|
+
// subtree except the root) are HEAD-cloned, so they must be idle
|
|
997
|
+
// before we read their snapshots. Wait+lock only those — the root
|
|
998
|
+
// was skipped above.
|
|
999
|
+
if (opts.forkPointer) {
|
|
1000
|
+
const descendants = effectiveSubtree.filter(
|
|
1001
|
+
(entity) => entity.url !== sourceRoot.url
|
|
1002
|
+
)
|
|
1003
|
+
if (descendants.length > 0) {
|
|
1004
|
+
await this.waitForGivenEntitiesIdle(descendants, opts, workLocks)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const snapshot = await this.readForkStateSnapshot(
|
|
1009
|
+
// Skip the root when we've already pre-filtered it — avoid both a
|
|
1010
|
+
// wasted HEAD read of main and a re-population that would clobber
|
|
1011
|
+
// the filtered entries.
|
|
1012
|
+
preFilteredRoot
|
|
1013
|
+
? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url)
|
|
1014
|
+
: effectiveSubtree
|
|
1015
|
+
)
|
|
1016
|
+
if (preFilteredRoot) {
|
|
1017
|
+
snapshot.manifestsByEntity.set(
|
|
1018
|
+
sourceRoot.url,
|
|
1019
|
+
preFilteredRoot.manifests
|
|
1020
|
+
)
|
|
1021
|
+
snapshot.childStatusesByEntity.set(
|
|
1022
|
+
sourceRoot.url,
|
|
1023
|
+
preFilteredRoot.childStatuses
|
|
1024
|
+
)
|
|
1025
|
+
snapshot.replayWatermarksByEntity.set(
|
|
1026
|
+
sourceRoot.url,
|
|
1027
|
+
preFilteredRoot.replayWatermarks
|
|
1028
|
+
)
|
|
1029
|
+
for (const id of preFilteredRoot.sharedStateIds) {
|
|
1030
|
+
snapshot.sharedStateIds.add(id)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
887
1034
|
const suffix = randomUUID().slice(0, 8)
|
|
888
|
-
const entityUrlMap = await this.buildForkEntityUrlMap(
|
|
1035
|
+
const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
|
|
889
1036
|
suffix,
|
|
890
1037
|
rootUrl,
|
|
891
1038
|
rootInstanceId: opts.rootInstanceId,
|
|
@@ -896,14 +1043,14 @@ export class EntityManager {
|
|
|
896
1043
|
)
|
|
897
1044
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap)
|
|
898
1045
|
const entityPlans = this.buildForkEntityPlans(
|
|
899
|
-
|
|
1046
|
+
effectiveSubtree,
|
|
900
1047
|
entityUrlMap,
|
|
901
1048
|
stringMap
|
|
902
1049
|
)
|
|
903
1050
|
|
|
904
1051
|
this.addForkLocks(
|
|
905
1052
|
this.forkWriteLockedEntities,
|
|
906
|
-
|
|
1053
|
+
effectiveSubtree.map((entity) => entity.url),
|
|
907
1054
|
writeEntityLocks
|
|
908
1055
|
)
|
|
909
1056
|
this.addForkLocks(
|
|
@@ -921,11 +1068,17 @@ export class EntityManager {
|
|
|
921
1068
|
|
|
922
1069
|
try {
|
|
923
1070
|
for (const plan of entityPlans) {
|
|
1071
|
+
const isRoot = plan.source.url === rootUrl
|
|
924
1072
|
await this.streamClient.fork(
|
|
925
1073
|
plan.fork.streams.main,
|
|
926
|
-
plan.source.streams.main
|
|
1074
|
+
plan.source.streams.main,
|
|
1075
|
+
isRoot && opts.forkPointer
|
|
1076
|
+
? { forkPointer: opts.forkPointer }
|
|
1077
|
+
: undefined
|
|
927
1078
|
)
|
|
928
1079
|
createdStreams.push(plan.fork.streams.main)
|
|
1080
|
+
// `error` always clones at HEAD — no canonical mapping
|
|
1081
|
+
// between main-offset and error-offset.
|
|
929
1082
|
await this.streamClient.fork(
|
|
930
1083
|
plan.fork.streams.error,
|
|
931
1084
|
plan.source.streams.error
|
|
@@ -1081,6 +1234,73 @@ export class EntityManager {
|
|
|
1081
1234
|
held.clear()
|
|
1082
1235
|
}
|
|
1083
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* Variant of {@link waitForIdleSubtree} that takes an explicit entity
|
|
1239
|
+
* list instead of walking the registry from `rootUrl`. Used by the
|
|
1240
|
+
* pointer-fork path to wait+lock only the kept descendants, since
|
|
1241
|
+
* the root is being forked from history and doesn't need to be idle.
|
|
1242
|
+
*/
|
|
1243
|
+
private async waitForGivenEntitiesIdle(
|
|
1244
|
+
entities: ReadonlyArray<ElectricAgentsEntity>,
|
|
1245
|
+
opts: ForkSubtreeOptions,
|
|
1246
|
+
workLocks: Set<string>
|
|
1247
|
+
): Promise<void> {
|
|
1248
|
+
if (entities.length === 0) return
|
|
1249
|
+
|
|
1250
|
+
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS
|
|
1251
|
+
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS
|
|
1252
|
+
|
|
1253
|
+
const refresh = async (): Promise<Array<ElectricAgentsEntity>> => {
|
|
1254
|
+
const refreshed = await Promise.all(
|
|
1255
|
+
entities.map((entity) => this.registry.getEntity(entity.url))
|
|
1256
|
+
)
|
|
1257
|
+
return refreshed.filter(
|
|
1258
|
+
(entity): entity is ElectricAgentsEntity => !!entity
|
|
1259
|
+
)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const deadline = Date.now() + timeoutMs
|
|
1263
|
+
while (true) {
|
|
1264
|
+
const present = await refresh()
|
|
1265
|
+
const stopped = present.find((entity) =>
|
|
1266
|
+
isTerminalEntityStatus(entity.status)
|
|
1267
|
+
)
|
|
1268
|
+
if (stopped) {
|
|
1269
|
+
throw new ElectricAgentsError(
|
|
1270
|
+
ErrCodeNotRunning,
|
|
1271
|
+
`Cannot fork terminal entity "${stopped.url}"`,
|
|
1272
|
+
409
|
|
1273
|
+
)
|
|
1274
|
+
}
|
|
1275
|
+
let active = present.filter(
|
|
1276
|
+
(entity) => entity.status !== `idle` && entity.status !== `paused`
|
|
1277
|
+
)
|
|
1278
|
+
if (active.length === 0) {
|
|
1279
|
+
this.addForkLocks(
|
|
1280
|
+
this.forkWorkLockedEntities,
|
|
1281
|
+
present.map((entity) => entity.url),
|
|
1282
|
+
workLocks
|
|
1283
|
+
)
|
|
1284
|
+
const reChecked = await refresh()
|
|
1285
|
+
const reActive = reChecked.filter(
|
|
1286
|
+
(entity) => entity.status !== `idle` && entity.status !== `paused`
|
|
1287
|
+
)
|
|
1288
|
+
if (reActive.length === 0) return
|
|
1289
|
+
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks)
|
|
1290
|
+
active = reActive
|
|
1291
|
+
}
|
|
1292
|
+
if (Date.now() >= deadline) {
|
|
1293
|
+
throw new ElectricAgentsError(
|
|
1294
|
+
ErrCodeForkWaitTimeout,
|
|
1295
|
+
`Timed out waiting for descendants to become idle`,
|
|
1296
|
+
409,
|
|
1297
|
+
{ active: active.map((entity) => entity.url) }
|
|
1298
|
+
)
|
|
1299
|
+
}
|
|
1300
|
+
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())))
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1084
1304
|
private async waitForIdleSubtree(
|
|
1085
1305
|
rootUrl: string,
|
|
1086
1306
|
opts: ForkSubtreeOptions,
|
|
@@ -1175,6 +1395,116 @@ export class EntityManager {
|
|
|
1175
1395
|
}
|
|
1176
1396
|
}
|
|
1177
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
|
|
1400
|
+
* source's flattened history. Throws a 400 if the pointer doesn't
|
|
1401
|
+
* address a real event.
|
|
1402
|
+
*
|
|
1403
|
+
* Semantics (mirroring the durable-streams server interpretation):
|
|
1404
|
+
* `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
|
|
1405
|
+
* messages forward." Concretely, the target event is the N-th event
|
|
1406
|
+
* after the last event whose `headers.offset` is ≤ X. (When `X` is
|
|
1407
|
+
* `null`, the anchor is the stream start and the target is the N-th
|
|
1408
|
+
* event from the very beginning.) The returned position is the count
|
|
1409
|
+
* of events to KEEP — events 1..position survive the filter.
|
|
1410
|
+
*
|
|
1411
|
+
* A pointer is valid when:
|
|
1412
|
+
* - `pointer.offset` is `null` (stream start) OR matches some
|
|
1413
|
+
* event's `headers.offset` value, AND
|
|
1414
|
+
* - `pointer.subOffset` is in `[1, total events past the anchor]`.
|
|
1415
|
+
*/
|
|
1416
|
+
private resolveForkPointerTarget(
|
|
1417
|
+
events: ReadonlyArray<Record<string, unknown>>,
|
|
1418
|
+
pointer: EventPointer,
|
|
1419
|
+
streamPath: string
|
|
1420
|
+
): number {
|
|
1421
|
+
// Count events at-or-before the anchor and validate the anchor exists.
|
|
1422
|
+
// `pointer.offset === null` is the stream-start anchor — no events
|
|
1423
|
+
// precede it, so `positionAtAnchor` stays at 0.
|
|
1424
|
+
let positionAtAnchor = 0
|
|
1425
|
+
let anchorSeen = pointer.offset === null
|
|
1426
|
+
for (const event of events) {
|
|
1427
|
+
const headers = isRecord(event.headers) ? event.headers : undefined
|
|
1428
|
+
const eventOffset =
|
|
1429
|
+
typeof headers?.offset === `string` ? headers.offset : undefined
|
|
1430
|
+
if (eventOffset === undefined) continue
|
|
1431
|
+
if (pointer.offset === null) continue
|
|
1432
|
+
if (eventOffset === pointer.offset) anchorSeen = true
|
|
1433
|
+
if (eventOffset <= pointer.offset) positionAtAnchor++
|
|
1434
|
+
}
|
|
1435
|
+
if (!anchorSeen) {
|
|
1436
|
+
throw new ElectricAgentsError(
|
|
1437
|
+
ErrCodeInvalidRequest,
|
|
1438
|
+
`fork_pointer.offset (${pointer.offset ?? `<stream-start>`}) does not match any event's Stream-Next-Offset on ${streamPath}`,
|
|
1439
|
+
400
|
|
1440
|
+
)
|
|
1441
|
+
}
|
|
1442
|
+
const eventsPastAnchor = events.length - positionAtAnchor
|
|
1443
|
+
if (pointer.subOffset < 1 || pointer.subOffset > eventsPastAnchor) {
|
|
1444
|
+
throw new ElectricAgentsError(
|
|
1445
|
+
ErrCodeInvalidRequest,
|
|
1446
|
+
`fork_pointer.sub_offset ${pointer.subOffset} out of range past anchor on ${streamPath} (valid: 1..${eventsPastAnchor})`,
|
|
1447
|
+
400
|
|
1448
|
+
)
|
|
1449
|
+
}
|
|
1450
|
+
return positionAtAnchor + pointer.subOffset
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
1455
|
+
* applied at the root. After filtering the root's manifest at the fork
|
|
1456
|
+
* pointer, only children whose manifest entries landed at or before the
|
|
1457
|
+
* pointer remain; those kept children carry their CURRENT (HEAD) subtree
|
|
1458
|
+
* along with them. Children dropped from the root's manifest, and any
|
|
1459
|
+
* of their descendants, are excluded.
|
|
1460
|
+
*/
|
|
1461
|
+
private computeEffectiveSubtree(
|
|
1462
|
+
sourceTree: ReadonlyArray<ElectricAgentsEntity>,
|
|
1463
|
+
rootUrl: string,
|
|
1464
|
+
filteredRootManifests: ReadonlyMap<string, Record<string, unknown>>
|
|
1465
|
+
): Array<ElectricAgentsEntity> {
|
|
1466
|
+
const keptChildUrls = new Set<string>()
|
|
1467
|
+
for (const value of filteredRootManifests.values()) {
|
|
1468
|
+
if (value.kind === `child` && typeof value.entity_url === `string`) {
|
|
1469
|
+
keptChildUrls.add(value.entity_url)
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const childrenByParent = new Map<string, Array<ElectricAgentsEntity>>()
|
|
1474
|
+
for (const entity of sourceTree) {
|
|
1475
|
+
if (!entity.parent) continue
|
|
1476
|
+
const list = childrenByParent.get(entity.parent) ?? []
|
|
1477
|
+
list.push(entity)
|
|
1478
|
+
childrenByParent.set(entity.parent, list)
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const rootEntity = sourceTree.find((e) => e.url === rootUrl)
|
|
1482
|
+
if (!rootEntity) return []
|
|
1483
|
+
|
|
1484
|
+
const result: Array<ElectricAgentsEntity> = [rootEntity]
|
|
1485
|
+
const queue: Array<ElectricAgentsEntity> = []
|
|
1486
|
+
for (const child of childrenByParent.get(rootUrl) ?? []) {
|
|
1487
|
+
if (keptChildUrls.has(child.url)) {
|
|
1488
|
+
queue.push(child)
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
const seen = new Set<string>([rootUrl])
|
|
1492
|
+
while (queue.length > 0) {
|
|
1493
|
+
const entity = queue.shift()!
|
|
1494
|
+
if (seen.has(entity.url)) continue
|
|
1495
|
+
seen.add(entity.url)
|
|
1496
|
+
result.push(entity)
|
|
1497
|
+
// Below the kept-children level the existing recursive subtree is
|
|
1498
|
+
// included unchanged — kept descendants are HEAD-cloned.
|
|
1499
|
+
for (const grandchild of childrenByParent.get(entity.url) ?? []) {
|
|
1500
|
+
if (!seen.has(grandchild.url)) {
|
|
1501
|
+
queue.push(grandchild)
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return result
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1178
1508
|
private async listEntitySubtree(
|
|
1179
1509
|
root: ElectricAgentsEntity
|
|
1180
1510
|
): Promise<Array<ElectricAgentsEntity>> {
|
|
@@ -3201,6 +3531,7 @@ export class EntityManager {
|
|
|
3201
3531
|
streams: entity.streams,
|
|
3202
3532
|
tags: entity.tags,
|
|
3203
3533
|
spawnArgs: entity.spawn_args,
|
|
3534
|
+
sandbox: entity.sandbox,
|
|
3204
3535
|
createdBy: entity.created_by,
|
|
3205
3536
|
},
|
|
3206
3537
|
principal: principalFromCreatedBy(entity.created_by),
|
package/src/entity-projector.ts
CHANGED
|
@@ -38,6 +38,7 @@ interface EntityShapeRow extends Row<unknown> {
|
|
|
38
38
|
status: `spawning` | `running` | `idle` | `stopped`
|
|
39
39
|
tags: EntityTags
|
|
40
40
|
spawn_args?: Record<string, unknown> | null
|
|
41
|
+
sandbox?: { profile: string } | null
|
|
41
42
|
parent?: string | null
|
|
42
43
|
type_revision?: number | null
|
|
43
44
|
inbox_schemas?: Record<string, Record<string, unknown>> | null
|
|
@@ -53,6 +54,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
53
54
|
`status`,
|
|
54
55
|
`tags`,
|
|
55
56
|
`spawn_args`,
|
|
57
|
+
`sandbox`,
|
|
56
58
|
`parent`,
|
|
57
59
|
`type_revision`,
|
|
58
60
|
`inbox_schemas`,
|
|
@@ -108,6 +110,7 @@ function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
|
|
|
108
110
|
status: entity.status,
|
|
109
111
|
tags: entity.tags,
|
|
110
112
|
spawn_args: entity.spawn_args ?? {},
|
|
113
|
+
sandbox: entity.sandbox ?? null,
|
|
111
114
|
parent: entity.parent ?? null,
|
|
112
115
|
type_revision: entity.type_revision ?? null,
|
|
113
116
|
inbox_schemas: entity.inbox_schemas ?? null,
|