@zenera/faker 1.1.24 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.js CHANGED
@@ -118,6 +118,8 @@ export class Cache {
118
118
  }
119
119
  catch (err) {
120
120
  const error = err instanceof Error ? err : new Error(String(err));
121
+ // The box holds the last rejected attempt; the next request must run the old file.
122
+ await box.write(operation.key, ctx.currentSource).catch(() => undefined);
121
123
  const dump = await this.#finished(operation, error);
122
124
  this.#opts.onFail?.({ operation, error, dump });
123
125
  throw error;
package/dist/command.js CHANGED
@@ -2,7 +2,7 @@ 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
8
  import { formatDumpPath } from "./logger.js";
@@ -238,7 +238,7 @@ async function cache(args, ctx) {
238
238
  // The container is named after its configuration, so a stale one would
239
239
  // otherwise sit there stopped forever with nothing pointing at it.
240
240
  // `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
241
- const mine = (await ownedContainers()).filter((c) => c.name.startsWith('zn-faker-'));
241
+ const mine = (await ownedContainers()).filter((c) => c.name.startsWith(CONTAINER_PREFIX));
242
242
  if (mine.length > 0) {
243
243
  await removeContainers(mine.map((c) => c.name));
244
244
  }
package/dist/generate.js CHANGED
@@ -47,6 +47,10 @@ export function reason(err) {
47
47
  }
48
48
  at = e.cause;
49
49
  }
50
+ const hint = err?.hint;
51
+ if (typeof hint === 'string' && hint) {
52
+ parts.push(hint);
53
+ }
50
54
  return parts.join(' — ') || String(err);
51
55
  }
