@foldrun/core 0.4.0 → 0.6.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 (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/runtime.ts CHANGED
@@ -3,14 +3,17 @@
3
3
  // An agent (or its workspace) declares what its scripts need:
4
4
  //
5
5
  // runtime:
6
- // python: "3.12" # optional pin; the host interpreter is used
6
+ // python: "3.12" # optional pin; uv fetches it when the image lacks it
7
7
  // packages: [pandas, requests]
8
8
  // node: true
9
9
  // npm: [lodash]
10
10
  //
11
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
12
+ // declaration, and reuses it for every later run — after checking it still
13
+ // holds what it promises. Python gets a venv (built with uv when the image has
14
+ // it, pip otherwise); Node gets an npm prefix exposed through NODE_PATH, and
15
+ // both put their bin directory first on PATH so a shebang or a bare `python3`
16
+ // finds them too. Entries nobody has used for 30 days are pruned. Nothing is installed into
14
17
  // the host's global site-packages, so two agents can want different versions
15
18
  // of the same library without colliding.
16
19
  //
@@ -35,6 +38,10 @@ export interface RuntimeSpec {
35
38
  * the script with "no module named pandas" is the least debuggable outcome
36
39
  * available. Never part of the fingerprint — it names nothing installed. */
37
40
  rejected?: string[];
41
+ /** Installer options a requirements.txt carried (`--hash`, `-r`,
42
+ * `--index-url`), by name only. Not applied — the installer takes options
43
+ * from the same argv as its packages — and said so in the build log. */
44
+ ignored?: string[];
38
45
  }
39
46
 
40
47
  /**
@@ -79,9 +86,8 @@ export function parseRuntime(raw: unknown): RuntimeSpec | null {
79
86
  npm: list(e.npm, NPM_REQUIREMENT),
80
87
  };
81
88
  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;
89
+ if (Array.isArray(e.ignored) && e.ignored.length) spec.ignored = e.ignored.map(String);
90
+ return wantsPython(spec) || wantsNode(spec) ? spec : null;
85
91
  }
86
92
 
87
93
  /**
@@ -113,6 +119,8 @@ export function mergeRuntimes(...specs: (RuntimeSpec | null | undefined)[]): Run
113
119
  };
114
120
  const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
115
121
  if (rejected.length) merged.rejected = rejected;
122
+ const ignored = uniq(present.flatMap((s) => s.ignored ?? []));
123
+ if (ignored.length) merged.ignored = ignored;
116
124
  return merged;
117
125
  }
118
126
 
@@ -146,6 +154,10 @@ export interface PreparedRuntime {
146
154
  /** Human-readable lines describing what was built, for the run log. */
147
155
  log: string[];
148
156
  error: string | null;
157
+ /** Removes what only this step may use — the private build a wedged or
158
+ * failed shared build left it with. Absent when there is nothing to
159
+ * remove; call it when the step is done with the environment. */
160
+ dispose?: () => void;
149
161
  }
150
162
 
151
163
  const EMPTY: PreparedRuntime = { interpreters: {}, env: {}, log: [], error: null };
