@zenera/faker 1.1.20 → 1.1.22

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,6 +57,12 @@ 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
62
+ it means rather than taken verbatim: the path is the part before the `?`, and
63
+ each pair after it becomes a required query value that tells those operations
64
+ apart. Call one without it and the 404 says which queries the path answers to.
65
+
60
66
  ## How a body is produced
61
67
 
62
68
  The first time an operation is called, the faker asks a model to write a
@@ -86,20 +92,21 @@ nothing it shouldn't, and hands back `abc` again - so the client asks for the
86
92
  same page forever.
87
93
 
88
94
  The faker reads the document for this. Where an operation has a paging
89
- parameter (`cursor`, `page`, `offset`, `page_token`, …) and a response property
90
- that carries the next one (`next`, `next_cursor`, `has_more`, …), three things
91
- happen, all in the operation's own names:
95
+ parameter (`cursor`, `page`, `offset`, `page_token`, …) in query or request body
96
+ and a response property that carries the next one (`next`, `next_cursor`, `has_more`, …),
97
+ three things happen, all in the operation's own names:
92
98
 
93
- - the model is told to fabricate **three pages** in total, to build the token
99
+ - the model is told to fabricate **between 1 and 10 pages** in total, to build the token
94
100
  out of the paging parameter rather than the seed, and to end the list - null,
95
- absent, or `has_more: false` where the schema leaves no other room;
101
+ absent, or `has_more: false` (and empty items) where the schema leaves no other room;
96
102
  - the generator is then **walked**: the faker calls it with no cursor, follows
97
103
  the token it gets back, and rejects the file if the token repeats, cycles, or
98
104
  never runs out. The diagnostics say which, and the model gets another go;
99
- - at request time a token identical to the one just sent is **cut** - nulled or
100
- dropped, whichever the schema allows - and the request line says so. Nothing
101
- is invented in its place; a generator written before this rule existed is
102
- still cached, and a cache is not rebuilt because a rule changed.
105
+ - at request time, pagination is tracked and bounded: a token identical to the one
106
+ just sent is **cut** (nulled or dropped), cycles are stopped, and streams are capped
107
+ to random 1-10 pages, returning empty results if a client loops past the end. The
108
+ request line narrates each cut. Nothing is invented in its place; a generator written
109
+ before this rule existed is still cached, and a cache is not rebuilt because a rule changed.
103
110
 
104
111
  Only paginated operations are affected. Their cache keys changed once, so they
105
112
  are written again on first use; everything else keeps the key it had.
@@ -108,8 +115,8 @@ are written again on first use; everything else keeps the key it had.
108
115
  Plenty of documents describe the envelope and never write down the parameter
109
116
  that reads it back. The first two steps cannot help there - nothing static can
110
117
  see a parameter that is not declared - but the cut still applies: it takes the
111
- paging parameter from the request itself, since a client only sends `?cursor=X`
112
- because a body handed it X.
118
+ paging parameter from query parameters or request body, since a client only sends
119
+ `cursor=X` because a body handed it X.
113
120
 
114
121
  ## Commands
115
122
 
@@ -117,15 +124,27 @@ because a body handed it X.
117
124
  zen faker serve <spec...> Serve them. Generators are written on demand.
118
125
  zen faker build <spec...> Write every generator now and exit.
119
126
  zen faker cache ls | clear What has been generated, or throw it away.
127
+ zen faker model [ref] Show or set the model it uses.
120
128
  ```
121
129
 
130
+ The model is looked for in this order: `--model`, `ZENERA_FAKER_MODEL`, what
131
+ `zen faker model <provider>:<id>` stored, then the best one your keys can buy.
132
+ The stored one lives beside the keyring in `~/.zenera/neo/faker.json`, so it is
133
+ remembered between runs and across projects; `zen faker model --clear` forgets
134
+ it.
135
+
122
136
  Useful options: `--port`, `--host` (reachable only from this machine by
123
137
  default), `--model`, `--seed` (same request, same answer), `--rebuild`,
124
138
  `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
125
139
  workspace), `--quiet`. `zen help faker` prints the full table.
