@nylorun/runtime 0.51.2-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 (53) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/adapters/sandbox/computer.d.ts +28 -0
  3. package/dist/adapters/sandbox/computer.js +184 -0
  4. package/dist/api/components.d.ts +48 -0
  5. package/dist/api/components.js +3 -1
  6. package/dist/api/http/routes/triggers.js +38 -6
  7. package/dist/api/openapi.js +1 -0
  8. package/dist/artifacts/disk.js +10 -2
  9. package/dist/harness/computer.d.ts +86 -0
  10. package/dist/harness/computer.js +273 -0
  11. package/dist/harness/executors.d.ts +5 -0
  12. package/dist/harness/executors.js +13 -5
  13. package/dist/harness/pod.d.ts +2 -0
  14. package/dist/harness/pod.js +1 -1
  15. package/dist/harness/service.js +58 -22
  16. package/dist/harness-api/server.js +17 -0
  17. package/dist/harness-api/start.js +2 -0
  18. package/dist/harness-api/workspace.d.ts +5 -0
  19. package/dist/harness-api/workspace.js +9 -5
  20. package/dist/host/harness-entry.js +7 -2
  21. package/dist/host/main.js +13 -0
  22. package/dist/host/stack-config.d.ts +13 -1
  23. package/dist/host/stack-config.js +42 -2
  24. package/dist/management-openapi.json +2 -1
  25. package/dist/openapi.json +348 -44
  26. package/dist/sandbox/computer.d.ts +53 -0
  27. package/dist/sandbox/computer.js +271 -0
  28. package/dist/sandbox/placement.d.ts +5 -2
  29. package/dist/sandbox/placement.js +6 -3
  30. package/dist/sandbox/pods/reconcile.js +1 -0
  31. package/dist/sandbox/pods/spec.d.ts +5 -0
  32. package/dist/sandbox/pods/spec.js +1 -1
  33. package/dist/sandbox/records.d.ts +2 -0
  34. package/dist/sandbox/records.js +11 -0
  35. package/dist/sandbox/types.d.ts +5 -2
  36. package/dist/store/postgres/drizzle/0049_agent_triggers_api.sql +3 -0
  37. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  38. package/dist/store/postgres/schema.d.ts +21 -4
  39. package/dist/store/postgres/schema.js +10 -7
  40. package/dist/tenant/advance.js +6 -3
  41. package/dist/tenant/agent-triggers.js +5 -2
  42. package/dist/tenant/api-triggers.d.ts +33 -0
  43. package/dist/tenant/api-triggers.js +140 -0
  44. package/dist/tenant/context.d.ts +3 -0
  45. package/dist/tenant/runtime.js +34 -0
  46. package/dist/tenant/sessions.js +2 -1
  47. package/dist/tenant/sweep.js +2 -1
  48. package/dist/tenant/transcript.d.ts +8 -8
  49. package/dist/tenant/triggers.d.ts +6 -4
  50. package/dist/tenant/triggers.js +22 -11
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +3 -3
@@ -0,0 +1,53 @@
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 { type KeyObject } from "node:crypto";
17
+ import type { TenantContext } from "../tenant/context.js";
18
+ import { type ComputerAccess } from "../adapters/sandbox/computer.js";
19
+ import { SandboxManager } from "./manager.js";
20
+ import type { SandboxHandle } from "./types.js";
21
+ /** How a Tenant reaches its computers. */
22
+ export interface TenantComputers {
23
+ /** The sandbox gateway's base URL (`http://sandbox-gateway:4400`). */
24
+ readonly gatewayUrl: string;
25
+ /** The ticket key's private half. */
26
+ readonly ticketKey: KeyObject;
27
+ /** How long a ticket holds (10 minutes). */
28
+ readonly ttlMs: number;
29
+ }
30
+ export declare const TICKET_TTL_MS: number;
31
+ /** Reads `NYLORUN_COMPUTER_TICKET_KEY_FILE`'s key (PKCS #8 PEM, Ed25519). */
32
+ export declare function loadTicketKey(path: string): KeyObject;
33
+ /** A computer ticket for sandbox `subject`, signed with `key`: a compact JWS (EdDSA). */
34
+ export declare function signTicket(key: KeyObject, subject: string, expiresAt: number): string;
35
+ /** Whether pod Environments of this Tenant are computers. */
36
+ export declare function computersOf(ctx: Pick<TenantContext, "pods">): TenantComputers | undefined;
37
+ /**
38
+ * Wakes computer `id` if needed, waits until it is ready, and answers where it is with a ticket.
39
+ * Refused for a lost, deleted or failed one.
40
+ */
41
+ export declare function computerAccess(ctx: TenantContext, id: string, signal?: AbortSignal): Promise<ComputerAccess>;
42
+ /**
43
+ * What `computer.open` answers a harness: access to computer `id`, once a seed is written, its
44
+ * setup ran, and a version's start check passed. Concurrent opens of one computer share one.
45
+ */
46
+ export declare function openComputer(ctx: TenantContext, id: string, signal?: AbortSignal): Promise<ComputerAccess>;
47
+ /** The disk's setup, run through the computer when its marker isn't the recipe's (R2d step 2). */
48
+ export declare function prepareComputerDisk(ctx: TenantContext, id: string, handle: SandboxHandle, signal?: AbortSignal): Promise<void>;
49
+ /**
50
+ * A SandboxManager per computer, for core's own uses: its backend opens the computer with
51
+ * `computerAccess` (no setup run: the harness's `computer.open` does that before a turn's tools).
52
+ */
53
+ export declare function computerManagers(ctx: () => TenantContext, emit: ConstructorParameters<typeof SandboxManager>[0]["emit"]): (environmentId: string) => SandboxManager;
@@ -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 {
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "nylorun"."agent_triggers" DROP CONSTRAINT "agent_triggers_origin_check";--> statement-breakpoint
2
+ ALTER TABLE "nylorun"."agent_triggers" ADD COLUMN "principal" text;--> statement-breakpoint
3
+ ALTER TABLE "nylorun"."agent_triggers" ADD CONSTRAINT "agent_triggers_origin_check" CHECK ("nylorun"."agent_triggers"."origin" IN ('manifest', 'agent', 'api', 'wake'));
@@ -344,6 +344,13 @@
344
344
  "when": 1794635246579,
345
345
  "tag": "0048_sandbox_pod_drained_at",
346
346
  "breakpoints": true
347
+ },
348
+ {
349
+ "idx": 49,
350
+ "version": "7",
351
+ "when": 1794674884188,
352
+ "tag": "0049_agent_triggers_api",
353
+ "breakpoints": true
347
354
  }
