@zenera/gym 0.0.0-stage → 1.1.38
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 +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
package/dist/cache.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { Cache as Store } from '@zenera/cli/lib';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { build, BuildFailed, regenerate } from "./generate.js";
|
|
4
|
+
import { saveBuildDump } from "./logger.js";
|
|
5
|
+
import { GENERATOR_RULES_VERSION } from "./spec.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The cache
|
|
8
|
+
//
|
|
9
|
+
// Two layers over one identity. `Operation.key` is a function of the
|
|
10
|
+
// operation's shape, so a spec edit produces a new key and the old artefact is
|
|
11
|
+
// simply never asked for again — there is nothing to invalidate, which is the
|
|
12
|
+
// part of a cache that is usually wrong.
|
|
13
|
+
//
|
|
14
|
+
// The generator itself lives in the machine's shared cache rather than beside
|
|
15
|
+
// the container's workspace, so the same spec served from two directories is
|
|
16
|
+
// written once. The box root is scratch: a hit is copied back into it, because
|
|
17
|
+
// the container can only run what is under its mount.
|
|
18
|
+
//
|
|
19
|
+
// The in-flight map is the other half and matters more than it looks: ten
|
|
20
|
+
// requests arriving together for an uncached operation must produce one build,
|
|
21
|
+
// not ten. A failed build is remembered too, for the same reason — an operation
|
|
22
|
+
// the model could not write for should not re-ask on every request.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
export const GYM_KIND = 'gym-generator';
|
|
25
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
26
|
+
export class Cache {
|
|
27
|
+
#opts;
|
|
28
|
+
#store;
|
|
29
|
+
#dumpDir;
|
|
30
|
+
#records = new Map();
|
|
31
|
+
#live = new Map();
|
|
32
|
+
#settled = new Set();
|
|
33
|
+
#slots;
|
|
34
|
+
#waiting = [];
|
|
35
|
+
#running = 0;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.#opts = opts;
|
|
38
|
+
this.#store = new Store(GYM_KIND, { dir: opts.cacheDir, mode: 0o600 });
|
|
39
|
+
this.#dumpDir = opts.dumpDir ?? join(opts.box.root, 'builds');
|
|
40
|
+
this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The generator for an operation, written if it does not exist yet. Callers
|
|
44
|
+
* that arrive during a build wait for that build rather than starting one.
|
|
45
|
+
*
|
|
46
|
+
* `cached` answers "did this call cost a model turn", which is what the
|
|
47
|
+
* response header is for — so it is false for the caller that triggered the
|
|
48
|
+
* build and for everyone who waited on it, and true from then on.
|
|
49
|
+
*/
|
|
50
|
+
ensure(operation) {
|
|
51
|
+
const already = this.#settled.has(operation.key);
|
|
52
|
+
const running = this.#live.get(operation.key);
|
|
53
|
+
if (running) {
|
|
54
|
+
return already ? running.then((g) => ({ ...g, cached: true })) : running;
|
|
55
|
+
}
|
|
56
|
+
const started = this.#make(operation).then((g) => {
|
|
57
|
+
this.#settled.add(operation.key);
|
|
58
|
+
return g;
|
|
59
|
+
}, (err) => {
|
|
60
|
+
// A model that tried and could not is remembered: re-asking it
|
|
61
|
+
// once per request is how a mock server becomes an invoice.
|
|
62
|
+
// Anything else — a rate limit, a dropped connection — is about
|
|
63
|
+
// this moment rather than this operation, so it is forgotten
|
|
64
|
+
// and the next request gets a fresh go.
|
|
65
|
+
if (!(err instanceof BuildFailed)) {
|
|
66
|
+
this.#live.delete(operation.key);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
});
|
|
70
|
+
this.#live.set(operation.key, started);
|
|
71
|
+
return started;
|
|
72
|
+
}
|
|
73
|
+
get model() {
|
|
74
|
+
return this.#opts.model;
|
|
75
|
+
}
|
|
76
|
+
async getSource(key) {
|
|
77
|
+
const found = this.#store.get(key);
|
|
78
|
+
return found?.source?.trim() ? found.source : undefined;
|
|
79
|
+
}
|
|
80
|
+
async regenerate(operation, ctx) {
|
|
81
|
+
const { box, model, checks, ephemeral } = this.#opts;
|
|
82
|
+
await this.#enter();
|
|
83
|
+
this.#opts.onStart?.({ operation });
|
|
84
|
+
try {
|
|
85
|
+
const built = await regenerate(operation, {
|
|
86
|
+
model,
|
|
87
|
+
box,
|
|
88
|
+
checks,
|
|
89
|
+
attempts: this.#opts.attempts,
|
|
90
|
+
currentSource: ctx.currentSource,
|
|
91
|
+
failingInput: ctx.failingInput,
|
|
92
|
+
fault: ctx.fault,
|
|
93
|
+
stderr: ctx.stderr,
|
|
94
|
+
examples: ctx.examples,
|
|
95
|
+
onAttempt: (attempt, diagnostics, source, limit) => this.#rejected(operation, attempt, diagnostics, source, limit, true),
|
|
96
|
+
});
|
|
97
|
+
if (!ephemeral) {
|
|
98
|
+
this.#store.put(operation.key, {
|
|
99
|
+
source: built.source,
|
|
100
|
+
meta: {
|
|
101
|
+
operationId: operation.operationId,
|
|
102
|
+
method: operation.method,
|
|
103
|
+
path: operation.path,
|
|
104
|
+
source: operation.source,
|
|
105
|
+
model: model.id,
|
|
106
|
+
attempts: built.attempts,
|
|
107
|
+
rulesVersion: GENERATOR_RULES_VERSION,
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
113
|
+
const gen = { key: operation.key, source: built.source, cached: false };
|
|
114
|
+
this.#settled.add(operation.key);
|
|
115
|
+
this.#live.set(operation.key, Promise.resolve(gen));
|
|
116
|
+
await this.#finished(operation);
|
|
117
|
+
return gen;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
121
|
+
// The box holds the last rejected attempt; the next request must run the old file.
|
|
122
|
+
await box.write(operation.key, ctx.currentSource).catch(() => undefined);
|
|
123
|
+
const dump = await this.#finished(operation, error);
|
|
124
|
+
this.#opts.onFail?.({ operation, error, dump });
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.#leave();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async #make(operation) {
|
|
132
|
+
const { box, model, checks, rebuild, ephemeral } = this.#opts;
|
|
133
|
+
// Read before queueing: a cache hit costs nothing and must not wait
|
|
134
|
+
// behind somebody else's model call.
|
|
135
|
+
const source = rebuild ? undefined : await this.#read(box, operation.key);
|
|
136
|
+
if (source !== undefined) {
|
|
137
|
+
this.#opts.onReady?.({ operation, cached: true, attempts: 0 });
|
|
138
|
+
return { key: operation.key, source, cached: true };
|
|
139
|
+
}
|
|
140
|
+
await this.#enter();
|
|
141
|
+
this.#opts.onStart?.({ operation });
|
|
142
|
+
try {
|
|
143
|
+
const built = await build(operation, {
|
|
144
|
+
model,
|
|
145
|
+
box,
|
|
146
|
+
checks,
|
|
147
|
+
attempts: this.#opts.attempts,
|
|
148
|
+
onAttempt: (attempt, diagnostics, source, limit) => this.#rejected(operation, attempt, diagnostics, source, limit, false),
|
|
149
|
+
});
|
|
150
|
+
if (!ephemeral) {
|
|
151
|
+
this.#store.put(operation.key, {
|
|
152
|
+
source: built.source,
|
|
153
|
+
meta: {
|
|
154
|
+
operationId: operation.operationId,
|
|
155
|
+
method: operation.method,
|
|
156
|
+
path: operation.path,
|
|
157
|
+
source: operation.source,
|
|
158
|
+
model: model.id,
|
|
159
|
+
attempts: built.attempts,
|
|
160
|
+
rulesVersion: GENERATOR_RULES_VERSION,
|
|
161
|
+
createdAt: new Date().toISOString(),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
166
|
+
await this.#finished(operation);
|
|
167
|
+
return { key: operation.key, source: built.source, cached: false };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
171
|
+
const dump = await this.#finished(operation, error);
|
|
172
|
+
this.#opts.onFail?.({ operation, error, dump });
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.#leave();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* An attempt the judge threw out. The report is rewritten with the file
|
|
181
|
+
* still in it before the next attempt overwrites `gen.py` — for a timeout
|
|
182
|
+
* that source is the only evidence there will ever be, since a generator
|
|
183
|
+
* that hangs leaves no traceback behind.
|
|
184
|
+
*/
|
|
185
|
+
async #rejected(operation, attempt, diagnostics, source, limit, regeneration) {
|
|
186
|
+
let record = this.#records.get(operation.key);
|
|
187
|
+
if (!record || attempt === 1) {
|
|
188
|
+
record = {
|
|
189
|
+
operation,
|
|
190
|
+
started: new Date(),
|
|
191
|
+
limit,
|
|
192
|
+
regeneration,
|
|
193
|
+
attempts: [],
|
|
194
|
+
};
|
|
195
|
+
this.#records.set(operation.key, record);
|
|
196
|
+
}
|
|
197
|
+
record.attempts.push({ attempt, at: new Date(), diagnostics, source });
|
|
198
|
+
await this.#report(record);
|
|
199
|
+
this.#opts.onAttempt?.({ operation, attempt, diagnostics, dump: record.path });
|
|
200
|
+
}
|
|
201
|
+
/** A report that cannot be written is not worth failing a build over. */
|
|
202
|
+
async #report(record, gaveUp = false) {
|
|
203
|
+
try {
|
|
204
|
+
record.path = await saveBuildDump(this.#dumpDir, {
|
|
205
|
+
started: record.started,
|
|
206
|
+
operationId: record.operation.operationId,
|
|
207
|
+
method: record.operation.method,
|
|
208
|
+
path: record.operation.path,
|
|
209
|
+
key: record.operation.key,
|
|
210
|
+
limit: record.limit,
|
|
211
|
+
regeneration: record.regeneration,
|
|
212
|
+
model: this.#opts.model.id,
|
|
213
|
+
attempts: record.attempts,
|
|
214
|
+
gaveUp,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// no report, then — the narration still says what failed
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The end of a build, either way. A failure is stamped as such and its
|
|
223
|
+
* report handed to the error, so the request that gets the 501 can say
|
|
224
|
+
* where to read about it.
|
|
225
|
+
*/
|
|
226
|
+
async #finished(operation, error) {
|
|
227
|
+
const record = this.#records.get(operation.key);
|
|
228
|
+
this.#records.delete(operation.key);
|
|
229
|
+
if (!record) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
if (error instanceof BuildFailed) {
|
|
233
|
+
await this.#report(record, true);
|
|
234
|
+
error.dump = record.path;
|
|
235
|
+
}
|
|
236
|
+
return record.path;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* A hit is written into the box before it is returned. The container runs
|
|
240
|
+
* files under its mount and nothing else, and the mount is scratch that any
|
|
241
|
+
* `cache clear` is free to delete.
|
|
242
|
+
*/
|
|
243
|
+
async #read(box, key) {
|
|
244
|
+
const found = this.#store.get(key);
|
|
245
|
+
if (!found?.source?.trim()) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await box.write(key, found.source);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// The mount went away. Writing it again is the model's job.
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return found.source;
|
|
256
|
+
}
|
|
257
|
+
#enter() {
|
|
258
|
+
if (this.#running < this.#slots) {
|
|
259
|
+
this.#running++;
|
|
260
|
+
return Promise.resolve();
|
|
261
|
+
}
|
|
262
|
+
return new Promise((admit) => this.#waiting.push(admit));
|
|
263
|
+
}
|
|
264
|
+
/** Hands the slot straight to whoever is next, so the count stays exact. */
|
|
265
|
+
#leave() {
|
|
266
|
+
const next = this.#waiting.shift();
|
|
267
|
+
if (next) {
|
|
268
|
+
next();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.#running--;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export { BuildFailed };
|
|
275
|
+
//# sourceMappingURL=cache.js.map
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, KeyStore, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
|
|
2
|
+
import { createModel } from '@zenera/neo';
|
|
3
|
+
import { rmSync } from 'node:fs';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import { CONTAINER_PREFIX, GENERATORS } from "./box.js";
|
|
6
|
+
import { GYM_KIND } from "./cache.js";
|
|
7
|
+
import { reason } from "./generate.js";
|
|
8
|
+
import { formatDumpPath } from "./logger.js";
|
|
9
|
+
import { listen } from "./server.js";
|
|
10
|
+
import { chooseModel, MODEL_ENV, open, readSettings, SOURCE_LABELS, writeSettings, } from "./setup.js";
|
|
11
|
+
import { called } from "./spec.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// zen gym — a mock API from a specification
|
|
14
|
+
//
|
|
15
|
+
// A command in another package rather than a binary of its own: one thing to
|
|
16
|
+
// install, one keyring, one name to remember. `zen` loads this module only when
|
|
17
|
+
// somebody types `zen gym`, so nothing here is on the path of `zen list`.
|
|
18
|
+
//
|
|
19
|
+
// That it stays up is not a problem for the frame — `run` simply does not
|
|
20
|
+
// resolve until a signal arrives, and the process lives as long as the answer
|
|
21
|
+
// takes, which is what every other command already means.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const USAGE = 'zen gym <serve|build|cache|model> [spec...]';
|
|
24
|
+
// `--json` is the frame's, lifted out of the arguments before they arrive; it
|
|
25
|
+
// reaches us as `ctx.json` and must not be declared again or `strict` rejects it.
|
|
26
|
+
const OPTIONS = {
|
|
27
|
+
port: { type: 'string' },
|
|
28
|
+
host: { type: 'string' },
|
|
29
|
+
model: { type: 'string' },
|
|
30
|
+
image: { type: 'string' },
|
|
31
|
+
cache: { type: 'string' },
|
|
32
|
+
attempts: { type: 'string' },
|
|
33
|
+
concurrency: { type: 'string' },
|
|
34
|
+
seed: { type: 'string' },
|
|
35
|
+
timeout: { type: 'string' },
|
|
36
|
+
'max-body': { type: 'string' },
|
|
37
|
+
'regen-limit': { type: 'string' },
|
|
38
|
+
'requests-dir': { type: 'string' },
|
|
39
|
+
rebuild: { type: 'boolean' },
|
|
40
|
+
'no-cache': { type: 'boolean' },
|
|
41
|
+
clear: { type: 'boolean' },
|
|
42
|
+
quiet: { type: 'boolean' },
|
|
43
|
+
};
|
|
44
|
+
export const command = {
|
|
45
|
+
summary: 'Simulated API environments for agent training and evaluation.',
|
|
46
|
+
usage: USAGE,
|
|
47
|
+
details: [
|
|
48
|
+
'Commands',
|
|
49
|
+
...table([
|
|
50
|
+
[' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
|
|
51
|
+
[' build <spec...>', dim('Write every generator now and exit.')],
|
|
52
|
+
[' cache ls|clear', dim('What has been generated, or throw it away.')],
|
|
53
|
+
[' model [ref]', dim('Show or set the model it uses. Remembered between runs.')],
|
|
54
|
+
]),
|
|
55
|
+
'',
|
|
56
|
+
'Options',
|
|
57
|
+
...table([
|
|
58
|
+
[' --port <n>', dim('Default 8787.')],
|
|
59
|
+
[' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
|
|
60
|
+
[' --model <ref>', dim('Which model writes the generators, for this run only.')],
|
|
61
|
+
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
62
|
+
[' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/gym.")],
|
|
63
|
+
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
64
|
+
[' --attempts <n>', dim('Tries per generator before giving up. Default 5.')],
|
|
65
|
+
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
66
|
+
[' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
|
|
67
|
+
[' --max-body <n>', dim('Largest request body accepted, in bytes.')],
|
|
68
|
+
[' --regen-limit <n>', dim('Maximum regenerations per URL on error. Default 3.')],
|
|
69
|
+
[' --requests-dir <dir>', dim('Where to save request/response .md files.')],
|
|
70
|
+
[' --rebuild', dim('Ignore what is cached and write it again.')],
|
|
71
|
+
[' --no-cache', dim('Do not record what is written.')],
|
|
72
|
+
[' --clear', dim('With `model`: forget the stored one.')],
|
|
73
|
+
[' --quiet', dim('No narration.')],
|
|
74
|
+
]),
|
|
75
|
+
'',
|
|
76
|
+
dim(`The model is looked for in this order: ${cyan('--model')}, ${cyan(MODEL_ENV)}, ` +
|
|
77
|
+
`${cyan('zen gym model')}, then the best one your keys can buy.`),
|
|
78
|
+
dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
|
|
79
|
+
],
|
|
80
|
+
async run(ctx) {
|
|
81
|
+
const [name, ...rest] = ctx.args;
|
|
82
|
+
switch (name) {
|
|
83
|
+
case 'serve':
|
|
84
|
+
return await serve(rest, ctx);
|
|
85
|
+
case 'build':
|
|
86
|
+
return await warm(rest, ctx);
|
|
87
|
+
case 'cache':
|
|
88
|
+
return await cache(rest, ctx);
|
|
89
|
+
case 'model':
|
|
90
|
+
return await model(rest, ctx);
|
|
91
|
+
default:
|
|
92
|
+
throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// serve
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
async function serve(args, ctx) {
|
|
100
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen gym serve <spec...>');
|
|
101
|
+
const loud = !values.quiet && !ctx.json;
|
|
102
|
+
const setup = await start(values, positionals, ctx, 'serve');
|
|
103
|
+
if (loud) {
|
|
104
|
+
printSpecs(setup.router.operations, ctx.cwd);
|
|
105
|
+
}
|
|
106
|
+
const host = values.host ?? '127.0.0.1';
|
|
107
|
+
const requestsDir = values['requests-dir']
|
|
108
|
+
? resolve(ctx.cwd, values['requests-dir'])
|
|
109
|
+
: undefined;
|
|
110
|
+
const listener = await listen({
|
|
111
|
+
router: setup.router,
|
|
112
|
+
cache: setup.cache,
|
|
113
|
+
checks: setup.checks,
|
|
114
|
+
box: setup.box,
|
|
115
|
+
seed: number(values.seed, 'seed'),
|
|
116
|
+
maxBody: number(values['max-body'], 'max-body'),
|
|
117
|
+
requestsDir,
|
|
118
|
+
regenLimit: number(values['regen-limit'], 'regen-limit'),
|
|
119
|
+
onRequest: values.quiet ? undefined : (line) => note(line),
|
|
120
|
+
}, host, number(values.port, 'port') ?? 8787);
|
|
121
|
+
if (!values.quiet) {
|
|
122
|
+
note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
|
|
123
|
+
dim(`${setup.router.operations.length} operations`));
|
|
124
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
125
|
+
note(yellow(`bound to ${host} — this mock is reachable from the network`));
|
|
126
|
+
}
|
|
127
|
+
note(dim(`generators: ${setup.root}/${GENERATORS}`));
|
|
128
|
+
note(dim(`request logs: ${requestsDir ?? `${setup.root}/requests`}`));
|
|
129
|
+
note(dim(`failed builds: ${setup.root}/builds`));
|
|
130
|
+
}
|
|
131
|
+
// The address is the answer; the rest was narration.
|
|
132
|
+
write(`http://${host}:${listener.port}`);
|
|
133
|
+
await until(['SIGINT', 'SIGTERM']);
|
|
134
|
+
if (!values.quiet) {
|
|
135
|
+
note(dim('stopping'));
|
|
136
|
+
}
|
|
137
|
+
await listener.close();
|
|
138
|
+
await setup.close();
|
|
139
|
+
}
|
|
140
|
+
/** Resolves when one of the signals arrives, and stops listening for them. */
|
|
141
|
+
function until(signals) {
|
|
142
|
+
return new Promise((settle) => {
|
|
143
|
+
const done = () => {
|
|
144
|
+
for (const s of signals) {
|
|
145
|
+
process.off(s, done);
|
|
146
|
+
}
|
|
147
|
+
settle();
|
|
148
|
+
};
|
|
149
|
+
for (const s of signals) {
|
|
150
|
+
process.once(s, done);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// build — every generator, up front
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
async function warm(args, ctx) {
|
|
158
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen gym build <spec...>');
|
|
159
|
+
const loud = !values.quiet && !ctx.json;
|
|
160
|
+
const setup = await start(values, positionals, ctx, 'build');
|
|
161
|
+
if (loud) {
|
|
162
|
+
printSpecs(setup.router.operations, ctx.cwd);
|
|
163
|
+
}
|
|
164
|
+
const results = [];
|
|
165
|
+
try {
|
|
166
|
+
for (const operation of setup.router.operations) {
|
|
167
|
+
const id = `${operation.method.toUpperCase()} ${called(operation)}`;
|
|
168
|
+
if (!operation.success.schema) {
|
|
169
|
+
results.push({ operation: id, status: 'skipped', detail: 'no response body' });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const generator = await setup.cache.ensure(operation);
|
|
174
|
+
results.push({ operation: id, status: generator.cached ? 'cached' : 'built' });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
results.push({
|
|
178
|
+
operation: id,
|
|
179
|
+
status: 'failed',
|
|
180
|
+
detail: reason(err),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await setup.close();
|
|
187
|
+
}
|
|
188
|
+
if (ctx.json) {
|
|
189
|
+
json(results);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
writeAll(table(results.map((r) => [
|
|
193
|
+
` ${mark(r.status)}`,
|
|
194
|
+
r.operation,
|
|
195
|
+
dim(r.detail?.slice(0, 100) ?? ''),
|
|
196
|
+
])));
|
|
197
|
+
}
|
|
198
|
+
// Reported first, then failed: the table is the answer either way.
|
|
199
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
200
|
+
if (failed > 0) {
|
|
201
|
+
throw new CliError(`${failed} of ${results.length} generators failed`, EXIT.failed, 'run again to retry, or raise --attempts');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const mark = (status) => status === 'failed' ? red('failed') : status === 'skipped' ? dim('skipped') : green(status);
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// cache
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
async function cache(args, ctx) {
|
|
209
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen gym cache <ls|clear>');
|
|
210
|
+
const root = values.cache ? resolve(ctx.cwd, values.cache) : paths.gym();
|
|
211
|
+
const sub = positionals[0] ?? 'ls';
|
|
212
|
+
if (sub === 'ls') {
|
|
213
|
+
const entries = listGenerators();
|
|
214
|
+
if (ctx.json) {
|
|
215
|
+
json(entries);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (entries.length === 0) {
|
|
219
|
+
note('nothing cached yet');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
writeAll(table([
|
|
223
|
+
[bold('KEY'), bold('OPERATION'), bold('MODEL'), bold('TRIES')],
|
|
224
|
+
...entries.map((e) => [
|
|
225
|
+
e.key,
|
|
226
|
+
`${e.method?.toUpperCase() ?? '?'} ${e.path ?? ''}`,
|
|
227
|
+
dim(e.model ?? '—'),
|
|
228
|
+
dim(String(e.attempts ?? '—')),
|
|
229
|
+
]),
|
|
230
|
+
]));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (sub === 'clear') {
|
|
234
|
+
clearCache({ kind: GYM_KIND });
|
|
235
|
+
// The workspace is scratch — whatever a hit was copied into it is
|
|
236
|
+
// written again from the cache, or by the model.
|
|
237
|
+
rmSync(join(root, GENERATORS), { recursive: true, force: true });
|
|
238
|
+
// The container is named after its configuration, so a stale one would
|
|
239
|
+
// otherwise sit there stopped forever with nothing pointing at it.
|
|
240
|
+
// `zn-<key>-<digest>` is the shape, and this one's key is `gym`.
|
|
241
|
+
const mine = (await ownedContainers()).filter((c) => c.name.startsWith(CONTAINER_PREFIX));
|
|
242
|
+
if (mine.length > 0) {
|
|
243
|
+
await removeContainers(mine.map((c) => c.name));
|
|
244
|
+
}
|
|
245
|
+
note(`${green('cleared')} ${dim(paths.cache())}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
throw usageError(`unknown cache command "${sub}"`, 'zen gym cache <ls|clear>');
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The keys are the operation keys, written verbatim — which is why they can be
|
|
252
|
+
* listed at all. Anything the store cannot parse is left out rather than shown
|
|
253
|
+
* as a row with nothing in it.
|
|
254
|
+
*/
|
|
255
|
+
function listGenerators() {
|
|
256
|
+
const { rows } = cacheItems(GYM_KIND);
|
|
257
|
+
return rows
|
|
258
|
+
.map((row) => ({ key: row.key, ...row.value?.meta }))
|
|
259
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// model
|
|
263
|
+
//
|
|
264
|
+
// A stored model is remembered the way `zen meta model` remembers one: in the
|
|
265
|
+
// home directory, beside the keyring, because it is a choice about this tool on
|
|
266
|
+
// this machine. Shown rather than merely set, since the question when a run
|
|
267
|
+
// surprises you is not "which model" but "why that one".
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
const MODEL_USAGE = 'zen gym model [ref|--clear]';
|
|
270
|
+
async function model(args, ctx) {
|
|
271
|
+
const { values, positionals } = parse(args, OPTIONS, MODEL_USAGE);
|
|
272
|
+
const ref = positionals[0];
|
|
273
|
+
if (ref && values.clear) {
|
|
274
|
+
throw usageError('--clear takes no model', MODEL_USAGE);
|
|
275
|
+
}
|
|
276
|
+
// Before anything builds a model: vertex reads its project out of the
|
|
277
|
+
// environment, and the keyring is what puts it there.
|
|
278
|
+
const keys = await KeyStore.open();
|
|
279
|
+
keys.materialize();
|
|
280
|
+
if (ref || values.clear) {
|
|
281
|
+
const file = await readSettings();
|
|
282
|
+
if (ref) {
|
|
283
|
+
// Only that the ref parses: asking the provider costs a call, and
|
|
284
|
+
// the next run makes one anyway.
|
|
285
|
+
createModel(ref);
|
|
286
|
+
writeSettings({ ...file, model: ref });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
delete file.model;
|
|
290
|
+
writeSettings(file);
|
|
291
|
+
}
|
|
292
|
+
if (ctx.json) {
|
|
293
|
+
json({ model: ref });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
note(ref ? `${green('set')} ${ref}` : `${green('cleared')} ${dim('the stored model')}`);
|
|
297
|
+
}
|
|
298
|
+
const chosen = await chooseModel(values.model, keys);
|
|
299
|
+
if (ctx.json) {
|
|
300
|
+
json({ model: chosen?.ref, from: chosen?.from, stored: (await readSettings()).model });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!chosen) {
|
|
304
|
+
note('no model, and no key to recommend one from');
|
|
305
|
+
note(dim('add a key: zen key add openai'));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
note(`${cyan(chosen.ref)} ${dim(`from ${SOURCE_LABELS[chosen.from]}`)}`);
|
|
309
|
+
if (chosen.from === 'default') {
|
|
310
|
+
note(dim(`set one: zen gym model <provider>:<id>`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function summarize(operations) {
|
|
314
|
+
const by = new Map();
|
|
315
|
+
for (const op of operations) {
|
|
316
|
+
let stat = by.get(op.source);
|
|
317
|
+
if (!stat) {
|
|
318
|
+
stat = { source: op.source, paths: new Set(), methods: 0, functions: 0 };
|
|
319
|
+
by.set(op.source, stat);
|
|
320
|
+
}
|
|
321
|
+
stat.paths.add(op.path);
|
|
322
|
+
stat.methods += 1;
|
|
323
|
+
if (op.success.schema) {
|
|
324
|
+
stat.functions += 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [...by.values()];
|
|
328
|
+
}
|
|
329
|
+
const HEADERS = ['PATHS', 'METHODS', 'FUNCTIONS'];
|
|
330
|
+
function printSpecs(operations, cwd) {
|
|
331
|
+
const stats = summarize(operations);
|
|
332
|
+
const rows = stats.map((s) => ({
|
|
333
|
+
name: relative(cwd, s.source) || s.source,
|
|
334
|
+
cells: [s.paths.size, s.methods, s.functions],
|
|
335
|
+
}));
|
|
336
|
+
if (rows.length > 1) {
|
|
337
|
+
rows.push({
|
|
338
|
+
name: 'total',
|
|
339
|
+
cells: HEADERS.map((_, i) => rows.reduce((n, r) => n + r.cells[i], 0)),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// Numbers are padded before they are styled: a colour code has no width,
|
|
343
|
+
// and `table` cannot know that.
|
|
344
|
+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r.cells[i]).length)));
|
|
345
|
+
const lines = table([
|
|
346
|
+
[bold('SPEC'), ...HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
|
|
347
|
+
...rows.map((r) => [
|
|
348
|
+
r.name === 'total' ? dim(r.name) : r.name,
|
|
349
|
+
...r.cells.map((c, i) => String(c).padStart(widths[i])),
|
|
350
|
+
]),
|
|
351
|
+
]);
|
|
352
|
+
note('');
|
|
353
|
+
for (const line of lines) {
|
|
354
|
+
note(` ${line}`);
|
|
355
|
+
}
|
|
356
|
+
note('');
|
|
357
|
+
}
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Shared
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
async function start(values, specs, ctx, what) {
|
|
362
|
+
const loud = !values.quiet && !ctx.json;
|
|
363
|
+
const setup = await open({
|
|
364
|
+
specs,
|
|
365
|
+
cwd: ctx.cwd,
|
|
366
|
+
cache: values.cache,
|
|
367
|
+
model: values.model,
|
|
368
|
+
image: values.image,
|
|
369
|
+
attempts: number(values.attempts, 'attempts'),
|
|
370
|
+
concurrency: number(values.concurrency, 'concurrency'),
|
|
371
|
+
timeout: number(values.timeout, 'timeout'),
|
|
372
|
+
rebuild: values.rebuild,
|
|
373
|
+
ephemeral: values['no-cache'],
|
|
374
|
+
onImageBuild: loud
|
|
375
|
+
? (tag) => note(`${dim('building')} ${tag} ${dim('— once, then cached')}`)
|
|
376
|
+
: undefined,
|
|
377
|
+
events: {
|
|
378
|
+
onStart: loud
|
|
379
|
+
? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${called(operation)}`)
|
|
380
|
+
: undefined,
|
|
381
|
+
onAttempt: loud
|
|
382
|
+
? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
|
|
383
|
+
: undefined,
|
|
384
|
+
onReady: loud && what === 'serve'
|
|
385
|
+
? ({ operation, cached }) => cached
|
|
386
|
+
? undefined
|
|
387
|
+
: note(` ${green('ready')} ${dim(operation.operationId)}`)
|
|
388
|
+
: undefined,
|
|
389
|
+
onFail: loud
|
|
390
|
+
? ({ operation, error, dump }) => {
|
|
391
|
+
note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`);
|
|
392
|
+
if (dump) {
|
|
393
|
+
note(formatDumpPath('report', dump));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
: undefined,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
if (loud) {
|
|
400
|
+
note(dim(`model ${setup.choice.ref} from ${SOURCE_LABELS[setup.choice.from]}`));
|
|
401
|
+
}
|
|
402
|
+
return setup;
|
|
403
|
+
}
|
|
404
|
+
function number(raw, what) {
|
|
405
|
+
if (raw === undefined) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
const value = Number(raw);
|
|
409
|
+
if (!Number.isFinite(value)) {
|
|
410
|
+
throw usageError(`--${what} must be a number, got "${raw}"`);
|
|
411
|
+
}
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
//# sourceMappingURL=command.js.map
|