@zenera/gym 0.0.0-stage → 1.1.39

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Ryabov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,201 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <img
3
+ src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/zenera-logo-with-media-queries.svg"
4
+ alt="Zenera Logo"
5
+ width="20"
6
+ />
7
+ <div>Powered By Zenera</div>
8
+ </div>
9
+ <br>
2
10
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
11
+ <div align="center">
12
+ <img src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/banner-gym-with-media-queries.svg" alt="ZENERA GYM" width="738">
13
+
14
+ [![npm](https://img.shields.io/npm/v/@zenera/gym.svg)](https://www.npmjs.com/package/@zenera/gym)
15
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/zenera-ai/ZeneraNeo/blob/main/LICENSE)
16
+ [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](https://nodejs.org)
17
+ [![CI](https://github.com/zenera-ai/ZeneraNeo/actions/workflows/ci.yml/badge.svg)](https://github.com/zenera-ai/ZeneraNeo/actions/workflows/ci.yml)
18
+
19
+ [Install](#install) · [Use](#use) · [Commands](#commands) · [Credentials](#credentials) · [Family](#the-rest-of-the-family) · [License](#license)
20
+
21
+ </div>
22
+
23
+ # @zenera/gym
24
+
25
+ **Simulated API environments for agent training and evaluation. Point Gym at
26
+ a Swagger/OpenAPI spec and it serves it - the response bodies are written,
27
+ once, by a model.**
28
+
29
+ Gym currently provides the API simulation layer, not a training loop, task
30
+ scoring, or rewards.
31
+
32
+ > Part of [ZeneraNeo](https://github.com/zenera-ai/ZeneraNeo). It ships no
33
+ > binary of its own: installing it adds a `gym` **subcommand** to
34
+ > [`zen`](https://github.com/zenera-ai/ZeneraNeo/blob/main/packages/cli/README.md),
35
+ > which is also where the credentials already are.
36
+
37
+ ## What you get
38
+
39
+ - **A mock server from a spec** - point it at one or more swagger/OpenAPI
40
+ documents and every operation answers.
41
+ - **Bodies written once** - a model writes a Python generator per operation,
42
+ checked against the response schema; after that no request costs a token.
43
+ - **Stable answers** - pin a `--seed` and the same request returns the same body.
44
+ - **Lists that end** - paging tokens are recognised, walked and cut, so a client
45
+ never loops forever.
46
+
47
+ ## Install
48
+
49
+ Node.js 24+ and [podman](https://podman.io). Install it alongside the CLI:
50
+
51
+ ```sh
52
+ npm i -g @zenera/cli @zenera/gym
53
+ zen key add openai # the keyring `zen` already uses
54
+ ```
55
+
56
+ The first start builds one image; every start after that reuses it. `zen --help`
57
+ lists `gym` whether or not it is installed, and says what to run if not.
58
+
59
+ ## Use
60
+
61
+ ```sh
62
+ zen gym serve api/openapi.yaml --port 8787
63
+ curl -s localhost:8787/users/12324
64
+ # { "user_id": 12324, "email": "brooke.hoffman@example.org", ... }
65
+ ```
66
+
67
+ More than one document at a time is fine, and the same request answers the same
68
+ way every time when you pin a seed:
69
+
70
+ ```sh
71
+ zen gym serve specs/*.yaml --seed 42
72
+ ```
73
+
74
+ Warm it up before a demo or a test run, so no request pays for a model turn:
75
+
76
+ ```sh
77
+ zen gym build api/openapi.yaml # write every generator now and exit
78
+ zen gym cache ls # what has been generated
79
+ zen gym cache clear # throw it away
80
+ ```
81
+
82
+ A path key with a query string on it — `/x/{id}?action=retry`, which some
83
+ vendors write when they need two `post`s under one path — is read for what
84
+ it means rather than taken verbatim: the path is the part before the `?`, and
85
+ each pair after it becomes a required query value that tells those operations
86
+ apart. Call one without it and the 404 says which queries the path answers to.
87
+
88
+ ## How a body is produced
89
+
90
+ The first time an operation is called, Gym asks a model to write a
91
+ **Python generator** for it - one file, taking a JSON input path and a JSON
92
+ output path. That file is then run against sample requests it makes up, and
93
+ judged twice: against the operation's response schema, and against the echo
94
+ rule, which says that where a path or query parameter shares a name with a
95
+ property in the response, the response has to carry the value that was asked
96
+ for. `GET /users/12324` answering with somebody else's id validates perfectly
97
+ and is still wrong.
98
+
99
+ If it fails, the diagnostics go back to the model and it tries again, up to
100
+ `--attempts` (five by default). A build that does not come good leaves a
101
+ **report** in `<cache>/builds/` - one markdown file per build, every rejected
102
+ attempt in it with its diagnostics and its whole source, and the `501` that the
103
+ request gets links to it, in the log line and in the body. The next attempt
104
+ overwrites `gen.py`, and a generator that merely hung leaves no traceback, so
105
+ the code is the only evidence there is. If it passes, the generator is kept in
106
+ this machine's shared cache under `~/.zenera/neo/cache/gym-generator/`, keyed
107
+ by the operation's shape, and every later request is just
108
+ `podman exec python3 gen.py in.json out.json` - no model, no tokens. The store
109
+ is the machine's, so the same document served from another directory costs
110
+ nothing the second time.
111
+
112
+ Generators run in a container with **no network**, on an image baked once with
113
+ `faker`, `exrex`, `jsonschema` and `python-dateutil`.
114
+
115
+ ## Pages that end
116
+
117
+ A list endpoint is the one place a mock can hang a real client. Given
118
+ `?cursor=abc`, the honest-looking answer is a body that validates, echoes
119
+ nothing it shouldn't, and hands back `abc` again - so the client asks for the
120
+ same page forever.
121
+
122
+ Gym reads the document for this. Where an operation has a paging
123
+ parameter (`cursor`, `page`, `offset`, `page_token`, …) in query or request body
124
+ and a response property that carries the next one (`next`, `next_cursor`, `has_more`, …),
125
+ three things happen, all in the operation's own names:
126
+
127
+ - the model is told to fabricate **between 1 and 10 pages** in total, to build the token
128
+ out of the paging parameter rather than the seed, and to end the list - null,
129
+ absent, or `has_more: false` (and empty items) where the schema leaves no other room;
130
+ - the generator is then **walked**: Gym calls it with no cursor, follows
131
+ the token it gets back, and rejects the file if the token repeats, cycles, or
132
+ never runs out. The diagnostics say which, and the model gets another go;
133
+ - at request time, pagination is tracked and bounded: a token identical to the one
134
+ just sent is **cut** (nulled or dropped), cycles are stopped, and streams are capped
135
+ to random 1-10 pages, returning empty results if a client loops past the end. The
136
+ request line narrates each cut. Nothing is invented in its place; a generator written
137
+ before this rule existed is still cached, and a cache is not rebuilt because a rule changed.
138
+
139
+ Only paginated operations are affected. Their cache keys changed once, so they
140
+ are written again on first use; everything else keeps the key it had.
141
+ `GET /__gym/routes` reports the shape that was recognised, per operation.
142
+
143
+ Plenty of documents describe the envelope and never write down the parameter
144
+ that reads it back. The first two steps cannot help there - nothing static can
145
+ see a parameter that is not declared - but the cut still applies: it takes the
146
+ paging parameter from query parameters or request body, since a client only sends
147
+ `cursor=X` because a body handed it X.
148
+
149
+ ## Commands
150
+
151
+ ```
152
+ zen gym serve <spec...> Serve them. Generators are written on demand.
153
+ zen gym build <spec...> Write every generator now and exit.
154
+ zen gym cache ls | clear What has been generated, or throw it away.
155
+ zen gym model [ref] Show or set the model it uses.
156
+ ```
157
+
158
+ The model is looked for in this order: `--model`, `ZENERA_GYM_MODEL`, what
159
+ `zen gym model <provider>:<id>` stored, then the best one your keys can buy.
160
+ The stored one lives beside the keyring in `~/.zenera/neo/gym.json`, so it is
161
+ remembered between runs and across projects; `zen gym model --clear` forgets
162
+ it.
163
+
164
+ Useful options: `--port`, `--host` (reachable only from this machine by
165
+ default), `--model`, `--seed` (same request, same answer), `--rebuild`,
166
+ `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
167
+ workspace), `--quiet`. `zen help gym` prints the full table.
168
+
169
+ `GET /` is a contents page: every operation the mock is serving, grouped by the
170
+ document it came from, with its parameters and the shape of its answer. Each
171
+ one has a **Run** button, which opens a form — a box per parameter, required
172
+ ones marked, a body prefilled with something the mock will accept — and shows
173
+ the status, the time and the answer. `GET /__gym/routes` is the same thing as
174
+ JSON; `GET /__gym/health` is a health check. A document that declares `/`
175
+ itself keeps it.
176
+
177
+ ## Credentials
178
+
179
+ The keyring is `zen`'s, so there is nothing new to configure:
180
+
181
+ ```sh
182
+ zen key add openai
183
+ zen key ls --check
184
+ ```
185
+
186
+ Environment variables still win over the keyring, exactly as they do for `zen`.
187
+
188
+ ## The rest of the family
189
+
190
+ | Package | What it is |
191
+ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------- |
192
+ | [`@zenera/cli`](https://github.com/zenera-ai/ZeneraNeo/blob/main/packages/cli/README.md) | `zen` - agent projects on the command line |
193
+ | [`@zenera/neo`](https://github.com/zenera-ai/ZeneraNeo/blob/main/packages/neo/README.md) | the runtime - agents, models, tools, skills, memory |
194
+ | **`@zenera/gym`** | `zen gym` - a mock API from an openapi/swagger document |
195
+ | [`@zenera/rag`](https://github.com/zenera-ai/ZeneraNeo/blob/main/packages/rag/README.md) | `zen rag` - indexing and search for documents and API schemas |
196
+ | [`@zenera/view`](https://github.com/zenera-ai/ZeneraNeo/blob/main/packages/view/README.md) | `zen view` - a live local viewer for a folder |
197
+
198
+ ## License
199
+
200
+ Early days and moving fast - issues, questions and pull requests are welcome.
201
+ [MIT](LICENSE).
package/dist/box.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { type Runner, type Sandbox } from '@zenera/neo';
2
+ export declare const GENERATORS = "generators";
3
+ /** Every gym container's name starts with this; `cache clear` relies on it. */
4
+ export declare const CONTAINER_PREFIX = "zn-gym-";
5
+ export interface BoxOptions {
6
+ /** host directory mounted at /workspace; holds generators/ and io/ */
7
+ root: string;
8
+ image: string;
9
+ /** seconds one generator may take */
10
+ timeout?: number;
11
+ engine?: string;
12
+ exec?: Runner;
13
+ /** the process the container belongs to; defaults to this one */
14
+ owner?: number;
15
+ }
16
+ /**
17
+ * The container engine could not run the generator at all. Nothing is known
18
+ * about the generator from this, so it must never be fed back to a model as a
19
+ * fault in the code: rewriting a working file cannot bring a container back.
20
+ */
21
+ export declare class SandboxUnavailable extends Error {
22
+ readonly hint: string;
23
+ readonly container: string;
24
+ readonly stderr?: string;
25
+ constructor(message: string, opts: {
26
+ hint: string;
27
+ container: string;
28
+ stderr?: string;
29
+ });
30
+ }
31
+ export interface Outcome {
32
+ ok: boolean;
33
+ /** parsed `output.json`, when the run produced one */
34
+ value?: unknown;
35
+ /** what went wrong, in the words the build loop feeds back to the model */
36
+ fault?: string;
37
+ stderr?: string;
38
+ durationMs: number;
39
+ }
40
+ export declare class Box {
41
+ #private;
42
+ readonly root: string;
43
+ constructor(opts: BoxOptions);
44
+ get sandbox(): Sandbox;
45
+ /**
46
+ * Removes this process's container if a previous owner of the pid left one,
47
+ * and every gym container whose owning process is gone. Belt to
48
+ * `persist: false`'s braces: a hard kill never runs `dispose`, and the
49
+ * leftover is exactly the stale-mount trap above. A container whose owner
50
+ * is alive is another server's, and is never touched.
51
+ */
52
+ fresh(): Promise<void>;
53
+ /** Host path of a generator's source file. */
54
+ sourceOf(key: string): string;
55
+ write(key: string, source: string): Promise<void>;
56
+ /**
57
+ * One generator, one input, one output. The io directory is removed
58
+ * afterwards whatever happened — a mock server left alone for a week must
59
+ * not fill a disk with request envelopes.
60
+ *
61
+ * Throws `SandboxUnavailable` when the engine, not the generator, failed.
62
+ */
63
+ run(key: string, input: unknown): Promise<Outcome>;
64
+ dispose(): Promise<void>;
65
+ }
66
+ //# sourceMappingURL=box.d.ts.map
package/dist/box.js ADDED
@@ -0,0 +1,259 @@
1
+ import { SANDBOX_MOUNT, SandboxError, SandboxPool, pidAlive, runProcess, } from '@zenera/neo';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdirSync, rmSync } from 'node:fs';
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ // ---------------------------------------------------------------------------
7
+ // Where generators run
8
+ //
9
+ // One container for the whole process, offline, with the gym's own directory
10
+ // bind-mounted at /workspace. That single mount is what makes the file contract
11
+ // work in both directions: the host writes `input.json` and reads
12
+ // `output.json`, the generator sees the same two paths from inside, and neither
13
+ // side has to serialise anything through a pipe.
14
+ //
15
+ // Nothing the model wrote ever reaches an argument on this side. The generator
16
+ // is invoked by a script built here out of paths derived here, and the script
17
+ // itself travels on stdin — the same rule the sandbox tools follow.
18
+ // ---------------------------------------------------------------------------
19
+ export const GENERATORS = 'generators';
20
+ const IO = 'io';
21
+ const ENTRY = 'gen.py';
22
+ /** Every gym container's name starts with this; `cache clear` relies on it. */
23
+ export const CONTAINER_PREFIX = 'zn-gym-';
24
+ /** `zn-gym-<pid>-<digest>`; a name without the pid predates per-process containers. */
25
+ const OWNED_BY = /^zn-gym-(\d+)-[0-9a-f]+$/;
26
+ /** Pause before the one retry of an engine failure, long enough for a concurrent rm to finish. */
27
+ const ENGINE_RETRY_MS = 300;
28
+ /**
29
+ * The container engine could not run the generator at all. Nothing is known
30
+ * about the generator from this, so it must never be fed back to a model as a
31
+ * fault in the code: rewriting a working file cannot bring a container back.
32
+ */
33
+ export class SandboxUnavailable extends Error {
34
+ hint;
35
+ container;
36
+ stderr;
37
+ constructor(message, opts) {
38
+ super(message);
39
+ this.name = 'SandboxUnavailable';
40
+ this.hint = opts.hint;
41
+ this.container = opts.container;
42
+ this.stderr = opts.stderr;
43
+ }
44
+ }
45
+ export class Box {
46
+ root;
47
+ #pool;
48
+ #timeout;
49
+ #engine;
50
+ #exec;
51
+ #owner;
52
+ constructor(opts) {
53
+ this.root = opts.root;
54
+ this.#timeout = opts.timeout ?? 30;
55
+ this.#engine = opts.engine ?? 'podman';
56
+ this.#exec = opts.exec ?? runProcess;
57
+ this.#owner = opts.owner ?? process.pid;
58
+ mkdirSync(join(opts.root, GENERATORS), { recursive: true });
59
+ mkdirSync(join(opts.root, IO), { recursive: true });
60
+ this.#pool = new SandboxPool({
61
+ root: opts.root,
62
+ // One container per process. The name is otherwise a pure function
63
+ // of the configuration, so two servers on the same --cache shared
64
+ // one container, and each start (`fresh`) or exit (`dispose`)
65
+ // removed it from under the other: `exited 125, no such container`.
66
+ key: `gym-${this.#owner}`,
67
+ image: opts.image,
68
+ // The one line that keeps model-written code from calling home.
69
+ network: 'none',
70
+ workdir: SANDBOX_MOUNT,
71
+ timeout: this.#timeout,
72
+ // Deliberately NOT persisted, unlike `zen`'s sandbox. A container's
73
+ // name is a hash of its configuration, which includes the host
74
+ // path but not the directory behind it — so a cache directory that
75
+ // is deleted and recreated (`zen gym cache clear`, or any rm -rf)
76
+ // gets a stopped container reattached whose bind mount still points
77
+ // at the old inode. Everything written here is then invisible
78
+ // inside, and every generator fails with "can't open file".
79
+ // `zen` persists to keep `pip install`s; the libraries here are
80
+ // baked into the image, so there is nothing to keep.
81
+ persist: false,
82
+ readOnly: false,
83
+ // Deliberately empty: the keyring is in this process's environment
84
+ // and none of it belongs in the container.
85
+ env: {},
86
+ engine: opts.engine,
87
+ exec: opts.exec,
88
+ });
89
+ }
90
+ get sandbox() {
91
+ return this.#pool.for();
92
+ }
93
+ /**
94
+ * Removes this process's container if a previous owner of the pid left one,
95
+ * and every gym container whose owning process is gone. Belt to
96
+ * `persist: false`'s braces: a hard kill never runs `dispose`, and the
97
+ * leftover is exactly the stale-mount trap above. A container whose owner
98
+ * is alive is another server's, and is never touched.
99
+ */
100
+ async fresh() {
101
+ const listed = await this.#exec(this.#engine, ['ps', '--all', '--filter', `name=^${CONTAINER_PREFIX}`, '--format', '{{.Names}}'], { timeoutMs: 60_000 }).catch(() => undefined);
102
+ const orphans = (listed?.code === 0 ? listed.stdout.split('\n') : [])
103
+ .map((n) => n.trim())
104
+ .filter((name) => {
105
+ const pid = Number(OWNED_BY.exec(name)?.[1]);
106
+ return Number.isInteger(pid) && pid !== this.#owner && !pidAlive(pid);
107
+ });
108
+ await this.#exec(this.#engine, ['rm', '--force', '--volumes', this.sandbox.name, ...orphans], { timeoutMs: 60_000 }).catch(() => undefined);
109
+ }
110
+ /** Host path of a generator's source file. */
111
+ sourceOf(key) {
112
+ return join(this.root, GENERATORS, key, ENTRY);
113
+ }
114
+ async write(key, source) {
115
+ mkdirSync(join(this.root, GENERATORS, key), { recursive: true });
116
+ await writeFile(this.sourceOf(key), source, 'utf8');
117
+ }
118
+ /**
119
+ * One generator, one input, one output. The io directory is removed
120
+ * afterwards whatever happened — a mock server left alone for a week must
121
+ * not fill a disk with request envelopes.
122
+ *
123
+ * Throws `SandboxUnavailable` when the engine, not the generator, failed.
124
+ */
125
+ async run(key, input) {
126
+ const id = randomUUID();
127
+ const dir = join(this.root, IO, id);
128
+ mkdirSync(dir, { recursive: true });
129
+ const started = Date.now();
130
+ try {
131
+ await writeFile(join(dir, 'input.json'), JSON.stringify(input), 'utf8');
132
+ const script = [
133
+ `exec python3 ${inside(GENERATORS, key, ENTRY)}`,
134
+ inside(IO, id, 'input.json'),
135
+ inside(IO, id, 'output.json'),
136
+ ].join(' ');
137
+ let res = await this.#exec1(script);
138
+ if (engineFault(res)) {
139
+ // `exec` already brought a vanished container back once; a
140
+ // second fault is usually a concurrent rm still settling.
141
+ await new Promise((settle) => setTimeout(settle, ENGINE_RETRY_MS));
142
+ res = await this.#exec1(script);
143
+ }
144
+ const fault = engineFault(res);
145
+ if (fault) {
146
+ throw this.#unavailable(fault, res.stderr.trim() || res.stdout.trim());
147
+ }
148
+ const stderr = res.stderr.trim();
149
+ if (res.timed_out) {
150
+ return {
151
+ ok: false,
152
+ fault: `took longer than ${this.#timeout}s`,
153
+ stderr,
154
+ durationMs: Date.now() - started,
155
+ };
156
+ }
157
+ if (res.exit_code !== 0) {
158
+ return {
159
+ ok: false,
160
+ fault: `exited ${res.exit_code}`,
161
+ stderr: stderr || res.stdout.trim(),
162
+ durationMs: Date.now() - started,
163
+ };
164
+ }
165
+ let text;
166
+ try {
167
+ text = await readFile(join(dir, 'output.json'), 'utf8');
168
+ }
169
+ catch {
170
+ return {
171
+ ok: false,
172
+ fault: 'wrote no output file',
173
+ stderr,
174
+ durationMs: Date.now() - started,
175
+ };
176
+ }
177
+ try {
178
+ return {
179
+ ok: true,
180
+ value: JSON.parse(text),
181
+ stderr,
182
+ durationMs: Date.now() - started,
183
+ };
184
+ }
185
+ catch (err) {
186
+ return {
187
+ ok: false,
188
+ fault: `output.json is not JSON: ${err instanceof Error ? err.message : String(err)}`,
189
+ stderr,
190
+ durationMs: Date.now() - started,
191
+ };
192
+ }
193
+ }
194
+ finally {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ }
198
+ async dispose() {
199
+ await this.#pool.dispose();
200
+ }
201
+ async #exec1(script) {
202
+ try {
203
+ return await this.sandbox.exec(script, { timeout: this.#timeout });
204
+ }
205
+ catch (err) {
206
+ if (err instanceof SandboxError) {
207
+ throw new SandboxUnavailable(`${this.#engine} could not run the generator: ${err.message}`, {
208
+ hint: err.hint ??
209
+ `check \`${this.#engine} ps -a --filter name=${CONTAINER_PREFIX}\``,
210
+ container: this.sandbox.name,
211
+ });
212
+ }
213
+ throw err;
214
+ }
215
+ }
216
+ #unavailable(fault, stderr) {
217
+ const name = this.sandbox.name;
218
+ const first = stderr.split('\n')[0] ?? '';
219
+ const hint = fault === 'missing-file'
220
+ ? `the generator is on the host but not inside ${name}, so its bind mount is stale ` +
221
+ `(the --cache directory was deleted and recreated while the server ran). ` +
222
+ `Restart \`zen gym serve\`.`
223
+ : fault === 'no-python'
224
+ ? `the image has no python3. Rebuild it: \`zen gym serve --rebuild …\`, or pass a working --image.`
225
+ : `${name} was removed or stopped while this server was using it, twice in a row. ` +
226
+ `Usual causes: \`zen sandbox clean\` or \`zen gym cache clear\` run meanwhile, ` +
227
+ `a ${this.#engine} machine restart, or an older \`zen gym\` (before per-process ` +
228
+ `containers) started on the same --cache. Check \`${this.#engine} ps -a --filter ` +
229
+ `name=${CONTAINER_PREFIX}\` and \`${this.#engine} machine list\`; the next request ` +
230
+ `recreates the container.`;
231
+ return new SandboxUnavailable(`${this.#engine} could not run the generator in ${name} — ${first || fault} ` +
232
+ `(an engine failure, not a fault in the generator; nothing was regenerated)`, { hint, container: name, stderr: stderr || undefined });
233
+ }
234
+ }
235
+ /**
236
+ * A failure of the container rather than of the code in it. 125 is podman's
237
+ * own exit code for "could not exec at all"; the other two are our command
238
+ * line failing before a line of the generator ran.
239
+ */
240
+ function engineFault(res) {
241
+ if (res.timed_out) {
242
+ return undefined;
243
+ }
244
+ if (res.exit_code === 125) {
245
+ return 'engine';
246
+ }
247
+ if (res.exit_code === 2 && /can't open file '\/workspace\/generators\//.test(res.stderr)) {
248
+ return 'missing-file';
249
+ }
250
+ if (res.exit_code === 127 && /python3: (not found|No such file)/.test(res.stderr)) {
251
+ return 'no-python';
252
+ }
253
+ return undefined;
254
+ }
255
+ /** A path inside the container. Every segment is derived here, never given. */
256
+ function inside(...parts) {
257
+ return [SANDBOX_MOUNT, ...parts].join('/');
258
+ }
259
+ //# sourceMappingURL=box.js.map
@@ -0,0 +1,87 @@
1
+ import type { Model } from '@zenera/neo';
2
+ import type { Box } from './box.ts';
3
+ import type { GeneratorInput } from './envelope.ts';
4
+ import { BuildFailed } from './generate.ts';
5
+ import type { ExampleRequest } from './prompt.ts';
6
+ import { type Operation } from './spec.ts';
7
+ import type { Checks } from './validate.ts';
8
+ export declare const GYM_KIND = "gym-generator";
9
+ /** What is kept about a generator besides the code, for `zen gym cache ls`. */
10
+ export interface GeneratorMeta {
11
+ operationId?: string;
12
+ method?: string;
13
+ path?: string;
14
+ source?: string;
15
+ model?: string;
16
+ attempts?: number;
17
+ rulesVersion?: number;
18
+ createdAt?: string;
19
+ }
20
+ export interface Generator {
21
+ key: string;
22
+ source: string;
23
+ /** whether it came out of the cache rather than out of a model */
24
+ cached: boolean;
25
+ }
26
+ export interface CacheEvent {
27
+ operation: Operation;
28
+ attempt?: number;
29
+ diagnostics?: readonly string[];
30
+ /** where the rejected generator was written down, when it was */
31
+ dump?: string;
32
+ }
33
+ export interface CacheOptions {
34
+ box: Box;
35
+ checks: Checks;
36
+ model: Model;
37
+ attempts?: number;
38
+ /**
39
+ * How many generators may be written at once. A client that walks every
40
+ * route — which is exactly what people do to a new mock — would otherwise
41
+ * open one model request per operation simultaneously and be rate limited
42
+ * on all of them.
43
+ */
44
+ concurrency?: number;
45
+ /** ignore what is cached and write fresh */
46
+ rebuild?: boolean;
47
+ /** run generators but keep nothing */
48
+ ephemeral?: boolean;
49
+ /** keep them somewhere other than the shared store */
50
+ cacheDir?: string;
51
+ /** where rejected generators are written down. Default `<box root>/builds` */
52
+ dumpDir?: string;
53
+ onStart?: (e: CacheEvent) => void;
54
+ onAttempt?: (e: CacheEvent) => void;
55
+ onReady?: (e: CacheEvent & {
56
+ cached: boolean;
57
+ attempts: number;
58
+ }) => void;
59
+ onFail?: (e: CacheEvent & {
60
+ error: Error;
61
+ }) => void;
62
+ }
63
+ export interface CacheRegenerateContext {
64
+ currentSource: string;
65
+ failingInput: GeneratorInput;
66
+ fault: string;
67
+ stderr?: string;
68
+ examples?: readonly ExampleRequest[];
69
+ }
70
+ export declare class Cache {
71
+ #private;
72
+ constructor(opts: CacheOptions);
73
+ /**
74
+ * The generator for an operation, written if it does not exist yet. Callers
75
+ * that arrive during a build wait for that build rather than starting one.
76
+ *
77
+ * `cached` answers "did this call cost a model turn", which is what the
78
+ * response header is for — so it is false for the caller that triggered the
79
+ * build and for everyone who waited on it, and true from then on.
80
+ */
81
+ ensure(operation: Operation): Promise<Generator>;
82
+ get model(): Model;
83
+ getSource(key: string): Promise<string | undefined>;
84
+ regenerate(operation: Operation, ctx: CacheRegenerateContext): Promise<Generator>;
85
+ }
86
+ export { BuildFailed };
87
+ //# sourceMappingURL=cache.d.ts.map