@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.
@@ -0,0 +1,5 @@
1
+ ALTER TABLE runners
2
+ ADD COLUMN sandbox_profiles jsonb NOT NULL DEFAULT '[]'::jsonb;
3
+ --> statement-breakpoint
4
+ ALTER TABLE entities
5
+ ADD COLUMN sandbox jsonb;
@@ -71,6 +71,13 @@
71
71
  "when": 1778540000000,
72
72
  "tag": "0009_entity_signal_statuses",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1779062400000,
79
+ "tag": "0010_sandbox_profiles",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.13",
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.19",
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.6"
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.10",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.9",
70
- "@electric-ax/agents-server-ui": "0.4.13"
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?: {
@@ -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
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks)
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
- const snapshot = await this.readForkStateSnapshot(sourceTree)
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(sourceTree, {
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
- sourceTree,
1046
+ effectiveSubtree,
900
1047
  entityUrlMap,
901
1048
  stringMap
902
1049
  )
903
1050
 
904
1051
  this.addForkLocks(
905
1052
  this.forkWriteLockedEntities,
906
- sourceTree.map((entity) => entity.url),
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),
@@ -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,