@genroc/eval-node 0.0.0-edge.b8f0518 → 0.0.0-edge.f85ea85

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 CHANGED
@@ -24,16 +24,6 @@ on the cwd:
24
24
  `genctl apply` and `genctl types` now resolve `$import` directives. Typechecking is the
25
25
  resolver's exit code, so a stored definition cannot hold code that failed to typecheck.
26
26
 
27
- ## Building
28
-
29
- npm run build # tsc -p tsconfig.build.json -> dist/
30
-
31
- The published package is JavaScript, not TypeScript. **Node refuses to strip types for files
32
- under `node_modules`**, so shipping `.ts` works from a checkout and fails for every consumer —
33
- which is why `prepublishOnly` builds and CI installs the packed tarball rather than only running
34
- from source. The source keeps its `.ts` import extensions; `rewriteRelativeImportExtensions`
35
- turns them into `.js` on the way out.
36
-
37
27
  ## Running the worker
38
28
 
39
29
  docker run -e GENROC_SERVER=http://host:8448 ghcr.io/genroc/eval-node:preview
package/bin/import.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // npm `bin` entries need a shebang and a stable extension; Node's type stripping handles the
3
+ // .ts behind it. .genroc points here: command: [npx, genroc-import]
4
+ import "../import.ts";
package/bin/worker.mjs ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../worker.ts";
package/eval.ts CHANGED
@@ -38,12 +38,7 @@ export type WorkerRequest = { code: string; input?: unknown };
38
38
  export type WorkerReply = EvalResult;
39
39
 
40
40
  const DEFAULT_TIMEOUT_MS = 5_000;
41
- // Resolved from THIS file's own extension: run from a checkout it is `.ts`, and from the
42
- // published package `.js`, because Node will not strip types under node_modules.
43
- const REALM_URL = new URL(
44
- import.meta.url.endsWith(".ts") ? "./realm.ts" : "./realm.js",
45
- import.meta.url,
46
- );
41
+ const REALM_URL = new URL("./realm.ts", import.meta.url);
47
42
 
48
43
  /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
49
44
  * answers by releasing the claim rather than by reporting an outcome. A script fault is a
package/import.ts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, non-zero exit
3
2
  // with the diagnostic on stderr. genctl never parses TypeScript and this never parses YAML
4
3
  // — the manifest is the whole contract. See specs/source-resolution.md.
package/package.json CHANGED
@@ -1,13 +1,11 @@
1
1
  {
2
2
  "name": "@genroc/eval-node",
3
- "version": "0.0.0-edge.b8f0518",
3
+ "version": "0.0.0-edge.f85ea85",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
7
7
  },
8
8
  "scripts": {
9
- "build": "tsc -p tsconfig.build.json",
10
- "prepublishOnly": "npm run build",
11
9
  "work": "node worker.ts",
12
10
  "typecheck": "tsc --noEmit"
13
11
  },
@@ -30,11 +28,11 @@
30
28
  },
31
29
  "homepage": "https://genroc.org",
32
30
  "bin": {
33
- "genroc-import": "dist/import.js",
34
- "genroc-eval-node": "dist/worker.js"
31
+ "genroc-import": "bin/import.mjs",
32
+ "genroc-eval-node": "bin/worker.mjs"
35
33
  },