52
56
  export async function build(operation, opts) {
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,6 +19,8 @@ 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;
23
25
  /** the build report, when the answer was "there is no generator" */
24
26
  buildDump?: string;
@@ -64,7 +66,7 @@ export declare function saveBuildDump(dir: string, data: BuildDumpData): Promise
64
66
  export declare function formatDumpPath(label: string, path: string): string;
65
67
  export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
66
68
  export declare function formatRegenLimitHit(urlPath: string, limit: number, operationId?: string): string;
67
- 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;
68
70
  export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
69
71
  export interface ExampleRequest {
70
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:
@@ -169,6 +171,9 @@ export async function saveRequestDump(dir, data) {
169
171
  if (data.errorMessage) {
170
172
  lines.push(`### Error Message`, `\`${data.errorMessage}\``, '');
171
173
  }
174
+ if (data.hint) {
175
+ lines.push(`### What to do`, data.hint, '');
176
+ }
172
177
  if (data.issues && data.issues.length > 0) {
173
178
  lines.push('### Validation Issues', '```json', JSON.stringify(data.issues, null, 2), '```', '');
174
179
  }
@@ -237,6 +242,9 @@ export function formatLogLine(data, dumpPath) {
237
242
  const lastStderr = data.stderr.trim().split('\n').slice(-4).join('\n ');
238
243
  lines.push(` ${dim('stderr:')} ${red(lastStderr)}`);
239
244
  }
245
+ if (data.hint) {
246
+ lines.push(` ${dim('↳ fix:')} ${yellow(data.hint)}`);
247
+ }
240
248
  if (dumpPath) {
241
249
  const fileUrl = `file://${resolve(dumpPath)}`;
242
250
  lines.push(` ${dim('↳ dump:')} ${cyan(fileUrl)}`);
@@ -254,11 +262,13 @@ export function formatRegenLimitHit(urlPath, limit, operationId) {
254
262
  const op = operationId ? ` (${operationId})` : '';
255
263
  return `${ts} ${red(bold('[REGENERATION_LIMIT_HIT]'))} Maximum regeneration limit (${limit}) reached for URL "${urlPath}"${op}. Halting further regeneration attempts.`;
256
264
  }
257
- export function formatRegenerating(urlPath, attempt, limit, operationId, reason) {
265
+ export function formatRegenerating(urlPath, attempt, limit, operationId, reason, stderr) {
258
266
  const ts = dim(`[${formatTimestamp(new Date())}]`);
259
267
  const op = operationId ? ` (${operationId})` : '';
260
268
  const r = reason ? ` [${reason}]` : '';
261
- 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;
262
272
  }
263
273
  export function formatRegenerated(urlPath, attempt, durationMs, operationId) {
264
274
  const ts = dim(`[${formatTimestamp(new Date())}]`);
package/dist/main.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=main.d.ts.map
package/dist/main.js ADDED
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ import { rmSync } from 'node:fs';
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import { join, relative } from 'node:path';
5
+ import { bold, CliError, cyan, dim, EXIT, fail, green, invokedAs, json, note, ownedContainers, parse, paths, printBanner, red, removeContainers, split, table, usageError, write, writeAll, yellow, } from 'zenera-cli/lib';
6
+ import { GENERATORS } from "./box.js";
7
+ import { reason } from "./generate.js";
8
+ import { listen } from "./server.js";
9
+ import { open } from "./setup.js";
10
+ import { SpecError } from "./spec.js";
11
+ // ---------------------------------------------------------------------------
12
+ // zfake — a mock API from a specification
13
+ //
14
+ // A separate binary rather than a `zen` subcommand. `zen` runs agent projects
15
+ // and everything in it is shaped by that; this is a server, it stays up, and
16
+ // the only thing the two genuinely share is where credentials live. Putting it
17
+ // under `zen` would have meant one command that means two different things.
18
+ // ---------------------------------------------------------------------------
19
+ /** What the user typed: `zfake`, `zen-fake`, `zen-faker` or `zenera-fake`. */
20
+ const NAME = invokedAs('zfake');
21
+ const USAGE = `${NAME} <serve|build|cache> [spec...] [options]`;
22
+ const BANNER = {
23
+ head: 'Zenera',
24
+ accent: 'Faker',
25
+ subtitle: 'Mock API Server',
26
+ };
27
+ const OPTIONS = {
28
+ port: { type: 'string' },
29
+ host: { type: 'string' },
30
+ model: { type: 'string' },
31
+ image: { type: 'string' },
32
+ cache: { type: 'string' },
33
+ attempts: { type: 'string' },
34
+ concurrency: { type: 'string' },
35
+ seed: { type: 'string' },
36
+ timeout: { type: 'string' },
37
+ 'max-body': { type: 'string' },
38
+ rebuild: { type: 'boolean' },
39
+ 'no-cache': { type: 'boolean' },
40
+ quiet: { type: 'boolean' },
41
+ json: { type: 'boolean' },
42
+ };
43
+ async function main(argv) {
44
+ const { name, after } = split(argv);
45
+ if (!name || name === 'help' || name === '--help' || name === '-h') {
46
+ usage();
47
+ return name ? EXIT.ok : EXIT.usage;
48
+ }
49
+ try {
50
+ switch (name) {
51
+ case 'serve':
52
+ return await serve(after);
53
+ case 'build':
54
+ return await warm(after);
55
+ case 'cache':
56
+ return await cache(after);
57
+ default:
58
+ throw usageError(`unknown command "${name}"`, USAGE);
59
+ }
60
+ }
61
+ catch (err) {
62
+ if (err instanceof CliError) {
63
+ fail(err.message, err.hint);
64
+ return err.code;
65
+ }
66
+ if (err instanceof SpecError) {
67
+ fail(err.message, err.hint);
68
+ return EXIT.invalid;
69
+ }
70
+ fail(err instanceof Error ? err.message : String(err));
71
+ return EXIT.failed;
72
+ }
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // serve
76
+ // ---------------------------------------------------------------------------
77
+ async function serve(args) {
78
+ const { values, positionals } = parse(args, OPTIONS, `${NAME} serve <spec...>`);
79
+ const loud = !values.quiet && !values.json;
80
+ if (loud) {
81
+ printBanner(BANNER);
82
+ }
83
+ const setup = await start(values, positionals, 'serve');
84
+ if (loud) {
85
+ printSpecs(setup.router.operations);
86
+ }
87
+ const host = values.host ?? '127.0.0.1';
88
+ const listener = await listen({
89
+ router: setup.router,
90
+ cache: setup.cache,
91
+ checks: setup.checks,
92
+ box: setup.box,
93
+ seed: number(values.seed, 'seed'),
94
+ maxBody: number(values['max-body'], 'max-body'),
95
+ onRequest: values.quiet ? undefined : (line) => note(dim(line)),
96
+ }, host, number(values.port, 'port') ?? 8787);
97
+ if (!values.quiet) {
98
+ note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
99
+ dim(`${setup.router.operations.length} operations`));
100
+ if (host !== '127.0.0.1' && host !== 'localhost') {
101
+ note(yellow(`bound to ${host} — this mock is reachable from the network`));
102
+ }
103
+ note(dim(`generators: ${setup.root}/${GENERATORS}`));
104
+ }
105
+ // The address is the answer; the rest was narration.
106
+ write(`http://${host}:${listener.port}`);
107
+ await until(['SIGINT', 'SIGTERM']);
108
+ if (!values.quiet) {
109
+ note(dim('stopping'));
110
+ }
111
+ await listener.close();
112
+ await setup.close();
113
+ return EXIT.ok;
114
+ }
115
+ /** Resolves when one of the signals arrives, and stops listening for them. */
116
+ function until(signals) {
117
+ return new Promise((settle) => {
118
+ const done = () => {
119
+ for (const s of signals) {
120
+ process.off(s, done);
121
+ }
122
+ settle();
123
+ };
124
+ for (const s of signals) {
125
+ process.once(s, done);
126
+ }
127
+ });
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // build — every generator, up front
131
+ // ---------------------------------------------------------------------------
132
+ async function warm(args) {
133
+ const { values, positionals } = parse(args, OPTIONS, `${NAME} build <spec...>`);
134
+ const loud = !values.quiet && !values.json;
135
+ if (loud) {
136
+ printBanner(BANNER);
137
+ }
138
+ const setup = await start(values, positionals, 'build');
139
+ if (loud) {
140
+ printSpecs(setup.router.operations);
141
+ }
142
+ const results = [];
143
+ try {
144
+ for (const operation of setup.router.operations) {
145
+ const id = `${operation.method.toUpperCase()} ${operation.path}`;
146
+ if (!operation.success.schema) {
147
+ results.push({ operation: id, status: 'skipped', detail: 'no response body' });
148
+ continue;
149
+ }
150
+ try {
151
+ const generator = await setup.cache.ensure(operation);
152
+ results.push({ operation: id, status: generator.cached ? 'cached' : 'built' });
153
+ }
154
+ catch (err) {
155
+ results.push({
156
+ operation: id,
157
+ status: 'failed',
158
+ detail: reason(err),
159
+ });
160
+ }
161
+ }
162
+ }
163
+ finally {
164
+ await setup.close();
165
+ }
166
+ if (values.json) {
167
+ json(results);
168
+ }
169
+ else {
170
+ writeAll(table(results.map((r) => [
171
+ ` ${mark(r.status)}`,
172
+ r.operation,
173
+ dim(r.detail?.slice(0, 100) ?? ''),
174
+ ])));
175
+ }
176
+ return results.some((r) => r.status === 'failed') ? EXIT.failed : EXIT.ok;
177
+ }
178
+ const mark = (status) => status === 'failed' ? red('failed') : status === 'skipped' ? dim('skipped') : green(status);
179
+ // ---------------------------------------------------------------------------
180
+ // cache
181
+ // ---------------------------------------------------------------------------
182
+ async function cache(args) {
183
+ const { values, positionals } = parse(args, OPTIONS, `${NAME} cache <ls|clear>`);
184
+ const root = values.cache ?? paths.faker();
185
+ const sub = positionals[0] ?? 'ls';
186
+ if (sub === 'ls') {
187
+ const entries = await listGenerators(root);
188
+ if (values.json) {
189
+ json(entries);
190
+ return EXIT.ok;
191
+ }
192
+ if (entries.length === 0) {
193
+ note('nothing cached yet');
194
+ return EXIT.ok;
195
+ }
196
+ writeAll(table([
197
+ [bold('KEY'), bold('OPERATION'), bold('MODEL'), bold('TRIES')],
198
+ ...entries.map((e) => [
199
+ e.key,
200
+ `${e.method?.toUpperCase() ?? '?'} ${e.path ?? ''}`,
201
+ dim(e.model ?? '—'),
202
+ dim(String(e.attempts ?? '—')),
203
+ ]),
204
+ ]));
205
+ return EXIT.ok;
206
+ }
207
+ if (sub === 'clear') {
208
+ rmSync(join(root, GENERATORS), { recursive: true, force: true });
209
+ // The container is named after its configuration, so a stale one would
210
+ // otherwise sit there stopped forever with nothing pointing at it.
211
+ // `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
212
+ const mine = (await ownedContainers()).filter((c) => c.name.startsWith('zn-faker-'));
213
+ if (mine.length > 0) {
214
+ await removeContainers(mine.map((c) => c.name));
215
+ }
216
+ note(`${green('cleared')} ${dim(root)}`);
217
+ return EXIT.ok;
218
+ }
219
+ throw usageError(`unknown cache command "${sub}"`, `${NAME} cache <ls|clear>`);
220
+ }
221
+ async function listGenerators(root) {
222
+ let keys;
223
+ try {
224
+ keys = await readdir(join(root, GENERATORS));
225
+ }
226
+ catch {
227
+ return [];
228
+ }
229
+ const out = [];
230
+ for (const key of keys.sort()) {
231
+ try {
232
+ const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
233
+ out.push({ key, ...meta });
234
+ }
235
+ catch {
236
+ out.push({ key });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+ function summarize(operations) {
242
+ const by = new Map();
243
+ for (const op of operations) {
244
+ let stat = by.get(op.source);
245
+ if (!stat) {
246
+ stat = { source: op.source, paths: new Set(), methods: 0, functions: 0 };
247
+ by.set(op.source, stat);
248
+ }
249
+ stat.paths.add(op.path);
250
+ stat.methods += 1;
251
+ if (op.success.schema) {
252
+ stat.functions += 1;
253
+ }
254
+ }
255
+ return [...by.values()];
256
+ }
257
+ const HEADERS = ['PATHS', 'METHODS', 'FUNCTIONS'];
258
+ function printSpecs(operations) {
259
+ const stats = summarize(operations);
260
+ const rows = stats.map((s) => ({
261
+ name: relative(process.cwd(), s.source) || s.source,
262
+ cells: [s.paths.size, s.methods, s.functions],
263
+ }));
264
+ if (rows.length > 1) {
265
+ rows.push({
266
+ name: 'total',
267
+ cells: HEADERS.map((_, i) => rows.reduce((n, r) => n + r.cells[i], 0)),
268
+ });
269
+ }
270
+ // Numbers are padded before they are styled: a colour code has no width,
271
+ // and `table` cannot know that.
272
+ const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r.cells[i]).length)));
273
+ const lines = table([
274
+ [bold('SPEC'), ...HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
275
+ ...rows.map((r) => [
276
+ r.name === 'total' ? dim(r.name) : r.name,
277
+ ...r.cells.map((c, i) => String(c).padStart(widths[i])),
278
+ ]),
279
+ ]);
280
+ note('');
281
+ for (const line of lines) {
282
+ note(` ${line}`);
283
+ }
284
+ note('');
285
+ }
286
+ // ---------------------------------------------------------------------------
287
+ // Shared
288
+ // ---------------------------------------------------------------------------
289
+ async function start(values, specs, what) {
290
+ const loud = !values.quiet && !values.json;
291
+ return open({
292
+ specs,
293
+ cwd: process.cwd(),
294
+ cache: values.cache,
295
+ model: values.model,
296
+ image: values.image,
297
+ attempts: number(values.attempts, 'attempts'),
298
+ concurrency: number(values.concurrency, 'concurrency'),
299
+ timeout: number(values.timeout, 'timeout'),
300
+ rebuild: values.rebuild,
301
+ ephemeral: values['no-cache'],
302
+ onImageBuild: loud
303
+ ? (tag) => note(`${dim('building')} ${tag} ${dim('— once, then cached')}`)
304
+ : undefined,
305
+ events: {
306
+ onStart: loud
307
+ ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
308
+ : undefined,
309
+ onAttempt: loud
310
+ ? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
311
+ : undefined,
312
+ onReady: loud && what === 'serve'
313
+ ? ({ operation, cached }) => cached
314
+ ? undefined
315
+ : note(` ${green('ready')} ${dim(operation.operationId)}`)
316
+ : undefined,
317
+ onFail: loud
318
+ ? ({ operation, error }) => note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`)
319
+ : undefined,
320
+ },
321
+ });
322
+ }
323
+ function number(raw, what) {
324
+ if (raw === undefined) {
325
+ return undefined;
326
+ }
327
+ const value = Number(raw);
328
+ if (!Number.isFinite(value)) {
329
+ throw usageError(`--${what} must be a number, got "${raw}"`);
330
+ }
331
+ return value;
332
+ }
333
+ function usage() {
334
+ printBanner(BANNER);
335
+ write(`${bold(NAME)} ${dim('— a mock API from an openapi/swagger document')}`);
336
+ write(`\n${bold('Usage')}\n ${USAGE}`);
337
+ write(`\n${bold('Commands')}`);
338
+ writeAll(table([
339
+ [' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
340
+ [' build <spec...>', dim('Write every generator now and exit.')],
341
+ [' cache ls|clear', dim('What has been generated, or throw it away.')],
342
+ ]));
343
+ write(`\n${bold('Options')}`);
344
+ writeAll(table([
345
+ [' --port <n>', dim('Default 8787.')],
346
+ [' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
347
+ [' --model <ref>', dim('Which model writes the generators.')],
348
+ [' --image <ref>', dim('Skip the baked image and use this one.')],
349
+ [' --cache <dir>', dim('Where generators live. Default ~/.zenera/neo/faker.')],
350
+ [' --seed <n>', dim('Answer the same request the same way every time.')],
351
+ [' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
352
+ [' --concurrency <n>', dim('Generators written at once. Default 4.')],
353
+ [' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
354
+ [' --max-body <n>', dim('Largest request body accepted, in bytes.')],
355
+ [' --rebuild', dim('Ignore what is cached and write it again.')],
356
+ [' --no-cache', dim('Do not record what is written.')],
357
+ [' --quiet', dim('No narration.')],
358
+ [' --json', dim('Machine-readable output.')],
359
+ ]));
360
+ write(`\n${dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`)}`);
361
+ }
362
+ process.exitCode = await main(process.argv.slice(2));
363
+ //# sourceMappingURL=main.js.map
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
  //
@@ -235,10 +235,18 @@ export function echoIssues(input, value, schema) {
235
235
  if (!schema) {
236
236
  return [];
237
237
  }
238
- 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
+ }
239
242
  const out = [];
240
243
  for (const [name, expected] of Object.entries(input.pathParams)) {
241
- 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))) {
242
250
  continue;
243
251
  }
244
252
  if (!carries(value, name, expected)) {
@@ -250,6 +258,17 @@ export function echoIssues(input, value, schema) {
250
258
  }
251
259
  return out;
252
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
+ }
253
272
  /** Whether `name` anywhere in the value holds something equal to `expected`. */
254
273
  function carries(value, name, expected) {
255
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,6 +94,7 @@ 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,
97
99
  buildDump: meta.buildDump,
98
100
  cacheStatus: meta.cacheStatus,
@@ -182,6 +184,30 @@ async function handle(req, res, opts) {
182
184
  }
183
185
  const { operation, pathParams } = match;
184
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
+ };
185
211
  let body;
186
212
  try {
187
213
  body = await readBody(req, opts.maxBody ?? DEFAULT_MAX_BODY);
@@ -240,6 +266,10 @@ async function handle(req, res, opts) {
240
266
  generator = await opts.cache.ensure(operation);
241
267
  }
242
268
  catch (err) {
269
+ if (err instanceof SandboxUnavailable) {
270
+ await sandboxDown(err, { requestBody: body });
271
+ return;
272
+ }
243
273
  const status = err instanceof BuildFailed ? 501 : 500;
244
274
  const detail = reason(err);
245
275
  const bodyObj = {
@@ -275,7 +305,17 @@ async function handle(req, res, opts) {
275
305
  body,
276
306
  seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
277
307
  };
278
- 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
+ }
279
319
  const responseValidator = opts.checks.for(operation).response;
280
320
  let schemaMismatch = false;
281
321
  if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
@@ -323,7 +363,7 @@ async function handle(req, res, opts) {
323
363
  return;
324
364
  }
325
365
  const attempt = opts.limiter.increment(urlKey);
326
- 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));
327
367
  const regenStarted = Date.now();
328
368
  let currentSource = generator.source;
329
369
  if (!currentSource?.trim()) {
@@ -361,6 +401,10 @@ async function handle(req, res, opts) {
361
401
  }
362
402
  }
363
403
  catch (regenErr) {
404
+ if (regenErr instanceof SandboxUnavailable) {
405
+ await sandboxDown(regenErr, { requestBody: body, cacheStatus });
406
+ return;
407
+ }
364
408
  const detail = reason(regenErr);
365
409
  const bodyObj = {
366
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/faker",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
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.24",
49
- "@zenera/neo": "^1.1.24"
48
+ "@zenera/cli": "^1.1.26",
49
+ "@zenera/neo": "^1.1.26"
50
50
  }
51
51
  }