@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.cjs CHANGED
@@ -90,6 +90,7 @@ const entities = (0, drizzle_orm_pg_core.pgTable)(`entities`, {
90
90
  tags: (0, drizzle_orm_pg_core.jsonb)(`tags`).notNull().default({}),
91
91
  tagsIndex: (0, drizzle_orm_pg_core.text)(`tags_index`).array().notNull().default(drizzle_orm.sql`'{}'::text[]`),
92
92
  spawnArgs: (0, drizzle_orm_pg_core.jsonb)(`spawn_args`).default({}),
93
+ sandbox: (0, drizzle_orm_pg_core.jsonb)(`sandbox`),
93
94
  parent: (0, drizzle_orm_pg_core.text)(`parent`),
94
95
  createdBy: (0, drizzle_orm_pg_core.text)(`created_by`),
95
96
  typeRevision: (0, drizzle_orm_pg_core.integer)(`type_revision`),
@@ -131,6 +132,7 @@ const runners = (0, drizzle_orm_pg_core.pgTable)(`runners`, {
131
132
  kind: (0, drizzle_orm_pg_core.text)(`kind`).notNull().default(`local`),
132
133
  adminStatus: (0, drizzle_orm_pg_core.text)(`admin_status`).notNull().default(`enabled`),
133
134
  wakeStream: (0, drizzle_orm_pg_core.text)(`wake_stream`).notNull(),
135
+ sandboxProfiles: (0, drizzle_orm_pg_core.jsonb)(`sandbox_profiles`).notNull().default([]),
134
136
  createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
135
137
  updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
136
138
  }, (table) => [
@@ -428,6 +430,7 @@ function toPublicEntity(entity) {
428
430
  dispatch_policy: entity.dispatch_policy,
429
431
  tags: entity.tags,
430
432
  spawn_args: entity.spawn_args,
433
+ sandbox: entity.sandbox,
431
434
  parent: entity.parent,
432
435
  created_by: entity.created_by,
433
436
  created_at: entity.created_at,
@@ -496,6 +499,12 @@ var PostgresRegistry = class {
496
499
  async createRunner(input) {
497
500
  const now = new Date();
498
501
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
502
+ const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
503
+ name: p.name,
504
+ label: p.label,
505
+ ...p.description !== void 0 && { description: p.description },
506
+ ...p.remote !== void 0 && { remote: p.remote }
507
+ })) : void 0;
499
508
  await this.db.insert(runners).values({
500
509
  tenantId: this.tenantId,
501
510
  id: input.id,
@@ -504,6 +513,7 @@ var PostgresRegistry = class {
504
513
  kind: input.kind ?? `local`,
505
514
  adminStatus: input.adminStatus ?? `enabled`,
506
515
  wakeStream,
516
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
507
517
  updatedAt: now
508
518
  }).onConflictDoUpdate({
509
519
  target: [runners.tenantId, runners.id],
@@ -513,6 +523,7 @@ var PostgresRegistry = class {
513
523
  kind: input.kind ?? `local`,
514
524
  adminStatus: input.adminStatus ?? `enabled`,
515
525
  wakeStream,
526
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
516
527
  updatedAt: now
517
528
  }
518
529
  });
@@ -520,6 +531,30 @@ var PostgresRegistry = class {
520
531
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
521
532
  return runner;
522
533
  }
534
+ /**
535
+ * Every sandbox profile advertised by a runner in this tenant (one entry
536
+ * per runner that advertises it — names may repeat across runners). Used by
537
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
538
+ * is remote (so a shared sandbox can skip the single-runner guard).
539
+ */
540
+ async listSandboxProfiles() {
541
+ const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where((0, drizzle_orm.eq)(runners.tenantId, this.tenantId));
542
+ const profiles = [];
543
+ for (const row of rows) {
544
+ const list = row.sandboxProfiles;
545
+ if (!Array.isArray(list)) continue;
546
+ for (const entry of list) {
547
+ if (!entry || typeof entry.name !== `string`) continue;
548
+ profiles.push({
549
+ name: entry.name,
550
+ label: typeof entry.label === `string` ? entry.label : entry.name,
551
+ ...typeof entry.description === `string` && { description: entry.description },
552
+ ...typeof entry.remote === `boolean` && { remote: entry.remote }
553
+ });
554
+ }
555
+ }
556
+ return profiles;
557
+ }
523
558
  async getRunner(id) {
524
559
  const rows = await this.db.select().from(runners).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(runners.tenantId, this.tenantId), (0, drizzle_orm.eq)(runners.id, id))).limit(1);
525
560
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -780,6 +815,7 @@ var PostgresRegistry = class {
780
815
  tags: (0, __electric_ax_agents_runtime.normalizeTags)(entity.tags),
781
816
  tagsIndex: (0, __electric_ax_agents_runtime.buildTagsIndex)(entity.tags),
782
817
  spawnArgs: entity.spawn_args ?? {},
818
+ sandbox: entity.sandbox ?? null,
783
819
  parent: entity.parent ?? null,
784
820
  createdBy: entity.created_by ?? null,
785
821
  typeRevision: entity.type_revision ?? null,
@@ -1117,6 +1153,7 @@ var PostgresRegistry = class {
1117
1153
  write_token: row.writeToken,
1118
1154
  tags: row.tags ?? {},
1119
1155
  spawn_args: row.spawnArgs,
1156
+ sandbox: row.sandbox ?? void 0,
1120
1157
  parent: row.parent ?? void 0,
1121
1158
  created_by: row.createdBy ?? void 0,
1122
1159
  type_revision: row.typeRevision ?? void 0,
@@ -1164,6 +1201,7 @@ var PostgresRegistry = class {
1164
1201
  kind: assertRunnerKind(row.kind),
1165
1202
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1166
1203
  wake_stream: row.wakeStream,
1204
+ sandbox_profiles: row.sandboxProfiles ?? [],
1167
1205
  created_at: row.createdAt.toISOString(),
1168
1206
  updated_at: row.updatedAt.toISOString()
1169
1207
  };
@@ -1294,6 +1332,7 @@ const ENTITY_SHAPE_COLUMNS = [
1294
1332
  `status`,
1295
1333
  `tags`,
1296
1334
  `spawn_args`,
1335
+ `sandbox`,
1297
1336
  `parent`,
1298
1337
  `type_revision`,
1299
1338
  `inbox_schemas`,
@@ -1327,6 +1366,7 @@ function toMemberRow(entity) {
1327
1366
  status: entity.status,
1328
1367
  tags: entity.tags,
1329
1368
  spawn_args: entity.spawn_args ?? {},
1369
+ sandbox: entity.sandbox ?? null,
1330
1370
  parent: entity.parent ?? null,
1331
1371
  type_revision: entity.type_revision ?? null,
1332
1372
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -2007,7 +2047,7 @@ var StreamClient = class {
2007
2047
  });
2008
2048
  });
2009
2049
  }
2010
- async fork(path$2, sourcePath) {
2050
+ async fork(path$2, sourcePath, opts) {
2011
2051
  return await withSpan(`stream.fork`, async (span) => {
2012
2052
  span.setAttributes({
2013
2053
  [ATTR.STREAM_PATH]: path$2,
@@ -2017,6 +2057,11 @@ var StreamClient = class {
2017
2057
  "content-type": `application/json`,
2018
2058
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
2019
2059
  };
2060
+ if (opts?.forkPointer) {
2061
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`;
2062
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
2063
+ if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
2064
+ }
2020
2065
  injectTraceHeaders(headers);
2021
2066
  const response = await fetch(this.streamUrl(path$2), {
2022
2067
  method: `PUT`,
@@ -2544,6 +2589,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2544
2589
  });
2545
2590
  }
2546
2591
 
2592
+ //#endregion
2593
+ //#region src/routing/sandbox.ts
2594
+ /**
2595
+ * Resolve and validate a spawn's sandbox CHOICE into the {@link
2596
+ * EntitySandboxSelection} persisted on the entity. Sibling of
2597
+ * `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
2598
+ * EntityManager so the spawn path reads as composed resolution steps.
2599
+ *
2600
+ * Profiles are a per-runner concern: each runner advertises what it supports.
2601
+ * When the spawn pins a runner via dispatch_policy, the chosen profile must be
2602
+ * in that runner's advertised set; otherwise we'd persist an unserviceable
2603
+ * choice that fails late at first wake. For unpinned dispatch (webhook /
2604
+ * parent-inherited) we can't pick a target ahead of time, so we fall back to a
2605
+ * tenant-wide "some runner offers this" check — better than nothing.
2606
+ */
2607
+ async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
2608
+ if (!requested) return void 0;
2609
+ const choice = applyInheritedSandbox(requested, parentEntity);
2610
+ if (!choice) return void 0;
2611
+ const chosenName = choice.profile;
2612
+ if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
2613
+ const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
2614
+ assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
2615
+ const selection = { profile: chosenName };
2616
+ if (choice.key !== void 0) selection.key = choice.key;
2617
+ else if (choice.scope !== void 0) selection.scope = choice.scope;
2618
+ if (choice.persistent !== void 0) selection.persistent = choice.persistent;
2619
+ if (choice.owner === false) selection.owner = false;
2620
+ return selection;
2621
+ }
2622
+ /**
2623
+ * Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
2624
+ * parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
2625
+ * parent has no shareable (keyed) sandbox the child simply gets none (returns
2626
+ * `undefined`), so `spawn_worker` can always request inheritance without
2627
+ * breaking unkeyed parents. (A running parent wake resolves inherit to its live
2628
+ * explicit key in the runtime instead — this server-side path covers direct API
2629
+ * callers, where only the parent's *stored* explicit key is available.)
2630
+ *
2631
+ * For a non-inherit choice the request passes through unchanged.
2632
+ *
2633
+ * NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
2634
+ * any sibling field on the request (e.g. a caller-supplied `persistent: false`)
2635
+ * is intentionally ignored, because a child attaches to the parent's existing
2636
+ * sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
2637
+ * permits the `{ inherit: true, persistent: ... }` combination, so the
2638
+ * precedence is resolved here rather than rejected at the schema level.
2639
+ */
2640
+ function applyInheritedSandbox(requested, parentEntity) {
2641
+ if (!requested.inherit) return requested;
2642
+ const parentKey = parentEntity?.sandbox?.key;
2643
+ if (!parentKey) return void 0;
2644
+ return {
2645
+ profile: parentEntity.sandbox.profile,
2646
+ key: parentKey,
2647
+ persistent: parentEntity.sandbox.persistent,
2648
+ owner: false
2649
+ };
2650
+ }
2651
+ /**
2652
+ * Validate the chosen profile is advertised by the relevant runner(s) and
2653
+ * determine whether it is a remote (off-host) sandbox, reachable from any
2654
+ * runner. Defaults to host-local (co-location required) unless every relevant
2655
+ * advertisement marks it remote. Throws if the profile is unserviceable.
2656
+ */
2657
+ async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
2658
+ const runnerIds = [];
2659
+ for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
2660
+ if (runnerIds.length > 0) {
2661
+ let allRemote = true;
2662
+ for (const runnerId of runnerIds) {
2663
+ const runner = await registry.getRunner(runnerId);
2664
+ const advertised = runner?.sandbox_profiles ?? [];
2665
+ const match = advertised.find((p) => p.name === chosenName);
2666
+ if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
2667
+ if (match.remote !== true) allRemote = false;
2668
+ }
2669
+ return allRemote;
2670
+ }
2671
+ const available = await registry.listSandboxProfiles();
2672
+ const matches = available.filter((p) => p.name === chosenName);
2673
+ 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);
2674
+ return matches.every((p) => p.remote === true);
2675
+ }
2676
+ /**
2677
+ * Co-location: a shared *local* sandbox lives on one host, so every
2678
+ * collaborator must be pinned to the same single runner. Subagents inherit the
2679
+ * parent's dispatch policy, so this holds once the root is pinned. A shared
2680
+ * *remote* sandbox is reachable from any runner, so the guard does not apply.
2681
+ */
2682
+ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
2683
+ if (key === void 0 || chosenIsRemote) return;
2684
+ const targets = dispatchPolicy?.targets ?? [];
2685
+ const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
2686
+ 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);
2687
+ }
2688
+
2547
2689
  //#endregion
2548
2690
  //#region src/principal.ts
2549
2691
  const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
@@ -2944,6 +3086,7 @@ var EntityManager = class {
2944
3086
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2945
3087
  }
2946
3088
  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;
3089
+ const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
2947
3090
  const now = Date.now();
2948
3091
  const entityData = {
2949
3092
  type: typeName,
@@ -2958,6 +3101,7 @@ var EntityManager = class {
2958
3101
  write_token: writeToken,
2959
3102
  tags: initialTags,
2960
3103
  spawn_args: req.args,
3104
+ sandbox,
2961
3105
  type_revision: entityType.revision,
2962
3106
  inbox_schemas: entityType.inbox_schemas,
2963
3107
  state_schemas: entityType.state_schemas,
@@ -3087,27 +3231,66 @@ var EntityManager = class {
3087
3231
  const writeEntityLocks = new Set();
3088
3232
  const writeStreamLocks = new Set();
3089
3233
  try {
3090
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3234
+ let sourceTree;
3235
+ if (opts.forkPointer) {
3236
+ const rootEntity = await this.registry.getEntity(rootUrl);
3237
+ if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3238
+ if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
3239
+ sourceTree = await this.listEntitySubtree(rootEntity);
3240
+ } else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3091
3241
  const sourceRoot = sourceTree[0];
3092
3242
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3093
- const snapshot = await this.readForkStateSnapshot(sourceTree);
3243
+ let preFilteredRoot;
3244
+ if (opts.forkPointer) {
3245
+ const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3246
+ const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3247
+ const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3248
+ const filteredEvents = flat.slice(0, target);
3249
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3250
+ const sharedStateIds = new Set();
3251
+ for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
3252
+ preFilteredRoot = {
3253
+ manifests: rootManifests,
3254
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
3255
+ replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
3256
+ sharedStateIds
3257
+ };
3258
+ }
3259
+ const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3260
+ if (opts.forkPointer) {
3261
+ const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3262
+ if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3263
+ }
3264
+ const snapshot = await this.readForkStateSnapshot(
3265
+ // Skip the root when we've already pre-filtered it — avoid both a
3266
+ // wasted HEAD read of main and a re-population that would clobber
3267
+ // the filtered entries.
3268
+ preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
3269
+ );
3270
+ if (preFilteredRoot) {
3271
+ snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
3272
+ snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
3273
+ snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
3274
+ for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
3275
+ }
3094
3276
  const suffix = (0, node_crypto.randomUUID)().slice(0, 8);
3095
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3277
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3096
3278
  suffix,
3097
3279
  rootUrl,
3098
3280
  rootInstanceId: opts.rootInstanceId
3099
3281
  });
3100
3282
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3101
3283
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3102
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3103
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
3284
+ const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
3285
+ this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3104
3286
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(id)), writeStreamLocks);
3105
3287
  const createdStreams = [];
3106
3288
  const createdEntities = [];
3107
3289
  const activeManifestsByEntity = new Map();
3108
3290
  try {
3109
3291
  for (const plan of entityPlans) {
3110
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
3292
+ const isRoot = plan.source.url === rootUrl;
3293
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3111
3294
  createdStreams.push(plan.fork.streams.main);
3112
3295
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3113
3296
  createdStreams.push(plan.fork.streams.error);
@@ -3200,6 +3383,38 @@ var EntityManager = class {
3200
3383
  }
3201
3384
  held.clear();
3202
3385
  }
3386
+ /**
3387
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
3388
+ * list instead of walking the registry from `rootUrl`. Used by the
3389
+ * pointer-fork path to wait+lock only the kept descendants, since
3390
+ * the root is being forked from history and doesn't need to be idle.
3391
+ */
3392
+ async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
3393
+ if (entities$1.length === 0) return;
3394
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3395
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
3396
+ const refresh = async () => {
3397
+ const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
3398
+ return refreshed.filter((entity) => !!entity);
3399
+ };
3400
+ const deadline = Date.now() + timeoutMs;
3401
+ while (true) {
3402
+ const present = await refresh();
3403
+ const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
3404
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3405
+ let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3406
+ if (active.length === 0) {
3407
+ this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
3408
+ const reChecked = await refresh();
3409
+ const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3410
+ if (reActive.length === 0) return;
3411
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3412
+ active = reActive;
3413
+ }
3414
+ if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
3415
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3416
+ }
3417
+ }
3203
3418
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3204
3419
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3205
3420
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3229,6 +3444,73 @@ var EntityManager = class {
3229
3444
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3230
3445
  }
3231
3446
  }
3447
+ /**
3448
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
3449
+ * source's flattened history. Throws a 400 if the pointer doesn't
3450
+ * address a real event.
3451
+ *
3452
+ * Semantics (mirroring the durable-streams server interpretation):
3453
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
3454
+ * messages forward." Concretely, the target event is the N-th event
3455
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
3456
+ * `null`, the anchor is the stream start and the target is the N-th
3457
+ * event from the very beginning.) The returned position is the count
3458
+ * of events to KEEP — events 1..position survive the filter.
3459
+ *
3460
+ * A pointer is valid when:
3461
+ * - `pointer.offset` is `null` (stream start) OR matches some
3462
+ * event's `headers.offset` value, AND
3463
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
3464
+ */
3465
+ resolveForkPointerTarget(events, pointer, streamPath) {
3466
+ let positionAtAnchor = 0;
3467
+ let anchorSeen = pointer.offset === null;
3468
+ for (const event of events) {
3469
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3470
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
3471
+ if (eventOffset === void 0) continue;
3472
+ if (pointer.offset === null) continue;
3473
+ if (eventOffset === pointer.offset) anchorSeen = true;
3474
+ if (eventOffset <= pointer.offset) positionAtAnchor++;
3475
+ }
3476
+ 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);
3477
+ const eventsPastAnchor = events.length - positionAtAnchor;
3478
+ 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);
3479
+ return positionAtAnchor + pointer.subOffset;
3480
+ }
3481
+ /**
3482
+ * Compute the subset of `sourceTree` that survives the manifest filter
3483
+ * applied at the root. After filtering the root's manifest at the fork
3484
+ * pointer, only children whose manifest entries landed at or before the
3485
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
3486
+ * along with them. Children dropped from the root's manifest, and any
3487
+ * of their descendants, are excluded.
3488
+ */
3489
+ computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
3490
+ const keptChildUrls = new Set();
3491
+ for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
3492
+ const childrenByParent = new Map();
3493
+ for (const entity of sourceTree) {
3494
+ if (!entity.parent) continue;
3495
+ const list = childrenByParent.get(entity.parent) ?? [];
3496
+ list.push(entity);
3497
+ childrenByParent.set(entity.parent, list);
3498
+ }
3499
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl);
3500
+ if (!rootEntity) return [];
3501
+ const result = [rootEntity];
3502
+ const queue = [];
3503
+ for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
3504
+ const seen = new Set([rootUrl]);
3505
+ while (queue.length > 0) {
3506
+ const entity = queue.shift();
3507
+ if (seen.has(entity.url)) continue;
3508
+ seen.add(entity.url);
3509
+ result.push(entity);
3510
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
3511
+ }
3512
+ return result;
3513
+ }
3232
3514
  async listEntitySubtree(root) {
3233
3515
  const result = [];
3234
3516
  const queue = [root];
@@ -4336,6 +4618,7 @@ var EntityManager = class {
4336
4618
  streams: entity.streams,
4337
4619
  tags: entity.tags,
4338
4620
  spawnArgs: entity.spawn_args,
4621
+ sandbox: entity.sandbox,
4339
4622
  createdBy: entity.created_by
4340
4623
  },
4341
4624
  principal: principalFromCreatedBy(entity.created_by),
@@ -6001,11 +6284,19 @@ var WakeRegistry = class {
6001
6284
  }
6002
6285
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
6003
6286
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
6004
- return { change: {
6287
+ const change = {
6005
6288
  collection: eventType,
6006
6289
  kind,
6007
6290
  key: event.key || ``
6008
- } };
6291
+ };
6292
+ if (eventType === `inbox`) {
6293
+ const value = event.value;
6294
+ if (typeof value?.from === `string`) change.from = value.from;
6295
+ if (`payload` in (value ?? {})) change.payload = value?.payload;
6296
+ if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
6297
+ if (typeof value?.message_type === `string`) change.message_type = value.message_type;
6298
+ }
6299
+ return { change };
6009
6300
  }
6010
6301
  };
