@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 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 VMware NSX
61
- and others write when they need two `post`s under one path — is read for what
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`. If it passes, the generator is kept in this machine's shared cache
79
- under `~/.zenera/neo/cache/faker-generator/`, keyed by the operation's shape, and
80
- every later request is just `podman exec python3 gen.py in.json out.json` - no
81
- model, no tokens. The store is the machine's, so the same document served from
82
- another directory costs nothing the second time.
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 a container of this name left behind by a process that did not
28
- * get to clean up. Belt to `persist: false`'s braces: a hard kill never
29
- * runs `dispose`, and the leftover is exactly the stale-mount trap above.
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
- key: 'faker',
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 a container of this name left behind by a process that did not
66
- * get to clean up. Belt to `persist: false`'s braces: a hard kill never
67
- * runs `dispose`, and the leftover is exactly the stale-mount trap above.
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, ['rm', '--force', '--volumes', this.sandbox.name], {
71
- timeoutMs: 60_000,
72
- }).catch(() => undefined);
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
- const res = await this.sandbox.exec(script, { timeout: this.#timeout });
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.#opts.onAttempt?.({ operation, attempt, diagnostics }),
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
- this.#opts.onFail?.({ operation, error });
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.#opts.onAttempt?.({ operation, attempt, diagnostics }),
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.#opts.onFail?.({ operation, error });
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 3.')],
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('zn-faker-'));
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 }) => note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(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
  });
@@ -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 ?? 3);
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 ?? 3);
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
- return `${ts} ${yellow(bold('[REGENERATING]'))} Error in generator for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
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 { propertyNames } from "./schema.js";
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] = sample(p.schema, variant, 0, p.schema);
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 = propertyNames(schema);
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
- if (!declared.has(name) || expected === undefined || expected === null) {
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. Do the same for a',
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 { Box } from './box.ts';
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 = err instanceof Error ? err.message : String(err);
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 = await opts.box.run(operation.key, input);
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 VMware, Oracle and others write one
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.23",
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.23",
49
- "@zenera/neo": "^1.1.23"
48
+ "@zenera/cli": "^1.1.25",
49
+ "@zenera/neo": "^1.1.25"
50
50
  }
51
51
  }