@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.
@@ -76,6 +76,7 @@ const entities = pgTable(`entities`, {
76
76
  tags: jsonb(`tags`).notNull().default({}),
77
77
  tagsIndex: text(`tags_index`).array().notNull().default(sql`'{}'::text[]`),
78
78
  spawnArgs: jsonb(`spawn_args`).default({}),
79
+ sandbox: jsonb(`sandbox`),
79
80
  parent: text(`parent`),
80
81
  createdBy: text(`created_by`),
81
82
  typeRevision: integer(`type_revision`),
@@ -117,6 +118,7 @@ const runners = pgTable(`runners`, {
117
118
  kind: text(`kind`).notNull().default(`local`),
118
119
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
119
120
  wakeStream: text(`wake_stream`).notNull(),
121
+ sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
120
122
  createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
121
123
  updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
122
124
  }, (table) => [
@@ -422,6 +424,7 @@ function toPublicEntity(entity) {
422
424
  dispatch_policy: entity.dispatch_policy,
423
425
  tags: entity.tags,
424
426
  spawn_args: entity.spawn_args,
427
+ sandbox: entity.sandbox,
425
428
  parent: entity.parent,
426
429
  created_by: entity.created_by,
427
430
  created_at: entity.created_at,
@@ -639,7 +642,7 @@ var StreamClient = class {
639
642
  });
640
643
  });
641
644
  }
642
- async fork(path$1, sourcePath) {
645
+ async fork(path$1, sourcePath, opts) {
643
646
  return await withSpan(`stream.fork`, async (span) => {
644
647
  span.setAttributes({
645
648
  [ATTR.STREAM_PATH]: path$1,
@@ -649,6 +652,11 @@ var StreamClient = class {
649
652
  "content-type": `application/json`,
650
653
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
651
654
  };
655
+ if (opts?.forkPointer) {
656
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`;
657
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
658
+ if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
659
+ }
652
660
  injectTraceHeaders(headers);
653
661
  const response = await fetch(this.streamUrl(path$1), {
654
662
  method: `PUT`,
@@ -1037,13 +1045,13 @@ function buildElectricProxyTarget(options) {
1037
1045
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
1038
1046
  const table = options.incomingUrl.searchParams.get(`table`);
1039
1047
  if (table === `entities`) {
1040
- 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"`);
1048
+ 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"`);
1041
1049
  applyTenantShapeWhere(target, options.tenantId);
1042
1050
  } else if (table === `entity_types`) {
1043
1051
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
1044
1052
  applyTenantShapeWhere(target, options.tenantId);
1045
1053
  } else if (table === `runners`) {
1046
- target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
1054
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
1047
1055
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral$2(options.principalUrl ?? ``)}`]);
1048
1056
  } else if (table === `runner_runtime_diagnostics`) {
1049
1057
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
@@ -1889,6 +1897,125 @@ function parseDispatchPolicy(value, label = `dispatch_policy`) {
1889
1897
  throw new Error(details ? `${label} does not match dispatch policy schema: ${details}` : `${label} does not match dispatch policy schema`);
1890
1898
  }
1891
1899
 
1900
+ //#endregion
1901
+ //#region src/sandbox-choice-schema.ts
1902
+ /**
1903
+ * Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
1904
+ * the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
1905
+ * persisted on the entity. The matching `SandboxChoice` type is hand-maintained
1906
+ * in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
1907
+ * the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
1908
+ *
1909
+ * Validation happens once, at the router boundary (this schema is embedded in
1910
+ * the spawn body schema); the spawn resolver consumes already-validated input,
1911
+ * so there is intentionally no separate `parse` helper here.
1912
+ */
1913
+ const sandboxChoiceSchema = Type.Object({
1914
+ profile: Type.Optional(Type.String()),
1915
+ key: Type.Optional(Type.String()),
1916
+ scope: Type.Optional(Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])),
1917
+ persistent: Type.Optional(Type.Boolean()),
1918
+ owner: Type.Optional(Type.Boolean()),
1919
+ inherit: Type.Optional(Type.Boolean())
1920
+ });
1921
+
1922
+ //#endregion
1923
+ //#region src/routing/sandbox.ts
1924
+ /**
1925
+ * Resolve and validate a spawn's sandbox CHOICE into the {@link
1926
+ * EntitySandboxSelection} persisted on the entity. Sibling of
1927
+ * `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
1928
+ * EntityManager so the spawn path reads as composed resolution steps.
1929
+ *
1930
+ * Profiles are a per-runner concern: each runner advertises what it supports.
1931
+ * When the spawn pins a runner via dispatch_policy, the chosen profile must be
1932
+ * in that runner's advertised set; otherwise we'd persist an unserviceable
1933
+ * choice that fails late at first wake. For unpinned dispatch (webhook /
1934
+ * parent-inherited) we can't pick a target ahead of time, so we fall back to a
1935
+ * tenant-wide "some runner offers this" check — better than nothing.
1936
+ */
1937
+ async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
1938
+ if (!requested) return void 0;
1939
+ const choice = applyInheritedSandbox(requested, parentEntity);
1940
+ if (!choice) return void 0;
1941
+ const chosenName = choice.profile;
1942
+ if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
1943
+ const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
1944
+ assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
1945
+ const selection = { profile: chosenName };
1946
+ if (choice.key !== void 0) selection.key = choice.key;
1947
+ else if (choice.scope !== void 0) selection.scope = choice.scope;
1948
+ if (choice.persistent !== void 0) selection.persistent = choice.persistent;
1949
+ if (choice.owner === false) selection.owner = false;
1950
+ return selection;
1951
+ }
1952
+ /**
1953
+ * Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
1954
+ * parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
1955
+ * parent has no shareable (keyed) sandbox the child simply gets none (returns
1956
+ * `undefined`), so `spawn_worker` can always request inheritance without
1957
+ * breaking unkeyed parents. (A running parent wake resolves inherit to its live
1958
+ * explicit key in the runtime instead — this server-side path covers direct API
1959
+ * callers, where only the parent's *stored* explicit key is available.)
1960
+ *
1961
+ * For a non-inherit choice the request passes through unchanged.
1962
+ *
1963
+ * NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
1964
+ * any sibling field on the request (e.g. a caller-supplied `persistent: false`)
1965
+ * is intentionally ignored, because a child attaches to the parent's existing
1966
+ * sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
1967
+ * permits the `{ inherit: true, persistent: ... }` combination, so the
1968
+ * precedence is resolved here rather than rejected at the schema level.
1969
+ */
1970
+ function applyInheritedSandbox(requested, parentEntity) {
1971
+ if (!requested.inherit) return requested;
1972
+ const parentKey = parentEntity?.sandbox?.key;
1973
+ if (!parentKey) return void 0;
1974
+ return {
1975
+ profile: parentEntity.sandbox.profile,
1976
+ key: parentKey,
1977
+ persistent: parentEntity.sandbox.persistent,
1978
+ owner: false
1979
+ };
1980
+ }
1981
+ /**
1982
+ * Validate the chosen profile is advertised by the relevant runner(s) and
1983
+ * determine whether it is a remote (off-host) sandbox, reachable from any
1984
+ * runner. Defaults to host-local (co-location required) unless every relevant
1985
+ * advertisement marks it remote. Throws if the profile is unserviceable.
1986
+ */
1987
+ async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
1988
+ const runnerIds = [];
1989
+ for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
1990
+ if (runnerIds.length > 0) {
1991
+ let allRemote = true;
1992
+ for (const runnerId of runnerIds) {
1993
+ const runner = await registry.getRunner(runnerId);
1994
+ const advertised = runner?.sandbox_profiles ?? [];
1995
+ const match = advertised.find((p) => p.name === chosenName);
1996
+ if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
1997
+ if (match.remote !== true) allRemote = false;
1998
+ }
1999
+ return allRemote;
2000
+ }
2001
+ const available = await registry.listSandboxProfiles();
2002
+ const matches = available.filter((p) => p.name === chosenName);
2003
+ 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);
2004
+ return matches.every((p) => p.remote === true);
2005
+ }
2006
+ /**
2007
+ * Co-location: a shared *local* sandbox lives on one host, so every
2008
+ * collaborator must be pinned to the same single runner. Subagents inherit the
2009
+ * parent's dispatch policy, so this holds once the root is pinned. A shared
2010
+ * *remote* sandbox is reachable from any runner, so the guard does not apply.
2011
+ */
2012
+ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
2013
+ if (key === void 0 || chosenIsRemote) return;
2014
+ const targets = dispatchPolicy?.targets ?? [];
2015
+ const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
2016
+ 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);
2017
+ }
2018
+
1892
2019
  //#endregion
1893
2020
  //#region src/tenant.ts
1894
2021
  const DEFAULT_TENANT_ID = `default`;
@@ -1932,6 +2059,12 @@ var PostgresRegistry = class {
1932
2059
  async createRunner(input) {
1933
2060
  const now = new Date();
1934
2061
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
2062
+ const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
2063
+ name: p.name,
2064
+ label: p.label,
2065
+ ...p.description !== void 0 && { description: p.description },
2066
+ ...p.remote !== void 0 && { remote: p.remote }
2067
+ })) : void 0;
1935
2068
  await this.db.insert(runners).values({
1936
2069
  tenantId: this.tenantId,
1937
2070
  id: input.id,
@@ -1940,6 +2073,7 @@ var PostgresRegistry = class {
1940
2073
  kind: input.kind ?? `local`,
1941
2074
  adminStatus: input.adminStatus ?? `enabled`,
1942
2075
  wakeStream,
2076
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
1943
2077
  updatedAt: now
1944
2078
  }).onConflictDoUpdate({
1945
2079
  target: [runners.tenantId, runners.id],
@@ -1949,6 +2083,7 @@ var PostgresRegistry = class {
1949
2083
  kind: input.kind ?? `local`,
1950
2084
  adminStatus: input.adminStatus ?? `enabled`,
1951
2085
  wakeStream,
2086
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
1952
2087
  updatedAt: now
1953
2088
  }
1954
2089
  });
@@ -1956,6 +2091,30 @@ var PostgresRegistry = class {
1956
2091
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
1957
2092
  return runner;
1958
2093
  }
2094
+ /**
2095
+ * Every sandbox profile advertised by a runner in this tenant (one entry
2096
+ * per runner that advertises it — names may repeat across runners). Used by
2097
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
2098
+ * is remote (so a shared sandbox can skip the single-runner guard).
2099
+ */
2100
+ async listSandboxProfiles() {
2101
+ const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where(eq(runners.tenantId, this.tenantId));
2102
+ const profiles = [];
2103
+ for (const row of rows) {
2104
+ const list = row.sandboxProfiles;
2105
+ if (!Array.isArray(list)) continue;
2106
+ for (const entry of list) {
2107
+ if (!entry || typeof entry.name !== `string`) continue;
2108
+ profiles.push({
2109
+ name: entry.name,
2110
+ label: typeof entry.label === `string` ? entry.label : entry.name,
2111
+ ...typeof entry.description === `string` && { description: entry.description },
2112
+ ...typeof entry.remote === `boolean` && { remote: entry.remote }
2113
+ });
2114
+ }
2115
+ }
2116
+ return profiles;
2117
+ }
1959
2118
  async getRunner(id) {
1960
2119
  const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
1961
2120
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -2216,6 +2375,7 @@ var PostgresRegistry = class {
2216
2375
  tags: normalizeTags(entity.tags),
2217
2376
  tagsIndex: buildTagsIndex(entity.tags),
2218
2377
  spawnArgs: entity.spawn_args ?? {},
2378
+ sandbox: entity.sandbox ?? null,
2219
2379
  parent: entity.parent ?? null,
2220
2380
  createdBy: entity.created_by ?? null,
2221
2381
  typeRevision: entity.type_revision ?? null,
@@ -2553,6 +2713,7 @@ var PostgresRegistry = class {
2553
2713
  write_token: row.writeToken,
2554
2714
  tags: row.tags ?? {},
2555
2715
  spawn_args: row.spawnArgs,
2716
+ sandbox: row.sandbox ?? void 0,
2556
2717
  parent: row.parent ?? void 0,
2557
2718
  created_by: row.createdBy ?? void 0,
2558
2719
  type_revision: row.typeRevision ?? void 0,
@@ -2600,6 +2761,7 @@ var PostgresRegistry = class {
2600
2761
  kind: assertRunnerKind(row.kind),
2601
2762
  admin_status: assertRunnerAdminStatus(row.adminStatus),
2602
2763
  wake_stream: row.wakeStream,
2764
+ sandbox_profiles: row.sandboxProfiles ?? [],
2603
2765
  created_at: row.createdAt.toISOString(),
2604
2766
  updated_at: row.updatedAt.toISOString()
2605
2767
  };
@@ -2947,6 +3109,7 @@ var EntityManager = class {
2947
3109
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2948
3110
  }
2949
3111
  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;
3112
+ const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
2950
3113
  const now = Date.now();
2951
3114
  const entityData = {
2952
3115
  type: typeName,
@@ -2961,6 +3124,7 @@ var EntityManager = class {
2961
3124
  write_token: writeToken,
2962
3125
  tags: initialTags,
2963
3126
  spawn_args: req.args,
3127
+ sandbox,
2964
3128
  type_revision: entityType.revision,
2965
3129
  inbox_schemas: entityType.inbox_schemas,
2966
3130
  state_schemas: entityType.state_schemas,
@@ -3090,27 +3254,66 @@ var EntityManager = class {
3090
3254
  const writeEntityLocks = new Set();
3091
3255
  const writeStreamLocks = new Set();
3092
3256
  try {
3093
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3257
+ let sourceTree;
3258
+ if (opts.forkPointer) {
3259
+ const rootEntity = await this.registry.getEntity(rootUrl);
3260
+ if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3261
+ if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
3262
+ sourceTree = await this.listEntitySubtree(rootEntity);
3263
+ } else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3094
3264
  const sourceRoot = sourceTree[0];
3095
3265
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3096
- const snapshot = await this.readForkStateSnapshot(sourceTree);
3266
+ let preFilteredRoot;
3267
+ if (opts.forkPointer) {
3268
+ const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3269
+ const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3270
+ const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3271
+ const filteredEvents = flat.slice(0, target);
3272
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3273
+ const sharedStateIds = new Set();
3274
+ for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
3275
+ preFilteredRoot = {
3276
+ manifests: rootManifests,
3277
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
3278
+ replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
3279
+ sharedStateIds
3280
+ };
3281
+ }
3282
+ const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3283
+ if (opts.forkPointer) {
3284
+ const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3285
+ if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3286
+ }
3287
+ const snapshot = await this.readForkStateSnapshot(
3288
+ // Skip the root when we've already pre-filtered it — avoid both a
3289
+ // wasted HEAD read of main and a re-population that would clobber
3290
+ // the filtered entries.
3291
+ preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
3292
+ );
3293
+ if (preFilteredRoot) {
3294
+ snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
3295
+ snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
3296
+ snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
3297
+ for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
3298
+ }
3097
3299
  const suffix = randomUUID().slice(0, 8);
3098
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3300
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3099
3301
  suffix,
3100
3302
  rootUrl,
3101
3303
  rootInstanceId: opts.rootInstanceId
3102
3304
  });
3103
3305
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3104
3306
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3105
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3106
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
3307
+ const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
3308
+ this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3107
3309
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3108
3310
  const createdStreams = [];
3109
3311
  const createdEntities = [];
3110
3312
  const activeManifestsByEntity = new Map();
3111
3313
  try {
3112
3314
  for (const plan of entityPlans) {
3113
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
3315
+ const isRoot = plan.source.url === rootUrl;
3316
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3114
3317
  createdStreams.push(plan.fork.streams.main);
3115
3318
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3116
3319
  createdStreams.push(plan.fork.streams.error);
@@ -3203,6 +3406,38 @@ var EntityManager = class {
3203
3406
  }
3204
3407
  held.clear();
3205
3408
  }
3409
+ /**
3410
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
3411
+ * list instead of walking the registry from `rootUrl`. Used by the
3412
+ * pointer-fork path to wait+lock only the kept descendants, since
3413
+ * the root is being forked from history and doesn't need to be idle.
3414
+ */
3415
+ async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
3416
+ if (entities$1.length === 0) return;
3417
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3418
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
3419
+ const refresh = async () => {
3420
+ const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
3421
+ return refreshed.filter((entity) => !!entity);
3422
+ };
3423
+ const deadline = Date.now() + timeoutMs;
3424
+ while (true) {
3425
+ const present = await refresh();
3426
+ const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
3427
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3428
+ let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3429
+ if (active.length === 0) {
3430
+ this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
3431
+ const reChecked = await refresh();
3432
+ const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3433
+ if (reActive.length === 0) return;
3434
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3435
+ active = reActive;
3436
+ }
3437
+ if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
3438
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3439
+ }
3440
+ }
3206
3441
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3207
3442
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3208
3443
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3232,6 +3467,73 @@ var EntityManager = class {
3232
3467
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3233
3468
  }
3234
3469
  }
3470
+ /**
3471
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
3472
+ * source's flattened history. Throws a 400 if the pointer doesn't
3473
+ * address a real event.
3474
+ *
3475
+ * Semantics (mirroring the durable-streams server interpretation):
3476
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
3477
+ * messages forward." Concretely, the target event is the N-th event
3478
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
3479
+ * `null`, the anchor is the stream start and the target is the N-th
3480
+ * event from the very beginning.) The returned position is the count
3481
+ * of events to KEEP — events 1..position survive the filter.
3482
+ *
3483
+ * A pointer is valid when:
3484
+ * - `pointer.offset` is `null` (stream start) OR matches some
3485
+ * event's `headers.offset` value, AND
3486
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
3487
+ */
3488
+ resolveForkPointerTarget(events, pointer, streamPath) {
3489
+ let positionAtAnchor = 0;
3490
+ let anchorSeen = pointer.offset === null;
3491
+ for (const event of events) {
3492
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3493
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
3494
+ if (eventOffset === void 0) continue;
3495
+ if (pointer.offset === null) continue;
3496
+ if (eventOffset === pointer.offset) anchorSeen = true;
3497
+ if (eventOffset <= pointer.offset) positionAtAnchor++;
3498
+ }
3499
+ 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);
3500
+ const eventsPastAnchor = events.length - positionAtAnchor;
3501
+ 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);
3502
+ return positionAtAnchor + pointer.subOffset;
3503
+ }
3504
+ /**
3505
+ * Compute the subset of `sourceTree` that survives the manifest filter
3506
+ * applied at the root. After filtering the root's manifest at the fork
3507
+ * pointer, only children whose manifest entries landed at or before the
3508
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
3509
+ * along with them. Children dropped from the root's manifest, and any
3510
+ * of their descendants, are excluded.
3511
+ */
3512
+ computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
3513
+ const keptChildUrls = new Set();
3514
+ for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
3515
+ const childrenByParent = new Map();
3516
+ for (const entity of sourceTree) {
3517
+ if (!entity.parent) continue;
3518
+ const list = childrenByParent.get(entity.parent) ?? [];
3519
+ list.push(entity);
3520
+ childrenByParent.set(entity.parent, list);
3521
+ }
3522
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl);
3523
+ if (!rootEntity) return [];
3524
+ const result = [rootEntity];
3525
+ const queue = [];
3526
+ for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
3527
+ const seen = new Set([rootUrl]);
3528
+ while (queue.length > 0) {
3529
+ const entity = queue.shift();
3530
+ if (seen.has(entity.url)) continue;
3531
+ seen.add(entity.url);
3532
+ result.push(entity);
3533
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
3534
+ }
3535
+ return result;
3536
+ }
3235
3537
  async listEntitySubtree(root) {
3236
3538
  const result = [];
3237
3539
  const queue = [root];
@@ -4339,6 +4641,7 @@ var EntityManager = class {
4339
4641
  streams: entity.streams,
4340
4642
  tags: entity.tags,
4341
4643
  spawnArgs: entity.spawn_args,
4644
+ sandbox: entity.sandbox,
4342
4645
  createdBy: entity.created_by
4343
4646
  },
4344
4647
  principal: principalFromCreatedBy(entity.created_by),
@@ -4629,6 +4932,7 @@ const spawnBodySchema = Type.Object({
4629
4932
  tags: Type.Optional(stringRecordSchema$1),
4630
4933
  parent: Type.Optional(Type.String()),
4631
4934
  dispatch_policy: Type.Optional(dispatchPolicySchema),
4935
+ sandbox: Type.Optional(sandboxChoiceSchema),
4632
4936
  initialMessage: Type.Optional(Type.Unknown()),
4633
4937
  wake: Type.Optional(Type.Object({
4634
4938
  subscriberUrl: Type.String(),
@@ -4670,7 +4974,11 @@ const inboxMessageBodySchema = Type.Object({
4670
4974
  });
4671
4975
  const forkBodySchema = Type.Object({
4672
4976
  instance_id: Type.Optional(Type.String()),
4673
- waitTimeoutMs: Type.Optional(Type.Number())
4977
+ waitTimeoutMs: Type.Optional(Type.Number()),
4978
+ fork_pointer: Type.Optional(Type.Object({
4979
+ offset: Type.Union([Type.String(), Type.Null()]),
4980
+ sub_offset: Type.Number()
4981
+ }))
4674
4982
  });
4675
4983
  const setTagBodySchema = Type.Object({ value: Type.String() });
4676
4984
  const entitySignalSchema = Type.Union([
@@ -4988,7 +5296,11 @@ async function forkEntity(request, ctx) {
4988
5296
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
4989
5297
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
4990
5298
  rootInstanceId: parsed.instance_id,
4991
- waitTimeoutMs: parsed.waitTimeoutMs
5299
+ waitTimeoutMs: parsed.waitTimeoutMs,
5300
+ ...parsed.fork_pointer && { forkPointer: {
5301
+ offset: parsed.fork_pointer.offset,
5302
+ subOffset: parsed.fork_pointer.sub_offset
5303
+ } }
4992
5304
  });
4993
5305
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
4994
5306
  return json({
@@ -5086,6 +5398,7 @@ async function spawnEntity(request, ctx) {
5086
5398
  tags: parsed.tags,
5087
5399
  parent: parsed.parent,
5088
5400
  dispatch_policy: dispatchPolicy,
5401
+ sandbox: parsed.sandbox,
5089
5402
  initialMessage: void 0,
5090
5403
  wake: parsed.wake,
5091
5404
  created_by: principal.url
@@ -5340,6 +5653,12 @@ function withLeadingSlash(path$1) {
5340
5653
 
5341
5654
  //#endregion
5342
5655
  //#region src/routing/runners-router.ts
5656
+ const sandboxProfileBodySchema = Type.Object({
5657
+ name: Type.String(),
5658
+ label: Type.String(),
5659
+ description: Type.Optional(Type.String()),
5660
+ remote: Type.Optional(Type.Boolean())
5661
+ });
5343
5662
  const registerRunnerBodySchema = Type.Object({
5344
5663
  id: Type.String(),
5345
5664
  owner_principal: Type.Optional(Type.String()),
@@ -5352,7 +5671,8 @@ const registerRunnerBodySchema = Type.Object({
5352
5671
  Type.Literal(`server`)
5353
5672
  ])),
5354
5673
  admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
5355
- wake_stream: Type.Optional(Type.String())
5674
+ wake_stream: Type.Optional(Type.String()),
5675
+ sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
5356
5676
  });
5357
5677
  const heartbeatBodySchema = Type.Object({
5358
5678
  lease_ms: Type.Optional(Type.Number()),
@@ -5450,7 +5770,8 @@ async function registerRunner(request, ctx) {
5450
5770
  label: parsed.label,
5451
5771
  kind: parsed.kind,
5452
5772
  adminStatus: parsed.admin_status,
5453
- wakeStream: parsed.wake_stream
5773
+ wakeStream: parsed.wake_stream,
5774
+ sandboxProfiles: parsed.sandbox_profiles
5454
5775
  });
5455
5776
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
5456
5777
  return json(runner, { status: 201 });
@@ -5680,6 +6001,7 @@ async function notificationFromClaim(ctx, input) {
5680
6001
  streams: entity.streams,
5681
6002
  tags: entity.tags,
5682
6003
  spawnArgs: entity.spawn_args,
6004
+ sandbox: entity.sandbox,
5683
6005
  createdBy: entity.created_by
5684
6006
  },
5685
6007
  principal: principalFromCreatedBy(entity.created_by)
@@ -8090,11 +8412,19 @@ var WakeRegistry = class {
8090
8412
  }
8091
8413
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
8092
8414
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
8093
- return { change: {
8415
+ const change = {
8094
8416
  collection: eventType,
8095
8417
  kind,
8096
8418
  key: event.key || ``
8097
- } };
8419
+ };
8420
+ if (eventType === `inbox`) {
8421
+ const value = event.value;
8422
+ if (typeof value?.from === `string`) change.from = value.from;
8423
+ if (`payload` in (value ?? {})) change.payload = value?.payload;
8424
+ if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
8425
+ if (typeof value?.message_type === `string`) change.message_type = value.message_type;
8426
+ }
8427
+ return { change };
8098
8428
  }
8099
8429
  };
8100
8430