@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.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
  };
@@ -1218,35 +1256,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
1218
1256
  const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
1219
1257
  const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
1220
1258
  const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
1221
- const LOG_DIR = USE_FILE_LOGS ? process.env.ELECTRIC_AGENTS_LOG_DIR ?? node_path.default.resolve(process.cwd(), `logs`) : void 0;
1222
- const LOG_FILE = LOG_DIR ? node_path.default.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`) : void 0;
1223
- if (LOG_DIR) node_fs.default.mkdirSync(LOG_DIR, { recursive: true });
1224
- const streams = [];
1225
- if (LOG_FILE) streams.push({ stream: pino.default.destination({
1226
- dest: LOG_FILE,
1227
- sync: IS_ELECTRON_MAIN
1228
- }) });
1229
- if (USE_PRETTY_LOGS) streams.push({ stream: pino.default.transport({
1230
- target: `pino-pretty`,
1231
- options: {
1232
- colorize: true,
1233
- ignore: `pid,hostname,name`,
1234
- translateTime: `SYS:HH:MM:ss`
1235
- }
1236
- }) });
1237
- const logger = streams.length > 0 ? (0, pino.default)({
1238
- base: void 0,
1239
- level: LOG_LEVEL
1240
- }, pino.default.multistream(streams)) : (0, pino.default)({
1241
- base: void 0,
1242
- enabled: false,
1243
- level: LOG_LEVEL
1244
- });
1259
+ let _logger;
1260
+ function getLogger() {
1261
+ if (_logger) return _logger;
1262
+ const streams = [];
1263
+ try {
1264
+ if (USE_FILE_LOGS) {
1265
+ const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? node_path.default.resolve(process.cwd(), `logs`);
1266
+ node_fs.default.mkdirSync(logDir, { recursive: true });
1267
+ const logFile = node_path.default.join(logDir, `agent-server-${Date.now()}.jsonl`);
1268
+ streams.push({ stream: pino.default.destination({
1269
+ dest: logFile,
1270
+ sync: IS_ELECTRON_MAIN
1271
+ }) });
1272
+ }
1273
+ } catch (err) {
1274
+ process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
1275
+ }
1276
+ try {
1277
+ if (USE_PRETTY_LOGS) streams.push({ stream: pino.default.transport({
1278
+ target: `pino-pretty`,
1279
+ options: {
1280
+ colorize: true,
1281
+ ignore: `pid,hostname,name`,
1282
+ translateTime: `SYS:HH:MM:ss`
1283
+ }
1284
+ }) });
1285
+ } catch {}
1286
+ _logger = streams.length > 0 ? (0, pino.default)({
1287
+ base: void 0,
1288
+ level: LOG_LEVEL
1289
+ }, pino.default.multistream(streams)) : (0, pino.default)({
1290
+ base: void 0,
1291
+ enabled: false,
1292
+ level: LOG_LEVEL
1293
+ });
1294
+ return _logger;
1295
+ }
1245
1296
  function formatArgs(args) {
1246
1297
  const errors = [];
1247
1298
  const parts = [];
1248
- for (const a of args) if (a instanceof Error) errors.push(a);
1249
- else parts.push(typeof a === `string` ? a : JSON.stringify(a));
1299
+ for (const value of args) if (value instanceof Error) errors.push(value);
1300
+ else parts.push(typeof value === `string` ? value : JSON.stringify(value));
1250
1301
  return {
1251
1302
  err: errors[0],
1252
1303
  msg: parts.join(` `)
@@ -1255,20 +1306,20 @@ function formatArgs(args) {
1255
1306
  const serverLog = {
1256
1307
  info(...args) {
1257
1308
  const { msg } = formatArgs(args);
1258
- logger.info(msg);
1309
+ getLogger().info(msg);
1259
1310
  },
1260
1311
  warn(...args) {
1261
1312
  const { err, msg } = formatArgs(args);
1262
- if (err) logger.warn({ err }, msg);
1263
- else logger.warn(msg);
1313
+ if (err) getLogger().warn({ err }, msg);
1314
+ else getLogger().warn(msg);
1264
1315
  },
1265
1316
  error(...args) {
1266
1317
  const { err, msg } = formatArgs(args);
1267
- if (err) logger.error({ err }, msg);
1268
- else logger.error(msg);
1318
+ if (err) getLogger().error({ err }, msg);
1319
+ else getLogger().error(msg);
1269
1320
  },
1270
1321
  event(obj, msg) {
1271
- logger.info(obj, msg);
1322
+ getLogger().info(obj, msg);
1272
1323
  }
1273
1324
  };
1274
1325
 
@@ -1281,6 +1332,7 @@ const ENTITY_SHAPE_COLUMNS = [
1281
1332
  `status`,
1282
1333
  `tags`,
1283
1334
  `spawn_args`,
1335
+ `sandbox`,
1284
1336
  `parent`,
1285
1337
  `type_revision`,
1286
1338
  `inbox_schemas`,
@@ -1314,6 +1366,7 @@ function toMemberRow(entity) {
1314
1366
  status: entity.status,
1315
1367
  tags: entity.tags,
1316
1368
  spawn_args: entity.spawn_args ?? {},
1369
+ sandbox: entity.sandbox ?? null,
1317
1370
  parent: entity.parent ?? null,
1318
1371
  type_revision: entity.type_revision ?? null,
1319
1372
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -1994,7 +2047,7 @@ var StreamClient = class {
1994
2047
  });
1995
2048
  });
1996
2049
  }
1997
- async fork(path$2, sourcePath) {
2050
+ async fork(path$2, sourcePath, opts) {
1998
2051
  return await withSpan(`stream.fork`, async (span) => {
1999
2052
  span.setAttributes({
2000
2053
  [ATTR.STREAM_PATH]: path$2,
@@ -2004,6 +2057,11 @@ var StreamClient = class {
2004
2057
  "content-type": `application/json`,
2005
2058
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
2006
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
+ }
2007
2065
  injectTraceHeaders(headers);
2008
2066
  const response = await fetch(this.streamUrl(path$2), {
2009
2067
  method: `PUT`,
@@ -2248,11 +2306,11 @@ var StreamClient = class {
2248
2306
  if (res.status === 404 || res.status === 204) return;
2249
2307
  if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
2250
2308
  }
2251
- async addSubscriptionStreams(subscriptionId, streams$1) {
2309
+ async addSubscriptionStreams(subscriptionId, streams) {
2252
2310
  const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
2253
2311
  method: `POST`,
2254
2312
  headers: await this.requestHeaders({ "content-type": `application/json` }),
2255
- body: JSON.stringify({ streams: streams$1.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
2313
+ body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
2256
2314
  });
2257
2315
  return await this.subscriptionJson(res, `Subscription stream add failed`);
2258
2316
  }
@@ -2531,6 +2589,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2531
2589
  });
2532
2590
  }
2533
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
+
2534
2689
  //#endregion
2535
2690
  //#region src/principal.ts
2536
2691
  const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
@@ -2931,6 +3086,7 @@ var EntityManager = class {
2931
3086
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2932
3087
  }
2933
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);
2934
3090
  const now = Date.now();
2935
3091
  const entityData = {
2936
3092
  type: typeName,
@@ -2945,6 +3101,7 @@ var EntityManager = class {
2945
3101
  write_token: writeToken,
2946
3102
  tags: initialTags,
2947
3103
  spawn_args: req.args,
3104
+ sandbox,
2948
3105
  type_revision: entityType.revision,
2949
3106
  inbox_schemas: entityType.inbox_schemas,
2950
3107
  state_schemas: entityType.state_schemas,
@@ -3074,27 +3231,66 @@ var EntityManager = class {
3074
3231
  const writeEntityLocks = new Set();
3075
3232
  const writeStreamLocks = new Set();
3076
3233
  try {
3077
- 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);
3078
3241
  const sourceRoot = sourceTree[0];
3079
3242
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3080
- 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
+ }
3081
3276
  const suffix = (0, node_crypto.randomUUID)().slice(0, 8);
3082
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3277
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3083
3278
  suffix,
3084
3279
  rootUrl,
3085
3280
  rootInstanceId: opts.rootInstanceId
3086
3281
  });
3087
3282
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3088
3283
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3089
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3090
- 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);
3091
3286
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(id)), writeStreamLocks);
3092
3287
  const createdStreams = [];
3093
3288
  const createdEntities = [];
3094
3289
  const activeManifestsByEntity = new Map();
3095
3290
  try {
3096
3291
  for (const plan of entityPlans) {
3097
- 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);
3098
3294
  createdStreams.push(plan.fork.streams.main);
3099
3295
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3100
3296
  createdStreams.push(plan.fork.streams.error);
@@ -3187,6 +3383,38 @@ var EntityManager = class {
3187
3383
  }
3188
3384
  held.clear();
3189
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
+ }
3190
3418
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3191
3419
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3192
3420
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3216,6 +3444,73 @@ var EntityManager = class {
3216
3444
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3217
3445
  }
3218
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
+ }
3219
3514
  async listEntitySubtree(root) {
3220
3515
  const result = [];
3221
3516
  const queue = [root];
@@ -4323,6 +4618,7 @@ var EntityManager = class {
4323
4618
  streams: entity.streams,
4324
4619
  tags: entity.tags,
4325
4620
  spawnArgs: entity.spawn_args,
4621
+ sandbox: entity.sandbox,
4326
4622
  createdBy: entity.created_by
4327
4623
  },
4328
4624
  principal: principalFromCreatedBy(entity.created_by),
@@ -5988,11 +6284,19 @@ var WakeRegistry = class {
5988
6284
  }
5989
6285
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
5990
6286
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
5991
- return { change: {
6287
+ const change = {
5992
6288
  collection: eventType,
5993
6289
  kind,
5994
6290
  key: event.key || ``
5995
- } };
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 };
5996
6300
  }
5997
6301
  };
5998
6302
 
@@ -6399,13 +6703,13 @@ function buildElectricProxyTarget(options) {
6399
6703
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6400
6704
  const table = options.incomingUrl.searchParams.get(`table`);
6401
6705
  if (table === `entities`) {
6402
- 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"`);
6403
6707
  applyTenantShapeWhere(target, options.tenantId);
