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