6011
6302
 
@@ -6412,13 +6703,13 @@ function buildElectricProxyTarget(options) {
6412
6703
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6413
6704
  const table = options.incomingUrl.searchParams.get(`table`);
6414
6705
  if (table === `entities`) {
6415
- 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"`);
6706
+ 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"`);
6416
6707
  applyTenantShapeWhere(target, options.tenantId);
6417
6708
  } else if (table === `entity_types`) {
6418
6709
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
6419
6710
  applyTenantShapeWhere(target, options.tenantId);
6420
6711
  } else if (table === `runners`) {
6421
- target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
6712
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
6422
6713
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6423
6714
  } else if (table === `runner_runtime_diagnostics`) {
6424
6715
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
@@ -6844,6 +7135,28 @@ async function proxyElectric(request, ctx) {
6844
7135
  });
6845
7136
  }
6846
7137
 
7138
+ //#endregion
7139
+ //#region src/sandbox-choice-schema.ts
7140
+ /**
7141
+ * Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
7142
+ * the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
7143
+ * persisted on the entity. The matching `SandboxChoice` type is hand-maintained
7144
+ * in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
7145
+ * the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
7146
+ *
7147
+ * Validation happens once, at the router boundary (this schema is embedded in
7148
+ * the spawn body schema); the spawn resolver consumes already-validated input,
7149
+ * so there is intentionally no separate `parse` helper here.
7150
+ */
7151
+ const sandboxChoiceSchema = __sinclair_typebox.Type.Object({
7152
+ profile: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7153
+ key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7154
+ scope: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`entity`), __sinclair_typebox.Type.Literal(`wake`)])),
7155
+ persistent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
7156
+ owner: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
7157
+ inherit: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
7158
+ });
7159
+
6847
7160
  //#endregion