36
34
  "files": [
37
- "dist",
35
+ "bin",
38
36
  "*.ts",
39
37
  "tsconfig.json",
40
38
  "README.md"
package/worker.ts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
3
2
  // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
4
3
  // nothing about the queue, which is what keeps the containment strategy swappable.
package/dist/eval.js DELETED
@@ -1,64 +0,0 @@
1
- // Evaluation core: run a code string in its OWN realm and classify every outcome into one of
2
- // the failure kinds in README.md. Nothing here knows about genroc — worker.ts is the only
3
- // thing that talks to the queue — so this stays testable and the containment stays swappable.
4
- //
5
- // The containment is a Worker per execution (realm.ts). It is what makes the budget real:
6
- // a synchronous busy loop never yields, so no in-process timer can interrupt it, and only a
7
- // thread the host can kill bounds it.
8
- import { Worker } from "node:worker_threads";
9
- const DEFAULT_TIMEOUT_MS = 5_000;
10
- // Resolved from THIS file's own extension: run from a checkout it is `.ts`, and from the
11
- // published package `.js`, because Node will not strip types under node_modules.
12
- const REALM_URL = new URL(import.meta.url.endsWith(".ts") ? "./realm.ts" : "./realm.js", import.meta.url);
13
- /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
14
- * answers by releasing the claim rather than by reporting an outcome. A script fault is a
15
- * return value. */
16
- class RealmFault extends Error {
17
- constructor(message) {
18
- super(message);
19
- this.name = "RealmFault";
20
- }
21
- }
22
- export async function evaluate(req) {
23
- const budget = typeof req.timeout_ms === "number" ? req.timeout_ms : DEFAULT_TIMEOUT_MS;
24
- const worker = new Worker(REALM_URL);
25
- let timer;
26
- try {
27
- return await new Promise((resolve, reject) => {
28
- timer = setTimeout(() => resolve(timedOut(budget)), budget);
29
- worker.once("message", (reply) => resolve(reply));
30
- // A script may end its own realm (`process.exit()`), which is not a throw and would
31
- // otherwise present as a hang until the budget expired. Our own terminate() raises
32
- // this too, by which time the promise has settled and the first result stands.
33
- worker.once("exit", (code) => resolve(exited(code)));
34
- worker.once("error", (err) => reject(new RealmFault(errorText(err))));
35
- worker.postMessage({ code: req.code, input: req.input });
36
- });
37
- }
38
- finally {
39
- clearTimeout(timer);
40
- // Awaited, and the whole point: on the timeout path a thread is still burning a core, and
41
- // resolving before it is gone would report an evaluation the machine is still running.
42
- await worker.terminate();
43
- }
44
- }
45
- function timedOut(ms) {
46
- return {
47
- ok: false,
48
- failure: { kind: "timeout", name: "TimeoutError", message: `script exceeded its ${ms}ms budget` },
49
- };
50
- }
51
- function exited(code) {
52
- return {
53
- ok: false,
54
- failure: {
55
- kind: "exited",
56
- name: "RealmExited",
57
- message: `the script ended its own realm with code ${code} instead of returning`,
58
- },
59
- };
60
- }
61
- function errorText(e) {
62
- const message = e?.message;
63
- return typeof message === "string" && message !== "" ? message : "the evaluation realm failed to start";
64
- }
package/dist/import.js DELETED
@@ -1,352 +0,0 @@
1
- #!/usr/bin/env node
2
- // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, non-zero exit
3
- // with the diagnostic on stderr. genctl never parses TypeScript and this never parses YAML
4
- // — the manifest is the whole contract. See specs/source-resolution.md.
5
- //
6
- // Two modes, one binary: "types" writes the declarations an editor needs and returns no
7
- // code; "build" typechecks and bundles. A separate types hook would mean a second `tsc`
8
- // over the same project.
9
- import { spawn } from "node:child_process";
10
- import { access, mkdir, writeFile } from "node:fs/promises";
11
- import { builtinModules } from "node:module";
12
- import { dirname, join, relative } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import commonjs from "@rollup/plugin-commonjs";
15
- import json from "@rollup/plugin-json";
16
- import { nodeResolve } from "@rollup/plugin-node-resolve";
17
- import { rollup } from "rollup";
18
- import ts from "typescript";
19
- function die(message) {
20
- console.error(message);
21
- process.exit(1);
22
- }
23
- async function exists(path) {
24
- try {
25
- await access(path);
26
- return true;
27
- }
28
- catch {
29
- return false;
30
- }
31
- }
32
- /** Creates the parent directory, which `.genroc-cache/` relies on: nothing else makes it. */
33
- async function write(path, content) {
34
- await mkdir(dirname(path), { recursive: true });
35
- await writeFile(path, content);
36
- }
37
- // ── JSON Schema → TypeScript ───────────────────────────────────────────────────
38
- /** Only genroc's keyword set is handled; anything else its strict decoder would have
39
- * refused before this ran (internal/schema, allowedKeywords). */
40
- function tsType(s, used) {
41
- if (s === undefined || s === null)
42
- return "unknown";
43
- if (typeof s.$ref === "string") {
44
- const name = s.$ref.replace(/^#\/\$defs\//, "");
45
- used.add(name);
46
- return identifier(name);
47
- }
48
- if (Array.isArray(s.enum)) {
49
- return s.enum.map((v) => JSON.stringify(v)).join(" | ") || "never";
50
- }
51
- if (Array.isArray(s.anyOf))
52
- return union(s.anyOf.map((a) => tsType(a, used)));
53
- if (Array.isArray(s.oneOf))
54
- return union(s.oneOf.map((a) => tsType(a, used)));
55
- if (Array.isArray(s.allOf)) {
56
- return s.allOf.map((a) => tsType(a, used)).join(" & ") || "unknown";
57
- }
58
- const types = s.type === undefined ? [] : Array.isArray(s.type) ? s.type : [s.type];
59
- if (types.length === 0) {
60
- // The top type: `{}` means unknown, not "an empty object". specs/unknown-type.md.
61
- return s.properties ? objectType(s, used) : "unknown";
62
- }
63
- return union(types.map((t) => scalarType(t, s, used)));
64
- }
65
- function scalarType(t, s, used) {
66
- switch (t) {
67
- case "object":
68
- return objectType(s, used);
69
- case "array":
70
- return s.items ? `Array<${tsType(s.items, used)}>` : "unknown[]";
71
- case "string":
72
- return "string";
73
- case "number":
74
- case "integer":
75
- return "number";
76
- case "boolean":
77
- return "boolean";
78
- case "null":
79
- return "null";
80
- default:
81
- return "unknown";
82
- }
83
- }
84
- function objectType(s, used) {
85
- const props = s.properties ?? {};
86
- const required = new Set(s.required ?? []);
87
- const lines = [];
88
- for (const [key, sub] of Object.entries(props)) {
89
- const doc = typeof sub.description === "string" ? ` /** ${sub.description} */\n` : "";
90
- lines.push(`${doc} ${propKey(key)}${required.has(key) ? "" : "?"}: ${tsType(sub, used)};`);
91
- }
92
- if (s.additionalProperties && typeof s.additionalProperties === "object") {
93
- lines.push(` [key: string]: ${tsType(s.additionalProperties, used)};`);
94
- }
95
- if (lines.length === 0)
96
- return "Record<string, unknown>";
97
- return `{\n${lines.join("\n")}\n}`;
98
- }
99
- function union(parts) {
100
- const seen = [...new Set(parts)];
101
- return seen.length === 0 ? "unknown" : seen.join(" | ");
102
- }
103
- const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
104
- const propKey = (k) => (IDENT.test(k) ? k : JSON.stringify(k));
105
- const identifier = (n) => (IDENT.test(n) ? n : `Def_${n.replace(/[^A-Za-z0-9_$]/g, "_")}`);
106
- function deref(s, defs) {
107
- let cur = s;
108
- for (let i = 0; cur && typeof cur.$ref === "string" && i < 16; i++) {
109
- cur = defs[cur.$ref.replace(/^#\/\$defs\//, "")];
110
- }
111
- return cur;
112
- }
113
- /** The manifest's `input` is the type of the whole ACTION input — for /eval that is
114
- * `{code, input, timeout_ms, …}`, and only its `input` field is bound as the script's
115
- * parameter. genroc cannot know that; this file owns the evaluator's wire contract, so
116
- * the navigation belongs here rather than in genctl. */
117
- function scriptInput(site, defs) {
118
- const action = deref(site.input, defs);
119
- const bound = action?.properties?.input;
120
- return bound ?? site.input;
121
- }
122
- /** Emits one named type per reachable $def rather than inlining: a task output may
123
- * reference itself (specs/recursive-type-inference.md) and inlining would not terminate. */
124
- function declarations(site, defs) {
125
- const used = new Set();
126
- const input = tsType(scriptInput(site, defs), used);
127
- const output = tsType(site.output, used);
128
- const emitted = [];
129
- const done = new Set();
130
- while (true) {
131
- const next = [...used].find((n) => !done.has(n));
132
- if (next === undefined)
133
- break;
134
- done.add(next);
135
- const body = tsType(defs[next], used);
136
- emitted.push(`export type ${identifier(next)} = ${body};`);
137
- }
138
- return [
139
- "// Generated by genroc. Do not edit - regenerate with `genctl types`.",
140
- `// ${site.process}${site.task ? ` / ${site.task}` : ""} (${site.pointer})`,
141
- "",
142
- ...emitted,
143
- emitted.length ? "" : "",
144
- `export type Input = ${input};`,
145
- "",
146
- `export type Output = ${output};`,
147
- "",
148
- ].join("\n");
149
- }
150
- /** Keyed by the script's PATH, not the task id: keyed by task, renaming a task would break
151
- * the author's `import type` line with the error landing nowhere near the rename. */
152
- function typesPathFor(scriptPath) {
153
- return scriptPath.replace(/\.[^.\/]+$/, "") + ".genroc.d.ts";
154
- }
155
- // ── typecheck ──────────────────────────────────────────────────────────────────
156
- /** The nearest tsconfig above the script — the one the author's editor already reads. Two
157
- * different configs mean a red editor over a clean apply, or the reverse. The walk stops at
158
- * the project root: above it is not this project. */
159
- async function nearestTsconfig(from, root) {
160
- for (let dir = from;; dir = dirname(dir)) {
161
- const candidate = join(dir, "tsconfig.json");
162
- if (await exists(candidate))
163
- return candidate;
164
- if (dir === root || dirname(dir) === dir)
165
- return null;
166
- }
167
- }
168
- async function typecheck(root, sites) {
169
- // NOT `.genroc`: that is the project config FILE, and a directory of the same name cannot
170
- // coexist with it. The suffix is what keeps the scratch area out of its way.
171
- const dir = join(root, ".genroc-cache");
172
- await write(join(dir, ".gitignore"), "*\n");
173
- // One tsc per distinct base config: `extends` takes a single base, so merging two would
174
- // check each script under the other author's options.
175
- const groups = new Map();
176
- for (const site of sites) {
177
- const base = (await nearestTsconfig(dirname(site.path), root)) ?? "";
178
- const group = groups.get(base);
179
- if (group)
180
- group.push(site);
181
- else
182
- groups.set(base, [site]);
183
- }
184
- let n = 0;
185
- for (const [base, group] of groups) {
186
- const config = {
187
- ...(base ? { extends: relative(dir, base) } : {}),
188
- compilerOptions: {
189
- noEmit: true,
190
- strict: true,
191
- skipLibCheck: true,
192
- moduleDetection: "force",
193
- module: "preserve",
194
- target: "esnext",
195
- // `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
196
- // a worker thread has no document, whatever an author's config claims.
197
- lib: ["esnext", "webworker"],
198
- // `types` is the author's, and it is how a script opts into the node globals —
199
- // the worker realm has them, so refusing the declarations would only lie. With no
200
- // base config there is nothing to opt in with, so the default stays none.
201
- ...(base ? {} : { types: [] }),
202
- },
203
- files: group.flatMap((s) => [relative(dir, s.path), relative(dir, typesPathFor(s.path))]),
204
- // `files` overrides the base's, but a base `include` survives beside it and would
205
- // drag the author's whole tree in, to be checked under the worker lib.
206
- include: [],
207
- };
208
- const configPath = join(dir, groups.size === 1 ? "tsconfig.json" : `tsconfig.${n++}.json`);
209
- await write(configPath, JSON.stringify(config, null, 2));
210
- await runTsc(root, configPath);
211
- }
212
- }
213
- async function runTsc(root, configPath) {
214
- const tsc = fileURLToPath(import.meta.resolve("typescript/bin/tsc"));
215
- const proc = spawn(process.execPath, [tsc, "--noEmit", "-p", configPath], {
216
- cwd: root,
217
- stdio: ["ignore", "pipe", "pipe"],
218
- });
219
- let out = "";
220
- let err = "";
221
- proc.stdout.on("data", (c) => (out += c));
222
- proc.stderr.on("data", (c) => (err += c));
223
- const code = await new Promise((resolve, reject) => {
224
- proc.on("error", reject);
225
- proc.on("close", (c) => resolve(c ?? 1));
226
- });
227
- if (code !== 0) {
228
- // tsc reports on stdout; the exit code IS the type check, so this is the diagnostic
229
- // genctl surfaces and the reason a failed import never produces a string.
230
- die([out, err].filter(Boolean).join("\n").trimEnd());
231
- }
232
- }
233
- // ── bundle ─────────────────────────────────────────────────────────────────────
234
- /** Transpiles only. The typecheck above already ran over the author's OWN tsconfig, and a
235
- * second opinion from a config they do not control could fail a build they cannot fix. */
236
- const transpile = {
237
- name: "genroc-transpile",
238
- transform(code, id) {
239
- if (!id.endsWith(".ts") && !id.endsWith(".tsx"))
240
- return null;
241
- const out = ts.transpileModule(code, {
242
- fileName: id,
243
- compilerOptions: {
244
- target: ts.ScriptTarget.ESNext,
245
- module: ts.ModuleKind.ESNext,
246
- verbatimModuleSyntax: false,
247
- jsx: id.endsWith(".tsx") ? ts.JsxEmit.ReactJSX : undefined,
248
- },
249
- });
250
- return { code: out.outputText, map: out.sourceMapText ?? null };
251
- },
252
- };
253
- const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
254
- /** Resolves imports through TYPESCRIPT, using the same config the typecheck ran under, so a
255
- * `paths` alias that compiles also bundles. Reimplementing `paths` here would be a second
256
- * resolver to keep in agreement with tsc; this one cannot disagree.
257
- * A package resolving to a `.d.ts` is declined — that is a type, not the implementation —
258
- * which leaves node_modules to nodeResolve. */
259
- function tsResolve(configPath) {
260
- let options = {};
261
- if (configPath) {
262
- const read = ts.readConfigFile(configPath, ts.sys.readFile);
263
- options = ts.parseJsonConfigFileContent(read.config ?? {}, ts.sys, dirname(configPath)).options;
264
- }
265
- return {
266
- name: "genroc-ts-resolve",
267
- resolveId(source, importer) {
268
- if (!importer || BUILTIN.has(source))
269
- return null;
270
- const { resolvedModule } = ts.resolveModuleName(source, importer, options, ts.sys);
271
- if (!resolvedModule || resolvedModule.isExternalLibraryImport)
272
- return null;
273
- return resolvedModule.resolvedFileName.endsWith(".d.ts") ? null : resolvedModule.resolvedFileName;
274
- },
275
- };
276
- }
277
- /** Bundles to CJS and wraps it as an async function BODY, which is what /eval compiles.
278
- * The runtime stays unchanged: bundling is entirely the importer's job, and the string it
279
- * produces is self-contained, so a definition version pins its code forever. */
280
- async function bundle(site, root) {
281
- // Builtins are EXTERNALISED as `require` calls that worker.ts satisfies. Anything else
282
- // unresolved is a REFUSAL, not an external: rollup's default is to leave it as a require
283
- // of a module that will not be there, which bundles clean and fails at runtime.
284
- const built = await rollup({
285
- input: site.path,
286
- external: (id) => BUILTIN.has(id),
287
- plugins: [
288
- tsResolve(await nearestTsconfig(dirname(site.path), root)),
289
- nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
290
- commonjs(),
291
- // A `.json` import is a data file inlined at build time, which the previous bundler
292
- // did natively; without it rollup hands the JSON to the JS parser.
293
- json(),
294
- transpile,
295
- ],
296
- onwarn(warning) {
297
- if (warning.code === "UNRESOLVED_IMPORT") {
298
- die(`${site.path}: cannot resolve ${warning.exporter ?? "an import"} — is it installed?`);
299
- }
300
- },
301
- }).catch((e) => die(`${site.path}: ${e instanceof Error ? e.message : String(e)}`));
302
- const { output } = await built.generate({ format: "cjs", exports: "auto", inlineDynamicImports: true });
303
- await built.close();
304
- const cjs = output[0].code;
305
- return [
306
- "var module = { exports: {} }, exports = module.exports;",
307
- cjs,
308
- "var __genroc_main = module.exports.default ?? module.exports;",
309
- 'if (typeof __genroc_main !== "function") {',
310
- ` throw new Error(${JSON.stringify(`${site.path} has no default export function`)});`,
311
- "}",
312
- "return await __genroc_main(input);",
313
- ].join("\n");
314
- }
315
- // ── main ───────────────────────────────────────────────────────────────────────
316
- const stdin = await new Promise((resolve, reject) => {
317
- let raw = "";
318
- process.stdin.setEncoding("utf8");
319
- process.stdin.on("data", (c) => (raw += c));
320
- process.stdin.on("end", () => resolve(raw));
321
- process.stdin.on("error", reject);
322
- });
323
- const manifest = JSON.parse(stdin);
324
- if (!manifest || !Array.isArray(manifest.sites))
325
- die("stdin is not a genroc resolver manifest");
326
- // One script at two sites with different input types is a refusal, not a union: the union
327
- // is sound and would typecheck a body that is wrong at one of the sites.
328
- const byPath = new Map();
329
- for (const site of manifest.sites) {
330
- const seen = byPath.get(site.path);
331
- const defsOf = (x) => (manifest.schemas[x.process]?.$defs ?? {});
332
- if (seen &&
333
- JSON.stringify(scriptInput(seen, defsOf(seen))) !==
334
- JSON.stringify(scriptInput(site, defsOf(site)))) {
335
- die(`${site.path} is imported at ${seen.pointer} and ${site.pointer} with different input types.\n` +
336
- "Split it into two scripts, or make the two call sites pass the same shape.");
337
- }
338
- byPath.set(site.path, site);
339
- }
340
- for (const site of byPath.values()) {
341
- const defs = (manifest.schemas[site.process]?.$defs ?? {});
342
- await write(typesPathFor(site.path), declarations(site, defs));
343
- }
344
- if (manifest.mode === "types") {
345
- process.exit(0);
346
- }
347
- await typecheck(manifest.root, [...byPath.values()]);
348
- const code = [];
349
- for (const site of manifest.sites) {
350
- code.push(await bundle(site, manifest.root));
351
- }
352
- process.stdout.write(JSON.stringify({ code }));
package/dist/realm.js DELETED
@@ -1,122 +0,0 @@
1
- // The evaluation realm. One Worker per execution: a fresh global object per script, and a
2
- // thread the host can kill mid-loop — the only thing that bounds a synchronous busy loop.
3
- // eval.ts owns the budget and does the killing; nothing here knows about time.
4
- //
5
- // Everything that touches the script's VALUE lives on this side of the boundary — compiling,
6
- // classifying, serialising — because this is the only realm the value exists in.
7
- import { createRequire } from "node:module";
8
- import { parentPort } from "node:worker_threads";
9
- const AsyncFunction = async function () { }.constructor;
10
- const STRICT = '"use strict";\n';
11
- // Bundled `node:*` imports survive as `require` calls — the importer externalises builtins and
12
- // inlines everything else — and a function built by the AsyncFunction constructor has no
13
- // `require` in scope. Passing one in is what makes an import of a builtin work at runtime rather than at
14
- // typecheck only. Resolution is anchored here, which is right: only builtins reach it.
15
- const scriptRequire = createRequire(import.meta.url);
16
- const STACK_BYTES = 2_048;
17
- /**
18
- * Line offset the AsyncFunction preamble adds, measured rather than assumed: the generated
19
- * wrapper's shape is engine-specific, and a hardcoded number silently misreports every
20
- * script's error location the day it changes.
21
- */
22
- const lineOffset = (async () => {
23
- // Same parameter list as a real compile: the preamble is what is being measured.
24
- const probe = new AsyncFunction("input", "require", STRICT + "throw new Error('probe');");
25
- try {
26
- await probe();
27
- return 0;
28
- }
29
- catch (err) {
30
- return reportedLine(err) - 1;
31
- }
32
- })();
33
- // V8 marks a frame compiled by the AsyncFunction constructor with the site that CALLED the
34
- // constructor, then the script's OWN position:
35
- // at inner (eval at run (file:///…/worker.ts:107:10), <anonymous>:6:9)
36
- // `eval at` is therefore what separates script frames from runner plumbing — matched without
37
- // the function name, which is whatever encloses the `new AsyncFunction` below. The LAST such
38
- // frame is the body's top level: frames interleave, since a script can throw inside a native
39
- // callback.
40
- const SCRIPT_FRAME = /\(eval at /;
41
- // The trailing `<anonymous>:LINE:COL` — the script's position, after the host file's own.
42
- const POSITION = /<anonymous>:(\d+):(\d+)\)?\s*$/;
43
- // ` at name (` — absent on the top-level frame, which V8 names `eval`.
44
- const FRAME_NAME = /^\s*at\s+(?:async\s+)?([^\s(]+)\s*\(/;
45
- /** Line number of the throw as the engine reported it, or 1 if the stack is unreadable. */
46
- function reportedLine(err) {
47
- const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : "";
48
- const frame = stack.split("\n").find((l) => SCRIPT_FRAME.test(l)) ?? "";
49
- const m = frame.match(POSITION);
50
- return m ? Number(m[1]) : 1;
51
- }
52
- /** Renumbers each script frame to the line the AUTHOR wrote and drops the runner's own.
53
- * Rewriting the whole location is also what keeps the runner's path out of a script's
54
- * stack — V8 puts it inside every compiled frame. */
55
- function scriptStack(err, offset) {
56
- if (!(err instanceof Error) || typeof err.stack !== "string")
57
- return undefined;
58
- const lines = err.stack.split("\n");
59
- let boundary = -1;
60
- for (let i = 0; i < lines.length; i++)
61
- if (SCRIPT_FRAME.test(lines[i]))
62
- boundary = i;
63
- const frames = (boundary >= 0 ? lines.slice(0, boundary + 1) : lines.slice(0, 1))
64
- .map((line) => {
65
- const pos = line.match(POSITION);
66
- if (!pos)
67
- return line; // the `Error: message` header and native frames, kept as-is
68
- const name = line.match(FRAME_NAME)?.[1];
69
- const at = name && name !== "eval" && name !== "anonymous" ? `at ${name} ` : "at ";
70
- const indent = line.match(/^\s*/)[0];
71
- return `${indent}${at}(script:${Math.max(1, Number(pos[1]) - offset)}:${pos[2]})`;
72
- })
73
- .join("\n");
74
- return frames.length > STACK_BYTES ? frames.slice(0, STACK_BYTES) : frames;
75
- }
76
- function describe(err, kind, offset) {
77
- if (err instanceof Error) {
78
- return { kind, name: err.name, message: err.message, stack: scriptStack(err, offset) };
79
- }
80
- // A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
81
- return { kind, name: "Thrown", message: safeText(err) };
82
- }
83
- function safeText(v) {
84
- try {
85
- return typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
86
- }
87
- catch {
88
- return String(v);
89
- }
90
- }
91
- async function run(req) {
92
- const offset = await lineOffset;
93
- let fn;
94
- try {
95
- // No compile cache: the realm is discarded after this execution, so a cache in it could
96
- // never be hit. Repeated compilation is the price of the fresh global object.
97
- fn = new AsyncFunction("input", "require", STRICT + req.code);
98
- }
99
- catch (err) {
100
- return { ok: false, failure: describe(err, "compile_error", offset) };
101
- }
102
- let value;
103
- try {
104
- value = await fn(req.input, scriptRequire);
105
- }
106
- catch (err) {
107
- return { ok: false, failure: describe(err, "threw", offset) };
108
- }
109
- try {
110
- // undefined stringifies to undefined, not "undefined"; an empty body is how genroc
111
- // spells null, which is the right reading of a script that returned nothing.
112
- return { ok: true, body: value === undefined ? "" : JSON.stringify(value) ?? "" };
113
- }
114
- catch (err) {
115
- return { ok: false, failure: describe(err, "nonserializable", offset) };
116
- }
117
- }
118
- // Non-null because this module only ever runs as a worker entry point; a null port here
119
- // would mean eval.ts loaded it as a plain module, which nothing does.
120
- parentPort.on("message", async (req) => {
121
- parentPort.postMessage(await run(req));
122
- });
package/dist/worker.js DELETED
@@ -1,280 +0,0 @@
1
- #!/usr/bin/env node
2
- // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
3
- // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
4
- // nothing about the queue, which is what keeps the containment strategy swappable.
5
- //
6
- // See README.md for the contract, and specs/external-task-queue.md for the queue itself.
7
- import { evaluate } from "./eval.js";
8
- const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
9
- const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
10
- // The credential, when the server runs with --auth token. A worker needs exactly the `worker`
11
- // permission — the four queue verbs plus GET /api/objects — so mint it scoped rather than
12
- // handing a worker an admin token: this is the credential most likely to sit on a machine you
13
- // trust least. specs/api-auth.md §5.
14
- //
15
- // Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
16
- // credentials ("Request cannot be constructed from a URL that includes credentials"), so the
17
- // basic-auth-in-the-URL trick that works for genctl is not available here.
18
- const TOKEN = process.env.GENROC_TOKEN ?? "";
19
- const authHeaders = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
20
- // Concurrency is the worker's to set, and that is the point of pulling: under the old fetch
21
- // shape genroc decided how many scripts ran at once (--max-concurrent, default 200) and the
22
- // evaluator accepted every one of them. Here it claims what it can run and no more, so a
23
- // backlog is a queue rather than 200 threads fighting over a core.
24
- const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
25
- const POLL_MS = Number(process.env.POLL_MS ?? 250);
26
- // The visibility timeout. Short, and renewed while work is in flight: a worker that dies
27
- // should return its task quickly rather than holding it for the whole budget.
28
- const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
29
- const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
30
- const PROCESS_FILTER = process.env.PROCESS ?? "";
31
- const TASK_FILTER = process.env.TASK ?? "";
32
- // Values too large to ship inline are listed rather than carried, and a bundle is exactly that:
33
- // one object shared by every instance of a definition version, fetched once instead of copied
34
- // into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
35
- const objectCache = new Map();
36
- async function fetchObject(ref) {
37
- const cached = objectCache.get(ref);
38
- if (cached !== undefined)
39
- return cached;
40
- // Left to throw on purpose: this runs while a task is IN FLIGHT, and a task whose input
41
- // cannot be fetched must fail rather than silently run against a missing value. The caller
42
- // releases the claim, so the task returns to the queue.
43
- const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
44
- if (!res.ok)
45
- throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
46
- const { data } = (await res.json());
47
- let value;
48
- try {
49
- value = JSON.parse(data);
50
- }
51
- catch {
52
- value = data;
53
- }
54
- objectCache.set(ref, value);
55
- return value;
56
- }
57
- /** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
58
- * needs no parser: the whole reason they are not JSON Pointer strings. */
59
- async function resolveObjects(job) {
60
- let input = job.input;
61
- for (const e of job.objects ?? []) {
62
- const value = await fetchObject(e.ref);
63
- if (e.path.length === 0) {
64
- input = value;
65
- continue;
66
- }
67
- // The path is rooted at the entry and starts with "input", which is the value being rebuilt.
68
- const rest = e.path[0] === "input" ? e.path.slice(1) : e.path;
69
- if (rest.length === 0) {
70
- input = value;
71
- continue;
72
- }
73
- let cur = input;
74
- for (let i = 0; i < rest.length - 1; i++)
75
- cur = cur?.[rest[i]];
76
- if (cur)
77
- cur[rest[rest.length - 1]] = value;
78
- }
79
- return input;
80
- }
81
- async function call(path, body) {
82
- // A network error is a REPLY, not a throw. A worker outlives the server it polls — a
83
- // restart, a rolling deploy, a container coming up before genroc is listening — and an
84
- // unhandled rejection here kills it for a condition the next poll would clear. Status 0
85
- // says "never reached the server", which is distinct from anything genroc answers.
86
- let res;
87
- try {
88
- res = await fetch(SERVER + path, {
89
- method: "POST",
90
- headers: { "content-type": "application/json", ...authHeaders },
91
- body: JSON.stringify(body),
92
- });
93
- }
94
- catch (err) {
95
- return { ok: false, status: 0, data: { error: `${SERVER} unreachable: ${err.message}` } };
96
- }
97
- const text = await res.text();
98
- let data = null;
99
- try {
100
- data = text ? JSON.parse(text) : null;
101
- }
102
- catch {
103
- data = { error: text };
104
- }
105
- return { ok: res.ok, status: res.status, data };
106
- }
107
- /** The task input IS an EvalRequest: `code` required, `input` and `timeout_ms` optional. A task
108
- * whose input is not that shape is the definition's fault, not the script's, and is reported
109
- * as a compile_error — the nearest permanent kind, since no retry can fix the definition. */
110
- function asEvalRequest(input) {
111
- if (typeof input !== "object" || input === null)
112
- return "the task input is not an object";
113
- const r = input;
114
- if (typeof r.code !== "string")
115
- return "the task input has no `code` string";
116
- return {
117
- code: r.code,
118
- input: r.input,
119
- timeout_ms: typeof r.timeout_ms === "number" ? r.timeout_ms : undefined,
120
- };
121
- }
122
- const inFlight = new Map();
123
- let running = true;
124
- // Whether the last claim reached genroc. A worker polls several times a second, so an
125
- // unreachable server would otherwise emit a line per poll — thousands during a restart, which
126
- // buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
127
- // back. Silence in between is the report that nothing changed.
128
- let serverReachable = true;
129
- async function claim(n) {
130
- const { ok, status, data } = await call("/api/external-tasks/claim", {
131
- worker_id: WORKER_ID,
132
- limit: n,
133
- lease_ms: LEASE_MS,
134
- ...(PROCESS_FILTER ? { process: PROCESS_FILTER } : {}),
135
- ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
136
- });
137
- if (!ok) {
138
- // A credential problem is not transient, and polling through it looks like a healthy
139
- // worker that never picks anything up — the worst shape for an operator to debug. Exit
140
- // instead, so a supervisor restarts it and the failure is visible where it happened.
141
- if (status === 401 || status === 403) {
142
- console.error(`claim rejected (${status}): ${JSON.stringify(data)}\n` +
143
- `The server requires authentication. Set GENROC_TOKEN to a token with the 'worker' ` +
144
- `permission — mint one with: genctl token create --perms worker --label evaluator -q`);
145
- process.exit(1);
146
- }
147
- // status 0 is "never reached the server" (see call): a restart, a rolling deploy, a
148
- // network blip. Not an error to act on — the next poll clears it — so it is reported once
149
- // and then waited out.
150
- if (status === 0) {
151
- if (serverReachable) {
152
- serverReachable = false;
153
- console.error(`genroc at ${SERVER} is unreachable — ${data?.error ?? ""}. ` +
154
- `Still polling every ${POLL_MS}ms; work resumes when it comes back.`);
155
- }
156
- return [];
157
- }
158
- console.error(`claim failed: ${JSON.stringify(data)}`);
159
- return [];
160
- }
161
- if (!serverReachable) {
162
- serverReachable = true;
163
- console.error(`genroc at ${SERVER} is reachable again — resuming.`);
164
- }
165
- return (data?.items ?? []);
166
- }
167
- async function release(token) {
168
- const { ok, data } = await call("/api/external-tasks/release", { token });
169
- if (!ok)
170
- console.error(`release failed: ${JSON.stringify(data)}`);
171
- }
172
- /** answer submits the outcome. A refusal is NOT retried with a different one: the definition
173
- * declared a contract this worker does not satisfy (an undeclared code, a payload that does
174
- * not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
175
- * the task returns to the queue and an operator sees it waiting rather than silently gone. */
176
- async function answer(token, outcome) {
177
- const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
178
- if (ok)
179
- return;
180
- console.error(`genroc refused the outcome for ${token}: ${JSON.stringify(data)}`);
181
- await release(token);
182
- }
183
- async function run(job) {
184
- let resolved;
185
- try {
186
- resolved = await resolveObjects(job);
187
- }
188
- catch (err) {
189
- // The values are there or they are not; this is the runner failing to read them, not the
190
- // script failing, so hand the task back for someone else rather than reporting an outcome.
191
- console.error(`resolving objects for ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
192
- await release(job.token);
193
- return;
194
- }
195
- const req = asEvalRequest(resolved);
196
- if (typeof req === "string") {
197
- await answer(job.token, {
198
- error: { code: "compile_error", message: req, data: { name: "BadTaskInput" } },
199
- });
200
- return;
201
- }
202
- let result;
203
- try {
204
- result = await evaluate(req);
205
- }
206
- catch (err) {
207
- // The RUNNER faulted, not the script — the one class where a retry can help. There is no
208
- // error code for it on purpose: releasing the claim is how a queue spells "retryable", and
209
- // it puts the task in front of a different worker instead of burning the definition's
210
- // on_error budget on this one's bad day.
211
- console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
212
- await release(job.token);
213
- return;
214
- }
215
- if (result.ok) {
216
- // `body` is JSON text produced inside the realm; an empty body is a script that returned
217
- // nothing, which genroc reads as null.
218
- await answer(job.token, { result: result.body === "" ? null : JSON.parse(result.body) });
219
- return;
220
- }
221
- const f = result.failure;
222
- await answer(job.token, {
223
- error: {
224
- // The failure KIND is the code, so an on_error rule branches on what went wrong without
225
- // reading a payload. Every kind is permanent; see eval.ts.
226
- code: f.kind,
227
- message: f.message,
228
- data: { name: f.name, ...(f.stack ? { stack: f.stack } : {}) },
229
- },
230
- });
231
- }
232
- async function renewLoop() {
233
- while (running) {
234
- await new Promise((r) => setTimeout(r, RENEW_MS));
235
- const tokens = [...inFlight.keys()];
236
- if (!tokens.length)
237
- continue;
238
- const { ok, data } = await call("/api/external-tasks/renew", {
239
- worker_id: WORKER_ID,
240
- tokens,
241
- lease_ms: LEASE_MS,
242
- });
243
- // A short count means a claim lapsed and was taken over. Nothing to do about it — the work
244
- // continues and its answer will be refused — but say so, because it is the signal that
245
- // LEASE_MS is too short for what these scripts actually take.
246
- if (ok && data?.renewed < tokens.length) {
247
- console.error(`renewed ${data.renewed}/${tokens.length} claims; a lease lapsed under load`);
248
- }
249
- }
250
- }
251
- async function pollLoop() {
252
- while (running) {
253
- const free = CONCURRENCY - inFlight.size;
254
- const jobs = free > 0 ? await claim(free) : [];
255
- for (const job of jobs) {
256
- inFlight.set(job.token, job);
257
- void run(job).finally(() => inFlight.delete(job.token));
258
- }
259
- // Only idle when there was nothing to take: a full queue should be drained at the speed the
260
- // realms allow, not at the poll interval.
261
- if (jobs.length === 0)
262
- await new Promise((r) => setTimeout(r, POLL_MS));
263
- }
264
- }
265
- async function shutdown(signal) {
266
- if (!running)
267
- return;
268
- running = false;
269
- const tokens = [...inFlight.keys()];
270
- console.log(`${signal}: releasing ${tokens.length} claim(s)`);
271
- // Hand work back rather than letting it sit out its lease. The evaluations still running are
272
- // abandoned, which is exactly what the release says: nobody answered.
273
- await Promise.all(tokens.map(release));
274
- process.exit(0);
275
- }
276
- process.on("SIGINT", () => void shutdown("SIGINT"));
277
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
278
- console.log(`evaluator worker ${WORKER_ID} polling ${SERVER} (concurrency=${CONCURRENCY}, lease=${LEASE_MS}ms)`);
279
- void renewLoop();
280
- void pollLoop();