@zenera/gym 1.1.40 → 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.
- 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/generate.js
CHANGED
|
@@ -1,22 +1,44 @@
|
|
|
1
|
+
import { loose, tool } from '@zenera/neo';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import RandExp from 'randexp';
|
|
5
|
+
import { runAgent } from "./agents.js";
|
|
6
|
+
import { ACCOUNTS, OWNER_TYPE } from "./runtime.js";
|
|
1
7
|
import { tokenOf } from "./paging.js";
|
|
2
8
|
import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
|
|
3
|
-
import {
|
|
9
|
+
import { brief, repairBrief, retryNudge, SYSTEM } from "./prompt.js";
|
|
10
|
+
import { properties } from "./schema.js";
|
|
4
11
|
import { called } from "./spec.js";
|
|
5
12
|
import { describeIssues, issues } from "./validate.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Writing a handler
|
|
15
|
+
//
|
|
16
|
+
// An agent with tools rather than a prompt with a retry loop. It can look
|
|
17
|
+
// before it writes — the operations around this one, the handlers already
|
|
18
|
+
// written for them, the collections the world already holds, any type it was
|
|
19
|
+
// not shown — and that is what makes `getMessage` read the collection
|
|
20
|
+
// `listMessages` filled instead of inventing its own.
|
|
21
|
+
//
|
|
22
|
+
// It cannot decide it is finished. `test_handler` is the judge: it runs the
|
|
23
|
+
// module against probe requests in a throwaway copy of the world and reports
|
|
24
|
+
// everything wrong with the answers, and the first test that passes ends the
|
|
25
|
+
// run. The judgement is deliberately harsher than the schema — a body can
|
|
26
|
+
// validate and still be about somebody else (the echo rule), or validate and
|
|
27
|
+
// page forever (the walk).
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/** Failed tests per build before giving up. */
|
|
12
30
|
export const DEFAULT_ATTEMPTS = 5;
|
|
31
|
+
/** How many times a run that stopped without passing is started again. */
|
|
32
|
+
const RESTARTS = 2;
|
|
13
33
|
export class BuildFailed extends Error {
|
|
14
34
|
operation;
|
|
15
35
|
diagnostics;
|
|
16
36
|
/** the build report on disk, filled in by whoever was writing one */
|
|
17
37
|
dump;
|
|
38
|
+
/** the agent's HTML run report */
|
|
39
|
+
report;
|
|
18
40
|
constructor(operation, diagnostics) {
|
|
19
|
-
super(`could not write a
|
|
41
|
+
super(`could not write a handler for ${operation.method.toUpperCase()} ${called(operation)}`);
|
|
20
42
|
this.name = 'BuildFailed';
|
|
21
43
|
this.operation = operation;
|
|
22
44
|
this.diagnostics = diagnostics;
|
|
@@ -27,8 +49,7 @@ export class BuildFailed extends Error {
|
|
|
27
49
|
*
|
|
28
50
|
* `fetch failed` is undici's word for a dozen different problems — a DNS
|
|
29
51
|
* miss, a refused connection, a TLS error, a timeout — and which one it was is
|
|
30
|
-
* only ever in `cause`.
|
|
31
|
-
* network fault into the same useless sentence.
|
|
52
|
+
* only ever in `cause`.
|
|
32
53
|
*/
|
|
33
54
|
export function reason(err) {
|
|
34
55
|
if (err instanceof BuildFailed && err.diagnostics.length > 0) {
|
|
@@ -53,148 +74,376 @@ export function reason(err) {
|
|
|
53
74
|
}
|
|
54
75
|
return parts.join(' — ') || String(err);
|
|
55
76
|
}
|
|
56
|
-
|
|
77
|
+
/** Writes the handler for an operation, or repairs the one it has. */
|
|
78
|
+
export async function build(operation, opts, repair) {
|
|
57
79
|
const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
const state = {
|
|
81
|
+
bindings: {},
|
|
82
|
+
failures: 0,
|
|
83
|
+
passed: undefined,
|
|
84
|
+
last: [],
|
|
85
|
+
lastSource: '',
|
|
86
|
+
};
|
|
87
|
+
let report;
|
|
88
|
+
const intro = [
|
|
89
|
+
brief(operation, {
|
|
90
|
+
catalog: opts.catalog,
|
|
91
|
+
siblings: await siblings(operation, opts),
|
|
92
|
+
collections: opts.world.store.collections(),
|
|
93
|
+
bindings: opts.bindings.all(),
|
|
94
|
+
passages: await opts.world.knowledge.recall([
|
|
95
|
+
operation.summary,
|
|
96
|
+
operation.operationId,
|
|
97
|
+
`${operation.method.toUpperCase()} ${operation.path}`,
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(' — '), 3),
|
|
101
|
+
}),
|
|
102
|
+
...(repair ? ['', repairBrief(repair)] : []),
|
|
103
|
+
].join('\n');
|
|
104
|
+
for (let run = 0; run <= RESTARTS && !state.passed && state.failures < limit; run++) {
|
|
105
|
+
const input = run === 0
|
|
106
|
+
? intro
|
|
107
|
+
: [
|
|
108
|
+
intro,
|
|
109
|
+
'',
|
|
110
|
+
'YOUR LAST VERSION:',
|
|
111
|
+
'```ts',
|
|
112
|
+
state.lastSource,
|
|
113
|
+
'```',
|
|
114
|
+
'',
|
|
115
|
+
retryNudge(state.last.length > 0 ? state.last : ['- it was never tested']),
|
|
116
|
+
].join('\n');
|
|
117
|
+
const done = await runAgent({
|
|
118
|
+
model: opts.model,
|
|
119
|
+
name: 'builder',
|
|
120
|
+
label: operation.operationId,
|
|
121
|
+
instructions: SYSTEM,
|
|
122
|
+
tools: tools(operation, opts, state, limit),
|
|
123
|
+
input,
|
|
124
|
+
maxToolCalls: limit * 3 + 10,
|
|
125
|
+
done: () => Boolean(state.passed) || state.failures >= limit,
|
|
126
|
+
reportDir: opts.reportDir,
|
|
69
127
|
signal: opts.signal,
|
|
70
128
|
});
|
|
71
|
-
|
|
72
|
-
if (!source.trim()) {
|
|
73
|
-
last = ['the answer was empty'];
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
await opts.box.write(operation.key, source);
|
|
77
|
-
last = await judge(operation, probes, response, opts.box);
|
|
78
|
-
if (last.length === 0) {
|
|
79
|
-
return { source, attempts: attempt };
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
await opts.onAttempt?.(attempt, last, source, limit);
|
|
83
|
-
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
129
|
+
report = done.report ?? report;
|
|
84
130
|
}
|
|
85
|
-
|
|
131
|
+
if (state.passed) {
|
|
132
|
+
return {
|
|
133
|
+
source: state.passed,
|
|
134
|
+
attempts: Math.max(1, state.failures + 1),
|
|
135
|
+
report,
|
|
136
|
+
bindings: state.bindings,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const failed = new BuildFailed(operation, state.last.length > 0 ? state.last : ['the agent stopped without testing a handler']);
|
|
140
|
+
failed.report = report;
|
|
141
|
+
throw failed;
|
|
86
142
|
}
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
operation,
|
|
100
|
-
currentSource: opts.currentSource,
|
|
101
|
-
failingInput: opts.failingInput,
|
|
102
|
-
fault: opts.fault,
|
|
103
|
-
stderr: opts.stderr,
|
|
104
|
-
examples: opts.examples,
|
|
105
|
-
}),
|
|
106
|
-
},
|
|
107
|
-
],
|
|
143
|
+
function tools(operation, opts, state, limit) {
|
|
144
|
+
const listOperations = tool({
|
|
145
|
+
name: 'list_operations',
|
|
146
|
+
description: 'Operations this server answers, with whether each already has a handler. ' +
|
|
147
|
+
'Filter by path (e.g. "/users/*/messages*") or by operationId.',
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
path: { type: 'string', description: 'A substring, or a glob with * and ?.' },
|
|
152
|
+
name: { type: 'string', description: 'A substring of the operationId.' },
|
|
153
|
+
},
|
|
154
|
+
additionalProperties: false,
|
|
108
155
|
},
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
156
|
+
execute: async ({ path, name }) => {
|
|
157
|
+
const byPath = path ? loose(path) : undefined;
|
|
158
|
+
const byName = name ? loose(name) : undefined;
|
|
159
|
+
const found = opts.operations.filter((o) => (!byPath || byPath(o.path)) && (!byName || byName(o.operationId)));
|
|
160
|
+
const rows = await Promise.all(found.slice(0, 80).map(async (o) => {
|
|
161
|
+
const built = Boolean(await opts.sourceOf(o));
|
|
162
|
+
return `${built ? '[built]' : '[ ]'} ${o.method.toUpperCase()} ${called(o)} ${o.operationId}`;
|
|
163
|
+
}));
|
|
164
|
+
return { found: found.length, operations: rows };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const readHandler = tool({
|
|
168
|
+
name: 'read_handler',
|
|
169
|
+
description: 'The handler already written for another operation. Read the siblings of this ' +
|
|
170
|
+
'operation before writing it, and use the same collections and types they use.',
|
|
171
|
+
parameters: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: { operationId: { type: 'string' } },
|
|
174
|
+
required: ['operationId'],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
execute: async ({ operationId }) => {
|
|
178
|
+
const other = opts.operations.find((o) => o.operationId === operationId);
|
|
179
|
+
if (!other) {
|
|
180
|
+
return {
|
|
181
|
+
error: `no operation called ${operationId}`,
|
|
182
|
+
hint: 'call list_operations',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const source = await opts.sourceOf(other);
|
|
186
|
+
return source ? { source } : { error: `${operationId} has no handler yet` };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const listCollections = tool({
|
|
190
|
+
name: 'list_collections',
|
|
191
|
+
description: 'The collections the world holds, with record counts and sample ids.',
|
|
192
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
193
|
+
execute: () => ({ collections: opts.world.store.collections() }),
|
|
194
|
+
});
|
|
195
|
+
const readCollection = tool({
|
|
196
|
+
name: 'read_collection',
|
|
197
|
+
description: 'A few records from a collection, to see the shape stored there.',
|
|
198
|
+
parameters: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
collection: { type: 'string' },
|
|
202
|
+
limit: { type: 'integer', description: 'Default 3.' },
|
|
203
|
+
},
|
|
204
|
+
required: ['collection'],
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
},
|
|
207
|
+
execute: ({ collection, limit }) => {
|
|
208
|
+
const col = opts.world.store.collection(collection);
|
|
209
|
+
const text = JSON.stringify(col.all().slice(0, Math.min(limit ?? 3, 10)));
|
|
210
|
+
return {
|
|
211
|
+
total: col.count(),
|
|
212
|
+
records: text.length > 12_000 ? `${text.slice(0, 12_000)}…` : JSON.parse(text),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const describeTypes = tool({
|
|
217
|
+
name: 'describe_types',
|
|
218
|
+
description: 'Schema types by name, as TypeScript, with everything they refer to.',
|
|
219
|
+
parameters: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: { names: { type: 'array', items: { type: 'string' } } },
|
|
222
|
+
required: ['names'],
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
},
|
|
225
|
+
execute: ({ names }) => {
|
|
226
|
+
const { code, omitted } = opts.catalog.declare(names, 30_000);
|
|
227
|
+
return code
|
|
228
|
+
? { typescript: code, ...(omitted.length ? { omitted } : {}) }
|
|
229
|
+
: { error: `no such type: ${names.join(', ')}` };
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
const testHandler = tool({
|
|
233
|
+
name: 'test_handler',
|
|
234
|
+
description: 'Saves the handler module and judges it: runs it against probe requests in a ' +
|
|
235
|
+
'copy of the world, checks every answer against the response schema, the echo ' +
|
|
236
|
+
'rule and, for paged operations, that the pages advance and end. Returns ok, or ' +
|
|
237
|
+
'every problem found.',
|
|
238
|
+
parameters: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
source: { type: 'string', description: 'The whole TypeScript module.' },
|
|
242
|
+
},
|
|
243
|
+
required: ['source'],
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
},
|
|
246
|
+
execute: async ({ source }) => {
|
|
247
|
+
if (state.passed || state.failures >= limit) {
|
|
248
|
+
return { error: 'the build is over' };
|
|
127
249
|
}
|
|
250
|
+
const code = unfence(String(source ?? ''));
|
|
251
|
+
state.lastSource = code;
|
|
252
|
+
const { problems, placed } = code.trim()
|
|
253
|
+
? await test(operation, code, opts)
|
|
254
|
+
: { problems: ['- the module is empty'], placed: {} };
|
|
255
|
+
if (problems.length === 0) {
|
|
256
|
+
// Bound only on a pass: a failing handler must not fix where a
|
|
257
|
+
// type lives for everyone after it.
|
|
258
|
+
for (const [type, binding] of Object.entries(placed)) {
|
|
259
|
+
opts.bindings.bind(type, binding);
|
|
260
|
+
}
|
|
261
|
+
state.passed = code;
|
|
262
|
+
state.bindings = placed;
|
|
263
|
+
return { ok: true };
|
|
264
|
+
}
|
|
265
|
+
state.failures++;
|
|
266
|
+
state.last = problems;
|
|
267
|
+
await opts.onAttempt?.(state.failures, problems, code, limit);
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
problems,
|
|
271
|
+
testsLeft: Math.max(0, limit - state.failures),
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
return [
|
|
276
|
+
listOperations,
|
|
277
|
+
readHandler,
|
|
278
|
+
listCollections,
|
|
279
|
+
readCollection,
|
|
280
|
+
describeTypes,
|
|
281
|
+
...opts.catalog.tools(),
|
|
282
|
+
...opts.world.knowledge.tools,
|
|
283
|
+
testHandler,
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
/** Writes the module and judges it in a copy of the world nobody else can see. */
|
|
287
|
+
async function test(operation, source, opts) {
|
|
288
|
+
await opts.box.write(operation.key, source);
|
|
289
|
+
const scratch = join(opts.box.root, 'scratch', `${operation.key}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sqlite`);
|
|
290
|
+
opts.world.store.snapshot(scratch);
|
|
291
|
+
try {
|
|
292
|
+
const used = [];
|
|
293
|
+
const problems = await judge(operation, opts, scratch, used);
|
|
294
|
+
const { problems: misplaced, placed } = placement(used, opts);
|
|
295
|
+
return { problems: [...problems, ...misplaced], placed };
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
opts.box.release(scratch);
|
|
299
|
+
opts.world.release(scratch);
|
|
300
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
301
|
+
rmSync(`${scratch}${suffix}`, { force: true });
|
|
128
302
|
}
|
|
129
|
-
await opts.onAttempt?.(attempt, last, source, limit);
|
|
130
|
-
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
131
303
|
}
|
|
132
|
-
throw new BuildFailed(operation, last);
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Streamed when the adapter can, and every delta is dropped: nothing here
|
|
136
|
-
* renders progress. It is the *socket* that needs them.
|
|
137
|
-
*
|
|
138
|
-
* A generator is a few hundred lines, and a model asked for one in a single
|
|
139
|
-
* non-streaming call sends nothing at all while it thinks. Something upstream
|
|
140
|
-
* closes a connection that has been idle for a minute, and the whole attempt
|
|
141
|
-
* dies with `UND_ERR_SOCKET other side closed` at ~61 s — not a timeout anyone
|
|
142
|
-
* here set, and not the SDK's, which is ten minutes. Tokens on the wire keep
|
|
143
|
-
* it open.
|
|
144
|
-
*/
|
|
145
|
-
function ask(opts, request) {
|
|
146
|
-
return opts.model.stream ? opts.model.stream(request, discard) : opts.model.generate(request);
|
|
147
304
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
async function judge(operation, probes, response, box) {
|
|
305
|
+
async function judge(operation, opts, db, used) {
|
|
306
|
+
const response = opts.checks.for(operation).response;
|
|
151
307
|
const out = [];
|
|
152
|
-
for (const probe of
|
|
153
|
-
const outcome = await box.run(operation.key, probe);
|
|
154
|
-
|
|
308
|
+
for (const probe of fitted(operation, probesFor(operation))) {
|
|
309
|
+
const outcome = await opts.box.run(operation.key, probe, { db, probe: true });
|
|
310
|
+
used.push(...(outcome.used ?? []));
|
|
311
|
+
const what = `request ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query, ...(probe.body !== undefined ? { body: probe.body } : {}) }).slice(0, 600)}`;
|
|
155
312
|
if (!outcome.ok) {
|
|
156
|
-
out.push(`- ${what}: the
|
|
313
|
+
out.push(`- ${what}: the handler ${outcome.fault}.`);
|
|
157
314
|
if (outcome.stderr) {
|
|
158
|
-
out.push(`
|
|
315
|
+
out.push(` stack: ${tail(outcome.stderr)}`);
|
|
159
316
|
}
|
|
160
|
-
// A file that will not run says nothing about the next probe.
|
|
161
317
|
break;
|
|
162
318
|
}
|
|
319
|
+
if (outcome.status !== undefined) {
|
|
320
|
+
out.push(`- ${what}: answered HTTP ${outcome.status} ${JSON.stringify(outcome.value).slice(0, 200)}. ` +
|
|
321
|
+
'An ordinary request must get a body: make missing content with ctx.lookup / ctx.ensure instead of failing.');
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
163
324
|
if (response && !response(outcome.value)) {
|
|
164
|
-
out.push(`- ${what}: the
|
|
325
|
+
out.push(`- ${what}: the answer does not match the response schema — ${describeIssues(issues('', response.errors))}. ` +
|
|
326
|
+
`It answered: ${JSON.stringify(outcome.value).slice(0, 400)}`);
|
|
165
327
|
}
|
|
166
328
|
const echo = echoIssues(probe, outcome.value, operation.success.schema);
|
|
167
329
|
if (echo.length > 0) {
|
|
168
330
|
out.push(`- ${what}: ${describeIssues(echo)}.`);
|
|
169
331
|
}
|
|
170
332
|
}
|
|
171
|
-
// Only worth the container round trips once the file answers at all, and
|
|
172
|
-
// only for an operation that hands out a token somebody could follow.
|
|
173
333
|
if (out.length === 0 && operation.paging?.next) {
|
|
174
|
-
out.push(...(await walk(operation, operation.paging,
|
|
334
|
+
out.push(...(await walk(operation, operation.paging, opts, db, used)));
|
|
175
335
|
}
|
|
176
336
|
return out;
|
|
177
337
|
}
|
|
178
|
-
/** How many pages a mock may offer before it is simply not terminating. */
|
|
179
|
-
const MAX_PAGES = 8;
|
|
180
338
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* is a
|
|
339
|
+
* Probe path values that the response would have to echo, made to fit the
|
|
340
|
+
* response's own pattern. Asking for message `ada` when ids are sixteen hex
|
|
341
|
+
* digits is a test no handler can pass.
|
|
184
342
|
*/
|
|
185
|
-
|
|
343
|
+
function fitted(operation, probes) {
|
|
344
|
+
const schema = operation.success.schema;
|
|
345
|
+
if (!schema) {
|
|
346
|
+
return probes;
|
|
347
|
+
}
|
|
348
|
+
const patterned = new Map();
|
|
349
|
+
for (const p of properties(schema)) {
|
|
350
|
+
if (typeof p.schema.pattern === 'string' && !patterned.has(p.name)) {
|
|
351
|
+
patterned.set(p.name, p.schema.pattern);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return probes.map((probe, i) => {
|
|
355
|
+
const pathParams = { ...probe.pathParams };
|
|
356
|
+
for (const name of Object.keys(pathParams)) {
|
|
357
|
+
const pattern = patterned.get(name);
|
|
358
|
+
if (pattern) {
|
|
359
|
+
try {
|
|
360
|
+
const gen = new RandExp(pattern);
|
|
361
|
+
gen.max = 6;
|
|
362
|
+
let n = i + 7;
|
|
363
|
+
gen.randInt = (a, b) => a + ((n = (n * 48271) % 2147483647) % (b - a + 1));
|
|
364
|
+
pathParams[name] = gen.gen();
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// an unreadable pattern keeps the plain probe value
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { ...probe, pathParams };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Where the handler put each type it touched, checked against where every
|
|
376
|
+
* handler before it put them. Untyped access is refused outright: a collection
|
|
377
|
+
* nobody can name a type for is a collection the next handler cannot find.
|
|
378
|
+
*/
|
|
379
|
+
function placement(used, opts) {
|
|
380
|
+
const problems = new Set();
|
|
381
|
+
const byType = new Map();
|
|
382
|
+
for (const use of used) {
|
|
383
|
+
if (use.collection === ACCOUNTS || use.type === OWNER_TYPE) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (!use.type) {
|
|
387
|
+
problems.add(`- \`${use.collection}\` is used without a type. Name the type instead — ` +
|
|
388
|
+
`ctx.lookup('Message', id), ctx.ensure('Message', …), ctx.db.collection('Message') — ` +
|
|
389
|
+
'so every handler finds the same records in the same place.');
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!opts.catalog.hasType(use.type)) {
|
|
393
|
+
problems.add(`- \`${use.type}\` is not a type the documents declare; ` +
|
|
394
|
+
'describe_types, list_api or grep_api finds the right name.');
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const seen = byType.get(use.type) ?? { collections: new Set(), idFields: new Set() };
|
|
398
|
+
seen.collections.add(use.collection);
|
|
399
|
+
if (use.idField) {
|
|
400
|
+
seen.idFields.add(use.idField);
|
|
401
|
+
}
|
|
402
|
+
byType.set(use.type, seen);
|
|
403
|
+
}
|
|
404
|
+
const placed = {};
|
|
405
|
+
for (const [type, seen] of byType) {
|
|
406
|
+
if (seen.collections.size > 1 || seen.idFields.size > 1) {
|
|
407
|
+
problems.add(`- \`${type}\` is stored two ways in this handler ` +
|
|
408
|
+
`(${[...seen.collections].join(', ')}; keyed by ${[...seen.idFields].join(', ') || 'id'}). ` +
|
|
409
|
+
'Use one type name and one id field throughout.');
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const binding = {
|
|
413
|
+
collection: [...seen.collections][0],
|
|
414
|
+
idField: [...seen.idFields][0] ?? opts.bindings.of(type)?.idField ?? 'id',
|
|
415
|
+
};
|
|
416
|
+
const conflict = opts.bindings.conflict(type, binding);
|
|
417
|
+
if (conflict) {
|
|
418
|
+
problems.add(`- ${conflict} — fixed by an earlier handler. Use the type name ` +
|
|
419
|
+
`(ctx.lookup('${type}', …)) and leave the collection and id field to the binding.`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
placed[type] = binding;
|
|
423
|
+
}
|
|
424
|
+
return { problems: [...problems], placed };
|
|
425
|
+
}
|
|
426
|
+
/** How many pages a judged handler may offer before it is not terminating. */
|
|
427
|
+
const MAX_PAGES = 8;
|
|
428
|
+
async function walk(operation, paging, opts, db, used) {
|
|
186
429
|
let input = walkStart(operation, paging);
|
|
187
430
|
const seen = new Set();
|
|
188
431
|
let sent;
|
|
432
|
+
// Enough content to need more than one page, so the token is exercised.
|
|
433
|
+
if (paging.size) {
|
|
434
|
+
input = setSize(input, paging, 2);
|
|
435
|
+
}
|
|
189
436
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
190
|
-
const outcome = await box.run(operation.key, input);
|
|
437
|
+
const outcome = await opts.box.run(operation.key, input, { db, probe: true });
|
|
438
|
+
used.push(...(outcome.used ?? []));
|
|
191
439
|
const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
|
|
192
440
|
if (!outcome.ok) {
|
|
193
|
-
return [`- ${what}: the
|
|
441
|
+
return [`- ${what}: the handler ${outcome.fault}.`];
|
|
194
442
|
}
|
|
443
|
+
const response = opts.checks.for(operation).response;
|
|
195
444
|
if (response && !response(outcome.value)) {
|
|
196
445
|
return [
|
|
197
|
-
`- ${what}: the
|
|
446
|
+
`- ${what}: the answer does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
|
|
198
447
|
];
|
|
199
448
|
}
|
|
200
449
|
const token = tokenOf(outcome.value, paging);
|
|
@@ -203,12 +452,12 @@ async function walk(operation, paging, response, box) {
|
|
|
203
452
|
}
|
|
204
453
|
if (token === sent) {
|
|
205
454
|
return [
|
|
206
|
-
`- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the
|
|
455
|
+
`- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the token the request sent in \`${paging.param}\`. Pass the client's token to ctx.page and answer with its nextToken.`,
|
|
207
456
|
];
|
|
208
457
|
}
|
|
209
458
|
if (seen.has(token)) {
|
|
210
459
|
return [
|
|
211
|
-
`- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out
|
|
460
|
+
`- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out before.`,
|
|
212
461
|
];
|
|
213
462
|
}
|
|
214
463
|
seen.add(token);
|
|
@@ -216,12 +465,60 @@ async function walk(operation, paging, response, box) {
|
|
|
216
465
|
input = nextPage(input, paging, token);
|
|
217
466
|
}
|
|
218
467
|
return [
|
|
219
|
-
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set.
|
|
468
|
+
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Page with ctx.page so the list ends.`,
|
|
220
469
|
];
|
|
221
470
|
}
|
|
471
|
+
function setSize(input, paging, size) {
|
|
472
|
+
if (!paging.size) {
|
|
473
|
+
return input;
|
|
474
|
+
}
|
|
475
|
+
if (paging.paramIn === 'body' && typeof input.body === 'object' && input.body !== null) {
|
|
476
|
+
return { ...input, body: { ...input.body, [paging.size]: size } };
|
|
477
|
+
}
|
|
478
|
+
return { ...input, query: { ...input.query, [paging.size]: String(size) } };
|
|
479
|
+
}
|
|
480
|
+
/** Operations sharing this one's resource path, or a response type with it. */
|
|
481
|
+
/**
|
|
482
|
+
* The operations a handler must agree with, closest first. Same record type and
|
|
483
|
+
* same resource are the strongest ties; operations that merely carry the type
|
|
484
|
+
* somewhere (traced through the graph) come next; nearby paths last. In a large
|
|
485
|
+
* API the list is cut, and the cut must fall on the weakest.
|
|
486
|
+
*/
|
|
487
|
+
async function siblings(operation, opts) {
|
|
488
|
+
const base = resourceOf(operation.path);
|
|
489
|
+
const mine = opts.catalog.itemType(operation);
|
|
490
|
+
const carriers = new Set((mine ? opts.catalog.carriers(mine) : []).map((c) => `${c.method} ${c.path}`));
|
|
491
|
+
const scored = opts.operations
|
|
492
|
+
.filter((o) => o !== operation)
|
|
493
|
+
.map((o) => {
|
|
494
|
+
const type = opts.catalog.itemType(o);
|
|
495
|
+
const other = resourceOf(o.path);
|
|
496
|
+
const score = (mine && type === mine ? 4 : 0) +
|
|
497
|
+
(other === base ? 3 : 0) +
|
|
498
|
+
(carriers.has(`${o.method} ${o.path}`) ? 2 : 0) +
|
|
499
|
+
(near(o.path, base) ? 1 : 0);
|
|
500
|
+
return { operation: o, type, score };
|
|
501
|
+
})
|
|
502
|
+
.filter((x) => x.score > 0)
|
|
503
|
+
.sort((a, b) => b.score - a.score || a.operation.path.localeCompare(b.operation.path))
|
|
504
|
+
.slice(0, 25);
|
|
505
|
+
return Promise.all(scored.map(async ({ operation: o, type }) => ({
|
|
506
|
+
operation: o,
|
|
507
|
+
type,
|
|
508
|
+
built: Boolean(await opts.sourceOf(o)),
|
|
509
|
+
})));
|
|
510
|
+
}
|
|
511
|
+
/** One or two segments above or below. */
|
|
512
|
+
function near(path, base) {
|
|
513
|
+
const depth = (p) => p.split('/').filter(Boolean).length;
|
|
514
|
+
const [short, long] = path.length < base.length ? [path, base] : [base, path];
|
|
515
|
+
return long.startsWith(`${short}/`) && depth(long) - depth(short) <= 2;
|
|
516
|
+
}
|
|
517
|
+
/** `/users/{userId}/messages/{id}` → `/users/{userId}/messages`. */
|
|
518
|
+
const resourceOf = (path) => path.replace(/\/\{[^/]+\}$/, '') || '/';
|
|
222
519
|
/**
|
|
223
|
-
* Models fence code even when told not to, and a stray ```
|
|
224
|
-
*
|
|
520
|
+
* Models fence code even when told not to, and a stray ```ts line is a syntax
|
|
521
|
+
* error rather than a bad answer — not worth a round trip.
|
|
225
522
|
*/
|
|
226
523
|
export function unfence(text) {
|
|
227
524
|
const trimmed = text.trim();
|
|
@@ -232,5 +529,5 @@ export function unfence(text) {
|
|
|
232
529
|
const close = body.lastIndexOf('```');
|
|
233
530
|
return (close === -1 ? body : body.slice(0, close)).trimEnd();
|
|
234
531
|
}
|
|
235
|
-
const tail = (s) => s.trim().split('\n').slice(
|
|
532
|
+
const tail = (s) => s.trim().split('\n').slice(0, 6).join('\n ');
|
|
236
533
|
//# sourceMappingURL=generate.js.map
|
package/dist/inputs.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every API document the arguments name, absolute, de-duplicated, in the order
|
|
3
|
+
* named (and sorted within a directory or a pattern, so a start is repeatable).
|
|
4
|
+
*/
|
|
5
|
+
export declare function expandSchemas(args: readonly string[], cwd: string): Promise<string[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Checks story and docs arguments before anything reads them. They are read by
|
|
8
|
+
* `@zenera/rag`, which takes a named file at its word — so an API document
|
|
9
|
+
* passed here by mistake would quietly become a very long story.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkTexts(args: readonly string[], cwd: string, flag: '--story' | '--docs'): Promise<void>;
|
|
12
|
+
//# sourceMappingURL=inputs.d.ts.map
|