@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/entrypoint.js +394 -51
- package/dist/index.cjs +396 -51
- package/dist/index.d.cts +332 -164
- package/dist/index.d.ts +332 -164
- package/dist/index.js +396 -51
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +2 -0
- package/src/electric-agents-types.ts +71 -0
- package/src/entity-manager.ts +337 -6
- package/src/entity-projector.ts +3 -0
- package/src/entity-registry.ts +73 -0
- package/src/routing/entities-router.ts +18 -0
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/log.ts +63 -52
- package/src/utils/server-utils.ts +2 -2
- package/src/wake-registry.ts +22 -11
package/dist/index.js
CHANGED
|
@@ -61,6 +61,7 @@ const entities = pgTable(`entities`, {
|
|
|
61
61
|
tags: jsonb(`tags`).notNull().default({}),
|
|
62
62
|
tagsIndex: text(`tags_index`).array().notNull().default(sql`'{}'::text[]`),
|
|
63
63
|
spawnArgs: jsonb(`spawn_args`).default({}),
|
|
64
|
+
sandbox: jsonb(`sandbox`),
|
|
64
65
|
parent: text(`parent`),
|
|
65
66
|
createdBy: text(`created_by`),
|
|
66
67
|
typeRevision: integer(`type_revision`),
|
|
@@ -102,6 +103,7 @@ const runners = pgTable(`runners`, {
|
|
|
102
103
|
kind: text(`kind`).notNull().default(`local`),
|
|
103
104
|
adminStatus: text(`admin_status`).notNull().default(`enabled`),
|
|
104
105
|
wakeStream: text(`wake_stream`).notNull(),
|
|
106
|
+
sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
|
|
105
107
|
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
106
108
|
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
107
109
|
}, (table) => [
|
|
@@ -399,6 +401,7 @@ function toPublicEntity(entity) {
|
|
|
399
401
|
dispatch_policy: entity.dispatch_policy,
|
|
400
402
|
tags: entity.tags,
|
|
401
403
|
spawn_args: entity.spawn_args,
|
|
404
|
+
sandbox: entity.sandbox,
|
|
402
405
|
parent: entity.parent,
|
|
403
406
|
created_by: entity.created_by,
|
|
404
407
|
created_at: entity.created_at,
|
|
@@ -467,6 +470,12 @@ var PostgresRegistry = class {
|
|
|
467
470
|
async createRunner(input) {
|
|
468
471
|
const now = new Date();
|
|
469
472
|
const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
|
|
473
|
+
const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
|
|
474
|
+
name: p.name,
|
|
475
|
+
label: p.label,
|
|
476
|
+
...p.description !== void 0 && { description: p.description },
|
|
477
|
+
...p.remote !== void 0 && { remote: p.remote }
|
|
478
|
+
})) : void 0;
|
|
470
479
|
await this.db.insert(runners).values({
|
|
471
480
|
tenantId: this.tenantId,
|
|
472
481
|
id: input.id,
|
|
@@ -475,6 +484,7 @@ var PostgresRegistry = class {
|
|
|
475
484
|
kind: input.kind ?? `local`,
|
|
476
485
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
477
486
|
wakeStream,
|
|
487
|
+
...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
|
|
478
488
|
updatedAt: now
|
|
479
489
|
}).onConflictDoUpdate({
|
|
480
490
|
target: [runners.tenantId, runners.id],
|
|
@@ -484,6 +494,7 @@ var PostgresRegistry = class {
|
|
|
484
494
|
kind: input.kind ?? `local`,
|
|
485
495
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
486
496
|
wakeStream,
|
|
497
|
+
...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
|
|
487
498
|
updatedAt: now
|
|
488
499
|
}
|
|
489
500
|
});
|
|
@@ -491,6 +502,30 @@ var PostgresRegistry = class {
|
|
|
491
502
|
if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
|
|
492
503
|
return runner;
|
|
493
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Every sandbox profile advertised by a runner in this tenant (one entry
|
|
507
|
+
* per runner that advertises it — names may repeat across runners). Used by
|
|
508
|
+
* spawn validation for unpinned dispatch to learn whether a chosen profile
|
|
509
|
+
* is remote (so a shared sandbox can skip the single-runner guard).
|
|
510
|
+
*/
|
|
511
|
+
async listSandboxProfiles() {
|
|
512
|
+
const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where(eq(runners.tenantId, this.tenantId));
|
|
513
|
+
const profiles = [];
|
|
514
|
+
for (const row of rows) {
|
|
515
|
+
const list = row.sandboxProfiles;
|
|
516
|
+
if (!Array.isArray(list)) continue;
|
|
517
|
+
for (const entry of list) {
|
|
518
|
+
if (!entry || typeof entry.name !== `string`) continue;
|
|
519
|
+
profiles.push({
|
|
520
|
+
name: entry.name,
|
|
521
|
+
label: typeof entry.label === `string` ? entry.label : entry.name,
|
|
522
|
+
...typeof entry.description === `string` && { description: entry.description },
|
|
523
|
+
...typeof entry.remote === `boolean` && { remote: entry.remote }
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return profiles;
|
|
528
|
+
}
|
|
494
529
|
async getRunner(id) {
|
|
495
530
|
const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
|
|
496
531
|
return rows[0] ? this.rowToRunner(rows[0]) : null;
|
|
@@ -751,6 +786,7 @@ var PostgresRegistry = class {
|
|
|
751
786
|
tags: normalizeTags(entity.tags),
|
|
752
787
|
tagsIndex: buildTagsIndex(entity.tags),
|
|
753
788
|
spawnArgs: entity.spawn_args ?? {},
|
|
789
|
+
sandbox: entity.sandbox ?? null,
|
|
754
790
|
parent: entity.parent ?? null,
|
|
755
791
|
createdBy: entity.created_by ?? null,
|
|
756
792
|
typeRevision: entity.type_revision ?? null,
|
|
@@ -1088,6 +1124,7 @@ var PostgresRegistry = class {
|
|
|
1088
1124
|
write_token: row.writeToken,
|
|
1089
1125
|
tags: row.tags ?? {},
|
|
1090
1126
|
spawn_args: row.spawnArgs,
|
|
1127
|
+
sandbox: row.sandbox ?? void 0,
|
|
1091
1128
|
parent: row.parent ?? void 0,
|
|
1092
1129
|
created_by: row.createdBy ?? void 0,
|
|
1093
1130
|
type_revision: row.typeRevision ?? void 0,
|
|
@@ -1135,6 +1172,7 @@ var PostgresRegistry = class {
|
|
|
1135
1172
|
kind: assertRunnerKind(row.kind),
|
|
1136
1173
|
admin_status: assertRunnerAdminStatus(row.adminStatus),
|
|
1137
1174
|
wake_stream: row.wakeStream,
|
|
1175
|
+
sandbox_profiles: row.sandboxProfiles ?? [],
|
|
1138
1176
|
created_at: row.createdAt.toISOString(),
|
|
1139
1177
|
updated_at: row.updatedAt.toISOString()
|
|
1140
1178
|
};
|
|
@@ -1189,35 +1227,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
|
1189
1227
|
const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
|
|
1190
1228
|
const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
|
|
1191
1229
|
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
if (
|
|
1195
|
-
const streams = [];
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1230
|
+
let _logger;
|
|
1231
|
+
function getLogger() {
|
|
1232
|
+
if (_logger) return _logger;
|
|
1233
|
+
const streams = [];
|
|
1234
|
+
try {
|
|
1235
|
+
if (USE_FILE_LOGS) {
|
|
1236
|
+
const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
1237
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
1238
|
+
const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
|
|
1239
|
+
streams.push({ stream: pino.destination({
|
|
1240
|
+
dest: logFile,
|
|
1241
|
+
sync: IS_ELECTRON_MAIN
|
|
1242
|
+
}) });
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
|
|
1249
|
+
target: `pino-pretty`,
|
|
1250
|
+
options: {
|
|
1251
|
+
colorize: true,
|
|
1252
|
+
ignore: `pid,hostname,name`,
|
|
1253
|
+
translateTime: `SYS:HH:MM:ss`
|
|
1254
|
+
}
|
|
1255
|
+
}) });
|
|
1256
|
+
} catch {}
|
|
1257
|
+
_logger = streams.length > 0 ? pino({
|
|
1258
|
+
base: void 0,
|
|
1259
|
+
level: LOG_LEVEL
|
|
1260
|
+
}, pino.multistream(streams)) : pino({
|
|
1261
|
+
base: void 0,
|
|
1262
|
+
enabled: false,
|
|
1263
|
+
level: LOG_LEVEL
|
|
1264
|
+
});
|
|
1265
|
+
return _logger;
|
|
1266
|
+
}
|
|
1216
1267
|
function formatArgs(args) {
|
|
1217
1268
|
const errors = [];
|
|
1218
1269
|
const parts = [];
|
|
1219
|
-
for (const
|
|
1220
|
-
else parts.push(typeof
|
|
1270
|
+
for (const value of args) if (value instanceof Error) errors.push(value);
|
|
1271
|
+
else parts.push(typeof value === `string` ? value : JSON.stringify(value));
|
|
1221
1272
|
return {
|
|
1222
1273
|
err: errors[0],
|
|
1223
1274
|
msg: parts.join(` `)
|
|
@@ -1226,20 +1277,20 @@ function formatArgs(args) {
|
|
|
1226
1277
|
const serverLog = {
|
|
1227
1278
|
info(...args) {
|
|
1228
1279
|
const { msg } = formatArgs(args);
|
|
1229
|
-
|
|
1280
|
+
getLogger().info(msg);
|
|
1230
1281
|
},
|
|
1231
1282
|
warn(...args) {
|
|
1232
1283
|
const { err, msg } = formatArgs(args);
|
|
1233
|
-
if (err)
|
|
1234
|
-
else
|
|
1284
|
+
if (err) getLogger().warn({ err }, msg);
|
|
1285
|
+
else getLogger().warn(msg);
|
|
1235
1286
|
},
|
|
1236
1287
|
error(...args) {
|
|
1237
1288
|
const { err, msg } = formatArgs(args);
|
|
1238
|
-
if (err)
|
|
1239
|
-
else
|
|
1289
|
+
if (err) getLogger().error({ err }, msg);
|
|
1290
|
+
else getLogger().error(msg);
|
|
1240
1291
|
},
|
|
1241
1292
|
event(obj, msg) {
|
|
1242
|
-
|
|
1293
|
+
getLogger().info(obj, msg);
|
|
1243
1294
|
}
|
|
1244
1295
|
};
|
|
1245
1296
|
|
|
@@ -1252,6 +1303,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
1252
1303
|
`status`,
|
|
1253
1304
|
`tags`,
|
|
1254
1305
|
`spawn_args`,
|
|
1306
|
+
`sandbox`,
|
|
1255
1307
|
`parent`,
|
|
1256
1308
|
`type_revision`,
|
|
1257
1309
|
`inbox_schemas`,
|
|
@@ -1285,6 +1337,7 @@ function toMemberRow(entity) {
|
|
|
1285
1337
|
status: entity.status,
|
|
1286
1338
|
tags: entity.tags,
|
|
1287
1339
|
spawn_args: entity.spawn_args ?? {},
|
|
1340
|
+
sandbox: entity.sandbox ?? null,
|
|
1288
1341
|
parent: entity.parent ?? null,
|
|
1289
1342
|
type_revision: entity.type_revision ?? null,
|
|
1290
1343
|
inbox_schemas: entity.inbox_schemas ?? null,
|
|
@@ -1965,7 +2018,7 @@ var StreamClient = class {
|
|
|
1965
2018
|
});
|
|
1966
2019
|
});
|
|
1967
2020
|
}
|
|
1968
|
-
async fork(path$1, sourcePath) {
|
|
2021
|
+
async fork(path$1, sourcePath, opts) {
|
|
1969
2022
|
return await withSpan(`stream.fork`, async (span) => {
|
|
1970
2023
|
span.setAttributes({
|
|
1971
2024
|
[ATTR.STREAM_PATH]: path$1,
|
|
@@ -1975,6 +2028,11 @@ var StreamClient = class {
|
|
|
1975
2028
|
"content-type": `application/json`,
|
|
1976
2029
|
"Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
|
|
1977
2030
|
};
|
|
2031
|
+
if (opts?.forkPointer) {
|
|
2032
|
+
const ZERO_OFFSET = `0000000000000000_0000000000000000`;
|
|
2033
|
+
headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
|
|
2034
|
+
if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
|
|
2035
|
+
}
|
|
1978
2036
|
injectTraceHeaders(headers);
|
|
1979
2037
|
const response = await fetch(this.streamUrl(path$1), {
|
|
1980
2038
|
method: `PUT`,
|
|
@@ -2219,11 +2277,11 @@ var StreamClient = class {
|
|
|
2219
2277
|
if (res.status === 404 || res.status === 204) return;
|
|
2220
2278
|
if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
|
|
2221
2279
|
}
|
|
2222
|
-
async addSubscriptionStreams(subscriptionId, streams
|
|
2280
|
+
async addSubscriptionStreams(subscriptionId, streams) {
|
|
2223
2281
|
const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
|
|
2224
2282
|
method: `POST`,
|
|
2225
2283
|
headers: await this.requestHeaders({ "content-type": `application/json` }),
|
|
2226
|
-
body: JSON.stringify({ streams: streams
|
|
2284
|
+
body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
|
|
2227
2285
|
});
|
|
2228
2286
|
return await this.subscriptionJson(res, `Subscription stream add failed`);
|
|
2229
2287
|
}
|
|
@@ -2502,6 +2560,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
|
|
|
2502
2560
|
});
|
|
2503
2561
|
}
|
|
2504
2562
|
|
|
2563
|
+
//#endregion
|
|
2564
|
+
//#region src/routing/sandbox.ts
|
|
2565
|
+
/**
|
|
2566
|
+
* Resolve and validate a spawn's sandbox CHOICE into the {@link
|
|
2567
|
+
* EntitySandboxSelection} persisted on the entity. Sibling of
|
|
2568
|
+
* `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
|
|
2569
|
+
* EntityManager so the spawn path reads as composed resolution steps.
|
|
2570
|
+
*
|
|
2571
|
+
* Profiles are a per-runner concern: each runner advertises what it supports.
|
|
2572
|
+
* When the spawn pins a runner via dispatch_policy, the chosen profile must be
|
|
2573
|
+
* in that runner's advertised set; otherwise we'd persist an unserviceable
|
|
2574
|
+
* choice that fails late at first wake. For unpinned dispatch (webhook /
|
|
2575
|
+
* parent-inherited) we can't pick a target ahead of time, so we fall back to a
|
|
2576
|
+
* tenant-wide "some runner offers this" check — better than nothing.
|
|
2577
|
+
*/
|
|
2578
|
+
async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
|
|
2579
|
+
if (!requested) return void 0;
|
|
2580
|
+
const choice = applyInheritedSandbox(requested, parentEntity);
|
|
2581
|
+
if (!choice) return void 0;
|
|
2582
|
+
const chosenName = choice.profile;
|
|
2583
|
+
if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
|
|
2584
|
+
const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
|
|
2585
|
+
assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
|
|
2586
|
+
const selection = { profile: chosenName };
|
|
2587
|
+
if (choice.key !== void 0) selection.key = choice.key;
|
|
2588
|
+
else if (choice.scope !== void 0) selection.scope = choice.scope;
|
|
2589
|
+
if (choice.persistent !== void 0) selection.persistent = choice.persistent;
|
|
2590
|
+
if (choice.owner === false) selection.owner = false;
|
|
2591
|
+
return selection;
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
|
|
2595
|
+
* parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
|
|
2596
|
+
* parent has no shareable (keyed) sandbox the child simply gets none (returns
|
|
2597
|
+
* `undefined`), so `spawn_worker` can always request inheritance without
|
|
2598
|
+
* breaking unkeyed parents. (A running parent wake resolves inherit to its live
|
|
2599
|
+
* explicit key in the runtime instead — this server-side path covers direct API
|
|
2600
|
+
* callers, where only the parent's *stored* explicit key is available.)
|
|
2601
|
+
*
|
|
2602
|
+
* For a non-inherit choice the request passes through unchanged.
|
|
2603
|
+
*
|
|
2604
|
+
* NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
|
|
2605
|
+
* any sibling field on the request (e.g. a caller-supplied `persistent: false`)
|
|
2606
|
+
* is intentionally ignored, because a child attaches to the parent's existing
|
|
2607
|
+
* sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
|
|
2608
|
+
* permits the `{ inherit: true, persistent: ... }` combination, so the
|
|
2609
|
+
* precedence is resolved here rather than rejected at the schema level.
|
|
2610
|
+
*/
|
|
2611
|
+
function applyInheritedSandbox(requested, parentEntity) {
|
|
2612
|
+
if (!requested.inherit) return requested;
|
|
2613
|
+
const parentKey = parentEntity?.sandbox?.key;
|
|
2614
|
+
if (!parentKey) return void 0;
|
|
2615
|
+
return {
|
|
2616
|
+
profile: parentEntity.sandbox.profile,
|
|
2617
|
+
key: parentKey,
|
|
2618
|
+
persistent: parentEntity.sandbox.persistent,
|
|
2619
|
+
owner: false
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* Validate the chosen profile is advertised by the relevant runner(s) and
|
|
2624
|
+
* determine whether it is a remote (off-host) sandbox, reachable from any
|
|
2625
|
+
* runner. Defaults to host-local (co-location required) unless every relevant
|
|
2626
|
+
* advertisement marks it remote. Throws if the profile is unserviceable.
|
|
2627
|
+
*/
|
|
2628
|
+
async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
|
|
2629
|
+
const runnerIds = [];
|
|
2630
|
+
for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
|
|
2631
|
+
if (runnerIds.length > 0) {
|
|
2632
|
+
let allRemote = true;
|
|
2633
|
+
for (const runnerId of runnerIds) {
|
|
2634
|
+
const runner = await registry.getRunner(runnerId);
|
|
2635
|
+
const advertised = runner?.sandbox_profiles ?? [];
|
|
2636
|
+
const match = advertised.find((p) => p.name === chosenName);
|
|
2637
|
+
if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
|
|
2638
|
+
if (match.remote !== true) allRemote = false;
|
|
2639
|
+
}
|
|
2640
|
+
return allRemote;
|
|
2641
|
+
}
|
|
2642
|
+
const available = await registry.listSandboxProfiles();
|
|
2643
|
+
const matches = available.filter((p) => p.name === chosenName);
|
|
2644
|
+
if (matches.length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not offered by any registered runner (available: ${[...new Set(available.map((p) => p.name))].join(`, `) || `(none)`}).`, 400);
|
|
2645
|
+
return matches.every((p) => p.remote === true);
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Co-location: a shared *local* sandbox lives on one host, so every
|
|
2649
|
+
* collaborator must be pinned to the same single runner. Subagents inherit the
|
|
2650
|
+
* parent's dispatch policy, so this holds once the root is pinned. A shared
|
|
2651
|
+
* *remote* sandbox is reachable from any runner, so the guard does not apply.
|
|
2652
|
+
*/
|
|
2653
|
+
function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
2654
|
+
if (key === void 0 || chosenIsRemote) return;
|
|
2655
|
+
const targets = dispatchPolicy?.targets ?? [];
|
|
2656
|
+
const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
|
|
2657
|
+
if (!pinnedToSingleRunner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`, 400);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2505
2660
|
//#endregion
|
|
2506
2661
|
//#region src/principal.ts
|
|
2507
2662
|
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
@@ -2902,6 +3057,7 @@ var EntityManager = class {
|
|
|
2902
3057
|
if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
|
|
2903
3058
|
}
|
|
2904
3059
|
const dispatchPolicy = req.dispatch_policy ? this.validateDispatchPolicy(req.dispatch_policy, { label: `dispatch_policy` }) : parentEntity?.dispatch_policy ? applyTypeDefaultSubscriptionScope(parentEntity.dispatch_policy, entityType.default_dispatch_policy) : entityType.default_dispatch_policy;
|
|
3060
|
+
const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
|
|
2905
3061
|
const now = Date.now();
|
|
2906
3062
|
const entityData = {
|
|
2907
3063
|
type: typeName,
|
|
@@ -2916,6 +3072,7 @@ var EntityManager = class {
|
|
|
2916
3072
|
write_token: writeToken,
|
|
2917
3073
|
tags: initialTags,
|
|
2918
3074
|
spawn_args: req.args,
|
|
3075
|
+
sandbox,
|
|
2919
3076
|
type_revision: entityType.revision,
|
|
2920
3077
|
inbox_schemas: entityType.inbox_schemas,
|
|
2921
3078
|
state_schemas: entityType.state_schemas,
|
|
@@ -3045,27 +3202,66 @@ var EntityManager = class {
|
|
|
3045
3202
|
const writeEntityLocks = new Set();
|
|
3046
3203
|
const writeStreamLocks = new Set();
|
|
3047
3204
|
try {
|
|
3048
|
-
|
|
3205
|
+
let sourceTree;
|
|
3206
|
+
if (opts.forkPointer) {
|
|
3207
|
+
const rootEntity = await this.registry.getEntity(rootUrl);
|
|
3208
|
+
if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3209
|
+
if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
|
|
3210
|
+
sourceTree = await this.listEntitySubtree(rootEntity);
|
|
3211
|
+
} else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
|
|
3049
3212
|
const sourceRoot = sourceTree[0];
|
|
3050
3213
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3051
|
-
|
|
3214
|
+
let preFilteredRoot;
|
|
3215
|
+
if (opts.forkPointer) {
|
|
3216
|
+
const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
|
|
3217
|
+
const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
|
|
3218
|
+
const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
|
|
3219
|
+
const filteredEvents = flat.slice(0, target);
|
|
3220
|
+
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
|
|
3221
|
+
const sharedStateIds = new Set();
|
|
3222
|
+
for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
|
|
3223
|
+
preFilteredRoot = {
|
|
3224
|
+
manifests: rootManifests,
|
|
3225
|
+
childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
|
|
3226
|
+
replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
|
|
3227
|
+
sharedStateIds
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
|
|
3231
|
+
if (opts.forkPointer) {
|
|
3232
|
+
const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
|
|
3233
|
+
if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
|
|
3234
|
+
}
|
|
3235
|
+
const snapshot = await this.readForkStateSnapshot(
|
|
3236
|
+
// Skip the root when we've already pre-filtered it — avoid both a
|
|
3237
|
+
// wasted HEAD read of main and a re-population that would clobber
|
|
3238
|
+
// the filtered entries.
|
|
3239
|
+
preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
|
|
3240
|
+
);
|
|
3241
|
+
if (preFilteredRoot) {
|
|
3242
|
+
snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
|
|
3243
|
+
snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
|
|
3244
|
+
snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
|
|
3245
|
+
for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
|
|
3246
|
+
}
|
|
3052
3247
|
const suffix = randomUUID().slice(0, 8);
|
|
3053
|
-
const entityUrlMap = await this.buildForkEntityUrlMap(
|
|
3248
|
+
const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
|
|
3054
3249
|
suffix,
|
|
3055
3250
|
rootUrl,
|
|
3056
3251
|
rootInstanceId: opts.rootInstanceId
|
|
3057
3252
|
});
|
|
3058
3253
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3059
3254
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3060
|
-
const entityPlans = this.buildForkEntityPlans(
|
|
3061
|
-
this.addForkLocks(this.forkWriteLockedEntities,
|
|
3255
|
+
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
|
|
3256
|
+
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3062
3257
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
3063
3258
|
const createdStreams = [];
|
|
3064
3259
|
const createdEntities = [];
|
|
3065
3260
|
const activeManifestsByEntity = new Map();
|
|
3066
3261
|
try {
|
|
3067
3262
|
for (const plan of entityPlans) {
|
|
3068
|
-
|
|
3263
|
+
const isRoot = plan.source.url === rootUrl;
|
|
3264
|
+
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
|
|
3069
3265
|
createdStreams.push(plan.fork.streams.main);
|
|
3070
3266
|
await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
|
|
3071
3267
|
createdStreams.push(plan.fork.streams.error);
|
|
@@ -3158,6 +3354,38 @@ var EntityManager = class {
|
|
|
3158
3354
|
}
|
|
3159
3355
|
held.clear();
|
|
3160
3356
|
}
|
|
3357
|
+
/**
|
|
3358
|
+
* Variant of {@link waitForIdleSubtree} that takes an explicit entity
|
|
3359
|
+
* list instead of walking the registry from `rootUrl`. Used by the
|
|
3360
|
+
* pointer-fork path to wait+lock only the kept descendants, since
|
|
3361
|
+
* the root is being forked from history and doesn't need to be idle.
|
|
3362
|
+
*/
|
|
3363
|
+
async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
|
|
3364
|
+
if (entities$1.length === 0) return;
|
|
3365
|
+
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
|
|
3366
|
+
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
|
|
3367
|
+
const refresh = async () => {
|
|
3368
|
+
const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
|
|
3369
|
+
return refreshed.filter((entity) => !!entity);
|
|
3370
|
+
};
|
|
3371
|
+
const deadline = Date.now() + timeoutMs;
|
|
3372
|
+
while (true) {
|
|
3373
|
+
const present = await refresh();
|
|
3374
|
+
const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
|
|
3375
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
3376
|
+
let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3377
|
+
if (active.length === 0) {
|
|
3378
|
+
this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
|
|
3379
|
+
const reChecked = await refresh();
|
|
3380
|
+
const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3381
|
+
if (reActive.length === 0) return;
|
|
3382
|
+
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3383
|
+
active = reActive;
|
|
3384
|
+
}
|
|
3385
|
+
if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
|
|
3386
|
+
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3161
3389
|
async waitForIdleSubtree(rootUrl, opts, workLocks) {
|
|
3162
3390
|
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
|
|
3163
3391
|
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
|
|
@@ -3187,6 +3415,73 @@ var EntityManager = class {
|
|
|
3187
3415
|
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
3188
3416
|
}
|
|
3189
3417
|
}
|
|
3418
|
+
/**
|
|
3419
|
+
* Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
|
|
3420
|
+
* source's flattened history. Throws a 400 if the pointer doesn't
|
|
3421
|
+
* address a real event.
|
|
3422
|
+
*
|
|
3423
|
+
* Semantics (mirroring the durable-streams server interpretation):
|
|
3424
|
+
* `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
|
|
3425
|
+
* messages forward." Concretely, the target event is the N-th event
|
|
3426
|
+
* after the last event whose `headers.offset` is ≤ X. (When `X` is
|
|
3427
|
+
* `null`, the anchor is the stream start and the target is the N-th
|
|
3428
|
+
* event from the very beginning.) The returned position is the count
|
|
3429
|
+
* of events to KEEP — events 1..position survive the filter.
|
|
3430
|
+
*
|
|
3431
|
+
* A pointer is valid when:
|
|
3432
|
+
* - `pointer.offset` is `null` (stream start) OR matches some
|
|
3433
|
+
* event's `headers.offset` value, AND
|
|
3434
|
+
* - `pointer.subOffset` is in `[1, total events past the anchor]`.
|
|
3435
|
+
*/
|
|
3436
|
+
resolveForkPointerTarget(events, pointer, streamPath) {
|
|
3437
|
+
let positionAtAnchor = 0;
|
|
3438
|
+
let anchorSeen = pointer.offset === null;
|
|
3439
|
+
for (const event of events) {
|
|
3440
|
+
const headers = isRecord(event.headers) ? event.headers : void 0;
|
|
3441
|
+
const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
|
|
3442
|
+
if (eventOffset === void 0) continue;
|
|
3443
|
+
if (pointer.offset === null) continue;
|
|
3444
|
+
if (eventOffset === pointer.offset) anchorSeen = true;
|
|
3445
|
+
if (eventOffset <= pointer.offset) positionAtAnchor++;
|
|
3446
|
+
}
|
|
3447
|
+
if (!anchorSeen) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.offset (${pointer.offset ?? `<stream-start>`}) does not match any event's Stream-Next-Offset on ${streamPath}`, 400);
|
|
3448
|
+
const eventsPastAnchor = events.length - positionAtAnchor;
|
|
3449
|
+
if (pointer.subOffset < 1 || pointer.subOffset > eventsPastAnchor) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.sub_offset ${pointer.subOffset} out of range past anchor on ${streamPath} (valid: 1..${eventsPastAnchor})`, 400);
|
|
3450
|
+
return positionAtAnchor + pointer.subOffset;
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
3454
|
+
* applied at the root. After filtering the root's manifest at the fork
|
|
3455
|
+
* pointer, only children whose manifest entries landed at or before the
|
|
3456
|
+
* pointer remain; those kept children carry their CURRENT (HEAD) subtree
|
|
3457
|
+
* along with them. Children dropped from the root's manifest, and any
|
|
3458
|
+
* of their descendants, are excluded.
|
|
3459
|
+
*/
|
|
3460
|
+
computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
|
|
3461
|
+
const keptChildUrls = new Set();
|
|
3462
|
+
for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
|
|
3463
|
+
const childrenByParent = new Map();
|
|
3464
|
+
for (const entity of sourceTree) {
|
|
3465
|
+
if (!entity.parent) continue;
|
|
3466
|
+
const list = childrenByParent.get(entity.parent) ?? [];
|
|
3467
|
+
list.push(entity);
|
|
3468
|
+
childrenByParent.set(entity.parent, list);
|
|
3469
|
+
}
|
|
3470
|
+
const rootEntity = sourceTree.find((e) => e.url === rootUrl);
|
|
3471
|
+
if (!rootEntity) return [];
|
|
3472
|
+
const result = [rootEntity];
|
|
3473
|
+
const queue = [];
|
|
3474
|
+
for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
|
|
3475
|
+
const seen = new Set([rootUrl]);
|
|
3476
|
+
while (queue.length > 0) {
|
|
3477
|
+
const entity = queue.shift();
|
|
3478
|
+
if (seen.has(entity.url)) continue;
|
|
3479
|
+
seen.add(entity.url);
|
|
3480
|
+
result.push(entity);
|
|
3481
|
+
for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
|
|
3482
|
+
}
|
|
3483
|
+
return result;
|
|
3484
|
+
}
|
|
3190
3485
|
async listEntitySubtree(root) {
|
|
3191
3486
|
const result = [];
|
|
3192
3487
|
const queue = [root];
|
|
@@ -4294,6 +4589,7 @@ var EntityManager = class {
|
|
|
4294
4589
|
streams: entity.streams,
|
|
4295
4590
|
tags: entity.tags,
|
|
4296
4591
|
spawnArgs: entity.spawn_args,
|
|
4592
|
+
sandbox: entity.sandbox,
|
|
4297
4593
|
createdBy: entity.created_by
|
|
4298
4594
|
},
|
|
4299
4595
|
principal: principalFromCreatedBy(entity.created_by),
|
|
@@ -5959,11 +6255,19 @@ var WakeRegistry = class {
|
|
|
5959
6255
|
}
|
|
5960
6256
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
5961
6257
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
5962
|
-
|
|
6258
|
+
const change = {
|
|
5963
6259
|
collection: eventType,
|
|
5964
6260
|
kind,
|
|
5965
6261
|
key: event.key || ``
|
|
5966
|
-
}
|
|
6262
|
+
};
|
|
6263
|
+
if (eventType === `inbox`) {
|
|
6264
|
+
const value = event.value;
|
|
6265
|
+
if (typeof value?.from === `string`) change.from = value.from;
|
|
6266
|
+
if (`payload` in (value ?? {})) change.payload = value?.payload;
|
|
6267
|
+
if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
|
|
6268
|
+
if (typeof value?.message_type === `string`) change.message_type = value.message_type;
|
|
6269
|
+
}
|
|
6270
|
+
return { change };
|
|
5967
6271
|
}
|
|
5968
6272
|
};
|
|
5969
6273
|
|
|
@@ -6370,13 +6674,13 @@ function buildElectricProxyTarget(options) {
|
|
|
6370
6674
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
6371
6675
|
const table = options.incomingUrl.searchParams.get(`table`);
|
|
6372
6676
|
if (table === `entities`) {
|
|
6373
|
-
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
6677
|
+
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
6374
6678
|
applyTenantShapeWhere(target, options.tenantId);
|
|
6375
6679
|
} else if (table === `entity_types`) {
|
|
6376
6680
|
target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
|
|
6377
6681
|
applyTenantShapeWhere(target, options.tenantId);
|
|
6378
6682
|
} else if (table === `runners`) {
|
|
6379
|
-
target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
|
|
6683
|
+
target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
|
|
6380
6684
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
|
|
6381
6685
|
} else if (table === `runner_runtime_diagnostics`) {
|
|
6382
6686
|
target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
|
|
@@ -6802,6 +7106,28 @@ async function proxyElectric(request, ctx) {
|
|
|
6802
7106
|
});
|
|
6803
7107
|
}
|
|
6804
7108
|
|
|
7109
|
+
//#endregion
|
|
7110
|
+
//#region src/sandbox-choice-schema.ts
|
|
7111
|
+
/**
|
|
7112
|
+
* Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
|
|
7113
|
+
* the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
|
|
7114
|
+
* persisted on the entity. The matching `SandboxChoice` type is hand-maintained
|
|
7115
|
+
* in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
|
|
7116
|
+
* the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
|
|
7117
|
+
*
|
|
7118
|
+
* Validation happens once, at the router boundary (this schema is embedded in
|
|
7119
|
+
* the spawn body schema); the spawn resolver consumes already-validated input,
|
|
7120
|
+
* so there is intentionally no separate `parse` helper here.
|
|
7121
|
+
*/
|
|
7122
|
+
const sandboxChoiceSchema = Type.Object({
|
|
7123
|
+
profile: Type.Optional(Type.String()),
|
|
7124
|
+
key: Type.Optional(Type.String()),
|
|
7125
|
+
scope: Type.Optional(Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])),
|
|
7126
|
+
persistent: Type.Optional(Type.Boolean()),
|
|
7127
|
+
owner: Type.Optional(Type.Boolean()),
|
|
7128
|
+
inherit: Type.Optional(Type.Boolean())
|
|
7129
|
+
});
|
|
7130
|
+
|
|
6805
7131
|
//#endregion
|
|
6806
7132
|
//#region src/routing/entities-router.ts
|
|
6807
7133
|
const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
|
|
@@ -6824,6 +7150,7 @@ const spawnBodySchema = Type.Object({
|
|
|
6824
7150
|
tags: Type.Optional(stringRecordSchema$1),
|
|
6825
7151
|
parent: Type.Optional(Type.String()),
|
|
6826
7152
|
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
7153
|
+
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
6827
7154
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
6828
7155
|
wake: Type.Optional(Type.Object({
|
|
6829
7156
|
subscriberUrl: Type.String(),
|
|
@@ -6865,7 +7192,11 @@ const inboxMessageBodySchema = Type.Object({
|
|
|
6865
7192
|
});
|
|
6866
7193
|
const forkBodySchema = Type.Object({
|
|
6867
7194
|
instance_id: Type.Optional(Type.String()),
|
|
6868
|
-
waitTimeoutMs: Type.Optional(Type.Number())
|
|
7195
|
+
waitTimeoutMs: Type.Optional(Type.Number()),
|
|
7196
|
+
fork_pointer: Type.Optional(Type.Object({
|
|
7197
|
+
offset: Type.Union([Type.String(), Type.Null()]),
|
|
7198
|
+
sub_offset: Type.Number()
|
|
7199
|
+
}))
|
|
6869
7200
|
});
|
|
6870
7201
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
6871
7202
|
const entitySignalSchema = Type.Union([
|
|
@@ -7183,7 +7514,11 @@ async function forkEntity(request, ctx) {
|
|
|
7183
7514
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
7184
7515
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
7185
7516
|
rootInstanceId: parsed.instance_id,
|
|
7186
|
-
waitTimeoutMs: parsed.waitTimeoutMs
|
|
7517
|
+
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
7518
|
+
...parsed.fork_pointer && { forkPointer: {
|
|
7519
|
+
offset: parsed.fork_pointer.offset,
|
|
7520
|
+
subOffset: parsed.fork_pointer.sub_offset
|
|
7521
|
+
} }
|
|
7187
7522
|
});
|
|
7188
7523
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
7189
7524
|
return json({
|
|
@@ -7281,6 +7616,7 @@ async function spawnEntity(request, ctx) {
|
|
|
7281
7616
|
tags: parsed.tags,
|
|
7282
7617
|
parent: parsed.parent,
|
|
7283
7618
|
dispatch_policy: dispatchPolicy,
|
|
7619
|
+
sandbox: parsed.sandbox,
|
|
7284
7620
|
initialMessage: void 0,
|
|
7285
7621
|
wake: parsed.wake,
|
|
7286
7622
|
created_by: principal.url
|
|
@@ -7535,6 +7871,12 @@ function withLeadingSlash(path$1) {
|
|
|
7535
7871
|
|
|
7536
7872
|
//#endregion
|
|
7537
7873
|
//#region src/routing/runners-router.ts
|
|
7874
|
+
const sandboxProfileBodySchema = Type.Object({
|
|
7875
|
+
name: Type.String(),
|
|
7876
|
+
label: Type.String(),
|
|
7877
|
+
description: Type.Optional(Type.String()),
|
|
7878
|
+
remote: Type.Optional(Type.Boolean())
|
|
7879
|
+
});
|
|
7538
7880
|
const registerRunnerBodySchema = Type.Object({
|
|
7539
7881
|
id: Type.String(),
|
|
7540
7882
|
owner_principal: Type.Optional(Type.String()),
|
|
@@ -7547,7 +7889,8 @@ const registerRunnerBodySchema = Type.Object({
|
|
|
7547
7889
|
Type.Literal(`server`)
|
|
7548
7890
|
])),
|
|
7549
7891
|
admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
|
|
7550
|
-
wake_stream: Type.Optional(Type.String())
|
|
7892
|
+
wake_stream: Type.Optional(Type.String()),
|
|
7893
|
+
sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
|
|
7551
7894
|
});
|
|
7552
7895
|
const heartbeatBodySchema = Type.Object({
|
|
7553
7896
|
lease_ms: Type.Optional(Type.Number()),
|
|
@@ -7645,7 +7988,8 @@ async function registerRunner(request, ctx) {
|
|
|
7645
7988
|
label: parsed.label,
|
|
7646
7989
|
kind: parsed.kind,
|
|
7647
7990
|
adminStatus: parsed.admin_status,
|
|
7648
|
-
wakeStream: parsed.wake_stream
|
|
7991
|
+
wakeStream: parsed.wake_stream,
|
|
7992
|
+
sandboxProfiles: parsed.sandbox_profiles
|
|
7649
7993
|
});
|
|
7650
7994
|
await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
|
|
7651
7995
|
return json(runner, { status: 201 });
|
|
@@ -7855,7 +8199,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7855
8199
|
leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
|
|
7856
8200
|
});
|
|
7857
8201
|
await ctx.entityManager.registry.updateStatus(entity.url, `running`);
|
|
7858
|
-
const streams
|
|
8202
|
+
const streams = input.claim.streams.map((stream) => ({
|
|
7859
8203
|
path: withLeadingSlash(stream.path),
|
|
7860
8204
|
offset: stream.tail_offset ?? ``
|
|
7861
8205
|
}));
|
|
@@ -7864,7 +8208,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7864
8208
|
epoch: input.claim.generation,
|
|
7865
8209
|
wakeId: input.claim.wake_id,
|
|
7866
8210
|
streamPath: primaryStream,
|
|
7867
|
-
streams
|
|
8211
|
+
streams,
|
|
7868
8212
|
callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
|
|
7869
8213
|
claimToken: input.claim.token,
|
|
7870
8214
|
triggerEvent: `message_received`,
|
|
@@ -7875,6 +8219,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7875
8219
|
streams: entity.streams,
|
|
7876
8220
|
tags: entity.tags,
|
|
7877
8221
|
spawnArgs: entity.spawn_args,
|
|
8222
|
+
sandbox: entity.sandbox,
|
|
7878
8223
|
createdBy: entity.created_by
|
|
7879
8224
|
},
|
|
7880
8225
|
principal: principalFromCreatedBy(entity.created_by)
|