@zenera/gym 1.1.39 → 1.1.41

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.
Files changed (51) hide show
  1. package/README.md +169 -104
  2. package/dist/agents.d.ts +25 -0
  3. package/dist/agents.js +52 -0
  4. package/dist/bindings.d.ts +25 -0
  5. package/dist/bindings.js +81 -0
  6. package/dist/box.d.ts +39 -36
  7. package/dist/box.js +183 -217
  8. package/dist/cache.d.ts +24 -14
  9. package/dist/cache.js +102 -66
  10. package/dist/catalog.d.ts +83 -0
  11. package/dist/catalog.js +293 -0
  12. package/dist/command.d.ts +1 -0
  13. package/dist/command.js +235 -68
  14. package/dist/fake.d.ts +4 -0
  15. package/dist/fake.js +142 -0
  16. package/dist/generate.d.ts +28 -24
  17. package/dist/generate.js +417 -120
  18. package/dist/inputs.d.ts +12 -0
  19. package/dist/inputs.js +132 -0
  20. package/dist/knowledge.d.ts +50 -0
  21. package/dist/knowledge.js +199 -0
  22. package/dist/logger.d.ts +10 -8
  23. package/dist/logger.js +4 -3
  24. package/dist/paging.d.ts +2 -2
  25. package/dist/paging.js +2 -2
  26. package/dist/probe.d.ts +5 -5
  27. package/dist/probe.js +15 -15
  28. package/dist/prompt.d.ts +31 -18
  29. package/dist/prompt.js +160 -146
  30. package/dist/quiet.d.ts +2 -0
  31. package/dist/quiet.js +7 -0
  32. package/dist/router.js +5 -1
  33. package/dist/runtime.d.ts +160 -0
  34. package/dist/runtime.js +229 -0
  35. package/dist/schema.js +1 -2
  36. package/dist/server.js +98 -57
  37. package/dist/setup.d.ts +47 -6
  38. package/dist/setup.js +157 -21
  39. package/dist/spec.d.ts +4 -3
  40. package/dist/spec.js +73 -22
  41. package/dist/store.d.ts +78 -0
  42. package/dist/store.js +253 -0
  43. package/dist/synth.d.ts +42 -0
  44. package/dist/synth.js +589 -0
  45. package/dist/worker.d.ts +41 -0
  46. package/dist/worker.js +99 -0
  47. package/package.json +5 -3
  48. package/dist/envelope.d.ts +0 -13
  49. package/dist/envelope.js +0 -9
  50. package/dist/image.d.ts +0 -25
  51. package/dist/image.js +0 -81