348
355
  ]
349
356
  }
@@ -1793,14 +1793,31 @@ export declare const agentTriggers: import("drizzle-orm/pg-core").PgTableWithCol
1793
1793
  tableName: "agent_triggers";
1794
1794
  dataType: "string";
1795
1795
  columnType: "PgText";
1796
- data: "agent" | "manifest" | "wake";
1796
+ data: "agent" | "api" | "manifest" | "wake";
1797
1797
  driverParam: string;
1798
1798
  notNull: true;
1799
1799
  hasDefault: true;
1800
1800
  isPrimaryKey: false;
1801
1801
  isAutoincrement: false;
1802
1802
  hasRuntimeDefault: false;
1803
- enumValues: ["manifest", "agent", "wake"];
1803
+ enumValues: ["manifest", "agent", "api", "wake"];
1804
+ baseColumn: never;
1805
+ identity: undefined;
1806
+ generated: undefined;
1807
+ }, {}, {}>;
1808
+ principal: import("drizzle-orm/pg-core").PgColumn<{
1809
+ name: "principal";
1810
+ tableName: "agent_triggers";
1811
+ dataType: "string";
1812
+ columnType: "PgText";
1813
+ data: string;
1814
+ driverParam: string;
1815
+ notNull: false;
1816
+ hasDefault: false;
1817
+ isPrimaryKey: false;
1818
+ isAutoincrement: false;
1819
+ hasRuntimeDefault: false;
1820
+ enumValues: [string, ...string[]];
1804
1821
  baseColumn: never;
1805
1822
  identity: undefined;
1806
1823
  generated: undefined;
@@ -2580,14 +2597,14 @@ export declare const triggerRuns: import("drizzle-orm/pg-core").PgTableWithColum
2580
2597
  tableName: "trigger_runs";
2581
2598
  dataType: "string";
2582
2599
  columnType: "PgText";
2583
- data: "agent" | "manifest" | "wake";
2600
+ data: "agent" | "api" | "manifest" | "wake";
2584
2601
  driverParam: string;
2585
2602
  notNull: true;
2586
2603
  hasDefault: true;
2587
2604
  isPrimaryKey: false;
2588
2605
  isAutoincrement: false;
2589
2606
  hasRuntimeDefault: false;
2590
- enumValues: ["manifest", "agent", "wake"];
2607
+ enumValues: ["manifest", "agent", "api", "wake"];
2591
2608
  baseColumn: never;
2592
2609
  identity: undefined;
2593
2610
  generated: undefined;
@@ -249,11 +249,14 @@ export const agentTriggers = nylorun.table("agent_triggers", {
249
249
  note: text(),
250
250
  effectId: text(),
251
251
  /**
252
- * Where its definition comes from (D83): `manifest` (the version's `runtime.triggers`),
253
- * `agent` (`definition`, made with the `triggers` tool) or `wake` (a booked wake).
252
+ * Where its definition comes from (D83, D88): `manifest` (the version's `runtime.triggers`),
253
+ * `agent` (`definition`, made with the `triggers` tool), `api` (`definition`, put by a client
254
+ * over the API) or `wake` (a booked wake).
254
255
  */
255
- origin: text({ enum: ["manifest", "agent", "wake"] }).notNull().default("manifest"),
256
- /** An agent-made trigger's title, its definition, and the session and turn that made it. */
256
+ origin: text({ enum: ["manifest", "agent", "api", "wake"] }).notNull().default("manifest"),
257
+ /** Who put an `api` trigger (D88): the caller's principal. */
258
+ principal: text(),
259
+ /** An agent-made or put trigger's title, its definition, and the session and turn that made it. */
257
260
  title: text(),
258
261
  definition: jsonText(),
259
262
  createdSessionId: textC(),
@@ -261,7 +264,7 @@ export const agentTriggers = nylorun.table("agent_triggers", {
261
264
  createdAt: textC().notNull(),
262
265
  updatedAt: textC().notNull(),
263
266
  }, (t) => [
264
- check("agent_triggers_origin_check", sql `${t.origin} IN ('manifest', 'agent', 'wake')`),
267
+ check("agent_triggers_origin_check", sql `${t.origin} IN ('manifest', 'agent', 'api', 'wake')`),
265
268
  primaryKey({ name: "agent_triggers_pkey", columns: [t.agentId, t.triggerId] }),
266
269
  index("agent_triggers_created_session").on(t.agentId, t.createdSessionId).where(sql `created_session_id IS NOT NULL`),
267
270
  index("agent_triggers_due").on(t.status, t.nextFireAt),
@@ -326,8 +329,8 @@ export const triggerRuns = nylorun.table("trigger_runs", {
326
329
  sessionId: textC(),
327
330
  turnId: textC(),
328
331
  queuedMessageId: textC(),
329
- /** Its trigger's origin and title when it ran (D83), for the runs stream after the trigger is gone. */
330
- origin: text({ enum: ["manifest", "agent", "wake"] }).notNull().default("manifest"),
332
+ /** Its trigger's origin and title when it ran (D83, D88), for the runs stream after the trigger is gone. */
333
+ origin: text({ enum: ["manifest", "agent", "api", "wake"] }).notNull().default("manifest"),
331
334
  title: text(),
332
335
  /** When it was appended to the Tenant's `runs` stream (ISO), or null until then. */
333
336
  streamedAt: textC(),
@@ -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({
@@ -115,7 +115,7 @@ export async function serveTriggersTool(t, s, request) {
115
115
  if (!row || !reaches(row, s.id))
116
116
  return failed("tool.not-found", `This session has no trigger ${input.id}: list shows the ones it has.`);
117
117
  await t.deleteAgentTrigger(s.agentId, input.id);
118
- await t.agentEvent(s.agentId, "agent.trigger_deleted", { triggerId: input.id, sessionId: s.id });
118
+ await t.agentEvent(s.agentId, "agent.trigger_deleted", { triggerId: input.id, origin: "agent", sessionId: s.id });
119
119
  return completed({ id: input.id, deleted: true });
120
120
  }
121
121
  // create: never from a turn a trigger or a booked wake started. Under the `nylorun-triggers`
@@ -153,7 +153,8 @@ export async function serveTriggersTool(t, s, request) {
153
153
  const agent = await liveEnvironment(t, s.agentId);
154
154
  const inForce = triggerLimitsInForce(runtime, agent?.applied?.limits);
155
155
  if (!existing) {
156
- const made = (await t.agentTriggers(s.agentId)).filter((row) => originOf(row) === "agent").length;
156
+ // One count for every trigger not from the definition: the tool's and the API's (D88).
157
+ const made = (await t.agentTriggers(s.agentId)).filter((row) => originOf(row) === "agent" || originOf(row) === "api").length;
157
158
  if (made >= inForce.count)
158
159
  return failed("tool.limit", `This Agent has ${made} triggers, the most it can have. Delete one to add another.`);
159
160
  }
@@ -176,6 +177,7 @@ export async function serveTriggersTool(t, s, request) {
176
177
  note: null,
177
178
  effectId: request.effectId,
178
179
  origin: "agent",
180
+ principal: null,
179
181
  title: input.title ?? null,
180
182
  definition,
181
183
  createdSessionId: s.id,
@@ -187,6 +189,7 @@ export async function serveTriggersTool(t, s, request) {
187
189
  await t.agentEvent(s.agentId, existing ? "agent.trigger_updated" : "agent.trigger_created", {
188
190
  triggerId: input.id,
189
191
  kind: definition.schedule === undefined ? "at" : "schedule",
192
+ origin: "agent",
190
193
  sessionId: s.id,
191
194
  session,
192
195
  ...(row.title === null ? {} : { title: row.title }),
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Triggers a client puts on one Agent (D88): `PUT /v1/agents/{agentId}/triggers/{triggerId}` with
3
+ * a `Trigger`, exactly as a definition's `runtime.triggers` entry holds it, and an optional title.
4
+ *
5
+ * - **Origin `api`.** The row keeps its own definition, like an agent-made trigger (D83), and the
6
+ * principal that put it. Upgrades and moves never touch it (`mergeAgentTriggers` merges
7
+ * `manifest` rows only); `DELETE` removes it; pause, resume, run and a webhook's secret work as
8
+ * for any trigger; it fires through `fireTimer` and `deliverRun` like every other.
9
+ * - **Create or replace.** A new id makes it active with its next time. The same id replaces it:
10
+ * `generation` goes up and the next time is worked out again, so the timer armed for the old
11
+ * time does nothing. A paused one stays paused; an `at` that ran is active again at its new time.
12
+ * - **The same body again changes nothing** and records no event, so a client may put its desired
13
+ * state as often as it likes.
14
+ * - **Conflicts.** An id the definition declares, or a row of another origin holds, is
15
+ * `409 trigger_conflict`: the API never replaces a trigger it didn't put.
16
+ * - **Checks and limits.** Those a definition save runs (`triggerIssues`), against the limits in
17
+ * force; `count` covers the Agent's `agent` and `api` triggers together (`422 trigger_limit`).
18
+ * - **Events.** `agent.trigger_created` or `agent.trigger_updated` (with `changed`) on the Agent.
19
+ *
20
+ * One transaction with the Agent locked; the timer is armed after the commit.
21
+ */
22
+ import { type Trigger, type TriggerPut, type TriggerView } from "@nylorun/core/contracts";
23
+ import type { TenantContext } from "./context.js";
24
+ /** The `Trigger` part of a put body, with only the fields it has. */
25
+ export declare function definitionOfPut(body: TriggerPut): Trigger;
26
+ /** The definition fields that differ between `before` and `after`, and `title` when it changed. */
27
+ export declare function changedFields(before: Trigger, after: Trigger, titleBefore: string | null, titleAfter: string | null): string[];
28
+ /**
29
+ * `PUT /v1/agents/{agentId}/triggers/{triggerId}` (D88): creates or replaces the Agent's `api`
30
+ * trigger `triggerId` and answers it as the list shows it. `principal` is who put it; `origin` is
31
+ * the URL origin the request reached, for a webhook's URL.
32
+ */
33
+ export declare function putTrigger(ctx: TenantContext, agentId: string, triggerId: string, body: TriggerPut, principal: string | undefined, origin?: string): Promise<TriggerView>;