@foldrun/core 0.2.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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,404 @@
1
+ // Script runtimes and their dependencies.
2
+ //
3
+ // An agent (or its workspace) declares what its scripts need:
4
+ //
5
+ // runtime:
6
+ // python: "3.12" # optional pin; the host interpreter is used
7
+ // packages: [pandas, requests]
8
+ // node: true
9
+ // npm: [lodash]
10
+ //
11
+ // The platform builds that environment once, keyed by a fingerprint of the
12
+ // declaration, and reuses it for every later run. Python gets a venv; Node
13
+ // gets an npm prefix exposed through NODE_PATH. Nothing is installed into
14
+ // the host's global site-packages, so two agents can want different versions
15
+ // of the same library without colliding.
16
+ //
17
+ // This is dependency *isolation*, not security isolation — scripts still run
18
+ // as the server user. Real isolation needs a container per run; see
19
+ // SPEC.md → Execution environments.
20
+
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { dataRoot } from "./paths.ts";
25
+ import crypto from "node:crypto";
26
+ import { spawnSync } from "node:child_process";
27
+
28
+ export interface RuntimeSpec {
29
+ python?: string | boolean;
30
+ packages: string[]; // pip
31
+ node?: string | boolean;
32
+ npm: string[];
33
+ /** Declared entries that are not valid requirements. Kept so the run can
34
+ * SAY they were dropped: silently discarding `pandas>=2` and then failing
35
+ * the script with "no module named pandas" is the least debuggable outcome
36
+ * available. Never part of the fingerprint — it names nothing installed. */
37
+ rejected?: string[];
38
+ }
39
+
40
+ /**
41
+ * What may be handed to `pip install`, and what may be handed to `npm install`.
42
+ *
43
+ * These are argument-injection guards before they are validators. Both
44
+ * installers take options in the same argv as their operands, and the previous
45
+ * pattern (`[\w.@/-]+`) admitted a leading dash — so `packages: ["--index-url",
46
+ * "http://elsewhere/simple", "pandas"]` in an agent's frontmatter was a valid
47
+ * declaration that quietly moved the whole install to another index. Anchored,
48
+ * and a requirement must begin with a letter or a digit.
49
+ *
50
+ * The version half is PEP 440 shaped: a comparator and a version, optionally
51
+ * several comma-separated. The old pattern allowed one comparator CHARACTER,
52
+ * which meant `pandas>2` passed while `pandas>=2` and `pandas==2.1.4` — the
53
+ * two forms anyone actually writes — were dropped without a word.
54
+ */
55
+ const PIP_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
56
+ const PIP_SPECIFIER = String.raw`(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
57
+ const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
58
+ /** npm's own shape: an optional @scope, then a name, then an optional @range. */
59
+ const NPM_REQUIREMENT =
60
+ /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
61
+
62
+ export function parseRuntime(raw: unknown): RuntimeSpec | null {
63
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
64
+ const e = raw as Record<string, unknown>;
65
+ const rejected: string[] = [];
66
+ const list = (v: unknown, ok: RegExp) =>
67
+ Array.isArray(v)
68
+ ? v.map(String).filter((entry) => {
69
+ const s = entry.trim();
70
+ if (ok.test(s)) return true;
71
+ if (s) rejected.push(s);
72
+ return false;
73
+ })
74
+ : [];
75
+ const spec: RuntimeSpec = {
76
+ python: typeof e.python === "string" || typeof e.python === "boolean" ? e.python : undefined,
77
+ packages: list(e.packages ?? e.pip, PIP_REQUIREMENT),
78
+ node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
79
+ npm: list(e.npm, NPM_REQUIREMENT),
80
+ };
81
+ if (rejected.length) spec.rejected = rejected;
82
+ const wantsSomething =
83
+ spec.python !== undefined || spec.node !== undefined || spec.packages.length || spec.npm.length;
84
+ return wantsSomething ? spec : null;
85
+ }
86
+
87
+ /**
88
+ * Several declarations, one environment.
89
+ *
90
+ * A tool declares what ITS program needs — `runtime: { packages: [requests] }`
91
+ * in tool.md — because the tool is the unit of code and its dependencies
92
+ * belong beside it, not in every agent that grants it. An agent that grants
93
+ * three Python tools gets one venv holding the union. Version pins are kept
94
+ * verbatim; if two tools pin the same package differently, pip is the one to
95
+ * say so, loudly, at build time — better than one of them silently winning.
96
+ */
97
+ export function mergeRuntimes(...specs: (RuntimeSpec | null | undefined)[]): RuntimeSpec | null {
98
+ const present = specs.filter((s): s is RuntimeSpec => Boolean(s));
99
+ if (present.length === 0) return null;
100
+ if (present.length === 1) return present[0];
101
+ const pick = (key: "python" | "node") => {
102
+ // A version pin beats a bare `true`; the first pin wins.
103
+ const pinned = present.map((s) => s[key]).find((v) => typeof v === "string");
104
+ if (pinned !== undefined) return pinned;
105
+ return present.some((s) => s[key] === true) ? true : undefined;
106
+ };
107
+ const uniq = (xs: string[]) => [...new Set(xs)];
108
+ const merged: RuntimeSpec = {
109
+ python: pick("python"),
110
+ packages: uniq(present.flatMap((s) => s.packages)),
111
+ node: pick("node"),
112
+ npm: uniq(present.flatMap((s) => s.npm)),
113
+ };
114
+ const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
115
+ if (rejected.length) merged.rejected = rejected;
116
+ return merged;
117
+ }
118
+
119
+ export function fingerprint(spec: RuntimeSpec): string {
120
+ const canonical = JSON.stringify({
121
+ python: spec.python ?? null,
122
+ packages: [...spec.packages].sort(),
123
+ node: spec.node ?? null,
124
+ npm: [...spec.npm].sort(),
125
+ });
126
+ return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
127
+ }
128
+
129
+ /**
130
+ * A tenant name that is safe as one path segment. Tenants come from account
131
+ * ids rather than user input, but this directory is also handed to `docker -v`
132
+ * and to a k8s `subPath`, where a `..` would escape into another tenant's
133
+ * cache — so it is checked at the boundary rather than assumed upstream.
134
+ */
135
+ export function safeTenantSegment(tenant: string): string | null {
136
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tenant) && tenant !== "." && tenant !== ".."
137
+ ? tenant
138
+ : null;
139
+ }
140
+
141
+ export interface PreparedRuntime {
142
+ /** Interpreter overrides by file extension, e.g. { ".py": "/…/venv/bin/python" }. */
143
+ interpreters: Record<string, string>;
144
+ /** Extra environment for spawned scripts (NODE_PATH, VIRTUAL_ENV, PATH). */
145
+ env: Record<string, string>;
146
+ /** Human-readable lines describing what was built, for the run log. */
147
+ log: string[];
148
+ error: string | null;
149
+ }
150
+
151
+ const EMPTY: PreparedRuntime = { interpreters: {}, env: {}, log: [], error: null };
152
+
153
+ /**
154
+ * Run an installer and always be able to say why it failed.
155
+ *
156
+ * `spawnSync` reports three different failures and only one of them writes to
157
+ * stdout or stderr. A command that cannot be spawned at all — not installed,
158
+ * not on PATH — sets `error` and leaves both streams null; one killed by the
159
+ * timeout sets `signal`; only a command that ran and exited non-zero has
160
+ * output to quote. Reporting the streams alone produced the least useful
161
+ * message a build can give: "npm install failed: " with nothing after it.
162
+ */
163
+ function run(
164
+ cmd: string,
165
+ args: string[],
166
+ cwd: string,
167
+ timeoutMs = 300_000,
168
+ extraEnv: Record<string, string> = {},
169
+ ) {
170
+ const res = spawnSync(cmd, args, {
171
+ cwd,
172
+ timeout: timeoutMs,
173
+ encoding: "utf8",
174
+ env: { ...process.env, ...extraEnv },
175
+ });
176
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
177
+ if (res.status === 0) return { ok: true, out };
178
+ const why = res.error
179
+ ? `could not run \`${cmd}\` — ${res.error.message}`
180
+ : res.signal
181
+ ? `\`${cmd}\` was killed by ${res.signal}${res.signal === "SIGTERM" ? ` (the ${Math.round(timeoutMs / 1000)}s limit)` : ""}`
182
+ : `\`${cmd}\` exited ${res.status}`;
183
+ // The reason first, then whatever it managed to say. Never just the output,
184
+ // because the output is empty in exactly the cases that are hardest to
185
+ // diagnose from a distance.
186
+ return { ok: false, out: out ? `${why}\n${out}` : why };
187
+ }
188
+
189
+ /** Wire an already-built root up, without installing anything. */
190
+ function wire(root: string, spec: RuntimeSpec, note: string): PreparedRuntime {
191
+ const interpreters: Record<string, string> = {};
192
+ const env: Record<string, string> = {};
193
+ const venvPython = path.join(root, "venv", "bin", "python");
194
+ const nodeModules = path.join(root, "node_modules");
195
+ if (wantsPython(spec) && fs.existsSync(venvPython)) {
196
+ interpreters[".py"] = venvPython;
197
+ env.VIRTUAL_ENV = path.join(root, "venv");
198
+ }
199
+ if (wantsNode(spec) && fs.existsSync(nodeModules)) env.NODE_PATH = nodeModules;
200
+
201
+ // A `.ready` root that cannot actually satisfy the declaration is worse than
202
+ // no cache: it reports "cached", wires nothing, and the failure surfaces
203
+ // much later as "Cannot find package 'x'" inside someone's script — which
204
+ // reads as a broken tool, not a broken runtime. Only packages that were
205
+ // asked for by name are checked: `node: true` with no npm list installs
206
+ // nothing and legitimately has no node_modules.
207
+ const missing: string[] = [];
208
+ if (spec.packages.length > 0 && !fs.existsSync(venvPython)) missing.push("the python venv");
209
+ if (spec.npm.length > 0 && !fs.existsSync(nodeModules)) missing.push("node_modules");
210
+ if (missing.length > 0) {
211
+ return {
212
+ interpreters,
213
+ env,
214
+ log: [note],
215
+ error: `${note.replace(/: .*$/, "")}: marked ready but ${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} missing — the cache entry is incomplete. Delete ${root} to rebuild it.`,
216
+ };
217
+ }
218
+ return { interpreters, env, log: [note], error: null };
219
+ }
220
+
221
+ const wantsPython = (s: RuntimeSpec) => s.python !== undefined || s.packages.length > 0;
222
+ const wantsNode = (s: RuntimeSpec) => s.node !== undefined || s.npm.length > 0;
223
+
224
+ /** How long a build may hold the claim, and therefore how long another step
225
+ * will wait on it, before it is treated as abandoned. Longer than the 300s
226
+ * install timeout, so a slow-but-live build is never stolen. Env-overridable
227
+ * because an operator who has seen numpy compile knows better than this
228
+ * default does — and because tests cannot spend six minutes proving it. */
229
+ function buildTimeoutMs(): number {
230
+ const raw = Number(process.env.FOLDRUN_RUNTIME_BUILD_TIMEOUT_MS);
231
+ return Number.isFinite(raw) && raw > 0 ? raw : 6 * 60_000;
232
+ }
233
+
234
+ /** Block without spinning. prepareRuntime is synchronous by contract — every
235
+ * caller is mid-spawn — so waiting cannot be done with a promise. */
236
+ function sleepSync(ms: number): void {
237
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
238
+ }
239
+
240
+ /**
241
+ * Claim the right to build `root`, or discover someone else holds it.
242
+ *
243
+ * The cache became shared the moment it started surviving the container, and
244
+ * two steps of the same account with the same dependencies now start at the
245
+ * same instant routinely — the platform runs four at a time. Both would run
246
+ * `python -m venv` into one directory and pip into it concurrently, and the
247
+ * loser's half-written venv is not a slow run, it is a corrupt cache that
248
+ * every later step inherits. `mkdir` is the lock because it is atomic on
249
+ * every filesystem this runs on; O_EXCL on a file would do as well.
250
+ */
251
+ function claimBuild(root: string): boolean {
252
+ const lock = path.join(root, ".building");
253
+ try {
254
+ fs.mkdirSync(lock);
255
+ return true;
256
+ } catch {
257
+ // Held — unless whoever held it died. A crashed build leaves the marker
258
+ // behind forever, and without this every later step would wait the full
259
+ // timeout and then build privately, permanently.
260
+ try {
261
+ if (Date.now() - fs.statSync(lock).mtimeMs > buildTimeoutMs()) {
262
+ fs.rmSync(lock, { recursive: true, force: true });
263
+ fs.mkdirSync(lock);
264
+ return true;
265
+ }
266
+ } catch {
267
+ // Someone else won the steal. Wait for them like any other holder.
268
+ }
269
+ return false;
270
+ }
271
+ }
272
+
273
+ /** Wait for another process's build, up to the point where it is abandoned. */
274
+ function awaitReady(ready: string): boolean {
275
+ const until = Date.now() + buildTimeoutMs();
276
+ while (Date.now() < until) {
277
+ if (fs.existsSync(ready)) return true;
278
+ sleepSync(250);
279
+ }
280
+ return false;
281
+ }
282
+
283
+ // Build (or reuse) the environment for one runtime declaration.
284
+ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime {
285
+ if (!spec) return EMPTY;
286
+
287
+ const fp = fingerprint(spec);
288
+ const shared = path.join(dataRoot(), tenant, ".runtimes", fp);
289
+ // Loud, and before anything else: a dropped requirement surfaces later as
290
+ // an import error inside a script, which points at the wrong thing entirely.
291
+ const dropped = spec.rejected?.length
292
+ ? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`]
293
+ : [];
294
+
295
+ // Already built: wire it up and skip the install. The whole point of the
296
+ // cache — on the hosted path this directory is a mounted volume, so the
297
+ // hit rate across a run is close to one.
298
+ if (fs.existsSync(path.join(shared, ".ready"))) {
299
+ const hit = wire(shared, spec, `runtime ${fp}: cached`);
300
+ return { ...hit, log: [...dropped, ...hit.log] };
301
+ }
302
+
303
+ fs.mkdirSync(shared, { recursive: true });
304
+
305
+ let root = shared;
306
+ const held = claimBuild(shared);
307
+ if (!held) {
308
+ // A concurrent step is building exactly this. Waiting for it beats
309
+ // duplicating it — the work is identical and it is already underway.
310
+ if (awaitReady(path.join(shared, ".ready"))) {
311
+ const hit = wire(shared, spec, `runtime ${fp}: cached (built by a concurrent step)`);
312
+ return { ...hit, log: [...dropped, ...hit.log] };
313
+ }
314
+ // It never finished. Build privately instead: slower and uncached, but a
315
+ // step that runs is worth more than a cache entry, and a wedged lock must
316
+ // never be able to stop work. In the tmpdir, not the cache — a private
317
+ // build is by definition not worth keeping, and leaving these beside the
318
+ // real entries would grow a directory nothing ever prunes.
319
+ root = fs.mkdtempSync(path.join(os.tmpdir(), `foldrun-runtime-${fp}-`));
320
+ }
321
+
322
+ const ready = path.join(root, ".ready");
323
+ const interpreters: Record<string, string> = {};
324
+ const env: Record<string, string> = {};
325
+ const log: string[] = [...dropped];
326
+
327
+ const venvPython = path.join(root, "venv", "bin", "python");
328
+ const nodeModules = path.join(root, "node_modules");
329
+ const wantsPy = wantsPython(spec);
330
+ const wantsNd = wantsNode(spec);
331
+
332
+ try {
333
+
334
+ if (wantsPy) {
335
+ const base = typeof spec.python === "string" ? `python${spec.python}` : "python3";
336
+ const exe = [base, "python3"].find((c) => run("command", ["-v", c], root).ok || run(c, ["--version"], root).ok);
337
+ if (!exe) {
338
+ return { ...EMPTY, error: `python interpreter "${base}" is not available on this host` };
339
+ }
340
+ // A sealed venv, never --system-site-packages. Inheriting the image's
341
+ // packages would make a warm start cheaper, and it was measured on the
342
+ // production box on 2026-08-29: a venv that shadows a baked `pandas` with
343
+ // a pinned one keeps the *system* numpy underneath it, and the two are not
344
+ // ABI-compatible — `pandas<2` on top of numpy 2 dies at import with
345
+ // "numpy.dtype size changed". A pin that silently produces a broken
346
+ // interpreter is worse than any install it saves.
347
+ const made = run(exe, ["-m", "venv", path.join(root, "venv")], root);
348
+ if (!made.ok) return { ...EMPTY, error: `failed to create venv: ${made.out.slice(0, 300)}` };
349
+ log.push(`runtime ${fp}: created venv (${exe})`);
350
+
351
+ if (spec.packages.length) {
352
+ const pip = path.join(root, "venv", "bin", "pip");
353
+ const installed = run(pip, ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
354
+ if (!installed.ok) {
355
+ return { ...EMPTY, error: `pip install failed: ${installed.out.slice(-500)}` };
356
+ }
357
+ log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
358
+ }
359
+ interpreters[".py"] = venvPython;
360
+ env.VIRTUAL_ENV = path.join(root, "venv");
361
+ }
362
+
363
+ if (wantsNd) {
364
+ if (spec.npm.length) {
365
+ fs.writeFileSync(
366
+ path.join(root, "package.json"),
367
+ JSON.stringify({ name: `foldrun-runtime-${fp}`, private: true }, null, 2),
368
+ );
369
+ // Not --silent: it sets npm's loglevel to silent, which suppresses the
370
+ // explanation along with the noise. A build log nobody reads is cheaper
371
+ // than a failure nobody can explain.
372
+ const installed = run(
373
+ "npm",
374
+ ["install", "--no-fund", "--no-audit", "--no-progress", ...spec.npm],
375
+ root,
376
+ 300_000,
377
+ {
378
+ // npm keeps its cache under $HOME/.npm. A sandbox runs as a user
379
+ // with no home directory, so that resolves to /.npm, which it may
380
+ // not create — and npm fails with ENOENT before it fetches
381
+ // anything. Every tool declaring `npm:` failed this way, on every
382
+ // executor, which is a long time for a feature to be broken
383
+ // quietly. The cache belongs beside the runtime it is building.
384
+ HOME: root,
385
+ npm_config_cache: path.join(root, ".npm-cache"),
386
+ },
387
+ );
388
+ if (!installed.ok) {
389
+ return { ...EMPTY, error: `npm install failed: ${installed.out.slice(-500)}` };
390
+ }
391
+ log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
392
+ }
393
+ if (fs.existsSync(nodeModules)) env.NODE_PATH = nodeModules;
394
+ }
395
+
396
+ fs.writeFileSync(ready, new Date().toISOString());
397
+ return { interpreters, env, log, error: null };
398
+ } finally {
399
+ // Whatever happened — built, failed, threw — the claim is released. A
400
+ // failed build leaves no `.ready`, so the next step retries it rather
401
+ // than inheriting a half-built environment.
402
+ if (held) fs.rmSync(path.join(shared, ".building"), { recursive: true, force: true });
403
+ }
404
+ }