@zenera/gym 1.1.40 → 1.1.42
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 +169 -104
- package/dist/agents.d.ts +25 -0
- package/dist/agents.js +52 -0
- package/dist/bindings.d.ts +25 -0
- package/dist/bindings.js +81 -0
- package/dist/box.d.ts +39 -36
- package/dist/box.js +183 -217
- package/dist/cache.d.ts +24 -14
- package/dist/cache.js +102 -66
- package/dist/catalog.d.ts +83 -0
- package/dist/catalog.js +293 -0
- package/dist/command.d.ts +1 -0
- package/dist/command.js +235 -68
- package/dist/fake.d.ts +4 -0
- package/dist/fake.js +142 -0
- package/dist/generate.d.ts +28 -24
- package/dist/generate.js +417 -120
- package/dist/inputs.d.ts +12 -0
- package/dist/inputs.js +132 -0
- package/dist/knowledge.d.ts +50 -0
- package/dist/knowledge.js +199 -0
- package/dist/logger.d.ts +10 -8
- package/dist/logger.js +4 -3
- package/dist/paging.d.ts +2 -2
- package/dist/paging.js +2 -2
- package/dist/probe.d.ts +5 -5
- package/dist/probe.js +15 -15
- package/dist/prompt.d.ts +31 -18
- package/dist/prompt.js +160 -146
- package/dist/quiet.d.ts +2 -0
- package/dist/quiet.js +7 -0
- package/dist/router.js +5 -1
- package/dist/runtime.d.ts +160 -0
- package/dist/runtime.js +229 -0
- package/dist/schema.js +1 -2
- package/dist/server.js +98 -57
- package/dist/setup.d.ts +47 -6
- package/dist/setup.js +157 -21
- package/dist/spec.d.ts +4 -3
- package/dist/spec.js +73 -22
- package/dist/store.d.ts +78 -0
- package/dist/store.js +253 -0
- package/dist/synth.d.ts +42 -0
- package/dist/synth.js +589 -0
- package/dist/worker.d.ts +41 -0
- package/dist/worker.js +99 -0
- package/package.json +5 -3
- package/dist/envelope.d.ts +0 -13
- package/dist/envelope.js +0 -9
- package/dist/image.d.ts +0 -25
- package/dist/image.js +0 -81
package/dist/runtime.js
ADDED
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// to loopback unless somebody says otherwise in as many
|
|
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
|
|
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
|
|
188
|
-
const
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
handler = await opts.cache.ensure(operation);
|
|
267
255
|
}
|
|
268
256
|
catch (err) {
|
|
269
|
-
if (err instanceof
|
|
270
|
-
await
|
|
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
|
|
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: '
|
|
288
|
-
errorMessage: `no
|
|
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
|
|
280
|
+
note: `no handler: ${detail}`,
|
|
292
281
|
});
|
|
293
282
|
end();
|
|
294
283
|
return;
|
|
295
284
|
}
|
|
296
|
-
res.setHeader('x-gym-cache',
|
|
297
|
-
let cacheStatus =
|
|
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
|
|
314
|
-
await
|
|
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 &&
|
|
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
|
-
? '
|
|
360
|
+
? 'HANDLER_TIMEOUT'
|
|
336
361
|
: schemaMismatch
|
|
337
|
-
? '
|
|
338
|
-
: '
|
|
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
|
|
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: '
|
|
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 =
|
|
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 = '#
|
|
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 ?? '
|
|
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
|
|
417
|
+
// Re-run the request with the newly regenerated handler
|
|
393
418
|
outcome = await opts.box.run(operation.key, input);
|
|
394
|
-
if (outcome.ok &&
|
|
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
|
|
405
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
556
|
-
* Credentials do not: the
|
|
557
|
-
*
|
|
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
|
-
|
|
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
|
|
54
|
+
/** where handlers, worlds, indexes and reports live */
|
|
28
55
|
cache?: string;
|
|
29
56
|
model?: string;
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|