@nylorun/runtime 0.52.0-beta → 0.53.1-beta
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/CHANGELOG.md +32 -0
- package/dist/adapters/sandbox/computer.d.ts +28 -0
- package/dist/adapters/sandbox/computer.js +184 -0
- package/dist/artifacts/disk.js +10 -2
- package/dist/harness/computer.d.ts +86 -0
- package/dist/harness/computer.js +273 -0
- package/dist/harness/executors.d.ts +5 -0
- package/dist/harness/executors.js +13 -5
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +1 -1
- package/dist/harness/service.js +58 -22
- package/dist/harness-api/server.js +17 -0
- package/dist/harness-api/start.js +2 -0
- package/dist/harness-api/workspace.d.ts +5 -0
- package/dist/harness-api/workspace.js +9 -5
- package/dist/host/harness-entry.js +7 -2
- package/dist/host/main.js +13 -0
- package/dist/host/stack-config.d.ts +13 -1
- package/dist/host/stack-config.js +42 -2
- package/dist/management-openapi.json +1 -1
- package/dist/openapi.json +1 -1
- package/dist/sandbox/computer.d.ts +53 -0
- package/dist/sandbox/computer.js +271 -0
- package/dist/sandbox/placement.d.ts +5 -2
- package/dist/sandbox/placement.js +6 -3
- package/dist/sandbox/pods/reconcile.js +1 -0
- package/dist/sandbox/pods/spec.d.ts +5 -0
- package/dist/sandbox/pods/spec.js +1 -1
- package/dist/sandbox/records.d.ts +2 -0
- package/dist/sandbox/records.js +11 -0
- package/dist/sandbox/types.d.ts +5 -2
- package/dist/tenant/advance.js +6 -3
- package/dist/tenant/context.d.ts +3 -0
- package/dist/tenant/runtime.js +34 -0
- package/dist/tenant/sessions.js +2 -1
- package/dist/tenant/sweep.js +2 -1
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computers in core (ADR-149, ADR-150): a pod Agent on an Agent Substrate cluster whose sandboxes
|
|
3
|
+
* service renders computers (`NYLORUN_SANDBOX_GATEWAY_URL` set). Its engine runs no loop: its
|
|
4
|
+
* sessions' runs go to the harness container, and its commands and files go through the sandbox
|
|
5
|
+
* gateway with a ticket core signs here (`computer-ticket.key`, Ed25519; ADR-148).
|
|
6
|
+
*
|
|
7
|
+
* - `computerAccess` wakes the computer when it isn't running (as a turn start does), waits until
|
|
8
|
+
* the sandboxes service says it is ready (its wake-up probe answered), records its first contact
|
|
9
|
+
* (`agent.running` or `agent.relaunched`, the pod's UID for the lost check), and answers where it
|
|
10
|
+
* is and a ticket. Core's own uses (the sandbox route, disk versions, artifacts) open it so.
|
|
11
|
+
* - `openComputer` is what a harness gets with `computer.open`: the same, once a seed being copied
|
|
12
|
+
* onto a new volume is written (R2d step 3), with the disk's setup run through the computer when
|
|
13
|
+
* its marker isn't the recipe's (R2d step 2; `prepareComputerDisk`), and a version's start check
|
|
14
|
+
* passed (T3).
|
|
15
|
+
*/
|
|
16
|
+
import { createPrivateKey, randomUUID, sign } from "node:crypto";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { posix } from "node:path";
|
|
19
|
+
import { fail, failRetryLater } from "../tenant/http.js";
|
|
20
|
+
import { wakePod } from "../tenant/agents.js";
|
|
21
|
+
import { passStartCheck } from "../tenant/agent-versions.js";
|
|
22
|
+
import { computerBackend } from "../adapters/sandbox/computer.js";
|
|
23
|
+
import { SandboxManager } from "./manager.js";
|
|
24
|
+
import { memorySandboxRecords } from "./records.js";
|
|
25
|
+
import { recordSetup, setupFile, setupPlanOf } from "./setup.js";
|
|
26
|
+
import { hostTuning } from "../tuning.js";
|
|
27
|
+
export const TICKET_TTL_MS = 10 * 60_000;
|
|
28
|
+
/** Reads `NYLORUN_COMPUTER_TICKET_KEY_FILE`'s key (PKCS #8 PEM, Ed25519). */
|
|
29
|
+
export function loadTicketKey(path) {
|
|
30
|
+
const key = createPrivateKey(readFileSync(path, "utf8"));
|
|
31
|
+
if (key.asymmetricKeyType !== "ed25519")
|
|
32
|
+
throw new Error(`${path} is not an Ed25519 key`);
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
/** A computer ticket for sandbox `subject`, signed with `key`: a compact JWS (EdDSA). */
|
|
36
|
+
export function signTicket(key, subject, expiresAt) {
|
|
37
|
+
const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url");
|
|
38
|
+
const body = Buffer.from(JSON.stringify({ aud: "nylorun-computer", sub: subject, exp: Math.floor(expiresAt / 1000) })).toString("base64url");
|
|
39
|
+
const signature = sign(null, Buffer.from(`${header}.${body}`), key).toString("base64url");
|
|
40
|
+
return `${header}.${body}.${signature}`;
|
|
41
|
+
}
|
|
42
|
+
/** Whether pod Environments of this Tenant are computers. */
|
|
43
|
+
export function computersOf(ctx) {
|
|
44
|
+
return ctx.pods?.computer;
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
/**
|
|
48
|
+
* Wakes computer `id` if needed, waits until it is ready, and answers where it is with a ticket.
|
|
49
|
+
* Refused for a lost, deleted or failed one.
|
|
50
|
+
*/
|
|
51
|
+
export async function computerAccess(ctx, id, signal) {
|
|
52
|
+
const computers = computersOf(ctx);
|
|
53
|
+
const pods = ctx.pods;
|
|
54
|
+
if (!computers || !pods)
|
|
55
|
+
return fail(409, `Agent ${id} is not a computer on this Runtime.`, { code: "agent_unavailable" });
|
|
56
|
+
await wakePod(ctx, id);
|
|
57
|
+
const deadline = Date.now() + hostTuning().pods.readyDeadlineMs;
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (signal?.aborted)
|
|
60
|
+
throw signal.reason ?? new Error("Cancelled");
|
|
61
|
+
const environment = await ctx.store.tx((t) => t.environment(id));
|
|
62
|
+
const pod = environment?.pod;
|
|
63
|
+
if (!environment || !pod || pod.desired === "deleted")
|
|
64
|
+
return fail(404, `Agent ${id} does not exist.`, { code: "not_found" });
|
|
65
|
+
if (pod.observed === "lost")
|
|
66
|
+
return fail(409, `Agent ${id}'s computer was lost (${pod.reason ?? "its volume is gone"}). Reset it.`, { code: "agent_lost" });
|
|
67
|
+
if (pod.observed === "failed")
|
|
68
|
+
return fail(409, `Agent ${id}'s computer failed: ${pod.reason ?? "it did not start"}.`, { code: "agent_unavailable" });
|
|
69
|
+
const status = await pods.client.status(pod.k8sName, { wait: "ready", timeoutMs: 20_000 }).catch(() => undefined);
|
|
70
|
+
if (status?.ready && status.podUID) {
|
|
71
|
+
await firstContact(ctx, id, status.podUID);
|
|
72
|
+
const expiresAt = Date.now() + computers.ttlMs;
|
|
73
|
+
return {
|
|
74
|
+
url: `${computers.gatewayUrl}/${pod.k8sName}`,
|
|
75
|
+
ticket: signTicket(computers.ticketKey, pod.k8sName, expiresAt),
|
|
76
|
+
expiresAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (Date.now() > deadline)
|
|
80
|
+
failRetryLater(`Agent ${id}'s computer was not ready within ${hostTuning().pods.readyDeadlineMs / 1000} s.`, "agent_unavailable");
|
|
81
|
+
// The reconcile moves the row along (creating → running); the next look waits on the service.
|
|
82
|
+
await ctx.sandboxSignal(id, { kind: "reconcile" }).catch(() => undefined);
|
|
83
|
+
await sleep(500);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Records the computer's first answer since it started: its UID, and `agent.running` or `agent.relaunched`. */
|
|
87
|
+
async function firstContact(ctx, id, podUid) {
|
|
88
|
+
await ctx.store.tx(async (t) => {
|
|
89
|
+
const environment = await t.environment(id, { lock: true });
|
|
90
|
+
const pod = environment?.pod;
|
|
91
|
+
if (!pod || pod.podUid === podUid || pod.desired !== "running")
|
|
92
|
+
return;
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const epoch = pod.hostEpoch + 1;
|
|
95
|
+
await t.updateEnvironmentPod(id, { podUid, hostEpoch: epoch, observed: "running", reason: null }, now);
|
|
96
|
+
if (pod.podUid !== undefined)
|
|
97
|
+
await t.agentEvent(id, "agent.relaunched", { hostEpoch: epoch });
|
|
98
|
+
else
|
|
99
|
+
await t.agentEvent(id, "agent.running", { hostEpoch: epoch });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const opening = new Map();
|
|
103
|
+
/**
|
|
104
|
+
* What `computer.open` answers a harness: access to computer `id`, once a seed is written, its
|
|
105
|
+
* setup ran, and a version's start check passed. Concurrent opens of one computer share one.
|
|
106
|
+
*/
|
|
107
|
+
export function openComputer(ctx, id, signal) {
|
|
108
|
+
const key = `${ctx.config.tenantId}\0${id}`;
|
|
109
|
+
let pending = opening.get(key);
|
|
110
|
+
if (!pending) {
|
|
111
|
+
pending = openOnce(ctx, id, signal).finally(() => opening.delete(key));
|
|
112
|
+
opening.set(key, pending);
|
|
113
|
+
}
|
|
114
|
+
return pending;
|
|
115
|
+
}
|
|
116
|
+
async function openOnce(ctx, id, signal) {
|
|
117
|
+
let access = await computerAccess(ctx, id, signal);
|
|
118
|
+
// A new volume being seeded from a disk version (R2d step 3): wait until core has written it.
|
|
119
|
+
const deadline = Date.now() + hostTuning().pods.readyDeadlineMs;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const environment = await ctx.store.tx((t) => t.environment(id));
|
|
122
|
+
if (!environment?.diskSeed || environment.diskSeed.written)
|
|
123
|
+
break;
|
|
124
|
+
const { runPendingSeed } = await import("../artifacts/disk.js");
|
|
125
|
+
void runPendingSeed(ctx, id);
|
|
126
|
+
if (Date.now() > deadline)
|
|
127
|
+
failRetryLater(`Agent ${id}'s disk is still being copied.`, "agent_busy");
|
|
128
|
+
await sleep(hostTuning().pods.enginePollMs);
|
|
129
|
+
}
|
|
130
|
+
const backend = computerBackend({ open: async () => (access = await computerAccess(ctx, id, signal)) });
|
|
131
|
+
const handle = await backend.open({ key: id, cpus: 1, memoryMiB: 1024, network: { preset: "none", hosts: [], suffixes: [] } });
|
|
132
|
+
await prepareComputerDisk(ctx, id, handle, signal);
|
|
133
|
+
const environment = await ctx.store.tx((t) => t.environment(id));
|
|
134
|
+
if (environment?.startCheck && environment.pod)
|
|
135
|
+
await passStartCheck(ctx, id, environment.pod.hostEpoch);
|
|
136
|
+
return access;
|
|
137
|
+
}
|
|
138
|
+
const MARKER = "/harness/setup.json";
|
|
139
|
+
const VOLUME = "/harness/volume-id";
|
|
140
|
+
/** The disk's setup, run through the computer when its marker isn't the recipe's (R2d step 2). */
|
|
141
|
+
export async function prepareComputerDisk(ctx, id, handle, signal) {
|
|
142
|
+
const state = await ctx.store.tx(async (t) => {
|
|
143
|
+
const environment = await t.environment(id);
|
|
144
|
+
if (!environment?.pod)
|
|
145
|
+
return undefined;
|
|
146
|
+
return {
|
|
147
|
+
epoch: environment.pod.hostEpoch,
|
|
148
|
+
setup: await setupPlanOf(ctx, t, environment),
|
|
149
|
+
seeded: environment.diskSeed !== undefined,
|
|
150
|
+
afterSeed: environment.diskSeed?.setup === true,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
if (!state)
|
|
154
|
+
return;
|
|
155
|
+
let volumeId = (await handle.readFile(VOLUME))?.trim();
|
|
156
|
+
if (!volumeId) {
|
|
157
|
+
volumeId = randomUUID();
|
|
158
|
+
await handle.writeFile(VOLUME, `${volumeId}\n`);
|
|
159
|
+
}
|
|
160
|
+
const markerOf = (setup) => ({
|
|
161
|
+
volumeId: volumeId,
|
|
162
|
+
recipeVersion: setup?.recipeVersion ?? 0,
|
|
163
|
+
...(setup?.setupHash === undefined ? {} : { setupHash: setup.setupHash }),
|
|
164
|
+
status: "ok",
|
|
165
|
+
});
|
|
166
|
+
const setup = state.setup;
|
|
167
|
+
if (state.seeded && !state.afterSeed) {
|
|
168
|
+
// The disk version holds what setup made: mark it done.
|
|
169
|
+
await handle.writeFile(MARKER, `${JSON.stringify(markerOf(setup))}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!setup) {
|
|
173
|
+
if (state.afterSeed)
|
|
174
|
+
await recordSetup(ctx, id, state.epoch, { status: "ok" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const raw = await handle.readFile(MARKER);
|
|
178
|
+
if (raw) {
|
|
179
|
+
try {
|
|
180
|
+
const done = JSON.parse(raw);
|
|
181
|
+
const same = done.setupHash === undefined || done.setupHash === setup.setupHash;
|
|
182
|
+
if (done.volumeId === volumeId && done.status === "ok" && same)
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// a broken marker: run it again
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
ctx.config.logger.info("environment setup started", { environmentId: id, recipeVersion: setup.recipeVersion });
|
|
190
|
+
const report = await runSetup(ctx, id, handle, setup, signal ?? new AbortController().signal);
|
|
191
|
+
if (report.status === "ok")
|
|
192
|
+
await handle.writeFile(MARKER, `${JSON.stringify(markerOf(setup))}\n`);
|
|
193
|
+
await recordSetup(ctx, id, state.epoch, report);
|
|
194
|
+
if (report.status === "failed")
|
|
195
|
+
fail(409, `Agent ${id}'s setup failed${report.index === undefined ? "" : ` at command ${report.index + 1}`}: ${(report.output ?? "").slice(-500)}`, {
|
|
196
|
+
code: "agent_unavailable",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function bytesOf(body) {
|
|
200
|
+
return new Uint8Array(await new Response(body).arrayBuffer());
|
|
201
|
+
}
|
|
202
|
+
async function ensureDir(handle, path, signal) {
|
|
203
|
+
await handle.exec({ command: `mkdir -p '${path.replace(/'/g, "'\\''")}'`, cwd: "/", timeoutMs: 30_000 }, signal);
|
|
204
|
+
}
|
|
205
|
+
function under(base, relative) {
|
|
206
|
+
const joined = posix.normalize(posix.join(base, relative));
|
|
207
|
+
if (joined !== base && !joined.startsWith(`${base.replace(/\/$/, "")}/`))
|
|
208
|
+
throw new Error(`${relative} would leave ${base}`);
|
|
209
|
+
return joined;
|
|
210
|
+
}
|
|
211
|
+
async function runSetup(ctx, id, handle, setup, signal) {
|
|
212
|
+
for (const file of setup.files) {
|
|
213
|
+
try {
|
|
214
|
+
const first = await setupFile(ctx, id, file.index, undefined);
|
|
215
|
+
if (!first)
|
|
216
|
+
throw new Error(`input file ${file.index + 1} is gone`);
|
|
217
|
+
if (first.kind === "file") {
|
|
218
|
+
await ensureDir(handle, posix.dirname(file.path), signal);
|
|
219
|
+
await handle.writeFile(file.path, await bytesOf(first.body));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for (const entry of first.entries) {
|
|
223
|
+
const one = await setupFile(ctx, id, file.index, entry.path);
|
|
224
|
+
if (!one || one.kind !== "file")
|
|
225
|
+
throw new Error(`input file ${file.index + 1}, ${entry.path}, is gone`);
|
|
226
|
+
const target = under(file.path, entry.path);
|
|
227
|
+
await ensureDir(handle, posix.dirname(target), signal);
|
|
228
|
+
await handle.writeFile(target, await bytesOf(one.body));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return { status: "failed", output: error instanceof Error ? error.message : String(error) };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const [index, command] of setup.commands.entries()) {
|
|
236
|
+
await ensureDir(handle, command.cwd, signal).catch(() => undefined);
|
|
237
|
+
try {
|
|
238
|
+
const result = await handle.exec({ command: command.command, cwd: command.cwd, timeoutMs: command.timeoutMs, env: setup.environment }, signal);
|
|
239
|
+
if (result.exitCode !== 0) {
|
|
240
|
+
const output = `${result.stdout}${result.stderr ? `\n${result.stderr}` : ""}${result.timedOut ? "\n[timed out]" : ""}`;
|
|
241
|
+
return { status: "failed", index, exitCode: result.exitCode, output: output.slice(-hostTuning().harness.setupReportTailChars) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return { status: "failed", index, output: error instanceof Error ? error.message : String(error) };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { status: "ok" };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A SandboxManager per computer, for core's own uses: its backend opens the computer with
|
|
252
|
+
* `computerAccess` (no setup run: the harness's `computer.open` does that before a turn's tools).
|
|
253
|
+
*/
|
|
254
|
+
export function computerManagers(ctx, emit) {
|
|
255
|
+
const managers = new Map();
|
|
256
|
+
return (environmentId) => {
|
|
257
|
+
let manager = managers.get(environmentId);
|
|
258
|
+
if (!manager) {
|
|
259
|
+
manager = new SandboxManager({
|
|
260
|
+
scope: ctx().config.tenantId,
|
|
261
|
+
records: memorySandboxRecords(),
|
|
262
|
+
backends: [computerBackend({ open: (signal) => computerAccess(ctx(), environmentId, signal) })],
|
|
263
|
+
preference: "auto",
|
|
264
|
+
ephemeral: false,
|
|
265
|
+
emit,
|
|
266
|
+
});
|
|
267
|
+
managers.set(environmentId, manager);
|
|
268
|
+
}
|
|
269
|
+
return manager;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Runtime's harness: in process, or the harness container) and `sandbox` (the engine in a pod).
|
|
6
6
|
* Default: both. `"*": { hosts: [] }` refuses every harness the map does not name (R4.1).
|
|
7
7
|
*
|
|
8
|
-
* A session on a pod Environment runs on `sandbox
|
|
8
|
+
* A session on a pod Environment runs on `sandbox` (on `harness-container` when pod Agents are
|
|
9
|
+
* computers, ADR-149); every other session (a virtual or `none`
|
|
9
10
|
* Environment, or none) on `harness-container` (D52). A host the Tenant does not allow is
|
|
10
11
|
* `409 placement_refused`. A pod Environment needs sandbox pods: without a cluster (`nylorun
|
|
11
12
|
* sandbox enable`), or with the sandboxes service not ready, it is `409 agent_unavailable`.
|
|
@@ -21,7 +22,9 @@ export declare function placementOf(config: TenantSandboxConfig, harnessId?: str
|
|
|
21
22
|
/** How to enable sandbox pods, for the messages that need them. */
|
|
22
23
|
export declare const ENABLE_PODS = "run `nylorun local sandbox enable --context <name>` to give this Tenant a cluster";
|
|
23
24
|
/** Refuses a session whose harness, on the host its Environment needs, the Tenant does not allow. */
|
|
24
|
-
export declare function checkPlacement(config: TenantSandboxConfig, kind: EnvironmentKind | undefined, harness?: string
|
|
25
|
+
export declare function checkPlacement(config: TenantSandboxConfig, kind: EnvironmentKind | undefined, harness?: string,
|
|
26
|
+
/** Pod Agents are computers (ADR-149): their harness runs in the harness container. */
|
|
27
|
+
computers?: boolean): void;
|
|
25
28
|
/** Whether the sandboxes service is up, the answer reused for `pods.readyCacheMs` (Host tuning). */
|
|
26
29
|
export declare function podsReady(pods: TenantPods): Promise<boolean>;
|
|
27
30
|
/** Refuses a pod Environment without sandbox pods, or with the sandboxes service down. */
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Runtime's harness: in process, or the harness container) and `sandbox` (the engine in a pod).
|
|
6
6
|
* Default: both. `"*": { hosts: [] }` refuses every harness the map does not name (R4.1).
|
|
7
7
|
*
|
|
8
|
-
* A session on a pod Environment runs on `sandbox
|
|
8
|
+
* A session on a pod Environment runs on `sandbox` (on `harness-container` when pod Agents are
|
|
9
|
+
* computers, ADR-149); every other session (a virtual or `none`
|
|
9
10
|
* Environment, or none) on `harness-container` (D52). A host the Tenant does not allow is
|
|
10
11
|
* `409 placement_refused`. A pod Environment needs sandbox pods: without a cluster (`nylorun
|
|
11
12
|
* sandbox enable`), or with the sandboxes service not ready, it is `409 agent_unavailable`.
|
|
@@ -24,8 +25,10 @@ export function placementOf(config, harnessId = HARNESS_ID) {
|
|
|
24
25
|
/** How to enable sandbox pods, for the messages that need them. */
|
|
25
26
|
export const ENABLE_PODS = "run `nylorun local sandbox enable --context <name>` to give this Tenant a cluster";
|
|
26
27
|
/** Refuses a session whose harness, on the host its Environment needs, the Tenant does not allow. */
|
|
27
|
-
export function checkPlacement(config, kind, harness = HARNESS_ID
|
|
28
|
-
|
|
28
|
+
export function checkPlacement(config, kind, harness = HARNESS_ID,
|
|
29
|
+
/** Pod Agents are computers (ADR-149): their harness runs in the harness container. */
|
|
30
|
+
computers = false) {
|
|
31
|
+
const host = kind === "pod" && !computers ? "sandbox" : "harness-container";
|
|
29
32
|
const allowed = placementOf(config, harness);
|
|
30
33
|
if (allowed.includes(host))
|
|
31
34
|
return;
|
|
@@ -131,6 +131,7 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
|
|
|
131
131
|
harness: row.applied.harness,
|
|
132
132
|
...(joinToken ? { joinToken } : {}),
|
|
133
133
|
keys,
|
|
134
|
+
...(pods.computer ? { computer: true } : {}),
|
|
134
135
|
});
|
|
135
136
|
try {
|
|
136
137
|
await pods.client.put(row.pod.k8sName, spec);
|
|
@@ -45,5 +45,10 @@ export interface PodSpecInput {
|
|
|
45
45
|
readonly joinToken?: string;
|
|
46
46
|
/** The keyed hosts (`keys.ts`), for Agent Substrate's egress. */
|
|
47
47
|
readonly keys?: PodSpec["keys"];
|
|
48
|
+
/**
|
|
49
|
+
* The pod is a computer (ADR-149): our engine runs there in computer mode whatever harness the
|
|
50
|
+
* Agent runs, since every harness runs outside it.
|
|
51
|
+
*/
|
|
52
|
+
readonly computer?: boolean;
|
|
48
53
|
}
|
|
49
54
|
export declare function podSpecOf(input: PodSpecInput): PodSpec;
|
|
@@ -40,7 +40,7 @@ export function podSpecOf(input) {
|
|
|
40
40
|
const lifecycle = podLifecycleConfig(config);
|
|
41
41
|
// Pinned by digest once a pod ran it (Q14).
|
|
42
42
|
const image = pod.imageDigest ?? spec.image;
|
|
43
|
-
const ours = (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
|
|
43
|
+
const ours = input.computer === true || (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
|
|
44
44
|
// Our engine, built into an image the Tenant lists, while it is this Runtime's (ADR-99).
|
|
45
45
|
const builtin = ours &&
|
|
46
46
|
runsBuiltinEngine({
|
|
@@ -27,3 +27,5 @@ export declare function storeSandboxRecords(store: SessionStore): SandboxRecords
|
|
|
27
27
|
* change. Writes are serialized; a file that cannot be read starts empty.
|
|
28
28
|
*/
|
|
29
29
|
export declare function fileSandboxRecords(path: string): SandboxRecords;
|
|
30
|
+
/** Records kept in memory: a computer's compute is core's pod row, not a `sandboxes` row. */
|
|
31
|
+
export declare function memorySandboxRecords(): SandboxRecords;
|
package/dist/sandbox/records.js
CHANGED
|
@@ -77,3 +77,14 @@ export function fileSandboxRecords(path) {
|
|
|
77
77
|
count: async () => records.size,
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/** Records kept in memory: a computer's compute is core's pod row, not a `sandboxes` row. */
|
|
81
|
+
export function memorySandboxRecords() {
|
|
82
|
+
const rows = new Map();
|
|
83
|
+
return {
|
|
84
|
+
get: async (key) => rows.get(key),
|
|
85
|
+
put: async (record) => void rows.set(record.key, record),
|
|
86
|
+
delete: async (key) => void rows.delete(key),
|
|
87
|
+
list: async () => [...rows.values()],
|
|
88
|
+
count: async () => rows.size,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* and import a substrate SDK; everything else in the Runtime talks to this interface.
|
|
4
4
|
*/
|
|
5
5
|
import type { SandboxNetworkPreset } from "@nylorun/core/define";
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `virtual` (just-bash, in process); `local` (the pod sandbox the engine runs in, F7.2);
|
|
8
|
+
* `computer` (an Agent Substrate computer, reached through the sandbox gateway, ADR-149).
|
|
9
|
+
*/
|
|
10
|
+
export type SandboxBackendName = "virtual" | "local" | "computer";
|
|
8
11
|
/** What separates a sandbox from its host: the Runtime process, or a container boundary. */
|
|
9
12
|
export type SandboxIsolation = "process" | "container";
|
|
10
13
|
export interface SandboxProbe {
|
package/dist/tenant/advance.js
CHANGED
|
@@ -285,14 +285,17 @@ async function runRemoteSegment(ctx, lease, started, controller, run, onTaken, p
|
|
|
285
285
|
const unusable = podUnusable(ctx, segment);
|
|
286
286
|
if (unusable)
|
|
287
287
|
throw new Error(unusable);
|
|
288
|
+
// A computer's run goes to the harness container (ADR-149); `computer.open` waits for its
|
|
289
|
+
// seed, runs its setup and passes its start check.
|
|
290
|
+
const computer = ctx.pods?.computer !== undefined;
|
|
288
291
|
// A new volume being seeded from a disk version takes no turn until it holds it (R2d step 3).
|
|
289
|
-
if (segment.pod?.seeding)
|
|
292
|
+
if (!computer && segment.pod?.seeding)
|
|
290
293
|
throw new HarnessUnavailable();
|
|
291
294
|
// A pod starting on a new version takes no turn until its harness joins (track T3): a start
|
|
292
295
|
// that fails goes back to the version it ran, and the turn's first segment re-pins to it.
|
|
293
|
-
if (segment.pod?.starting)
|
|
296
|
+
if (!computer && segment.pod?.starting)
|
|
294
297
|
throw new HarnessUnavailable();
|
|
295
|
-
const pod = segment.pod?.id;
|
|
298
|
+
const pod = computer ? undefined : segment.pod?.id;
|
|
296
299
|
if (started.environmentId !== undefined)
|
|
297
300
|
placed.environment = started.environmentId;
|
|
298
301
|
end = await ctx.harness.offer({
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
|
|
15
15
|
* under ownership (§10.6); `abortLocal` aborts an advance running on this process.
|
|
16
16
|
*/
|
|
17
|
+
import type { TenantComputers } from "../sandbox/computer.js";
|
|
17
18
|
import type { SessionHistory } from "./history.js";
|
|
18
19
|
import type { IssuerScope, SessionModelChoice, SessionModelRecord, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
|
|
19
20
|
import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
|
|
@@ -390,6 +391,8 @@ export interface TenantPods {
|
|
|
390
391
|
readonly client: SandboxesClient;
|
|
391
392
|
/** The Runtime image the pods copy the engine from (`NYLORUN_SANDBOX_HARNESS_IMAGE`). */
|
|
392
393
|
readonly harnessImage: string;
|
|
394
|
+
/** Computers (ADR-149): every pod Agent is one, reached through the sandbox gateway. */
|
|
395
|
+
readonly computer?: TenantComputers;
|
|
393
396
|
}
|
|
394
397
|
/** The harness a session runs (R4.1): the one it pinned, else ours. */
|
|
395
398
|
export declare function harnessOf(session: Pick<Session, "harness">): string;
|
package/dist/tenant/runtime.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root of an open Tenant: validates the config, opens the Session Store and the
|
|
3
|
+
* vault key, builds the services and the `TenantContext`, wires the seams, registers the
|
|
4
|
+
* Tenant's Worker handlers and arms its sweep, and implements `TenantHandle` (handle,
|
|
5
|
+
* summary, drain, close) by delegating to the Tenant modules.
|
|
6
|
+
*
|
|
7
|
+
* Seams wired here: `wireStreams()` connects the store's commits to Durable Streams (the
|
|
8
|
+
* relay, and the history and SSE readers); `wireControl()` follows the control bus on
|
|
9
|
+
* Postgres (cancels and resets from other processes); `wake` goes to the
|
|
10
|
+
* `DurableExecution`, whose handlers (`worker.ts`) call `advance` and `sweep`; `abortLocal`
|
|
11
|
+
* aborts an advance running on this process.
|
|
12
|
+
*
|
|
13
|
+
* Execution: the Host passes its `TenantExecution` (`TenantOpenHooks.execution`); without
|
|
14
|
+
* one, the Tenant runs its own in-process `MemoryExecution`. No lock file: several processes may open a Tenant, and ownership of each
|
|
15
|
+
* session (§10.6) keeps its advances apart.
|
|
16
|
+
*/
|
|
17
|
+
import { computerManagers } from "../sandbox/computer.js";
|
|
1
18
|
import { mkdirSync } from "node:fs";
|
|
2
19
|
import { scriptedModel, toolFixtureModel, } from "../core/provider.js";
|
|
3
20
|
import { tenantModelGate } from "../gates/in-process.js";
|
|
@@ -185,6 +202,23 @@ export class TenantRuntime {
|
|
|
185
202
|
logger: config.logger,
|
|
186
203
|
preference,
|
|
187
204
|
isPod: async (sandboxId) => (await opened.tx((t) => t.environment(sandboxId)))?.kind === "pod",
|
|
205
|
+
...(hooks.pods.computer
|
|
206
|
+
? {
|
|
207
|
+
computer: (() => {
|
|
208
|
+
const managerOf = computerManagers(() => ctx, async (sessionId, turnId, type, payload) => {
|
|
209
|
+
if (ctx.closed)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
await opened.tx((t) => t.event(sessionId, turnId, type, payload));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* the session is gone (reset) or the store closed */
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return (environmentId) => localWorkspace(managerOf(environmentId));
|
|
219
|
+
})(),
|
|
220
|
+
}
|
|
221
|
+
: {}),
|
|
188
222
|
})
|
|
189
223
|
: workspaces;
|
|
190
224
|
// A model that is not the vault's (scripted, the fixture, an embedder's provider) is
|
package/dist/tenant/sessions.js
CHANGED
|
@@ -93,7 +93,8 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
|
|
|
93
93
|
await checkSkillLimit(t, pin.manifest, agent);
|
|
94
94
|
const config = await readSandboxConfig(t);
|
|
95
95
|
// Placement (D38, D52): where this session's harness may run, decided now and kept.
|
|
96
|
-
|
|
96
|
+
// A computer's harness runs in the harness container (ADR-149).
|
|
97
|
+
checkPlacement(config, agent.kind, pin.harness, ctx.pods?.computer !== undefined);
|
|
97
98
|
if (agent.kind === "pod")
|
|
98
99
|
await requirePods(ctx.pods, `Agent ${agent.id}`);
|
|
99
100
|
// The model the version names (`harness.model`, Host feature `agent-model`) is the new
|
package/dist/tenant/sweep.js
CHANGED
|
@@ -328,7 +328,8 @@ async function reconcileStalePods(ctx, now) {
|
|
|
328
328
|
pod.observed === "deleting" ||
|
|
329
329
|
(pod.desired === "suspended" && pod.observed !== "suspended") ||
|
|
330
330
|
// Running, but its engine is not connected: the pod may be gone, or its volume.
|
|
331
|
-
(
|
|
331
|
+
// (A computer has no engine connection, ADR-149.)
|
|
332
|
+
(pod.desired === "running" && pod.observed === "running" && !ctx.pods?.computer && !ctx.harness.hosting(row.id)) ||
|
|
332
333
|
// Running past its idle limit: its idle timer is missing (or a turn holds it, which the
|
|
333
334
|
// reconcile leaves alone).
|
|
334
335
|
(pod.desired === "running" &&
|
|
@@ -231,7 +231,6 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
231
231
|
gate?: GateTimes | undefined;
|
|
232
232
|
policy?: ToolPolicyFact | undefined;
|
|
233
233
|
} | {
|
|
234
|
-
redacted?: undefined;
|
|
235
234
|
agent: import("@nylorun/core/define").AgentRef;
|
|
236
235
|
error: {
|
|
237
236
|
code: string;
|
|
@@ -241,6 +240,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
241
240
|
retryable?: boolean | undefined;
|
|
242
241
|
};
|
|
243
242
|
output?: undefined;
|
|
243
|
+
redacted?: undefined;
|
|
244
244
|
toolName: string;
|
|
245
245
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
246
246
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -254,7 +254,6 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
254
254
|
gate?: GateTimes | undefined;
|
|
255
255
|
policy?: ToolPolicyFact | undefined;
|
|
256
256
|
} | {
|
|
257
|
-
redacted?: undefined;
|
|
258
257
|
agent?: undefined;
|
|
259
258
|
error: {
|
|
260
259
|
code: string;
|
|
@@ -264,6 +263,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
264
263
|
retryable?: boolean | undefined;
|
|
265
264
|
};
|
|
266
265
|
output?: undefined;
|
|
266
|
+
redacted?: undefined;
|
|
267
267
|
toolName: string;
|
|
268
268
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
269
269
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -311,10 +311,10 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
311
311
|
gate?: GateTimes | undefined;
|
|
312
312
|
policy?: ToolPolicyFact | undefined;
|
|
313
313
|
} | {
|
|
314
|
-
redacted?: undefined;
|
|
315
314
|
agent: import("@nylorun/core/define").AgentRef;
|
|
316
315
|
error?: undefined;
|
|
317
316
|
output: unknown;
|
|
317
|
+
redacted?: undefined;
|
|
318
318
|
toolName: string;
|
|
319
319
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
320
320
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -328,10 +328,10 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
328
328
|
gate?: GateTimes | undefined;
|
|
329
329
|
policy?: ToolPolicyFact | undefined;
|
|
330
330
|
} | {
|
|
331
|
-
redacted?: undefined;
|
|
332
331
|
agent?: undefined;
|
|
333
332
|
error?: undefined;
|
|
334
333
|
output: unknown;
|
|
334
|
+
redacted?: undefined;
|
|
335
335
|
toolName: string;
|
|
336
336
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
337
337
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -395,7 +395,6 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
395
395
|
gate?: GateTimes | undefined;
|
|
396
396
|
policy?: ToolPolicyFact | undefined;
|
|
397
397
|
} | {
|
|
398
|
-
redacted?: undefined;
|
|
399
398
|
callId: string;
|
|
400
399
|
invocationId: string;
|
|
401
400
|
agent: import("@nylorun/core/define").AgentRef;
|
|
@@ -407,6 +406,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
407
406
|
retryable?: boolean | undefined;
|
|
408
407
|
};
|
|
409
408
|
output?: undefined;
|
|
409
|
+
redacted?: undefined;
|
|
410
410
|
toolName: string;
|
|
411
411
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
412
412
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -420,7 +420,6 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
420
420
|
gate?: GateTimes | undefined;
|
|
421
421
|
policy?: ToolPolicyFact | undefined;
|
|
422
422
|
} | {
|
|
423
|
-
redacted?: undefined;
|
|
424
423
|
callId: string;
|
|
425
424
|
invocationId: string;
|
|
426
425
|
agent?: undefined;
|
|
@@ -432,6 +431,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
432
431
|
retryable?: boolean | undefined;
|
|
433
432
|
};
|
|
434
433
|
output?: undefined;
|
|
434
|
+
redacted?: undefined;
|
|
435
435
|
toolName: string;
|
|
436
436
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
437
437
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -483,12 +483,12 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
483
483
|
gate?: GateTimes | undefined;
|
|
484
484
|
policy?: ToolPolicyFact | undefined;
|
|
485
485
|
} | {
|
|
486
|
-
redacted?: undefined;
|
|
487
486
|
callId: string;
|
|
488
487
|
invocationId: string;
|
|
489
488
|
agent: import("@nylorun/core/define").AgentRef;
|
|
490
489
|
error?: undefined;
|
|
491
490
|
output: unknown;
|
|
491
|
+
redacted?: undefined;
|
|
492
492
|
toolName: string;
|
|
493
493
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
494
494
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
|
@@ -502,12 +502,12 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
|
|
|
502
502
|
gate?: GateTimes | undefined;
|
|
503
503
|
policy?: ToolPolicyFact | undefined;
|
|
504
504
|
} | {
|
|
505
|
-
redacted?: undefined;
|
|
506
505
|
callId: string;
|
|
507
506
|
invocationId: string;
|
|
508
507
|
agent?: undefined;
|
|
509
508
|
error?: undefined;
|
|
510
509
|
output: unknown;
|
|
510
|
+
redacted?: undefined;
|
|
511
511
|
toolName: string;
|
|
512
512
|
status?: "cancelled" | "completed" | "failed" | undefined;
|
|
513
513
|
kind?: "harness" | "http" | "mcp" | "runtime" | "sandbox" | undefined;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const RUNTIME_VERSION = "0.
|
|
1
|
+
export declare const RUNTIME_VERSION = "0.53.1-beta";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Kept in step with package.json by runtime/server/scripts/check-package.mjs, which runs in npm run check.
|
|
2
2
|
// release:prepare also rewrites this when it bumps @nylorun/runtime.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
3
|
+
export const RUNTIME_VERSION = "0.53.1-beta";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.1-beta",
|
|
4
4
|
"description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"@hono/node-server": "^2.1.8",
|
|
66
66
|
"@hono/zod-openapi": "^1.6.3",
|
|
67
67
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
68
|
-
"@nylorun/core": "0.
|
|
69
|
-
"@nylorun/harness": "0.36.
|
|
68
|
+
"@nylorun/core": "0.46.0-beta",
|
|
69
|
+
"@nylorun/harness": "0.36.6-beta",
|
|
70
70
|
"@restatedev/restate-sdk": "1.18.3",
|
|
71
71
|
"@restatedev/restate-sdk-clients": "1.18.3",
|
|
72
72
|
"@s2-dev/streamstore": "0.30.0",
|