@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.
- package/dist/entrypoint.js +345 -15
- package/dist/index.cjs +347 -15
- package/dist/index.d.cts +332 -164
- package/dist/index.d.ts +332 -164
- package/dist/index.js +347 -15
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- 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/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
|
};
|
|
@@ -1265,6 +1303,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
1265
1303
|
`status`,
|
|
1266
1304
|
`tags`,
|
|
1267
1305
|
`spawn_args`,
|
|
1306
|
+
`sandbox`,
|
|
1268
1307
|
`parent`,
|
|
1269
1308
|
`type_revision`,
|
|
1270
1309
|
`inbox_schemas`,
|
|
@@ -1298,6 +1337,7 @@ function toMemberRow(entity) {
|
|
|
1298
1337
|
status: entity.status,
|
|
1299
1338
|
tags: entity.tags,
|
|
1300
1339
|
spawn_args: entity.spawn_args ?? {},
|
|
1340
|
+
sandbox: entity.sandbox ?? null,
|
|
1301
1341
|
parent: entity.parent ?? null,
|
|
1302
1342
|
type_revision: entity.type_revision ?? null,
|
|
1303
1343
|
inbox_schemas: entity.inbox_schemas ?? null,
|
|
@@ -1978,7 +2018,7 @@ var StreamClient = class {
|
|
|
1978
2018
|
});
|
|
1979
2019
|
});
|
|
1980
2020
|
}
|
|
1981
|
-
async fork(path$1, sourcePath) {
|
|
2021
|
+
async fork(path$1, sourcePath, opts) {
|
|
1982
2022
|
return await withSpan(`stream.fork`, async (span) => {
|
|
1983
2023
|
span.setAttributes({
|
|
1984
2024
|
[ATTR.STREAM_PATH]: path$1,
|
|
@@ -1988,6 +2028,11 @@ var StreamClient = class {
|
|
|
1988
2028
|
"content-type": `application/json`,
|
|
1989
2029
|
"Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
|
|
1990
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
|
+
}
|
|
1991
2036
|
injectTraceHeaders(headers);
|
|
1992
2037
|
const response = await fetch(this.streamUrl(path$1), {
|
|
1993
2038
|
method: `PUT`,
|
|
@@ -2515,6 +2560,103 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
|
|
|
2515
2560
|
});
|
|
2516
2561
|
}
|
|
2517
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
|
+
|
|
2518
2660
|
//#endregion
|
|
2519
2661
|
//#region src/principal.ts
|
|
2520
2662
|
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
@@ -2915,6 +3057,7 @@ var EntityManager = class {
|
|
|
2915
3057
|
if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
|
|
2916
3058
|
}
|
|
2917
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);
|
|
2918
3061
|
const now = Date.now();
|
|
2919
3062
|
const entityData = {
|
|
2920
3063
|
type: typeName,
|
|
@@ -2929,6 +3072,7 @@ var EntityManager = class {
|
|
|
2929
3072
|
write_token: writeToken,
|
|
2930
3073
|
tags: initialTags,
|
|
2931
3074
|
spawn_args: req.args,
|
|
3075
|
+
sandbox,
|
|
2932
3076
|
type_revision: entityType.revision,
|
|
2933
3077
|
inbox_schemas: entityType.inbox_schemas,
|
|
2934
3078
|
state_schemas: entityType.state_schemas,
|
|
@@ -3058,27 +3202,66 @@ var EntityManager = class {
|
|
|
3058
3202
|
const writeEntityLocks = new Set();
|
|
3059
3203
|
const writeStreamLocks = new Set();
|
|
3060
3204
|
try {
|
|
3061
|
-
|
|
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);
|
|
3062
3212
|
const sourceRoot = sourceTree[0];
|
|
3063
3213
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3064
|
-
|
|
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
|
+
}
|
|
3065
3247
|
const suffix = randomUUID().slice(0, 8);
|
|
3066
|
-
const entityUrlMap = await this.buildForkEntityUrlMap(
|
|
3248
|
+
const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
|
|
3067
3249
|
suffix,
|
|
3068
3250
|
rootUrl,
|
|
3069
3251
|
rootInstanceId: opts.rootInstanceId
|
|
3070
3252
|
});
|
|
3071
3253
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3072
3254
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3073
|
-
const entityPlans = this.buildForkEntityPlans(
|
|
3074
|
-
this.addForkLocks(this.forkWriteLockedEntities,
|
|
3255
|
+
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
|
|
3256
|
+
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3075
3257
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
3076
3258
|
const createdStreams = [];
|
|
3077
3259
|
const createdEntities = [];
|
|
3078
3260
|
const activeManifestsByEntity = new Map();
|
|
3079
3261
|
try {
|
|
3080
3262
|
for (const plan of entityPlans) {
|
|
3081
|
-
|
|
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);
|
|
3082
3265
|
createdStreams.push(plan.fork.streams.main);
|
|
3083
3266
|
await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
|
|
3084
3267
|
createdStreams.push(plan.fork.streams.error);
|
|
@@ -3171,6 +3354,38 @@ var EntityManager = class {
|
|
|
3171
3354
|
}
|
|
3172
3355
|
held.clear();
|
|
3173
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
|
+
}
|
|
3174
3389
|
async waitForIdleSubtree(rootUrl, opts, workLocks) {
|
|
3175
3390
|
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
|
|
3176
3391
|
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
|
|
@@ -3200,6 +3415,73 @@ var EntityManager = class {
|
|
|
3200
3415
|
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
3201
3416
|
}
|
|
3202
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
|
+
}
|
|
3203
3485
|
async listEntitySubtree(root) {
|
|
3204
3486
|
const result = [];
|
|
3205
3487
|
const queue = [root];
|
|
@@ -4307,6 +4589,7 @@ var EntityManager = class {
|
|
|
4307
4589
|
streams: entity.streams,
|
|
4308
4590
|
tags: entity.tags,
|
|
4309
4591
|
spawnArgs: entity.spawn_args,
|
|
4592
|
+
sandbox: entity.sandbox,
|
|
4310
4593
|
createdBy: entity.created_by
|
|
4311
4594
|
},
|
|
4312
4595
|
principal: principalFromCreatedBy(entity.created_by),
|
|
@@ -5972,11 +6255,19 @@ var WakeRegistry = class {
|
|
|
5972
6255
|
}
|
|
5973
6256
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
5974
6257
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
5975
|
-
|
|
6258
|
+
const change = {
|
|
5976
6259
|
collection: eventType,
|
|
5977
6260
|
kind,
|
|
5978
6261
|
key: event.key || ``
|
|
5979
|
-
}
|
|
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 };
|
|
5980
6271
|
}
|
|
5981
6272
|
};
|
|
5982
6273
|
|
|
@@ -6383,13 +6674,13 @@ function buildElectricProxyTarget(options) {
|
|
|
6383
6674
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
6384
6675
|
const table = options.incomingUrl.searchParams.get(`table`);
|
|
6385
6676
|
if (table === `entities`) {
|
|
6386
|
-
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"`);
|
|
6387
6678
|
applyTenantShapeWhere(target, options.tenantId);
|
|
6388
6679
|
} else if (table === `entity_types`) {
|
|
6389
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"`);
|
|
6390
6681
|
applyTenantShapeWhere(target, options.tenantId);
|
|
6391
6682
|
} else if (table === `runners`) {
|
|
6392
|
-
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"`);
|
|
6393
6684
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
|
|
6394
6685
|
} else if (table === `runner_runtime_diagnostics`) {
|
|
6395
6686
|
target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
|
|
@@ -6815,6 +7106,28 @@ async function proxyElectric(request, ctx) {
|
|
|
6815
7106
|
});
|
|
6816
7107
|
}
|
|
6817
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
|
+
|
|
6818
7131
|
//#endregion
|
|
6819
7132
|
//#region src/routing/entities-router.ts
|
|
6820
7133
|
const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
|
|
@@ -6837,6 +7150,7 @@ const spawnBodySchema = Type.Object({
|
|
|
6837
7150
|
tags: Type.Optional(stringRecordSchema$1),
|
|
6838
7151
|
parent: Type.Optional(Type.String()),
|
|
6839
7152
|
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
7153
|
+
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
6840
7154
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
6841
7155
|
wake: Type.Optional(Type.Object({
|
|
6842
7156
|
subscriberUrl: Type.String(),
|
|
@@ -6878,7 +7192,11 @@ const inboxMessageBodySchema = Type.Object({
|
|
|
6878
7192
|
});
|
|
6879
7193
|
const forkBodySchema = Type.Object({
|
|
6880
7194
|
instance_id: Type.Optional(Type.String()),
|
|
6881
|
-
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
|
+
}))
|
|
6882
7200
|
});
|
|
6883
7201
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
6884
7202
|
const entitySignalSchema = Type.Union([
|
|
@@ -7196,7 +7514,11 @@ async function forkEntity(request, ctx) {
|
|
|
7196
7514
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
7197
7515
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
7198
7516
|
rootInstanceId: parsed.instance_id,
|
|
7199
|
-
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
|
+
} }
|
|
7200
7522
|
});
|
|
7201
7523
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
7202
7524
|
return json({
|
|
@@ -7294,6 +7616,7 @@ async function spawnEntity(request, ctx) {
|
|
|
7294
7616
|
tags: parsed.tags,
|
|
7295
7617
|
parent: parsed.parent,
|
|
7296
7618
|
dispatch_policy: dispatchPolicy,
|
|
7619
|
+
sandbox: parsed.sandbox,
|
|
7297
7620
|
initialMessage: void 0,
|
|
7298
7621
|
wake: parsed.wake,
|
|
7299
7622
|
created_by: principal.url
|
|
@@ -7548,6 +7871,12 @@ function withLeadingSlash(path$1) {
|
|
|
7548
7871
|
|
|
7549
7872
|
//#endregion
|
|
7550
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
|
+
});
|
|
7551
7880
|
const registerRunnerBodySchema = Type.Object({
|
|
7552
7881
|
id: Type.String(),
|
|
7553
7882
|
owner_principal: Type.Optional(Type.String()),
|
|
@@ -7560,7 +7889,8 @@ const registerRunnerBodySchema = Type.Object({
|
|
|
7560
7889
|
Type.Literal(`server`)
|
|
7561
7890
|
])),
|
|
7562
7891
|
admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
|
|
7563
|
-
wake_stream: Type.Optional(Type.String())
|
|
7892
|
+
wake_stream: Type.Optional(Type.String()),
|
|
7893
|
+
sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
|
|
7564
7894
|
});
|
|
7565
7895
|
const heartbeatBodySchema = Type.Object({
|
|
7566
7896
|
lease_ms: Type.Optional(Type.Number()),
|
|
@@ -7658,7 +7988,8 @@ async function registerRunner(request, ctx) {
|
|
|
7658
7988
|
label: parsed.label,
|
|
7659
7989
|
kind: parsed.kind,
|
|
7660
7990
|
adminStatus: parsed.admin_status,
|
|
7661
|
-
wakeStream: parsed.wake_stream
|
|
7991
|
+
wakeStream: parsed.wake_stream,
|
|
7992
|
+
sandboxProfiles: parsed.sandbox_profiles
|
|
7662
7993
|
});
|
|
7663
7994
|
await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
|
|
7664
7995
|
return json(runner, { status: 201 });
|
|
@@ -7888,6 +8219,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7888
8219
|
streams: entity.streams,
|
|
7889
8220
|
tags: entity.tags,
|
|
7890
8221
|
spawnArgs: entity.spawn_args,
|
|
8222
|
+
sandbox: entity.sandbox,
|
|
7891
8223
|
createdBy: entity.created_by
|
|
7892
8224
|
},
|
|
7893
8225
|
principal: principalFromCreatedBy(entity.created_by)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"pino-pretty": "^13.0.0",
|
|
55
55
|
"postgres": "^3.4.0",
|
|
56
56
|
"undici": "^7.24.7",
|
|
57
|
-
"@electric-ax/agents-runtime": "0.3.
|
|
57
|
+
"@electric-ax/agents-runtime": "0.3.8"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.19.15",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"tsx": "^4.19.0",
|
|
66
66
|
"typescript": "^5.0.0",
|
|
67
67
|
"vitest": "^4.1.0",
|
|
68
|
-
"@electric-ax/agents": "0.4.
|
|
69
|
-
"@electric-ax/agents-server-conformance-tests": "0.1.
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.4.
|
|
68
|
+
"@electric-ax/agents": "0.4.12",
|
|
69
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.10",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.15"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/db/schema.ts
CHANGED
|
@@ -49,6 +49,7 @@ export const entities = pgTable(
|
|
|
49
49
|
.notNull()
|
|
50
50
|
.default(sql`'{}'::text[]`),
|
|
51
51
|
spawnArgs: jsonb(`spawn_args`).default({}),
|
|
52
|
+
sandbox: jsonb(`sandbox`),
|
|
52
53
|
parent: text(`parent`),
|
|
53
54
|
createdBy: text(`created_by`),
|
|
54
55
|
typeRevision: integer(`type_revision`),
|
|
@@ -111,6 +112,7 @@ export const runners = pgTable(
|
|
|
111
112
|
kind: text(`kind`).notNull().default(`local`),
|
|
112
113
|
adminStatus: text(`admin_status`).notNull().default(`enabled`),
|
|
113
114
|
wakeStream: text(`wake_stream`).notNull(),
|
|
115
|
+
sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
|
|
114
116
|
createdAt: timestamp(`created_at`, { withTimezone: true })
|
|
115
117
|
.notNull()
|
|
116
118
|
.defaultNow(),
|