@electric-ax/agents-server 0.4.13 → 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
  };
@@ -1189,35 +1227,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
1189
1227
  const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
1190
1228
  const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
1191
1229
  const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
1192
- const LOG_DIR = USE_FILE_LOGS ? process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`) : void 0;
1193
- const LOG_FILE = LOG_DIR ? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`) : void 0;
1194
- if (LOG_DIR) fs.mkdirSync(LOG_DIR, { recursive: true });
1195
- const streams = [];
1196
- if (LOG_FILE) streams.push({ stream: pino.destination({
1197
- dest: LOG_FILE,
1198
- sync: IS_ELECTRON_MAIN
1199
- }) });
1200
- if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1201
- target: `pino-pretty`,
1202
- options: {
1203
- colorize: true,
1204
- ignore: `pid,hostname,name`,
1205
- translateTime: `SYS:HH:MM:ss`
1206
- }
1207
- }) });
1208
- const logger = streams.length > 0 ? pino({
1209
- base: void 0,
1210
- level: LOG_LEVEL
1211
- }, pino.multistream(streams)) : pino({
1212
- base: void 0,
1213
- enabled: false,
1214
- level: LOG_LEVEL
1215
- });
1230
+ let _logger;
1231
+ function getLogger() {
1232
+ if (_logger) return _logger;
1233
+ const streams = [];
1234
+ try {
1235
+ if (USE_FILE_LOGS) {
1236
+ const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
1237
+ fs.mkdirSync(logDir, { recursive: true });
1238
+ const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
1239
+ streams.push({ stream: pino.destination({
1240
+ dest: logFile,
1241
+ sync: IS_ELECTRON_MAIN
1242
+ }) });
1243
+ }
1244
+ } catch (err) {
1245
+ process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
1246
+ }
1247
+ try {
1248
+ if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1249
+ target: `pino-pretty`,
1250
+ options: {
1251
+ colorize: true,
1252
+ ignore: `pid,hostname,name`,
1253
+ translateTime: `SYS:HH:MM:ss`
1254
+ }
1255
+ }) });
1256
+ } catch {}
1257
+ _logger = streams.length > 0 ? pino({
1258
+ base: void 0,
1259
+ level: LOG_LEVEL
1260
+ }, pino.multistream(streams)) : pino({
1261
+ base: void 0,
1262
+ enabled: false,
1263
+ level: LOG_LEVEL
1264
+ });
1265
+ return _logger;
1266
+ }
1216
1267
  function formatArgs(args) {
1217
1268
  const errors = [];
1218
1269
  const parts = [];
1219
- for (const a of args) if (a instanceof Error) errors.push(a);
1220
- else parts.push(typeof a === `string` ? a : JSON.stringify(a));
1270
+ for (const value of args) if (value instanceof Error) errors.push(value);
1271
+ else parts.push(typeof value === `string` ? value : JSON.stringify(value));
1221
1272
  return {
1222
1273
  err: errors[0],
1223
1274
  msg: parts.join(` `)
@@ -1226,20 +1277,20 @@ function formatArgs(args) {
1226
1277
  const serverLog = {
1227
1278
  info(...args) {
1228
1279
  const { msg } = formatArgs(args);
1229
- logger.info(msg);
1280
+ getLogger().info(msg);
1230
1281
  },
1231
1282
  warn(...args) {
1232
1283
  const { err, msg } = formatArgs(args);
1233
- if (err) logger.warn({ err }, msg);
1234
- else logger.warn(msg);
1284
+ if (err) getLogger().warn({ err }, msg);
1285
+ else getLogger().warn(msg);
1235
1286
  },
1236
1287
  error(...args) {
1237
1288
  const { err, msg } = formatArgs(args);
1238
- if (err) logger.error({ err }, msg);
1239
- else logger.error(msg);
1289
+ if (err) getLogger().error({ err }, msg);
1290
+ else getLogger().error(msg);
1240
1291
  },
1241
1292
  event(obj, msg) {
1242
- logger.info(obj, msg);
1293
+ getLogger().info(obj, msg);
1243
1294
  }
1244
1295
  };
1245
1296
 
@@ -1252,6 +1303,7 @@ const ENTITY_SHAPE_COLUMNS = [
1252
1303
  `status`,
1253
1304
  `tags`,
1254
1305
  `spawn_args`,
1306
+ `sandbox`,
1255
1307
  `parent`,
1256
1308
  `type_revision`,
1257
1309
  `inbox_schemas`,
@@ -1285,6 +1337,7 @@ function toMemberRow(entity) {
1285
1337
  status: entity.status,
1286
1338
  tags: entity.tags,
1287
1339
  spawn_args: entity.spawn_args ?? {},
1340
+ sandbox: entity.sandbox ?? null,
1288
1341
  parent: entity.parent ?? null,
1289
1342
  type_revision: entity.type_revision ?? null,
1290
1343
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -1965,7 +2018,7 @@ var StreamClient = class {
1965
2018
  });
1966
2019
  });
1967
2020
  }
1968
- async fork(path$1, sourcePath) {
2021
+ async fork(path$1, sourcePath, opts) {
1969
2022
  return await withSpan(`stream.fork`, async (span) => {
1970
2023
  span.setAttributes({
1971
2024
  [ATTR.STREAM_PATH]: path$1,
@@ -1975,6 +2028,11 @@ var StreamClient = class {
1975
2028
  "content-type": `application/json`,
1976
2029
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
1977
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
+ }
1978
2036
  injectTraceHeaders(headers);
1979
2037
  const response = await fetch(this.streamUrl(path$1), {
1980
2038
  method: `PUT`,
@@ -2219,11 +2277,11 @@ var StreamClient = class {
2219
2277
  if (res.status === 404 || res.status === 204) return;
2220
2278
  if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
2221
2279
  }
2222
- async addSubscriptionStreams(subscriptionId, streams$1) {
2280
+ async addSubscriptionStreams(subscriptionId, streams) {
2223
2281
  const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
2224
2282
  method: `POST`,
2225
2283
  headers: await this.requestHeaders({ "content-type": `application/json` }),
2226
- body: JSON.stringify({ streams: streams$1.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
2284
+ body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
2227
2285
  });
2228
2286
  return await this.subscriptionJson(res, `Subscription stream add failed`);
2229
2287
  }
@@ -2502,6 +2560,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2502
2560
  });
2503
2561
  }
2504
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
+
2505
2660
  //#endregion
2506
2661
  //#region src/principal.ts
2507
2662
  const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
@@ -2902,6 +3057,7 @@ var EntityManager = class {
2902
3057
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2903
3058
  }
2904
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);
2905
3061
  const now = Date.now();
2906
3062
  const entityData = {
2907
3063
  type: typeName,
@@ -2916,6 +3072,7 @@ var EntityManager = class {
2916
3072
  write_token: writeToken,
2917
3073
  tags: initialTags,
2918
3074
  spawn_args: req.args,
3075
+ sandbox,
2919
3076
  type_revision: entityType.revision,
2920
3077
  inbox_schemas: entityType.inbox_schemas,
2921
3078
  state_schemas: entityType.state_schemas,
@@ -3045,27 +3202,66 @@ var EntityManager = class {
3045
3202
  const writeEntityLocks = new Set();
3046
3203
  const writeStreamLocks = new Set();
3047
3204
  try {
3048
- 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);
3049
3212
  const sourceRoot = sourceTree[0];
3050
3213
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3051
- 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
+ }
3052
3247
  const suffix = randomUUID().slice(0, 8);
3053
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3248
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3054
3249
  suffix,
3055
3250
  rootUrl,
3056
3251
  rootInstanceId: opts.rootInstanceId
3057
3252
  });
3058
3253
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3059
3254
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3060
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3061
- 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);
3062
3257
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3063
3258
  const createdStreams = [];
3064
3259
  const createdEntities = [];
3065
3260
  const activeManifestsByEntity = new Map();
3066
3261
  try {
3067
3262
  for (const plan of entityPlans) {
3068
- 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);
3069
3265
  createdStreams.push(plan.fork.streams.main);
3070
3266
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3071
3267
  createdStreams.push(plan.fork.streams.error);
@@ -3158,6 +3354,38 @@ var EntityManager = class {
3158
3354
  }
3159
3355
  held.clear();
3160
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
+ }
3161
3389
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3162
3390
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3163
3391
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3187,6 +3415,73 @@ var EntityManager = class {
3187
3415
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3188
3416
  }
3189
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
+ }
3190
3485
  async listEntitySubtree(root) {
3191
3486
  const result = [];
3192
3487
  const queue = [root];
@@ -4294,6 +4589,7 @@ var EntityManager = class {
4294
4589
  streams: entity.streams,
4295
4590
  tags: entity.tags,
4296
4591
  spawnArgs: entity.spawn_args,
4592
+ sandbox: entity.sandbox,
4297
4593
  createdBy: entity.created_by
4298
4594
  },
4299
4595
  principal: principalFromCreatedBy(entity.created_by),
@@ -5959,11 +6255,19 @@ var WakeRegistry = class {
5959
6255
  }
5960
6256
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
5961
6257
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
5962
- return { change: {
6258
+ const change = {
5963
6259
  collection: eventType,
5964
6260
  kind,
5965
6261
  key: event.key || ``
5966
- } };
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 };
5967
6271
  }
5968
6272
  };
5969
6273
 
@@ -6370,13 +6674,13 @@ function buildElectricProxyTarget(options) {
6370
6674
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6371
6675
  const table = options.incomingUrl.searchParams.get(`table`);
6372
6676
  if (table === `entities`) {
6373
- 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"`);
6374
6678
  applyTenantShapeWhere(target, options.tenantId);
6375
6679
  } else if (table === `entity_types`) {
6376
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"`);
6377
6681
  applyTenantShapeWhere(target, options.tenantId);
6378
6682
  } else if (table === `runners`) {
6379
- 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"`);
6380
6684
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6381
6685
  } else if (table === `runner_runtime_diagnostics`) {
6382
6686
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
@@ -6802,6 +7106,28 @@ async function proxyElectric(request, ctx) {
6802
7106
  });
6803
7107
  }
6804
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
+
6805
7131
  //#endregion
6806
7132
  //#region src/routing/entities-router.ts
6807
7133
  const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
@@ -6824,6 +7150,7 @@ const spawnBodySchema = Type.Object({
6824
7150
  tags: Type.Optional(stringRecordSchema$1),
6825
7151
  parent: Type.Optional(Type.String()),
6826
7152
  dispatch_policy: Type.Optional(dispatchPolicySchema),
7153
+ sandbox: Type.Optional(sandboxChoiceSchema),
6827
7154
  initialMessage: Type.Optional(Type.Unknown()),
6828
7155
  wake: Type.Optional(Type.Object({
6829
7156
  subscriberUrl: Type.String(),
@@ -6865,7 +7192,11 @@ const inboxMessageBodySchema = Type.Object({
6865
7192
  });
6866
7193
  const forkBodySchema = Type.Object({
6867
7194
  instance_id: Type.Optional(Type.String()),
6868
- 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
+ }))
6869
7200
  });
6870
7201
  const setTagBodySchema = Type.Object({ value: Type.String() });
6871
7202
  const entitySignalSchema = Type.Union([
@@ -7183,7 +7514,11 @@ async function forkEntity(request, ctx) {
7183
7514
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7184
7515
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7185
7516
  rootInstanceId: parsed.instance_id,
7186
- 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
+ } }
7187
7522
  });
7188
7523
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7189
7524
  return json({
@@ -7281,6 +7616,7 @@ async function spawnEntity(request, ctx) {
7281
7616
  tags: parsed.tags,
7282
7617
  parent: parsed.parent,
7283
7618
  dispatch_policy: dispatchPolicy,
7619
+ sandbox: parsed.sandbox,
7284
7620
  initialMessage: void 0,
7285
7621
  wake: parsed.wake,
7286
7622
  created_by: principal.url
@@ -7535,6 +7871,12 @@ function withLeadingSlash(path$1) {
7535
7871
 
7536
7872
  //#endregion
7537
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
+ });
7538
7880
  const registerRunnerBodySchema = Type.Object({
7539
7881
  id: Type.String(),
7540
7882
  owner_principal: Type.Optional(Type.String()),
@@ -7547,7 +7889,8 @@ const registerRunnerBodySchema = Type.Object({
7547
7889
  Type.Literal(`server`)
7548
7890
  ])),
7549
7891
  admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
7550
- wake_stream: Type.Optional(Type.String())
7892
+ wake_stream: Type.Optional(Type.String()),
7893
+ sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
7551
7894
  });
7552
7895
  const heartbeatBodySchema = Type.Object({
7553
7896
  lease_ms: Type.Optional(Type.Number()),
@@ -7645,7 +7988,8 @@ async function registerRunner(request, ctx) {
7645
7988
  label: parsed.label,
7646
7989
  kind: parsed.kind,
7647
7990
  adminStatus: parsed.admin_status,
7648
- wakeStream: parsed.wake_stream
7991
+ wakeStream: parsed.wake_stream,
7992
+ sandboxProfiles: parsed.sandbox_profiles
7649
7993
  });
7650
7994
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7651
7995
  return json(runner, { status: 201 });
@@ -7855,7 +8199,7 @@ async function notificationFromClaim(ctx, input) {
7855
8199
  leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
7856
8200
  });
7857
8201
  await ctx.entityManager.registry.updateStatus(entity.url, `running`);
7858
- const streams$1 = input.claim.streams.map((stream) => ({
8202
+ const streams = input.claim.streams.map((stream) => ({
7859
8203
  path: withLeadingSlash(stream.path),
7860
8204
  offset: stream.tail_offset ?? ``
7861
8205
  }));
@@ -7864,7 +8208,7 @@ async function notificationFromClaim(ctx, input) {
7864
8208
  epoch: input.claim.generation,
7865
8209
  wakeId: input.claim.wake_id,
7866
8210
  streamPath: primaryStream,
7867
- streams: streams$1,
8211
+ streams,
7868
8212
  callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
7869
8213
  claimToken: input.claim.token,
7870
8214
  triggerEvent: `message_received`,
@@ -7875,6 +8219,7 @@ async function notificationFromClaim(ctx, input) {
7875
8219
  streams: entity.streams,
7876
8220
  tags: entity.tags,
7877
8221
  spawnArgs: entity.spawn_args,
8222
+ sandbox: entity.sandbox,
7878
8223
  createdBy: entity.created_by
7879
8224
  },
7880
8225
  principal: principalFromCreatedBy(entity.created_by)