6404
6708
  } else if (table === `entity_types`) {
6405
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"`);
6406
6710
  applyTenantShapeWhere(target, options.tenantId);
6407
6711
  } else if (table === `runners`) {
6408
- 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"`);
6409
6713
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6410
6714
  } else if (table === `runner_runtime_diagnostics`) {
6411
6715
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
@@ -6831,6 +7135,28 @@ async function proxyElectric(request, ctx) {
6831
7135
  });
6832
7136
  }
6833
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
+
6834
7160
  //#endregion
6835
7161
  //#region src/routing/entities-router.ts
6836
7162
  const stringRecordSchema$1 = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String());
@@ -6853,6 +7179,7 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
6853
7179
  tags: __sinclair_typebox.Type.Optional(stringRecordSchema$1),
6854
7180
  parent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6855
7181
  dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
7182
+ sandbox: __sinclair_typebox.Type.Optional(sandboxChoiceSchema),
6856
7183
  initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
6857
7184
  wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
6858
7185
  subscriberUrl: __sinclair_typebox.Type.String(),
@@ -6894,7 +7221,11 @@ const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
6894
7221
  });
6895
7222
  const forkBodySchema = __sinclair_typebox.Type.Object({
6896
7223
  instance_id: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6897
- 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
+ }))
6898
7229
  });
