@zenera/cli 1.1.0 → 1.1.2
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/commands/check.js +47 -14
- package/dist/commands/sandbox.js +226 -22
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +9 -2
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +1 -1
- package/dist/main.js +0 -0
- package/dist/podman.d.ts +57 -1
- package/dist/podman.js +177 -12
- package/dist/projects.d.ts +18 -0
- package/dist/projects.js +60 -1
- package/dist/sandbox.d.ts +12 -1
- package/dist/sandbox.js +35 -6
- package/dist/scaffold.d.ts +6 -0
- package/dist/scaffold.js +49 -11
- package/dist/term.d.ts +2 -0
- package/dist/term.js +14 -0
- package/dist/validate.d.ts +20 -3
- package/dist/validate.js +306 -11
- package/package.json +2 -2
- package/templates/.github/copilot-instructions.md +154 -42
- package/templates/.github/prompts/new-skill.prompt.md +13 -6
- package/templates/sandbox/Dockerfile +19 -0
package/dist/podman.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { runProcess } from '@zenera/neo';
|
|
2
|
+
import type { ResolvedBuild } from './image.ts';
|
|
3
|
+
import { CliError } from './term.ts';
|
|
2
4
|
export interface PodmanOptions {
|
|
3
5
|
/** the image the project needs on disk before the first run */
|
|
4
6
|
image?: string;
|
|
7
|
+
/** a Dockerfile to build into `image`, instead of a registry to pull it from */
|
|
8
|
+
build?: ResolvedBuild;
|
|
9
|
+
/** build even when the tag is already on disk; `zen sandbox pull` */
|
|
10
|
+
rebuild?: boolean;
|
|
5
11
|
/** machine size, when one has to be created */
|
|
6
12
|
cpus?: number;
|
|
7
13
|
/** MiB */
|
|
@@ -28,12 +34,36 @@ export interface PodmanStatus {
|
|
|
28
34
|
imagePresent?: boolean;
|
|
29
35
|
}
|
|
30
36
|
export declare function ensurePodmanReady(opts?: PodmanOptions): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Builds the project's Dockerfile under its tag.
|
|
39
|
+
*
|
|
40
|
+
* Run every time rather than skipped when the tag already exists, because the
|
|
41
|
+
* tag is a hash of the Dockerfile and its context and the engine's layer cache
|
|
42
|
+
* is a hash of the same thing plus the base image. Asking it is cheap, and it
|
|
43
|
+
* is the only thing that notices when `FROM node:24` starts meaning a different
|
|
44
|
+
* node:24.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* A build that ran and failed, rather than a host that could not be asked.
|
|
48
|
+
*
|
|
49
|
+
* The difference is invisible at the command line — both stop the run and
|
|
50
|
+
* print why — but it decides everything for `zen check`: a Dockerfile that
|
|
51
|
+
* does not build is a broken project, and a laptop without podman on it is
|
|
52
|
+
* not. Only one of them is an error.
|
|
53
|
+
*/
|
|
54
|
+
export declare class BuildError extends CliError {
|
|
55
|
+
}
|
|
31
56
|
/** What `zn sandbox status` prints. Changes nothing, and never throws. */
|
|
32
57
|
export declare function podmanStatus(opts?: PodmanOptions): Promise<PodmanStatus>;
|
|
33
58
|
export interface OwnedContainer {
|
|
34
59
|
name: string;
|
|
35
60
|
/** podman's own word: `running`, `exited`, `created`, `paused` */
|
|
36
61
|
state: string;
|
|
62
|
+
/** the session that owns it, from the `zenera.key` label */
|
|
63
|
+
key?: string;
|
|
64
|
+
createdAt?: string;
|
|
65
|
+
/** bytes written on top of the image; only present when `sizes` is asked for */
|
|
66
|
+
size?: number;
|
|
37
67
|
}
|
|
38
68
|
/**
|
|
39
69
|
* Containers this CLI created, whatever session they belong to, and whether
|
|
@@ -41,6 +71,32 @@ export interface OwnedContainer {
|
|
|
41
71
|
* *stopped* container behind, and a listing that only showed running ones
|
|
42
72
|
* would say nothing is there while the disk says otherwise.
|
|
43
73
|
*/
|
|
44
|
-
export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner
|
|
74
|
+
export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner, opts?: {
|
|
75
|
+
sizes?: boolean;
|
|
76
|
+
}): Promise<OwnedContainer[]>;
|
|
45
77
|
export declare function removeContainers(names: readonly string[], engine?: string, exec?: import("@zenera/neo").Runner): Promise<void>;
|
|
78
|
+
export interface DiskLine {
|
|
79
|
+
count: number;
|
|
80
|
+
active: number;
|
|
81
|
+
size: number;
|
|
82
|
+
reclaimable: number;
|
|
83
|
+
}
|
|
84
|
+
export interface EngineDisk {
|
|
85
|
+
images: DiskLine;
|
|
86
|
+
containers: DiskLine;
|
|
87
|
+
volumes: DiskLine;
|
|
88
|
+
/** the filesystem images and layers are kept on — inside the machine, if there is one */
|
|
89
|
+
store?: {
|
|
90
|
+
used: number;
|
|
91
|
+
capacity: number;
|
|
92
|
+
};
|
|
93
|
+
/** the machine's disk image, as it costs this host; absent on Linux */
|
|
94
|
+
image?: {
|
|
95
|
+
name: string;
|
|
96
|
+
path: string;
|
|
97
|
+
allocated: number;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** What the engine is using. Never throws: a disk report is not worth a crash. */
|
|
101
|
+
export declare function engineDisk(engine?: string, exec?: import("@zenera/neo").Runner): Promise<EngineDisk | undefined>;
|
|
46
102
|
//# sourceMappingURL=podman.d.ts.map
|
package/dist/podman.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { platform } from 'node:os';
|
|
2
1
|
import { runProcess, SandboxError } from '@zenera/neo';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
|
|
4
6
|
const DEFAULT_MACHINE_CPUS = 2;
|
|
5
7
|
const DEFAULT_MACHINE_MEMORY = 2048;
|
|
6
8
|
/** Starting a virtual machine and pulling an image are both slow on purpose. */
|
|
7
9
|
const SLOW_MS = 600_000;
|
|
10
|
+
/** ...and a build from a cold cache is slower than either. */
|
|
11
|
+
const BUILD_MS = 900_000;
|
|
8
12
|
// One process asks once. Several agents starting containers in the same run
|
|
9
13
|
// must not each decide to boot a virtual machine.
|
|
10
14
|
const settled = new Map();
|
|
@@ -62,7 +66,10 @@ async function preflight(opts) {
|
|
|
62
66
|
}
|
|
63
67
|
// 4. The image, so the first command is not a five-minute pull that looks
|
|
64
68
|
// like a hung model.
|
|
65
|
-
if (opts.
|
|
69
|
+
if (opts.build) {
|
|
70
|
+
await build(engine, opts.build, run, opts.rebuild);
|
|
71
|
+
}
|
|
72
|
+
else if (opts.image) {
|
|
66
73
|
const present = await call(['image', 'exists', opts.image], 30_000);
|
|
67
74
|
if (present.code !== 0) {
|
|
68
75
|
note(dim(`pulling ${opts.image} — this happens once`));
|
|
@@ -158,6 +165,52 @@ function parseMachines(stdout) {
|
|
|
158
165
|
}
|
|
159
166
|
}
|
|
160
167
|
// ---------------------------------------------------------------------------
|
|
168
|
+
// The image
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/**
|
|
171
|
+
* Builds the project's Dockerfile under its tag.
|
|
172
|
+
*
|
|
173
|
+
* Run every time rather than skipped when the tag already exists, because the
|
|
174
|
+
* tag is a hash of the Dockerfile and its context and the engine's layer cache
|
|
175
|
+
* is a hash of the same thing plus the base image. Asking it is cheap, and it
|
|
176
|
+
* is the only thing that notices when `FROM node:24` starts meaning a different
|
|
177
|
+
* node:24.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* A build that ran and failed, rather than a host that could not be asked.
|
|
181
|
+
*
|
|
182
|
+
* The difference is invisible at the command line — both stop the run and
|
|
183
|
+
* print why — but it decides everything for `zen check`: a Dockerfile that
|
|
184
|
+
* does not build is a broken project, and a laptop without podman on it is
|
|
185
|
+
* not. Only one of them is an error.
|
|
186
|
+
*/
|
|
187
|
+
export class BuildError extends CliError {
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Builds the project's Dockerfile under its tag, unless that tag is already on
|
|
191
|
+
* disk.
|
|
192
|
+
*
|
|
193
|
+
* Skipping is safe here in a way it would not be for an ordinary tag, because
|
|
194
|
+
* this one is a hash of the Dockerfile and its context: the image existing
|
|
195
|
+
* *means* the content is unchanged. What it does not cover is a moved base
|
|
196
|
+
* image — `podman build` defaults to `--pull=missing` and reuses whatever
|
|
197
|
+
* `FROM node:24` resolved to last time — so `zen sandbox pull` forces the
|
|
198
|
+
* build, and `--pull` is where that would be fixed if it ever needs to be.
|
|
199
|
+
*/
|
|
200
|
+
async function build(engine, spec, run, force = false) {
|
|
201
|
+
if (!force) {
|
|
202
|
+
const present = await run(engine, ['image', 'exists', spec.tag], { timeoutMs: 30_000 });
|
|
203
|
+
if (present.code === 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
note(dim(`building ${spec.tag} from ${spec.dockerfile}`));
|
|
208
|
+
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
|
|
209
|
+
if (built.code !== 0) {
|
|
210
|
+
throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
161
214
|
// Reporting
|
|
162
215
|
// ---------------------------------------------------------------------------
|
|
163
216
|
/** What `zn sandbox status` prints. Changes nothing, and never throws. */
|
|
@@ -210,19 +263,41 @@ function safeMachines(stdout) {
|
|
|
210
263
|
* *stopped* container behind, and a listing that only showed running ones
|
|
211
264
|
* would say nothing is there while the disk says otherwise.
|
|
212
265
|
*/
|
|
213
|
-
export async function ownedContainers(engine = 'podman', exec = runProcess) {
|
|
214
|
-
const
|
|
266
|
+
export async function ownedContainers(engine = 'podman', exec = runProcess, opts = {}) {
|
|
267
|
+
const args = ['ps', '--all', '--filter', 'label=zenera=1', '--format', 'json'];
|
|
268
|
+
// Asked for by name only: podman works a size out by diffing the layer,
|
|
269
|
+
// which costs more than everything else `status` does put together.
|
|
270
|
+
if (opts.sizes) {
|
|
271
|
+
args.push('--size');
|
|
272
|
+
}
|
|
273
|
+
const res = await exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
|
|
215
274
|
if (!res || res.code !== 0) {
|
|
216
275
|
return [];
|
|
217
276
|
}
|
|
218
|
-
return res.stdout
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
277
|
+
return parseContainers(res.stdout);
|
|
278
|
+
}
|
|
279
|
+
/** Newest first, which is the order someone reading a list of them wants. */
|
|
280
|
+
function parseContainers(stdout) {
|
|
281
|
+
let raw;
|
|
282
|
+
try {
|
|
283
|
+
raw = JSON.parse(stdout.trim() || '[]');
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(raw)) {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
return raw
|
|
292
|
+
.map((c) => ({
|
|
293
|
+
name: c.Names?.[0] ?? '',
|
|
294
|
+
state: c.State?.trim() || 'unknown',
|
|
295
|
+
key: c.Labels?.['zenera.key'],
|
|
296
|
+
createdAt: c.Created ? new Date(c.Created * 1000).toISOString() : undefined,
|
|
297
|
+
size: c.Size?.rwSize,
|
|
298
|
+
}))
|
|
299
|
+
.filter((c) => c.name)
|
|
300
|
+
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
|
226
301
|
}
|
|
227
302
|
export async function removeContainers(names, engine = 'podman', exec = runProcess) {
|
|
228
303
|
if (names.length === 0) {
|
|
@@ -230,6 +305,91 @@ export async function removeContainers(names, engine = 'podman', exec = runProce
|
|
|
230
305
|
}
|
|
231
306
|
await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
|
|
232
307
|
}
|
|
308
|
+
/** What the engine is using. Never throws: a disk report is not worth a crash. */
|
|
309
|
+
export async function engineDisk(engine = 'podman', exec = runProcess) {
|
|
310
|
+
const call = (args) => exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
|
|
311
|
+
const res = await call(['system', 'df', '--format', 'json']);
|
|
312
|
+
if (!res || res.code !== 0) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
let raw;
|
|
316
|
+
try {
|
|
317
|
+
raw = JSON.parse(res.stdout.trim() || '[]');
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
323
|
+
const pick = (type) => {
|
|
324
|
+
const row = rows.find((r) => r.Type === type);
|
|
325
|
+
return {
|
|
326
|
+
count: row?.TotalCount ?? row?.Total ?? 0,
|
|
327
|
+
active: row?.Active ?? 0,
|
|
328
|
+
size: row?.RawSize ?? 0,
|
|
329
|
+
reclaimable: row?.RawReclaimable ?? 0,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const info = await call([
|
|
333
|
+
'info',
|
|
334
|
+
'--format',
|
|
335
|
+
'{{.Store.GraphRootUsed}}\t{{.Store.GraphRootAllocated}}',
|
|
336
|
+
]);
|
|
337
|
+
const [used, capacity] = (info?.code === 0 ? info.stdout.trim() : '').split('\t').map(Number);
|
|
338
|
+
return {
|
|
339
|
+
images: pick('Images'),
|
|
340
|
+
containers: pick('Containers'),
|
|
341
|
+
volumes: pick('Local Volumes'),
|
|
342
|
+
store: used > 0 && capacity > 0 ? { used, capacity } : undefined,
|
|
343
|
+
image: platform() === 'linux' ? undefined : await machineImage(engine, exec),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* What the machine costs the host, which is not what it says it costs: the
|
|
348
|
+
* disk image is created sparse at its full size, so only its allocated blocks
|
|
349
|
+
* are real, and blocks freed inside the machine are not handed back until
|
|
350
|
+
* something trims them. That is why this can exceed the machine's own `used`.
|
|
351
|
+
*
|
|
352
|
+
* The path is not something podman will tell us — `machine inspect` stopped
|
|
353
|
+
* carrying it — so this is the documented default location and nothing is
|
|
354
|
+
* reported when the file is not there.
|
|
355
|
+
*/
|
|
356
|
+
async function machineImage(engine, exec) {
|
|
357
|
+
const listed = await exec(engine, ['machine', 'list', '--format', 'json'], {
|
|
358
|
+
timeoutMs: 30_000,
|
|
359
|
+
}).catch(() => undefined);
|
|
360
|
+
if (!listed || listed.code !== 0) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
const machines = safeMachines(listed.stdout);
|
|
364
|
+
const name = (machines.find((m) => m.Default) ?? machines[0])?.Name;
|
|
365
|
+
if (!name) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const base = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'containers', 'podman', 'machine');
|
|
369
|
+
for (const provider of children(base)) {
|
|
370
|
+
for (const file of children(join(base, provider))) {
|
|
371
|
+
if (!file.startsWith(name) || !/\.(raw|qcow2|img)$/.test(file)) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const path = join(base, provider, file);
|
|
375
|
+
try {
|
|
376
|
+
return { name, path, allocated: statSync(path).blocks * 512 };
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
function children(dir) {
|
|
386
|
+
try {
|
|
387
|
+
return readdirSync(dir);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
233
393
|
// ---------------------------------------------------------------------------
|
|
234
394
|
/**
|
|
235
395
|
* Long steps print as they go. A five-minute pull with no output is
|
|
@@ -248,6 +408,11 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
|
248
408
|
function first(res) {
|
|
249
409
|
return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
|
|
250
410
|
}
|
|
411
|
+
/** A build says what went wrong on its last line, not its first. */
|
|
412
|
+
function last(res) {
|
|
413
|
+
const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
|
|
414
|
+
return lines.slice(-2).join(' — ');
|
|
415
|
+
}
|
|
251
416
|
function sandboxError(message, hint) {
|
|
252
417
|
return new CliError(message, EXIT.sandbox, hint);
|
|
253
418
|
}
|
package/dist/projects.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
|
|
1
2
|
/** Whether a directory is a project: whether the loader has something to read. */
|
|
2
3
|
export declare function isProjectDir(dir: string): boolean;
|
|
3
4
|
/** A project resolved on disk. */
|
|
@@ -6,6 +7,16 @@ export interface Project {
|
|
|
6
7
|
/** what it is called: the registry's name, or the directory's own */
|
|
7
8
|
name: string;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The trees a run mounts besides the workspace itself.
|
|
12
|
+
*
|
|
13
|
+
* One array, handed to the file tools and to the container both, because the
|
|
14
|
+
* two have to agree on the name: a path `run_command` prints has to be a path
|
|
15
|
+
* `read_file` takes. Everything here is read-only — material an agent consults
|
|
16
|
+
* and does not edit — and the paths are resolved, because the podman machine on
|
|
17
|
+
* macOS shares the real path or nothing.
|
|
18
|
+
*/
|
|
19
|
+
export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
|
|
9
20
|
/** Walks up from `start` looking for a project configuration. */
|
|
10
21
|
export declare function findUp(start: string): Promise<Project | undefined>;
|
|
11
22
|
export declare function openDir(dir: string): Promise<Project>;
|
|
@@ -59,6 +70,13 @@ export declare const sessionsDir: (projectDir: string) => string;
|
|
|
59
70
|
/** Session ids, oldest first. Only well-formed stamps count as sessions. */
|
|
60
71
|
export declare function sessionIds(projectDir: string): string[];
|
|
61
72
|
export declare function runIds(sessionDir: string): string[];
|
|
73
|
+
/**
|
|
74
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
75
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
76
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
77
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
78
|
+
*/
|
|
79
|
+
export declare function dirSize(dir: string): number;
|
|
62
80
|
/** True when a session's lock names a process that is still alive. */
|
|
63
81
|
export declare function isBusy(sessionDir: string): boolean;
|
|
64
82
|
/**
|
package/dist/projects.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
4
|
import { paths, readJson, writeJson } from "./home.js";
|
|
4
5
|
import { isStamp } from "./ids.js";
|
|
@@ -25,6 +26,29 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
|
|
|
25
26
|
export function isProjectDir(dir) {
|
|
26
27
|
return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The trees a run mounts besides the workspace itself.
|
|
31
|
+
*
|
|
32
|
+
* One array, handed to the file tools and to the container both, because the
|
|
33
|
+
* two have to agree on the name: a path `run_command` prints has to be a path
|
|
34
|
+
* `read_file` takes. Everything here is read-only — material an agent consults
|
|
35
|
+
* and does not edit — and the paths are resolved, because the podman machine on
|
|
36
|
+
* macOS shares the real path or nothing.
|
|
37
|
+
*/
|
|
38
|
+
export function projectMounts(root, config) {
|
|
39
|
+
const mounts = [];
|
|
40
|
+
const assets = assetsDir(root, config);
|
|
41
|
+
if (assets) {
|
|
42
|
+
mounts.push({ host: realpathSync(assets), at: ASSETS_MOUNT, readOnly: true });
|
|
43
|
+
}
|
|
44
|
+
// The catalog goes in whole, at fixed names, before anything is loaded: a
|
|
45
|
+
// container's mounts are decided when it is created, so a skill folder
|
|
46
|
+
// cannot be added at the moment `skill_load` asks for it.
|
|
47
|
+
for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
|
|
48
|
+
mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
|
|
49
|
+
}
|
|
50
|
+
return mounts;
|
|
51
|
+
}
|
|
28
52
|
// ---------------------------------------------------------------------------
|
|
29
53
|
// The project directory
|
|
30
54
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +225,41 @@ function stampedChildren(dir) {
|
|
|
201
225
|
.map((e) => e.name)
|
|
202
226
|
.sort();
|
|
203
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
230
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
231
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
232
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
233
|
+
*/
|
|
234
|
+
export function dirSize(dir) {
|
|
235
|
+
let total = 0;
|
|
236
|
+
const stack = [dir];
|
|
237
|
+
while (stack.length > 0) {
|
|
238
|
+
const at = stack.pop();
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const path = join(at, entry.name);
|
|
248
|
+
if (entry.isDirectory()) {
|
|
249
|
+
stack.push(path);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
total += lstatSync(path).blocks * 512;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Gone between the listing and the stat: a live session is
|
|
257
|
+
// writing, which is normal and not worth failing a report for.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return total;
|
|
262
|
+
}
|
|
204
263
|
/** True when a session's lock names a process that is still alive. */
|
|
205
264
|
export function isBusy(sessionDir) {
|
|
206
265
|
const path = join(sessionDir, '.lock');
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxSpec } from '@zenera/neo';
|
|
1
|
+
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
|
|
2
|
+
import { type ResolvedBuild } from './image.ts';
|
|
2
3
|
import type { SessionPaths } from './session.ts';
|
|
3
4
|
export interface SandboxSetup {
|
|
4
5
|
pool: SandboxPool;
|
|
5
6
|
/** the resolved base spec, for `zn sandbox status` and the image pre-warm */
|
|
6
7
|
spec: SandboxSpec;
|
|
7
8
|
image: string;
|
|
9
|
+
/** the Dockerfile behind `image`, when it came from one */
|
|
10
|
+
build?: ResolvedBuild;
|
|
8
11
|
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
9
12
|
home: string;
|
|
10
13
|
}
|
|
@@ -12,7 +15,15 @@ export interface SandboxInputs {
|
|
|
12
15
|
config: ProjectConfig;
|
|
13
16
|
session: SessionPaths;
|
|
14
17
|
workspace: string;
|
|
18
|
+
/** the project directory, which `build:` paths are relative to */
|
|
19
|
+
root: string;
|
|
15
20
|
readOnly?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Trees to mount besides the workspace and the home — the project's assets
|
|
23
|
+
* and its skill catalog. The same array goes to the file tools, so a path
|
|
24
|
+
* a command prints is a path `read_file` accepts.
|
|
25
|
+
*/
|
|
26
|
+
mounts?: readonly SandboxMount[];
|
|
16
27
|
/** `--image` */
|
|
17
28
|
image?: string;
|
|
18
29
|
}
|
package/dist/sandbox.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
1
2
|
import { mkdirSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
+
import { resolveBuild } from "./image.js";
|
|
4
5
|
import { ensurePodmanReady } from "./podman.js";
|
|
5
6
|
import { warn } from "./term.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -23,14 +24,25 @@ import { warn } from "./term.js";
|
|
|
23
24
|
/** Where the persistent home lives, inside the container. */
|
|
24
25
|
const HOME = '/home/agent';
|
|
25
26
|
export function buildSandbox(opts) {
|
|
26
|
-
|
|
27
|
+
// An explicit --image is an answer, so there is nothing left to build.
|
|
28
|
+
const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
|
|
29
|
+
const base = {
|
|
30
|
+
...(opts.config.sandbox ?? {}),
|
|
31
|
+
...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
|
|
32
|
+
};
|
|
27
33
|
const home = join(opts.session.data, 'sandbox', 'home');
|
|
28
|
-
const mounts = [{ host: home, at: HOME }];
|
|
29
|
-
|
|
34
|
+
const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
|
|
35
|
+
// Skills and assets are mounted read-only, and a python script run from a
|
|
36
|
+
// read-only directory fails on writing its own `__pycache__` — a confusing
|
|
37
|
+
// error about a file nobody asked for.
|
|
38
|
+
const spec = toSpec(base, { HOME, PYTHONDONTWRITEBYTECODE: '1' });
|
|
30
39
|
const agents = {};
|
|
31
40
|
for (const agent of opts.config.agents) {
|
|
32
41
|
if (agent.sandbox) {
|
|
33
|
-
agents[agent.name] = toSpec(
|
|
42
|
+
agents[agent.name] = toSpec(merge(base, agent.sandbox), {
|
|
43
|
+
HOME,
|
|
44
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
45
|
+
});
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
const pool = new SandboxPool({
|
|
@@ -41,7 +53,23 @@ export function buildSandbox(opts) {
|
|
|
41
53
|
readOnly: opts.readOnly,
|
|
42
54
|
mounts,
|
|
43
55
|
});
|
|
44
|
-
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, home };
|
|
56
|
+
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* An agent's overrides on the project's block. `image` and `build` answer the
|
|
60
|
+
* same question, so naming either one drops the other: a plain spread would
|
|
61
|
+
* leave an agent's `image` sitting next to the project's `build`, and the
|
|
62
|
+
* schema forbids exactly that combination when it is written down.
|
|
63
|
+
*/
|
|
64
|
+
function merge(base, agent) {
|
|
65
|
+
const merged = { ...base, ...agent };
|
|
66
|
+
if (agent.image !== undefined) {
|
|
67
|
+
delete merged.build;
|
|
68
|
+
}
|
|
69
|
+
if (agent.build !== undefined) {
|
|
70
|
+
delete merged.image;
|
|
71
|
+
}
|
|
72
|
+
return merged;
|
|
45
73
|
}
|
|
46
74
|
/**
|
|
47
75
|
* Config names a variable; this reads it. A name that is not set on this host
|
|
@@ -87,6 +115,7 @@ export async function preflight(setup, yes) {
|
|
|
87
115
|
mkdirSync(setup.home, { recursive: true });
|
|
88
116
|
await ensurePodmanReady({
|
|
89
117
|
image: setup.image,
|
|
118
|
+
build: setup.build,
|
|
90
119
|
cpus: setup.spec.cpus,
|
|
91
120
|
memory: setup.spec.memory,
|
|
92
121
|
yes,
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export declare function editorSettings(dir: string): string;
|
|
|
11
11
|
* one is the only one worth having. Returns the relative paths written.
|
|
12
12
|
*/
|
|
13
13
|
export declare function copilotInstructions(dir: string): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
|
|
16
|
+
* the editor files this becomes the project's own — it is meant to be edited —
|
|
17
|
+
* so anything already there is left alone.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sandboxTemplate(dir: string): string[];
|
|
14
20
|
export interface ScaffoldOptions {
|
|
15
21
|
/** the project directory */
|
|
16
22
|
dir: string;
|
package/dist/scaffold.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -65,14 +65,26 @@ version: 1
|
|
|
65
65
|
|
|
66
66
|
${MODEL_SECTION(model, options)}
|
|
67
67
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
68
|
+
# Anything in assets/ is mounted read-only at /assets for every agent: they can
|
|
69
|
+
# read, list and search it, and no tool can change it. It is a convention, so
|
|
70
|
+
# the folder is enough — set \`assets: <path>\` only to keep the material
|
|
71
|
+
# somewhere else in this project.
|
|
72
|
+
|
|
73
|
+
# The container \`sandbox:*\` commands run in. \`build:\` names a Dockerfile to
|
|
74
|
+
# build instead of an image to pull, so anything this project always needs is
|
|
75
|
+
# in the image rather than installed by an agent on every run — put it in
|
|
76
|
+
# sandbox/Dockerfile. Swap the whole block for \`image: <ref>\` to pull a
|
|
77
|
+
# published one instead; the two cannot both be set.
|
|
78
|
+
#
|
|
79
|
+
# \`persist: true\` keeps the container between runs rather than throwing it
|
|
80
|
+
# away, so what an agent installs for itself is still there next time —
|
|
81
|
+
# otherwise only /workspace and its home directory survive. \`zen sandbox
|
|
72
82
|
# clean\` removes the ones left behind. Everything else has a default; see the
|
|
73
|
-
# sandbox: block in docs/agents-yaml.md to size
|
|
83
|
+
# sandbox: block in docs/agents-yaml.md to size it.
|
|
74
84
|
sandbox:
|
|
75
85
|
persist: true
|
|
86
|
+
build:
|
|
87
|
+
dockerfile: sandbox/Dockerfile
|
|
76
88
|
|
|
77
89
|
agents:
|
|
78
90
|
- name: default
|
|
@@ -111,6 +123,19 @@ const GITIGNORE = `# Sessions hold run state, memory, blobs and whatever the age
|
|
|
111
123
|
# None of it is source.
|
|
112
124
|
sessions/
|
|
113
125
|
`;
|
|
126
|
+
const ASSETS_README = `# assets
|
|
127
|
+
|
|
128
|
+
Everything in this folder is mounted at /assets when an agent runs. Every agent
|
|
129
|
+
in this project can read, list and search it, and no tool of theirs can change
|
|
130
|
+
it — so this is where reference material goes: handbooks, specifications,
|
|
131
|
+
schemas, worked examples, the style guide the output is supposed to follow.
|
|
132
|
+
|
|
133
|
+
It is the project's own files that agents get without being asked. The
|
|
134
|
+
workspace they are pointed at is the work; this is what they consult while
|
|
135
|
+
doing it.
|
|
136
|
+
|
|
137
|
+
Delete this file once there is something here to read.
|
|
138
|
+
`;
|
|
114
139
|
// ---------------------------------------------------------------------------
|
|
115
140
|
// Telling the editor which instructions are not for it
|
|
116
141
|
//
|
|
@@ -163,6 +188,7 @@ export function editorSettings(dir) {
|
|
|
163
188
|
// adding a skill or a prompt file is adding a file there and nothing else.
|
|
164
189
|
// ---------------------------------------------------------------------------
|
|
165
190
|
const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.meta.url));
|
|
191
|
+
const SANDBOX_TEMPLATE = fileURLToPath(new URL('../templates/sandbox', import.meta.url));
|
|
166
192
|
/**
|
|
167
193
|
* Writes the `.github/` tree under `dir`, replacing what is there — it
|
|
168
194
|
* describes the file formats of the version of `zen` in hand, so the current
|
|
@@ -171,11 +197,19 @@ const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.met
|
|
|
171
197
|
export function copilotInstructions(dir) {
|
|
172
198
|
return copyTree(GITHUB_TEMPLATE, dir, '.github');
|
|
173
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
|
|
202
|
+
* the editor files this becomes the project's own — it is meant to be edited —
|
|
203
|
+
* so anything already there is left alone.
|
|
204
|
+
*/
|
|
205
|
+
export function sandboxTemplate(dir) {
|
|
206
|
+
return copyTree(SANDBOX_TEMPLATE, dir, 'sandbox', { keep: true });
|
|
207
|
+
}
|
|
174
208
|
/**
|
|
175
209
|
* Copies one template directory into `dir` at `rel`, depth first, sorted so
|
|
176
210
|
* the list it returns is the same on every machine.
|
|
177
211
|
*/
|
|
178
|
-
function copyTree(from, dir, rel) {
|
|
212
|
+
function copyTree(from, dir, rel, opts) {
|
|
179
213
|
const written = [];
|
|
180
214
|
mkdirSync(join(dir, rel), { recursive: true });
|
|
181
215
|
const entries = readdirSync(from, { withFileTypes: true });
|
|
@@ -183,12 +217,14 @@ function copyTree(from, dir, rel) {
|
|
|
183
217
|
for (const entry of entries) {
|
|
184
218
|
const child = join(rel, entry.name);
|
|
185
219
|
if (entry.isDirectory()) {
|
|
186
|
-
written.push(...copyTree(join(from, entry.name), dir, child));
|
|
220
|
+
written.push(...copyTree(join(from, entry.name), dir, child, opts));
|
|
221
|
+
continue;
|
|
187
222
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
written.push(child);
|
|
223
|
+
if (opts?.keep && existsSync(join(dir, child))) {
|
|
224
|
+
continue;
|
|
191
225
|
}
|
|
226
|
+
writeFileSync(join(dir, child), readFileSync(join(from, entry.name)));
|
|
227
|
+
written.push(child);
|
|
192
228
|
}
|
|
193
229
|
return written;
|
|
194
230
|
}
|
|
@@ -211,7 +247,9 @@ export function scaffold(opts) {
|
|
|
211
247
|
put('INSTRUCTIONS.md', INSTRUCTIONS_MD);
|
|
212
248
|
put('agents.yaml', AGENTS_YAML(opts.model, opts.modelOptions, opts.web));
|
|
213
249
|
put(join('agents', 'prompts', 'default.md'), PROMPT);
|
|
250
|
+
put(join('assets', 'README.md'), ASSETS_README);
|
|
214
251
|
put('.gitignore', GITIGNORE);
|
|
252
|
+
written.push(...sandboxTemplate(opts.dir));
|
|
215
253
|
// The project directory is what `zen open` opens, so this is where the
|
|
216
254
|
// editor actually reads them.
|
|
217
255
|
written.push(editorSettings(opts.dir), ...copilotInstructions(opts.dir));
|
package/dist/term.d.ts
CHANGED
|
@@ -66,4 +66,6 @@ export declare function choose<T>(title: string, choices: readonly Choice<T>[]):
|
|
|
66
66
|
export declare function readStdin(): Promise<string | undefined>;
|
|
67
67
|
export declare function ago(iso: string | undefined): string;
|
|
68
68
|
export declare function count(n: number, singular: string, plural?: string): string;
|
|
69
|
+
/** Powers of 1000, as the container engine prints them, so the two agree. */
|
|
70
|
+
export declare function bytes(n: number): string;
|
|
69
71
|
//# sourceMappingURL=term.d.ts.map
|