@@ -0,0 +1,229 @@
1
+ import RandExp from 'randexp';
2
+ /** Key order is an accident of how a handler wrote it; it must not change a key. */
3
+ function canonical(value) {
4
+ if (Array.isArray(value)) {
5
+ return value.map(canonical);
6
+ }
7
+ if (value !== null && typeof value === 'object') {
8
+ return Object.keys(value)
9
+ .sort()
10
+ .map((k) => [k, canonical(value[k])]);
11
+ }
12
+ return value;
13
+ }
14
+ /** Where accounts live, and the id of the one the API is called as. */
15
+ export const ACCOUNTS = 'accounts';
16
+ /**
17
+ * Whether a collection — or the part of it `where` selects — has ever been
18
+ * filled by the world agent. Counting records cannot tell: a mailbox whose
19
+ * only message is the one the client just sent is not an inbox yet.
20
+ */
21
+ export function populatedKey(collection, where) {
22
+ return `populated:${collection}:${JSON.stringify(canonical(where ?? {}))}`;
23
+ }
24
+ export const OWNER = '@me';
25
+ export class HttpError extends Error {
26
+ status;
27
+ body;
28
+ constructor(status, body) {
29
+ super(`HTTP ${status}`);
30
+ this.name = 'HttpError';
31
+ this.status = status;
32
+ this.body = body ?? { error: `HTTP ${status}` };
33
+ }
34
+ }
35
+ /** The type gym gives the owner account; no document declares one. */
36
+ export const OWNER_TYPE = '@owner';
37
+ /** A name that starts with a capital is a type: `Message`, not `messages`. */
38
+ export const isTypeName = (name) => /^[A-Z]/.test(name);
39
+ /**
40
+ * The collection an unbound type gets: `Message` → `messages`,
41
+ * `PolicyTransportZone` → `policy_transport_zones`. Only ever a first guess —
42
+ * once bound, the binding is the answer.
43
+ */
44
+ export function collectionFor(type) {
45
+ const snake = type
46
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
47
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
48
+ .replace(/[^A-Za-z0-9]+/g, '_')
49
+ .toLowerCase()
50
+ .replace(/^_+|_+$/g, '');
51
+ return /s$/.test(snake) ? snake : `${snake}s`;
52
+ }
53
+ /**
54
+ * A name as a handler wrote it, made concrete. A type name finds its
55
+ * collection through the bindings; a collection name finds its type the same
56
+ * way, backwards. Whatever was asked is reported to `record`, which is how the
57
+ * judge learns which types a handler stores where.
58
+ */
59
+ export function resolve(name, options, bindings) {
60
+ if (name === ACCOUNTS) {
61
+ return { collection: ACCOUNTS, type: OWNER_TYPE, idField: 'id' };
62
+ }
63
+ if (isTypeName(name)) {
64
+ const bound = bindings[name];
65
+ return {
66
+ collection: bound?.collection ?? collectionFor(name),
67
+ type: name,
68
+ idField: options.idField ?? bound?.idField,
69
+ };
70
+ }
71
+ const type = options.type ?? Object.entries(bindings).find(([, b]) => b.collection === name)?.[0];
72
+ const bound = type ? bindings[type] : undefined;
73
+ return { collection: name, type, idField: options.idField ?? bound?.idField };
74
+ }
75
+ export function createContext(input, store, synthesize, bindings = {}, record = () => { }) {
76
+ const at = (name, options = {}) => {
77
+ const where = resolve(name, options, bindings);
78
+ record(where);
79
+ return where;
80
+ };
81
+ const random = mulberry32(input.seed || 1);
82
+ const int = (min, max) => min + Math.floor(random() * (max - min + 1));
83
+ const fromPattern = (pattern) => {
84
+ const gen = new RandExp(stripSlashes(pattern));
85
+ gen.max = 8;
86
+ gen.randInt = int;
87
+ return gen.gen();
88
+ };
89
+ const ctx = {
90
+ db: { collection: (name) => store.collection(at(name).collection) },
91
+ async lookup(name, id, options = {}) {
92
+ const { collection, type, idField } = at(name, options);
93
+ const col = store.collection(collection);
94
+ const found = col.get(id);
95
+ if (found !== undefined) {
96
+ return found;
97
+ }
98
+ await synthesize({ ...options, collection, type, idField, ids: [id] });
99
+ const made = col.get(id);
100
+ if (made === undefined) {
101
+ throw new Error(`the world agent did not create ${collection}/${String(id)}`);
102
+ }
103
+ return made;
104
+ },
105
+ async ensure(name, options = {}) {
106
+ const { collection, type, idField } = at(name, options);
107
+ const min = options.min ?? 1;
108
+ const col = store.collection(collection);
109
+ const matching = () => col.all().filter((r) => matches(r, options.where));
110
+ const have = matching().length;
111
+ const populate = !store.getMeta(populatedKey(collection, options.where));
112
+ if (populate || have < min) {
113
+ await synthesize({
114
+ ...options,
115
+ collection,
116
+ type,
117
+ idField,
118
+ min,
119
+ populate,
120
+ count: options.count ?? (populate ? Math.max(min, 8) : Math.max(min - have, 5)),
121
+ });
122
+ }
123
+ return matching();
124
+ },
125
+ synthesize: (request) => {
126
+ const { collection, type, idField } = at(request.collection, request);
127
+ return synthesize({ ...request, collection, type, idField });
128
+ },
129
+ owner: () => ctx.lookup(ACCOUNTS, OWNER, {
130
+ hint: 'the person whose mailbox, calendar and data this API serves',
131
+ }),
132
+ page: (items, options = {}) => page(items, options),
133
+ fail(status, body) {
134
+ throw new HttpError(status, body);
135
+ },
136
+ fromPattern,
137
+ newId: (pattern) => pattern
138
+ ? fromPattern(pattern)
139
+ : Array.from({ length: 16 }, () => int(0, 15).toString(16)).join(''),
140
+ random,
141
+ int,
142
+ pick: (items) => items[int(0, items.length - 1)],
143
+ now: () => new Date().toISOString(),
144
+ };
145
+ return ctx;
146
+ }
147
+ /** Every key in `where` present on the record with an equal value, compared as text. */
148
+ /** Every key in `where` matched by the record: see `same`. */
149
+ export function matches(record, where) {
150
+ return !where || same(record, where);
151
+ }
152
+ /**
153
+ * Whether a value is what was asked for. Scalars compare as text (a path
154
+ * segment is text, the stored id may be a number); an object asks for a subset
155
+ * of the same shape; a list asks for every item it names; and a scalar asked
156
+ * of a list asks whether the list holds it — `{ labelIds: 'INBOX' }` finds the
157
+ * messages labelled INBOX.
158
+ */
159
+ export function same(actual, wanted) {
160
+ if (wanted !== null && typeof wanted === 'object') {
161
+ if (Array.isArray(wanted)) {
162
+ return Array.isArray(actual) && wanted.every((w) => actual.some((a) => same(a, w)));
163
+ }
164
+ return (actual !== null &&
165
+ typeof actual === 'object' &&
166
+ Object.entries(wanted).every(([k, v]) => same(actual[k], v)));
167
+ }
168
+ if (Array.isArray(actual)) {
169
+ return actual.some((a) => same(a, wanted));
170
+ }
171
+ return actual !== undefined && String(actual) === String(wanted);
172
+ }
173
+ /**
174
+ * The one paging rule, written once rather than by every handler: the token is
175
+ * the next offset, it strictly advances, and it is absent on the last page. A
176
+ * token that cannot be read is the end of the list, never the start of it.
177
+ */
178
+ export function page(items, options) {
179
+ const size = clampSize(options.size, options.defaultSize ?? 10);
180
+ let start = 0;
181
+ if (options.token !== undefined && options.token !== null && options.token !== '') {
182
+ const decoded = decodeToken(String(options.token));
183
+ start = decoded === undefined ? items.length : decoded;
184
+ }
185
+ else if (options.offset !== undefined && options.offset !== null && options.offset !== '') {
186
+ const n = Number(options.offset);
187
+ start = Number.isFinite(n) && n >= 0 ? Math.floor(n) : items.length;
188
+ }
189
+ start = Math.min(start, items.length);
190
+ const end = Math.min(start + size, items.length);
191
+ const hasMore = end < items.length;
192
+ return {
193
+ items: items.slice(start, end),
194
+ nextToken: hasMore ? encodeToken(end) : null,
195
+ nextOffset: hasMore ? end : null,
196
+ hasMore,
197
+ total: items.length,
198
+ };
199
+ }
200
+ function clampSize(asked, fallback) {
201
+ const n = Number(asked);
202
+ return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), 500) : fallback;
203
+ }
204
+ const encodeToken = (offset) => Buffer.from(JSON.stringify({ o: offset })).toString('base64url');
205
+ function decodeToken(token) {
206
+ try {
207
+ const parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
208
+ return typeof parsed.o === 'number' && parsed.o >= 0 ? parsed.o : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ /** Some documents write a pattern as a JavaScript literal, slashes and all. */
215
+ const stripSlashes = (pattern) => {
216
+ const m = /^\/(.*)\/[a-z]*$/.exec(pattern);
217
+ return m ? m[1] : pattern;
218
+ };
219
+ function mulberry32(seed) {
220
+ let a = seed >>> 0;
221
+ return () => {
222
+ a = (a + 0x6d2b79f5) >>> 0;
223
+ let t = a;
224
+ t = Math.imul(t ^ (t >>> 15), t | 1);
225
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
226
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
227
+ };
228
+ }
229
+ //# sourceMappingURL=runtime.js.map
package/dist/schema.js CHANGED
@@ -5,8 +5,7 @@
5
5
  // OpenAPI 3.0 carries a Draft-4 derivative with its own `nullable`, and
6
6
  // OpenAPI 3.1 is honest 2020-12. Ajv can be asked to speak any of them, but
7
7
  // then every consumer downstream — the request check, the response check, the
8
- // prompt the model reads, the `jsonschema` call inside the generator — has to
9
- // be told which one it is looking at. So they are converted once, here, into
8
+ // probes, the sampler — has to be told which one it is looking at. So they are converted once, here, into
10
9
  // 2020-12, and nothing below this file knows a dialect exists.
11
10
  //
12
11
  // The other job is cycles. `dereference` replaces every `$ref` with the object
package/dist/server.js CHANGED
@@ -2,7 +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
+ import { WorkerUnavailable } from "./box.js";
6
6
  import { BuildFailed } from "./cache.js";
7
7
  import { reason } from "./generate.js";
8
8
  import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
@@ -14,15 +14,16 @@ import { describeIssues, issues } from "./validate.js";
14
14
  //
15
15
  // `node:http` and a switch. A framework would earn its place if there were
16
16
  // middleware to compose, and there is not: one route table, one validation
17
- // step, one generator.
17
+ // step, one handler.
18
18
  //
19
19
  // The rules that are not obvious from the code are the ones about what does not
20
- // travel. Request headers are filtered before the envelope is written, because
21
- // that envelope becomes a file inside a container. Request bodies never reach a
22
- // prompt at all — the build loop uses probes it made up. And the listener binds
23
- // to loopback unless somebody says otherwise in as many words.
20
+ // travel. Request headers are filtered before a handler sees them, because the
21
+ // request can be quoted to the world agent when it asks for content. Handlers
22
+ // are written against probes the build loop made up, never against traffic.
23
+ // And the listener binds to loopback unless somebody says otherwise in as many
24
+ // words.
24
25
  // ---------------------------------------------------------------------------
25
- /** Headers that are none of a generator's business. */
26
+ /** Headers that are none of a handler's business. */
26
27
  const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
27
28
  const SECRETISH = /key|token|secret|password|credential/i;
28
29
  const DEFAULT_MAX_BODY = 1024 * 1024;
@@ -184,12 +185,11 @@ async function handle(req, res, opts) {
184
185
  }
185
186
  const { operation, pathParams } = match;
186
187
  res.setHeader('x-gym-operation', operation.operationId);
187
- /** The container could not run anything: say so, and never blame the generator. */
188
- const sandboxDown = async (err, extra = {}) => {
188
+ /** The worker could not run anything: say so, and never blame the handler. */
189
+ const workerDown = async (err, extra = {}) => {
189
190
  const bodyObj = {
190
191
  error: err.message,
191
192
  hint: err.hint,
192
- container: err.container,
193
193
  stderr: err.stderr,
194
194
  };
195
195
  res.setHeader('retry-after', '1');
@@ -199,12 +199,12 @@ async function handle(req, res, opts) {
199
199
  routePath: operation.path,
200
200
  pathParams,
201
201
  ...extra,
202
- errorKind: 'SANDBOX_UNAVAILABLE',
202
+ errorKind: 'WORKER_UNAVAILABLE',
203
203
  errorMessage: err.message,
204
204
  hint: err.hint,
205
205
  stderr: err.stderr,
206
206
  responseBody: bodyObj,
207
- note: 'container engine failed',
207
+ note: 'worker unavailable',
208
208
  });
209
209
  end();
210
210
  };
@@ -249,34 +249,23 @@ async function handle(req, res, opts) {
249
249
  end();
250
250
  return;
251
251
  }
252
- if (!operation.success.schema) {
253
- res.statusCode = operation.success.status;
254
- await finish(operation.success.status, {
255
- operationId: operation.operationId,
256
- routePath: operation.path,
257
- pathParams,
258
- requestBody: body,
259
- note: 'no body declared',
260
- });
261
- res.end();
262
- return;
263
- }
264
- let generator;
252
+ let handler;
265
253
  try {
266
- generator = await opts.cache.ensure(operation);
254
+ handler = await opts.cache.ensure(operation);
267
255
  }
268
256
  catch (err) {
269
- if (err instanceof SandboxUnavailable) {
270
- await sandboxDown(err, { requestBody: body });
257
+ if (err instanceof WorkerUnavailable) {
258
+ await workerDown(err, { requestBody: body });
271
259
  return;
272
260
  }
273
261
  const status = err instanceof BuildFailed ? 501 : 500;
274
262
  const detail = reason(err);
275
263
  const bodyObj = {
276
- error: `no generator for ${operation.operationId}`,
264
+ error: `no handler for ${operation.operationId}`,
277
265
  detail,
278
266
  diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
279
267
  report: err instanceof BuildFailed ? err.dump : undefined,
268
+ agentRun: err instanceof BuildFailed ? err.report : undefined,
280
269
  };
281
270
  const end = prepareSend(res, status, bodyObj);
282
271
  await finish(status, {
@@ -284,17 +273,17 @@ async function handle(req, res, opts) {
284
273
  routePath: operation.path,
285
274
  pathParams,
286
275
  requestBody: body,
287
- errorKind: 'GENERATOR_BUILD_FAILED',
288
- errorMessage: `no generator: ${detail}`,
276
+ errorKind: 'HANDLER_BUILD_FAILED',
277
+ errorMessage: `no handler: ${detail}`,
289
278
  buildDump: err instanceof BuildFailed ? err.dump : undefined,
290
279
  responseBody: bodyObj,
291
- note: `no generator: ${detail}`,
280
+ note: `no handler: ${detail}`,
292
281
  });
293
282
  end();
294
283
  return;
295
284
  }
296
- res.setHeader('x-gym-cache', generator.cached ? 'hit' : 'miss');
297
- let cacheStatus = generator.cached ? 'hit' : 'miss';
285
+ res.setHeader('x-gym-cache', handler.cached ? 'hit' : 'miss');
286
+ let cacheStatus = handler.cached ? 'hit' : 'miss';
298
287
  const input = {
299
288
  operationId: operation.operationId,
300
289
  method: operation.method,
@@ -310,15 +299,18 @@ async function handle(req, res, opts) {
310
299
  outcome = await opts.box.run(operation.key, input);
311
300
  }
312
301
  catch (err) {
313
- if (err instanceof SandboxUnavailable) {
314
- await sandboxDown(err, { requestBody: body, cacheStatus });
302
+ if (err instanceof WorkerUnavailable) {
303
+ await workerDown(err, { requestBody: body, cacheStatus });
315
304
  return;
316
305
  }
317
306
  throw err;
318
307
  }
319
308
  const responseValidator = opts.checks.for(operation).response;
320
309
  let schemaMismatch = false;
321
- if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
310
+ if (outcome.ok &&
311
+ outcome.status === undefined &&
312
+ responseValidator &&
313
+ !responseValidator(outcome.value)) {
322
314
  schemaMismatch = true;
323
315
  const desc = describeIssues(issues('', responseValidator.errors));
324
316
  outcome = {
@@ -329,18 +321,51 @@ async function handle(req, res, opts) {
329
321
  value: outcome.value,
330
322
  };
331
323
  }
324
+ // The handler chose an HTTP error with `ctx.fail`: that is an answer, not a fault.
325
+ if (outcome.ok && outcome.status !== undefined) {
326
+ const end = prepareSend(res, outcome.status, outcome.value);
327
+ await finish(outcome.status, {
328
+ operationId: operation.operationId,
329
+ routePath: operation.path,
330
+ pathParams,
331
+ requestBody: body,
332
+ responseBody: outcome.value,
333
+ cacheStatus,
334
+ note: 'answered by the handler',
335
+ });
336
+ end();
337
+ return;
338
+ }
339
+ // The world agent failed, not the handler: rewriting working code cannot help.
340
+ if (!outcome.ok && outcome.agentFault) {
341
+ const bodyObj = { error: outcome.fault, retryable: true };
342
+ const end = prepareSend(res, 502, bodyObj);
343
+ await finish(502, {
344
+ operationId: operation.operationId,
345
+ routePath: operation.path,
346
+ pathParams,
347
+ requestBody: body,
348
+ errorKind: 'WORLD_AGENT_FAILED',
349
+ errorMessage: outcome.fault,
350
+ responseBody: bodyObj,
351
+ cacheStatus,
352
+ note: 'world agent failed',
353
+ });
354
+ end();
355
+ return;
356
+ }
332
357
  if (!outcome.ok) {
333
358
  const urlKey = url.pathname;
334
359
  const errorKind = outcome.fault?.includes('longer than')
335
- ? 'GENERATOR_TIMEOUT'
360
+ ? 'HANDLER_TIMEOUT'
336
361
  : schemaMismatch
337
- ? 'GENERATOR_SCHEMA_MISMATCH'
338
- : 'GENERATOR_FAULT';
362
+ ? 'HANDLER_SCHEMA_MISMATCH'
363
+ : 'HANDLER_FAULT';
339
364
  // Check regeneration limit for this URL
340
365
  if (opts.limiter.isLimitHit(urlKey)) {
341
366
  opts.onRequest?.(formatRegenLimitHit(urlKey, opts.limiter.limit, operation.operationId));
342
367
  const bodyObj = {
343
- error: `the generator for ${operation.operationId} ${outcome.fault}`,
368
+ error: `the handler for ${operation.operationId} ${outcome.fault}`,
344
369
  stderr: outcome.stderr || undefined,
345
370
  regenerationLimitHit: true,
346
371
  regenerationLimit: opts.limiter.limit,
@@ -357,7 +382,7 @@ async function handle(req, res, opts) {
357
382
  responseBody: bodyObj,
358
383
  cacheStatus,
359
384
  regenLimitHit: true,
360
- note: 'generator faulted (limit hit)',
385
+ note: 'handler faulted (limit hit)',
361
386
  });
362
387
  end();
363
388
  return;
@@ -365,7 +390,7 @@ async function handle(req, res, opts) {
365
390
  const attempt = opts.limiter.increment(urlKey);
366
391
  opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault, outcome.stderr));
367
392
  const regenStarted = Date.now();
368
- let currentSource = generator.source;
393
+ let currentSource = handler.source;
369
394
  if (!currentSource?.trim()) {
370
395
  currentSource = (await opts.cache.getSource?.(operation.key)) ?? '';
371
396
  }
@@ -374,7 +399,7 @@ async function handle(req, res, opts) {
374
399
  currentSource = await readFile(opts.box.sourceOf(operation.key), 'utf8');
375
400
  }
376
401
  catch {
377
- currentSource = '# generator';
402
+ currentSource = '# handler';
378
403
  }
379
404
  }
380
405
  const examples = opts.history.getSuccessful(operation.key, 2);
@@ -382,16 +407,19 @@ async function handle(req, res, opts) {
382
407
  await opts.cache.regenerate(operation, {
383
408
  currentSource,
384
409
  failingInput: input,
385
- fault: outcome.fault ?? 'generator faulted',
410
+ fault: outcome.fault ?? 'handler faulted',
386
411
  stderr: outcome.stderr,
387
412
  examples,
388
413
  });
389
414
  cacheStatus = 'regenerated';
390
415
  res.setHeader('x-gym-cache', 'regenerated');
391
416
  opts.onRequest?.(formatRegenerated(urlKey, attempt, Date.now() - regenStarted, operation.operationId));
392
- // Re-run the request with the newly regenerated generator
417
+ // Re-run the request with the newly regenerated handler
393
418
  outcome = await opts.box.run(operation.key, input);
394
- if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
419
+ if (outcome.ok &&
420
+ outcome.status === undefined &&
421
+ responseValidator &&
422
+ !responseValidator(outcome.value)) {
395
423
  outcome = {
396
424
  ok: false,
397
425
  fault: 'output does not match response schema after regeneration',
@@ -401,8 +429,8 @@ async function handle(req, res, opts) {
401
429
  }
402
430
  }
403
431
  catch (regenErr) {
404
- if (regenErr instanceof SandboxUnavailable) {
405
- await sandboxDown(regenErr, { requestBody: body, cacheStatus });
432
+ if (regenErr instanceof WorkerUnavailable) {
433
+ await workerDown(regenErr, { requestBody: body, cacheStatus });
406
434
  return;
407
435
  }
408
436
  const detail = reason(regenErr);
@@ -430,7 +458,7 @@ async function handle(req, res, opts) {
430
458
  }
431
459
  if (!outcome.ok) {
432
460
  const bodyObj = {
433
- error: `the regenerated generator for ${operation.operationId} ${outcome.fault}`,
461
+ error: `the regenerated handler for ${operation.operationId} ${outcome.fault}`,
434
462
  stderr: outcome.stderr || undefined,
435
463
  regenerated: true,
436
464
  };
@@ -441,13 +469,13 @@ async function handle(req, res, opts) {
441
469
  pathParams,
442
470
  requestBody: body,
443
471
  errorKind,
444
- errorMessage: `regenerated generator ${outcome.fault}`,
472
+ errorMessage: `regenerated handler ${outcome.fault}`,
445
473
  stderr: outcome.stderr,
446
474
  responseBody: bodyObj,
447
475
  cacheStatus,
448
476
  regenerated: true,
449
477
  regenAttempts: attempt,
450
- note: 'regenerated generator faulted',
478
+ note: 'regenerated handler faulted',
451
479
  });
452
480
  end();
453
481
  return;
@@ -457,6 +485,19 @@ async function handle(req, res, opts) {
457
485
  const streamKey = `${operation.key}:${req.socket.remoteAddress || 'local'}`;
458
486
  const cutResult = cut(operation, outcome.value, url.searchParams, body, opts.paginationLimiter, streamKey);
459
487
  opts.history.recordSuccess(operation.key, input, outcome.value);
488
+ if (!operation.success.schema) {
489
+ res.statusCode = operation.success.status;
490
+ await finish(operation.success.status, {
491
+ operationId: operation.operationId,
492
+ routePath: operation.path,
493
+ pathParams,
494
+ requestBody: body,
495
+ cacheStatus,
496
+ note: `${note} · no body declared`,
497
+ });
498
+ res.end();
499
+ return;
500
+ }
460
501
  const end = prepareSend(res, operation.success.status, outcome.value);
461
502
  await finish(operation.success.status, {
462
503
  operationId: operation.operationId,
@@ -471,7 +512,7 @@ async function handle(req, res, opts) {
471
512
  end();
472
513
  }
473
514
  /**
474
- * The generator on disk was written before the pagination rule existed, and a
515
+ * The handler on disk was written before the pagination rule existed, and a
475
516
  * cache is not rebuilt just because the rule changed. A body offering back the
476
517
  * token it was handed is therefore still possible, and it is the one bug here
477
518
  * that costs the client rather than the mock: it hangs.
@@ -497,7 +538,7 @@ function check(operation, checks, pathParams, query, body) {
497
538
  const compiled = checks.for(operation);
498
539
  const out = [];
499
540
  // Coercion rewrites what it is given, so the copies are validated and the
500
- // originals are what reach the generator.
541
+ // originals are what reach the handler.
501
542
  if (!compiled.path({ ...pathParams })) {
502
543
  out.push(...issues('path', compiled.path.errors));
503
544
  }
@@ -552,9 +593,9 @@ async function readBody(req, max) {
552
593
  }
553
594
  }
554
595
  /**
555
- * Headers reach the generator so it can vary on things like `accept-language`.
556
- * Credentials do not: the envelope is written to a file inside a container, and
557
- * a mock is exactly what people point a real bearer token at by accident.
596
+ * Headers reach the handler so it can vary on things like `accept-language`.
597
+ * Credentials do not: the request can be quoted to the world agent, and a mock
598
+ * is exactly what people point a real bearer token at by accident.
558
599
  */
559
600
  function safeHeaders(req) {
560
601
  const out = {};
package/dist/setup.d.ts CHANGED
@@ -2,8 +2,11 @@ import { KeyStore } from '@zenera/cli/lib';
2
2
  import { type Model } from '@zenera/neo';
3
3
  import { Box } from './box.ts';
4
4
  import { Cache, type CacheOptions } from './cache.ts';
5
+ import { Catalog } from './catalog.ts';
6
+ import { type Knowledge, type KnowledgeOptions } from './knowledge.ts';
5
7
  import { Router } from './router.ts';
6
8
  import { SpecError } from './spec.ts';
9
+ import { World, type WorldEvent } from './synth.ts';
7
10
  import { Checks } from './validate.ts';
8
11
  /** The variable a shell or a CI job names it with — a zen ref. */
9
12
  export declare const MODEL_ENV = "ZENERA_GYM_MODEL";
@@ -16,26 +19,57 @@ export interface ModelChoice {
16
19
  export interface GymFile {
17
20
  version: 1;
18
21
  model?: string;
22
+ /** an embedding ref, or `none` */
23
+ embedding?: string;
19
24
  }
20
25
  export declare const readSettings: () => Promise<GymFile>;
21
26
  export declare function writeSettings(file: GymFile): void;
22
27
  /** What `open` would run, and where it came from, so a command can say so too. */
23
28
  export declare function chooseModel(flag: string | undefined, keys: KeyStore): Promise<ModelChoice | undefined>;
29
+ /** The variable that names the embedding model, or `none`. */
30
+ export declare const EMBEDDING_ENV = "ZENERA_GYM_EMBEDDING";
31
+ export interface EmbeddingChoice {
32
+ /** undefined: no embedding, search by wording only */
33
+ ref?: string;
34
+ from: ModelSource | 'none';
35
+ }
36
+ /**
37
+ * Which model embeds the docs, if any. Off unless the flag asks: a ref names
38
+ * the model outright; `on` (or `default`) takes the configured one — the
39
+ * variable, then the stored setting, then the first keyed provider that offers
40
+ * one. The variable and the setting only answer that question; on their own
41
+ * they turn nothing on.
42
+ */
43
+ export declare function chooseEmbedding(flag: string | undefined, keys: KeyStore): Promise<EmbeddingChoice>;
44
+ /** The model `--embedding on` would use; `none` at a link means off. */
45
+ export declare function configuredEmbedding(keys: KeyStore): Promise<EmbeddingChoice>;
24
46
  export interface SetupOptions {
25
- specs: readonly string[];
47
+ /** API documents: files, directories or patterns */
48
+ schemas: readonly string[];
49
+ /** the story of the world: files, directories or patterns */
50
+ story?: readonly string[];
51
+ /** reference material: files, directories or patterns */
52
+ docs?: readonly string[];
26
53
  cwd: string;
27
- /** where generators and the container's workspace live */
54
+ /** where handlers, worlds, indexes and reports live */
28
55
  cache?: string;
29
56
  model?: string;
30
- image?: string;
57
+ /** an embedding ref, or `none` */
58
+ embedding?: string;
59
+ /** a world name, or a path to its file; derived from the story when unset */
60
+ world?: string;
61
+ /** start from an empty world */
62
+ reset?: boolean;
31
63
  attempts?: number;
32
- /** how many generators may be written at once */
64
+ /** how many handlers may be written at once */
33
65
  concurrency?: number;
34
66
  rebuild?: boolean;
35
67
  ephemeral?: boolean;
36
68
  timeout?: number;
37
- onImageBuild?: (tag: string) => void;
38
69
  events?: Pick<CacheOptions, 'onStart' | 'onAttempt' | 'onReady' | 'onFail'>;
70
+ onWorld?: (event: WorldEvent) => void;
71
+ onWarn?: (message: string) => void;
72
+ onIndexing?: KnowledgeOptions['onProgress'];
39
73
  }
40
74
  export interface Setup {
41
75
  router: Router;
@@ -44,7 +78,14 @@ export interface Setup {
44
78
  box: Box;
45
79
  model: Model;
46
80
  choice: ModelChoice;
47
- image: string;
81
+ catalog: Catalog;
82
+ knowledge: Knowledge;
83
+ embedding: EmbeddingChoice;
84
+ /** the API documents served, absolute */
85
+ files: string[];
86
+ world: World;
87
+ /** the world's SQLite file */
88
+ worldFile: string;
48
89
  root: string;
49
90
  close(): Promise<void>;
50
91
  }