6899
7230
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
6900
7231
  const entitySignalSchema = __sinclair_typebox.Type.Union([
@@ -7212,7 +7543,11 @@ async function forkEntity(request, ctx) {
7212
7543
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7213
7544
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7214
7545
  rootInstanceId: parsed.instance_id,
7215
- 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
+ } }
7216
7551
  });
7217
7552
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7218
7553
  return (0, itty_router.json)({
@@ -7310,6 +7645,7 @@ async function spawnEntity(request, ctx) {
7310
7645
  tags: parsed.tags,
7311
7646
  parent: parsed.parent,
7312
7647
  dispatch_policy: dispatchPolicy,
7648
+ sandbox: parsed.sandbox,
7313
7649
  initialMessage: void 0,
7314
7650
  wake: parsed.wake,
7315
7651
  created_by: principal.url
@@ -7564,6 +7900,12 @@ function withLeadingSlash(path$2) {
7564
7900
 
7565
7901
  //#endregion
7566
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
+ });
7567
7909
  const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7568
7910
  id: __sinclair_typebox.Type.String(),
7569
7911
  owner_principal: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -7576,7 +7918,8 @@ const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7576
7918
  __sinclair_typebox.Type.Literal(`server`)
7577
7919
  ])),
7578
7920
  admin_status: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`enabled`), __sinclair_typebox.Type.Literal(`disabled`)])),
7579
- 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))
7580
7923
  });
7581
7924
  const heartbeatBodySchema = __sinclair_typebox.Type.Object({
7582
7925
  lease_ms: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
@@ -7674,7 +8017,8 @@ async function registerRunner(request, ctx) {
7674
8017
  label: parsed.label,
7675
8018
  kind: parsed.kind,
7676
8019
  adminStatus: parsed.admin_status,
7677
- wakeStream: parsed.wake_stream
8020
+ wakeStream: parsed.wake_stream,
8021
+ sandboxProfiles: parsed.sandbox_profiles
7678
8022
  });
7679
8023
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7680
8024
  return (0, itty_router.json)(runner, { status: 201 });
@@ -7884,7 +8228,7 @@ async function notificationFromClaim(ctx, input) {
7884
8228
  leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
7885
8229
  });
7886
8230
  await ctx.entityManager.registry.updateStatus(entity.url, `running`);
7887
- const streams$1 = input.claim.streams.map((stream) => ({
8231
+ const streams = input.claim.streams.map((stream) => ({
7888
8232
  path: withLeadingSlash(stream.path),
7889
8233
  offset: stream.tail_offset ?? ``
7890
8234
  }));
@@ -7893,7 +8237,7 @@ async function notificationFromClaim(ctx, input) {
7893
8237
  epoch: input.claim.generation,
7894
8238
  wakeId: input.claim.wake_id,
7895
8239
  streamPath: primaryStream,
7896
- streams: streams$1,
8240
+ streams,
7897
8241
  callback: (0, __electric_ax_agents_runtime.appendPathToUrl)(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
7898
8242
  claimToken: input.claim.token,
7899
8243
  triggerEvent: `message_received`,
@@ -7904,6 +8248,7 @@ async function notificationFromClaim(ctx, input) {
7904
8248
  streams: entity.streams,
7905
8249
  tags: entity.tags,
7906
8250
  spawnArgs: entity.spawn_args,
8251
+ sandbox: entity.sandbox,
7907
8252
  createdBy: entity.created_by
7908
8253
  },
7909
8254
  principal: principalFromCreatedBy(entity.created_by)