@@ -192,11 +204,23 @@ function wire(root: string, spec: RuntimeSpec, note: string): PreparedRuntime {
192
204
  const env: Record<string, string> = {};
193
205
  const venvPython = path.join(root, "venv", "bin", "python");
194
206
  const nodeModules = path.join(root, "node_modules");
207
+ const bins: string[] = [];
195
208
  if (wantsPython(spec) && fs.existsSync(venvPython)) {
196
209
  interpreters[".py"] = venvPython;
197
210
  env.VIRTUAL_ENV = path.join(root, "venv");
211
+ bins.push(path.join(root, "venv", "bin"));
198
212
  }
199
- if (wantsNode(spec) && fs.existsSync(nodeModules)) env.NODE_PATH = nodeModules;
213
+ if (wantsNode(spec) && fs.existsSync(nodeModules)) {
214
+ env.NODE_PATH = nodeModules;
215
+ if (fs.existsSync(path.join(nodeModules, ".bin"))) bins.push(path.join(nodeModules, ".bin"));
216
+ }
217
+ // First on PATH, so the environment is the one that answers however a
218
+ // script is started: `interpreter: python3` in a tool file, a
219
+ // `#!/usr/bin/env python3` shebang, or the agent typing `python3` in Bash.
220
+ // Before this only a .py file with no declared interpreter got the venv,
221
+ // and a tool that said `interpreter: python3` — the usual way to write one —
222
+ // ran on the bare system python and died on its first import.
223
+ if (bins.length) env.PATH = [...bins, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
200
224
 
201
225
  // A `.ready` root that cannot actually satisfy the declaration is worse than
202
226
  // no cache: it reports "cached", wires nothing, and the failure surfaces
@@ -218,8 +242,10 @@ function wire(root: string, spec: RuntimeSpec, note: string): PreparedRuntime {
218
242
  return { interpreters, env, log: [note], error: null };
219
243
  }
220
244
 
221
- const wantsPython = (s: RuntimeSpec) => s.python !== undefined || s.packages.length > 0;
222
- const wantsNode = (s: RuntimeSpec) => s.node !== undefined || s.npm.length > 0;
245
+ // `python: false` says "not this one" — it used to read as a declaration
246
+ // because it was not undefined, and built a venv nobody asked for.
247
+ const wantsPython = (s: RuntimeSpec) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
248
+ const wantsNode = (s: RuntimeSpec) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
223
249
 
224
250
  /** How long a build may hold the claim, and therefore how long another step
225
251
  * will wait on it, before it is treated as abandoned. Longer than the 300s
@@ -248,36 +274,285 @@ function sleepSync(ms: number): void {
248
274
  * every later step inherits. `mkdir` is the lock because it is atomic on
249
275
  * every filesystem this runs on; O_EXCL on a file would do as well.
250
276
  */
251
- function claimBuild(root: string): boolean {
277
+ // The claim carries a token naming its holder, so a build releases only a
278
+ // claim that is still its own: one whose claim was taken over as abandoned
279
+ // used to remove the new holder's lock on its way out, and a third step then
280
+ // built into the same directory beside the second.
281
+ export function claimBuild(
282
+ root: string,
283
+ /** Test seam: runs once the claim is judged abandoned, before the
284
+ * takeover — where a second claimer used to slip in. */
285
+ seam?: { beforeTakeover?: () => void },
286
+ ): string | null {
252
287
  const lock = path.join(root, ".building");
253
- try {
288
+ const token = crypto.randomUUID();
289
+ const take = () => {
254
290
  fs.mkdirSync(lock);
255
- return true;
291
+ fs.writeFileSync(path.join(lock, "owner"), token);
292
+ return token;
293
+ };
294
+ try {
295
+ return take();
256
296
  } catch {
257
297
  // Held — unless whoever held it died. A crashed build leaves the marker
258
298
  // behind forever, and without this every later step would wait the full
259
- // timeout and then build privately, permanently.
299
+ // timeout and then build privately, permanently. A live build is never
300
+ // taken this way: it beats on the claim between installer runs (see
301
+ // heartbeat), and no single run may outlast the timeout.
260
302
  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;
303
+ if (!isAbandoned(lock)) return null;
304
+ seam?.beforeTakeover?.();
305
+ return takeOver(lock, token, take);
306
+ } catch {
307
+ // Someone else won the takeover. Wait for them like any other holder.
308
+ return null;
309
+ }
310
+ }
311
+ }
312
+
313
+ /** Quiet past the build timeout. */
314
+ function isAbandoned(dir: string): boolean {
315
+ return Date.now() - fs.statSync(dir).mtimeMs > buildTimeoutMs();
316
+ }
317
+
318
+ /**
319
+ * Take over an abandoned claim — atomically. Remove-then-take was not: two
320
+ * waiters that both saw the stale lock both removed it, and the second
321
+ * removal deleted the lock the first had just taken, so both built. Now a
322
+ * takeover happens only under a second `mkdir` lock (one at a time), the
323
+ * staleness is re-read under it (a lock another takeover just made is fresh
324
+ * and is left alone), and the stale lock is renamed aside — then checked to
325
+ * be the same claim that was judged stale — before it is removed.
326
+ */
327
+ function takeOver(lock: string, token: string, take: () => string): string | null {
328
+ const steal = `${lock}.steal`;
329
+ try {
330
+ fs.mkdirSync(steal);
331
+ } catch {
332
+ // Another takeover is in flight — or died in its few microseconds. A
333
+ // takeover lock that old is cleared (renamed first: one clearer wins)
334
+ // and the next poll tries again.
335
+ try {
336
+ if (isAbandoned(steal)) {
337
+ const aside = `${steal}.${token}`;
338
+ fs.renameSync(steal, aside);
339
+ fs.rmSync(aside, { recursive: true, force: true });
265
340
  }
266
341
  } catch {
267
- // Someone else won the steal. Wait for them like any other holder.
342
+ // someone else cleared it
268
343
  }
344
+ return null;
345
+ }
346
+ try {
347
+ if (!isAbandoned(lock)) return null;
348
+ const judged = readOwner(lock);
349
+ const aside = `${lock}.stale-${token}`;
350
+ fs.renameSync(lock, aside);
351
+ if (readOwner(aside) !== judged) {
352
+ // Not the claim judged stale: its build released it and a new one took
353
+ // it in between. Hand it back and wait on it.
354
+ try {
355
+ fs.renameSync(aside, lock);
356
+ } catch {
357
+ fs.rmSync(aside, { recursive: true, force: true });
358
+ }
359
+ return null;
360
+ }
361
+ fs.rmSync(aside, { recursive: true, force: true });
362
+ return take();
363
+ } finally {
364
+ fs.rmSync(steal, { recursive: true, force: true });
365
+ }
366
+ }
367
+
368
+ function readOwner(dir: string): string | null {
369
+ try {
370
+ return fs.readFileSync(path.join(dir, "owner"), "utf8");
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ /** Is the claim on `root` still the one `token` took? */
377
+ function ownsBuild(root: string, token: string): boolean {
378
+ try {
379
+ return fs.readFileSync(path.join(root, ".building", "owner"), "utf8") === token;
380
+ } catch {
269
381
  return false;
270
382
  }
271
383
  }
272
384
 
273
- /** Wait for another process's build, up to the point where it is abandoned. */
274
- function awaitReady(ready: string): boolean {
385
+ /** Say the build is alive: the claim's mtime is the clock claimBuild reads.
386
+ * Synchronous installers block the event loop, so this cannot be a timer —
387
+ * it is called before and after every installer run instead. */
388
+ function heartbeat(root: string, token: string): void {
389
+ if (ownsBuild(root, token)) touch(path.join(root, ".building"));
390
+ }
391
+
392
+ /** Release the claim — only if it is still ours. */
393
+ export function releaseBuild(root: string, token: string): void {
394
+ if (ownsBuild(root, token)) fs.rmSync(path.join(root, ".building"), { recursive: true, force: true });
395
+ }
396
+
397
+ /** How long a failed shared build is news to the steps that waited on it.
398
+ * Older than this it describes an earlier attempt, and a new one is due. */
399
+ const FAILED_TTL_MS = 60_000;
400
+
401
+ /** Wait for another process's build, up to the point where it is abandoned.
402
+ * Waits for the claim to be released, not merely for `.ready` to exist: a
403
+ * stale entry being rebuilt still has its old marker for a moment, and
404
+ * reading that as "done" would wire the half-rebuilt directory up. A build
405
+ * that failed says so in `.failed`, and the wait ends there — it used to
406
+ * spin the full six minutes on a lock that was already gone. */
407
+ function awaitReady(root: string): { ready: true } | { ready: false; failed: string | null } {
275
408
  const until = Date.now() + buildTimeoutMs();
276
409
  while (Date.now() < until) {
277
- if (fs.existsSync(ready)) return true;
410
+ if (!fs.existsSync(path.join(root, ".building"))) {
411
+ if (fs.existsSync(path.join(root, ".ready"))) return { ready: true };
412
+ const failed = readFailed(root);
413
+ if (failed !== null) return { ready: false, failed };
414
+ }
278
415
  sleepSync(250);
279
416
  }
280
- return false;
417
+ return { ready: false, failed: null };
418
+ }
419
+
420
+ /** The error a recent failed build of `root` left, or null. */
421
+ function readFailed(root: string): string | null {
422
+ const file = path.join(root, ".failed");
423
+ try {
424
+ if (Date.now() - fs.statSync(file).mtimeMs > FAILED_TTL_MS) return null;
425
+ return (JSON.parse(fs.readFileSync(file, "utf8")) as { error?: string }).error ?? "the build failed";
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
431
+ /** A requirement's distribution name: `requests[socks]>=2` → `requests`. */
432
+ export function distName(req: string): string {
433
+ return req.split(/[[<>=!~]/)[0].trim();
434
+ }
435
+
436
+ /** An npm requirement's package name: `@scope/pkg@^1` → `@scope/pkg`. */
437
+ export function npmName(req: string): string {
438
+ const m = /^(@[^/]+\/)?[^@]+/.exec(req);
439
+ return m ? m[0] : req;
440
+ }
441
+
442
+ // Asks the venv's own interpreter which declared distributions it can find.
443
+ // importlib.metadata, not `import x`: the distribution and the module are
444
+ // often named differently (beautifulsoup4 is bs4, pillow is PIL), and the
445
+ // declaration names distributions.
446
+ const PY_CHECK = [
447
+ "import sys, importlib.metadata as m",
448
+ "miss = []",
449
+ "for n in sys.argv[1:]:",
450
+ " try: m.version(n)",
451
+ " except m.PackageNotFoundError: miss.append(n)",
452
+ "print(','.join(miss))",
453
+ "sys.exit(3 if miss else 0)",
454
+ ].join("\n");
455
+
456
+ /**
457
+ * Does a `.ready` entry still hold what the declaration asks for? Null when it
458
+ * does, else why not.
459
+ *
460
+ * `.ready` is written once and trusted for ever, and that trust was misplaced
461
+ * more than once: a build interrupted between install and marker, a cache
462
+ * volume restored without its contents, and — the quiet one — an image whose
463
+ * python moved (bookworm's 3.11 to trixie's 3.13), which leaves every venv
464
+ * with a valid-looking bin/python pointing at an interpreter that is gone.
465
+ * Each reported "cached" and failed later inside somebody's tool. One spawn
466
+ * of the venv's python per step is the price of never doing that again.
467
+ */
468
+ export function checkEntry(root: string, spec: RuntimeSpec): string | null {
469
+ const venvPython = path.join(root, "venv", "bin", "python");
470
+ if (wantsPython(spec)) {
471
+ // existsSync follows the link, so a bin/python pointing at an interpreter
472
+ // an image upgrade removed reads as missing here, which is the truth.
473
+ if (!fs.existsSync(venvPython)) {
474
+ return fs.lstatSync(venvPython, { throwIfNoEntry: false })
475
+ ? "the venv's python no longer runs (the image's python has changed)"
476
+ : "the python venv is missing";
477
+ }
478
+ const probe = () =>
479
+ spawnSync(venvPython, ["-c", PY_CHECK, ...spec.packages.map(distName)], {
480
+ encoding: "utf8",
481
+ timeout: checkTimeoutMs(),
482
+ });
483
+ // A check that ran out of time says nothing about the venv — only that
484
+ // the host is busy (a cold disk, twenty steps importing at once). Read as
485
+ // broken, it deleted a venv other steps were running in and rebuilt it.
486
+ // Asked once more; still no answer, and the entry is used as it stands.
487
+ const timedOut = (r: ReturnType<typeof probe>) => (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
488
+ let res = probe();
489
+ if (timedOut(res)) res = probe();
490
+ if (timedOut(res)) return null;
491
+ if (res.status === 3) return `the venv is missing ${String(res.stdout).trim().split(",").join(", ")}`;
492
+ if (res.status !== 0) return "the venv's python no longer runs (the image's python has changed)";
493
+ }
494
+ for (const req of spec.npm) {
495
+ if (!fs.existsSync(path.join(root, "node_modules", npmName(req), "package.json"))) {
496
+ return `node_modules is missing ${npmName(req)}`;
497
+ }
498
+ }
499
+ return null;
500
+ }
501
+
502
+ /** How long the venv's python gets to answer checkEntry. Env-overridable
503
+ * for the same reason as the build timeout: tests cannot wait a minute. */
504
+ function checkTimeoutMs(): number {
505
+ const raw = Number(process.env.FOLDRUN_RUNTIME_CHECK_TIMEOUT_MS);
506
+ return Number.isFinite(raw) && raw > 0 ? raw : 60_000;
507
+ }
508
+
509
+ /** Days an entry may go unused before a later build prunes it. */
510
+ function maxAgeDays(): number {
511
+ const raw = Number(process.env.FOLDRUN_RUNTIME_MAX_AGE_DAYS);
512
+ return Number.isFinite(raw) && raw > 0 ? raw : 30;
513
+ }
514
+
515
+ /**
516
+ * Remove entries nobody has used for maxAgeDays. `.ready`'s mtime is the
517
+ * last-used clock (every hit touches it); an entry with no `.ready` is a
518
+ * failed build and ages from its directory's own mtime. Never an entry that
519
+ * is being built, and never `keep`. Runs after a successful build — the only
520
+ * moment a cache grows — so it costs nothing on the hot path.
521
+ */
522
+ export function pruneRuntimes(cacheRoot: string, keep: string): string[] {
523
+ const cutoff = Date.now() - maxAgeDays() * 86_400_000;
524
+ const removed: string[] = [];
525
+ let names: string[] = [];
526
+ try {
527
+ names = fs.readdirSync(cacheRoot);
528
+ } catch {
529
+ return removed;
530
+ }
531
+ for (const name of names) {
532
+ // .uv-cache and .python are shared by every entry; never an entry.
533
+ if (name.startsWith(".") || name === keep) continue;
534
+ const dir = path.join(cacheRoot, name);
535
+ try {
536
+ if (fs.existsSync(path.join(dir, ".building"))) continue;
537
+ const ready = path.join(dir, ".ready");
538
+ const clock = fs.existsSync(ready) ? fs.statSync(ready).mtimeMs : fs.statSync(dir).mtimeMs;
539
+ if (clock < cutoff) {
540
+ fs.rmSync(dir, { recursive: true, force: true });
541
+ removed.push(name);
542
+ }
543
+ } catch {
544
+ // Another process removed or claimed it meanwhile; its business.
545
+ }
546
+ }
547
+ return removed;
548
+ }
549
+
550
+ /** Is `uv` on this host? Asked once per process. FOLDRUN_UV=0 forces pip. */
551
+ let uvKnown: string | null | undefined;
552
+ function findUv(): string | null {
553
+ if (process.env.FOLDRUN_UV === "0") return null;
554
+ if (uvKnown === undefined) uvKnown = run("uv", ["--version"], os.tmpdir(), 30_000).ok ? "uv" : null;
555
+ return uvKnown;
281
556
  }
282
557
 
283
558
  // Build (or reuse) the environment for one runtime declaration.
@@ -285,19 +560,45 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
285
560
  if (!spec) return EMPTY;
286
561
 
287
562
  const fp = fingerprint(spec);
288
- const shared = path.join(dataRoot(), tenant, ".runtimes", fp);
563
+ const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
564
+ const shared = path.join(cacheRoot, fp);
289
565
  // Loud, and before anything else: a dropped requirement surfaces later as
290
566
  // 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
- : [];
567
+ const dropped = [
568
+ ...(spec.rejected?.length ? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`] : []),
569
+ ...(spec.ignored?.length
570
+ ? [
571
+ `runtime ${fp}: requirements.txt option(s) not applied: ${spec.ignored.join(", ")} — the requirements are installed${spec.ignored.includes("--hash") ? " without hash checking" : ""}`,
572
+ ]
573
+ : []),
574
+ ];
294
575
 
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.
576
+ // The platform's shared layer first: environments built once, by the
577
+ // platform, for every account that declares exactly this — mounted
578
+ // read-only into the run (FOLDRUN_RUNTIME_SHARED, run-k8s.ts). Only a
579
+ // healthy entry is used; anything else falls through to the account's own
580
+ // cache, which is writable and builds what the shared layer lacks.
581
+ const sharedLayer = process.env.FOLDRUN_RUNTIME_SHARED;
582
+ if (sharedLayer) {
583
+ const entry = path.join(sharedLayer, fp);
584
+ if (fs.existsSync(path.join(entry, ".ready")) && !checkEntry(entry, spec)) {
585
+ const hit = wire(entry, spec, `runtime ${fp}: shared`);
586
+ return { ...hit, log: [...dropped, ...hit.log] };
587
+ }
588
+ }
589
+
590
+ // Already built, and still holding what it promises: wire it up and skip
591
+ // the install. On the hosted path this directory is a mounted volume, so
592
+ // the hit rate across a run is close to one. An entry that fails the check
593
+ // is rebuilt, not trusted and not merely reported.
594
+ let stale: string | null = null;
298
595
  if (fs.existsSync(path.join(shared, ".ready"))) {
299
- const hit = wire(shared, spec, `runtime ${fp}: cached`);
300
- return { ...hit, log: [...dropped, ...hit.log] };
596
+ stale = checkEntry(shared, spec);
597
+ if (!stale) {
598
+ touch(path.join(shared, ".ready"));
599
+ const hit = wire(shared, spec, `runtime ${fp}: cached`);
600
+ return { ...hit, log: [...dropped, ...hit.log] };
601
+ }
301
602
  }
302
603
 
303
604
  fs.mkdirSync(shared, { recursive: true });
@@ -307,36 +608,87 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
307
608
  if (!held) {
308
609
  // A concurrent step is building exactly this. Waiting for it beats
309
610
  // duplicating it — the work is identical and it is already underway.
310
- if (awaitReady(path.join(shared, ".ready"))) {
611
+ const waited = awaitReady(shared);
612
+ if (waited.ready && !checkEntry(shared, spec)) {
311
613
  const hit = wire(shared, spec, `runtime ${fp}: cached (built by a concurrent step)`);
312
614
  return { ...hit, log: [...dropped, ...hit.log] };
313
615
  }
616
+ // It failed, and just now: the same declaration would fail the same way
617
+ // here, so say what it said rather than spend another five minutes on it.
618
+ if (!waited.ready && waited.failed !== null) {
619
+ return { ...EMPTY, log: dropped, error: `runtime ${fp}: a concurrent step's build of this runtime failed — ${waited.failed}` };
620
+ }
314
621
  // It never finished. Build privately instead: slower and uncached, but a
315
622
  // step that runs is worth more than a cache entry, and a wedged lock must
316
623
  // never be able to stop work. In the tmpdir, not the cache — a private
317
624
  // build is by definition not worth keeping, and leaving these beside the
318
- // real entries would grow a directory nothing ever prunes.
625
+ // real entries would grow a directory nothing ever prunes. Removed by
626
+ // dispose() when the step is done with it.
319
627
  root = fs.mkdtempSync(path.join(os.tmpdir(), `foldrun-runtime-${fp}-`));
628
+ } else {
629
+ fs.rmSync(path.join(shared, ".failed"), { force: true });
630
+ if (stale || !fs.existsSync(path.join(shared, ".ready"))) {
631
+ // A clean slate: whatever a stale or half-finished build left behind is
632
+ // exactly what must not be built on top of.
633
+ for (const leftover of [".ready", "venv", "node_modules", "package.json", "package-lock.json"]) {
634
+ fs.rmSync(path.join(shared, leftover), { recursive: true, force: true });
635
+ }
636
+ }
320
637
  }
638
+ const privateRoot = root === shared ? null : root;
639
+ const disposePrivate = () => {
640
+ if (privateRoot) fs.rmSync(privateRoot, { recursive: true, force: true });
641
+ };
642
+ // Every installer run goes through here, so the claim is beaten on before
643
+ // and after each one — no run can go quieter than its own timeout.
644
+ const install = (...args: Parameters<typeof run>) => {
645
+ if (held) heartbeat(shared, held);
646
+ try {
647
+ return run(...args);
648
+ } finally {
649
+ if (held) heartbeat(shared, held);
650
+ }
651
+ };
321
652
 
322
653
  const ready = path.join(root, ".ready");
323
654
  const interpreters: Record<string, string> = {};
324
655
  const env: Record<string, string> = {};
325
656
  const log: string[] = [...dropped];
657
+ if (stale) log.push(`runtime ${fp}: cached entry unusable (${stale}); rebuilding`);
326
658
 
327
659
  const venvPython = path.join(root, "venv", "bin", "python");
328
660
  const nodeModules = path.join(root, "node_modules");
329
661
  const wantsPy = wantsPython(spec);
330
662
  const wantsNd = wantsNode(spec);
663
+ const built: Record<string, unknown> = { built: new Date().toISOString() };
664
+ let succeeded = false;
665
+ let failure = "the build failed";
666
+ const fail = (error: string): PreparedRuntime => {
667
+ failure = error;
668
+ return { ...EMPTY, log, error };
669
+ };
331
670
 
332
671
  try {
333
672
 
334
673
  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
- }
674
+ const uv = findUv();
675
+ const pin = typeof spec.python === "string" ? spec.python : null;
676
+ // uv's own caches sit beside the entries they fill, so they live as long
677
+ // as the cache volume does: a second environment asking for pandas links
678
+ // it out of .uv-cache instead of downloading it again, and a pinned
679
+ // python fetched once into .python serves every later venv. Copy, not
680
+ // hardlink — the volume may be NFS or EFS, where links across the cache
681
+ // and the entry are not guaranteed.
682
+ const uvEnv = {
683
+ UV_CACHE_DIR: path.join(cacheRoot, ".uv-cache"),
684
+ // Inside the entry when the platform builds for the shared layer: that
685
+ // build may write only its own entry, and a venv whose interpreter
686
+ // lived beside it would point at nothing once mounted elsewhere.
687
+ UV_PYTHON_INSTALL_DIR:
688
+ process.env.FOLDRUN_RUNTIME_PYTHON_IN_ENTRY === "1" ? path.join(root, ".python") : path.join(cacheRoot, ".python"),
689
+ UV_LINK_MODE: "copy",
690
+ UV_NO_PROGRESS: "1",
691
+ };
340
692
  // A sealed venv, never --system-site-packages. Inheriting the image's
341
693
  // packages would make a warm start cheaper, and it was measured on the
342
694
  // production box on 2026-08-29: a venv that shadows a baked `pandas` with
@@ -344,20 +696,48 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
344
696
  // ABI-compatible — `pandas<2` on top of numpy 2 dies at import with
345
697
  // "numpy.dtype size changed". A pin that silently produces a broken
346
698
  // 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})`);
699
+ if (uv) {
700
+ const want = pin ?? (hasCommand("python3", root) ? "python3" : "3");
701
+ // --seed puts pip in the venv as well, for the agent that types
702
+ // `pip install` in Bash; uv itself does not need it.
703
+ const made = install(uv, ["venv", "--seed", "-q", "--python", want, path.join(root, "venv")], root, 300_000, uvEnv);
704
+ if (!made.ok) {
705
+ return fail(
706
+ pin
707
+ ? `python ${pin} could not be provided (uv tried the image and a download): ${made.out.slice(-400)}`
708
+ : `failed to create venv: ${made.out.slice(-400)}`,
709
+ );
710
+ }
711
+ } else {
712
+ // No uv: the image's own interpreter or nothing. A pin the image cannot
713
+ // meet is an error, said as one — it used to fall back to python3 in
714
+ // silence, and "I asked for 3.12" then ran on 3.11.
715
+ const exe = pin ? (hasCommand(`python${pin}`, root) ? `python${pin}` : null) : hasCommand("python3", root) ? "python3" : null;
716
+ if (!exe) {
717
+ return fail(
718
+ pin
719
+ ? `python ${pin} is not installed here, and uv (which could fetch it) is not available`
720
+ : `no python3 on this host, and uv (which could fetch one) is not available`,
721
+ );
722
+ }
723
+ const made = install(exe, ["-m", "venv", path.join(root, "venv")], root);
724
+ if (!made.ok) return fail(`failed to create venv: ${made.out.slice(0, 300)}`);
725
+ }
726
+ const version = install(venvPython, ["-c", "import platform; print(platform.python_version())"], root, 30_000);
727
+ built.python = version.ok ? version.out : "unknown";
728
+ built.installer = uv ? "uv" : "pip";
729
+ log.push(`runtime ${fp}: created venv (python ${built.python}${uv ? " via uv" : ""})`);
350
730
 
351
731
  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);
732
+ const installed = uv
733
+ ? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
734
+ : install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
354
735
  if (!installed.ok) {
355
- return { ...EMPTY, error: `pip install failed: ${installed.out.slice(-500)}` };
736
+ return fail(`${uv ? "uv pip" : "pip"} install failed: ${installed.out.slice(-500)}`);
356
737
  }
738
+ built.packages = spec.packages;
357
739
  log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
358
740
  }
359
- interpreters[".py"] = venvPython;
360
- env.VIRTUAL_ENV = path.join(root, "venv");
361
741
  }
362
742
 
363
743
  if (wantsNd) {
@@ -369,7 +749,7 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
369
749
  // Not --silent: it sets npm's loglevel to silent, which suppresses the
370
750
  // explanation along with the noise. A build log nobody reads is cheaper
371
751
  // than a failure nobody can explain.
372
- const installed = run(
752
+ const installed = install(
373
753
  "npm",
374
754
  ["install", "--no-fund", "--no-audit", "--no-progress", ...spec.npm],
375
755
  root,
@@ -386,19 +766,71 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
386
766
  },
387
767
  );
388
768
  if (!installed.ok) {
389
- return { ...EMPTY, error: `npm install failed: ${installed.out.slice(-500)}` };
769
+ return fail(`npm install failed: ${installed.out.slice(-500)}`);
390
770
  }
771
+ built.npm = spec.npm;
391
772
  log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
392
773
  }
393
- if (fs.existsSync(nodeModules)) env.NODE_PATH = nodeModules;
394
774
  }
395
775
 
396
- fs.writeFileSync(ready, new Date().toISOString());
397
- return { interpreters, env, log, error: null };
776
+ // Checked before it is marked ready, by the same test every later hit will
777
+ // apply: an installer that exits 0 without installing what was asked (it
778
+ // has happened, with npm and a cache it could not write) is caught here,
779
+ // where the log still has the build beside it.
780
+ if (held) heartbeat(shared, held);
781
+ const problem = checkEntry(root, spec);
782
+ if (problem) return fail(`runtime ${fp}: built but ${problem}`);
783
+
784
+ // A build that lost its claim — it went quiet past the timeout and another
785
+ // step took the entry over — must not mark ready what the new holder may
786
+ // be clearing out beneath it — nor use it, for the same reason.
787
+ if (held && !ownsBuild(shared, held)) {
788
+ return fail(`runtime ${fp}: the build was taken over by another step as abandoned (over ${Math.round(buildTimeoutMs() / 1000)}s without a sign of life); run the step again`);
789
+ }
790
+ fs.writeFileSync(ready, JSON.stringify(built));
791
+ const wired = wire(root, spec, "");
792
+ Object.assign(interpreters, wired.interpreters);
793
+ Object.assign(env, wired.env);
794
+ if (held) {
795
+ const pruned = pruneRuntimes(cacheRoot, fp);
796
+ if (pruned.length) log.push(`runtime: pruned ${pruned.length} entr${pruned.length === 1 ? "y" : "ies"} unused for ${maxAgeDays()} days`);
797
+ }
798
+ succeeded = true;
799
+ return { interpreters, env, log, error: null, ...(privateRoot ? { dispose: disposePrivate } : {}) };
800
+ } catch (err) {
801
+ failure = err instanceof Error ? err.message : String(err);
802
+ throw err;
398
803
  } 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 });
804
+ // Whatever happened — built, failed, threw — the claim is released, and
805
+ // only if it is still ours. A failed build leaves no `.ready`, so the
806
+ // next step retries it rather than inheriting a half-built environment;
807
+ // it leaves `.failed`, so the steps waiting on it stop waiting now. A
808
+ // failed private build is removed here: nothing will ever use it.
809
+ if (held) {
810
+ if (!succeeded && ownsBuild(shared, held)) {
811
+ try {
812
+ fs.writeFileSync(path.join(shared, ".failed"), JSON.stringify({ error: failure, at: new Date().toISOString() }));
813
+ } catch {
814
+ // best effort — a waiter without it waits the timeout, as before
815
+ }
816
+ }
817
+ releaseBuild(shared, held);
818
+ }
819
+ if (!succeeded) disposePrivate();
820
+ }
821
+ }
822
+
823
+ function hasCommand(cmd: string, cwd: string): boolean {
824
+ return run(cmd, ["--version"], cwd, 30_000).ok;
825
+ }
826
+
827
+ /** Mark an entry used now; the clock pruneRuntimes reads. Best effort — a
828
+ * read-only cache mount is still a perfectly good cache. */
829
+ function touch(file: string): void {
830
+ try {
831
+ const now = new Date();
832
+ fs.utimesSync(file, now, now);
833
+ } catch {
834
+ // read-only or gone; neither is a reason to fail the step
403
835
  }
404
836
  }