@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.
- package/CHANGELOG.md +43 -0
- package/dist/adapters/sandbox/computer.d.ts +28 -0
- package/dist/adapters/sandbox/computer.js +184 -0
- package/dist/api/components.d.ts +48 -0
- package/dist/api/components.js +3 -1
- package/dist/api/http/routes/triggers.js +38 -6
- package/dist/api/openapi.js +1 -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 +2 -1
- package/dist/openapi.json +348 -44
- 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/store/postgres/drizzle/0049_agent_triggers_api.sql +3 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +21 -4
- package/dist/store/postgres/schema.js +10 -7
- package/dist/tenant/advance.js +6 -3
- package/dist/tenant/agent-triggers.js +5 -2
- package/dist/tenant/api-triggers.d.ts +33 -0
- package/dist/tenant/api-triggers.js +140 -0
- 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/tenant/triggers.d.ts +6 -4
- package/dist/tenant/triggers.js +22 -11
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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 {
|
|
@@ -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)
|
|
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
|
-
/**
|
|
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(),
|
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({
|
|
@@ -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
|
-
|
|
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>;
|