@zenera/gym 0.0.0-stage → 1.1.38
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/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GeneratorInput {
|
|
2
|
+
operationId: string;
|
|
3
|
+
method: string;
|
|
4
|
+
/** the template, not the resolved path — `/users/{user_id}` */
|
|
5
|
+
path: string;
|
|
6
|
+
pathParams: Record<string, unknown>;
|
|
7
|
+
query: Record<string, unknown>;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body: unknown;
|
|
10
|
+
/** seeds `random` and `Faker` inside the generator */
|
|
11
|
+
seed: number;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=envelope.d.ts.map
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// What a generator is handed
|
|
3
|
+
//
|
|
4
|
+
// One shape, written by the server for a real request and by the build loop for
|
|
5
|
+
// a synthetic probe. That they are the same shape is the whole reason a
|
|
6
|
+
// generator that satisfies its probes also satisfies traffic.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=envelope.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Model } from '@zenera/neo';
|
|
2
|
+
import type { Box } from './box.ts';
|
|
3
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
4
|
+
import { type ExampleRequest } from './prompt.ts';
|
|
5
|
+
import { type Operation } from './spec.ts';
|
|
6
|
+
import { type Checks } from './validate.ts';
|
|
7
|
+
export interface BuildOptions {
|
|
8
|
+
model: Model;
|
|
9
|
+
box: Box;
|
|
10
|
+
checks: Checks;
|
|
11
|
+
/** how many times the model may be asked before giving up */
|
|
12
|
+
attempts?: number;
|
|
13
|
+
onAttempt?: (attempt: number, diagnostics: readonly string[], source: string, limit: number) => void | Promise<void>;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export interface Built {
|
|
17
|
+
source: string;
|
|
18
|
+
attempts: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tries per generator. Each one carries the previous file and what was wrong
|
|
22
|
+
* with it, so the later attempts are the informed ones — a model that has seen
|
|
23
|
+
* its own traceback usually needs two or three goes at a fiddly schema, and the
|
|
24
|
+
* whole loop costs nothing once the file is cached.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_ATTEMPTS = 5;
|
|
27
|
+
export interface RegenerateOptions extends BuildOptions {
|
|
28
|
+
currentSource: string;
|
|
29
|
+
failingInput: GeneratorInput;
|
|
30
|
+
fault: string;
|
|
31
|
+
stderr?: string;
|
|
32
|
+
examples?: readonly ExampleRequest[];
|
|
33
|
+
}
|
|
34
|
+
export declare class BuildFailed extends Error {
|
|
35
|
+
readonly operation: Operation;
|
|
36
|
+
readonly diagnostics: readonly string[];
|
|
37
|
+
/** the build report on disk, filled in by whoever was writing one */
|
|
38
|
+
dump?: string;
|
|
39
|
+
constructor(operation: Operation, diagnostics: readonly string[]);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Why something failed, in one line, including the part that is usually hidden.
|
|
43
|
+
*
|
|
44
|
+
* `fetch failed` is undici's word for a dozen different problems — a DNS
|
|
45
|
+
* miss, a refused connection, a TLS error, a timeout — and which one it was is
|
|
46
|
+
* only ever in `cause`. Reporting the top-level message alone turns every
|
|
47
|
+
* network fault into the same useless sentence.
|
|
48
|
+
*/
|
|
49
|
+
export declare function reason(err: unknown): string;
|
|
50
|
+
export declare function build(operation: Operation, opts: BuildOptions): Promise<Built>;
|
|
51
|
+
export declare function regenerate(operation: Operation, opts: RegenerateOptions): Promise<Built>;
|
|
52
|
+
/**
|
|
53
|
+
* Models fence code even when told not to, and a stray ```python line is a
|
|
54
|
+
* syntax error rather than a bad answer — not worth a round trip.
|
|
55
|
+
*/
|
|
56
|
+
export declare function unfence(text: string): string;
|
|
57
|
+
//# sourceMappingURL=generate.d.ts.map
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { tokenOf } from "./paging.js";
|
|
2
|
+
import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
|
|
3
|
+
import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
|
|
4
|
+
import { called } from "./spec.js";
|
|
5
|
+
import { describeIssues, issues } from "./validate.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tries per generator. Each one carries the previous file and what was wrong
|
|
8
|
+
* with it, so the later attempts are the informed ones — a model that has seen
|
|
9
|
+
* its own traceback usually needs two or three goes at a fiddly schema, and the
|
|
10
|
+
* whole loop costs nothing once the file is cached.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_ATTEMPTS = 5;
|
|
13
|
+
export class BuildFailed extends Error {
|
|
14
|
+
operation;
|
|
15
|
+
diagnostics;
|
|
16
|
+
/** the build report on disk, filled in by whoever was writing one */
|
|
17
|
+
dump;
|
|
18
|
+
constructor(operation, diagnostics) {
|
|
19
|
+
super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
|
|
20
|
+
this.name = 'BuildFailed';
|
|
21
|
+
this.operation = operation;
|
|
22
|
+
this.diagnostics = diagnostics;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why something failed, in one line, including the part that is usually hidden.
|
|
27
|
+
*
|
|
28
|
+
* `fetch failed` is undici's word for a dozen different problems — a DNS
|
|
29
|
+
* miss, a refused connection, a TLS error, a timeout — and which one it was is
|
|
30
|
+
* only ever in `cause`. Reporting the top-level message alone turns every
|
|
31
|
+
* network fault into the same useless sentence.
|
|
32
|
+
*/
|
|
33
|
+
export function reason(err) {
|
|
34
|
+
if (err instanceof BuildFailed && err.diagnostics.length > 0) {
|
|
35
|
+
return `${err.message}: ${err.diagnostics.join(' ')}`;
|
|
36
|
+
}
|
|
37
|
+
const parts = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
let at = err;
|
|
40
|
+
while (at instanceof Error && !seen.has(at)) {
|
|
41
|
+
seen.add(at);
|
|
42
|
+
const e = at;
|
|
43
|
+
const head = [e.status ?? e.statusCode, e.code].filter(Boolean).join(' ');
|
|
44
|
+
const line = `${head} ${e.message.split('\n')[0].trim()}`.trim();
|
|
45
|
+
if (line && !parts.includes(line)) {
|
|
46
|
+
parts.push(line);
|
|
47
|
+
}
|
|
48
|
+
at = e.cause;
|
|
49
|
+
}
|
|
50
|
+
const hint = err?.hint;
|
|
51
|
+
if (typeof hint === 'string' && hint) {
|
|
52
|
+
parts.push(hint);
|
|
53
|
+
}
|
|
54
|
+
return parts.join(' — ') || String(err);
|
|
55
|
+
}
|
|
56
|
+
export async function build(operation, opts) {
|
|
57
|
+
const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
58
|
+
const probes = probesFor(operation);
|
|
59
|
+
const response = opts.checks.for(operation).response;
|
|
60
|
+
const messages = [
|
|
61
|
+
{ role: 'user', content: [{ type: 'text', text: instruction(operation) }] },
|
|
62
|
+
];
|
|
63
|
+
let last = [];
|
|
64
|
+
for (let attempt = 1; attempt <= limit; attempt++) {
|
|
65
|
+
const answer = await ask(opts, {
|
|
66
|
+
system: SYSTEM,
|
|
67
|
+
messages,
|
|
68
|
+
tools: [],
|
|
69
|
+
signal: opts.signal,
|
|
70
|
+
});
|
|
71
|
+
const source = unfence(answer.text);
|
|
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) }] });
|
|
84
|
+
}
|
|
85
|
+
throw new BuildFailed(operation, last);
|
|
86
|
+
}
|
|
87
|
+
export async function regenerate(operation, opts) {
|
|
88
|
+
const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
89
|
+
const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
|
|
90
|
+
const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
|
|
91
|
+
const response = opts.checks.for(operation).response;
|
|
92
|
+
const messages = [
|
|
93
|
+
{
|
|
94
|
+
role: 'user',
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: 'text',
|
|
98
|
+
text: regenerationInstruction({
|
|
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
|
+
],
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
let last = [];
|
|
111
|
+
for (let attempt = 1; attempt <= limit; attempt++) {
|
|
112
|
+
const answer = await ask(opts, {
|
|
113
|
+
system: SYSTEM,
|
|
114
|
+
messages,
|
|
115
|
+
tools: [],
|
|
116
|
+
signal: opts.signal,
|
|
117
|
+
});
|
|
118
|
+
const source = unfence(answer.text);
|
|
119
|
+
if (!source.trim()) {
|
|
120
|
+
last = ['the answer was empty'];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
await opts.box.write(operation.key, source);
|
|
124
|
+
last = await judge(operation, probes, response, opts.box);
|
|
125
|
+
if (last.length === 0) {
|
|
126
|
+
return { source, attempts: attempt };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await opts.onAttempt?.(attempt, last, source, limit);
|
|
130
|
+
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
131
|
+
}
|
|
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
|
+
}
|
|
148
|
+
const discard = () => { };
|
|
149
|
+
/** Every probe, run and checked. Empty means the generator is good. */
|
|
150
|
+
async function judge(operation, probes, response, box) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const probe of probes) {
|
|
153
|
+
const outcome = await box.run(operation.key, probe);
|
|
154
|
+
const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
|
|
155
|
+
if (!outcome.ok) {
|
|
156
|
+
out.push(`- ${what}: the file ${outcome.fault}.`);
|
|
157
|
+
if (outcome.stderr) {
|
|
158
|
+
out.push(` stderr: ${tail(outcome.stderr)}`);
|
|
159
|
+
}
|
|
160
|
+
// A file that will not run says nothing about the next probe.
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (response && !response(outcome.value)) {
|
|
164
|
+
out.push(`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
|
|
165
|
+
}
|
|
166
|
+
const echo = echoIssues(probe, outcome.value, operation.success.schema);
|
|
167
|
+
if (echo.length > 0) {
|
|
168
|
+
out.push(`- ${what}: ${describeIssues(echo)}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
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
|
+
if (out.length === 0 && operation.paging?.next) {
|
|
174
|
+
out.push(...(await walk(operation, operation.paging, response, box)));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/** How many pages a mock may offer before it is simply not terminating. */
|
|
179
|
+
const MAX_PAGES = 8;
|
|
180
|
+
/**
|
|
181
|
+
* Follows the operation's own cursor and reports the ways that walk fails to
|
|
182
|
+
* end. A schema cannot express this and neither can any one response: the bug
|
|
183
|
+
* is a relation between two of them.
|
|
184
|
+
*/
|
|
185
|
+
async function walk(operation, paging, response, box) {
|
|
186
|
+
let input = walkStart(operation, paging);
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
let sent;
|
|
189
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
190
|
+
const outcome = await box.run(operation.key, input);
|
|
191
|
+
const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
|
|
192
|
+
if (!outcome.ok) {
|
|
193
|
+
return [`- ${what}: the file ${outcome.fault}.`];
|
|
194
|
+
}
|
|
195
|
+
if (response && !response(outcome.value)) {
|
|
196
|
+
return [
|
|
197
|
+
`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
const token = tokenOf(outcome.value, paging);
|
|
201
|
+
if (token === undefined) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
if (token === sent) {
|
|
205
|
+
return [
|
|
206
|
+
`- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after pagination ends.`,
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
if (seen.has(token)) {
|
|
210
|
+
return [
|
|
211
|
+
`- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
seen.add(token);
|
|
215
|
+
sent = token;
|
|
216
|
+
input = nextPage(input, paging, token);
|
|
217
|
+
}
|
|
218
|
+
return [
|
|
219
|
+
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate between 1 and 10 pages in total and set it to null on the last.`,
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Models fence code even when told not to, and a stray ```python line is a
|
|
224
|
+
* syntax error rather than a bad answer — not worth a round trip.
|
|
225
|
+
*/
|
|
226
|
+
export function unfence(text) {
|
|
227
|
+
const trimmed = text.trim();
|
|
228
|
+
if (!trimmed.startsWith('```')) {
|
|
229
|
+
return trimmed;
|
|
230
|
+
}
|
|
231
|
+
const body = trimmed.slice(trimmed.indexOf('\n') + 1);
|
|
232
|
+
const close = body.lastIndexOf('```');
|
|
233
|
+
return (close === -1 ? body : body.slice(0, close)).trimEnd();
|
|
234
|
+
}
|
|
235
|
+
const tail = (s) => s.trim().split('\n').slice(-6).join('\n ');
|
|
236
|
+
//# sourceMappingURL=generate.js.map
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Runner } from '@zenera/neo';
|
|
2
|
+
/**
|
|
3
|
+
* Lower bounds rather than exact pins: the tag is a function of this list, so
|
|
4
|
+
* an edit here yields a new image and a new container either way, and pinning a
|
|
5
|
+
* patch release would only mean a rebuild every time one is published.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REQUIREMENTS: readonly ["Faker>=37", "exrex>=0.11", "jsonschema>=4.23", "python-dateutil>=2.9"];
|
|
8
|
+
/** What the generator may import. Quoted verbatim in the prompt. */
|
|
9
|
+
export declare const AVAILABLE: readonly ["faker", "exrex", "jsonschema", "dateutil"];
|
|
10
|
+
export declare const BASE_IMAGE = "docker.io/library/python:3.14-slim-bookworm";
|
|
11
|
+
export declare function imageTag(base?: string): string;
|
|
12
|
+
export interface ImageOptions {
|
|
13
|
+
/** where the empty build context is created */
|
|
14
|
+
root: string;
|
|
15
|
+
base?: string;
|
|
16
|
+
engine?: string;
|
|
17
|
+
exec?: Runner;
|
|
18
|
+
onBuild?: (tag: string) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The tag to run, built if it is not there yet. Returns without touching podman
|
|
22
|
+
* when the image already exists, which is every start after the first.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ensureImage(opts: ImageOptions): Promise<string>;
|
|
25
|
+
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DEFAULT_SANDBOX_IMAGE, runProcess } from '@zenera/neo';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The image
|
|
7
|
+
//
|
|
8
|
+
// The generators are Python, and Python that has to invent a plausible name out
|
|
9
|
+
// of `random.choice` writes output that looks like test data because it is. So
|
|
10
|
+
// the container gets real libraries.
|
|
11
|
+
//
|
|
12
|
+
// They cannot be installed into the *running* box. `containerName()` in
|
|
13
|
+
// @zenera/neo hashes the image and the network, so "start online, install, then
|
|
14
|
+
// switch to `network: none`" resolves to two different containers and the
|
|
15
|
+
// install goes with the first one. Baking them into an image instead is one
|
|
16
|
+
// build, cached by podman's own layers, and leaves the serving container
|
|
17
|
+
// offline — which is the property that matters, because the code it runs was
|
|
18
|
+
// written by a model.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Lower bounds rather than exact pins: the tag is a function of this list, so
|
|
22
|
+
* an edit here yields a new image and a new container either way, and pinning a
|
|
23
|
+
* patch release would only mean a rebuild every time one is published.
|
|
24
|
+
*/
|
|
25
|
+
export const REQUIREMENTS = [
|
|
26
|
+
'Faker>=37',
|
|
27
|
+
'exrex>=0.11',
|
|
28
|
+
'jsonschema>=4.23',
|
|
29
|
+
'python-dateutil>=2.9',
|
|
30
|
+
];
|
|
31
|
+
/** What the generator may import. Quoted verbatim in the prompt. */
|
|
32
|
+
export const AVAILABLE = ['faker', 'exrex', 'jsonschema', 'dateutil'];
|
|
33
|
+
export const BASE_IMAGE = DEFAULT_SANDBOX_IMAGE;
|
|
34
|
+
export function imageTag(base = BASE_IMAGE) {
|
|
35
|
+
const digest = createHash('sha256')
|
|
36
|
+
.update(JSON.stringify([base, [...REQUIREMENTS].sort()]))
|
|
37
|
+
.digest('hex')
|
|
38
|
+
.slice(0, 12);
|
|
39
|
+
return `localhost/zenera-gym:${digest}`;
|
|
40
|
+
}
|
|
41
|
+
function containerfile(base) {
|
|
42
|
+
return [
|
|
43
|
+
`FROM ${base}`,
|
|
44
|
+
`RUN pip install --no-cache-dir --disable-pip-version-check ${REQUIREMENTS.join(' ')}`,
|
|
45
|
+
'',
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The tag to run, built if it is not there yet. Returns without touching podman
|
|
50
|
+
* when the image already exists, which is every start after the first.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureImage(opts) {
|
|
53
|
+
const base = opts.base ?? BASE_IMAGE;
|
|
54
|
+
const tag = imageTag(base);
|
|
55
|
+
const engine = opts.engine ?? 'podman';
|
|
56
|
+
const run = opts.exec ?? runProcess;
|
|
57
|
+
const exists = await run(engine, ['image', 'exists', tag], { timeoutMs: 60_000 });
|
|
58
|
+
if (exists.code === 0) {
|
|
59
|
+
return tag;
|
|
60
|
+
}
|
|
61
|
+
opts.onBuild?.(tag);
|
|
62
|
+
// An empty directory, because there is nothing to COPY: handing podman the
|
|
63
|
+
// working directory instead would tar up whatever happened to be in it.
|
|
64
|
+
const context = join(opts.root, 'build');
|
|
65
|
+
mkdirSync(context, { recursive: true });
|
|
66
|
+
// A real file rather than `--file -`: a spawned child's stdin is a
|
|
67
|
+
// socketpair, and a socket cannot be reopened through /dev/stdin, so the
|
|
68
|
+
// engine fails with ENXIO. It sits beside the context, not inside it, so
|
|
69
|
+
// the context stays empty.
|
|
70
|
+
const file = join(opts.root, 'Containerfile');
|
|
71
|
+
writeFileSync(file, containerfile(base));
|
|
72
|
+
const built = await run(engine, ['build', '--tag', tag, '--file', file, context], {
|
|
73
|
+
timeoutMs: 900_000,
|
|
74
|
+
maxBytes: 256 * 1024,
|
|
75
|
+
});
|
|
76
|
+
if (built.code !== 0) {
|
|
77
|
+
throw new Error(`could not build ${tag}: ${(built.stderr.trim() || built.stdout.trim()).split('\n').slice(-3).join(' ')}`);
|
|
78
|
+
}
|
|
79
|
+
return tag;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=image.js.map
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
+
export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SANDBOX_UNAVAILABLE' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
|
|
3
|
+
export interface RequestDumpData {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: Date;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
pathname: string;
|
|
10
|
+
search: string;
|
|
11
|
+
operationId?: string;
|
|
12
|
+
routePath?: string;
|
|
13
|
+
pathParams?: Record<string, string>;
|
|
14
|
+
query?: Record<string, string>;
|
|
15
|
+
requestHeaders: Record<string, string>;
|
|
16
|
+
requestBody?: unknown;
|
|
17
|
+
status: number;
|
|
18
|
+
responseHeaders?: Record<string, string | number>;
|
|
19
|
+
responseBody?: unknown;
|
|
20
|
+
errorKind?: ErrorKind;
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
/** what to do about it, when the fix is not in the generator */
|
|
23
|
+
hint?: string;
|
|
24
|
+
stderr?: string;
|
|
25
|
+
/** the build report, when the answer was "there is no generator" */
|
|
26
|
+
buildDump?: string;
|
|
27
|
+
cacheStatus?: 'hit' | 'miss' | 'regenerated';
|
|
28
|
+
regenerated?: boolean;
|
|
29
|
+
regenAttempts?: number;
|
|
30
|
+
regenLimitHit?: boolean;
|
|
31
|
+
regenLimit?: number;
|
|
32
|
+
issues?: readonly unknown[];
|
|
33
|
+
note?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function formatTimestamp(d: Date): string;
|
|
36
|
+
export declare function statusText(status: number): string;
|
|
37
|
+
export declare function colorForStatus(status: number): (s: string) => string;
|
|
38
|
+
export declare function colorForErrorKind(kind: ErrorKind): (s: string) => string;
|
|
39
|
+
/**
|
|
40
|
+
* Saves a markdown file with all input information and output data for a request.
|
|
41
|
+
* Returns the absolute path of the generated markdown file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function saveRequestDump(dir: string, data: RequestDumpData): Promise<string>;
|
|
44
|
+
export interface BuildAttempt {
|
|
45
|
+
attempt: number;
|
|
46
|
+
at: Date;
|
|
47
|
+
diagnostics: readonly string[];
|
|
48
|
+
source: string;
|
|
49
|
+
}
|
|
50
|
+
export interface BuildDumpData {
|
|
51
|
+
started: Date;
|
|
52
|
+
operationId?: string;
|
|
53
|
+
method: string;
|
|
54
|
+
path: string;
|
|
55
|
+
/** the operation's cache key, which is also its directory in the box */
|
|
56
|
+
key: string;
|
|
57
|
+
limit: number;
|
|
58
|
+
/** rewriting a generator that already existed rather than writing the first */
|
|
59
|
+
regeneration?: boolean;
|
|
60
|
+
model?: string;
|
|
61
|
+
attempts: readonly BuildAttempt[];
|
|
62
|
+
/** the attempts ran out — no generator was produced */
|
|
63
|
+
gaveUp?: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare function saveBuildDump(dir: string, data: BuildDumpData): Promise<string>;
|
|
66
|
+
export declare function formatDumpPath(label: string, path: string): string;
|
|
67
|
+
export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
|
|
68
|
+
export declare function formatRegenLimitHit(urlPath: string, limit: number, operationId?: string): string;
|
|
69
|
+
export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string, stderr?: string): string;
|
|
70
|
+
export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
|
|
71
|
+
export interface ExampleRequest {
|
|
72
|
+
input: GeneratorInput;
|
|
73
|
+
output?: unknown;
|
|
74
|
+
}
|
|
75
|
+
export declare class RequestHistory {
|
|
76
|
+
#private;
|
|
77
|
+
constructor(maxExamples?: number);
|
|
78
|
+
recordSuccess(operationKey: string, input: GeneratorInput, output: unknown): void;
|
|
79
|
+
getSuccessful(operationKey: string, count?: number): ExampleRequest[];
|
|
80
|
+
}
|
|
81
|
+
export declare class RegenerationLimiter {
|
|
82
|
+
#private;
|
|
83
|
+
readonly limit: number;
|
|
84
|
+
constructor(limit?: number);
|
|
85
|
+
countFor(url: string): number;
|
|
86
|
+
isLimitHit(url: string): boolean;
|
|
87
|
+
increment(url: string): number;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=logger.d.ts.map
|