126
140
 
127
- `GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
128
- health check.
141
+ `GET /` is a contents page: every operation the mock is serving, grouped by the
142
+ document it came from, with its parameters and the shape of its answer. Each
143
+ one has a **Run** button, which opens a form — a box per parameter, required
144
+ ones marked, a body prefilled with something the mock will accept — and shows
145
+ the status, the time and the answer. `GET /__faker/routes` is the same thing as
146
+ JSON; `GET /__faker/health` is a health check. A document that declares `/`
147
+ itself keeps it.
129
148
 
130
149
  ## Credentials
131
150
 
package/dist/cache.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Model } from '@zenera/neo';
2
2
  import type { Box } from './box.ts';
3
+ import type { GeneratorInput } from './envelope.ts';
3
4
  import { BuildFailed } from './generate.ts';
4
- import type { Operation } from './spec.ts';
5
+ import type { ExampleRequest } from './prompt.ts';
6
+ import { type Operation } from './spec.ts';
5
7
  import type { Checks } from './validate.ts';
6
8
  export declare const FAKER_KIND = "faker-generator";
7
9
  /** What is kept about a generator besides the code, for `zen faker cache ls`. */
@@ -12,6 +14,7 @@ export interface GeneratorMeta {
12
14
  source?: string;
13
15
  model?: string;
14
16
  attempts?: number;
17
+ rulesVersion?: number;
15
18
  createdAt?: string;
16
19
  }
17
20
  export interface Generator {
@@ -53,6 +56,13 @@ export interface CacheOptions {
53
56
  error: Error;
54
57
  }) => void;
55
58
  }
59
+ export interface CacheRegenerateContext {
60
+ currentSource: string;
61
+ failingInput: GeneratorInput;
62
+ fault: string;
63
+ stderr?: string;
64
+ examples?: readonly ExampleRequest[];
65
+ }
56
66
  export declare class Cache {
57
67
  #private;
58
68
  constructor(opts: CacheOptions);
@@ -65,6 +75,9 @@ export declare class Cache {
65
75
  * build and for everyone who waited on it, and true from then on.
66
76
  */
67
77
  ensure(operation: Operation): Promise<Generator>;
78
+ get model(): Model;
79
+ getSource(key: string): Promise<string | undefined>;
80
+ regenerate(operation: Operation, ctx: CacheRegenerateContext): Promise<Generator>;
68
81
  }
69
82
  export { BuildFailed };
70
83
  //# sourceMappingURL=cache.d.ts.map
package/dist/cache.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Cache as Store } from '@zenera/cli/lib';
2
- import { build, BuildFailed } from "./generate.js";
2
+ import { build, BuildFailed, regenerate } from "./generate.js";
3
+ import { GENERATOR_RULES_VERSION } from "./spec.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // The cache
5
6
  //
@@ -64,6 +65,60 @@ export class Cache {
64
65
  this.#live.set(operation.key, started);
65
66
  return started;
66
67
  }
68
+ get model() {
69
+ return this.#opts.model;
70
+ }
71
+ async getSource(key) {
72
+ const found = this.#store.get(key);
73
+ return found?.source?.trim() ? found.source : undefined;
74
+ }
75
+ async regenerate(operation, ctx) {
76
+ const { box, model, checks, ephemeral } = this.#opts;
77
+ await this.#enter();
78
+ this.#opts.onStart?.({ operation });
79
+ try {
80
+ const built = await regenerate(operation, {
81
+ model,
82
+ box,
83
+ checks,
84
+ attempts: this.#opts.attempts,
85
+ currentSource: ctx.currentSource,
86
+ failingInput: ctx.failingInput,
87
+ fault: ctx.fault,
88
+ stderr: ctx.stderr,
89
+ examples: ctx.examples,
90
+ onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
91
+ });
92
+ if (!ephemeral) {
93
+ this.#store.put(operation.key, {
94
+ source: built.source,
95
+ meta: {
96
+ operationId: operation.operationId,
97
+ method: operation.method,
98
+ path: operation.path,
99
+ source: operation.source,
100
+ model: model.id,
101
+ attempts: built.attempts,
102
+ rulesVersion: GENERATOR_RULES_VERSION,
103
+ createdAt: new Date().toISOString(),
104
+ },
105
+ });
106
+ }
107
+ this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
108
+ const gen = { key: operation.key, source: built.source, cached: false };
109
+ this.#settled.add(operation.key);
110
+ this.#live.set(operation.key, Promise.resolve(gen));
111
+ return gen;
112
+ }
113
+ catch (err) {
114
+ const error = err instanceof Error ? err : new Error(String(err));
115
+ this.#opts.onFail?.({ operation, error });
116
+ throw error;
117
+ }
118
+ finally {
119
+ this.#leave();
120
+ }
121
+ }
67
122
  async #make(operation) {
68
123
  const { box, model, checks, rebuild, ephemeral } = this.#opts;
69
124
  // Read before queueing: a cache hit costs nothing and must not wait
@@ -93,6 +148,7 @@ export class Cache {
93
148
  source: operation.source,
94
149
  model: model.id,
95
150
  attempts: built.attempts,
151
+ rulesVersion: GENERATOR_RULES_VERSION,
96
152
  createdAt: new Date().toISOString(),
97
153
  },
98
154
  });
package/dist/command.js CHANGED
@@ -1,12 +1,13 @@
1
- import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
1
+ import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, KeyStore, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
2
+ import { createModel } from '@zenera/neo';
2
3
  import { rmSync } from 'node:fs';
3
4
  import { join, relative, resolve } from 'node:path';
4
5
  import { GENERATORS } from "./box.js";
5
6
  import { FAKER_KIND } from "./cache.js";
6
7
  import { reason } from "./generate.js";
7
8
  import { listen } from "./server.js";
8
- import { open } from "./setup.js";
9
- import {} from "./spec.js";
9
+ import { chooseModel, MODEL_ENV, open, readSettings, SOURCE_LABELS, writeSettings, } from "./setup.js";
10
+ import { called } from "./spec.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // zen faker — a mock API from a specification
12
13
  //
@@ -18,7 +19,7 @@ import {} from "./spec.js";
18
19
  // resolve until a signal arrives, and the process lives as long as the answer
19
20
  // takes, which is what every other command already means.
20
21
  // ---------------------------------------------------------------------------
21
- const USAGE = 'zen faker <serve|build|cache> [spec...]';
22
+ const USAGE = 'zen faker <serve|build|cache|model> [spec...]';
22
23
  // `--json` is the frame's, lifted out of the arguments before they arrive; it
23
24
  // reaches us as `ctx.json` and must not be declared again or `strict` rejects it.
24
25
  const OPTIONS = {
@@ -32,8 +33,11 @@ const OPTIONS = {
32
33
  seed: { type: 'string' },
33
34
  timeout: { type: 'string' },
34
35
  'max-body': { type: 'string' },
36
+ 'regen-limit': { type: 'string' },
37
+ 'requests-dir': { type: 'string' },
35
38
  rebuild: { type: 'boolean' },
36
39
  'no-cache': { type: 'boolean' },
40
+ clear: { type: 'boolean' },
37
41
  quiet: { type: 'boolean' },
38
42
  };
39
43
  export const command = {
@@ -45,13 +49,14 @@ export const command = {
45
49
  [' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
46
50
  [' build <spec...>', dim('Write every generator now and exit.')],
47
51
  [' cache ls|clear', dim('What has been generated, or throw it away.')],
52
+ [' model [ref]', dim('Show or set the model it uses. Remembered between runs.')],
48
53
  ]),
49
54
  '',
50
55
  'Options',
51
56
  ...table([
52
57
  [' --port <n>', dim('Default 8787.')],
53
58
  [' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
54
- [' --model <ref>', dim('Which model writes the generators.')],
59
+ [' --model <ref>', dim('Which model writes the generators, for this run only.')],
55
60
  [' --image <ref>', dim('Skip the baked image and use this one.')],
56
61
  [' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
57
62
  [' --seed <n>', dim('Answer the same request the same way every time.')],
@@ -59,11 +64,16 @@ export const command = {
59
64
  [' --concurrency <n>', dim('Generators written at once. Default 4.')],
60
65
  [' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
61
66
  [' --max-body <n>', dim('Largest request body accepted, in bytes.')],
67
+ [' --regen-limit <n>', dim('Maximum regenerations per URL on error. Default 3.')],
68
+ [' --requests-dir <dir>', dim('Where to save request/response .md files.')],
62
69
  [' --rebuild', dim('Ignore what is cached and write it again.')],
63
70
  [' --no-cache', dim('Do not record what is written.')],
71
+ [' --clear', dim('With `model`: forget the stored one.')],
64
72
  [' --quiet', dim('No narration.')],
65
73
  ]),
66
74
  '',
75
+ dim(`The model is looked for in this order: ${cyan('--model')}, ${cyan(MODEL_ENV)}, ` +
76
+ `${cyan('zen faker model')}, then the best one your keys can buy.`),
67
77
  dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
68
78
  ],
69
79
  async run(ctx) {
@@ -75,6 +85,8 @@ export const command = {
75
85
  return await warm(rest, ctx);
76
86
  case 'cache':
77
87
  return await cache(rest, ctx);
88
+ case 'model':
89
+ return await model(rest, ctx);
78
90
  default:
79
91
  throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
80
92
  }
@@ -91,6 +103,9 @@ async function serve(args, ctx) {
91
103
  printSpecs(setup.router.operations, ctx.cwd);
92
104
  }
93
105
  const host = values.host ?? '127.0.0.1';
106
+ const requestsDir = values['requests-dir']
107
+ ? resolve(ctx.cwd, values['requests-dir'])
108
+ : undefined;
94
109
  const listener = await listen({
95
110
  router: setup.router,
96
111
  cache: setup.cache,
@@ -98,7 +113,9 @@ async function serve(args, ctx) {
98
113
  box: setup.box,
99
114
  seed: number(values.seed, 'seed'),
100
115
  maxBody: number(values['max-body'], 'max-body'),
101
- onRequest: values.quiet ? undefined : (line) => note(dim(line)),
116
+ requestsDir,
117
+ regenLimit: number(values['regen-limit'], 'regen-limit'),
118
+ onRequest: values.quiet ? undefined : (line) => note(line),
102
119
  }, host, number(values.port, 'port') ?? 8787);
103
120
  if (!values.quiet) {
104
121
  note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
@@ -107,6 +124,7 @@ async function serve(args, ctx) {
107
124
  note(yellow(`bound to ${host} — this mock is reachable from the network`));
108
125
  }
109
126
  note(dim(`generators: ${setup.root}/${GENERATORS}`));
127
+ note(dim(`request logs: ${requestsDir ?? `${setup.root}/requests`}`));
110
128
  }
111
129
  // The address is the answer; the rest was narration.
112
130
  write(`http://${host}:${listener.port}`);
