@zenera/faker 1.1.23 → 1.1.25
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/README.md +13 -7
- package/dist/box.d.ts +26 -3
- package/dist/box.js +110 -9
- package/dist/cache.d.ts +4 -0
- package/dist/cache.js +74 -4
- package/dist/command.js +11 -4
- package/dist/generate.d.ts +10 -1
- package/dist/generate.js +17 -4
- package/dist/logger.d.ts +29 -2
- package/dist/logger.js +50 -2
- package/dist/probe.js +41 -7
- package/dist/prompt.js +3 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +50 -3
- package/dist/setup.js +1 -1
- package/dist/spec.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -57,8 +57,8 @@ zen faker cache clear # throw it away
|
|
|
57
57
|
|
|
58
58
|
`zen mock` is the same command under a shorter noun.
|
|
59
59
|
|
|
60
|
-
A path key with a query string on it — `/x/{id}?action=retry`, which
|
|
61
|
-
|
|
60
|
+
A path key with a query string on it — `/x/{id}?action=retry`, which some
|
|
61
|
+
vendors write when they need two `post`s under one path — is read for what
|
|
62
62
|
it means rather than taken verbatim: the path is the part before the `?`, and
|
|
63
63
|
each pair after it becomes a required query value that tells those operations
|
|
64
64
|
apart. Call one without it and the 404 says which queries the path answers to.
|
|
@@ -75,11 +75,17 @@ for. `GET /users/12324` answering with somebody else's id validates perfectly
|
|
|
75
75
|
and is still wrong.
|
|
76
76
|
|
|
77
77
|
If it fails, the diagnostics go back to the model and it tries again, up to
|
|
78
|
-
`--attempts
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
`--attempts` (five by default). A build that does not come good leaves a
|
|
79
|
+
**report** in `<cache>/builds/` - one markdown file per build, every rejected
|
|
80
|
+
attempt in it with its diagnostics and its whole source, and the `501` that the
|
|
81
|
+
request gets links to it, in the log line and in the body. The next attempt
|
|
82
|
+
overwrites `gen.py`, and a generator that merely hung leaves no traceback, so
|
|
83
|
+
the code is the only evidence there is. If it passes, the generator is kept in
|
|
84
|
+
this machine's shared cache under `~/.zenera/neo/cache/faker-generator/`, keyed
|
|
85
|
+
by the operation's shape, and every later request is just
|
|
86
|
+
`podman exec python3 gen.py in.json out.json` - no model, no tokens. The store
|
|
87
|
+
is the machine's, so the same document served from another directory costs
|
|
88
|
+
nothing the second time.
|
|
83
89
|
|
|
84
90
|
Generators run in a container with **no network**, on an image baked once with
|
|
85
91
|
`faker`, `exrex`, `jsonschema` and `python-dateutil`.
|
package/dist/box.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Runner, type Sandbox } from '@zenera/neo';
|
|
2
2
|
export declare const GENERATORS = "generators";
|
|
3
|
+
/** Every faker container's name starts with this; `cache clear` relies on it. */
|
|
4
|
+
export declare const CONTAINER_PREFIX = "zn-faker-";
|
|
3
5
|
export interface BoxOptions {
|
|
4
6
|
/** host directory mounted at /workspace; holds generators/ and io/ */
|
|
5
7
|
root: string;
|
|
@@ -8,6 +10,23 @@ export interface BoxOptions {
|
|
|
8
10
|
timeout?: number;
|
|
9
11
|
engine?: string;
|
|
10
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
|
+
});
|
|
11
30
|
}
|
|
12
31
|
export interface Outcome {
|
|
13
32
|
ok: boolean;
|
|
@@ -24,9 +43,11 @@ export declare class Box {
|
|
|
24
43
|
constructor(opts: BoxOptions);
|
|
25
44
|
get sandbox(): Sandbox;
|
|
26
45
|
/**
|
|
27
|
-
* Removes
|
|
28
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* Removes this process's container if a previous owner of the pid left one,
|
|
47
|
+
* and every faker 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.
|
|
30
51
|
*/
|
|
31
52
|
fresh(): Promise<void>;
|
|
32
53
|
/** Host path of a generator's source file. */
|
|
@@ -36,6 +57,8 @@ export declare class Box {
|
|
|
36
57
|
* One generator, one input, one output. The io directory is removed
|
|
37
58
|
* afterwards whatever happened — a mock server left alone for a week must
|
|
38
59
|
* not fill a disk with request envelopes.
|
|
60
|
+
*
|
|
61
|
+
* Throws `SandboxUnavailable` when the engine, not the generator, failed.
|
|
39
62
|
*/
|
|
40
63
|
run(key: string, input: unknown): Promise<Outcome>;
|
|
41
64
|
dispose(): Promise<void>;
|
package/dist/box.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { SANDBOX_MOUNT, SandboxError, SandboxPool, pidAlive, runProcess, } from '@zenera/neo';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { mkdirSync, rmSync } from 'node:fs';
|
|
3
4
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
|
-
import { SANDBOX_MOUNT, SandboxPool, runProcess } from '@zenera/neo';
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Where generators run
|
|
8
8
|
//
|
|
@@ -19,22 +19,51 @@ import { SANDBOX_MOUNT, SandboxPool, runProcess } from '@zenera/neo';
|
|
|
19
19
|
export const GENERATORS = 'generators';
|
|
20
20
|
const IO = 'io';
|
|
21
21
|
const ENTRY = 'gen.py';
|
|
22
|
+
/** Every faker container's name starts with this; `cache clear` relies on it. */
|
|
23
|
+
export const CONTAINER_PREFIX = 'zn-faker-';
|
|
24
|
+
/** `zn-faker-<pid>-<digest>`; a name without the pid predates per-process containers. */
|
|
25
|
+
const OWNED_BY = /^zn-faker-(\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
|
+
}
|
|
22
45
|
export class Box {
|
|
23
46
|
root;
|
|
24
47
|
#pool;
|
|
25
48
|
#timeout;
|
|
26
49
|
#engine;
|
|
27
50
|
#exec;
|
|
51
|
+
#owner;
|
|
28
52
|
constructor(opts) {
|
|
29
53
|
this.root = opts.root;
|
|
30
54
|
this.#timeout = opts.timeout ?? 30;
|
|
31
55
|
this.#engine = opts.engine ?? 'podman';
|
|
32
56
|
this.#exec = opts.exec ?? runProcess;
|
|
57
|
+
this.#owner = opts.owner ?? process.pid;
|
|
33
58
|
mkdirSync(join(opts.root, GENERATORS), { recursive: true });
|
|
34
59
|
mkdirSync(join(opts.root, IO), { recursive: true });
|
|
35
60
|
this.#pool = new SandboxPool({
|
|
36
61
|
root: opts.root,
|
|
37
|
-
|
|
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: `faker-${this.#owner}`,
|
|
38
67
|
image: opts.image,
|
|
39
68
|
// The one line that keeps model-written code from calling home.
|
|
40
69
|
network: 'none',
|
|
@@ -62,14 +91,21 @@ export class Box {
|
|
|
62
91
|
return this.#pool.for();
|
|
63
92
|
}
|
|
64
93
|
/**
|
|
65
|
-
* Removes
|
|
66
|
-
*
|
|
67
|
-
*
|
|
94
|
+
* Removes this process's container if a previous owner of the pid left one,
|
|
95
|
+
* and every faker 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.
|
|
68
99
|
*/
|
|
69
100
|
async fresh() {
|
|
70
|
-
await this.#exec(this.#engine, ['
|
|
71
|
-
|
|
72
|
-
|
|
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);
|
|
73
109
|
}
|
|
74
110
|
/** Host path of a generator's source file. */
|
|
75
111
|
sourceOf(key) {
|
|
@@ -83,6 +119,8 @@ export class Box {
|
|
|
83
119
|
* One generator, one input, one output. The io directory is removed
|
|
84
120
|
* afterwards whatever happened — a mock server left alone for a week must
|
|
85
121
|
* not fill a disk with request envelopes.
|
|
122
|
+
*
|
|
123
|
+
* Throws `SandboxUnavailable` when the engine, not the generator, failed.
|
|
86
124
|
*/
|
|
87
125
|
async run(key, input) {
|
|
88
126
|
const id = randomUUID();
|
|
@@ -96,7 +134,17 @@ export class Box {
|
|
|
96
134
|
inside(IO, id, 'input.json'),
|
|
97
135
|
inside(IO, id, 'output.json'),
|
|
98
136
|
].join(' ');
|
|
99
|
-
|
|
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
|
+
}
|
|
100
148
|
const stderr = res.stderr.trim();
|
|
101
149
|
if (res.timed_out) {
|
|
102
150
|
return {
|
|
@@ -150,6 +198,59 @@ export class Box {
|
|
|
150
198
|
async dispose() {
|
|
151
199
|
await this.#pool.dispose();
|
|
152
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 faker serve\`.`
|
|
223
|
+
: fault === 'no-python'
|
|
224
|
+
? `the image has no python3. Rebuild it: \`zen faker 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 faker cache clear\` run meanwhile, ` +
|
|
227
|
+
`a ${this.#engine} machine restart, or an older \`zen faker\` (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;
|
|
153
254
|
}
|
|
154
255
|
/** A path inside the container. Every segment is derived here, never given. */
|
|
155
256
|
function inside(...parts) {
|
package/dist/cache.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface CacheEvent {
|
|
|
27
27
|
operation: Operation;
|
|
28
28
|
attempt?: number;
|
|
29
29
|
diagnostics?: readonly string[];
|
|
30
|
+
/** where the rejected generator was written down, when it was */
|
|
31
|
+
dump?: string;
|
|
30
32
|
}
|
|
31
33
|
export interface CacheOptions {
|
|
32
34
|
box: Box;
|
|
@@ -46,6 +48,8 @@ export interface CacheOptions {
|
|
|
46
48
|
ephemeral?: boolean;
|
|
47
49
|
/** keep them somewhere other than the shared store */
|
|
48
50
|
cacheDir?: string;
|
|
51
|
+
/** where rejected generators are written down. Default `<box root>/builds` */
|
|
52
|
+
dumpDir?: string;
|
|
49
53
|
onStart?: (e: CacheEvent) => void;
|
|
50
54
|
onAttempt?: (e: CacheEvent) => void;
|
|
51
55
|
onReady?: (e: CacheEvent & {
|
package/dist/cache.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Cache as Store } from '@zenera/cli/lib';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { build, BuildFailed, regenerate } from "./generate.js";
|
|
4
|
+
import { saveBuildDump } from "./logger.js";
|
|
3
5
|
import { GENERATOR_RULES_VERSION } from "./spec.js";
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
5
7
|
// The cache
|
|
@@ -24,6 +26,8 @@ const DEFAULT_CONCURRENCY = 4;
|
|
|
24
26
|
export class Cache {
|
|
25
27
|
#opts;
|
|
26
28
|
#store;
|
|
29
|
+
#dumpDir;
|
|
30
|
+
#records = new Map();
|
|
27
31
|
#live = new Map();
|
|
28
32
|
#settled = new Set();
|
|
29
33
|
#slots;
|
|
@@ -32,6 +36,7 @@ export class Cache {
|
|
|
32
36
|
constructor(opts) {
|
|
33
37
|
this.#opts = opts;
|
|
34
38
|
this.#store = new Store(FAKER_KIND, { dir: opts.cacheDir, mode: 0o600 });
|
|
39
|
+
this.#dumpDir = opts.dumpDir ?? join(opts.box.root, 'builds');
|
|
35
40
|
this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
36
41
|
}
|
|
37
42
|
/**
|
|
@@ -87,7 +92,7 @@ export class Cache {
|
|
|
87
92
|
fault: ctx.fault,
|
|
88
93
|
stderr: ctx.stderr,
|
|
89
94
|
examples: ctx.examples,
|
|
90
|
-
onAttempt: (attempt, diagnostics) => this.#
|
|
95
|
+
onAttempt: (attempt, diagnostics, source, limit) => this.#rejected(operation, attempt, diagnostics, source, limit, true),
|
|
91
96
|
});
|
|
92
97
|
if (!ephemeral) {
|
|
93
98
|
this.#store.put(operation.key, {
|
|
@@ -108,11 +113,15 @@ export class Cache {
|
|
|
108
113
|
const gen = { key: operation.key, source: built.source, cached: false };
|
|
109
114
|
this.#settled.add(operation.key);
|
|
110
115
|
this.#live.set(operation.key, Promise.resolve(gen));
|
|
116
|
+
await this.#finished(operation);
|
|
111
117
|
return gen;
|
|
112
118
|
}
|
|
113
119
|
catch (err) {
|
|
114
120
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
115
|
-
|
|
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 });
|
|
116
125
|
throw error;
|
|
117
126
|
}
|
|
118
127
|
finally {
|
|
@@ -136,7 +145,7 @@ export class Cache {
|
|
|
136
145
|
box,
|
|
137
146
|
checks,
|
|
138
147
|
attempts: this.#opts.attempts,
|
|
139
|
-
onAttempt: (attempt, diagnostics) => this.#
|
|
148
|
+
onAttempt: (attempt, diagnostics, source, limit) => this.#rejected(operation, attempt, diagnostics, source, limit, false),
|
|
140
149
|
});
|
|
141
150
|
if (!ephemeral) {
|
|
142
151
|
this.#store.put(operation.key, {
|
|
@@ -154,17 +163,78 @@ export class Cache {
|
|
|
154
163
|
});
|
|
155
164
|
}
|
|
156
165
|
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
166
|
+
await this.#finished(operation);
|
|
157
167
|
return { key: operation.key, source: built.source, cached: false };
|
|
158
168
|
}
|
|
159
169
|
catch (err) {
|
|
160
170
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
161
|
-
this.#
|
|
171
|
+
const dump = await this.#finished(operation, error);
|
|
172
|
+
this.#opts.onFail?.({ operation, error, dump });
|
|
162
173
|
throw error;
|
|
163
174
|
}
|
|
164
175
|
finally {
|
|
165
176
|
this.#leave();
|
|
166
177
|
}
|
|
167
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
|
+
}
|
|
168
238
|
/**
|
|
169
239
|
* A hit is written into the box before it is returned. The container runs
|
|
170
240
|
* files under its mount and nothing else, and the mount is scratch that any
|
package/dist/command.js
CHANGED
|
@@ -2,9 +2,10 @@ import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, K
|
|
|
2
2
|
import { createModel } from '@zenera/neo';
|
|
3
3
|
import { rmSync } from 'node:fs';
|
|
4
4
|
import { join, relative, resolve } from 'node:path';
|
|
5
|
-
import { GENERATORS } from "./box.js";
|
|
5
|
+
import { CONTAINER_PREFIX, GENERATORS } from "./box.js";
|
|
6
6
|
import { FAKER_KIND } from "./cache.js";
|
|
7
7
|
import { reason } from "./generate.js";
|
|
8
|
+
import { formatDumpPath } from "./logger.js";
|
|
8
9
|
import { listen } from "./server.js";
|
|
9
10
|
import { chooseModel, MODEL_ENV, open, readSettings, SOURCE_LABELS, writeSettings, } from "./setup.js";
|
|
10
11
|
import { called } from "./spec.js";
|
|
@@ -60,7 +61,7 @@ export const command = {
|
|
|
60
61
|
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
61
62
|
[' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
|
|
62
63
|
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
63
|
-
[' --attempts <n>', dim('Tries per generator before giving up. Default
|
|
64
|
+
[' --attempts <n>', dim('Tries per generator before giving up. Default 5.')],
|
|
64
65
|
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
65
66
|
[' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
|
|
66
67
|
[' --max-body <n>', dim('Largest request body accepted, in bytes.')],
|
|
@@ -125,6 +126,7 @@ async function serve(args, ctx) {
|
|
|
125
126
|
}
|
|
126
127
|
note(dim(`generators: ${setup.root}/${GENERATORS}`));
|
|
127
128
|
note(dim(`request logs: ${requestsDir ?? `${setup.root}/requests`}`));
|
|
129
|
+
note(dim(`failed builds: ${setup.root}/builds`));
|
|
128
130
|
}
|
|
129
131
|
// The address is the answer; the rest was narration.
|
|
130
132
|
write(`http://${host}:${listener.port}`);
|
|
@@ -236,7 +238,7 @@ async function cache(args, ctx) {
|
|
|
236
238
|
// The container is named after its configuration, so a stale one would
|
|
237
239
|
// otherwise sit there stopped forever with nothing pointing at it.
|
|
238
240
|
// `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
|
|
239
|
-
const mine = (await ownedContainers()).filter((c) => c.name.startsWith(
|
|
241
|
+
const mine = (await ownedContainers()).filter((c) => c.name.startsWith(CONTAINER_PREFIX));
|
|
240
242
|
if (mine.length > 0) {
|
|
241
243
|
await removeContainers(mine.map((c) => c.name));
|
|
242
244
|
}
|
|
@@ -385,7 +387,12 @@ async function start(values, specs, ctx, what) {
|
|
|
385
387
|
: note(` ${green('ready')} ${dim(operation.operationId)}`)
|
|
386
388
|
: undefined,
|
|
387
389
|
onFail: loud
|
|
388
|
-
? ({ operation, error }) =>
|
|
390
|
+
? ({ operation, error, dump }) => {
|
|
391
|
+
note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`);
|
|
392
|
+
if (dump) {
|
|
393
|
+
note(formatDumpPath('report', dump));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
389
396
|
: undefined,
|
|
390
397
|
},
|
|
391
398
|
});
|
package/dist/generate.d.ts
CHANGED
|
@@ -10,13 +10,20 @@ export interface BuildOptions {
|
|
|
10
10
|
checks: Checks;
|
|
11
11
|
/** how many times the model may be asked before giving up */
|
|
12
12
|
attempts?: number;
|
|
13
|
-
onAttempt?: (attempt: number, diagnostics: readonly string[]) => void
|
|
13
|
+
onAttempt?: (attempt: number, diagnostics: readonly string[], source: string, limit: number) => void | Promise<void>;
|
|
14
14
|
signal?: AbortSignal;
|
|
15
15
|
}
|
|
16
16
|
export interface Built {
|
|
17
17
|
source: string;
|
|
18
18
|
attempts: number;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Tries per generator. Each one carries the previous file and what was wrong
|
|
22
|
+
* with it, so the later attempts are the informed ones — a model that has seen
|
|
23
|
+
* its own traceback usually needs two or three goes at a fiddly schema, and the
|
|
24
|
+
* whole loop costs nothing once the file is cached.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_ATTEMPTS = 5;
|
|
20
27
|
export interface RegenerateOptions extends BuildOptions {
|
|
21
28
|
currentSource: string;
|
|
22
29
|
failingInput: GeneratorInput;
|
|
@@ -27,6 +34,8 @@ export interface RegenerateOptions extends BuildOptions {
|
|
|
27
34
|
export declare class BuildFailed extends Error {
|
|
28
35
|
readonly operation: Operation;
|
|
29
36
|
readonly diagnostics: readonly string[];
|
|
37
|
+
/** the build report on disk, filled in by whoever was writing one */
|
|
38
|
+
dump?: string;
|
|
30
39
|
constructor(operation: Operation, diagnostics: readonly string[]);
|
|
31
40
|
}
|
|
32
41
|
/**
|
package/dist/generate.js
CHANGED
|
@@ -3,9 +3,18 @@ import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
|
|
|
3
3
|
import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
|
|
4
4
|
import { called } from "./spec.js";
|
|
5
5
|
import { describeIssues, issues } from "./validate.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tries per generator. Each one carries the previous file and what was wrong
|
|
8
|
+
* with it, so the later attempts are the informed ones — a model that has seen
|
|
9
|
+
* its own traceback usually needs two or three goes at a fiddly schema, and the
|
|
10
|
+
* whole loop costs nothing once the file is cached.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_ATTEMPTS = 5;
|
|
6
13
|
export class BuildFailed extends Error {
|
|
7
14
|
operation;
|
|
8
15
|
diagnostics;
|
|
16
|
+
/** the build report on disk, filled in by whoever was writing one */
|
|
17
|
+
dump;
|
|
9
18
|
constructor(operation, diagnostics) {
|
|
10
19
|
super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
|
|
11
20
|
this.name = 'BuildFailed';
|
|
@@ -38,10 +47,14 @@ export function reason(err) {
|
|
|
38
47
|
}
|
|
39
48
|
at = e.cause;
|
|
40
49
|
}
|
|
50
|
+
const hint = err?.hint;
|
|
51
|
+
if (typeof hint === 'string' && hint) {
|
|
52
|
+
parts.push(hint);
|
|
53
|
+
}
|
|
41
54
|
return parts.join(' — ') || String(err);
|
|
42
55
|
}
|
|
43
56
|
export async function build(operation, opts) {
|
|
44
|
-
const limit = Math.max(1, opts.attempts ??
|
|
57
|
+
const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
45
58
|
const probes = probesFor(operation);
|
|
46
59
|
const response = opts.checks.for(operation).response;
|
|
47
60
|
const messages = [
|
|
@@ -66,13 +79,13 @@ export async function build(operation, opts) {
|
|
|
66
79
|
return { source, attempts: attempt };
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
|
-
opts.onAttempt?.(attempt, last);
|
|
82
|
+
await opts.onAttempt?.(attempt, last, source, limit);
|
|
70
83
|
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
71
84
|
}
|
|
72
85
|
throw new BuildFailed(operation, last);
|
|
73
86
|
}
|
|
74
87
|
export async function regenerate(operation, opts) {
|
|
75
|
-
const limit = Math.max(1, opts.attempts ??
|
|
88
|
+
const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
76
89
|
const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
|
|
77
90
|
const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
|
|
78
91
|
const response = opts.checks.for(operation).response;
|
|
@@ -113,7 +126,7 @@ export async function regenerate(operation, opts) {
|
|
|
113
126
|
return { source, attempts: attempt };
|
|
114
127
|
}
|
|
115
128
|
}
|
|
116
|
-
opts.onAttempt?.(attempt, last);
|
|
129
|
+
await opts.onAttempt?.(attempt, last, source, limit);
|
|
117
130
|
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
118
131
|
}
|
|
119
132
|
throw new BuildFailed(operation, last);
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
-
export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
|
|
2
|
+
export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SANDBOX_UNAVAILABLE' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
|
|
3
3
|
export interface RequestDumpData {
|
|
4
4
|
id: string;
|
|
5
5
|
timestamp: Date;
|
|
@@ -19,7 +19,11 @@ export interface RequestDumpData {
|
|
|
19
19
|
responseBody?: unknown;
|
|
20
20
|
errorKind?: ErrorKind;
|
|
21
21
|
errorMessage?: string;
|
|
22
|
+
/** what to do about it, when the fix is not in the generator */
|
|
23
|
+
hint?: string;
|
|
22
24
|
stderr?: string;
|
|
25
|
+
/** the build report, when the answer was "there is no generator" */
|
|
26
|
+
buildDump?: string;
|
|
23
27
|
cacheStatus?: 'hit' | 'miss' | 'regenerated';
|
|
24
28
|
regenerated?: boolean;
|
|
25
29
|
regenAttempts?: number;
|
|
@@ -37,9 +41,32 @@ export declare function colorForErrorKind(kind: ErrorKind): (s: string) => strin
|
|
|
37
41
|
* Returns the absolute path of the generated markdown file.
|
|
38
42
|
*/
|
|
39
43
|
export declare function saveRequestDump(dir: string, data: RequestDumpData): Promise<string>;
|
|
44
|
+
export interface BuildAttempt {
|
|
45
|
+
attempt: number;
|
|
46
|
+
at: Date;
|
|
47
|
+
diagnostics: readonly string[];
|
|
48
|
+
source: string;
|
|
49
|
+
}
|
|
50
|
+
export interface BuildDumpData {
|
|
51
|
+
started: Date;
|
|
52
|
+
operationId?: string;
|
|
53
|
+
method: string;
|
|
54
|
+
path: string;
|
|
55
|
+
/** the operation's cache key, which is also its directory in the box */
|
|
56
|
+
key: string;
|
|
57
|
+
limit: number;
|
|
58
|
+
/** rewriting a generator that already existed rather than writing the first */
|
|
59
|
+
regeneration?: boolean;
|
|
60
|
+
model?: string;
|
|
61
|
+
attempts: readonly BuildAttempt[];
|
|
62
|
+
/** the attempts ran out — no generator was produced */
|
|
63
|
+
gaveUp?: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare function saveBuildDump(dir: string, data: BuildDumpData): Promise<string>;
|
|
66
|
+
export declare function formatDumpPath(label: string, path: string): string;
|
|
40
67
|
export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
|
|
41
68
|
export declare function formatRegenLimitHit(urlPath: string, limit: number, operationId?: string): string;
|
|
42
|
-
export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string): string;
|
|
69
|
+
export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string, stderr?: string): string;
|
|
43
70
|
export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
|
|
44
71
|
export interface ExampleRequest {
|
|
45
72
|
input: GeneratorInput;
|
package/dist/logger.js
CHANGED
|
@@ -44,6 +44,8 @@ export function statusText(status) {
|
|
|
44
44
|
return 'Not Implemented';
|
|
45
45
|
case 502:
|
|
46
46
|
return 'Bad Gateway';
|
|
47
|
+
case 503:
|
|
48
|
+
return 'Service Unavailable';
|
|
47
49
|
case 504:
|
|
48
50
|
return 'Gateway Timeout';
|
|
49
51
|
default:
|
|
@@ -102,6 +104,9 @@ export async function saveRequestDump(dir, data) {
|
|
|
102
104
|
if (data.note) {
|
|
103
105
|
lines.push(`- **Note**: ${data.note}`);
|
|
104
106
|
}
|
|
107
|
+
if (data.buildDump) {
|
|
108
|
+
lines.push(`- **Build report**: [${data.buildDump}](file://${data.buildDump})`);
|
|
109
|
+
}
|
|
105
110
|
lines.push('', '---', '', '## Request Info', '');
|
|
106
111
|
lines.push('### URL & Method', '```http', `${data.method.toUpperCase()} ${data.url}`, '```', '');
|
|
107
112
|
lines.push('### Path Parameters');
|
|
@@ -166,6 +171,9 @@ export async function saveRequestDump(dir, data) {
|
|
|
166
171
|
if (data.errorMessage) {
|
|
167
172
|
lines.push(`### Error Message`, `\`${data.errorMessage}\``, '');
|
|
168
173
|
}
|
|
174
|
+
if (data.hint) {
|
|
175
|
+
lines.push(`### What to do`, data.hint, '');
|
|
176
|
+
}
|
|
169
177
|
if (data.issues && data.issues.length > 0) {
|
|
170
178
|
lines.push('### Validation Issues', '```json', JSON.stringify(data.issues, null, 2), '```', '');
|
|
171
179
|
}
|
|
@@ -176,6 +184,38 @@ export async function saveRequestDump(dir, data) {
|
|
|
176
184
|
await writeFile(fullPath, lines.join('\n'), 'utf8');
|
|
177
185
|
return resolve(fullPath);
|
|
178
186
|
}
|
|
187
|
+
export async function saveBuildDump(dir, data) {
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
const safePath = data.path.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'root';
|
|
190
|
+
const isoPrefix = data.started.toISOString().replace(/[:.]/g, '-');
|
|
191
|
+
const what = data.regeneration ? 'regen' : 'build';
|
|
192
|
+
const fullPath = join(dir, `${isoPrefix}-${what}-${data.method.toUpperCase()}-${safePath}.md`);
|
|
193
|
+
const outcome = data.gaveUp
|
|
194
|
+
? `gave up after ${data.attempts.length} of ${data.limit}`
|
|
195
|
+
: `attempt ${data.attempts.length} of ${data.limit} rejected`;
|
|
196
|
+
const lines = [
|
|
197
|
+
`# ${data.regeneration ? 'Regeneration' : 'Build'}: ` +
|
|
198
|
+
`${data.method.toUpperCase()} ${data.path}`,
|
|
199
|
+
'',
|
|
200
|
+
`- **Operation ID**: ${data.operationId ? `\`${data.operationId}\`` : '_None_'}`,
|
|
201
|
+
`- **Key**: \`${data.key}\``,
|
|
202
|
+
`- **Started**: ${data.started.toISOString()}`,
|
|
203
|
+
`- **Model**: ${data.model ? `\`${data.model}\`` : '_Unknown_'}`,
|
|
204
|
+
`- **Outcome**: ${outcome}`,
|
|
205
|
+
'',
|
|
206
|
+
];
|
|
207
|
+
for (const a of data.attempts) {
|
|
208
|
+
const elapsed = Math.max(0, a.at.getTime() - data.started.getTime());
|
|
209
|
+
lines.push('---', '', `## Attempt ${a.attempt} of ${data.limit}`, '', `_Rejected ${a.at.toISOString()}, ${elapsed}ms into the build._`, '', '### Why it was rejected', '', ...(a.diagnostics.length > 0
|
|
210
|
+
? a.diagnostics.map((d) => (d.trimStart().startsWith('-') ? d : `- ${d}`))
|
|
211
|
+
: ['_No diagnostics._']), '', '### Generator', '', '```python', a.source, '```', '');
|
|
212
|
+
}
|
|
213
|
+
await writeFile(fullPath, lines.join('\n'), 'utf8');
|
|
214
|
+
return resolve(fullPath);
|
|
215
|
+
}
|
|
216
|
+
export function formatDumpPath(label, path) {
|
|
217
|
+
return ` ${dim(`↳ ${label}:`)} ${cyan(`file://${resolve(path)}`)}`;
|
|
218
|
+
}
|
|
179
219
|
// ---------------------------------------------------------------------------
|
|
180
220
|
// Log Line Formatter
|
|
181
221
|
// ---------------------------------------------------------------------------
|
|
@@ -202,10 +242,16 @@ export function formatLogLine(data, dumpPath) {
|
|
|
202
242
|
const lastStderr = data.stderr.trim().split('\n').slice(-4).join('\n ');
|
|
203
243
|
lines.push(` ${dim('stderr:')} ${red(lastStderr)}`);
|
|
204
244
|
}
|
|
245
|
+
if (data.hint) {
|
|
246
|
+
lines.push(` ${dim('↳ fix:')} ${yellow(data.hint)}`);
|
|
247
|
+
}
|
|
205
248
|
if (dumpPath) {
|
|
206
249
|
const fileUrl = `file://${resolve(dumpPath)}`;
|
|
207
250
|
lines.push(` ${dim('↳ dump:')} ${cyan(fileUrl)}`);
|
|
208
251
|
}
|
|
252
|
+
if (data.buildDump) {
|
|
253
|
+
lines.push(formatDumpPath('build', data.buildDump));
|
|
254
|
+
}
|
|
209
255
|
return lines.join('\n');
|
|
210
256
|
}
|
|
211
257
|
// ---------------------------------------------------------------------------
|
|
@@ -216,11 +262,13 @@ export function formatRegenLimitHit(urlPath, limit, operationId) {
|
|
|
216
262
|
const op = operationId ? ` (${operationId})` : '';
|
|
217
263
|
return `${ts} ${red(bold('[REGENERATION_LIMIT_HIT]'))} Maximum regeneration limit (${limit}) reached for URL "${urlPath}"${op}. Halting further regeneration attempts.`;
|
|
218
264
|
}
|
|
219
|
-
export function formatRegenerating(urlPath, attempt, limit, operationId, reason) {
|
|
265
|
+
export function formatRegenerating(urlPath, attempt, limit, operationId, reason, stderr) {
|
|
220
266
|
const ts = dim(`[${formatTimestamp(new Date())}]`);
|
|
221
267
|
const op = operationId ? ` (${operationId})` : '';
|
|
222
268
|
const r = reason ? ` [${reason}]` : '';
|
|
223
|
-
|
|
269
|
+
const head = `${ts} ${yellow(bold('[REGENERATING]'))} Error in generator for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
|
|
270
|
+
const tail = stderr?.trim().split('\n').slice(-3).join('\n ');
|
|
271
|
+
return tail ? `${head}\n ${dim('stderr:')} ${red(tail)}` : head;
|
|
224
272
|
}
|
|
225
273
|
export function formatRegenerated(urlPath, attempt, durationMs, operationId) {
|
|
226
274
|
const ts = dim(`[${formatTimestamp(new Date())}]`);
|
package/dist/probe.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { properties } from "./schema.js";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Probes
|
|
4
4
|
//
|
|
@@ -12,13 +12,25 @@ import { propertyNames } from "./schema.js";
|
|
|
12
12
|
// schema allows — a generator that hard-codes the first probe's id passes once
|
|
13
13
|
// and fails the second, which is the mistake this catches.
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* How many items a probe asks a paged operation for.
|
|
17
|
+
*
|
|
18
|
+
* Not the schema's maximum. `bounded` below answers an unbounded want with
|
|
19
|
+
* `maximum - 1`, so a `page_size` declared `maximum: 1000` used to make every
|
|
20
|
+
* probe ask for 999 fabricated objects — minutes of Faker inside a container
|
|
21
|
+
* with a 30s budget, reported as "the file took longer than 30s" and blamed on
|
|
22
|
+
* a generator that was doing exactly what it was told. A page is judged on its
|
|
23
|
+
* shape, and three rows show it.
|
|
24
|
+
*/
|
|
25
|
+
const PROBE_PAGE_SIZE = 3;
|
|
15
26
|
export function probesFor(operation) {
|
|
27
|
+
const size = operation.paging?.size;
|
|
16
28
|
return [0, 1].map((variant) => ({
|
|
17
29
|
operationId: operation.operationId,
|
|
18
30
|
method: operation.method,
|
|
19
31
|
path: operation.path,
|
|
20
|
-
pathParams: values(operation.params, 'path', variant),
|
|
21
|
-
query: values(operation.params, 'query', variant),
|
|
32
|
+
pathParams: values(operation.params, 'path', variant, size),
|
|
33
|
+
query: values(operation.params, 'query', variant, size),
|
|
22
34
|
headers: {},
|
|
23
35
|
body: operation.requestBody
|
|
24
36
|
? sample(operation.requestBody.schema, variant, 0, operation.requestBody.schema)
|
|
@@ -26,7 +38,7 @@ export function probesFor(operation) {
|
|
|
26
38
|
seed: variant === 0 ? 1 : 2,
|
|
27
39
|
}));
|
|
28
40
|
}
|
|
29
|
-
function values(params, where, variant) {
|
|
41
|
+
function values(params, where, variant, size) {
|
|
30
42
|
const out = {};
|
|
31
43
|
for (const p of params) {
|
|
32
44
|
if (p.in !== where) {
|
|
@@ -37,7 +49,10 @@ function values(params, where, variant) {
|
|
|
37
49
|
if (!p.required && variant === 1) {
|
|
38
50
|
continue;
|
|
39
51
|
}
|
|
40
|
-
out[p.name] =
|
|
52
|
+
out[p.name] =
|
|
53
|
+
p.name === size
|
|
54
|
+
? bounded(p.schema, PROBE_PAGE_SIZE, true)
|
|
55
|
+
: sample(p.schema, variant, 0, p.schema);
|
|
41
56
|
}
|
|
42
57
|
return out;
|
|
43
58
|
}
|
|
@@ -220,10 +235,18 @@ export function echoIssues(input, value, schema) {
|
|
|
220
235
|
if (!schema) {
|
|
221
236
|
return [];
|
|
222
237
|
}
|
|
223
|
-
const declared =
|
|
238
|
+
const declared = new Map();
|
|
239
|
+
for (const p of properties(schema)) {
|
|
240
|
+
declared.set(p.name, [...(declared.get(p.name) ?? []), p.schema]);
|
|
241
|
+
}
|
|
224
242
|
const out = [];
|
|
225
243
|
for (const [name, expected] of Object.entries(input.pathParams)) {
|
|
226
|
-
|
|
244
|
+
const subs = declared.get(name);
|
|
245
|
+
if (!subs || expected === undefined || expected === null) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A value the response may not hold cannot be echoed: the schema check wins.
|
|
249
|
+
if (!subs.some((s) => admits(s, expected))) {
|
|
227
250
|
continue;
|
|
228
251
|
}
|
|
229
252
|
if (!carries(value, name, expected)) {
|
|
@@ -235,6 +258,17 @@ export function echoIssues(input, value, schema) {
|
|
|
235
258
|
}
|
|
236
259
|
return out;
|
|
237
260
|
}
|
|
261
|
+
function admits(schema, value) {
|
|
262
|
+
if (typeof schema.pattern !== 'string') {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return new RegExp(schema.pattern, 'u').test(String(value));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
238
272
|
/** Whether `name` anywhere in the value holds something equal to `expected`. */
|
|
239
273
|
function carries(value, name, expected) {
|
|
240
274
|
const stack = [value];
|
package/dist/prompt.js
CHANGED
|
@@ -36,7 +36,9 @@ export const SYSTEM = [
|
|
|
36
36
|
" in the response schema, that property must carry the request's value,",
|
|
37
37
|
' converted to the type the schema declares. `GET /users/{user_id}` called',
|
|
38
38
|
' with `user_id=12324` answers with `user_id` 12324, not a random one. Do',
|
|
39
|
-
' this generically, by looking the names up at run time.
|
|
39
|
+
' this generically, by looking the names up at run time. If the value would',
|
|
40
|
+
" break that property's schema (it fails a `pattern`, say), the schema wins:",
|
|
41
|
+
' generate a valid value instead, and do not exit non-zero. Do the same for a',
|
|
40
42
|
' query parameter where it plainly describes the content rather than',
|
|
41
43
|
' controlling the call. A paging control — a cursor, page, offset or',
|
|
42
44
|
' page-size parameter (whether in query or request body) — is never content',
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import type
|
|
2
|
+
import { type Box } from './box.ts';
|
|
3
3
|
import { type Cache } from './cache.ts';
|
|
4
4
|
import { RegenerationLimiter, RequestHistory } from './logger.ts';
|
|
5
5
|
import { PaginationLimiter } from './paging.ts';
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomInt, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { SandboxUnavailable } from "./box.js";
|
|
5
6
|
import { BuildFailed } from "./cache.js";
|
|
6
7
|
import { reason } from "./generate.js";
|
|
7
8
|
import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
|
|
@@ -39,7 +40,7 @@ export function build(opts) {
|
|
|
39
40
|
};
|
|
40
41
|
return createServer((req, res) => {
|
|
41
42
|
handle(req, res, enriched).catch((err) => {
|
|
42
|
-
const message =
|
|
43
|
+
const message = reason(err);
|
|
43
44
|
send(res, 500, { error: message });
|
|
44
45
|
opts.onRequest?.(`${req.method ?? 'GET'} ${req.url ?? '/'} 500 [SERVER_INTERNAL_ERROR] ${message}`);
|
|
45
46
|
});
|
|
@@ -93,7 +94,9 @@ async function handle(req, res, opts) {
|
|
|
93
94
|
responseBody: meta.responseBody,
|
|
94
95
|
errorKind: meta.errorKind,
|
|
95
96
|
errorMessage: meta.errorMessage,
|
|
97
|
+
hint: meta.hint,
|
|
96
98
|
stderr: meta.stderr,
|
|
99
|
+
buildDump: meta.buildDump,
|
|
97
100
|
cacheStatus: meta.cacheStatus,
|
|
98
101
|
regenerated: meta.regenerated,
|
|
99
102
|
regenAttempts: meta.regenAttempts,
|
|
@@ -181,6 +184,30 @@ async function handle(req, res, opts) {
|
|
|
181
184
|
}
|
|
182
185
|
const { operation, pathParams } = match;
|
|
183
186
|
res.setHeader('x-faker-operation', operation.operationId);
|
|
187
|
+
/** The container could not run anything: say so, and never blame the generator. */
|
|
188
|
+
const sandboxDown = async (err, extra = {}) => {
|
|
189
|
+
const bodyObj = {
|
|
190
|
+
error: err.message,
|
|
191
|
+
hint: err.hint,
|
|
192
|
+
container: err.container,
|
|
193
|
+
stderr: err.stderr,
|
|
194
|
+
};
|
|
195
|
+
res.setHeader('retry-after', '1');
|
|
196
|
+
const end = prepareSend(res, 503, bodyObj);
|
|
197
|
+
await finish(503, {
|
|
198
|
+
operationId: operation.operationId,
|
|
199
|
+
routePath: operation.path,
|
|
200
|
+
pathParams,
|
|
201
|
+
...extra,
|
|
202
|
+
errorKind: 'SANDBOX_UNAVAILABLE',
|
|
203
|
+
errorMessage: err.message,
|
|
204
|
+
hint: err.hint,
|
|
205
|
+
stderr: err.stderr,
|
|
206
|
+
responseBody: bodyObj,
|
|
207
|
+
note: 'container engine failed',
|
|
208
|
+
});
|
|
209
|
+
end();
|
|
210
|
+
};
|
|
184
211
|
let body;
|
|
185
212
|
try {
|
|
186
213
|
body = await readBody(req, opts.maxBody ?? DEFAULT_MAX_BODY);
|
|
@@ -239,12 +266,17 @@ async function handle(req, res, opts) {
|
|
|
239
266
|
generator = await opts.cache.ensure(operation);
|
|
240
267
|
}
|
|
241
268
|
catch (err) {
|
|
269
|
+
if (err instanceof SandboxUnavailable) {
|
|
270
|
+
await sandboxDown(err, { requestBody: body });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
242
273
|
const status = err instanceof BuildFailed ? 501 : 500;
|
|
243
274
|
const detail = reason(err);
|
|
244
275
|
const bodyObj = {
|
|
245
276
|
error: `no generator for ${operation.operationId}`,
|
|
246
277
|
detail,
|
|
247
278
|
diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
|
|
279
|
+
report: err instanceof BuildFailed ? err.dump : undefined,
|
|
248
280
|
};
|
|
249
281
|
const end = prepareSend(res, status, bodyObj);
|
|
250
282
|
await finish(status, {
|
|
@@ -254,6 +286,7 @@ async function handle(req, res, opts) {
|
|
|
254
286
|
requestBody: body,
|
|
255
287
|
errorKind: 'GENERATOR_BUILD_FAILED',
|
|
256
288
|
errorMessage: `no generator: ${detail}`,
|
|
289
|
+
buildDump: err instanceof BuildFailed ? err.dump : undefined,
|
|
257
290
|
responseBody: bodyObj,
|
|
258
291
|
note: `no generator: ${detail}`,
|
|
259
292
|
});
|
|
@@ -272,7 +305,17 @@ async function handle(req, res, opts) {
|
|
|
272
305
|
body,
|
|
273
306
|
seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
|
|
274
307
|
};
|
|
275
|
-
let outcome
|
|
308
|
+
let outcome;
|
|
309
|
+
try {
|
|
310
|
+
outcome = await opts.box.run(operation.key, input);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (err instanceof SandboxUnavailable) {
|
|
314
|
+
await sandboxDown(err, { requestBody: body, cacheStatus });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
276
319
|
const responseValidator = opts.checks.for(operation).response;
|
|
277
320
|
let schemaMismatch = false;
|
|
278
321
|
if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
|
|
@@ -320,7 +363,7 @@ async function handle(req, res, opts) {
|
|
|
320
363
|
return;
|
|
321
364
|
}
|
|
322
365
|
const attempt = opts.limiter.increment(urlKey);
|
|
323
|
-
opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault));
|
|
366
|
+
opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault, outcome.stderr));
|
|
324
367
|
const regenStarted = Date.now();
|
|
325
368
|
let currentSource = generator.source;
|
|
326
369
|
if (!currentSource?.trim()) {
|
|
@@ -358,6 +401,10 @@ async function handle(req, res, opts) {
|
|
|
358
401
|
}
|
|
359
402
|
}
|
|
360
403
|
catch (regenErr) {
|
|
404
|
+
if (regenErr instanceof SandboxUnavailable) {
|
|
405
|
+
await sandboxDown(regenErr, { requestBody: body, cacheStatus });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
361
408
|
const detail = reason(regenErr);
|
|
362
409
|
const bodyObj = {
|
|
363
410
|
error: `regeneration failed for ${operation.operationId}: ${detail}`,
|
package/dist/setup.js
CHANGED
|
@@ -19,7 +19,7 @@ import { Checks } from "./validate.js";
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
/**
|
|
21
21
|
* The model each provider gets when none is named — the same picks `zen meta`
|
|
22
|
-
* recommends (`RECOMMENDED` in `packages/cli/src/meta.ts`). Every ref names its
|
|
22
|
+
* recommends (`RECOMMENDED` in `packages/cli/src/meta/model.ts`). Every ref names its
|
|
23
23
|
* provider: the shorthand reads the first segment as a *provider name*, so a
|
|
24
24
|
* bare `gemini-3.8-flash` would be asked of OpenAI.
|
|
25
25
|
*/
|
package/dist/spec.js
CHANGED
|
@@ -97,7 +97,7 @@ function join(prefix, template) {
|
|
|
97
97
|
/** The path as it has to be called — what a person needs to see. */
|
|
98
98
|
export const called = (op) => op.fixed ? `${op.path}?${new URLSearchParams(op.fixed).toString()}` : op.path;
|
|
99
99
|
/**
|
|
100
|
-
* A path template has no query string, but
|
|
100
|
+
* A path template has no query string, but some vendors write one
|
|
101
101
|
* anyway — `/x/{id}?action=retry` — because they need two `post`s under one
|
|
102
102
|
* path and the object model gives them one. Taken verbatim the key matches
|
|
103
103
|
* nothing, since a request path never contains a `?`. So it is read for what
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/faker",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.25",
|
|
4
4
|
"description": "Mock HTTP server for swagger/OpenAPI documents, with response bodies generated by a model.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openapi",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
46
46
|
"ajv": "^8.17.1",
|
|
47
47
|
"ajv-formats": "^3.0.1",
|
|
48
|
-
"@zenera/cli": "^1.1.
|
|
49
|
-
"@zenera/neo": "^1.1.
|
|
48
|
+
"@zenera/cli": "^1.1.25",
|
|
49
|
+
"@zenera/neo": "^1.1.25"
|
|
50
50
|
}
|
|
51
51
|
}
|