@electric-ax/agents-server 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -61,6 +61,7 @@ const entities = pgTable(`entities`, {
61
61
  tags: jsonb(`tags`).notNull().default({}),
62
62
  tagsIndex: text(`tags_index`).array().notNull().default(sql`'{}'::text[]`),
63
63
  spawnArgs: jsonb(`spawn_args`).default({}),
64
+ sandbox: jsonb(`sandbox`),
64
65
  parent: text(`parent`),
65
66
  createdBy: text(`created_by`),
66
67
  typeRevision: integer(`type_revision`),
@@ -102,6 +103,7 @@ const runners = pgTable(`runners`, {
102
103
  kind: text(`kind`).notNull().default(`local`),
103
104
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
104
105
  wakeStream: text(`wake_stream`).notNull(),
106
+ sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
105
107
  createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
106
108
  updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
107
109
  }, (table) => [
@@ -399,6 +401,7 @@ function toPublicEntity(entity) {
399
401
  dispatch_policy: entity.dispatch_policy,
400
402
  tags: entity.tags,
401
403
  spawn_args: entity.spawn_args,
404
+ sandbox: entity.sandbox,
402
405
  parent: entity.parent,
403
406
  created_by: entity.created_by,
404
407
  created_at: entity.created_at,
@@ -467,6 +470,12 @@ var PostgresRegistry = class {
467
470
  async createRunner(input) {
468
471
  const now = new Date();
469
472
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
473
+ const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
474
+ name: p.name,
475
+ label: p.label,
476
+ ...p.description !== void 0 && { description: p.description },
477
+ ...p.remote !== void 0 && { remote: p.remote }
478
+ })) : void 0;
470
479
  await this.db.insert(runners).values({
471
480
  tenantId: this.tenantId,
472
481
  id: input.id,
@@ -475,6 +484,7 @@ var PostgresRegistry = class {
475
484
  kind: input.kind ?? `local`,
476
485
  adminStatus: input.adminStatus ?? `enabled`,
477
486
  wakeStream,
487
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
478
488
  updatedAt: now
479
489
  }).onConflictDoUpdate({
480
490
  target: [runners.tenantId, runners.id],
@@ -484,6 +494,7 @@ var PostgresRegistry = class {
484
494
  kind: input.kind ?? `local`,
485
495
  adminStatus: input.adminStatus ?? `enabled`,
486
496
  wakeStream,
497
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
487
498
  updatedAt: now
488
499
  }
489
500
  });
@@ -491,6 +502,30 @@ var PostgresRegistry = class {
491
502
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
492
503
  return runner;
493
504
  }
505
+ /**
506
+ * Every sandbox profile advertised by a runner in this tenant (one entry
507
+ * per runner that advertises it — names may repeat across runners). Used by
508
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
509
+ * is remote (so a shared sandbox can skip the single-runner guard).
510
+ */
511
+ async listSandboxProfiles() {
512
+ const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where(eq(runners.tenantId, this.tenantId));
513
+ const profiles = [];
514
+ for (const row of rows) {
515
+ const list = row.sandboxProfiles;
516
+ if (!Array.isArray(list)) continue;
517
+ for (const entry of list) {
518
+ if (!entry || typeof entry.name !== `string`) continue;
519
+ profiles.push({
520
+ name: entry.name,
521
+ label: typeof entry.label === `string` ? entry.label : entry.name,
522
+ ...typeof entry.description === `string` && { description: entry.description },
523
+ ...typeof entry.remote === `boolean` && { remote: entry.remote }
524
+ });
525
+ }
526
+ }
527
+ return profiles;
528
+ }
494
529
  async getRunner(id) {
495
530
  const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
496
531
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -751,6 +786,7 @@ var PostgresRegistry = class {
751
786
  tags: normalizeTags(entity.tags),
752
787
  tagsIndex: buildTagsIndex(entity.tags),
753
788
  spawnArgs: entity.spawn_args ?? {},
789
+ sandbox: entity.sandbox ?? null,
754
790
  parent: entity.parent ?? null,
755
791
  createdBy: entity.created_by ?? null,
756
792
  typeRevision: entity.type_revision ?? null,
@@ -1088,6 +1124,7 @@ var PostgresRegistry = class {
1088
1124
  write_token: row.writeToken,
1089
1125
  tags: row.tags ?? {},
1090
1126
  spawn_args: row.spawnArgs,
1127
+ sandbox: row.sandbox ?? void 0,
1091
1128
  parent: row.parent ?? void 0,
1092
1129
  created_by: row.createdBy ?? void 0,
1093
1130
  type_revision: row.typeRevision ?? void 0,
@@ -1135,6 +1172,7 @@ var PostgresRegistry = class {
1135
1172
  kind: assertRunnerKind(row.kind),
1136
1173
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1137
1174
  wake_stream: row.wakeStream,
1175
+ sandbox_profiles: row.sandboxProfiles ?? [],
1138
1176
  created_at: row.createdAt.toISOString(),
1139
1177
  updated_at: row.updatedAt.toISOString()
1140
1178
  };
@@ -1265,6 +1303,7 @@ const ENTITY_SHAPE_COLUMNS = [
1265
1303
  `status`,
1266
1304
  `tags`,
1267
1305
  `spawn_args`,
1306
+ `sandbox`,
1268
1307
  `parent`,
1269
1308
  `type_revision`,
1270
1309
  `inbox_schemas`,
@@ -1298,6 +1337,7 @@ function toMemberRow(entity) {
1298
1337
  status: entity.status,
1299
1338
  tags: entity.tags,
1300
1339
  spawn_args: entity.spawn_args ?? {},
1340
+ sandbox: entity.sandbox ?? null,
1301
1341
  parent: entity.parent ?? null,
1302
1342
  type_revision: entity.type_revision ?? null,
1303
1343
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -1978,7 +2018,7 @@ var StreamClient = class {
1978
2018
  });
1979
2019
  });
1980
2020
  }
1981
- async fork(path$1, sourcePath) {
2021
+ async fork(path$1, sourcePath, opts) {
1982
2022
  return await withSpan(`stream.fork`, async (span) => {
1983
2023
  span.setAttributes({
1984
2024
  [ATTR.STREAM_PATH]: path$1,
@@ -1988,6 +2028,11 @@ var StreamClient = class {
1988
2028
  "content-type": `application/json`,
1989
2029
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
1990
2030
  };
2031
+ if (opts?.forkPointer) {
2032
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`;
2033
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
2034
+ if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
2035
+ }
1991
2036
  injectTraceHeaders(headers);
1992
2037
  const response = await fetch(this.streamUrl(path$1), {
1993
2038
  method: `PUT`,
@@ -2515,6 +2560,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2515
2560
  });
2516
2561
  }
2517
2562
 
2563
+ //#endregion
2564
+ //#region src/routing/sandbox.ts
2565
+ /**
2566
+ * Resolve and validate a spawn's sandbox CHOICE into the {@link
2567
+ * EntitySandboxSelection} persisted on the entity. Sibling of
2568
+ * `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
2569
+ * EntityManager so the spawn path reads as composed resolution steps.
2570
+ *
2571
+ * Profiles are a per-runner concern: each runner advertises what it supports.
2572
+ * When the spawn pins a runner via dispatch_policy, the chosen profile must be
2573
+ * in that runner's advertised set; otherwise we'd persist an unserviceable
2574
+ * choice that fails late at first wake. For unpinned dispatch (webhook /
2575
+ * parent-inherited) we can't pick a target ahead of time, so we fall back to a
2576
+ * tenant-wide "some runner offers this" check — better than nothing.
2577
+ */
2578
+ async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
2579
+ if (!requested) return void 0;
2580
+ const choice = applyInheritedSandbox(requested, parentEntity);
2581
+ if (!choice) return void 0;
2582
+ const chosenName = choice.profile;
2583
+ if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
2584
+ const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
2585
+ assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
2586
+ const selection = { profile: chosenName };
2587
+ if (choice.key !== void 0) selection.key = choice.key;
2588
+ else if (choice.scope !== void 0) selection.scope = choice.scope;
2589
+ if (choice.persistent !== void 0) selection.persistent = choice.persistent;
2590
+ if (choice.owner === false) selection.owner = false;
2591
+ return selection;
2592
+ }
2593
+ /**
2594
+ * Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
2595
+ * parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
2596
+ * parent has no shareable (keyed) sandbox the child simply gets none (returns
2597
+ * `undefined`), so `spawn_worker` can always request inheritance without
2598
+ * breaking unkeyed parents. (A running parent wake resolves inherit to its live
2599
+ * explicit key in the runtime instead — this server-side path covers direct API
2600
+ * callers, where only the parent's *stored* explicit key is available.)
2601
+ *
2602
+ * For a non-inherit choice the request passes through unchanged.
2603
+ *
2604
+ * NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
2605
+ * any sibling field on the request (e.g. a caller-supplied `persistent: false`)
2606
+ * is intentionally ignored, because a child attaches to the parent's existing
2607
+ * sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
2608
+ * permits the `{ inherit: true, persistent: ... }` combination, so the
2609
+ * precedence is resolved here rather than rejected at the schema level.
2610
+ */
2611
+ function applyInheritedSandbox(requested, parentEntity) {
2612
+ if (!requested.inherit) return requested;
2613
+ const parentKey = parentEntity?.sandbox?.key;
2614
+ if (!parentKey) return void 0;
2615
+ return {
2616
+ profile: parentEntity.sandbox.profile,
2617
+ key: parentKey,
2618
+ persistent: parentEntity.sandbox.persistent,
2619
+ owner: false
2620
+ };
2621
+ }
2622
+ /**
2623
+ * Validate the chosen profile is advertised by the relevant runner(s) and
2624
+ * determine whether it is a remote (off-host) sandbox, reachable from any
2625
+ * runner. Defaults to host-local (co-location required) unless every relevant
2626
+ * advertisement marks it remote. Throws if the profile is unserviceable.
2627
+ */
2628
+ async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
2629
+ const runnerIds = [];
2630
+ for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
2631
+ if (runnerIds.length > 0) {
2632
+ let allRemote = true;
2633
+ for (const runnerId of runnerIds) {
2634
+ const runner = await registry.getRunner(runnerId);
2635
+ const advertised = runner?.sandbox_profiles ?? [];
2636
+ const match = advertised.find((p) => p.name === chosenName);
2637
+ if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
2638
+ if (match.remote !== true) allRemote = false;
2639
+ }
2640
+ return allRemote;
2641
+ }
2642
+ const available = await registry.listSandboxProfiles();
2643
+ const matches = available.filter((p) => p.name === chosenName);
2644
+ if (matches.length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not offered by any registered runner (available: ${[...new Set(available.map((p) => p.name))].join(`, `) || `(none)`}).`, 400);
2645
+ return matches.every((p) => p.remote === true);
2646
+ }
2647
+ /**
2648
+ * Co-location: a shared *local* sandbox lives on one host, so every
2649
+ * collaborator must be pinned to the same single runner. Subagents inherit the
2650
+ * parent's dispatch policy, so this holds once the root is pinned. A shared
2651
+ * *remote* sandbox is reachable from any runner, so the guard does not apply.
2652
+ */
2653
+ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
2654
+ if (key === void 0 || chosenIsRemote) return;
2655
+ const targets = dispatchPolicy?.targets ?? [];
2656
+ const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
2657
+ if (!pinnedToSingleRunner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`, 400);
2658
+ }
2659
+
2518
2660
  //#endregion
2519
2661
  //#region src/principal.ts
2520
2662
  const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
@@ -2915,6 +3057,7 @@ var EntityManager = class {
2915
3057
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2916
3058
  }
2917
3059
  const dispatchPolicy = req.dispatch_policy ? this.validateDispatchPolicy(req.dispatch_policy, { label: `dispatch_policy` }) : parentEntity?.dispatch_policy ? applyTypeDefaultSubscriptionScope(parentEntity.dispatch_policy, entityType.default_dispatch_policy) : entityType.default_dispatch_policy;
3060
+ const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
2918
3061
  const now = Date.now();
2919
3062
  const entityData = {
2920
3063
  type: typeName,
@@ -2929,6 +3072,7 @@ var EntityManager = class {
2929
3072
  write_token: writeToken,
2930
3073
  tags: initialTags,
2931
3074
  spawn_args: req.args,
3075
+ sandbox,
2932
3076
  type_revision: entityType.revision,
2933
3077
  inbox_schemas: entityType.inbox_schemas,
2934
3078
  state_schemas: entityType.state_schemas,
@@ -3058,27 +3202,66 @@ var EntityManager = class {
3058
3202
  const writeEntityLocks = new Set();
3059
3203
  const writeStreamLocks = new Set();
3060
3204
  try {
3061
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3205
+ let sourceTree;
3206
+ if (opts.forkPointer) {
3207
+ const rootEntity = await this.registry.getEntity(rootUrl);
3208
+ if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3209
+ if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
3210
+ sourceTree = await this.listEntitySubtree(rootEntity);
3211
+ } else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3062
3212
  const sourceRoot = sourceTree[0];
3063
3213
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3064
- const snapshot = await this.readForkStateSnapshot(sourceTree);
3214
+ let preFilteredRoot;
3215
+ if (opts.forkPointer) {
3216
+ const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3217
+ const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3218
+ const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3219
+ const filteredEvents = flat.slice(0, target);
3220
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3221
+ const sharedStateIds = new Set();
3222
+ for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
3223
+ preFilteredRoot = {
3224
+ manifests: rootManifests,
3225
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
3226
+ replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
3227
+ sharedStateIds
3228
+ };
3229
+ }
3230
+ const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3231
+ if (opts.forkPointer) {
3232
+ const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3233
+ if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3234
+ }
3235
+ const snapshot = await this.readForkStateSnapshot(
3236
+ // Skip the root when we've already pre-filtered it — avoid both a
3237
+ // wasted HEAD read of main and a re-population that would clobber
3238
+ // the filtered entries.
3239
+ preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
3240
+ );
3241
+ if (preFilteredRoot) {
3242
+ snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
3243
+ snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
3244
+ snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
3245
+ for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
3246
+ }
3065
3247
  const suffix = randomUUID().slice(0, 8);
3066
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3248
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3067
3249
  suffix,
3068
3250
  rootUrl,
3069
3251
  rootInstanceId: opts.rootInstanceId
3070
3252
  });
3071
3253
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3072
3254
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3073
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3074
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
3255
+ const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
3256
+ this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3075
3257
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3076
3258
  const createdStreams = [];
3077
3259
  const createdEntities = [];
3078
3260
  const activeManifestsByEntity = new Map();
3079
3261
  try {
3080
3262
  for (const plan of entityPlans) {
3081
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
3263
+ const isRoot = plan.source.url === rootUrl;
3264
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3082
3265
  createdStreams.push(plan.fork.streams.main);
3083
3266
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3084
3267
  createdStreams.push(plan.fork.streams.error);
@@ -3171,6 +3354,38 @@ var EntityManager = class {
3171
3354
  }
3172
3355
  held.clear();
3173
3356
  }
3357
+ /**
3358
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
3359
+ * list instead of walking the registry from `rootUrl`. Used by the
3360
+ * pointer-fork path to wait+lock only the kept descendants, since
3361
+ * the root is being forked from history and doesn't need to be idle.
3362
+ */
3363
+ async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
3364
+ if (entities$1.length === 0) return;
3365
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3366
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
3367
+ const refresh = async () => {
3368
+ const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
3369
+ return refreshed.filter((entity) => !!entity);
3370
+ };
3371
+ const deadline = Date.now() + timeoutMs;
3372
+ while (true) {
3373
+ const present = await refresh();
3374
+ const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
3375
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3376
+ let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3377
+ if (active.length === 0) {
3378
+ this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
3379
+ const reChecked = await refresh();
3380
+ const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3381
+ if (reActive.length === 0) return;
3382
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3383
+ active = reActive;
3384
+ }
3385
+ if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
3386
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3387
+ }
3388
+ }
3174
3389
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3175
3390
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3176
3391
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3200,6 +3415,73 @@ var EntityManager = class {
3200
3415
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3201
3416
  }
3202
3417
  }
3418
+ /**
3419
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
3420
+ * source's flattened history. Throws a 400 if the pointer doesn't
3421
+ * address a real event.
3422
+ *
3423
+ * Semantics (mirroring the durable-streams server interpretation):
3424
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
3425
+ * messages forward." Concretely, the target event is the N-th event
3426
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
3427
+ * `null`, the anchor is the stream start and the target is the N-th
3428
+ * event from the very beginning.) The returned position is the count
3429
+ * of events to KEEP — events 1..position survive the filter.
3430
+ *
3431
+ * A pointer is valid when:
3432
+ * - `pointer.offset` is `null` (stream start) OR matches some
3433
+ * event's `headers.offset` value, AND
3434
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
3435
+ */
3436
+ resolveForkPointerTarget(events, pointer, streamPath) {
3437
+ let positionAtAnchor = 0;
3438
+ let anchorSeen = pointer.offset === null;
3439
+ for (const event of events) {
3440
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3441
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
3442
+ if (eventOffset === void 0) continue;
3443
+ if (pointer.offset === null) continue;
3444
+ if (eventOffset === pointer.offset) anchorSeen = true;
3445
+ if (eventOffset <= pointer.offset) positionAtAnchor++;
3446
+ }
3447
+ if (!anchorSeen) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.offset (${pointer.offset ?? `<stream-start>`}) does not match any event's Stream-Next-Offset on ${streamPath}`, 400);
3448
+ const eventsPastAnchor = events.length - positionAtAnchor;
3449
+ if (pointer.subOffset < 1 || pointer.subOffset > eventsPastAnchor) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.sub_offset ${pointer.subOffset} out of range past anchor on ${streamPath} (valid: 1..${eventsPastAnchor})`, 400);
3450
+ return positionAtAnchor + pointer.subOffset;
3451
+ }
3452
+ /**
3453
+ * Compute the subset of `sourceTree` that survives the manifest filter
3454
+ * applied at the root. After filtering the root's manifest at the fork
3455
+ * pointer, only children whose manifest entries landed at or before the
3456
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
3457
+ * along with them. Children dropped from the root's manifest, and any
3458
+ * of their descendants, are excluded.
3459
+ */
3460
+ computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
3461
+ const keptChildUrls = new Set();
3462
+ for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
3463
+ const childrenByParent = new Map();
3464
+ for (const entity of sourceTree) {
3465
+ if (!entity.parent) continue;
3466
+ const list = childrenByParent.get(entity.parent) ?? [];
3467
+ list.push(entity);
3468
+ childrenByParent.set(entity.parent, list);
3469
+ }
3470
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl);
3471
+ if (!rootEntity) return [];
3472
+ const result = [rootEntity];
3473
+ const queue = [];
3474
+ for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
3475
+ const seen = new Set([rootUrl]);
3476
+ while (queue.length > 0) {
3477
+ const entity = queue.shift();
3478
+ if (seen.has(entity.url)) continue;
3479
+ seen.add(entity.url);
3480
+ result.push(entity);
3481
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
3482
+ }
3483
+ return result;
3484
+ }
3203
3485
  async listEntitySubtree(root) {
3204
3486
  const result = [];
3205
3487
  const queue = [root];
@@ -4307,6 +4589,7 @@ var EntityManager = class {
4307
4589
  streams: entity.streams,
4308
4590
  tags: entity.tags,
4309
4591
  spawnArgs: entity.spawn_args,
4592
+ sandbox: entity.sandbox,
4310
4593
  createdBy: entity.created_by
4311
4594
  },
4312
4595
  principal: principalFromCreatedBy(entity.created_by),
@@ -5972,11 +6255,19 @@ var WakeRegistry = class {
5972
6255
  }
5973
6256
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
5974
6257
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
5975
- return { change: {
6258
+ const change = {
5976
6259
  collection: eventType,
5977
6260
  kind,
5978
6261
  key: event.key || ``
5979
- } };
6262
+ };
6263
+ if (eventType === `inbox`) {
6264
+ const value = event.value;
6265
+ if (typeof value?.from === `string`) change.from = value.from;
6266
+ if (`payload` in (value ?? {})) change.payload = value?.payload;
6267
+ if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
6268
+ if (typeof value?.message_type === `string`) change.message_type = value.message_type;
6269
+ }
6270
+ return { change };
5980
6271
  }
5981
6272
  };
5982
6273
 
@@ -6383,13 +6674,13 @@ function buildElectricProxyTarget(options) {
6383
6674
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6384
6675
  const table = options.incomingUrl.searchParams.get(`table`);
6385
6676
  if (table === `entities`) {
6386
- target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
6677
+ target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
6387
6678
  applyTenantShapeWhere(target, options.tenantId);
6388
6679
  } else if (table === `entity_types`) {
6389
6680
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
6390
6681
  applyTenantShapeWhere(target, options.tenantId);
6391
6682
  } else if (table === `runners`) {
6392
- target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
6683
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
6393
6684
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6394
6685
  } else if (table === `runner_runtime_diagnostics`) {
6395
6686
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
@@ -6815,6 +7106,28 @@ async function proxyElectric(request, ctx) {
6815
7106
  });
6816
7107
  }
6817
7108
 
7109
+ //#endregion
7110
+ //#region src/sandbox-choice-schema.ts
7111
+ /**
7112
+ * Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
7113
+ * the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
7114
+ * persisted on the entity. The matching `SandboxChoice` type is hand-maintained
7115
+ * in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
7116
+ * the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
7117
+ *
7118
+ * Validation happens once, at the router boundary (this schema is embedded in
7119
+ * the spawn body schema); the spawn resolver consumes already-validated input,
7120
+ * so there is intentionally no separate `parse` helper here.
7121
+ */
7122
+ const sandboxChoiceSchema = Type.Object({
7123
+ profile: Type.Optional(Type.String()),
7124
+ key: Type.Optional(Type.String()),
7125
+ scope: Type.Optional(Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])),
7126
+ persistent: Type.Optional(Type.Boolean()),
7127
+ owner: Type.Optional(Type.Boolean()),
7128
+ inherit: Type.Optional(Type.Boolean())
7129
+ });
7130
+
6818
7131
  //#endregion
6819
7132
  //#region src/routing/entities-router.ts
6820
7133
  const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
@@ -6837,6 +7150,7 @@ const spawnBodySchema = Type.Object({
6837
7150
  tags: Type.Optional(stringRecordSchema$1),
6838
7151
  parent: Type.Optional(Type.String()),
6839
7152
  dispatch_policy: Type.Optional(dispatchPolicySchema),
7153
+ sandbox: Type.Optional(sandboxChoiceSchema),
6840
7154
  initialMessage: Type.Optional(Type.Unknown()),
6841
7155
  wake: Type.Optional(Type.Object({
6842
7156
  subscriberUrl: Type.String(),
@@ -6878,7 +7192,11 @@ const inboxMessageBodySchema = Type.Object({
6878
7192
  });
6879
7193
  const forkBodySchema = Type.Object({
6880
7194
  instance_id: Type.Optional(Type.String()),
6881
- waitTimeoutMs: Type.Optional(Type.Number())
7195
+ waitTimeoutMs: Type.Optional(Type.Number()),
7196
+ fork_pointer: Type.Optional(Type.Object({
7197
+ offset: Type.Union([Type.String(), Type.Null()]),
7198
+ sub_offset: Type.Number()
7199
+ }))
6882
7200
  });
6883
7201
  const setTagBodySchema = Type.Object({ value: Type.String() });
6884
7202
  const entitySignalSchema = Type.Union([
@@ -7196,7 +7514,11 @@ async function forkEntity(request, ctx) {
7196
7514
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7197
7515
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7198
7516
  rootInstanceId: parsed.instance_id,
7199
- waitTimeoutMs: parsed.waitTimeoutMs
7517
+ waitTimeoutMs: parsed.waitTimeoutMs,
7518
+ ...parsed.fork_pointer && { forkPointer: {
7519
+ offset: parsed.fork_pointer.offset,
7520
+ subOffset: parsed.fork_pointer.sub_offset
7521
+ } }
7200
7522
  });
7201
7523
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7202
7524
  return json({
@@ -7294,6 +7616,7 @@ async function spawnEntity(request, ctx) {
7294
7616
  tags: parsed.tags,
7295
7617
  parent: parsed.parent,
7296
7618
  dispatch_policy: dispatchPolicy,
7619
+ sandbox: parsed.sandbox,
7297
7620
  initialMessage: void 0,
7298
7621
  wake: parsed.wake,
7299
7622
  created_by: principal.url
@@ -7548,6 +7871,12 @@ function withLeadingSlash(path$1) {
7548
7871
 
7549
7872
  //#endregion
7550
7873
  //#region src/routing/runners-router.ts
7874
+ const sandboxProfileBodySchema = Type.Object({
7875
+ name: Type.String(),
7876
+ label: Type.String(),
7877
+ description: Type.Optional(Type.String()),
7878
+ remote: Type.Optional(Type.Boolean())
7879
+ });
7551
7880
  const registerRunnerBodySchema = Type.Object({
7552
7881
  id: Type.String(),
7553
7882
  owner_principal: Type.Optional(Type.String()),
@@ -7560,7 +7889,8 @@ const registerRunnerBodySchema = Type.Object({
7560
7889
  Type.Literal(`server`)
7561
7890
  ])),
7562
7891
  admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
7563
- wake_stream: Type.Optional(Type.String())
7892
+ wake_stream: Type.Optional(Type.String()),
7893
+ sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
7564
7894
  });
7565
7895
  const heartbeatBodySchema = Type.Object({
7566
7896
  lease_ms: Type.Optional(Type.Number()),
@@ -7658,7 +7988,8 @@ async function registerRunner(request, ctx) {
7658
7988
  label: parsed.label,
7659
7989
  kind: parsed.kind,
7660
7990
  adminStatus: parsed.admin_status,
7661
- wakeStream: parsed.wake_stream
7991
+ wakeStream: parsed.wake_stream,
7992
+ sandboxProfiles: parsed.sandbox_profiles
7662
7993
  });
7663
7994
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7664
7995
  return json(runner, { status: 201 });
@@ -7888,6 +8219,7 @@ async function notificationFromClaim(ctx, input) {
7888
8219
  streams: entity.streams,
7889
8220
  tags: entity.tags,
7890
8221
  spawnArgs: entity.spawn_args,
8222
+ sandbox: entity.sandbox,
7891
8223
  createdBy: entity.created_by
7892
8224
  },
7893
8225
  principal: principalFromCreatedBy(entity.created_by)
@@ -0,0 +1,5 @@
1
+ ALTER TABLE runners
2
+ ADD COLUMN sandbox_profiles jsonb NOT NULL DEFAULT '[]'::jsonb;
3
+ --> statement-breakpoint
4
+ ALTER TABLE entities
5
+ ADD COLUMN sandbox jsonb;
@@ -71,6 +71,13 @@
71
71
  "when": 1778540000000,
72
72
  "tag": "0009_entity_signal_statuses",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1779062400000,
79
+ "tag": "0010_sandbox_profiles",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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.7"
57
+ "@electric-ax/agents-runtime": "0.3.8"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.11",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.9",
70
- "@electric-ax/agents-server-ui": "0.4.14"
68
+ "@electric-ax/agents": "0.4.12",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.10",
70
+ "@electric-ax/agents-server-ui": "0.4.15"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -49,6 +49,7 @@ export const entities = pgTable(
49
49
  .notNull()
50
50
  .default(sql`'{}'::text[]`),
51
51
  spawnArgs: jsonb(`spawn_args`).default({}),
52
+ sandbox: jsonb(`sandbox`),
52
53
  parent: text(`parent`),
53
54
  createdBy: text(`created_by`),
54
55
  typeRevision: integer(`type_revision`),
@@ -111,6 +112,7 @@ export const runners = pgTable(
111
112
  kind: text(`kind`).notNull().default(`local`),
112
113
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
113
114
  wakeStream: text(`wake_stream`).notNull(),
115
+ sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
114
116
  createdAt: timestamp(`created_at`, { withTimezone: true })
115
117
  .notNull()
116
118
  .defaultNow(),