@@ -144,7 +162,7 @@ async function warm(args, ctx) {
144
162
  const results = [];
145
163
  try {
146
164
  for (const operation of setup.router.operations) {
147
- const id = `${operation.method.toUpperCase()} ${operation.path}`;
165
+ const id = `${operation.method.toUpperCase()} ${called(operation)}`;
148
166
  if (!operation.success.schema) {
149
167
  results.push({ operation: id, status: 'skipped', detail: 'no response body' });
150
168
  continue;
@@ -238,6 +256,58 @@ function listGenerators() {
238
256
  .map((row) => ({ key: row.key, ...row.value?.meta }))
239
257
  .sort((a, b) => a.key.localeCompare(b.key));
240
258
  }
259
+ // ---------------------------------------------------------------------------
260
+ // model
261
+ //
262
+ // A stored model is remembered the way `zen meta model` remembers one: in the
263
+ // home directory, beside the keyring, because it is a choice about this tool on
264
+ // this machine. Shown rather than merely set, since the question when a run
265
+ // surprises you is not "which model" but "why that one".
266
+ // ---------------------------------------------------------------------------
267
+ const MODEL_USAGE = 'zen faker model [ref|--clear]';
268
+ async function model(args, ctx) {
269
+ const { values, positionals } = parse(args, OPTIONS, MODEL_USAGE);
270
+ const ref = positionals[0];
271
+ if (ref && values.clear) {
272
+ throw usageError('--clear takes no model', MODEL_USAGE);
273
+ }
274
+ // Before anything builds a model: vertex reads its project out of the
275
+ // environment, and the keyring is what puts it there.
276
+ const keys = await KeyStore.open();
277
+ keys.materialize();
278
+ if (ref || values.clear) {
279
+ const file = await readSettings();
280
+ if (ref) {
281
+ // Only that the ref parses: asking the provider costs a call, and
282
+ // the next run makes one anyway.
283
+ createModel(ref);
284
+ writeSettings({ ...file, model: ref });
285
+ }
286
+ else {
287
+ delete file.model;
288
+ writeSettings(file);
289
+ }
290
+ if (ctx.json) {
291
+ json({ model: ref });
292
+ return;
293
+ }
294
+ note(ref ? `${green('set')} ${ref}` : `${green('cleared')} ${dim('the stored model')}`);
295
+ }
296
+ const chosen = await chooseModel(values.model, keys);
297
+ if (ctx.json) {
298
+ json({ model: chosen?.ref, from: chosen?.from, stored: (await readSettings()).model });
299
+ return;
300
+ }
301
+ if (!chosen) {
302
+ note('no model, and no key to recommend one from');
303
+ note(dim('add a key: zen key add openai'));
304
+ return;
305
+ }
306
+ note(`${cyan(chosen.ref)} ${dim(`from ${SOURCE_LABELS[chosen.from]}`)}`);
307
+ if (chosen.from === 'default') {
308
+ note(dim(`set one: zen faker model <provider>:<id>`));
309
+ }
310
+ }
241
311
  function summarize(operations) {
242
312
  const by = new Map();
243
313
  for (const op of operations) {
@@ -288,7 +358,7 @@ function printSpecs(operations, cwd) {
288
358
  // ---------------------------------------------------------------------------
289
359
  async function start(values, specs, ctx, what) {
290
360
  const loud = !values.quiet && !ctx.json;
291
- return open({
361
+ const setup = await open({
292
362
  specs,
293
363
  cwd: ctx.cwd,
294
364
  cache: values.cache,
@@ -304,7 +374,7 @@ async function start(values, specs, ctx, what) {
304
374
  : undefined,
305
375
  events: {
306
376
  onStart: loud
307
- ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
377
+ ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${called(operation)}`)
308
378
  : undefined,
309
379
  onAttempt: loud
310
380
  ? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
@@ -319,6 +389,10 @@ async function start(values, specs, ctx, what) {
319
389
  : undefined,
320
390
  },
321
391
  });
392
+ if (loud) {
393
+ note(dim(`model ${setup.choice.ref} from ${SOURCE_LABELS[setup.choice.from]}`));
394
+ }
395
+ return setup;
322
396
  }
323
397
  function number(raw, what) {
324
398
  if (raw === undefined) {
@@ -1,6 +1,8 @@
1
1
  import type { Model } from '@zenera/neo';
2
2
  import type { Box } from './box.ts';
3
- import type { Operation } from './spec.ts';
3
+ import type { GeneratorInput } from './envelope.ts';
4
+ import { type ExampleRequest } from './prompt.ts';
5
+ import { type Operation } from './spec.ts';
4
6
  import { type Checks } from './validate.ts';
5
7
  export interface BuildOptions {
6
8
  model: Model;
@@ -15,6 +17,13 @@ export interface Built {
15
17
  source: string;
16
18
  attempts: number;
17
19
  }
20
+ export interface RegenerateOptions extends BuildOptions {
21
+ currentSource: string;
22
+ failingInput: GeneratorInput;
23
+ fault: string;
24
+ stderr?: string;
25
+ examples?: readonly ExampleRequest[];
26
+ }
18
27
  export declare class BuildFailed extends Error {
19
28
  readonly operation: Operation;
20
29
  readonly diagnostics: readonly string[];
@@ -30,6 +39,7 @@ export declare class BuildFailed extends Error {
30
39
  */
31
40
  export declare function reason(err: unknown): string;
32
41
  export declare function build(operation: Operation, opts: BuildOptions): Promise<Built>;
42
+ export declare function regenerate(operation: Operation, opts: RegenerateOptions): Promise<Built>;
33
43
  /**
34
44
  * Models fence code even when told not to, and a stray ```python line is a
35
45
  * syntax error rather than a bad answer — not worth a round trip.
package/dist/generate.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { tokenOf } from "./paging.js";
2
2
  import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
3
- import { instruction, retry, SYSTEM } from "./prompt.js";
3
+ import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
4
+ import { called } from "./spec.js";
4
5
  import { describeIssues, issues } from "./validate.js";
5
6
  export class BuildFailed extends Error {
6
7
  operation;
7
8
  diagnostics;
8
9
  constructor(operation, diagnostics) {
9
- super(`could not write a generator for ${operation.method.toUpperCase()} ${operation.path}`);
10
+ super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
10
11
  this.name = 'BuildFailed';
11
12
  this.operation = operation;
12
13
  this.diagnostics = diagnostics;
@@ -70,6 +71,53 @@ export async function build(operation, opts) {
70
71
  }
71
72
  throw new BuildFailed(operation, last);
72
73
  }
74
+ export async function regenerate(operation, opts) {
75
+ const limit = Math.max(1, opts.attempts ?? 3);
76
+ const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
77
+ const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
78
+ const response = opts.checks.for(operation).response;
79
+ const messages = [
80
+ {
81
+ role: 'user',
82
+ content: [
83
+ {
84
+ type: 'text',
85
+ text: regenerationInstruction({
86
+ operation,
87
+ currentSource: opts.currentSource,
88
+ failingInput: opts.failingInput,
89
+ fault: opts.fault,
90
+ stderr: opts.stderr,
91
+ examples: opts.examples,
92
+ }),
93
+ },
94
+ ],
95
+ },
96
+ ];
97
+ let last = [];
98
+ for (let attempt = 1; attempt <= limit; attempt++) {
99
+ const answer = await ask(opts, {
100
+ system: SYSTEM,
101
+ messages,
102
+ tools: [],
103
+ signal: opts.signal,
104
+ });
105
+ const source = unfence(answer.text);
106
+ if (!source.trim()) {
107
+ last = ['the answer was empty'];
108
+ }
109
+ else {
110
+ await opts.box.write(operation.key, source);
111
+ last = await judge(operation, probes, response, opts.box);
112
+ if (last.length === 0) {
113
+ return { source, attempts: attempt };
114
+ }
115
+ }
116
+ opts.onAttempt?.(attempt, last);
117
+ messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
118
+ }
119
+ throw new BuildFailed(operation, last);
120
+ }
73
121
  /**
74
122
  * Streamed when the adapter can, and every delta is dropped: nothing here
75
123
  * renders progress. It is the *socket* that needs them.
@@ -90,9 +138,9 @@ async function judge(operation, probes, response, box) {
90
138
  const out = [];
91
139
  for (const probe of probes) {
92
140
  const outcome = await box.run(operation.key, probe);
93
- const called = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
141
+ const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
94
142
  if (!outcome.ok) {
95
- out.push(`- ${called}: the file ${outcome.fault}.`);
143
+ out.push(`- ${what}: the file ${outcome.fault}.`);
96
144
  if (outcome.stderr) {
97
145
  out.push(` stderr: ${tail(outcome.stderr)}`);
98
146
  }
@@ -100,11 +148,11 @@ async function judge(operation, probes, response, box) {
100
148
  break;
101
149
  }
102
150
  if (response && !response(outcome.value)) {
103
- out.push(`- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
151
+ out.push(`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
104
152
  }
105
153
  const echo = echoIssues(probe, outcome.value, operation.success.schema);
106
154
  if (echo.length > 0) {
107
- out.push(`- ${called}: ${describeIssues(echo)}.`);
155
+ out.push(`- ${what}: ${describeIssues(echo)}.`);
108
156
  }
109
157
  }
110
158
  // Only worth the container round trips once the file answers at all, and
@@ -127,13 +175,13 @@ async function walk(operation, paging, response, box) {
127
175
  let sent;
128
176
  for (let page = 1; page <= MAX_PAGES; page++) {
129
177
  const outcome = await box.run(operation.key, input);
130
- const called = `page ${page} of ${operation.method.toUpperCase()} ${operation.path}`;
178
+ const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
131
179
  if (!outcome.ok) {
132
- return [`- ${called}: the file ${outcome.fault}.`];
180
+ return [`- ${what}: the file ${outcome.fault}.`];
133
181
  }
134
182
  if (response && !response(outcome.value)) {
135
183
  return [
136
- `- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
184
+ `- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
137
185
  ];
138
186
  }
139
187
  const token = tokenOf(outcome.value, paging);
@@ -142,12 +190,12 @@ async function walk(operation, paging, response, box) {
142
190
  }
143
191
  if (token === sent) {
144
192
  return [
145
- `- ${called}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after three pages.`,
193
+ `- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after pagination ends.`,
146
194
  ];
147
195
  }
148
196
  if (seen.has(token)) {
149
197
  return [
150
- `- ${called}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
198
+ `- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
151
199
  ];
152
200
  }
153
201
  seen.add(token);
@@ -155,7 +203,7 @@ async function walk(operation, paging, response, box) {
155
203
  input = nextPage(input, paging, token);
156
204
  }
157
205
  return [
158
- `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate three pages in total and set it to null on the last.`,
206
+ `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate between 1 and 10 pages in total and set it to null on the last.`,
159
207
  ];
160
208
  }
161
209
  /**
@@ -0,0 +1,62 @@
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';
3
+ export interface RequestDumpData {
4
+ id: string;
5
+ timestamp: Date;
6
+ durationMs: number;
7
+ method: string;
8
+ url: string;
9
+ pathname: string;
10
+ search: string;
11
+ operationId?: string;
12
+ routePath?: string;
13
+ pathParams?: Record<string, string>;
14
+ query?: Record<string, string>;
15
+ requestHeaders: Record<string, string>;
16
+ requestBody?: unknown;
17
+ status: number;
18
+ responseHeaders?: Record<string, string | number>;
19
+ responseBody?: unknown;
20
+ errorKind?: ErrorKind;
21
+ errorMessage?: string;
22
+ stderr?: string;
23
+ cacheStatus?: 'hit' | 'miss' | 'regenerated';
24
+ regenerated?: boolean;
25
+ regenAttempts?: number;
26
+ regenLimitHit?: boolean;
27
+ regenLimit?: number;
28
+ issues?: readonly unknown[];
29
+ note?: string;
30
+ }
31
+ export declare function formatTimestamp(d: Date): string;
32
+ export declare function statusText(status: number): string;
33
+ export declare function colorForStatus(status: number): (s: string) => string;
34
+ export declare function colorForErrorKind(kind: ErrorKind): (s: string) => string;
35
+ /**
36
+ * Saves a markdown file with all input information and output data for a request.
37
+ * Returns the absolute path of the generated markdown file.
38
+ */
39
+ export declare function saveRequestDump(dir: string, data: RequestDumpData): Promise<string>;
40
+ export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
41
+ 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;
43
+ export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
44
+ export interface ExampleRequest {
45
+ input: GeneratorInput;
46
+ output?: unknown;
47
+ }
48
+ export declare class RequestHistory {
49
+ #private;
50
+ constructor(maxExamples?: number);
51
+ recordSuccess(operationKey: string, input: GeneratorInput, output: unknown): void;
52
+ getSuccessful(operationKey: string, count?: number): ExampleRequest[];
53
+ }
54
+ export declare class RegenerationLimiter {
55
+ #private;
56
+ readonly limit: number;
57
+ constructor(limit?: number);
58
+ countFor(url: string): number;
59
+ isLimitHit(url: string): boolean;
60
+ increment(url: string): number;
61
+ }
62
+ //# sourceMappingURL=logger.d.ts.map