@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.
- package/dist/entrypoint.js +1530 -256
- package/dist/index.cjs +1517 -232
- package/dist/index.d.cts +1359 -212
- package/dist/index.d.ts +1359 -212
- package/dist/index.js +1519 -234
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/0011_entity_permissions.sql +100 -0
- package/drizzle/0012_horton_user_manage_permission.sql +25 -0
- package/drizzle/0013_worker_user_manage_permission.sql +25 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/db/schema.ts +200 -0
- package/src/electric-agents-types.ts +147 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +411 -62
- package/src/entity-projector.ts +79 -17
- package/src/entity-registry.ts +681 -5
- package/src/index.ts +11 -0
- package/src/permissions.ts +239 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +125 -4
- package/src/routing/electric-proxy-router.ts +4 -0
- package/src/routing/entities-router.ts +362 -20
- package/src/routing/entity-types-router.ts +244 -15
- package/src/routing/hooks.ts +1 -0
- package/src/routing/observations-router.ts +2 -1
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/runtime.ts +34 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/server-utils.ts +192 -12
- package/src/wake-registry.ts +30 -11
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,
|
|
@@ -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)
|
|
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)
|
|
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,
|
|
729
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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),
|