@omg-dev/sandbox 0.4.26 → 0.4.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +2 -2
- package/dist/{templates-CdQWcmOC.mjs → templates-C3mBSGdT.mjs} +183 -15
- package/dist/templates.mjs +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/mini-template/.gitkeep +0 -0
- package/src/__fixtures__/mini-template/nested/hello.txt +1 -0
- package/src/__fixtures__/mini-template/package.json +4 -0
- package/src/client.test.ts +127 -20
- package/src/client.ts +19 -1
- package/src/templates.test.ts +137 -4
- package/src/templates.ts +240 -18
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as compileStartScript, c as download, d as
|
|
2
|
-
export { SandboxClient, applyTemplate, apt, assertTemplateId, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, run, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
1
|
+
import { _ as SandboxClient, a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, g as SandboxApiError, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run, v as assertTemplateId } from "./templates-C3mBSGdT.mjs";
|
|
2
|
+
export { SandboxApiError, SandboxClient, applyTemplate, apt, assertTemplateId, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
2
4
|
//#region src/client.ts
|
|
5
|
+
/** Error thrown by SandboxClient.request carrying the HTTP status, so callers
|
|
6
|
+
* can branch on well-defined statuses (e.g. 404 template probe) instead of
|
|
7
|
+
* string-matching error messages. */
|
|
8
|
+
var SandboxApiError = class extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "SandboxApiError";
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
3
16
|
const TEMPLATE_ID = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
|
4
17
|
function assertTemplateId(id) {
|
|
5
18
|
const normalized = id.trim().toLowerCase();
|
|
@@ -38,7 +51,7 @@ var SandboxClient = class {
|
|
|
38
51
|
} catch {}
|
|
39
52
|
if (!response.ok) {
|
|
40
53
|
const detail = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : text || `HTTP ${response.status}`;
|
|
41
|
-
throw new
|
|
54
|
+
throw new SandboxApiError(`sandbox API ${method} ${path}: ${response.status} ${detail}`, response.status);
|
|
42
55
|
}
|
|
43
56
|
return parsed;
|
|
44
57
|
}
|
|
@@ -49,6 +62,9 @@ var SandboxClient = class {
|
|
|
49
62
|
async getSnapshot(id) {
|
|
50
63
|
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
51
64
|
}
|
|
65
|
+
async deleteSnapshot(id) {
|
|
66
|
+
await this.request("DELETE", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
67
|
+
}
|
|
52
68
|
async publishTemplate(templateId, snapshotId, runtime) {
|
|
53
69
|
const id = assertTemplateId(templateId);
|
|
54
70
|
await this.request("POST", `/v1/templates/${id}/latest`, {
|
|
@@ -107,6 +123,14 @@ function run(options) {
|
|
|
107
123
|
...options
|
|
108
124
|
};
|
|
109
125
|
}
|
|
126
|
+
const files = { copy(options) {
|
|
127
|
+
if (!options.sourceDir.trim()) throw new Error("files.copy requires a sourceDir");
|
|
128
|
+
if (!options.destination.trim()) throw new Error("files.copy requires a destination");
|
|
129
|
+
return {
|
|
130
|
+
kind: "files",
|
|
131
|
+
...options
|
|
132
|
+
};
|
|
133
|
+
} };
|
|
110
134
|
const check = {
|
|
111
135
|
command(command, options = {}) {
|
|
112
136
|
return {
|
|
@@ -129,11 +153,18 @@ function defineTemplate(definition) {
|
|
|
129
153
|
for (const port of definition.ports ?? []) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid template port ${port}`);
|
|
130
154
|
return Object.freeze(definition);
|
|
131
155
|
}
|
|
132
|
-
function
|
|
133
|
-
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
156
|
+
function refWithSuffix(id, suffix) {
|
|
134
157
|
const maxBase = 40 - suffix.length;
|
|
135
158
|
if (maxBase < 2) throw new Error("template id and version are too long for registry");
|
|
136
|
-
return assertTemplateId(`${
|
|
159
|
+
return assertTemplateId(`${id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
|
|
160
|
+
}
|
|
161
|
+
function templateVersionRef(definition) {
|
|
162
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`);
|
|
163
|
+
}
|
|
164
|
+
function templateContentVersionRef(definition, contentKey) {
|
|
165
|
+
if (!contentKey.trim()) throw new Error("template content key (base rootfs sha) is required");
|
|
166
|
+
const hash = createHash("sha256").update(`${templateDefinitionHash(definition)}\n${contentKey.trim()}`).digest("hex").slice(0, 8);
|
|
167
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}-${hash}`);
|
|
137
168
|
}
|
|
138
169
|
function templateDefinitionHash(definition) {
|
|
139
170
|
return createHash("sha256").update(JSON.stringify(definition)).digest("hex");
|
|
@@ -150,10 +181,19 @@ function templateRuntimeContract(definition, immutableRef) {
|
|
|
150
181
|
function quote(value) {
|
|
151
182
|
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
|
152
183
|
}
|
|
184
|
+
const PROXY_ENV_VARS = [
|
|
185
|
+
"OMG_AI_URL",
|
|
186
|
+
"ANTHROPIC_BASE_URL",
|
|
187
|
+
"ANTHROPIC_API_KEY",
|
|
188
|
+
"OPENAI_BASE_URL",
|
|
189
|
+
"OPENAI_API_KEY",
|
|
190
|
+
"OMG_MEDIA_URL"
|
|
191
|
+
];
|
|
153
192
|
function commandForUser(command, user) {
|
|
154
193
|
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
155
194
|
const home = user === "root" ? "/root" : `/home/${user}`;
|
|
156
|
-
|
|
195
|
+
const proxyEnv = PROXY_ENV_VARS.map((name) => `${name}="\${${name}:-}"`).join(" ");
|
|
196
|
+
return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} ${proxyEnv} /bin/bash -c ${quote(command)}`;
|
|
157
197
|
}
|
|
158
198
|
function shellEnvAssignments(env) {
|
|
159
199
|
return Object.entries(env).map(([key, value]) => {
|
|
@@ -162,6 +202,7 @@ function shellEnvAssignments(env) {
|
|
|
162
202
|
});
|
|
163
203
|
}
|
|
164
204
|
function compileStep(step) {
|
|
205
|
+
if (step.kind === "files") throw new Error("files steps transfer local content and are applied via applyTemplate(), not compileStep()");
|
|
165
206
|
if (step.kind === "apt") return { script: `set -euo pipefail\napt-get update -qq\nDEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends ${step.packages.map(quote).join(" ")}\nrm -rf /var/lib/apt/lists/*` };
|
|
166
207
|
if (step.kind === "archive") {
|
|
167
208
|
const strip = step.stripComponents ?? 1;
|
|
@@ -187,11 +228,113 @@ function compileStartScript(start) {
|
|
|
187
228
|
async function assertExec(label, result) {
|
|
188
229
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
189
230
|
}
|
|
231
|
+
async function runTemplateStep(sandbox, label, script, timeoutMs) {
|
|
232
|
+
const workDir = `/tmp/omg-template-step-${randomUUID()}`;
|
|
233
|
+
const pidPath = `${workDir}/pid`;
|
|
234
|
+
const statusPath = `${workDir}/status`;
|
|
235
|
+
const stdoutPath = `${workDir}/stdout`;
|
|
236
|
+
const stderrPath = `${workDir}/stderr`;
|
|
237
|
+
const wrapper = [
|
|
238
|
+
"set -u",
|
|
239
|
+
`mkdir -p ${quote(workDir)}`,
|
|
240
|
+
`printf '%s\\n' \"$$\" > ${quote(pidPath)}`,
|
|
241
|
+
`set +e; /bin/bash -lc ${quote(script)} > ${quote(stdoutPath)} 2> ${quote(stderrPath)}`,
|
|
242
|
+
"code=$?",
|
|
243
|
+
`printf '%s\\n' \"$code\" > ${quote(`${statusPath}.tmp`)}`,
|
|
244
|
+
`mv ${quote(`${statusPath}.tmp`)} ${quote(statusPath)}`,
|
|
245
|
+
"exit \"$code\""
|
|
246
|
+
].join("\n");
|
|
247
|
+
const prepared = await sandbox.shell(`mkdir -p ${quote(workDir)}`, { timeoutMs: 1e4 });
|
|
248
|
+
await assertExec(`${label} workspace preparation`, prepared);
|
|
249
|
+
const deadline = Date.now() + timeoutMs;
|
|
250
|
+
const launchDeadline = Math.min(deadline, Date.now() + 1e4);
|
|
251
|
+
try {
|
|
252
|
+
if (!(await sandbox.shell(wrapper, { detached: true })).commandId) throw new Error(`${label} did not start as a detached guest command`);
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
const probe = await sandbox.shell([
|
|
255
|
+
`if test -f ${quote(statusPath)}; then`,
|
|
256
|
+
" printf 'done\\n'",
|
|
257
|
+
` cat ${quote(statusPath)}`,
|
|
258
|
+
`elif test -f ${quote(pidPath)} && kill -0 \"$(cat ${quote(pidPath)})\" 2>/dev/null; then`,
|
|
259
|
+
" printf 'running\\n'",
|
|
260
|
+
`elif test ! -f ${quote(pidPath)}; then`,
|
|
261
|
+
" printf 'starting\\n'",
|
|
262
|
+
"else",
|
|
263
|
+
" printf 'lost\\n'",
|
|
264
|
+
"fi"
|
|
265
|
+
].join("\n"), { timeoutMs: 1e4 });
|
|
266
|
+
await assertExec(`${label} status probe`, probe);
|
|
267
|
+
const [state, rawExitCode] = probe.stdout.trim().split(/\s+/, 2);
|
|
268
|
+
if (state === "done") {
|
|
269
|
+
const exitCode = Number(rawExitCode);
|
|
270
|
+
if (!Number.isInteger(exitCode)) throw new Error(`${label} wrote an invalid exit status: ${JSON.stringify(rawExitCode)}`);
|
|
271
|
+
const stdout = await sandbox.shell(`cat ${quote(stdoutPath)} 2>/dev/null || true`, { timeoutMs: 1e4 });
|
|
272
|
+
const stderr = await sandbox.shell(`cat ${quote(stderrPath)} 2>/dev/null || true`, { timeoutMs: 1e4 });
|
|
273
|
+
await assertExec(`${label} stdout read`, stdout);
|
|
274
|
+
await assertExec(`${label} stderr read`, stderr);
|
|
275
|
+
return {
|
|
276
|
+
exitCode,
|
|
277
|
+
stdout: stdout.stdout,
|
|
278
|
+
stderr: stderr.stdout
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (state === "lost") throw new Error(`${label} guest process exited without a completion record`);
|
|
282
|
+
if (state === "starting" && Date.now() >= launchDeadline) throw new Error(`${label} guest process never initialized`);
|
|
283
|
+
if (state !== "running" && state !== "starting") throw new Error(`${label} returned an invalid guest state: ${JSON.stringify(state)}`);
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
285
|
+
}
|
|
286
|
+
await sandbox.shell(`test ! -f ${quote(pidPath)} || kill \"$(cat ${quote(pidPath)})\" 2>/dev/null || true`, { timeoutMs: 1e4 }).catch(() => {});
|
|
287
|
+
throw new Error(`${label} exceeded its ${timeoutMs}ms guest execution deadline`);
|
|
288
|
+
} finally {
|
|
289
|
+
await sandbox.shell(`rm -rf ${quote(workDir)}`, { timeoutMs: 1e4 }).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
293
|
+
async function walkSourceDir(dir) {
|
|
294
|
+
const out = [];
|
|
295
|
+
async function visit(d) {
|
|
296
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
const full = join(d, entry.name);
|
|
299
|
+
if (entry.isDirectory()) {
|
|
300
|
+
await visit(full);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
|
|
304
|
+
out.push({
|
|
305
|
+
relPath: relative(dir, full),
|
|
306
|
+
buf: await readFile(full)
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
await visit(dir);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
async function applyFilesStep(sandbox, step, onLog) {
|
|
314
|
+
const walked = await walkSourceDir(step.sourceDir);
|
|
315
|
+
if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
|
|
316
|
+
const destination = step.destination.replace(/\/$/, "");
|
|
317
|
+
const batchSize = 50;
|
|
318
|
+
for (let i = 0; i < walked.length; i += batchSize) {
|
|
319
|
+
const batch = walked.slice(i, i + batchSize).map((f) => ({
|
|
320
|
+
path: `${destination}/${f.relPath}`,
|
|
321
|
+
content: f.buf,
|
|
322
|
+
mode: 420
|
|
323
|
+
}));
|
|
324
|
+
await sandbox.writeFiles(batch);
|
|
325
|
+
}
|
|
326
|
+
onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
|
|
327
|
+
}
|
|
190
328
|
async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
191
329
|
for (const [index, step] of definition.install.entries()) {
|
|
192
330
|
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
331
|
+
if (step.kind === "files") {
|
|
332
|
+
await applyFilesStep(sandbox, step, onLog);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
193
335
|
const compiled = compileStep(step);
|
|
194
|
-
|
|
336
|
+
const label = `template step ${index + 1}`;
|
|
337
|
+
await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 6e4));
|
|
195
338
|
}
|
|
196
339
|
if (definition.start) await sandbox.writeFiles([{
|
|
197
340
|
path: "/home/user/.omg/template/bootstrap.sh",
|
|
@@ -215,14 +358,14 @@ async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
|
215
358
|
async function waitForSnapshotUpload(client, snapshot, timeoutMs = 10 * 6e4) {
|
|
216
359
|
const deadline = Date.now() + timeoutMs;
|
|
217
360
|
while (Date.now() < deadline) {
|
|
218
|
-
|
|
361
|
+
const uploaded = await client.getSnapshot(snapshot.id);
|
|
362
|
+
if (uploaded.uploadedToTigris) return uploaded;
|
|
219
363
|
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
220
364
|
}
|
|
221
365
|
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
222
366
|
}
|
|
223
367
|
async function bakeTemplate(client, definition, options = {}) {
|
|
224
368
|
const log = options.onLog ?? (() => {});
|
|
225
|
-
const versionRef = templateVersionRef(definition);
|
|
226
369
|
const sandbox = await client.create({
|
|
227
370
|
ports: [...definition.ports ?? []],
|
|
228
371
|
skipAppProcesses: true,
|
|
@@ -233,19 +376,44 @@ async function bakeTemplate(client, definition, options = {}) {
|
|
|
233
376
|
await applyTemplate(sandbox, definition, log);
|
|
234
377
|
const snapshot = await sandbox.snapshot();
|
|
235
378
|
snapshotted = true;
|
|
236
|
-
await waitForSnapshotUpload(client, snapshot);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
379
|
+
const rootfsSha = (await waitForSnapshotUpload(client, snapshot)).rootfsSha?.trim();
|
|
380
|
+
if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha — cannot derive an immutable template version identity`);
|
|
381
|
+
const versionRef = templateContentVersionRef(definition, rootfsSha);
|
|
382
|
+
const publishClient = options.systemClient ?? client;
|
|
383
|
+
const runtime = templateRuntimeContract(definition, publishClient.ownerId ? void 0 : versionRef);
|
|
384
|
+
const existing = await resolveTemplateVersion(publishClient, versionRef);
|
|
385
|
+
if (existing) {
|
|
386
|
+
log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
|
|
387
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
|
|
388
|
+
await client.deleteSnapshot(snapshot.id).catch(() => {});
|
|
389
|
+
return {
|
|
390
|
+
templateId: definition.id,
|
|
391
|
+
versionRef,
|
|
392
|
+
definitionHash: templateDefinitionHash(definition),
|
|
393
|
+
snapshotId: existing.snapshotId,
|
|
394
|
+
reusedExisting: true
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
|
|
398
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
|
|
240
399
|
return {
|
|
241
400
|
templateId: definition.id,
|
|
242
401
|
versionRef,
|
|
243
402
|
definitionHash: templateDefinitionHash(definition),
|
|
244
|
-
snapshotId: snapshot.id
|
|
403
|
+
snapshotId: snapshot.id,
|
|
404
|
+
reusedExisting: false
|
|
245
405
|
};
|
|
246
406
|
} finally {
|
|
247
407
|
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
248
408
|
}
|
|
249
409
|
}
|
|
410
|
+
async function resolveTemplateVersion(client, versionRef) {
|
|
411
|
+
try {
|
|
412
|
+
return await client.resolveTemplate(versionRef);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
if (err instanceof SandboxApiError && err.status === 404) return void 0;
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
250
418
|
//#endregion
|
|
251
|
-
export { compileStartScript as a, download as c,
|
|
419
|
+
export { SandboxClient as _, compileStartScript as a, download as c, templateContentVersionRef as d, templateDefinitionHash as f, SandboxApiError as g, waitForSnapshotUpload as h, check as i, files as l, templateVersionRef as m, apt as n, compileStep as o, templateRuntimeContract as p, bakeTemplate as r, defineTemplate as s, applyTemplate as t, run as u, assertTemplateId as v };
|
package/dist/templates.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as compileStartScript, c as download, d as
|
|
2
|
-
export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, run, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
1
|
+
import { a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run } from "./templates-C3mBSGdT.mjs";
|
|
2
|
+
export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
package/package.json
CHANGED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hello from the fixture
|
package/src/client.test.ts
CHANGED
|
@@ -1,39 +1,104 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { SandboxClient } from "./client";
|
|
3
|
-
import { bakeTemplate, defineTemplate } from "./templates";
|
|
3
|
+
import { bakeTemplate, defineTemplate, templateContentVersionRef, templateVersionRef } from "./templates";
|
|
4
|
+
|
|
5
|
+
interface RecordedCall { method: string; path: string; body?: unknown; owner?: string | null }
|
|
6
|
+
|
|
7
|
+
// Fake infra API for bake flows. Registry versions are immutable content-
|
|
8
|
+
// addressed refs: GET on an unknown ref 404s (fresh publish), GET on a known
|
|
9
|
+
// ref returns its snapshot (reuse path). POSTing an existing immutable ref
|
|
10
|
+
// with a different snapshot would 409 in production — tests assert bakeTemplate
|
|
11
|
+
// never attempts it.
|
|
12
|
+
function fakeInfra(options: { existingVersions?: Record<string, string>; snapshotRootfsSha?: string | null } = {}) {
|
|
13
|
+
const calls: RecordedCall[] = [];
|
|
14
|
+
const existing = options.existingVersions ?? {};
|
|
15
|
+
const fakeFetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
16
|
+
const url = new URL(String(input));
|
|
17
|
+
const method = init?.method ?? "GET";
|
|
18
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
19
|
+
calls.push({ method, path: url.pathname, body, owner: new Headers(init?.headers).get("x-on-behalf-of") });
|
|
20
|
+
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
|
|
21
|
+
if (url.pathname === "/v1/sandboxes" && method === "POST") return json({ id: "sb-1" });
|
|
22
|
+
if (url.pathname.endsWith("/files")) return json({ ok: true });
|
|
23
|
+
if (url.pathname.endsWith("/snapshot")) return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z" });
|
|
24
|
+
if (url.pathname === "/v1/snapshots/snap-1" && method === "GET") {
|
|
25
|
+
const snap: Record<string, unknown> = { id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z", uploadedToTigris: true };
|
|
26
|
+
if (options.snapshotRootfsSha !== null) snap.rootfsSha = options.snapshotRootfsSha ?? "rootfs-sha-1";
|
|
27
|
+
return json(snap);
|
|
28
|
+
}
|
|
29
|
+
if (url.pathname === "/v1/snapshots/snap-1" && method === "DELETE") return new Response(null, { status: 204 });
|
|
30
|
+
const templateMatch = url.pathname.match(/^\/v1\/templates\/([^/]+)\/latest$/);
|
|
31
|
+
if (templateMatch && method === "GET") {
|
|
32
|
+
const snapshotId = existing[templateMatch[1]!];
|
|
33
|
+
if (!snapshotId) return json({ error: "template not found" }, 404);
|
|
34
|
+
return json({ templateId: templateMatch[1], snapshotId });
|
|
35
|
+
}
|
|
36
|
+
if (templateMatch && method === "POST") return json({ ok: true });
|
|
37
|
+
throw new Error(`unexpected ${method} ${url.pathname}`);
|
|
38
|
+
}) as typeof fetch;
|
|
39
|
+
return { calls, fakeFetch };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("template version identity", () => {
|
|
43
|
+
const definition = defineTemplate({
|
|
44
|
+
id: "my-agent",
|
|
45
|
+
version: "3",
|
|
46
|
+
title: "Mine",
|
|
47
|
+
install: [],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("content ref covers definition AND base rootfs", () => {
|
|
51
|
+
const ref = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
52
|
+
expect(ref).toMatch(/^my-agent-v3-[0-9a-f]{8}$/);
|
|
53
|
+
// Deterministic for identical inputs — identical content resolves to the
|
|
54
|
+
// same immutable version instead of minting duplicates.
|
|
55
|
+
expect(templateContentVersionRef(definition, "rootfs-sha-1")).toBe(ref);
|
|
56
|
+
// A rootfs change (same catalog definition) MUST mint a new identity —
|
|
57
|
+
// this is the exact collision that 409ed the Jul-18 rebake, where the
|
|
58
|
+
// static <id>-v<version> ref could not express a base-image change.
|
|
59
|
+
expect(templateContentVersionRef(definition, "rootfs-sha-2")).not.toBe(ref);
|
|
60
|
+
// A definition change also mints a new identity.
|
|
61
|
+
const bumped = defineTemplate({ ...definition, title: "Mine v2" });
|
|
62
|
+
expect(templateContentVersionRef(bumped, "rootfs-sha-1")).not.toBe(ref);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty content key fails loud instead of falling back to the static ref", () => {
|
|
66
|
+
expect(() => templateContentVersionRef(definition, "")).toThrow(/content key/);
|
|
67
|
+
expect(() => templateContentVersionRef(definition, " ")).toThrow(/content key/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("long ids truncate to fit the 40-char registry limit", () => {
|
|
71
|
+
const long = defineTemplate({ id: "a".repeat(40), version: "12", title: "Long", install: [] });
|
|
72
|
+
const ref = templateContentVersionRef(long, "rootfs-sha-1");
|
|
73
|
+
expect(ref.length).toBeLessThanOrEqual(40);
|
|
74
|
+
expect(ref).toMatch(/-v12-[0-9a-f]{8}$/);
|
|
75
|
+
expect(templateVersionRef(long).length).toBeLessThanOrEqual(40);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
4
78
|
|
|
5
79
|
describe("SandboxClient template bake", () => {
|
|
6
80
|
test("attributes ownership and publishes private version plus latest pointers", async () => {
|
|
7
|
-
const
|
|
8
|
-
const fakeFetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
9
|
-
const url = new URL(String(input));
|
|
10
|
-
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
11
|
-
calls.push({ method: init?.method ?? "GET", path: url.pathname, body, owner: new Headers(init?.headers).get("x-on-behalf-of") });
|
|
12
|
-
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
|
13
|
-
if (url.pathname === "/v1/sandboxes" && init?.method === "POST") return json({ id: "sb-1" });
|
|
14
|
-
if (url.pathname.endsWith("/files")) return json({ ok: true });
|
|
15
|
-
if (url.pathname.endsWith("/snapshot")) return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z" });
|
|
16
|
-
if (url.pathname === "/v1/snapshots/snap-1") return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z", uploadedToTigris: true });
|
|
17
|
-
if (url.pathname.startsWith("/v1/templates/")) return json({ ok: true });
|
|
18
|
-
throw new Error(`unexpected ${init?.method} ${url.pathname}`);
|
|
19
|
-
}) as typeof fetch;
|
|
81
|
+
const { calls, fakeFetch } = fakeInfra();
|
|
20
82
|
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
21
|
-
const
|
|
83
|
+
const definition = defineTemplate({
|
|
22
84
|
id: "my-agent",
|
|
23
85
|
version: "3",
|
|
24
86
|
title: "Mine",
|
|
25
87
|
ports: [8766],
|
|
26
88
|
install: [],
|
|
27
89
|
start: { command: "bun start", readiness: { port: 8766, path: "/health" } },
|
|
28
|
-
})
|
|
29
|
-
|
|
90
|
+
});
|
|
91
|
+
const expectedRef = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
92
|
+
const result = await bakeTemplate(client, definition);
|
|
93
|
+
expect(result.versionRef).toBe(expectedRef);
|
|
94
|
+
expect(result.reusedExisting).toBe(false);
|
|
30
95
|
expect(calls.every((call) => call.owner === "user-123")).toBe(true);
|
|
31
|
-
expect(calls.filter((call) => call.path.includes("/v1/templates/")).map((call) => call.path)).toEqual([
|
|
32
|
-
|
|
96
|
+
expect(calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST").map((call) => call.path)).toEqual([
|
|
97
|
+
`/v1/templates/${expectedRef}/latest`,
|
|
33
98
|
"/v1/templates/my-agent/latest",
|
|
34
99
|
]);
|
|
35
100
|
expect(calls[0]?.body).toEqual({ ports: [8766], skipAppProcesses: true, projectSlug: "my-agent" });
|
|
36
|
-
expect(calls.filter((call) => call.path.includes("/v1/templates/")).map((call) => call.body)).toEqual([
|
|
101
|
+
expect(calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST").map((call) => call.body)).toEqual([
|
|
37
102
|
{
|
|
38
103
|
snapshotId: "snap-1",
|
|
39
104
|
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
@@ -50,4 +115,46 @@ describe("SandboxClient template bake", () => {
|
|
|
50
115
|
},
|
|
51
116
|
]);
|
|
52
117
|
});
|
|
118
|
+
|
|
119
|
+
test("systemClient publishes the registry entries unscoped even when the sandbox-lifecycle client carries an ownerId", async () => {
|
|
120
|
+
const { calls, fakeFetch } = fakeInfra();
|
|
121
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "svc:template-builder", fetch: fakeFetch });
|
|
122
|
+
const systemClient = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", fetch: fakeFetch });
|
|
123
|
+
await bakeTemplate(client, defineTemplate({
|
|
124
|
+
id: "my-agent",
|
|
125
|
+
version: "3",
|
|
126
|
+
title: "Mine",
|
|
127
|
+
install: [],
|
|
128
|
+
}), { systemClient });
|
|
129
|
+
const sandboxCalls = calls.filter((call) => !call.path.includes("/v1/templates/"));
|
|
130
|
+
const templateCalls = calls.filter((call) => call.path.includes("/v1/templates/"));
|
|
131
|
+
expect(sandboxCalls.every((call) => call.owner === "svc:template-builder")).toBe(true);
|
|
132
|
+
expect(templateCalls.every((call) => call.owner === null)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an already-published content identity is reused: latest repoints, the immutable version is never republished", async () => {
|
|
136
|
+
const definition = defineTemplate({ id: "my-agent", version: "3", title: "Mine", install: [] });
|
|
137
|
+
const ref = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
138
|
+
const { calls, fakeFetch } = fakeInfra({ existingVersions: { [ref]: "snap-old" } });
|
|
139
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
140
|
+
const result = await bakeTemplate(client, definition);
|
|
141
|
+
expect(result.reusedExisting).toBe(true);
|
|
142
|
+
expect(result.versionRef).toBe(ref);
|
|
143
|
+
expect(result.snapshotId).toBe("snap-old");
|
|
144
|
+
const publishes = calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST");
|
|
145
|
+
// Never POSTs the immutable ref (would 409); only the mutable latest
|
|
146
|
+
// pointer moves, and it moves to the EXISTING version's snapshot so
|
|
147
|
+
// latest and the immutable ref stay consistent.
|
|
148
|
+
expect(publishes.map((call) => call.path)).toEqual(["/v1/templates/my-agent/latest"]);
|
|
149
|
+
expect(publishes[0]?.body).toMatchObject({ snapshotId: "snap-old" });
|
|
150
|
+
// The redundant duplicate snapshot from this bake is cleaned up.
|
|
151
|
+
expect(calls.some((call) => call.method === "DELETE" && call.path === "/v1/snapshots/snap-1")).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("missing rootfsSha fails loud instead of publishing a collision-prone static ref", async () => {
|
|
155
|
+
const { fakeFetch } = fakeInfra({ snapshotRootfsSha: null });
|
|
156
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
157
|
+
await expect(bakeTemplate(client, defineTemplate({ id: "my-agent", version: "3", title: "Mine", install: [] })))
|
|
158
|
+
.rejects.toThrow(/rootfsSha/);
|
|
159
|
+
});
|
|
53
160
|
});
|
package/src/client.ts
CHANGED
|
@@ -25,6 +25,20 @@ export interface Snapshot {
|
|
|
25
25
|
sizeBytes: number;
|
|
26
26
|
createdAt: string;
|
|
27
27
|
uploadedToTigris?: boolean;
|
|
28
|
+
/** sha256 of the base rootfs the source sandbox was created from. */
|
|
29
|
+
rootfsSha?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Error thrown by SandboxClient.request carrying the HTTP status, so callers
|
|
33
|
+
* can branch on well-defined statuses (e.g. 404 template probe) instead of
|
|
34
|
+
* string-matching error messages. */
|
|
35
|
+
export class SandboxApiError extends Error {
|
|
36
|
+
readonly status: number;
|
|
37
|
+
constructor(message: string, status: number) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "SandboxApiError";
|
|
40
|
+
this.status = status;
|
|
41
|
+
}
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export interface CreateSandboxOptions {
|
|
@@ -98,7 +112,7 @@ export class SandboxClient {
|
|
|
98
112
|
const detail = parsed && typeof parsed === "object" && "error" in parsed
|
|
99
113
|
? String((parsed as { error: unknown }).error)
|
|
100
114
|
: text || `HTTP ${response.status}`;
|
|
101
|
-
throw new
|
|
115
|
+
throw new SandboxApiError(`sandbox API ${method} ${path}: ${response.status} ${detail}`, response.status);
|
|
102
116
|
}
|
|
103
117
|
return parsed as T;
|
|
104
118
|
}
|
|
@@ -112,6 +126,10 @@ export class SandboxClient {
|
|
|
112
126
|
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
async deleteSnapshot(id: string): Promise<void> {
|
|
130
|
+
await this.request("DELETE", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
async publishTemplate(templateId: string, snapshotId: string, runtime?: TemplateRuntimeContract): Promise<void> {
|
|
116
134
|
const id = assertTemplateId(templateId);
|
|
117
135
|
await this.request("POST", `/v1/templates/${id}/latest`, { snapshotId, ...runtime });
|
package/src/templates.test.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { applyTemplate, apt, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateRuntimeContract, templateVersionRef } from "./templates";
|
|
3
4
|
import { agentTemplates } from "../../../templates/agent-catalog";
|
|
5
|
+
import type { Sandbox } from "./client";
|
|
6
|
+
|
|
7
|
+
const FIXTURE_DIR = join(import.meta.dirname, "__fixtures__", "mini-template");
|
|
8
|
+
|
|
9
|
+
function fakeSandbox(): Sandbox & { writeBatches: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>>; shellCalls: string[] } {
|
|
10
|
+
const writeBatches: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>> = [];
|
|
11
|
+
const shellCalls: string[] = [];
|
|
12
|
+
return {
|
|
13
|
+
id: "fake",
|
|
14
|
+
writeBatches,
|
|
15
|
+
shellCalls,
|
|
16
|
+
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
17
|
+
shell: async (script: string) => {
|
|
18
|
+
shellCalls.push(script);
|
|
19
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
20
|
+
},
|
|
21
|
+
writeFiles: async (batch) => {
|
|
22
|
+
writeBatches.push(batch);
|
|
23
|
+
},
|
|
24
|
+
snapshot: async () => ({ id: "snap", sizeBytes: 0, createdAt: new Date(0).toISOString() }),
|
|
25
|
+
stop: async () => {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
describe("sandbox templates", () => {
|
|
6
30
|
test("defines a typed, immutable versioned template", () => {
|
|
@@ -23,19 +47,128 @@ describe("sandbox templates", () => {
|
|
|
23
47
|
expect(() => compileStep(run({ command: "true", env: { "BAD-NAME": "x" } }))).toThrow("invalid environment variable");
|
|
24
48
|
});
|
|
25
49
|
|
|
50
|
+
test("files.copy requires non-empty sourceDir/destination", () => {
|
|
51
|
+
expect(() => files.copy({ sourceDir: "", destination: "/home/user/app" })).toThrow("sourceDir");
|
|
52
|
+
expect(() => files.copy({ sourceDir: "/tmp/x", destination: "" })).toThrow("destination");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("compileStep rejects files steps — they are applied, not compiled", () => {
|
|
56
|
+
expect(() => compileStep(files.copy({ sourceDir: "/tmp/x", destination: "/home/user/app" }))).toThrow("applyTemplate");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("applyTemplate copies a local directory into the sandbox via writeFiles, skipping .gitkeep", async () => {
|
|
60
|
+
const sandbox = fakeSandbox();
|
|
61
|
+
const definition = defineTemplate({
|
|
62
|
+
id: "files-fixture",
|
|
63
|
+
version: "1",
|
|
64
|
+
title: "files fixture",
|
|
65
|
+
install: [files.copy({ sourceDir: FIXTURE_DIR, destination: "/home/user/app/" })],
|
|
66
|
+
});
|
|
67
|
+
await applyTemplate(sandbox, definition);
|
|
68
|
+
// applyTemplate also writes the manifest (definition.json) after every
|
|
69
|
+
// install step — the files-step batch is whichever call carries our
|
|
70
|
+
// fixture's paths.
|
|
71
|
+
const scaffoldBatch = sandbox.writeBatches.find((batch) => batch.some((f) => f.path.startsWith("/home/user/app/")));
|
|
72
|
+
expect(scaffoldBatch).toBeDefined();
|
|
73
|
+
const paths = scaffoldBatch!.map((f) => f.path).sort();
|
|
74
|
+
expect(paths).toEqual(["/home/user/app/nested/hello.txt", "/home/user/app/package.json"]);
|
|
75
|
+
// trailing slash on destination is normalized, not doubled
|
|
76
|
+
expect(paths.every((p) => !p.includes("//"))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("applyTemplate throws loud when a files step's sourceDir has no files", async () => {
|
|
80
|
+
const sandbox = fakeSandbox();
|
|
81
|
+
const definition = defineTemplate({
|
|
82
|
+
id: "empty-fixture",
|
|
83
|
+
version: "1",
|
|
84
|
+
title: "empty fixture",
|
|
85
|
+
install: [files.copy({ sourceDir: join(FIXTURE_DIR, "nested", "does-not-exist"), destination: "/home/user/app" })],
|
|
86
|
+
});
|
|
87
|
+
await expect(applyTemplate(sandbox, definition)).rejects.toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("applyTemplate runs install commands out of band from the CDN request window", async () => {
|
|
91
|
+
const calls: Array<{ script: string; detached?: boolean }> = [];
|
|
92
|
+
const writes: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>> = [];
|
|
93
|
+
const sandbox: Sandbox = {
|
|
94
|
+
id: "fake",
|
|
95
|
+
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
96
|
+
shell: async (script, options = {}) => {
|
|
97
|
+
calls.push({ script, detached: options.detached });
|
|
98
|
+
if (options.detached) return { stdout: "", stderr: "", exitCode: 0, commandId: "cmd-1" };
|
|
99
|
+
if (script.includes("printf 'done")) return { stdout: "done\n0\n", stderr: "", exitCode: 0 };
|
|
100
|
+
if (script.includes("/stdout")) return { stdout: "installed\n", stderr: "", exitCode: 0 };
|
|
101
|
+
if (script.includes("/stderr")) return { stdout: "", stderr: "", exitCode: 0 };
|
|
102
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
103
|
+
},
|
|
104
|
+
writeFiles: async (batch) => { writes.push(batch); },
|
|
105
|
+
snapshot: async () => ({ id: "snap", sizeBytes: 0, createdAt: new Date(0).toISOString() }),
|
|
106
|
+
stop: async () => {},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
await applyTemplate(sandbox, defineTemplate({
|
|
110
|
+
id: "async-install",
|
|
111
|
+
version: "1",
|
|
112
|
+
title: "async install",
|
|
113
|
+
install: [run({ command: "sleep 120 && echo installed" })],
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const detached = calls.find((call) => call.detached);
|
|
117
|
+
expect(detached).toBeDefined();
|
|
118
|
+
expect(detached?.script).toContain("sleep 120 && echo installed");
|
|
119
|
+
expect(calls.some((call) => call.script.includes("printf 'done"))).toBe(true);
|
|
120
|
+
expect(calls.some((call) => call.script.includes("printf 'starting"))).toBe(true);
|
|
121
|
+
expect(writes.some((batch) => batch.some((file) => file.path.endsWith("definition.json")))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("applyTemplate preserves a detached install's failing exit status and stderr", async () => {
|
|
125
|
+
const sandbox = fakeSandbox();
|
|
126
|
+
sandbox.shell = async (script, options = {}) => {
|
|
127
|
+
if (options.detached) return { stdout: "", stderr: "", exitCode: 0, commandId: "cmd-2" };
|
|
128
|
+
if (script.includes("printf 'done")) return { stdout: "done\n42\n", stderr: "", exitCode: 0 };
|
|
129
|
+
if (script.includes("/stdout")) return { stdout: "", stderr: "", exitCode: 0 };
|
|
130
|
+
if (script.includes("/stderr")) return { stdout: "package exploded\n", stderr: "", exitCode: 0 };
|
|
131
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await expect(applyTemplate(sandbox, defineTemplate({
|
|
135
|
+
id: "async-failure",
|
|
136
|
+
version: "1",
|
|
137
|
+
title: "async failure",
|
|
138
|
+
install: [run({ command: "exit 42" })],
|
|
139
|
+
}))).rejects.toThrow("package exploded");
|
|
140
|
+
});
|
|
141
|
+
|
|
26
142
|
test("LFG owns every runtime dependency and zero-config transport default", () => {
|
|
27
143
|
const lfg = agentTemplates.lfg;
|
|
28
|
-
expect(templateVersionRef(lfg)).toBe("agent-lfg-
|
|
144
|
+
expect(templateVersionRef(lfg)).toBe("agent-lfg-v17");
|
|
29
145
|
expect(lfg.install).toContainEqual(apt.packages(["tmux"]));
|
|
30
146
|
expect(lfg.checks).toContainEqual(check.command("tmux"));
|
|
31
147
|
expect(lfg.start?.env?.LIVE_TRANSPORT).toBe("ws");
|
|
32
148
|
expect(lfg.start?.readiness?.port).toBe(8766);
|
|
33
149
|
expect(templateRuntimeContract(lfg, templateVersionRef(lfg))).toEqual({
|
|
34
|
-
immutableRef: "agent-lfg-
|
|
150
|
+
immutableRef: "agent-lfg-v17",
|
|
35
151
|
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
36
152
|
readinessPort: 8766,
|
|
37
153
|
readinessPath: "/",
|
|
38
|
-
ports: [8766],
|
|
154
|
+
ports: [8766, 5173],
|
|
39
155
|
});
|
|
40
156
|
});
|
|
157
|
+
|
|
158
|
+
test("LFG bakes the react-ts scaffold into /home/user/project at bake time", () => {
|
|
159
|
+
const lfg = agentTemplates.lfg;
|
|
160
|
+
const filesSteps = lfg.install.filter((step) => step.kind === "files");
|
|
161
|
+
expect(filesSteps).toHaveLength(1);
|
|
162
|
+
const scaffold = filesSteps[0] as Extract<(typeof filesSteps)[number], { kind: "files" }>;
|
|
163
|
+
expect(scaffold.destination).toBe("/home/user/project");
|
|
164
|
+
expect(scaffold.sourceDir.endsWith(join("templates", "react-ts"))).toBe(true);
|
|
165
|
+
// recipe.sh (shadcn init) must run against the baked scaffold, not just
|
|
166
|
+
// land on disk unused — otherwise Computers still improvise shadcn init
|
|
167
|
+
// on first turn and the whole point of baking is lost.
|
|
168
|
+
const commands = lfg.install
|
|
169
|
+
.filter((step): step is Extract<typeof step, { kind: "run" }> => step.kind === "run")
|
|
170
|
+
.map((step) => step.command);
|
|
171
|
+
expect(commands.some((c) => c.includes("recipe.sh"))).toBe(true);
|
|
172
|
+
expect(lfg.metadata?.lfgRelease).toBe("v0.1.40");
|
|
173
|
+
});
|
|
41
174
|
});
|
package/src/templates.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
2
4
|
import type { Sandbox, SandboxClient, Snapshot } from "./client.js";
|
|
3
|
-
import { assertTemplateId } from "./client.js";
|
|
5
|
+
import { assertTemplateId, SandboxApiError } from "./client.js";
|
|
4
6
|
|
|
5
7
|
export type TemplateStep =
|
|
6
8
|
| { kind: "apt"; packages: readonly string[] }
|
|
7
9
|
| { kind: "archive"; url: string; destination: string; stripComponents?: number }
|
|
8
|
-
| { kind: "run"; command: string; cwd?: string; user?: string; env?: Record<string, string>; timeoutMs?: number }
|
|
10
|
+
| { kind: "run"; command: string; cwd?: string; user?: string; env?: Record<string, string>; timeoutMs?: number }
|
|
11
|
+
// Copies a local directory (resolved by the caller at definition-authoring
|
|
12
|
+
// time — e.g. templates/react-ts) into the sandbox at bake time. Unlike the
|
|
13
|
+
// other steps this is NOT a shell script: compileStep() rejects it, and
|
|
14
|
+
// applyTemplate() special-cases it to walk sourceDir on the local
|
|
15
|
+
// filesystem (the bake runs from a full monorepo checkout) and batch
|
|
16
|
+
// sandbox.writeFiles() calls, same pattern as build-template.ts's own
|
|
17
|
+
// walkTemplate/writeFilesBatched for the legacy (non-typed) templates.
|
|
18
|
+
| { kind: "files"; sourceDir: string; destination: string };
|
|
9
19
|
|
|
10
20
|
export type TemplateCheck =
|
|
11
21
|
| { kind: "command"; command: string; user?: string }
|
|
@@ -36,6 +46,10 @@ export interface BakeResult {
|
|
|
36
46
|
versionRef: string;
|
|
37
47
|
definitionHash: string;
|
|
38
48
|
snapshotId: string;
|
|
49
|
+
/** True when this exact content identity (definition + base rootfs) was
|
|
50
|
+
* already published: the existing immutable version was reused and no new
|
|
51
|
+
* registry version was created. */
|
|
52
|
+
reusedExisting: boolean;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
export const apt = {
|
|
@@ -56,6 +70,14 @@ export function run(options: Omit<Extract<TemplateStep, { kind: "run" }>, "kind"
|
|
|
56
70
|
return { kind: "run", ...options };
|
|
57
71
|
}
|
|
58
72
|
|
|
73
|
+
export const files = {
|
|
74
|
+
copy(options: { sourceDir: string; destination: string }): TemplateStep {
|
|
75
|
+
if (!options.sourceDir.trim()) throw new Error("files.copy requires a sourceDir");
|
|
76
|
+
if (!options.destination.trim()) throw new Error("files.copy requires a destination");
|
|
77
|
+
return { kind: "files", ...options };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
59
81
|
export const check = {
|
|
60
82
|
command(command: string, options: { user?: string } = {}): TemplateCheck {
|
|
61
83
|
return { kind: "command", command, ...options };
|
|
@@ -73,11 +95,38 @@ export function defineTemplate<const T extends SandboxTemplate>(definition: T):
|
|
|
73
95
|
return Object.freeze(definition);
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
|
|
77
|
-
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
98
|
+
function refWithSuffix(id: string, suffix: string): string {
|
|
78
99
|
const maxBase = 40 - suffix.length;
|
|
79
100
|
if (maxBase < 2) throw new Error("template id and version are too long for registry");
|
|
80
|
-
return assertTemplateId(`${
|
|
101
|
+
return assertTemplateId(`${id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function templateVersionRef(definition: SandboxTemplate): string {
|
|
105
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// templateContentVersionRef is the identity a bake publishes as its immutable
|
|
109
|
+
// registry version: <id>-v<version>-<hash8>, where the hash covers BOTH the
|
|
110
|
+
// template definition and the base image the bake ran on (contentKey — the
|
|
111
|
+
// snapshot's rootfsSha). Registry versions are immutable, so the ref MUST
|
|
112
|
+
// change whenever the baked content can differ. The hand-bumped catalog
|
|
113
|
+
// `version` alone cannot express that: a rootfs/agent change alters every
|
|
114
|
+
// bake's output without touching the catalog, and republishing the same
|
|
115
|
+
// static ref (e.g. agent-claude-v2) with a new snapshot is a 409 immutability
|
|
116
|
+
// violation by design. Content-addressing the ref makes that collision
|
|
117
|
+
// structurally impossible — new content → new ref; identical content → same
|
|
118
|
+
// ref, which bakeTemplate resolves and reuses instead of republishing.
|
|
119
|
+
export function templateContentVersionRef(definition: SandboxTemplate, contentKey: string): string {
|
|
120
|
+
if (!contentKey.trim()) {
|
|
121
|
+
// No silent fallback to the static ref: an empty content key would revert
|
|
122
|
+
// to exactly the colliding identity this function exists to prevent.
|
|
123
|
+
throw new Error("template content key (base rootfs sha) is required");
|
|
124
|
+
}
|
|
125
|
+
const hash = createHash("sha256")
|
|
126
|
+
.update(`${templateDefinitionHash(definition)}\n${contentKey.trim()}`)
|
|
127
|
+
.digest("hex")
|
|
128
|
+
.slice(0, 8);
|
|
129
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}-${hash}`);
|
|
81
130
|
}
|
|
82
131
|
|
|
83
132
|
export function templateDefinitionHash(definition: SandboxTemplate): string {
|
|
@@ -96,10 +145,21 @@ export function templateRuntimeContract(definition: SandboxTemplate, immutableRe
|
|
|
96
145
|
|
|
97
146
|
function quote(value: string): string { return `'${value.replaceAll("'", `'\"'\"'`)}'`; }
|
|
98
147
|
|
|
148
|
+
// Manager.Exec (apps/infra) injects the platform LLM-proxy vars into every
|
|
149
|
+
// exec call's own root-level env, but "sudo -u <user>" resets the
|
|
150
|
+
// environment by default and (absent this) only HOME/PATH were forwarded —
|
|
151
|
+
// a template's `start` command (e.g. lfg's pi backend) would otherwise see
|
|
152
|
+
// real-Anthropic 401s instead of the sandbox proxy. Forwarding them as
|
|
153
|
+
// literal VAR=value args to env(1) is not "preserving" the invoking shell's
|
|
154
|
+
// env (which sudoers' env_reset would still block) — it is env(1) itself
|
|
155
|
+
// setting them in the de-privileged child, which needs no sudoers policy.
|
|
156
|
+
const PROXY_ENV_VARS = ["OMG_AI_URL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OMG_MEDIA_URL"];
|
|
157
|
+
|
|
99
158
|
function commandForUser(command: string, user?: string): string {
|
|
100
159
|
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
101
160
|
const home = user === "root" ? "/root" : `/home/${user}`;
|
|
102
|
-
|
|
161
|
+
const proxyEnv = PROXY_ENV_VARS.map((name) => `${name}="\${${name}:-}"`).join(" ");
|
|
162
|
+
return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} ${proxyEnv} /bin/bash -c ${quote(command)}`;
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
function shellEnvAssignments(env: Record<string, string>): string[] {
|
|
@@ -110,6 +170,9 @@ function shellEnvAssignments(env: Record<string, string>): string[] {
|
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
export function compileStep(step: TemplateStep): { script: string; timeoutMs?: number } {
|
|
173
|
+
if (step.kind === "files") {
|
|
174
|
+
throw new Error("files steps transfer local content and are applied via applyTemplate(), not compileStep()");
|
|
175
|
+
}
|
|
113
176
|
if (step.kind === "apt") {
|
|
114
177
|
return { script: `set -euo pipefail\napt-get update -qq\nDEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends ${step.packages.map(quote).join(" ")}\nrm -rf /var/lib/apt/lists/*` };
|
|
115
178
|
}
|
|
@@ -140,11 +203,127 @@ async function assertExec(label: string, result: { exitCode: number; stdout: str
|
|
|
140
203
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
// Template installs routinely outlive Cloudflare's request window (apt and
|
|
207
|
+
// package-manager steps can take several minutes). A blocking /exec request
|
|
208
|
+
// therefore makes a healthy guest look failed with a 524. Launch the command
|
|
209
|
+
// inside the guest and make its on-disk completion record the authority; every
|
|
210
|
+
// control-plane request below is short and bounded, while exit status and logs
|
|
211
|
+
// still fail loud exactly once.
|
|
212
|
+
async function runTemplateStep(
|
|
213
|
+
sandbox: Sandbox,
|
|
214
|
+
label: string,
|
|
215
|
+
script: string,
|
|
216
|
+
timeoutMs: number,
|
|
217
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
218
|
+
const workDir = `/tmp/omg-template-step-${randomUUID()}`;
|
|
219
|
+
const pidPath = `${workDir}/pid`;
|
|
220
|
+
const statusPath = `${workDir}/status`;
|
|
221
|
+
const stdoutPath = `${workDir}/stdout`;
|
|
222
|
+
const stderrPath = `${workDir}/stderr`;
|
|
223
|
+
const wrapper = [
|
|
224
|
+
"set -u",
|
|
225
|
+
`mkdir -p ${quote(workDir)}`,
|
|
226
|
+
`printf '%s\\n' \"$$\" > ${quote(pidPath)}`,
|
|
227
|
+
`set +e; /bin/bash -lc ${quote(script)} > ${quote(stdoutPath)} 2> ${quote(stderrPath)}`,
|
|
228
|
+
"code=$?",
|
|
229
|
+
`printf '%s\\n' \"$code\" > ${quote(`${statusPath}.tmp`)}`,
|
|
230
|
+
`mv ${quote(`${statusPath}.tmp`)} ${quote(statusPath)}`,
|
|
231
|
+
"exit \"$code\"",
|
|
232
|
+
].join("\n");
|
|
233
|
+
|
|
234
|
+
const prepared = await sandbox.shell(`mkdir -p ${quote(workDir)}`, { timeoutMs: 10_000 });
|
|
235
|
+
await assertExec(`${label} workspace preparation`, prepared);
|
|
236
|
+
const deadline = Date.now() + timeoutMs;
|
|
237
|
+
const launchDeadline = Math.min(deadline, Date.now() + 10_000);
|
|
238
|
+
try {
|
|
239
|
+
const started = await sandbox.shell(wrapper, { detached: true });
|
|
240
|
+
if (!started.commandId) throw new Error(`${label} did not start as a detached guest command`);
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const probe = await sandbox.shell([
|
|
243
|
+
`if test -f ${quote(statusPath)}; then`,
|
|
244
|
+
" printf 'done\\n'",
|
|
245
|
+
` cat ${quote(statusPath)}`,
|
|
246
|
+
`elif test -f ${quote(pidPath)} && kill -0 \"$(cat ${quote(pidPath)})\" 2>/dev/null; then`,
|
|
247
|
+
" printf 'running\\n'",
|
|
248
|
+
`elif test ! -f ${quote(pidPath)}; then`,
|
|
249
|
+
" printf 'starting\\n'",
|
|
250
|
+
"else",
|
|
251
|
+
" printf 'lost\\n'",
|
|
252
|
+
"fi",
|
|
253
|
+
].join("\n"), { timeoutMs: 10_000 });
|
|
254
|
+
await assertExec(`${label} status probe`, probe);
|
|
255
|
+
const [state, rawExitCode] = probe.stdout.trim().split(/\s+/, 2);
|
|
256
|
+
if (state === "done") {
|
|
257
|
+
const exitCode = Number(rawExitCode);
|
|
258
|
+
if (!Number.isInteger(exitCode)) throw new Error(`${label} wrote an invalid exit status: ${JSON.stringify(rawExitCode)}`);
|
|
259
|
+
const stdout = await sandbox.shell(`cat ${quote(stdoutPath)} 2>/dev/null || true`, { timeoutMs: 10_000 });
|
|
260
|
+
const stderr = await sandbox.shell(`cat ${quote(stderrPath)} 2>/dev/null || true`, { timeoutMs: 10_000 });
|
|
261
|
+
await assertExec(`${label} stdout read`, stdout);
|
|
262
|
+
await assertExec(`${label} stderr read`, stderr);
|
|
263
|
+
return { exitCode, stdout: stdout.stdout, stderr: stderr.stdout };
|
|
264
|
+
}
|
|
265
|
+
if (state === "lost") throw new Error(`${label} guest process exited without a completion record`);
|
|
266
|
+
if (state === "starting" && Date.now() >= launchDeadline) throw new Error(`${label} guest process never initialized`);
|
|
267
|
+
if (state !== "running" && state !== "starting") throw new Error(`${label} returned an invalid guest state: ${JSON.stringify(state)}`);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
269
|
+
}
|
|
270
|
+
await sandbox.shell(`test ! -f ${quote(pidPath)} || kill \"$(cat ${quote(pidPath)})\" 2>/dev/null || true`, { timeoutMs: 10_000 }).catch(() => {});
|
|
271
|
+
throw new Error(`${label} exceeded its ${timeoutMs}ms guest execution deadline`);
|
|
272
|
+
} finally {
|
|
273
|
+
await sandbox.shell(`rm -rf ${quote(workDir)}`, { timeoutMs: 10_000 }).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
278
|
+
|
|
279
|
+
async function walkSourceDir(dir: string): Promise<Array<{ relPath: string; buf: Buffer }>> {
|
|
280
|
+
const out: Array<{ relPath: string; buf: Buffer }> = [];
|
|
281
|
+
async function visit(d: string): Promise<void> {
|
|
282
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
const full = join(d, entry.name);
|
|
285
|
+
if (entry.isDirectory()) {
|
|
286
|
+
await visit(full);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
|
|
290
|
+
out.push({ relPath: relative(dir, full), buf: await readFile(full) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
await visit(dir);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function applyFilesStep(
|
|
298
|
+
sandbox: Sandbox,
|
|
299
|
+
step: Extract<TemplateStep, { kind: "files" }>,
|
|
300
|
+
onLog: (line: string) => void,
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
const walked = await walkSourceDir(step.sourceDir);
|
|
303
|
+
if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
|
|
304
|
+
const destination = step.destination.replace(/\/$/, "");
|
|
305
|
+
const batchSize = 50;
|
|
306
|
+
for (let i = 0; i < walked.length; i += batchSize) {
|
|
307
|
+
const batch = walked.slice(i, i + batchSize).map((f) => ({
|
|
308
|
+
path: `${destination}/${f.relPath}`,
|
|
309
|
+
content: f.buf,
|
|
310
|
+
mode: 0o644,
|
|
311
|
+
}));
|
|
312
|
+
await sandbox.writeFiles(batch);
|
|
313
|
+
}
|
|
314
|
+
onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
143
317
|
export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplate, onLog: (line: string) => void = () => {}): Promise<void> {
|
|
144
318
|
for (const [index, step] of definition.install.entries()) {
|
|
145
319
|
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
320
|
+
if (step.kind === "files") {
|
|
321
|
+
await applyFilesStep(sandbox, step, onLog);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
146
324
|
const compiled = compileStep(step);
|
|
147
|
-
|
|
325
|
+
const label = `template step ${index + 1}`;
|
|
326
|
+
await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 60_000));
|
|
148
327
|
}
|
|
149
328
|
if (definition.start) {
|
|
150
329
|
await sandbox.writeFiles([{ path: "/home/user/.omg/template/bootstrap.sh", content: compileStartScript(definition.start), mode: 0o755 }]);
|
|
@@ -157,33 +336,76 @@ export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplat
|
|
|
157
336
|
}
|
|
158
337
|
}
|
|
159
338
|
|
|
160
|
-
export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<
|
|
339
|
+
export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<Snapshot> {
|
|
161
340
|
const deadline = Date.now() + timeoutMs;
|
|
162
341
|
while (Date.now() < deadline) {
|
|
163
|
-
|
|
342
|
+
const uploaded = await client.getSnapshot(snapshot.id);
|
|
343
|
+
if (uploaded.uploadedToTigris) return uploaded;
|
|
164
344
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
165
345
|
}
|
|
166
346
|
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
167
347
|
}
|
|
168
348
|
|
|
169
|
-
export async function bakeTemplate(
|
|
349
|
+
export async function bakeTemplate(
|
|
350
|
+
client: SandboxClient,
|
|
351
|
+
definition: SandboxTemplate,
|
|
352
|
+
options: { onLog?: (line: string) => void; publishLatest?: boolean; systemClient?: SandboxClient } = {},
|
|
353
|
+
): Promise<BakeResult> {
|
|
170
354
|
const log = options.onLog ?? (() => {});
|
|
171
|
-
const versionRef = templateVersionRef(definition);
|
|
172
355
|
const sandbox = await client.create({ ports: [...(definition.ports ?? [])], skipAppProcesses: true, projectSlug: definition.id });
|
|
173
356
|
let snapshotted = false;
|
|
174
357
|
try {
|
|
175
358
|
await applyTemplate(sandbox, definition, log);
|
|
176
359
|
const snapshot = await sandbox.snapshot();
|
|
177
360
|
snapshotted = true;
|
|
178
|
-
await waitForSnapshotUpload(client, snapshot);
|
|
361
|
+
const uploaded = await waitForSnapshotUpload(client, snapshot);
|
|
362
|
+
// The immutable version ref is content-addressed: definition hash + the
|
|
363
|
+
// base rootfs the bake actually ran on (recorded on the snapshot). See
|
|
364
|
+
// templateContentVersionRef — this is what lets a rootfs/agent-triggered
|
|
365
|
+
// rebake mint a NEW version instead of 409ing against the previous one.
|
|
366
|
+
// Fail loud if the API did not report a rootfs sha; falling back to the
|
|
367
|
+
// static <id>-v<version> ref would reintroduce the collision.
|
|
368
|
+
const rootfsSha = uploaded.rootfsSha?.trim();
|
|
369
|
+
if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha — cannot derive an immutable template version identity`);
|
|
370
|
+
const versionRef = templateContentVersionRef(definition, rootfsSha);
|
|
179
371
|
// A client with ownerId acts on behalf of a user and publishes into that
|
|
180
372
|
// user's private namespace. Only the direct service client may mark a
|
|
181
|
-
// global system version immutable.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
373
|
+
// global system version immutable. `client` may need an ownerId just to
|
|
374
|
+
// pass the sandbox-ownership check on create/exec/snapshot (a bare
|
|
375
|
+
// service-token call with no owner has caller="" and 403s there) — that
|
|
376
|
+
// is orthogonal to whether THIS bake should publish globally. Callers
|
|
377
|
+
// baking an official, system-wide catalog template (not a private/scoped
|
|
378
|
+
// one) pass options.systemClient (no ownerId) so the registry publish
|
|
379
|
+
// itself lands in the global namespace regardless of what identity was
|
|
380
|
+
// needed upstream for the sandbox lifecycle calls.
|
|
381
|
+
const publishClient = options.systemClient ?? client;
|
|
382
|
+
const runtime = templateRuntimeContract(definition, publishClient.ownerId ? undefined : versionRef);
|
|
383
|
+
// Same content identity already published (an unchanged re-run, or a
|
|
384
|
+
// partially-failed run being re-executed after some templates landed):
|
|
385
|
+
// registry versions are immutable, so NEVER republish the ref. Reuse the
|
|
386
|
+
// existing version's snapshot — repoint only the mutable latest pointer
|
|
387
|
+
// at it, and drop this run's redundant duplicate snapshot (best-effort;
|
|
388
|
+
// an orphan is only storage, never correctness).
|
|
389
|
+
const existing = await resolveTemplateVersion(publishClient, versionRef);
|
|
390
|
+
if (existing) {
|
|
391
|
+
log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
|
|
392
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
|
|
393
|
+
await client.deleteSnapshot(snapshot.id).catch(() => {});
|
|
394
|
+
return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: existing.snapshotId, reusedExisting: true };
|
|
395
|
+
}
|
|
396
|
+
await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
|
|
397
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
|
|
398
|
+
return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: snapshot.id, reusedExisting: false };
|
|
186
399
|
} finally {
|
|
187
400
|
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
188
401
|
}
|
|
189
402
|
}
|
|
403
|
+
|
|
404
|
+
async function resolveTemplateVersion(client: SandboxClient, versionRef: string): Promise<{ snapshotId: string } | undefined> {
|
|
405
|
+
try {
|
|
406
|
+
return await client.resolveTemplate(versionRef);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (err instanceof SandboxApiError && err.status === 404) return undefined;
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
}
|