@zenera/faker 1.1.0
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 +106 -0
- package/dist/box.d.ts +43 -0
- package/dist/box.js +158 -0
- package/dist/cache.d.ts +57 -0
- package/dist/cache.js +125 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +339 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +38 -0
- package/dist/generate.js +125 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +77 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +278 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +98 -0
- package/dist/router.d.ts +14 -0
- package/dist/router.js +83 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +221 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +256 -0
- package/dist/setup.d.ts +35 -0
- package/dist/setup.js +81 -0
- package/dist/spec.d.ts +41 -0
- package/dist/spec.js +257 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrey Ryabov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @zenera/faker
|
|
2
|
+
|
|
3
|
+
**A mock HTTP server for swagger/OpenAPI documents. Point it at a spec and it
|
|
4
|
+
serves it — the response bodies are written, once, by a model.**
|
|
5
|
+
|
|
6
|
+
[](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
> Part of [ZeneraNeo](https://github.com/andreyryabov/ZeneraNeo). It ships no
|
|
10
|
+
> binary of its own: installing it adds a `faker` **subcommand** to
|
|
11
|
+
> [`zen`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md),
|
|
12
|
+
> which is also where the credentials already are.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Node.js 24+ and [podman](https://podman.io). Install it alongside the CLI:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i -g @zenera/cli @zenera/faker openai
|
|
20
|
+
zen key add openai # the keyring `zen` already uses
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The first start builds one image; every start after that reuses it. `zen --help`
|
|
24
|
+
lists `faker` whether or not it is installed, and says what to run if not.
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
zen faker serve api/openapi.yaml --port 8787
|
|
30
|
+
curl -s localhost:8787/users/12324
|
|
31
|
+
# { "user_id": 12324, "email": "brooke.hoffman@example.org", ... }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
More than one document at a time is fine, and the same request answers the same
|
|
35
|
+
way every time when you pin a seed:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
zen faker serve specs/*.yaml --seed 42
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Warm it up before a demo or a test run, so no request pays for a model turn:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
zen faker build api/openapi.yaml # write every generator now and exit
|
|
45
|
+
zen faker cache ls # what has been generated
|
|
46
|
+
zen faker cache clear # throw it away
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`zen mock` is the same command under a shorter noun.
|
|
50
|
+
|
|
51
|
+
## How a body is produced
|
|
52
|
+
|
|
53
|
+
The first time an operation is called, the faker asks a model to write a
|
|
54
|
+
**Python generator** for it — one file, taking a JSON input path and a JSON
|
|
55
|
+
output path. That file is then run against synthetic probes and judged twice:
|
|
56
|
+
against the operation's response schema, and against the echo rule, which says
|
|
57
|
+
that where a path or query parameter shares a name with a property in the
|
|
58
|
+
response, the response has to carry the value that was asked for.
|
|
59
|
+
`GET /users/12324` answering with somebody else's id validates perfectly and is
|
|
60
|
+
still wrong.
|
|
61
|
+
|
|
62
|
+
If it fails, the diagnostics go back to the model and it tries again, up to
|
|
63
|
+
`--attempts`. If it passes, the file is cached under `~/.zenera/neo/faker` and
|
|
64
|
+
every later request is just `podman exec python3 gen.py in.json out.json` — no
|
|
65
|
+
model, no tokens.
|
|
66
|
+
|
|
67
|
+
Generators run in a container with **no network**, on an image baked once with
|
|
68
|
+
`faker`, `exrex`, `jsonschema` and `python-dateutil`.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
zen faker serve <spec...> Serve them. Generators are written on demand.
|
|
74
|
+
zen faker build <spec...> Write every generator now and exit.
|
|
75
|
+
zen faker cache ls | clear What has been generated, or throw it away.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Useful options: `--port`, `--host` (loopback by default), `--model`, `--seed`
|
|
79
|
+
(same request, same answer), `--rebuild`, `--attempts`, `--concurrency`,
|
|
80
|
+
`--timeout`, `--cache <dir>`, `--quiet`. `zen help faker` prints the full table.
|
|
81
|
+
|
|
82
|
+
`GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
|
|
83
|
+
health check.
|
|
84
|
+
|
|
85
|
+
## Credentials
|
|
86
|
+
|
|
87
|
+
The keyring is `zen`'s, so there is nothing new to configure:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
zen key add openai
|
|
91
|
+
zen key ls --check
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Environment variables still win over the keyring, exactly as they do for `zen`.
|
|
95
|
+
|
|
96
|
+
## The rest of the family
|
|
97
|
+
|
|
98
|
+
| Package | What it is |
|
|
99
|
+
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
100
|
+
| [`@zenera/cli`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md) | `zen` — agent projects on the command line |
|
|
101
|
+
| [`@zenera/neo`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/neo/README.md) | the runtime — agents, models, tools, skills, memory |
|
|
102
|
+
| [`@zenera/rag`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/rag/README.md) | `zen rag` — an API description as a searchable graph |
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[MIT](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE).
|
package/dist/box.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Runner, type Sandbox } from '@zenera/neo';
|
|
2
|
+
export declare const GENERATORS = "generators";
|
|
3
|
+
export interface BoxOptions {
|
|
4
|
+
/** host directory mounted at /workspace; holds generators/ and io/ */
|
|
5
|
+
root: string;
|
|
6
|
+
image: string;
|
|
7
|
+
/** seconds one generator may take */
|
|
8
|
+
timeout?: number;
|
|
9
|
+
engine?: string;
|
|
10
|
+
exec?: Runner;
|
|
11
|
+
}
|
|
12
|
+
export interface Outcome {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
/** parsed `output.json`, when the run produced one */
|
|
15
|
+
value?: unknown;
|
|
16
|
+
/** what went wrong, in the words the build loop feeds back to the model */
|
|
17
|
+
fault?: string;
|
|
18
|
+
stderr?: string;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class Box {
|
|
22
|
+
#private;
|
|
23
|
+
readonly root: string;
|
|
24
|
+
constructor(opts: BoxOptions);
|
|
25
|
+
get sandbox(): Sandbox;
|
|
26
|
+
/**
|
|
27
|
+
* Removes a container of this name left behind by a process that did not
|
|
28
|
+
* get to clean up. Belt to `persist: false`'s braces: a hard kill never
|
|
29
|
+
* runs `dispose`, and the leftover is exactly the stale-mount trap above.
|
|
30
|
+
*/
|
|
31
|
+
fresh(): Promise<void>;
|
|
32
|
+
/** Host path of a generator's source file. */
|
|
33
|
+
sourceOf(key: string): string;
|
|
34
|
+
write(key: string, source: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* One generator, one input, one output. The io directory is removed
|
|
37
|
+
* afterwards whatever happened — a mock server left alone for a week must
|
|
38
|
+
* not fill a disk with request envelopes.
|
|
39
|
+
*/
|
|
40
|
+
run(key: string, input: unknown): Promise<Outcome>;
|
|
41
|
+
dispose(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=box.d.ts.map
|
package/dist/box.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { SANDBOX_MOUNT, SandboxPool, runProcess } from '@zenera/neo';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Where generators run
|
|
8
|
+
//
|
|
9
|
+
// One container for the whole process, offline, with the faker's own directory
|
|
10
|
+
// bind-mounted at /workspace. That single mount is what makes the file contract
|
|
11
|
+
// work in both directions: the host writes `input.json` and reads
|
|
12
|
+
// `output.json`, the generator sees the same two paths from inside, and neither
|
|
13
|
+
// side has to serialise anything through a pipe.
|
|
14
|
+
//
|
|
15
|
+
// Nothing the model wrote ever reaches an argument on this side. The generator
|
|
16
|
+
// is invoked by a script built here out of paths derived here, and the script
|
|
17
|
+
// itself travels on stdin — the same rule the sandbox tools follow.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export const GENERATORS = 'generators';
|
|
20
|
+
const IO = 'io';
|
|
21
|
+
const ENTRY = 'gen.py';
|
|
22
|
+
export class Box {
|
|
23
|
+
root;
|
|
24
|
+
#pool;
|
|
25
|
+
#timeout;
|
|
26
|
+
#engine;
|
|
27
|
+
#exec;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.root = opts.root;
|
|
30
|
+
this.#timeout = opts.timeout ?? 30;
|
|
31
|
+
this.#engine = opts.engine ?? 'podman';
|
|
32
|
+
this.#exec = opts.exec ?? runProcess;
|
|
33
|
+
mkdirSync(join(opts.root, GENERATORS), { recursive: true });
|
|
34
|
+
mkdirSync(join(opts.root, IO), { recursive: true });
|
|
35
|
+
this.#pool = new SandboxPool({
|
|
36
|
+
root: opts.root,
|
|
37
|
+
key: 'faker',
|
|
38
|
+
image: opts.image,
|
|
39
|
+
// The one line that keeps model-written code from calling home.
|
|
40
|
+
network: 'none',
|
|
41
|
+
workdir: SANDBOX_MOUNT,
|
|
42
|
+
timeout: this.#timeout,
|
|
43
|
+
// Deliberately NOT persisted, unlike `zen`'s sandbox. A container's
|
|
44
|
+
// name is a hash of its configuration, which includes the host
|
|
45
|
+
// path but not the directory behind it — so a cache directory that
|
|
46
|
+
// is deleted and recreated (`zen faker cache clear`, or any rm -rf)
|
|
47
|
+
// gets a stopped container reattached whose bind mount still points
|
|
48
|
+
// at the old inode. Everything written here is then invisible
|
|
49
|
+
// inside, and every generator fails with "can't open file".
|
|
50
|
+
// `zen` persists to keep `pip install`s; the libraries here are
|
|
51
|
+
// baked into the image, so there is nothing to keep.
|
|
52
|
+
persist: false,
|
|
53
|
+
readOnly: false,
|
|
54
|
+
// Deliberately empty: the keyring is in this process's environment
|
|
55
|
+
// and none of it belongs in the container.
|
|
56
|
+
env: {},
|
|
57
|
+
engine: opts.engine,
|
|
58
|
+
exec: opts.exec,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
get sandbox() {
|
|
62
|
+
return this.#pool.for();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Removes a container of this name left behind by a process that did not
|
|
66
|
+
* get to clean up. Belt to `persist: false`'s braces: a hard kill never
|
|
67
|
+
* runs `dispose`, and the leftover is exactly the stale-mount trap above.
|
|
68
|
+
*/
|
|
69
|
+
async fresh() {
|
|
70
|
+
await this.#exec(this.#engine, ['rm', '--force', '--volumes', this.sandbox.name], {
|
|
71
|
+
timeoutMs: 60_000,
|
|
72
|
+
}).catch(() => undefined);
|
|
73
|
+
}
|
|
74
|
+
/** Host path of a generator's source file. */
|
|
75
|
+
sourceOf(key) {
|
|
76
|
+
return join(this.root, GENERATORS, key, ENTRY);
|
|
77
|
+
}
|
|
78
|
+
async write(key, source) {
|
|
79
|
+
mkdirSync(join(this.root, GENERATORS, key), { recursive: true });
|
|
80
|
+
await writeFile(this.sourceOf(key), source, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One generator, one input, one output. The io directory is removed
|
|
84
|
+
* afterwards whatever happened — a mock server left alone for a week must
|
|
85
|
+
* not fill a disk with request envelopes.
|
|
86
|
+
*/
|
|
87
|
+
async run(key, input) {
|
|
88
|
+
const id = randomUUID();
|
|
89
|
+
const dir = join(this.root, IO, id);
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(join(dir, 'input.json'), JSON.stringify(input), 'utf8');
|
|
94
|
+
const script = [
|
|
95
|
+
`exec python3 ${inside(GENERATORS, key, ENTRY)}`,
|
|
96
|
+
inside(IO, id, 'input.json'),
|
|
97
|
+
inside(IO, id, 'output.json'),
|
|
98
|
+
].join(' ');
|
|
99
|
+
const res = await this.sandbox.exec(script, { timeout: this.#timeout });
|
|
100
|
+
const stderr = res.stderr.trim();
|
|
101
|
+
if (res.timed_out) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
fault: `took longer than ${this.#timeout}s`,
|
|
105
|
+
stderr,
|
|
106
|
+
durationMs: Date.now() - started,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (res.exit_code !== 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
fault: `exited ${res.exit_code}`,
|
|
113
|
+
stderr: stderr || res.stdout.trim(),
|
|
114
|
+
durationMs: Date.now() - started,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
let text;
|
|
118
|
+
try {
|
|
119
|
+
text = await readFile(join(dir, 'output.json'), 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
fault: 'wrote no output file',
|
|
125
|
+
stderr,
|
|
126
|
+
durationMs: Date.now() - started,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
value: JSON.parse(text),
|
|
133
|
+
stderr,
|
|
134
|
+
durationMs: Date.now() - started,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
fault: `output.json is not JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
141
|
+
stderr,
|
|
142
|
+
durationMs: Date.now() - started,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async dispose() {
|
|
151
|
+
await this.#pool.dispose();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** A path inside the container. Every segment is derived here, never given. */
|
|
155
|
+
function inside(...parts) {
|
|
156
|
+
return [SANDBOX_MOUNT, ...parts].join('/');
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=box.js.map
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Model } from '@zenera/neo';
|
|
2
|
+
import { type Box } from './box.ts';
|
|
3
|
+
import { BuildFailed } from './generate.ts';
|
|
4
|
+
import type { Operation } from './spec.ts';
|
|
5
|
+
import type { Checks } from './validate.ts';
|
|
6
|
+
export interface Generator {
|
|
7
|
+
key: string;
|
|
8
|
+
source: string;
|
|
9
|
+
/** whether it came off disk rather than out of a model */
|
|
10
|
+
cached: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CacheEvent {
|
|
13
|
+
operation: Operation;
|
|
14
|
+
attempt?: number;
|
|
15
|
+
diagnostics?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export interface CacheOptions {
|
|
18
|
+
box: Box;
|
|
19
|
+
checks: Checks;
|
|
20
|
+
model: Model;
|
|
21
|
+
attempts?: number;
|
|
22
|
+
/**
|
|
23
|
+
* How many generators may be written at once. A client that walks every
|
|
24
|
+
* route — which is exactly what people do to a new mock — would otherwise
|
|
25
|
+
* open one model request per operation simultaneously and be rate limited
|
|
26
|
+
* on all of them.
|
|
27
|
+
*/
|
|
28
|
+
concurrency?: number;
|
|
29
|
+
/** ignore what is on disk and write fresh */
|
|
30
|
+
rebuild?: boolean;
|
|
31
|
+
/** run generators but keep nothing */
|
|
32
|
+
ephemeral?: boolean;
|
|
33
|
+
onStart?: (e: CacheEvent) => void;
|
|
34
|
+
onAttempt?: (e: CacheEvent) => void;
|
|
35
|
+
onReady?: (e: CacheEvent & {
|
|
36
|
+
cached: boolean;
|
|
37
|
+
attempts: number;
|
|
38
|
+
}) => void;
|
|
39
|
+
onFail?: (e: CacheEvent & {
|
|
40
|
+
error: Error;
|
|
41
|
+
}) => void;
|
|
42
|
+
}
|
|
43
|
+
export declare class Cache {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(opts: CacheOptions);
|
|
46
|
+
/**
|
|
47
|
+
* The generator for an operation, written if it does not exist yet. Callers
|
|
48
|
+
* that arrive during a build wait for that build rather than starting one.
|
|
49
|
+
*
|
|
50
|
+
* `cached` answers "did this call cost a model turn", which is what the
|
|
51
|
+
* response header is for — so it is false for the caller that triggered the
|
|
52
|
+
* build and for everyone who waited on it, and true from then on.
|
|
53
|
+
*/
|
|
54
|
+
ensure(operation: Operation): Promise<Generator>;
|
|
55
|
+
}
|
|
56
|
+
export { BuildFailed };
|
|
57
|
+
//# sourceMappingURL=cache.d.ts.map
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { writeJson } from '@zenera/cli/lib';
|
|
5
|
+
import { GENERATORS } from "./box.js";
|
|
6
|
+
import { build, BuildFailed } from "./generate.js";
|
|
7
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
8
|
+
export class Cache {
|
|
9
|
+
#opts;
|
|
10
|
+
#live = new Map();
|
|
11
|
+
#settled = new Set();
|
|
12
|
+
#slots;
|
|
13
|
+
#waiting = [];
|
|
14
|
+
#running = 0;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.#opts = opts;
|
|
17
|
+
this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The generator for an operation, written if it does not exist yet. Callers
|
|
21
|
+
* that arrive during a build wait for that build rather than starting one.
|
|
22
|
+
*
|
|
23
|
+
* `cached` answers "did this call cost a model turn", which is what the
|
|
24
|
+
* response header is for — so it is false for the caller that triggered the
|
|
25
|
+
* build and for everyone who waited on it, and true from then on.
|
|
26
|
+
*/
|
|
27
|
+
ensure(operation) {
|
|
28
|
+
const already = this.#settled.has(operation.key);
|
|
29
|
+
const running = this.#live.get(operation.key);
|
|
30
|
+
if (running) {
|
|
31
|
+
return already ? running.then((g) => ({ ...g, cached: true })) : running;
|
|
32
|
+
}
|
|
33
|
+
const started = this.#make(operation).then((g) => {
|
|
34
|
+
this.#settled.add(operation.key);
|
|
35
|
+
return g;
|
|
36
|
+
}, (err) => {
|
|
37
|
+
// A model that tried and could not is remembered: re-asking it
|
|
38
|
+
// once per request is how a mock server becomes an invoice.
|
|
39
|
+
// Anything else — a rate limit, a dropped connection — is about
|
|
40
|
+
// this moment rather than this operation, so it is forgotten
|
|
41
|
+
// and the next request gets a fresh go.
|
|
42
|
+
if (!(err instanceof BuildFailed)) {
|
|
43
|
+
this.#live.delete(operation.key);
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
});
|
|
47
|
+
this.#live.set(operation.key, started);
|
|
48
|
+
return started;
|
|
49
|
+
}
|
|
50
|
+
async #make(operation) {
|
|
51
|
+
const { box, model, checks, rebuild, ephemeral } = this.#opts;
|
|
52
|
+
// Read before queueing: a cache hit costs nothing and must not wait
|
|
53
|
+
// behind somebody else's model call.
|
|
54
|
+
const source = rebuild ? undefined : await read(box, operation.key);
|
|
55
|
+
if (source !== undefined) {
|
|
56
|
+
this.#opts.onReady?.({ operation, cached: true, attempts: 0 });
|
|
57
|
+
return { key: operation.key, source, cached: true };
|
|
58
|
+
}
|
|
59
|
+
await this.#enter();
|
|
60
|
+
this.#opts.onStart?.({ operation });
|
|
61
|
+
try {
|
|
62
|
+
const built = await build(operation, {
|
|
63
|
+
model,
|
|
64
|
+
box,
|
|
65
|
+
checks,
|
|
66
|
+
attempts: this.#opts.attempts,
|
|
67
|
+
onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
|
|
68
|
+
});
|
|
69
|
+
if (!ephemeral) {
|
|
70
|
+
writeJson(join(box.root, GENERATORS, operation.key, 'meta.json'), {
|
|
71
|
+
version: 1,
|
|
72
|
+
operationId: operation.operationId,
|
|
73
|
+
method: operation.method,
|
|
74
|
+
path: operation.path,
|
|
75
|
+
source: operation.source,
|
|
76
|
+
model: model.id,
|
|
77
|
+
attempts: built.attempts,
|
|
78
|
+
rebuilt: Boolean(rebuild),
|
|
79
|
+
createdAt: new Date().toISOString(),
|
|
80
|
+
}, 0o644);
|
|
81
|
+
}
|
|
82
|
+
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
83
|
+
return { key: operation.key, source: built.source, cached: false };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
87
|
+
this.#opts.onFail?.({ operation, error });
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.#leave();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#enter() {
|
|
95
|
+
if (this.#running < this.#slots) {
|
|
96
|
+
this.#running++;
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
return new Promise((admit) => this.#waiting.push(admit));
|
|
100
|
+
}
|
|
101
|
+
/** Hands the slot straight to whoever is next, so the count stays exact. */
|
|
102
|
+
#leave() {
|
|
103
|
+
const next = this.#waiting.shift();
|
|
104
|
+
if (next) {
|
|
105
|
+
next();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.#running--;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function read(box, key) {
|
|
112
|
+
const path = box.sourceOf(key);
|
|
113
|
+
if (!existsSync(path)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const source = await readFile(path, 'utf8');
|
|
118
|
+
return source.trim() ? source : undefined;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export { BuildFailed };
|
|
125
|
+
//# sourceMappingURL=cache.js.map
|