@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.
@@ -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`,
@@ -893,11 +901,11 @@ var StreamClient = class {
893
901
  if (res.status === 404 || res.status === 204) return;
894
902
  if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
895
903
  }
896
- async addSubscriptionStreams(subscriptionId, streams$1) {
904
+ async addSubscriptionStreams(subscriptionId, streams) {
897
905
  const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
898
906
  method: `POST`,
899
907
  headers: await this.requestHeaders({ "content-type": `application/json` }),
900
- body: JSON.stringify({ streams: streams$1.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
908
+ body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
901
909
  });
902
910
  return await this.subscriptionJson(res, `Subscription stream add failed`);
903
911
  }
@@ -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"`);
@@ -1139,35 +1147,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
1139
1147
  const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
1140
1148
  const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
1141
1149
  const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
1142
- const LOG_DIR = USE_FILE_LOGS ? process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`) : void 0;
1143
- const LOG_FILE = LOG_DIR ? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`) : void 0;
1144
- if (LOG_DIR) fs.mkdirSync(LOG_DIR, { recursive: true });
1145
- const streams = [];
1146
- if (LOG_FILE) streams.push({ stream: pino.destination({
1147
- dest: LOG_FILE,
1148
- sync: IS_ELECTRON_MAIN
1149
- }) });
1150
- if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1151
- target: `pino-pretty`,
1152
- options: {
1153
- colorize: true,
1154
- ignore: `pid,hostname,name`,
1155
- translateTime: `SYS:HH:MM:ss`
1156
- }
1157
- }) });
1158
- const logger = streams.length > 0 ? pino({
1159
- base: void 0,
1160
- level: LOG_LEVEL
1161
- }, pino.multistream(streams)) : pino({
1162
- base: void 0,
1163
- enabled: false,
1164
- level: LOG_LEVEL
1165
- });
1150
+ let _logger;
1151
+ function getLogger() {
1152
+ if (_logger) return _logger;
1153
+ const streams = [];
1154
+ try {
1155
+ if (USE_FILE_LOGS) {
1156
+ const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
1157
+ fs.mkdirSync(logDir, { recursive: true });
1158
+ const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
1159
+ streams.push({ stream: pino.destination({
1160
+ dest: logFile,
1161
+ sync: IS_ELECTRON_MAIN
1162
+ }) });
1163
+ }
1164
+ } catch (err) {
1165
+ process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
1166
+ }
1167
+ try {
1168
+ if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1169
+ target: `pino-pretty`,
1170
+ options: {
1171
+ colorize: true,
1172
+ ignore: `pid,hostname,name`,
1173
+ translateTime: `SYS:HH:MM:ss`
1174
+ }
1175
+ }) });
1176
+ } catch {}
1177
+ _logger = streams.length > 0 ? pino({
1178
+ base: void 0,
1179
+ level: LOG_LEVEL
1180
+ }, pino.multistream(streams)) : pino({
1181
+ base: void 0,
1182
+ enabled: false,
1183
+ level: LOG_LEVEL
1184
+ });
1185
+ return _logger;
1186
+ }
1166
1187
  function formatArgs(args) {
1167
1188
  const errors = [];
1168
1189
  const parts = [];
1169
- for (const a of args) if (a instanceof Error) errors.push(a);
1170
- else parts.push(typeof a === `string` ? a : JSON.stringify(a));
1190
+ for (const value of args) if (value instanceof Error) errors.push(value);
1191
+ else parts.push(typeof value === `string` ? value : JSON.stringify(value));
1171
1192
  return {
1172
1193
  err: errors[0],
1173
1194
  msg: parts.join(` `)
@@ -1176,20 +1197,20 @@ function formatArgs(args) {
1176
1197
  const serverLog = {
1177
1198
  info(...args) {
1178
1199
  const { msg } = formatArgs(args);
1179
- logger.info(msg);
1200
+ getLogger().info(msg);
1180
1201
  },
1181
1202
  warn(...args) {
1182
1203
  const { err, msg } = formatArgs(args);
1183
- if (err) logger.warn({ err }, msg);
1184
- else logger.warn(msg);
1204
+ if (err) getLogger().warn({ err }, msg);
1205
+ else getLogger().warn(msg);
1185
1206
  },
1186
1207
  error(...args) {
1187
1208
  const { err, msg } = formatArgs(args);
1188
- if (err) logger.error({ err }, msg);
1189
- else logger.error(msg);
1209
+ if (err) getLogger().error({ err }, msg);
1210
+ else getLogger().error(msg);
1190
1211
  },
1191
1212
  event(obj, msg) {
1192
- logger.info(obj, msg);
1213
+ getLogger().info(obj, msg);
1193
1214
  }
1194
1215
  };
1195
1216
 
@@ -1876,6 +1897,125 @@ function parseDispatchPolicy(value, label = `dispatch_policy`) {
1876
1897
  throw new Error(details ? `${label} does not match dispatch policy schema: ${details}` : `${label} does not match dispatch policy schema`);
1877
1898
  }
1878
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
+
1879
2019
  //#endregion
1880
2020
  //#region src/tenant.ts
1881
2021
  const DEFAULT_TENANT_ID = `default`;
@@ -1919,6 +2059,12 @@ var PostgresRegistry = class {
1919
2059
  async createRunner(input) {
1920
2060
  const now = new Date();
1921
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;
1922
2068
  await this.db.insert(runners).values({
1923
2069
  tenantId: this.tenantId,
1924
2070
  id: input.id,
@@ -1927,6 +2073,7 @@ var PostgresRegistry = class {
1927
2073
  kind: input.kind ?? `local`,
1928
2074
  adminStatus: input.adminStatus ?? `enabled`,
1929
2075
  wakeStream,
2076
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
1930
2077
  updatedAt: now
1931
2078
  }).onConflictDoUpdate({
1932
2079
  target: [runners.tenantId, runners.id],
@@ -1936,6 +2083,7 @@ var PostgresRegistry = class {
1936
2083
  kind: input.kind ?? `local`,
1937
2084
  adminStatus: input.adminStatus ?? `enabled`,
1938
2085
  wakeStream,
2086
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
1939
2087
  updatedAt: now
1940
2088
  }
1941
2089
  });
@@ -1943,6 +2091,30 @@ var PostgresRegistry = class {
1943
2091
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
1944
2092
  return runner;
1945
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
+ }
1946
2118
  async getRunner(id) {
1947
2119
  const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
1948
2120
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -2203,6 +2375,7 @@ var PostgresRegistry = class {
2203
2375
  tags: normalizeTags(entity.tags),
2204
2376
  tagsIndex: buildTagsIndex(entity.tags),
2205
2377
  spawnArgs: entity.spawn_args ?? {},
2378
+ sandbox: entity.sandbox ?? null,
2206
2379
  parent: entity.parent ?? null,
2207
2380
  createdBy: entity.created_by ?? null,
2208
2381
  typeRevision: entity.type_revision ?? null,
@@ -2540,6 +2713,7 @@ var PostgresRegistry = class {
2540
2713
  write_token: row.writeToken,
2541
2714
  tags: row.tags ?? {},
2542
2715
  spawn_args: row.spawnArgs,
2716
+ sandbox: row.sandbox ?? void 0,
2543
2717
  parent: row.parent ?? void 0,
2544
2718
  created_by: row.createdBy ?? void 0,
2545
2719
  type_revision: row.typeRevision ?? void 0,
@@ -2587,6 +2761,7 @@ var PostgresRegistry = class {
2587
2761
  kind: assertRunnerKind(row.kind),
2588
2762
  admin_status: assertRunnerAdminStatus(row.adminStatus),
2589
2763
  wake_stream: row.wakeStream,
2764
+ sandbox_profiles: row.sandboxProfiles ?? [],
2590
2765
  created_at: row.createdAt.toISOString(),
2591
2766
  updated_at: row.updatedAt.toISOString()
2592
2767
  };
@@ -2934,6 +3109,7 @@ var EntityManager = class {
2934
3109
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2935
3110
  }
2936
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);
2937
3113
  const now = Date.now();
2938
3114
  const entityData = {
2939
3115
  type: typeName,
@@ -2948,6 +3124,7 @@ var EntityManager = class {
2948
3124
  write_token: writeToken,
2949
3125
  tags: initialTags,
2950
3126
  spawn_args: req.args,
3127
+ sandbox,
2951
3128
  type_revision: entityType.revision,
2952
3129
  inbox_schemas: entityType.inbox_schemas,
2953
3130
  state_schemas: entityType.state_schemas,
@@ -3077,27 +3254,66 @@ var EntityManager = class {
3077
3254
  const writeEntityLocks = new Set();
3078
3255
  const writeStreamLocks = new Set();
3079
3256
  try {
3080
- 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);
3081
3264
  const sourceRoot = sourceTree[0];
3082
3265
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3083
- 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
+ }
3084
3299
  const suffix = randomUUID().slice(0, 8);
3085
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3300
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3086
3301
  suffix,
3087
3302
  rootUrl,
3088
3303
  rootInstanceId: opts.rootInstanceId
3089
3304
  });
3090
3305
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3091
3306
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3092
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3093
- 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);
3094
3309
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3095
3310
  const createdStreams = [];
3096
3311
  const createdEntities = [];
3097
3312
  const activeManifestsByEntity = new Map();
3098
3313
  try {
3099
3314
  for (const plan of entityPlans) {
3100
- 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);
3101
3317
  createdStreams.push(plan.fork.streams.main);
3102
3318
  await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3103
3319
  createdStreams.push(plan.fork.streams.error);
@@ -3190,6 +3406,38 @@ var EntityManager = class {
3190
3406
  }
3191
3407
  held.clear();
3192
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
+ }
3193
3441
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3194
3442
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3195
3443
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3219,6 +3467,73 @@ var EntityManager = class {
3219
3467
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3220
3468
  }
3221
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
+ }
3222
3537
  async listEntitySubtree(root) {
3223
3538
  const result = [];
3224
3539
  const queue = [root];
@@ -4326,6 +4641,7 @@ var EntityManager = class {
4326
4641
  streams: entity.streams,
4327
4642
  tags: entity.tags,
4328
4643
  spawnArgs: entity.spawn_args,
4644
+ sandbox: entity.sandbox,
4329
4645
  createdBy: entity.created_by
4330
4646
  },
4331
4647
  principal: principalFromCreatedBy(entity.created_by),
@@ -4616,6 +4932,7 @@ const spawnBodySchema = Type.Object({
4616
4932
  tags: Type.Optional(stringRecordSchema$1),
4617
4933
  parent: Type.Optional(Type.String()),
4618
4934
  dispatch_policy: Type.Optional(dispatchPolicySchema),
4935
+ sandbox: Type.Optional(sandboxChoiceSchema),
4619
4936
  initialMessage: Type.Optional(Type.Unknown()),
4620
4937
  wake: Type.Optional(Type.Object({
4621
4938
  subscriberUrl: Type.String(),
@@ -4657,7 +4974,11 @@ const inboxMessageBodySchema = Type.Object({
4657
4974
  });
4658
4975
  const forkBodySchema = Type.Object({
4659
4976
  instance_id: Type.Optional(Type.String()),
4660
- 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
+ }))
4661
4982
  });
4662
4983
  const setTagBodySchema = Type.Object({ value: Type.String() });
4663
4984
  const entitySignalSchema = Type.Union([
@@ -4975,7 +5296,11 @@ async function forkEntity(request, ctx) {
4975
5296
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
4976
5297
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
4977
5298
  rootInstanceId: parsed.instance_id,
4978
- 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
+ } }
4979
5304
  });
4980
5305
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
4981
5306
  return json({
@@ -5073,6 +5398,7 @@ async function spawnEntity(request, ctx) {
5073
5398
  tags: parsed.tags,
5074
5399
  parent: parsed.parent,
5075
5400
  dispatch_policy: dispatchPolicy,
5401
+ sandbox: parsed.sandbox,
5076
5402
  initialMessage: void 0,
5077
5403
  wake: parsed.wake,
5078
5404
  created_by: principal.url
@@ -5327,6 +5653,12 @@ function withLeadingSlash(path$1) {
5327
5653
 
5328
5654
  //#endregion
5329
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
+ });
5330
5662
  const registerRunnerBodySchema = Type.Object({
5331
5663
  id: Type.String(),
5332
5664
  owner_principal: Type.Optional(Type.String()),
@@ -5339,7 +5671,8 @@ const registerRunnerBodySchema = Type.Object({
5339
5671
  Type.Literal(`server`)
5340
5672
  ])),
5341
5673
  admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
5342
- wake_stream: Type.Optional(Type.String())
5674
+ wake_stream: Type.Optional(Type.String()),
5675
+ sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
5343
5676
  });
5344
5677
  const heartbeatBodySchema = Type.Object({
5345
5678
  lease_ms: Type.Optional(Type.Number()),
@@ -5437,7 +5770,8 @@ async function registerRunner(request, ctx) {
5437
5770
  label: parsed.label,
5438
5771
  kind: parsed.kind,
5439
5772
  adminStatus: parsed.admin_status,
5440
- wakeStream: parsed.wake_stream
5773
+ wakeStream: parsed.wake_stream,
5774
+ sandboxProfiles: parsed.sandbox_profiles
5441
5775
  });
5442
5776
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
5443
5777
  return json(runner, { status: 201 });
@@ -5647,7 +5981,7 @@ async function notificationFromClaim(ctx, input) {
5647
5981
  leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
5648
5982
  });
5649
5983
  await ctx.entityManager.registry.updateStatus(entity.url, `running`);
5650
- const streams$1 = input.claim.streams.map((stream) => ({
5984
+ const streams = input.claim.streams.map((stream) => ({
5651
5985
  path: withLeadingSlash(stream.path),
5652
5986
  offset: stream.tail_offset ?? ``
5653
5987
  }));
@@ -5656,7 +5990,7 @@ async function notificationFromClaim(ctx, input) {
5656
5990
  epoch: input.claim.generation,
5657
5991
  wakeId: input.claim.wake_id,
5658
5992
  streamPath: primaryStream,
5659
- streams: streams$1,
5993
+ streams,
5660
5994
  callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
5661
5995
  claimToken: input.claim.token,
5662
5996
  triggerEvent: `message_received`,
@@ -5667,6 +6001,7 @@ async function notificationFromClaim(ctx, input) {
5667
6001
  streams: entity.streams,
5668
6002
  tags: entity.tags,
5669
6003
  spawnArgs: entity.spawn_args,
6004
+ sandbox: entity.sandbox,
5670
6005
  createdBy: entity.created_by
5671
6006
  },
5672
6007
  principal: principalFromCreatedBy(entity.created_by)
@@ -8077,11 +8412,19 @@ var WakeRegistry = class {
8077
8412
  }
8078
8413
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
8079
8414
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
8080
- return { change: {
8415
+ const change = {
8081
8416
  collection: eventType,
8082
8417
  kind,
8083
8418
  key: event.key || ``
8084
- } };
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 };
8085
8428
  }
8086
8429
  };
8087
8430