@nylorun/runtime 0.52.0-beta → 0.53.0-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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/adapters/sandbox/computer.d.ts +28 -0
  3. package/dist/adapters/sandbox/computer.js +184 -0
  4. package/dist/artifacts/disk.js +10 -2
  5. package/dist/harness/computer.d.ts +86 -0
  6. package/dist/harness/computer.js +273 -0
  7. package/dist/harness/executors.d.ts +5 -0
  8. package/dist/harness/executors.js +13 -5
  9. package/dist/harness/pod.d.ts +2 -0
  10. package/dist/harness/pod.js +1 -1
  11. package/dist/harness/service.js +58 -22
  12. package/dist/harness-api/server.js +17 -0
  13. package/dist/harness-api/start.js +2 -0
  14. package/dist/harness-api/workspace.d.ts +5 -0
  15. package/dist/harness-api/workspace.js +9 -5
  16. package/dist/host/harness-entry.js +7 -2
  17. package/dist/host/main.js +13 -0
  18. package/dist/host/stack-config.d.ts +13 -1
  19. package/dist/host/stack-config.js +42 -2
  20. package/dist/management-openapi.json +1 -1
  21. package/dist/openapi.json +1 -1
  22. package/dist/sandbox/computer.d.ts +53 -0
  23. package/dist/sandbox/computer.js +271 -0
  24. package/dist/sandbox/placement.d.ts +5 -2
  25. package/dist/sandbox/placement.js +6 -3
  26. package/dist/sandbox/pods/reconcile.js +1 -0
  27. package/dist/sandbox/pods/spec.d.ts +5 -0
  28. package/dist/sandbox/pods/spec.js +1 -1
  29. package/dist/sandbox/records.d.ts +2 -0
  30. package/dist/sandbox/records.js +11 -0
  31. package/dist/sandbox/types.d.ts +5 -2
  32. package/dist/tenant/advance.js +6 -3
  33. package/dist/tenant/context.d.ts +3 -0
  34. package/dist/tenant/runtime.js +34 -0
  35. package/dist/tenant/sessions.js +2 -1
  36. package/dist/tenant/sweep.js +2 -1
  37. package/dist/tenant/transcript.d.ts +8 -8
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. 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`; every other session (a virtual or `none`
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): void;
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`; every other session (a virtual or `none`
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
- const host = kind === "pod" ? "sandbox" : "harness-container";
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;
@@ -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
+ }
@@ -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
- /** `virtual` (just-bash, in process); `local` (the pod sandbox the engine runs in, F7.2). */
7
- export type SandboxBackendName = "virtual" | "local";
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 {
@@ -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({
@@ -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;
@@ -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
@@ -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
- checkPlacement(config, agent.kind, pin.harness);
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
@@ -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
- (pod.desired === "running" && pod.observed === "running" && !ctx.harness.hosting(row.id)) ||
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.52.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.53.0-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.52.0-beta";
3
+ export const RUNTIME_VERSION = "0.53.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.52.0-beta",
3
+ "version": "0.53.0-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.45.0-beta",
69
- "@nylorun/harness": "0.36.5-beta",
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",