6848
7161
  //#region src/routing/entities-router.ts
6849
7162
  const stringRecordSchema$1 = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String());
@@ -6866,6 +7179,7 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
6866
7179
  tags: __sinclair_typebox.Type.Optional(stringRecordSchema$1),
6867
7180
  parent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6868
7181
  dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
7182
+ sandbox: __sinclair_typebox.Type.Optional(sandboxChoiceSchema),
6869
7183
  initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
6870
7184
  wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
6871
7185
  subscriberUrl: __sinclair_typebox.Type.String(),
@@ -6907,7 +7221,11 @@ const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
6907
7221
  });
6908
7222
  const forkBodySchema = __sinclair_typebox.Type.Object({
6909
7223
  instance_id: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6910
- waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
7224
+ waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
7225
+ fork_pointer: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
7226
+ offset: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Null()]),
7227
+ sub_offset: __sinclair_typebox.Type.Number()
7228
+ }))
6911
7229
  });
6912
7230
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
6913
7231
  const entitySignalSchema = __sinclair_typebox.Type.Union([
@@ -7225,7 +7543,11 @@ async function forkEntity(request, ctx) {
7225
7543
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7226
7544
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7227
7545
  rootInstanceId: parsed.instance_id,
7228
- waitTimeoutMs: parsed.waitTimeoutMs
7546
+ waitTimeoutMs: parsed.waitTimeoutMs,
7547
+ ...parsed.fork_pointer && { forkPointer: {
7548
+ offset: parsed.fork_pointer.offset,
7549
+ subOffset: parsed.fork_pointer.sub_offset
7550
+ } }
7229
7551
  });
7230
7552
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7231
7553
  return (0, itty_router.json)({
@@ -7323,6 +7645,7 @@ async function spawnEntity(request, ctx) {
7323
7645
  tags: parsed.tags,
7324
7646
  parent: parsed.parent,
7325
7647
  dispatch_policy: dispatchPolicy,
7648
+ sandbox: parsed.sandbox,
7326
7649
  initialMessage: void 0,
7327
7650
  wake: parsed.wake,
7328
7651
  created_by: principal.url
@@ -7577,6 +7900,12 @@ function withLeadingSlash(path$2) {
7577
7900
 
7578
7901
  //#endregion
7579
7902
  //#region src/routing/runners-router.ts
7903
+ const sandboxProfileBodySchema = __sinclair_typebox.Type.Object({
7904
+ name: __sinclair_typebox.Type.String(),
7905
+ label: __sinclair_typebox.Type.String(),
7906
+ description: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7907
+ remote: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
7908
+ });
7580
7909
  const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7581
7910
  id: __sinclair_typebox.Type.String(),
7582
7911
  owner_principal: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -7589,7 +7918,8 @@ const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7589
7918
  __sinclair_typebox.Type.Literal(`server`)
7590
7919
  ])),
7591
7920
  admin_status: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`enabled`), __sinclair_typebox.Type.Literal(`disabled`)])),
7592
- wake_stream: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
7921
+ wake_stream: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7922
+ sandbox_profiles: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(sandboxProfileBodySchema))
7593
7923
  });
7594
7924
  const heartbeatBodySchema = __sinclair_typebox.Type.Object({
7595
7925
  lease_ms: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
@@ -7687,7 +8017,8 @@ async function registerRunner(request, ctx) {
7687
8017
  label: parsed.label,
7688
8018
  kind: parsed.kind,
7689
8019
  adminStatus: parsed.admin_status,
7690
- wakeStream: parsed.wake_stream
8020
+ wakeStream: parsed.wake_stream,
8021
+ sandboxProfiles: parsed.sandbox_profiles
7691
8022
  });
7692
8023
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7693
8024
  return (0, itty_router.json)(runner, { status: 201 });
@@ -7917,6 +8248,7 @@ async function notificationFromClaim(ctx, input) {
7917
8248
  streams: entity.streams,
7918
8249
  tags: entity.tags,
7919
8250
  spawnArgs: entity.spawn_args,
8251
+ sandbox: entity.sandbox,
7920
8252
  createdBy: entity.created_by
7921
8253
  },
7922
8254
  principal: principalFromCreatedBy(entity.created_by)