@zenera/faker 1.1.24 → 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/dist/box.d.ts +26 -3
- package/dist/box.js +110 -9
- package/dist/cache.js +2 -0
- package/dist/command.js +2 -2
- package/dist/generate.js +4 -0
- package/dist/logger.d.ts +4 -2
- package/dist/logger.js +12 -2
- package/dist/probe.js +22 -3
- package/dist/prompt.js +3 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +47 -3
- package/dist/setup.js +1 -1
- package/package.json +3 -3
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.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(
|
|
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
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
|
-
|
|
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/probe.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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,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
|
|
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.
|
|
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
|
}
|