@electric-ax/agents-server 0.4.14 → 0.4.16

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.
@@ -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,
@@ -71,7 +73,6 @@ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
71
73
  import type { Principal } from './principal.js'
72
74
 
73
75
  type SpawnPersistResult = [
74
- PromiseSettledResult<void>,
75
76
  PromiseSettledResult<void>,
76
77
  PromiseSettledResult<number>,
77
78
  ]
@@ -161,6 +162,16 @@ type ForkSubtreeOptions = {
161
162
  rootInstanceId?: string
162
163
  waitTimeoutMs?: number
163
164
  waitPollMs?: number
165
+ createdBy?: string
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; shared-state streams clone at HEAD regardless.
173
+ */
174
+ forkPointer?: EventPointer
164
175
  }
165
176
 
166
177
  type ForkEntityPlan = {
@@ -485,7 +496,10 @@ export class EntityManager {
485
496
 
486
497
  async ensurePrincipal(principal: Principal): Promise<ElectricAgentsEntity> {
487
498
  const existing = await this.registry.getEntity(principal.url)
488
- if (existing) return existing
499
+ if (existing) {
500
+ await this.ensureUserPrincipal(principal)
501
+ return existing
502
+ }
489
503
  await this.ensurePrincipalEntityType()
490
504
  try {
491
505
  const entity = await this.spawn(`principal`, {
@@ -510,6 +524,7 @@ export class EntityManager {
510
524
  },
511
525
  })
512
526
  )
527
+ await this.ensureUserPrincipal(principal)
513
528
  return entity
514
529
  } catch (error) {
515
530
  if (
@@ -517,12 +532,21 @@ export class EntityManager {
517
532
  error.code === ErrCodeDuplicateURL
518
533
  ) {
519
534
  const raced = await this.registry.getEntity(principal.url)
520
- if (raced) return raced
535
+ if (raced) {
536
+ await this.ensureUserPrincipal(principal)
537
+ return raced
538
+ }
521
539
  }
522
540
  throw error
523
541
  }
524
542
  }
525
543
 
544
+ private async ensureUserPrincipal(principal: Principal): Promise<void> {
545
+ if (principal.kind === `user`) {
546
+ await this.registry.ensureUserForPrincipal(principal)
547
+ }
548
+ }
549
+
526
550
  // ==========================================================================
527
551
  // Spawn
528
552
  // ==========================================================================
@@ -612,7 +636,6 @@ export class EntityManager {
612
636
  ? principalUrl(instanceId)
613
637
  : `/${typeName}/${instanceId}`
614
638
  const mainPath = `${entityURL}/main`
615
- const errorPath = `${entityURL}/error`
616
639
 
617
640
  const subscriptionId = `${typeName}-handler`
618
641
 
@@ -650,6 +673,13 @@ export class EntityManager {
650
673
  )
651
674
  : entityType.default_dispatch_policy
652
675
 
676
+ const sandbox = await resolveSandboxForSpawn(
677
+ this.registry,
678
+ dispatchPolicy,
679
+ req.sandbox,
680
+ parentEntity
681
+ )
682
+
653
683
  const now = Date.now()
654
684
  const entityData: ElectricAgentsEntity = {
655
685
  type: typeName,
@@ -657,13 +687,13 @@ export class EntityManager {
657
687
  url: entityURL,
658
688
  streams: {
659
689
  main: mainPath,
660
- error: errorPath,
661
690
  },
662
691
  subscription_id: subscriptionId,
663
692
  dispatch_policy: dispatchPolicy,
664
693
  write_token: writeToken,
665
694
  tags: initialTags,
666
695
  spawn_args: req.args,
696
+ sandbox,
667
697
  type_revision: entityType.revision,
668
698
  inbox_schemas: entityType.inbox_schemas,
669
699
  state_schemas: entityType.state_schemas,
@@ -725,8 +755,8 @@ export class EntityManager {
725
755
  const queueEnterT0 = performance.now()
726
756
  const queueWaiting = this.spawnPersistQueue.length()
727
757
  const queueRunning = this.spawnPersistQueue.running()
728
- const [mainStreamResult, errorStreamResult, entityResult] =
729
- await this.spawnPersistQueue.push(async () => {
758
+ const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(
759
+ async () => {
730
760
  // Create entity first so it's visible in the DB before stream
731
761
  // creation can trigger webhooks that look up the entity.
732
762
  let entityTxid: number
@@ -736,41 +766,34 @@ export class EntityManager {
736
766
  )
737
767
  } catch (err) {
738
768
  return [
739
- { status: `fulfilled`, value: undefined },
740
769
  { status: `fulfilled`, value: undefined },
741
770
  { status: `rejected`, reason: err },
742
771
  ] as SpawnPersistResult
743
772
  }
744
773
 
745
- const [mainStreamResult, errorStreamResult] = await Promise.allSettled([
774
+ const [mainStreamResult] = await Promise.allSettled([
746
775
  this.streamClient.create(mainPath, {
747
776
  contentType,
748
777
  body: initialBody,
749
778
  }),
750
- this.streamClient.create(errorPath, { contentType }),
751
779
  ])
752
780
 
753
781
  return [
754
782
  mainStreamResult,
755
- errorStreamResult,
756
783
  { status: `fulfilled`, value: entityTxid },
757
784
  ] as SpawnPersistResult
758
- })
785
+ }
786
+ )
759
787
  const parallelMs = +(performance.now() - queueEnterT0).toFixed(2)
760
788
 
761
789
  if (
762
790
  mainStreamResult.status === `rejected` ||
763
- errorStreamResult.status === `rejected` ||
764
791
  entityResult.status === `rejected`
765
792
  ) {
766
793
  const entityReason =
767
794
  entityResult.status === `rejected` ? entityResult.reason : null
768
795
  const streamReason =
769
- mainStreamResult.status === `rejected`
770
- ? mainStreamResult.reason
771
- : errorStreamResult.status === `rejected`
772
- ? errorStreamResult.reason
773
- : null
796
+ mainStreamResult.status === `rejected` ? mainStreamResult.reason : null
774
797
  const isDuplicate = entityReason instanceof EntityAlreadyExistsError
775
798
  const isStreamConflict =
776
799
  !!streamReason &&
@@ -785,9 +808,6 @@ export class EntityManager {
785
808
  if (mainStreamResult.status === `fulfilled`) {
786
809
  rollbacks.push(this.streamClient.delete(mainPath))
787
810
  }
788
- if (errorStreamResult.status === `fulfilled`) {
789
- rollbacks.push(this.streamClient.delete(errorPath))
790
- }
791
811
  if (entityResult.status === `fulfilled`) {
792
812
  rollbacks.push(this.registry.deleteEntity(entityURL))
793
813
  }
@@ -814,9 +834,7 @@ export class EntityManager {
814
834
  const failure =
815
835
  mainStreamResult.status === `rejected`
816
836
  ? mainStreamResult.reason
817
- : errorStreamResult.status === `rejected`
818
- ? errorStreamResult.reason
819
- : (entityResult as PromiseRejectedResult).reason
837
+ : (entityResult as PromiseRejectedResult).reason
820
838
  if (failure instanceof Error) throw failure
821
839
  throw new ElectricAgentsError(
822
840
  `SPAWN_FAILED`,
@@ -873,7 +891,36 @@ export class EntityManager {
873
891
  const writeStreamLocks = new Set<string>()
874
892
 
875
893
  try {
876
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks)
894
+ // For pointer-forks we read the source root HISTORICALLY at a
895
+ // frozen offset, so concurrent activity on the root past the
896
+ // pointer can't tear our snapshot — we don't need to wait for
897
+ // the root to be idle (which would block the "click fork right
898
+ // after the response landed" case, since the runtime keeps the
899
+ // worker warm for `idleTimeout`). We still wait+lock any kept
900
+ // descendants below (after `computeEffectiveSubtree` runs), since
901
+ // those are HEAD-cloned and need a stable snapshot. For HEAD-forks
902
+ // the old all-idle requirement still applies.
903
+ let sourceTree: Array<ElectricAgentsEntity>
904
+ if (opts.forkPointer) {
905
+ const rootEntity = await this.registry.getEntity(rootUrl)
906
+ if (!rootEntity) {
907
+ throw new ElectricAgentsError(
908
+ ErrCodeNotFound,
909
+ `Entity not found`,
910
+ 404
911
+ )
912
+ }
913
+ if (isTerminalEntityStatus(rootEntity.status)) {
914
+ throw new ElectricAgentsError(
915
+ ErrCodeNotRunning,
916
+ `Cannot fork terminal entity "${rootEntity.url}"`,
917
+ 409
918
+ )
919
+ }
920
+ sourceTree = await this.listEntitySubtree(rootEntity)
921
+ } else {
922
+ sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks)
923
+ }
877
924
  const sourceRoot = sourceTree[0]!
878
925
  if (sourceRoot.parent) {
879
926
  throw new ElectricAgentsError(
@@ -883,9 +930,107 @@ export class EntityManager {
883
930
  )
884
931
  }
885
932
 
886
- const snapshot = await this.readForkStateSnapshot(sourceTree)
933
+ // When forking at a pointer, pre-read the root's main, validate the
934
+ // pointer against the source's true history, and materialise the
935
+ // root-at-pointer snapshot fragments. The pointer only applies to
936
+ // the root's `main` stream. Descendants kept by the manifest filter
937
+ // are forked at HEAD.
938
+ //
939
+ // Pointer→position translation: the runtime mints pointers as
940
+ // `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
941
+ // the anchor offset is the END of the delivery batch that
942
+ // PRECEDED the targeted event. The durable-streams server
943
+ // interprets `{ X, N }` as "from offset X, take N flattened
944
+ // messages forward" — independent of how delivery is chunked. We
945
+ // mirror that interpretation here by translating the pointer to a
946
+ // 1-indexed CUMULATIVE position in the source's flattened
947
+ // history, then filtering events with position ≤ that target.
948
+ let preFilteredRoot:
949
+ | {
950
+ manifests: Map<string, Record<string, unknown>>
951
+ childStatuses: Map<string, Record<string, unknown>>
952
+ replayWatermarks: Map<string, Record<string, unknown>>
953
+ sharedStateIds: Set<string>
954
+ }
955
+ | undefined
956
+ if (opts.forkPointer) {
957
+ const sourceEvents = await this.streamClient.readJson<
958
+ Record<string, unknown>
959
+ >(sourceRoot.streams.main)
960
+ const flat = sourceEvents.flatMap((item) =>
961
+ Array.isArray(item) ? item : [item]
962
+ ) as Array<Record<string, unknown>>
963
+ const target = this.resolveForkPointerTarget(
964
+ flat,
965
+ opts.forkPointer,
966
+ sourceRoot.streams.main
967
+ )
968
+ const filteredEvents = flat.slice(0, target)
969
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`)
970
+ const sharedStateIds = new Set<string>()
971
+ for (const manifest of rootManifests.values()) {
972
+ this.collectSharedStateIds(manifest, sharedStateIds)
973
+ }
974
+ preFilteredRoot = {
975
+ manifests: rootManifests,
976
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
977
+ replayWatermarks: this.reduceStateRows(
978
+ filteredEvents,
979
+ `replay_watermark`
980
+ ),
981
+ sharedStateIds,
982
+ }
983
+ }
984
+
985
+ const effectiveSubtree = preFilteredRoot
986
+ ? this.computeEffectiveSubtree(
987
+ sourceTree,
988
+ sourceRoot.url,
989
+ preFilteredRoot.manifests
990
+ )
991
+ : sourceTree
992
+
993
+ // For pointer-forks, kept descendants (everything in the effective
994
+ // subtree except the root) are HEAD-cloned, so they must be idle
995
+ // before we read their snapshots. Wait+lock only those — the root
996
+ // was skipped above.
997
+ if (opts.forkPointer) {
998
+ const descendants = effectiveSubtree.filter(
999
+ (entity) => entity.url !== sourceRoot.url
1000
+ )
1001
+ if (descendants.length > 0) {
1002
+ await this.waitForGivenEntitiesIdle(descendants, opts, workLocks)
1003
+ }
1004
+ }
1005
+
1006
+ const snapshot = await this.readForkStateSnapshot(
1007
+ // Skip the root when we've already pre-filtered it — avoid both a
1008
+ // wasted HEAD read of main and a re-population that would clobber
1009
+ // the filtered entries.
1010
+ preFilteredRoot
1011
+ ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url)
1012
+ : effectiveSubtree
1013
+ )
1014
+ if (preFilteredRoot) {
1015
+ snapshot.manifestsByEntity.set(
1016
+ sourceRoot.url,
1017
+ preFilteredRoot.manifests
1018
+ )
1019
+ snapshot.childStatusesByEntity.set(
1020
+ sourceRoot.url,
1021
+ preFilteredRoot.childStatuses
1022
+ )
1023
+ snapshot.replayWatermarksByEntity.set(
1024
+ sourceRoot.url,
1025
+ preFilteredRoot.replayWatermarks
1026
+ )
1027
+ for (const id of preFilteredRoot.sharedStateIds) {
1028
+ snapshot.sharedStateIds.add(id)
1029
+ }
1030
+ }
1031
+
887
1032
  const suffix = randomUUID().slice(0, 8)
888
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
1033
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
889
1034
  suffix,
890
1035
  rootUrl,
891
1036
  rootInstanceId: opts.rootInstanceId,
@@ -896,14 +1041,15 @@ export class EntityManager {
896
1041
  )
897
1042
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap)
898
1043
  const entityPlans = this.buildForkEntityPlans(
899
- sourceTree,
1044
+ effectiveSubtree,
900
1045
  entityUrlMap,
901
- stringMap
1046
+ stringMap,
1047
+ opts.createdBy
902
1048
  )
903
1049
 
904
1050
  this.addForkLocks(
905
1051
  this.forkWriteLockedEntities,
906
- sourceTree.map((entity) => entity.url),
1052
+ effectiveSubtree.map((entity) => entity.url),
907
1053
  writeEntityLocks
908
1054
  )
909
1055
  this.addForkLocks(
@@ -921,16 +1067,15 @@ export class EntityManager {
921
1067
 
922
1068
  try {
923
1069
  for (const plan of entityPlans) {
1070
+ const isRoot = plan.source.url === rootUrl
924
1071
  await this.streamClient.fork(
925
1072
  plan.fork.streams.main,
926
- plan.source.streams.main
1073
+ plan.source.streams.main,
1074
+ isRoot && opts.forkPointer
1075
+ ? { forkPointer: opts.forkPointer }
1076
+ : undefined
927
1077
  )
928
1078
  createdStreams.push(plan.fork.streams.main)
929
- await this.streamClient.fork(
930
- plan.fork.streams.error,
931
- plan.source.streams.error
932
- )
933
- createdStreams.push(plan.fork.streams.error)
934
1079
  }
935
1080
 
936
1081
  for (const [sourceId, forkId] of sharedStateIdMap) {
@@ -1081,6 +1226,73 @@ export class EntityManager {
1081
1226
  held.clear()
1082
1227
  }
1083
1228
 
1229
+ /**
1230
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
1231
+ * list instead of walking the registry from `rootUrl`. Used by the
1232
+ * pointer-fork path to wait+lock only the kept descendants, since
1233
+ * the root is being forked from history and doesn't need to be idle.
1234
+ */
1235
+ private async waitForGivenEntitiesIdle(
1236
+ entities: ReadonlyArray<ElectricAgentsEntity>,
1237
+ opts: ForkSubtreeOptions,
1238
+ workLocks: Set<string>
1239
+ ): Promise<void> {
1240
+ if (entities.length === 0) return
1241
+
1242
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS
1243
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS
1244
+
1245
+ const refresh = async (): Promise<Array<ElectricAgentsEntity>> => {
1246
+ const refreshed = await Promise.all(
1247
+ entities.map((entity) => this.registry.getEntity(entity.url))
1248
+ )
1249
+ return refreshed.filter(
1250
+ (entity): entity is ElectricAgentsEntity => !!entity
1251
+ )
1252
+ }
1253
+
1254
+ const deadline = Date.now() + timeoutMs
1255
+ while (true) {
1256
+ const present = await refresh()
1257
+ const stopped = present.find((entity) =>
1258
+ isTerminalEntityStatus(entity.status)
1259
+ )
1260
+ if (stopped) {
1261
+ throw new ElectricAgentsError(
1262
+ ErrCodeNotRunning,
1263
+ `Cannot fork terminal entity "${stopped.url}"`,
1264
+ 409
1265
+ )
1266
+ }
1267
+ let active = present.filter(
1268
+ (entity) => entity.status !== `idle` && entity.status !== `paused`
1269
+ )
1270
+ if (active.length === 0) {
1271
+ this.addForkLocks(
1272
+ this.forkWorkLockedEntities,
1273
+ present.map((entity) => entity.url),
1274
+ workLocks
1275
+ )
1276
+ const reChecked = await refresh()
1277
+ const reActive = reChecked.filter(
1278
+ (entity) => entity.status !== `idle` && entity.status !== `paused`
1279
+ )
1280
+ if (reActive.length === 0) return
1281
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks)
1282
+ active = reActive
1283
+ }
1284
+ if (Date.now() >= deadline) {
1285
+ throw new ElectricAgentsError(
1286
+ ErrCodeForkWaitTimeout,
1287
+ `Timed out waiting for descendants to become idle`,
1288
+ 409,
1289
+ { active: active.map((entity) => entity.url) }
1290
+ )
1291
+ }
1292
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())))
1293
+ }
1294
+ }
1295
+
1084
1296
  private async waitForIdleSubtree(
1085
1297
  rootUrl: string,
1086
1298
  opts: ForkSubtreeOptions,
@@ -1175,6 +1387,116 @@ export class EntityManager {
1175
1387
  }
1176
1388
  }
1177
1389
 
1390
+ /**
1391
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
1392
+ * source's flattened history. Throws a 400 if the pointer doesn't
1393
+ * address a real event.
1394
+ *
1395
+ * Semantics (mirroring the durable-streams server interpretation):
1396
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
1397
+ * messages forward." Concretely, the target event is the N-th event
1398
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
1399
+ * `null`, the anchor is the stream start and the target is the N-th
1400
+ * event from the very beginning.) The returned position is the count
1401
+ * of events to KEEP — events 1..position survive the filter.
1402
+ *
1403
+ * A pointer is valid when:
1404
+ * - `pointer.offset` is `null` (stream start) OR matches some
1405
+ * event's `headers.offset` value, AND
1406
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
1407
+ */
1408
+ private resolveForkPointerTarget(
1409
+ events: ReadonlyArray<Record<string, unknown>>,
1410
+ pointer: EventPointer,
1411
+ streamPath: string
1412
+ ): number {
1413
+ // Count events at-or-before the anchor and validate the anchor exists.
1414
+ // `pointer.offset === null` is the stream-start anchor — no events
1415
+ // precede it, so `positionAtAnchor` stays at 0.
1416
+ let positionAtAnchor = 0
1417
+ let anchorSeen = pointer.offset === null
1418
+ for (const event of events) {
1419
+ const headers = isRecord(event.headers) ? event.headers : undefined
1420
+ const eventOffset =
1421
+ typeof headers?.offset === `string` ? headers.offset : undefined
1422
+ if (eventOffset === undefined) continue
1423
+ if (pointer.offset === null) continue
1424
+ if (eventOffset === pointer.offset) anchorSeen = true
1425
+ if (eventOffset <= pointer.offset) positionAtAnchor++
1426
+ }
1427
+ if (!anchorSeen) {
1428
+ throw new ElectricAgentsError(
1429
+ ErrCodeInvalidRequest,
1430
+ `fork_pointer.offset (${pointer.offset ?? `<stream-start>`}) does not match any event's Stream-Next-Offset on ${streamPath}`,
1431
+ 400
1432
+ )
1433
+ }
1434
+ const eventsPastAnchor = events.length - positionAtAnchor
1435
+ if (pointer.subOffset < 1 || pointer.subOffset > eventsPastAnchor) {
1436
+ throw new ElectricAgentsError(
1437
+ ErrCodeInvalidRequest,
1438
+ `fork_pointer.sub_offset ${pointer.subOffset} out of range past anchor on ${streamPath} (valid: 1..${eventsPastAnchor})`,
1439
+ 400
1440
+ )
1441
+ }
1442
+ return positionAtAnchor + pointer.subOffset
1443
+ }
1444
+
1445
+ /**
1446
+ * Compute the subset of `sourceTree` that survives the manifest filter
1447
+ * applied at the root. After filtering the root's manifest at the fork
1448
+ * pointer, only children whose manifest entries landed at or before the
1449
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
1450
+ * along with them. Children dropped from the root's manifest, and any
1451
+ * of their descendants, are excluded.
1452
+ */
1453
+ private computeEffectiveSubtree(
1454
+ sourceTree: ReadonlyArray<ElectricAgentsEntity>,
1455
+ rootUrl: string,
1456
+ filteredRootManifests: ReadonlyMap<string, Record<string, unknown>>
1457
+ ): Array<ElectricAgentsEntity> {
1458
+ const keptChildUrls = new Set<string>()
1459
+ for (const value of filteredRootManifests.values()) {
1460
+ if (value.kind === `child` && typeof value.entity_url === `string`) {
1461
+ keptChildUrls.add(value.entity_url)
1462
+ }
1463
+ }
1464
+
1465
+ const childrenByParent = new Map<string, Array<ElectricAgentsEntity>>()
1466
+ for (const entity of sourceTree) {
1467
+ if (!entity.parent) continue
1468
+ const list = childrenByParent.get(entity.parent) ?? []
1469
+ list.push(entity)
1470
+ childrenByParent.set(entity.parent, list)
1471
+ }
1472
+
1473
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl)
1474
+ if (!rootEntity) return []
1475
+
1476
+ const result: Array<ElectricAgentsEntity> = [rootEntity]
1477
+ const queue: Array<ElectricAgentsEntity> = []
1478
+ for (const child of childrenByParent.get(rootUrl) ?? []) {
1479
+ if (keptChildUrls.has(child.url)) {
1480
+ queue.push(child)
1481
+ }
1482
+ }
1483
+ const seen = new Set<string>([rootUrl])
1484
+ while (queue.length > 0) {
1485
+ const entity = queue.shift()!
1486
+ if (seen.has(entity.url)) continue
1487
+ seen.add(entity.url)
1488
+ result.push(entity)
1489
+ // Below the kept-children level the existing recursive subtree is
1490
+ // included unchanged — kept descendants are HEAD-cloned.
1491
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) {
1492
+ if (!seen.has(grandchild.url)) {
1493
+ queue.push(grandchild)
1494
+ }
1495
+ }
1496
+ }
1497
+ return result
1498
+ }
1499
+
1178
1500
  private async listEntitySubtree(
1179
1501
  root: ElectricAgentsEntity
1180
1502
  ): Promise<Array<ElectricAgentsEntity>> {
@@ -1381,7 +1703,6 @@ export class EntityManager {
1381
1703
  for (const [sourceUrl, forkUrl] of entityUrlMap) {
1382
1704
  stringMap.set(sourceUrl, forkUrl)
1383
1705
  stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`)
1384
- stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`)
1385
1706
  }
1386
1707
  for (const [sourceId, forkId] of sharedStateIdMap) {
1387
1708
  stringMap.set(sourceId, forkId)
@@ -1396,7 +1717,8 @@ export class EntityManager {
1396
1717
  private buildForkEntityPlans(
1397
1718
  entitiesToFork: Array<ElectricAgentsEntity>,
1398
1719
  entityUrlMap: Map<string, string>,
1399
- stringMap: Map<string, string>
1720
+ stringMap: Map<string, string>,
1721
+ createdBy?: string
1400
1722
  ): Array<ForkEntityPlan> {
1401
1723
  const now = Date.now()
1402
1724
  return entitiesToFork.map((source) => {
@@ -1420,12 +1742,12 @@ export class EntityManager {
1420
1742
  status: `idle`,
1421
1743
  streams: {
1422
1744
  main: `${forkUrl}/main`,
1423
- error: `${forkUrl}/error`,
1424
1745
  },
1425
1746
  subscription_id: `${type}-handler`,
1426
1747
  write_token: randomUUID(),
1427
1748
  spawn_args: spawnArgs,
1428
1749
  parent,
1750
+ created_by: createdBy ?? source.created_by,
1429
1751
  created_at: now,
1430
1752
  updated_at: now,
1431
1753
  }
@@ -1717,12 +2039,7 @@ export class EntityManager {
1717
2039
  manifests: Map<string, Record<string, unknown>>
1718
2040
  ): Promise<void> {
1719
2041
  for (const [manifestKey, manifest] of manifests) {
1720
- await this.syncEntitiesManifestSource(
1721
- entityUrl,
1722
- manifestKey,
1723
- `upsert`,
1724
- manifest
1725
- )
2042
+ await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest)
1726
2043
 
1727
2044
  const wake = buildManifestWakeRegistration(
1728
2045
  entityUrl,
@@ -1795,6 +2112,7 @@ export class EntityManager {
1795
2112
  {
1796
2113
  entityUrl: targetUrl,
1797
2114
  from: senderUrl,
2115
+ from_agent: senderUrl,
1798
2116
  payload: manifest.payload,
1799
2117
  key: `scheduled-${producerId}`,
1800
2118
  type:
@@ -1861,7 +2179,7 @@ export class EntityManager {
1861
2179
  `msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1862
2180
 
1863
2181
  const value: Record<string, unknown> = {
1864
- from: req.from,
2182
+ from: req.from_principal ?? req.from,
1865
2183
  payload: req.payload,
1866
2184
  timestamp: now,
1867
2185
  mode: req.mode ?? `immediate`,
@@ -1870,6 +2188,12 @@ export class EntityManager {
1870
2188
  ? `pending`
1871
2189
  : `processed`,
1872
2190
  }
2191
+ if (req.from_principal) {
2192
+ value.from_principal = req.from_principal
2193
+ }
2194
+ if (req.from_agent) {
2195
+ value.from_agent = req.from_agent
2196
+ }
1873
2197
  if (req.type) {
1874
2198
  value.message_type = req.type
1875
2199
  }
@@ -2280,14 +2604,21 @@ export class EntityManager {
2280
2604
  return updated
2281
2605
  }
2282
2606
 
2283
- async ensureEntitiesMembershipStream(tags: Record<string, string>): Promise<{
2607
+ async ensureEntitiesMembershipStream(
2608
+ tags: Record<string, string>,
2609
+ principal: { url: string; kind: string }
2610
+ ): Promise<{
2284
2611
  sourceRef: string
2285
2612
  streamUrl: string
2286
2613
  }> {
2287
2614
  if (!this.entityBridgeManager) {
2288
2615
  throw new Error(`Entity bridge manager not configured`)
2289
2616
  }
2290
- return this.entityBridgeManager.register(this.validateTags(tags))
2617
+ return this.entityBridgeManager.register(
2618
+ this.validateTags(tags),
2619
+ principal.url,
2620
+ principal.kind
2621
+ )
2291
2622
  }
2292
2623
 
2293
2624
  async writeManifestEntry(
@@ -2320,12 +2651,12 @@ export class EntityManager {
2320
2651
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, {
2321
2652
  producerId: opts.producerId,
2322
2653
  })
2323
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value)
2654
+ await this.syncManifestLinks(entityUrl, key, operation, value)
2324
2655
  return
2325
2656
  }
2326
2657
 
2327
2658
  await this.streamClient.append(entity.streams.main, encoded)
2328
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value)
2659
+ await this.syncManifestLinks(entityUrl, key, operation, value)
2329
2660
  }
2330
2661
 
2331
2662
  async upsertCronSchedule(
@@ -2620,6 +2951,8 @@ export class EntityManager {
2620
2951
  {
2621
2952
  entityUrl,
2622
2953
  from: req.from,
2954
+ from_principal: req.from_principal,
2955
+ from_agent: req.from_agent,
2623
2956
  payload: req.payload,
2624
2957
  key: req.key,
2625
2958
  type: req.type,
@@ -2701,7 +3034,7 @@ export class EntityManager {
2701
3034
  })
2702
3035
  }
2703
3036
 
2704
- private async syncEntitiesManifestSource(
3037
+ private async syncManifestLinks(
2705
3038
  entityUrl: string,
2706
3039
  manifestKey: string,
2707
3040
  operation: `insert` | `update` | `upsert` | `delete`,
@@ -2714,6 +3047,14 @@ export class EntityManager {
2714
3047
  manifestKey,
2715
3048
  sourceRef
2716
3049
  )
3050
+
3051
+ const sharedStateId =
3052
+ operation === `delete` ? undefined : this.extractSharedStateId(value)
3053
+ await this.registry.replaceSharedStateLink(
3054
+ entityUrl,
3055
+ manifestKey,
3056
+ sharedStateId
3057
+ )
2717
3058
  }
2718
3059
 
2719
3060
  private extractEntitiesSourceRef(
@@ -2729,6 +3070,24 @@ export class EntityManager {
2729
3070
  return undefined
2730
3071
  }
2731
3072
 
3073
+ private extractSharedStateId(
3074
+ manifest?: Record<string, unknown>
3075
+ ): string | undefined {
3076
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) {
3077
+ return manifest.id
3078
+ }
3079
+
3080
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) {
3081
+ return undefined
3082
+ }
3083
+
3084
+ if (typeof manifest.sourceRef === `string`) {
3085
+ return manifest.sourceRef
3086
+ }
3087
+ const config = isRecord(manifest.config) ? manifest.config : undefined
3088
+ return typeof config?.id === `string` ? config.id : undefined
3089
+ }
3090
+
2732
3091
  /**
2733
3092
  * Read a child entity's stream and extract concatenated text deltas
2734
3093
  * for a specific run, plus any error messages for that run.
@@ -3004,19 +3363,8 @@ export class EntityManager {
3004
3363
  return
3005
3364
  }
3006
3365
 
3007
- const errorCloseEvent = {
3008
- type: `signal`,
3009
- key: signalEvent.key,
3010
- value: signalEvent.value,
3011
- headers: signalEvent.headers,
3012
- }
3013
- const errorSignalData = this.encodeChangeEvent(
3014
- errorCloseEvent as unknown as Record<string, unknown>
3015
- )
3016
-
3017
3366
  for (const [streamPath, data] of [
3018
3367
  [entity.streams.main, signalData],
3019
- [entity.streams.error, errorSignalData],
3020
3368
  ] as const) {
3021
3369
  try {
3022
3370
  await this.streamClient.append(streamPath, data, { close: true })
@@ -3201,6 +3549,7 @@ export class EntityManager {
3201
3549
  streams: entity.streams,
3202
3550
  tags: entity.tags,
3203
3551
  spawnArgs: entity.spawn_args,
3552
+ sandbox: entity.sandbox,
3204
3553
  createdBy: entity.created_by,
3205
3554
  },
3206
3555
  principal: principalFromCreatedBy(entity.created_by),