@electric-ax/agents-server 0.4.17 → 0.4.19
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 +118 -9
- package/dist/index.cjs +118 -9
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +118 -9
- package/package.json +5 -5
- package/src/entity-manager.ts +164 -11
- package/src/routing/durable-streams-router.ts +13 -0
- package/src/routing/entities-router.ts +119 -1
package/dist/entrypoint.js
CHANGED
|
@@ -2235,7 +2235,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
|
|
|
2235
2235
|
}
|
|
2236
2236
|
if (method === `PUT` || method === `POST`) {
|
|
2237
2237
|
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
2238
|
-
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl))
|
|
2238
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
|
|
2239
|
+
if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
|
|
2240
|
+
return void 0;
|
|
2241
|
+
}
|
|
2239
2242
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
2240
2243
|
}
|
|
2241
2244
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
@@ -3973,9 +3976,10 @@ var EntityManager = class {
|
|
|
3973
3976
|
const workLocks = new Set();
|
|
3974
3977
|
const writeEntityLocks = new Set();
|
|
3975
3978
|
const writeStreamLocks = new Set();
|
|
3979
|
+
const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
|
|
3976
3980
|
try {
|
|
3977
3981
|
let sourceTree;
|
|
3978
|
-
if (
|
|
3982
|
+
if (usePointerPath) {
|
|
3979
3983
|
const rootEntity = await this.registry.getEntity(rootUrl);
|
|
3980
3984
|
if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3981
3985
|
if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
|
|
@@ -3984,10 +3988,13 @@ var EntityManager = class {
|
|
|
3984
3988
|
const sourceRoot = sourceTree[0];
|
|
3985
3989
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3986
3990
|
let preFilteredRoot;
|
|
3987
|
-
|
|
3991
|
+
let effectiveForkPointer = opts.forkPointer;
|
|
3992
|
+
if (usePointerPath) {
|
|
3988
3993
|
const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
|
|
3989
3994
|
const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
|
|
3990
|
-
|
|
3995
|
+
if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
|
|
3996
|
+
if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
|
|
3997
|
+
const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
|
|
3991
3998
|
const filteredEvents = flat.slice(0, target);
|
|
3992
3999
|
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
|
|
3993
4000
|
const sharedStateIds = new Set();
|
|
@@ -4000,7 +4007,7 @@ var EntityManager = class {
|
|
|
4000
4007
|
};
|
|
4001
4008
|
}
|
|
4002
4009
|
const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
|
|
4003
|
-
if (
|
|
4010
|
+
if (usePointerPath) {
|
|
4004
4011
|
const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
|
|
4005
4012
|
if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
|
|
4006
4013
|
}
|
|
@@ -4025,6 +4032,14 @@ var EntityManager = class {
|
|
|
4025
4032
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
4026
4033
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
4027
4034
|
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
|
|
4035
|
+
const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
4036
|
+
if (rootPlanForOverrides) {
|
|
4037
|
+
if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
|
|
4038
|
+
if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
|
|
4039
|
+
...rootPlanForOverrides.fork.tags ?? {},
|
|
4040
|
+
...opts.tags
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4028
4043
|
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
4029
4044
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
4030
4045
|
const createdStreams = [];
|
|
@@ -4033,7 +4048,7 @@ var EntityManager = class {
|
|
|
4033
4048
|
try {
|
|
4034
4049
|
for (const plan of entityPlans) {
|
|
4035
4050
|
const isRoot = plan.source.url === rootUrl;
|
|
4036
|
-
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot &&
|
|
4051
|
+
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
|
|
4037
4052
|
createdStreams.push(plan.fork.streams.main);
|
|
4038
4053
|
}
|
|
4039
4054
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
@@ -4060,6 +4075,20 @@ var EntityManager = class {
|
|
|
4060
4075
|
await this.registry.createEntity(plan.fork);
|
|
4061
4076
|
createdEntities.push(plan.fork.url);
|
|
4062
4077
|
}
|
|
4078
|
+
if (opts.wake !== void 0) {
|
|
4079
|
+
const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
4080
|
+
if (rootPlan) await this.wakeRegistry.register({
|
|
4081
|
+
tenantId: this.tenantId,
|
|
4082
|
+
subscriberUrl: opts.wake.subscriberUrl,
|
|
4083
|
+
sourceUrl: rootPlan.fork.url,
|
|
4084
|
+
condition: opts.wake.condition,
|
|
4085
|
+
debounceMs: opts.wake.debounceMs,
|
|
4086
|
+
timeoutMs: opts.wake.timeoutMs,
|
|
4087
|
+
oneShot: false,
|
|
4088
|
+
includeResponse: opts.wake.includeResponse,
|
|
4089
|
+
manifestKey: opts.wake.manifestKey
|
|
4090
|
+
});
|
|
4091
|
+
}
|
|
4063
4092
|
for (const plan of entityPlans) {
|
|
4064
4093
|
const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
|
|
4065
4094
|
await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
|
|
@@ -4220,6 +4249,45 @@ var EntityManager = class {
|
|
|
4220
4249
|
return positionAtAnchor + pointer.subOffset;
|
|
4221
4250
|
}
|
|
4222
4251
|
/**
|
|
4252
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
4253
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
4254
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
4255
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
4256
|
+
* skipped.
|
|
4257
|
+
*
|
|
4258
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
4259
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
4260
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
4261
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
4262
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
4263
|
+
*/
|
|
4264
|
+
resolveLatestCompletedRunPointer(events, streamPath) {
|
|
4265
|
+
let priorEntryOffset = null;
|
|
4266
|
+
let currentEntryOffset = null;
|
|
4267
|
+
let positionInEntry = 0;
|
|
4268
|
+
let latest = null;
|
|
4269
|
+
for (const event of events) {
|
|
4270
|
+
const headers = isRecord(event.headers) ? event.headers : void 0;
|
|
4271
|
+
const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
|
|
4272
|
+
if (eventOffset !== currentEntryOffset) {
|
|
4273
|
+
priorEntryOffset = currentEntryOffset;
|
|
4274
|
+
currentEntryOffset = eventOffset;
|
|
4275
|
+
positionInEntry = 0;
|
|
4276
|
+
}
|
|
4277
|
+
positionInEntry++;
|
|
4278
|
+
if (event.type !== `run`) continue;
|
|
4279
|
+
if (headers?.operation === `delete`) continue;
|
|
4280
|
+
if (!isRecord(event.value)) continue;
|
|
4281
|
+
if (event.value.status !== `completed`) continue;
|
|
4282
|
+
latest = {
|
|
4283
|
+
offset: priorEntryOffset,
|
|
4284
|
+
subOffset: positionInEntry
|
|
4285
|
+
};
|
|
4286
|
+
}
|
|
4287
|
+
if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
|
|
4288
|
+
return latest;
|
|
4289
|
+
}
|
|
4290
|
+
/**
|
|
4223
4291
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
4224
4292
|
* applied at the root. After filtering the root's manifest at the fork
|
|
4225
4293
|
* pointer, only children whose manifest entries landed at or before the
|
|
@@ -5726,6 +5794,14 @@ function agentUrlPath(value) {
|
|
|
5726
5794
|
return value;
|
|
5727
5795
|
}
|
|
5728
5796
|
}
|
|
5797
|
+
async function hasValidAgentWriteToken(request, ctx, fromAgent) {
|
|
5798
|
+
const agentUrl = agentUrlPath(fromAgent);
|
|
5799
|
+
const token = writeTokenFromRequest(request);
|
|
5800
|
+
if (!token) return false;
|
|
5801
|
+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
|
|
5802
|
+
if (!agentEntity) return false;
|
|
5803
|
+
return ctx.entityManager.isValidWriteToken(agentEntity, token);
|
|
5804
|
+
}
|
|
5729
5805
|
const inboxMessageBodySchema = Type.Object({
|
|
5730
5806
|
payload: Type.Optional(Type.Unknown()),
|
|
5731
5807
|
position: Type.Optional(Type.String()),
|
|
@@ -5747,7 +5823,19 @@ const forkBodySchema = Type.Object({
|
|
|
5747
5823
|
fork_pointer: Type.Optional(Type.Object({
|
|
5748
5824
|
offset: Type.Union([Type.String(), Type.Null()]),
|
|
5749
5825
|
sub_offset: Type.Number()
|
|
5750
|
-
}))
|
|
5826
|
+
})),
|
|
5827
|
+
anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
|
|
5828
|
+
parent: Type.Optional(Type.String()),
|
|
5829
|
+
wake: Type.Optional(Type.Object({
|
|
5830
|
+
subscriberUrl: Type.String(),
|
|
5831
|
+
condition: wakeConditionSchema,
|
|
5832
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
5833
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
5834
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
5835
|
+
manifestKey: Type.Optional(Type.String())
|
|
5836
|
+
})),
|
|
5837
|
+
initialMessage: Type.Optional(Type.Unknown()),
|
|
5838
|
+
tags: Type.Optional(stringRecordSchema$1)
|
|
5751
5839
|
});
|
|
5752
5840
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
5753
5841
|
const entitySignalSchema = Type.Union([
|
|
@@ -6134,8 +6222,18 @@ async function forkEntity(request, ctx) {
|
|
|
6134
6222
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
6135
6223
|
if (principalMutationError) return principalMutationError;
|
|
6136
6224
|
const parsed = routeBody(request);
|
|
6225
|
+
if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
|
|
6137
6226
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
6138
6227
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
6228
|
+
if (parsed.parent !== void 0) {
|
|
6229
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
|
|
6230
|
+
if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
|
|
6231
|
+
if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
|
|
6232
|
+
}
|
|
6233
|
+
if (parsed.wake !== void 0) {
|
|
6234
|
+
if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
|
|
6235
|
+
if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
|
|
6236
|
+
}
|
|
6139
6237
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
6140
6238
|
rootInstanceId: parsed.instance_id,
|
|
6141
6239
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
@@ -6143,9 +6241,17 @@ async function forkEntity(request, ctx) {
|
|
|
6143
6241
|
...parsed.fork_pointer && { forkPointer: {
|
|
6144
6242
|
offset: parsed.fork_pointer.offset,
|
|
6145
6243
|
subOffset: parsed.fork_pointer.sub_offset
|
|
6146
|
-
} }
|
|
6244
|
+
} },
|
|
6245
|
+
...parsed.anchor && { anchor: parsed.anchor },
|
|
6246
|
+
...parsed.parent !== void 0 && { parent: parsed.parent },
|
|
6247
|
+
...parsed.wake !== void 0 && { wake: parsed.wake },
|
|
6248
|
+
...parsed.tags !== void 0 && { tags: parsed.tags }
|
|
6147
6249
|
});
|
|
6148
6250
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
6251
|
+
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
|
|
6252
|
+
from: parsed.parent ?? ctx.principal.url,
|
|
6253
|
+
payload: parsed.initialMessage
|
|
6254
|
+
});
|
|
6149
6255
|
return json({
|
|
6150
6256
|
root: toPublicEntity(result.root),
|
|
6151
6257
|
entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
|
|
@@ -6158,7 +6264,10 @@ async function sendEntity(request, ctx) {
|
|
|
6158
6264
|
if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
|
|
6159
6265
|
if (parsed.from_agent !== void 0) {
|
|
6160
6266
|
const principalAgentUrl = agentUrlForPrincipal(principal);
|
|
6161
|
-
|
|
6267
|
+
const fromAgentUrl = agentUrlPath(parsed.from_agent);
|
|
6268
|
+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
|
|
6269
|
+
const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
|
|
6270
|
+
if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
|
|
6162
6271
|
}
|
|
6163
6272
|
await ctx.entityManager.ensurePrincipal(principal);
|
|
6164
6273
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
package/dist/index.cjs
CHANGED
|
@@ -3664,9 +3664,10 @@ var EntityManager = class {
|
|
|
3664
3664
|
const workLocks = new Set();
|
|
3665
3665
|
const writeEntityLocks = new Set();
|
|
3666
3666
|
const writeStreamLocks = new Set();
|
|
3667
|
+
const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
|
|
3667
3668
|
try {
|
|
3668
3669
|
let sourceTree;
|
|
3669
|
-
if (
|
|
3670
|
+
if (usePointerPath) {
|
|
3670
3671
|
const rootEntity = await this.registry.getEntity(rootUrl);
|
|
3671
3672
|
if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3672
3673
|
if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
|
|
@@ -3675,10 +3676,13 @@ var EntityManager = class {
|
|
|
3675
3676
|
const sourceRoot = sourceTree[0];
|
|
3676
3677
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3677
3678
|
let preFilteredRoot;
|
|
3678
|
-
|
|
3679
|
+
let effectiveForkPointer = opts.forkPointer;
|
|
3680
|
+
if (usePointerPath) {
|
|
3679
3681
|
const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
|
|
3680
3682
|
const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
|
|
3681
|
-
|
|
3683
|
+
if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
|
|
3684
|
+
if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
|
|
3685
|
+
const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
|
|
3682
3686
|
const filteredEvents = flat.slice(0, target);
|
|
3683
3687
|
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
|
|
3684
3688
|
const sharedStateIds = new Set();
|
|
@@ -3691,7 +3695,7 @@ var EntityManager = class {
|
|
|
3691
3695
|
};
|
|
3692
3696
|
}
|
|
3693
3697
|
const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
|
|
3694
|
-
if (
|
|
3698
|
+
if (usePointerPath) {
|
|
3695
3699
|
const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
|
|
3696
3700
|
if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
|
|
3697
3701
|
}
|
|
@@ -3716,6 +3720,14 @@ var EntityManager = class {
|
|
|
3716
3720
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3717
3721
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3718
3722
|
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
|
|
3723
|
+
const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
3724
|
+
if (rootPlanForOverrides) {
|
|
3725
|
+
if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
|
|
3726
|
+
if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
|
|
3727
|
+
...rootPlanForOverrides.fork.tags ?? {},
|
|
3728
|
+
...opts.tags
|
|
3729
|
+
};
|
|
3730
|
+
}
|
|
3719
3731
|
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3720
3732
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(id)), writeStreamLocks);
|
|
3721
3733
|
const createdStreams = [];
|
|
@@ -3724,7 +3736,7 @@ var EntityManager = class {
|
|
|
3724
3736
|
try {
|
|
3725
3737
|
for (const plan of entityPlans) {
|
|
3726
3738
|
const isRoot = plan.source.url === rootUrl;
|
|
3727
|
-
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot &&
|
|
3739
|
+
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
|
|
3728
3740
|
createdStreams.push(plan.fork.streams.main);
|
|
3729
3741
|
}
|
|
3730
3742
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
@@ -3751,6 +3763,20 @@ var EntityManager = class {
|
|
|
3751
3763
|
await this.registry.createEntity(plan.fork);
|
|
3752
3764
|
createdEntities.push(plan.fork.url);
|
|
3753
3765
|
}
|
|
3766
|
+
if (opts.wake !== void 0) {
|
|
3767
|
+
const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
3768
|
+
if (rootPlan) await this.wakeRegistry.register({
|
|
3769
|
+
tenantId: this.tenantId,
|
|
3770
|
+
subscriberUrl: opts.wake.subscriberUrl,
|
|
3771
|
+
sourceUrl: rootPlan.fork.url,
|
|
3772
|
+
condition: opts.wake.condition,
|
|
3773
|
+
debounceMs: opts.wake.debounceMs,
|
|
3774
|
+
timeoutMs: opts.wake.timeoutMs,
|
|
3775
|
+
oneShot: false,
|
|
3776
|
+
includeResponse: opts.wake.includeResponse,
|
|
3777
|
+
manifestKey: opts.wake.manifestKey
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3754
3780
|
for (const plan of entityPlans) {
|
|
3755
3781
|
const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
|
|
3756
3782
|
await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
|
|
@@ -3911,6 +3937,45 @@ var EntityManager = class {
|
|
|
3911
3937
|
return positionAtAnchor + pointer.subOffset;
|
|
3912
3938
|
}
|
|
3913
3939
|
/**
|
|
3940
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
3941
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
3942
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
3943
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
3944
|
+
* skipped.
|
|
3945
|
+
*
|
|
3946
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
3947
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
3948
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
3949
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
3950
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
3951
|
+
*/
|
|
3952
|
+
resolveLatestCompletedRunPointer(events, streamPath) {
|
|
3953
|
+
let priorEntryOffset = null;
|
|
3954
|
+
let currentEntryOffset = null;
|
|
3955
|
+
let positionInEntry = 0;
|
|
3956
|
+
let latest = null;
|
|
3957
|
+
for (const event of events) {
|
|
3958
|
+
const headers = isRecord(event.headers) ? event.headers : void 0;
|
|
3959
|
+
const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
|
|
3960
|
+
if (eventOffset !== currentEntryOffset) {
|
|
3961
|
+
priorEntryOffset = currentEntryOffset;
|
|
3962
|
+
currentEntryOffset = eventOffset;
|
|
3963
|
+
positionInEntry = 0;
|
|
3964
|
+
}
|
|
3965
|
+
positionInEntry++;
|
|
3966
|
+
if (event.type !== `run`) continue;
|
|
3967
|
+
if (headers?.operation === `delete`) continue;
|
|
3968
|
+
if (!isRecord(event.value)) continue;
|
|
3969
|
+
if (event.value.status !== `completed`) continue;
|
|
3970
|
+
latest = {
|
|
3971
|
+
offset: priorEntryOffset,
|
|
3972
|
+
subOffset: positionInEntry
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
|
|
3976
|
+
return latest;
|
|
3977
|
+
}
|
|
3978
|
+
/**
|
|
3914
3979
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
3915
3980
|
* applied at the root. After filtering the root's manifest at the fork
|
|
3916
3981
|
* pointer, only children whose manifest entries landed at or before the
|
|
@@ -7857,7 +7922,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
|
|
|
7857
7922
|
}
|
|
7858
7923
|
if (method === `PUT` || method === `POST`) {
|
|
7859
7924
|
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
7860
|
-
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl))
|
|
7925
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
|
|
7926
|
+
if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
|
|
7927
|
+
return void 0;
|
|
7928
|
+
}
|
|
7861
7929
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
7862
7930
|
}
|
|
7863
7931
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
@@ -8017,6 +8085,14 @@ function agentUrlPath(value) {
|
|
|
8017
8085
|
return value;
|
|
8018
8086
|
}
|
|
8019
8087
|
}
|
|
8088
|
+
async function hasValidAgentWriteToken(request, ctx, fromAgent) {
|
|
8089
|
+
const agentUrl = agentUrlPath(fromAgent);
|
|
8090
|
+
const token = writeTokenFromRequest(request);
|
|
8091
|
+
if (!token) return false;
|
|
8092
|
+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
|
|
8093
|
+
if (!agentEntity) return false;
|
|
8094
|
+
return ctx.entityManager.isValidWriteToken(agentEntity, token);
|
|
8095
|
+
}
|
|
8020
8096
|
const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
|
|
8021
8097
|
payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
|
|
8022
8098
|
position: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
@@ -8038,7 +8114,19 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
|
|
|
8038
8114
|
fork_pointer: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
|
|
8039
8115
|
offset: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Null()]),
|
|
8040
8116
|
sub_offset: __sinclair_typebox.Type.Number()
|
|
8041
|
-
}))
|
|
8117
|
+
})),
|
|
8118
|
+
anchor: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Literal(`latest_completed_run`)),
|
|
8119
|
+
parent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
8120
|
+
wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
|
|
8121
|
+
subscriberUrl: __sinclair_typebox.Type.String(),
|
|
8122
|
+
condition: wakeConditionSchema,
|
|
8123
|
+
debounceMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
|
|
8124
|
+
timeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
|
|
8125
|
+
includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
|
|
8126
|
+
manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
|
|
8127
|
+
})),
|
|
8128
|
+
initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
|
|
8129
|
+
tags: __sinclair_typebox.Type.Optional(stringRecordSchema$1)
|
|
8042
8130
|
});
|
|
8043
8131
|
const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
|
|
8044
8132
|
const entitySignalSchema = __sinclair_typebox.Type.Union([
|
|
@@ -8425,8 +8513,18 @@ async function forkEntity(request, ctx) {
|
|
|
8425
8513
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
8426
8514
|
if (principalMutationError) return principalMutationError;
|
|
8427
8515
|
const parsed = routeBody(request);
|
|
8516
|
+
if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
|
|
8428
8517
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
8429
8518
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
8519
|
+
if (parsed.parent !== void 0) {
|
|
8520
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
|
|
8521
|
+
if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
|
|
8522
|
+
if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
|
|
8523
|
+
}
|
|
8524
|
+
if (parsed.wake !== void 0) {
|
|
8525
|
+
if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
|
|
8526
|
+
if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
|
|
8527
|
+
}
|
|
8430
8528
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
8431
8529
|
rootInstanceId: parsed.instance_id,
|
|
8432
8530
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
@@ -8434,9 +8532,17 @@ async function forkEntity(request, ctx) {
|
|
|
8434
8532
|
...parsed.fork_pointer && { forkPointer: {
|
|
8435
8533
|
offset: parsed.fork_pointer.offset,
|
|
8436
8534
|
subOffset: parsed.fork_pointer.sub_offset
|
|
8437
|
-
} }
|
|
8535
|
+
} },
|
|
8536
|
+
...parsed.anchor && { anchor: parsed.anchor },
|
|
8537
|
+
...parsed.parent !== void 0 && { parent: parsed.parent },
|
|
8538
|
+
...parsed.wake !== void 0 && { wake: parsed.wake },
|
|
8539
|
+
...parsed.tags !== void 0 && { tags: parsed.tags }
|
|
8438
8540
|
});
|
|
8439
8541
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
8542
|
+
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
|
|
8543
|
+
from: parsed.parent ?? ctx.principal.url,
|
|
8544
|
+
payload: parsed.initialMessage
|
|
8545
|
+
});
|
|
8440
8546
|
return (0, itty_router.json)({
|
|
8441
8547
|
root: toPublicEntity(result.root),
|
|
8442
8548
|
entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
|
|
@@ -8449,7 +8555,10 @@ async function sendEntity(request, ctx) {
|
|
|
8449
8555
|
if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
|
|
8450
8556
|
if (parsed.from_agent !== void 0) {
|
|
8451
8557
|
const principalAgentUrl = agentUrlForPrincipal(principal);
|
|
8452
|
-
|
|
8558
|
+
const fromAgentUrl = agentUrlPath(parsed.from_agent);
|
|
8559
|
+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
|
|
8560
|
+
const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
|
|
8561
|
+
if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
|
|
8453
8562
|
}
|
|
8454
8563
|
await ctx.entityManager.ensurePrincipal(principal);
|
|
8455
8564
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
package/dist/index.d.cts
CHANGED
|
@@ -5245,6 +5245,38 @@ type ForkSubtreeOptions = {
|
|
|
5245
5245
|
* `main` stream; shared-state streams clone at HEAD regardless.
|
|
5246
5246
|
*/
|
|
5247
5247
|
forkPointer?: EventPointer;
|
|
5248
|
+
/**
|
|
5249
|
+
* Named server-resolved anchor. Resolves to a concrete `forkPointer`
|
|
5250
|
+
* by reading the source root's `main` history. Mutually exclusive with
|
|
5251
|
+
* `forkPointer`. Use this when the caller doesn't have access to the
|
|
5252
|
+
* source's per-row pointer side-table (e.g. an agent forking a session
|
|
5253
|
+
* via a tool).
|
|
5254
|
+
*
|
|
5255
|
+
* - `latest_completed_run` — the most recent `runs` row on the source
|
|
5256
|
+
* with `status === 'completed'`. Errors if no such row exists. This
|
|
5257
|
+
* is the eligibility rule the per-row "Fork from here" UI uses.
|
|
5258
|
+
*/
|
|
5259
|
+
anchor?: `latest_completed_run`;
|
|
5260
|
+
/**
|
|
5261
|
+
* Optional parent URL for the new root fork entity. When set, the
|
|
5262
|
+
* new fork is a CHILD of this URL — its `parent` field is overridden
|
|
5263
|
+
* (rather than inherited from the source, which is `null` for the
|
|
5264
|
+
* only allowed source: a top-level entity). Pairs with `wake` to give
|
|
5265
|
+
* the parent reply-delivery the same way spawn does.
|
|
5266
|
+
*/
|
|
5267
|
+
parent?: string;
|
|
5268
|
+
/**
|
|
5269
|
+
* Optional wake subscription registered on the new root fork at fork
|
|
5270
|
+
* time. Same shape and semantics as `spawn`'s `wake` (see
|
|
5271
|
+
* `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
|
|
5272
|
+
* to wire reply delivery via the parent's manifest-anchored wake.
|
|
5273
|
+
*/
|
|
5274
|
+
wake?: TypedSpawnRequest[`wake`];
|
|
5275
|
+
/**
|
|
5276
|
+
* Optional tags stamped onto the new root fork entity in addition
|
|
5277
|
+
* to those copied from the source. Mirrors `spawn`'s `tags`.
|
|
5278
|
+
*/
|
|
5279
|
+
tags?: Record<string, string>;
|
|
5248
5280
|
};
|
|
5249
5281
|
type ForkResult = {
|
|
5250
5282
|
root: ElectricAgentsEntity;
|
|
@@ -5333,6 +5365,20 @@ declare class EntityManager {
|
|
|
5333
5365
|
* - `pointer.subOffset` is in `[1, total events past the anchor]`.
|
|
5334
5366
|
*/
|
|
5335
5367
|
private resolveForkPointerTarget;
|
|
5368
|
+
/**
|
|
5369
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
5370
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
5371
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
5372
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
5373
|
+
* skipped.
|
|
5374
|
+
*
|
|
5375
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
5376
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
5377
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
5378
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
5379
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
5380
|
+
*/
|
|
5381
|
+
private resolveLatestCompletedRunPointer;
|
|
5336
5382
|
/**
|
|
5337
5383
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
5338
5384
|
* applied at the root. After filtering the root's manifest at the fork
|
package/dist/index.d.ts
CHANGED
|
@@ -5246,6 +5246,38 @@ type ForkSubtreeOptions = {
|
|
|
5246
5246
|
* `main` stream; shared-state streams clone at HEAD regardless.
|
|
5247
5247
|
*/
|
|
5248
5248
|
forkPointer?: EventPointer;
|
|
5249
|
+
/**
|
|
5250
|
+
* Named server-resolved anchor. Resolves to a concrete `forkPointer`
|
|
5251
|
+
* by reading the source root's `main` history. Mutually exclusive with
|
|
5252
|
+
* `forkPointer`. Use this when the caller doesn't have access to the
|
|
5253
|
+
* source's per-row pointer side-table (e.g. an agent forking a session
|
|
5254
|
+
* via a tool).
|
|
5255
|
+
*
|
|
5256
|
+
* - `latest_completed_run` — the most recent `runs` row on the source
|
|
5257
|
+
* with `status === 'completed'`. Errors if no such row exists. This
|
|
5258
|
+
* is the eligibility rule the per-row "Fork from here" UI uses.
|
|
5259
|
+
*/
|
|
5260
|
+
anchor?: `latest_completed_run`;
|
|
5261
|
+
/**
|
|
5262
|
+
* Optional parent URL for the new root fork entity. When set, the
|
|
5263
|
+
* new fork is a CHILD of this URL — its `parent` field is overridden
|
|
5264
|
+
* (rather than inherited from the source, which is `null` for the
|
|
5265
|
+
* only allowed source: a top-level entity). Pairs with `wake` to give
|
|
5266
|
+
* the parent reply-delivery the same way spawn does.
|
|
5267
|
+
*/
|
|
5268
|
+
parent?: string;
|
|
5269
|
+
/**
|
|
5270
|
+
* Optional wake subscription registered on the new root fork at fork
|
|
5271
|
+
* time. Same shape and semantics as `spawn`'s `wake` (see
|
|
5272
|
+
* `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
|
|
5273
|
+
* to wire reply delivery via the parent's manifest-anchored wake.
|
|
5274
|
+
*/
|
|
5275
|
+
wake?: TypedSpawnRequest[`wake`];
|
|
5276
|
+
/**
|
|
5277
|
+
* Optional tags stamped onto the new root fork entity in addition
|
|
5278
|
+
* to those copied from the source. Mirrors `spawn`'s `tags`.
|
|
5279
|
+
*/
|
|
5280
|
+
tags?: Record<string, string>;
|
|
5249
5281
|
};
|
|
5250
5282
|
type ForkResult = {
|
|
5251
5283
|
root: ElectricAgentsEntity;
|
|
@@ -5334,6 +5366,20 @@ declare class EntityManager {
|
|
|
5334
5366
|
* - `pointer.subOffset` is in `[1, total events past the anchor]`.
|
|
5335
5367
|
*/
|
|
5336
5368
|
private resolveForkPointerTarget;
|
|
5369
|
+
/**
|
|
5370
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
5371
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
5372
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
5373
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
5374
|
+
* skipped.
|
|
5375
|
+
*
|
|
5376
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
5377
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
5378
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
5379
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
5380
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
5381
|
+
*/
|
|
5382
|
+
private resolveLatestCompletedRunPointer;
|
|
5337
5383
|
/**
|
|
5338
5384
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
5339
5385
|
* applied at the root. After filtering the root's manifest at the fork
|
package/dist/index.js
CHANGED
|
@@ -3635,9 +3635,10 @@ var EntityManager = class {
|
|
|
3635
3635
|
const workLocks = new Set();
|
|
3636
3636
|
const writeEntityLocks = new Set();
|
|
3637
3637
|
const writeStreamLocks = new Set();
|
|
3638
|
+
const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
|
|
3638
3639
|
try {
|
|
3639
3640
|
let sourceTree;
|
|
3640
|
-
if (
|
|
3641
|
+
if (usePointerPath) {
|
|
3641
3642
|
const rootEntity = await this.registry.getEntity(rootUrl);
|
|
3642
3643
|
if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3643
3644
|
if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
|
|
@@ -3646,10 +3647,13 @@ var EntityManager = class {
|
|
|
3646
3647
|
const sourceRoot = sourceTree[0];
|
|
3647
3648
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3648
3649
|
let preFilteredRoot;
|
|
3649
|
-
|
|
3650
|
+
let effectiveForkPointer = opts.forkPointer;
|
|
3651
|
+
if (usePointerPath) {
|
|
3650
3652
|
const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
|
|
3651
3653
|
const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
|
|
3652
|
-
|
|
3654
|
+
if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
|
|
3655
|
+
if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
|
|
3656
|
+
const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
|
|
3653
3657
|
const filteredEvents = flat.slice(0, target);
|
|
3654
3658
|
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
|
|
3655
3659
|
const sharedStateIds = new Set();
|
|
@@ -3662,7 +3666,7 @@ var EntityManager = class {
|
|
|
3662
3666
|
};
|
|
3663
3667
|
}
|
|
3664
3668
|
const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
|
|
3665
|
-
if (
|
|
3669
|
+
if (usePointerPath) {
|
|
3666
3670
|
const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
|
|
3667
3671
|
if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
|
|
3668
3672
|
}
|
|
@@ -3687,6 +3691,14 @@ var EntityManager = class {
|
|
|
3687
3691
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3688
3692
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3689
3693
|
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
|
|
3694
|
+
const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
3695
|
+
if (rootPlanForOverrides) {
|
|
3696
|
+
if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
|
|
3697
|
+
if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
|
|
3698
|
+
...rootPlanForOverrides.fork.tags ?? {},
|
|
3699
|
+
...opts.tags
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3690
3702
|
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3691
3703
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
3692
3704
|
const createdStreams = [];
|
|
@@ -3695,7 +3707,7 @@ var EntityManager = class {
|
|
|
3695
3707
|
try {
|
|
3696
3708
|
for (const plan of entityPlans) {
|
|
3697
3709
|
const isRoot = plan.source.url === rootUrl;
|
|
3698
|
-
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot &&
|
|
3710
|
+
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
|
|
3699
3711
|
createdStreams.push(plan.fork.streams.main);
|
|
3700
3712
|
}
|
|
3701
3713
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
@@ -3722,6 +3734,20 @@ var EntityManager = class {
|
|
|
3722
3734
|
await this.registry.createEntity(plan.fork);
|
|
3723
3735
|
createdEntities.push(plan.fork.url);
|
|
3724
3736
|
}
|
|
3737
|
+
if (opts.wake !== void 0) {
|
|
3738
|
+
const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
|
|
3739
|
+
if (rootPlan) await this.wakeRegistry.register({
|
|
3740
|
+
tenantId: this.tenantId,
|
|
3741
|
+
subscriberUrl: opts.wake.subscriberUrl,
|
|
3742
|
+
sourceUrl: rootPlan.fork.url,
|
|
3743
|
+
condition: opts.wake.condition,
|
|
3744
|
+
debounceMs: opts.wake.debounceMs,
|
|
3745
|
+
timeoutMs: opts.wake.timeoutMs,
|
|
3746
|
+
oneShot: false,
|
|
3747
|
+
includeResponse: opts.wake.includeResponse,
|
|
3748
|
+
manifestKey: opts.wake.manifestKey
|
|
3749
|
+
});
|
|
3750
|
+
}
|
|
3725
3751
|
for (const plan of entityPlans) {
|
|
3726
3752
|
const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
|
|
3727
3753
|
await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
|
|
@@ -3882,6 +3908,45 @@ var EntityManager = class {
|
|
|
3882
3908
|
return positionAtAnchor + pointer.subOffset;
|
|
3883
3909
|
}
|
|
3884
3910
|
/**
|
|
3911
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
3912
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
3913
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
3914
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
3915
|
+
* skipped.
|
|
3916
|
+
*
|
|
3917
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
3918
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
3919
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
3920
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
3921
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
3922
|
+
*/
|
|
3923
|
+
resolveLatestCompletedRunPointer(events, streamPath) {
|
|
3924
|
+
let priorEntryOffset = null;
|
|
3925
|
+
let currentEntryOffset = null;
|
|
3926
|
+
let positionInEntry = 0;
|
|
3927
|
+
let latest = null;
|
|
3928
|
+
for (const event of events) {
|
|
3929
|
+
const headers = isRecord(event.headers) ? event.headers : void 0;
|
|
3930
|
+
const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
|
|
3931
|
+
if (eventOffset !== currentEntryOffset) {
|
|
3932
|
+
priorEntryOffset = currentEntryOffset;
|
|
3933
|
+
currentEntryOffset = eventOffset;
|
|
3934
|
+
positionInEntry = 0;
|
|
3935
|
+
}
|
|
3936
|
+
positionInEntry++;
|
|
3937
|
+
if (event.type !== `run`) continue;
|
|
3938
|
+
if (headers?.operation === `delete`) continue;
|
|
3939
|
+
if (!isRecord(event.value)) continue;
|
|
3940
|
+
if (event.value.status !== `completed`) continue;
|
|
3941
|
+
latest = {
|
|
3942
|
+
offset: priorEntryOffset,
|
|
3943
|
+
subOffset: positionInEntry
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3946
|
+
if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
|
|
3947
|
+
return latest;
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3885
3950
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
3886
3951
|
* applied at the root. After filtering the root's manifest at the fork
|
|
3887
3952
|
* pointer, only children whose manifest entries landed at or before the
|
|
@@ -7828,7 +7893,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
|
|
|
7828
7893
|
}
|
|
7829
7894
|
if (method === `PUT` || method === `POST`) {
|
|
7830
7895
|
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
7831
|
-
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl))
|
|
7896
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
|
|
7897
|
+
if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
|
|
7898
|
+
return void 0;
|
|
7899
|
+
}
|
|
7832
7900
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
7833
7901
|
}
|
|
7834
7902
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
@@ -7988,6 +8056,14 @@ function agentUrlPath(value) {
|
|
|
7988
8056
|
return value;
|
|
7989
8057
|
}
|
|
7990
8058
|
}
|
|
8059
|
+
async function hasValidAgentWriteToken(request, ctx, fromAgent) {
|
|
8060
|
+
const agentUrl = agentUrlPath(fromAgent);
|
|
8061
|
+
const token = writeTokenFromRequest(request);
|
|
8062
|
+
if (!token) return false;
|
|
8063
|
+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
|
|
8064
|
+
if (!agentEntity) return false;
|
|
8065
|
+
return ctx.entityManager.isValidWriteToken(agentEntity, token);
|
|
8066
|
+
}
|
|
7991
8067
|
const inboxMessageBodySchema = Type.Object({
|
|
7992
8068
|
payload: Type.Optional(Type.Unknown()),
|
|
7993
8069
|
position: Type.Optional(Type.String()),
|
|
@@ -8009,7 +8085,19 @@ const forkBodySchema = Type.Object({
|
|
|
8009
8085
|
fork_pointer: Type.Optional(Type.Object({
|
|
8010
8086
|
offset: Type.Union([Type.String(), Type.Null()]),
|
|
8011
8087
|
sub_offset: Type.Number()
|
|
8012
|
-
}))
|
|
8088
|
+
})),
|
|
8089
|
+
anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
|
|
8090
|
+
parent: Type.Optional(Type.String()),
|
|
8091
|
+
wake: Type.Optional(Type.Object({
|
|
8092
|
+
subscriberUrl: Type.String(),
|
|
8093
|
+
condition: wakeConditionSchema,
|
|
8094
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
8095
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
8096
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
8097
|
+
manifestKey: Type.Optional(Type.String())
|
|
8098
|
+
})),
|
|
8099
|
+
initialMessage: Type.Optional(Type.Unknown()),
|
|
8100
|
+
tags: Type.Optional(stringRecordSchema$1)
|
|
8013
8101
|
});
|
|
8014
8102
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
8015
8103
|
const entitySignalSchema = Type.Union([
|
|
@@ -8396,8 +8484,18 @@ async function forkEntity(request, ctx) {
|
|
|
8396
8484
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
8397
8485
|
if (principalMutationError) return principalMutationError;
|
|
8398
8486
|
const parsed = routeBody(request);
|
|
8487
|
+
if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
|
|
8399
8488
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
8400
8489
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
8490
|
+
if (parsed.parent !== void 0) {
|
|
8491
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
|
|
8492
|
+
if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
|
|
8493
|
+
if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
|
|
8494
|
+
}
|
|
8495
|
+
if (parsed.wake !== void 0) {
|
|
8496
|
+
if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
|
|
8497
|
+
if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
|
|
8498
|
+
}
|
|
8401
8499
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
8402
8500
|
rootInstanceId: parsed.instance_id,
|
|
8403
8501
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
@@ -8405,9 +8503,17 @@ async function forkEntity(request, ctx) {
|
|
|
8405
8503
|
...parsed.fork_pointer && { forkPointer: {
|
|
8406
8504
|
offset: parsed.fork_pointer.offset,
|
|
8407
8505
|
subOffset: parsed.fork_pointer.sub_offset
|
|
8408
|
-
} }
|
|
8506
|
+
} },
|
|
8507
|
+
...parsed.anchor && { anchor: parsed.anchor },
|
|
8508
|
+
...parsed.parent !== void 0 && { parent: parsed.parent },
|
|
8509
|
+
...parsed.wake !== void 0 && { wake: parsed.wake },
|
|
8510
|
+
...parsed.tags !== void 0 && { tags: parsed.tags }
|
|
8409
8511
|
});
|
|
8410
8512
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
8513
|
+
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
|
|
8514
|
+
from: parsed.parent ?? ctx.principal.url,
|
|
8515
|
+
payload: parsed.initialMessage
|
|
8516
|
+
});
|
|
8411
8517
|
return json({
|
|
8412
8518
|
root: toPublicEntity(result.root),
|
|
8413
8519
|
entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
|
|
@@ -8420,7 +8526,10 @@ async function sendEntity(request, ctx) {
|
|
|
8420
8526
|
if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
|
|
8421
8527
|
if (parsed.from_agent !== void 0) {
|
|
8422
8528
|
const principalAgentUrl = agentUrlForPrincipal(principal);
|
|
8423
|
-
|
|
8529
|
+
const fromAgentUrl = agentUrlPath(parsed.from_agent);
|
|
8530
|
+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
|
|
8531
|
+
const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
|
|
8532
|
+
if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
|
|
8424
8533
|
}
|
|
8425
8534
|
await ctx.entityManager.ensurePrincipal(principal);
|
|
8426
8535
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
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.19",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -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.12"
|
|
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-
|
|
70
|
-
"@electric-ax/agents-server-
|
|
68
|
+
"@electric-ax/agents": "0.4.16",
|
|
69
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.11",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.19"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/entity-manager.ts
CHANGED
|
@@ -175,6 +175,38 @@ type ForkSubtreeOptions = {
|
|
|
175
175
|
* `main` stream; shared-state streams clone at HEAD regardless.
|
|
176
176
|
*/
|
|
177
177
|
forkPointer?: EventPointer
|
|
178
|
+
/**
|
|
179
|
+
* Named server-resolved anchor. Resolves to a concrete `forkPointer`
|
|
180
|
+
* by reading the source root's `main` history. Mutually exclusive with
|
|
181
|
+
* `forkPointer`. Use this when the caller doesn't have access to the
|
|
182
|
+
* source's per-row pointer side-table (e.g. an agent forking a session
|
|
183
|
+
* via a tool).
|
|
184
|
+
*
|
|
185
|
+
* - `latest_completed_run` — the most recent `runs` row on the source
|
|
186
|
+
* with `status === 'completed'`. Errors if no such row exists. This
|
|
187
|
+
* is the eligibility rule the per-row "Fork from here" UI uses.
|
|
188
|
+
*/
|
|
189
|
+
anchor?: `latest_completed_run`
|
|
190
|
+
/**
|
|
191
|
+
* Optional parent URL for the new root fork entity. When set, the
|
|
192
|
+
* new fork is a CHILD of this URL — its `parent` field is overridden
|
|
193
|
+
* (rather than inherited from the source, which is `null` for the
|
|
194
|
+
* only allowed source: a top-level entity). Pairs with `wake` to give
|
|
195
|
+
* the parent reply-delivery the same way spawn does.
|
|
196
|
+
*/
|
|
197
|
+
parent?: string
|
|
198
|
+
/**
|
|
199
|
+
* Optional wake subscription registered on the new root fork at fork
|
|
200
|
+
* time. Same shape and semantics as `spawn`'s `wake` (see
|
|
201
|
+
* `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
|
|
202
|
+
* to wire reply delivery via the parent's manifest-anchored wake.
|
|
203
|
+
*/
|
|
204
|
+
wake?: TypedSpawnRequest[`wake`]
|
|
205
|
+
/**
|
|
206
|
+
* Optional tags stamped onto the new root fork entity in addition
|
|
207
|
+
* to those copied from the source. Mirrors `spawn`'s `tags`.
|
|
208
|
+
*/
|
|
209
|
+
tags?: Record<string, string>
|
|
178
210
|
}
|
|
179
211
|
|
|
180
212
|
type ForkEntityPlan = {
|
|
@@ -909,6 +941,13 @@ export class EntityManager {
|
|
|
909
941
|
const writeEntityLocks = new Set<string>()
|
|
910
942
|
const writeStreamLocks = new Set<string>()
|
|
911
943
|
|
|
944
|
+
// Anchor-forks resolve to a concrete pointer server-side by reading the
|
|
945
|
+
// source's `main` history. Below this point the resolved pointer (or
|
|
946
|
+
// an explicit one from `opts.forkPointer`) drives the same code path —
|
|
947
|
+
// anchor handling collapses entirely into the pointer-fork flow.
|
|
948
|
+
const usePointerPath =
|
|
949
|
+
opts.forkPointer !== undefined || opts.anchor !== undefined
|
|
950
|
+
|
|
912
951
|
try {
|
|
913
952
|
// For pointer-forks we read the source root HISTORICALLY at a
|
|
914
953
|
// frozen offset, so concurrent activity on the root past the
|
|
@@ -920,7 +959,7 @@ export class EntityManager {
|
|
|
920
959
|
// those are HEAD-cloned and need a stable snapshot. For HEAD-forks
|
|
921
960
|
// the old all-idle requirement still applies.
|
|
922
961
|
let sourceTree: Array<ElectricAgentsEntity>
|
|
923
|
-
if (
|
|
962
|
+
if (usePointerPath) {
|
|
924
963
|
const rootEntity = await this.registry.getEntity(rootUrl)
|
|
925
964
|
if (!rootEntity) {
|
|
926
965
|
throw new ElectricAgentsError(
|
|
@@ -949,11 +988,11 @@ export class EntityManager {
|
|
|
949
988
|
)
|
|
950
989
|
}
|
|
951
990
|
|
|
952
|
-
// When forking at a pointer, pre-read the root's main,
|
|
953
|
-
// pointer against the source's true history, and
|
|
954
|
-
// root-at-pointer snapshot fragments. The pointer
|
|
955
|
-
// the root's `main` stream. Descendants kept by
|
|
956
|
-
// are forked at HEAD.
|
|
991
|
+
// When forking at a pointer (or anchor), pre-read the root's main,
|
|
992
|
+
// validate the pointer against the source's true history, and
|
|
993
|
+
// materialise the root-at-pointer snapshot fragments. The pointer
|
|
994
|
+
// only applies to the root's `main` stream. Descendants kept by
|
|
995
|
+
// the manifest filter are forked at HEAD.
|
|
957
996
|
//
|
|
958
997
|
// Pointer→position translation: the runtime mints pointers as
|
|
959
998
|
// `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
|
|
@@ -972,16 +1011,32 @@ export class EntityManager {
|
|
|
972
1011
|
sharedStateIds: Set<string>
|
|
973
1012
|
}
|
|
974
1013
|
| undefined
|
|
975
|
-
|
|
1014
|
+
let effectiveForkPointer: EventPointer | undefined = opts.forkPointer
|
|
1015
|
+
if (usePointerPath) {
|
|
976
1016
|
const sourceEvents = await this.streamClient.readJson<
|
|
977
1017
|
Record<string, unknown>
|
|
978
1018
|
>(sourceRoot.streams.main)
|
|
979
1019
|
const flat = sourceEvents.flatMap((item) =>
|
|
980
1020
|
Array.isArray(item) ? item : [item]
|
|
981
1021
|
) as Array<Record<string, unknown>>
|
|
1022
|
+
if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) {
|
|
1023
|
+
effectiveForkPointer = this.resolveLatestCompletedRunPointer(
|
|
1024
|
+
flat,
|
|
1025
|
+
sourceRoot.streams.main
|
|
1026
|
+
)
|
|
1027
|
+
}
|
|
1028
|
+
if (!effectiveForkPointer) {
|
|
1029
|
+
// Defensive — would only fire on a future anchor variant we
|
|
1030
|
+
// forget to handle above.
|
|
1031
|
+
throw new ElectricAgentsError(
|
|
1032
|
+
ErrCodeInvalidRequest,
|
|
1033
|
+
`Internal: pointer-path fork with no resolved pointer`,
|
|
1034
|
+
500
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
982
1037
|
const target = this.resolveForkPointerTarget(
|
|
983
1038
|
flat,
|
|
984
|
-
|
|
1039
|
+
effectiveForkPointer,
|
|
985
1040
|
sourceRoot.streams.main
|
|
986
1041
|
)
|
|
987
1042
|
const filteredEvents = flat.slice(0, target)
|
|
@@ -1013,7 +1068,7 @@ export class EntityManager {
|
|
|
1013
1068
|
// subtree except the root) are HEAD-cloned, so they must be idle
|
|
1014
1069
|
// before we read their snapshots. Wait+lock only those — the root
|
|
1015
1070
|
// was skipped above.
|
|
1016
|
-
if (
|
|
1071
|
+
if (usePointerPath) {
|
|
1017
1072
|
const descendants = effectiveSubtree.filter(
|
|
1018
1073
|
(entity) => entity.url !== sourceRoot.url
|
|
1019
1074
|
)
|
|
@@ -1066,6 +1121,28 @@ export class EntityManager {
|
|
|
1066
1121
|
opts.createdBy
|
|
1067
1122
|
)
|
|
1068
1123
|
|
|
1124
|
+
// Override fields on the new root fork's plan from caller opts.
|
|
1125
|
+
// Plumbed through from `forkBodySchema` — descendants in
|
|
1126
|
+
// `effectiveSubtree` keep what `buildForkEntityPlans` copied.
|
|
1127
|
+
//
|
|
1128
|
+
// - `parent`: child-fork relationship (vs the default inheritance
|
|
1129
|
+
// from the source, which is `null` for a top-level source).
|
|
1130
|
+
// - `tags`: merged on top of source tags (caller-supplied wins).
|
|
1131
|
+
const rootPlanForOverrides = entityPlans.find(
|
|
1132
|
+
(plan) => plan.source.url === rootUrl
|
|
1133
|
+
)
|
|
1134
|
+
if (rootPlanForOverrides) {
|
|
1135
|
+
if (opts.parent !== undefined) {
|
|
1136
|
+
rootPlanForOverrides.fork.parent = opts.parent
|
|
1137
|
+
}
|
|
1138
|
+
if (opts.tags !== undefined) {
|
|
1139
|
+
rootPlanForOverrides.fork.tags = {
|
|
1140
|
+
...(rootPlanForOverrides.fork.tags ?? {}),
|
|
1141
|
+
...opts.tags,
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1069
1146
|
this.addForkLocks(
|
|
1070
1147
|
this.forkWriteLockedEntities,
|
|
1071
1148
|
effectiveSubtree.map((entity) => entity.url),
|
|
@@ -1090,8 +1167,8 @@ export class EntityManager {
|
|
|
1090
1167
|
await this.streamClient.fork(
|
|
1091
1168
|
plan.fork.streams.main,
|
|
1092
1169
|
plan.source.streams.main,
|
|
1093
|
-
isRoot &&
|
|
1094
|
-
? { forkPointer:
|
|
1170
|
+
isRoot && effectiveForkPointer
|
|
1171
|
+
? { forkPointer: effectiveForkPointer }
|
|
1095
1172
|
: undefined
|
|
1096
1173
|
)
|
|
1097
1174
|
createdStreams.push(plan.fork.streams.main)
|
|
@@ -1146,6 +1223,30 @@ export class EntityManager {
|
|
|
1146
1223
|
createdEntities.push(plan.fork.url)
|
|
1147
1224
|
}
|
|
1148
1225
|
|
|
1226
|
+
// Register a wake subscription on the new root fork when the
|
|
1227
|
+
// caller asked for one. Mirrors the spawn flow's wake handling
|
|
1228
|
+
// (entity-manager.spawnInner). Rollback below already calls
|
|
1229
|
+
// `wakeRegistry.unregisterBySource(forkUrl)` for every created
|
|
1230
|
+
// entity, so this cleans up automatically on failure.
|
|
1231
|
+
if (opts.wake !== undefined) {
|
|
1232
|
+
const rootPlan = entityPlans.find(
|
|
1233
|
+
(plan) => plan.source.url === rootUrl
|
|
1234
|
+
)
|
|
1235
|
+
if (rootPlan) {
|
|
1236
|
+
await this.wakeRegistry.register({
|
|
1237
|
+
tenantId: this.tenantId,
|
|
1238
|
+
subscriberUrl: opts.wake.subscriberUrl,
|
|
1239
|
+
sourceUrl: rootPlan.fork.url,
|
|
1240
|
+
condition: opts.wake.condition,
|
|
1241
|
+
debounceMs: opts.wake.debounceMs,
|
|
1242
|
+
timeoutMs: opts.wake.timeoutMs,
|
|
1243
|
+
oneShot: false,
|
|
1244
|
+
includeResponse: opts.wake.includeResponse,
|
|
1245
|
+
manifestKey: opts.wake.manifestKey,
|
|
1246
|
+
})
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1149
1250
|
for (const plan of entityPlans) {
|
|
1150
1251
|
const manifests =
|
|
1151
1252
|
activeManifestsByEntity.get(plan.fork.url) ?? new Map()
|
|
@@ -1461,6 +1562,58 @@ export class EntityManager {
|
|
|
1461
1562
|
return positionAtAnchor + pointer.subOffset
|
|
1462
1563
|
}
|
|
1463
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
1567
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
1568
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
1569
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
1570
|
+
* skipped.
|
|
1571
|
+
*
|
|
1572
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
1573
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
1574
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
1575
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
1576
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
1577
|
+
*/
|
|
1578
|
+
private resolveLatestCompletedRunPointer(
|
|
1579
|
+
events: ReadonlyArray<Record<string, unknown>>,
|
|
1580
|
+
streamPath: string
|
|
1581
|
+
): EventPointer {
|
|
1582
|
+
let priorEntryOffset: string | null = null
|
|
1583
|
+
let currentEntryOffset: string | null = null
|
|
1584
|
+
let positionInEntry = 0
|
|
1585
|
+
let latest: EventPointer | null = null
|
|
1586
|
+
|
|
1587
|
+
for (const event of events) {
|
|
1588
|
+
const headers = isRecord(event.headers) ? event.headers : undefined
|
|
1589
|
+
const eventOffset =
|
|
1590
|
+
typeof headers?.offset === `string` ? headers.offset : null
|
|
1591
|
+
|
|
1592
|
+
if (eventOffset !== currentEntryOffset) {
|
|
1593
|
+
priorEntryOffset = currentEntryOffset
|
|
1594
|
+
currentEntryOffset = eventOffset
|
|
1595
|
+
positionInEntry = 0
|
|
1596
|
+
}
|
|
1597
|
+
positionInEntry++
|
|
1598
|
+
|
|
1599
|
+
if (event.type !== `run`) continue
|
|
1600
|
+
if (headers?.operation === `delete`) continue
|
|
1601
|
+
if (!isRecord(event.value)) continue
|
|
1602
|
+
if (event.value.status !== `completed`) continue
|
|
1603
|
+
|
|
1604
|
+
latest = { offset: priorEntryOffset, subOffset: positionInEntry }
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (!latest) {
|
|
1608
|
+
throw new ElectricAgentsError(
|
|
1609
|
+
ErrCodeInvalidRequest,
|
|
1610
|
+
`Source ${streamPath} has no completed run to fork from`,
|
|
1611
|
+
400
|
|
1612
|
+
)
|
|
1613
|
+
}
|
|
1614
|
+
return latest
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1464
1617
|
/**
|
|
1465
1618
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
1466
1619
|
* applied at the root. After filtering the root's manifest at the fork
|
|
@@ -717,6 +717,19 @@ async function authorizeDurableStreamAccess(
|
|
|
717
717
|
ownerEntityUrl
|
|
718
718
|
)
|
|
719
719
|
) {
|
|
720
|
+
// Bootstrap the link synchronously so subsequent reads on this stream
|
|
721
|
+
// (e.g. the runtime's preload right after `mkdb`) can resolve the owner
|
|
722
|
+
// without waiting for the entity's manifest stream event to be
|
|
723
|
+
// processed eventually-consistently. Without this, a brand-new
|
|
724
|
+
// shared-state always 401s on the first preload because the link row
|
|
725
|
+
// is only created later via `applyManifestEntitySource`.
|
|
726
|
+
if (ownerEntityUrl) {
|
|
727
|
+
await ctx.entityManager.registry.replaceSharedStateLink(
|
|
728
|
+
ownerEntityUrl,
|
|
729
|
+
`shared-state:${sharedStateId}`,
|
|
730
|
+
sharedStateId
|
|
731
|
+
)
|
|
732
|
+
}
|
|
720
733
|
return undefined
|
|
721
734
|
}
|
|
722
735
|
return apiError(
|
|
@@ -187,6 +187,19 @@ function agentUrlPath(value: string): string {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
async function hasValidAgentWriteToken(
|
|
191
|
+
request: AgentsRouteRequest,
|
|
192
|
+
ctx: TenantContext,
|
|
193
|
+
fromAgent: string
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const agentUrl = agentUrlPath(fromAgent)
|
|
196
|
+
const token = writeTokenFromRequest(request)
|
|
197
|
+
if (!token) return false
|
|
198
|
+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl)
|
|
199
|
+
if (!agentEntity) return false
|
|
200
|
+
return ctx.entityManager.isValidWriteToken(agentEntity, token)
|
|
201
|
+
}
|
|
202
|
+
|
|
190
203
|
const inboxMessageBodySchema = Type.Object({
|
|
191
204
|
payload: Type.Optional(Type.Unknown()),
|
|
192
205
|
position: Type.Optional(Type.String()),
|
|
@@ -219,6 +232,41 @@ const forkBodySchema = Type.Object({
|
|
|
219
232
|
sub_offset: Type.Number(),
|
|
220
233
|
})
|
|
221
234
|
),
|
|
235
|
+
// Named server-resolved anchor. Resolves to a concrete fork pointer on
|
|
236
|
+
// the source root's `main` server-side, so callers don't need access
|
|
237
|
+
// to the source's per-row pointer side-table. Mutually exclusive with
|
|
238
|
+
// `fork_pointer`.
|
|
239
|
+
anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
|
|
240
|
+
// Optional parent URL. When set, the new root fork is a CHILD of this
|
|
241
|
+
// URL (rather than inheriting the source's parent, which is `null`
|
|
242
|
+
// for the only allowed source — a top-level entity). Used by the
|
|
243
|
+
// agent `fork` tool to make a forking entity own its forks the same
|
|
244
|
+
// way a spawning entity owns its workers.
|
|
245
|
+
parent: Type.Optional(Type.String()),
|
|
246
|
+
// Optional wake subscription registered on the new root fork at fork
|
|
247
|
+
// time. Mirrors the `wake` field on spawn — the subscriber URL gets
|
|
248
|
+
// woken when the fork meets the named condition, with the response
|
|
249
|
+
// optionally inlined. Used to wire fork-as-child reply delivery
|
|
250
|
+
// through the same manifest-anchored mechanism spawn uses.
|
|
251
|
+
wake: Type.Optional(
|
|
252
|
+
Type.Object({
|
|
253
|
+
subscriberUrl: Type.String(),
|
|
254
|
+
condition: wakeConditionSchema,
|
|
255
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
256
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
257
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
258
|
+
manifestKey: Type.Optional(Type.String()),
|
|
259
|
+
})
|
|
260
|
+
),
|
|
261
|
+
// Optional initial inbox message for the new root fork. Folds
|
|
262
|
+
// fork+send into one round-trip for the caller. Not atomic with
|
|
263
|
+
// fork creation: the server sends it after the fork is created
|
|
264
|
+
// and dispatch-linked (see the `entityManager.send` call below),
|
|
265
|
+
// so a failed send can leave an idle dispatched fork.
|
|
266
|
+
initialMessage: Type.Optional(Type.Unknown()),
|
|
267
|
+
// Optional tags stamped on the new root fork entity in addition to
|
|
268
|
+
// those copied from the source. Mirrors spawn's `tags`.
|
|
269
|
+
tags: Type.Optional(stringRecordSchema),
|
|
222
270
|
})
|
|
223
271
|
|
|
224
272
|
const setTagBodySchema = Type.Object({
|
|
@@ -1088,8 +1136,57 @@ async function forkEntity(
|
|
|
1088
1136
|
if (principalMutationError) return principalMutationError
|
|
1089
1137
|
|
|
1090
1138
|
const parsed = routeBody<ForkBody>(request)
|
|
1139
|
+
if (parsed.fork_pointer && parsed.anchor) {
|
|
1140
|
+
return apiError(
|
|
1141
|
+
400,
|
|
1142
|
+
ErrCodeInvalidRequest,
|
|
1143
|
+
`fork_pointer and anchor are mutually exclusive`
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1091
1146
|
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
1092
1147
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
|
|
1148
|
+
|
|
1149
|
+
// Validate `parent` and `wake.subscriberUrl` before forking — mirrors
|
|
1150
|
+
// the spawn route's parent-validation flow. Without these checks, a
|
|
1151
|
+
// direct HTTP caller could attach a fork under an arbitrary parent or
|
|
1152
|
+
// register a wake firing to an arbitrary subscriber.
|
|
1153
|
+
if (parsed.parent !== undefined) {
|
|
1154
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent)
|
|
1155
|
+
if (!parent) {
|
|
1156
|
+
return apiError(404, ErrCodeNotFound, `Parent entity not found`)
|
|
1157
|
+
}
|
|
1158
|
+
if (!(await canAccessEntity(ctx, parent, `spawn`, request as Request))) {
|
|
1159
|
+
return apiError(
|
|
1160
|
+
401,
|
|
1161
|
+
ErrCodeUnauthorized,
|
|
1162
|
+
`Principal is not allowed to spawn children from ${parent.url}`
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (parsed.wake !== undefined) {
|
|
1167
|
+
// The only sensible target for a fork's wake is the new fork's
|
|
1168
|
+
// parent (so the parent gets woken when the fork's run finishes
|
|
1169
|
+
// — same model as spawn). Require parent + matching subscriber to
|
|
1170
|
+
// prevent a caller from registering a wake firing to an entity
|
|
1171
|
+
// they don't own. If subscriber-flexibility ever becomes a real
|
|
1172
|
+
// use case, replace this with a proper canAccessEntity check on
|
|
1173
|
+
// the subscriber.
|
|
1174
|
+
if (parsed.parent === undefined) {
|
|
1175
|
+
return apiError(
|
|
1176
|
+
400,
|
|
1177
|
+
ErrCodeInvalidRequest,
|
|
1178
|
+
`wake requires parent (the fork's wake fires to its parent)`
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1181
|
+
if (parsed.wake.subscriberUrl !== parsed.parent) {
|
|
1182
|
+
return apiError(
|
|
1183
|
+
401,
|
|
1184
|
+
ErrCodeUnauthorized,
|
|
1185
|
+
`wake.subscriberUrl must match parent`
|
|
1186
|
+
)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1093
1190
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
1094
1191
|
rootInstanceId: parsed.instance_id,
|
|
1095
1192
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
@@ -1100,10 +1197,24 @@ async function forkEntity(
|
|
|
1100
1197
|
subOffset: parsed.fork_pointer.sub_offset,
|
|
1101
1198
|
},
|
|
1102
1199
|
}),
|
|
1200
|
+
...(parsed.anchor && { anchor: parsed.anchor }),
|
|
1201
|
+
...(parsed.parent !== undefined && { parent: parsed.parent }),
|
|
1202
|
+
...(parsed.wake !== undefined && { wake: parsed.wake }),
|
|
1203
|
+
...(parsed.tags !== undefined && { tags: parsed.tags }),
|
|
1103
1204
|
})
|
|
1104
1205
|
for (const forkedEntity of result.entities) {
|
|
1105
1206
|
await linkEntityDispatchSubscription(ctx, forkedEntity)
|
|
1106
1207
|
}
|
|
1208
|
+
// Deliver the initial message via entityManager.send AFTER the
|
|
1209
|
+
// dispatch subscription is linked — same ordering spawn uses. Sending
|
|
1210
|
+
// before linking would land the inbox row on the stream before the
|
|
1211
|
+
// dispatcher is subscribed, and the dispatcher would never pick it up.
|
|
1212
|
+
if (parsed.initialMessage !== undefined) {
|
|
1213
|
+
await ctx.entityManager.send(result.root.url, {
|
|
1214
|
+
from: parsed.parent ?? ctx.principal.url,
|
|
1215
|
+
payload: parsed.initialMessage,
|
|
1216
|
+
})
|
|
1217
|
+
}
|
|
1107
1218
|
return json(
|
|
1108
1219
|
{
|
|
1109
1220
|
root: toPublicEntity(result.root),
|
|
@@ -1138,7 +1249,14 @@ async function sendEntity(
|
|
|
1138
1249
|
}
|
|
1139
1250
|
if (parsed.from_agent !== undefined) {
|
|
1140
1251
|
const principalAgentUrl = agentUrlForPrincipal(principal)
|
|
1141
|
-
|
|
1252
|
+
const fromAgentUrl = agentUrlPath(parsed.from_agent)
|
|
1253
|
+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl
|
|
1254
|
+
const hasAgentWriteToken = await hasValidAgentWriteToken(
|
|
1255
|
+
request,
|
|
1256
|
+
ctx,
|
|
1257
|
+
parsed.from_agent
|
|
1258
|
+
)
|
|
1259
|
+
if (!matchesPrincipalAgent && !hasAgentWriteToken) {
|
|
1142
1260
|
return apiError(
|
|
1143
1261
|
400,
|
|
1144
1262
|
ErrCodeInvalidRequest,
|