@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
@@ -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
  //
@@ -66,8 +69,9 @@ export function parseRuntime(raw) {
66
69
  };
67
70
  if (rejected.length)
68
71
  spec.rejected = rejected;
69
- const wantsSomething = spec.python !== undefined || spec.node !== undefined || spec.packages.length || spec.npm.length;
70
- return wantsSomething ? spec : null;
72
+ if (Array.isArray(e.ignored) && e.ignored.length)
73
+ spec.ignored = e.ignored.map(String);
74
+ return wantsPython(spec) || wantsNode(spec) ? spec : null;
71
75
  }
72
76
  /**
73
77
  * Several declarations, one environment.
@@ -102,6 +106,9 @@ export function mergeRuntimes(...specs) {
102
106
  const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
103
107
  if (rejected.length)
104
108
  merged.rejected = rejected;
109
+ const ignored = uniq(present.flatMap((s) => s.ignored ?? []));
110
+ if (ignored.length)
111
+ merged.ignored = ignored;
105
112
  return merged;
106
113
  }
107
114
  export function fingerprint(spec) {
@@ -161,12 +168,25 @@ function wire(root, spec, note) {
161
168
  const env = {};
162
169
  const venvPython = path.join(root, "venv", "bin", "python");
163
170
  const nodeModules = path.join(root, "node_modules");
171
+ const bins = [];
164
172
  if (wantsPython(spec) && fs.existsSync(venvPython)) {
165
173
  interpreters[".py"] = venvPython;
166
174
  env.VIRTUAL_ENV = path.join(root, "venv");
175
+ bins.push(path.join(root, "venv", "bin"));
167
176
  }
168
- if (wantsNode(spec) && fs.existsSync(nodeModules))
177
+ if (wantsNode(spec) && fs.existsSync(nodeModules)) {
169
178
  env.NODE_PATH = nodeModules;
179
+ if (fs.existsSync(path.join(nodeModules, ".bin")))
180
+ bins.push(path.join(nodeModules, ".bin"));
181
+ }
182
+ // First on PATH, so the environment is the one that answers however a
183
+ // script is started: `interpreter: python3` in a tool file, a
184
+ // `#!/usr/bin/env python3` shebang, or the agent typing `python3` in Bash.
185
+ // Before this only a .py file with no declared interpreter got the venv,
186
+ // and a tool that said `interpreter: python3` — the usual way to write one —
187
+ // ran on the bare system python and died on its first import.
188
+ if (bins.length)
189
+ env.PATH = [...bins, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
170
190
  // A `.ready` root that cannot actually satisfy the declaration is worse than
171
191
  // no cache: it reports "cached", wires nothing, and the failure surfaces
172
192
  // much later as "Cannot find package 'x'" inside someone's script — which
@@ -188,8 +208,10 @@ function wire(root, spec, note) {
188
208
  }
189
209
  return { interpreters, env, log: [note], error: null };
190
210
  }
191
- const wantsPython = (s) => s.python !== undefined || s.packages.length > 0;
192
- const wantsNode = (s) => s.node !== undefined || s.npm.length > 0;
211
+ // `python: false` says "not this one" — it used to read as a declaration
212
+ // because it was not undefined, and built a venv nobody asked for.
213
+ const wantsPython = (s) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
214
+ const wantsNode = (s) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
193
215
  /** How long a build may hold the claim, and therefore how long another step
194
216
  * will wait on it, before it is treated as abandoned. Longer than the 300s
195
217
  * install timeout, so a slow-but-live build is never stolen. Env-overridable
@@ -215,56 +237,334 @@ function sleepSync(ms) {
215
237
  * every later step inherits. `mkdir` is the lock because it is atomic on
216
238
  * every filesystem this runs on; O_EXCL on a file would do as well.
217
239
  */
218
- function claimBuild(root) {
240
+ // The claim carries a token naming its holder, so a build releases only a
241
+ // claim that is still its own: one whose claim was taken over as abandoned
242
+ // used to remove the new holder's lock on its way out, and a third step then
243
+ // built into the same directory beside the second.
244
+ export function claimBuild(root,
245
+ /** Test seam: runs once the claim is judged abandoned, before the
246
+ * takeover — where a second claimer used to slip in. */
247
+ seam) {
219
248
  const lock = path.join(root, ".building");
220
- try {
249
+ const token = crypto.randomUUID();
250
+ const take = () => {
221
251
  fs.mkdirSync(lock);
222
- return true;
252
+ fs.writeFileSync(path.join(lock, "owner"), token);
253
+ return token;
254
+ };
255
+ try {
256
+ return take();
223
257
  }
224
258
  catch {
225
259
  // Held — unless whoever held it died. A crashed build leaves the marker
226
260
  // behind forever, and without this every later step would wait the full
227
- // timeout and then build privately, permanently.
261
+ // timeout and then build privately, permanently. A live build is never
262
+ // taken this way: it beats on the claim between installer runs (see
263
+ // heartbeat), and no single run may outlast the timeout.
228
264
  try {
229
- if (Date.now() - fs.statSync(lock).mtimeMs > buildTimeoutMs()) {
230
- fs.rmSync(lock, { recursive: true, force: true });
231
- fs.mkdirSync(lock);
232
- return true;
265
+ if (!isAbandoned(lock))
266
+ return null;
267
+ seam?.beforeTakeover?.();
268
+ return takeOver(lock, token, take);
269
+ }
270
+ catch {
271
+ // Someone else won the takeover. Wait for them like any other holder.
272
+ return null;
273
+ }
274
+ }
275
+ }
276
+ /** Quiet past the build timeout. */
277
+ function isAbandoned(dir) {
278
+ return Date.now() - fs.statSync(dir).mtimeMs > buildTimeoutMs();
279
+ }
280
+ /**
281
+ * Take over an abandoned claim — atomically. Remove-then-take was not: two
282
+ * waiters that both saw the stale lock both removed it, and the second
283
+ * removal deleted the lock the first had just taken, so both built. Now a
284
+ * takeover happens only under a second `mkdir` lock (one at a time), the
285
+ * staleness is re-read under it (a lock another takeover just made is fresh
286
+ * and is left alone), and the stale lock is renamed aside — then checked to
287
+ * be the same claim that was judged stale — before it is removed.
288
+ */
289
+ function takeOver(lock, token, take) {
290
+ const steal = `${lock}.steal`;
291
+ try {
292
+ fs.mkdirSync(steal);
293
+ }
294
+ catch {
295
+ // Another takeover is in flight — or died in its few microseconds. A
296
+ // takeover lock that old is cleared (renamed first: one clearer wins)
297
+ // and the next poll tries again.
298
+ try {
299
+ if (isAbandoned(steal)) {
300
+ const aside = `${steal}.${token}`;
301
+ fs.renameSync(steal, aside);
302
+ fs.rmSync(aside, { recursive: true, force: true });
233
303
  }
234
304
  }
235
305
  catch {
236
- // Someone else won the steal. Wait for them like any other holder.
306
+ // someone else cleared it
307
+ }
308
+ return null;
309
+ }
310
+ try {
311
+ if (!isAbandoned(lock))
312
+ return null;
313
+ const judged = readOwner(lock);
314
+ const aside = `${lock}.stale-${token}`;
315
+ fs.renameSync(lock, aside);
316
+ if (readOwner(aside) !== judged) {
317
+ // Not the claim judged stale: its build released it and a new one took
318
+ // it in between. Hand it back and wait on it.
319
+ try {
320
+ fs.renameSync(aside, lock);
321
+ }
322
+ catch {
323
+ fs.rmSync(aside, { recursive: true, force: true });
324
+ }
325
+ return null;
237
326
  }
327
+ fs.rmSync(aside, { recursive: true, force: true });
328
+ return take();
329
+ }
330
+ finally {
331
+ fs.rmSync(steal, { recursive: true, force: true });
332
+ }
333
+ }
334
+ function readOwner(dir) {
335
+ try {
336
+ return fs.readFileSync(path.join(dir, "owner"), "utf8");
337
+ }
338
+ catch {
339
+ return null;
340
+ }
341
+ }
342
+ /** Is the claim on `root` still the one `token` took? */
343
+ function ownsBuild(root, token) {
344
+ try {
345
+ return fs.readFileSync(path.join(root, ".building", "owner"), "utf8") === token;
346
+ }
347
+ catch {
238
348
  return false;
239
349
  }
240
350
  }
241
- /** Wait for another process's build, up to the point where it is abandoned. */
242
- function awaitReady(ready) {
351
+ /** Say the build is alive: the claim's mtime is the clock claimBuild reads.
352
+ * Synchronous installers block the event loop, so this cannot be a timer —
353
+ * it is called before and after every installer run instead. */
354
+ function heartbeat(root, token) {
355
+ if (ownsBuild(root, token))
356
+ touch(path.join(root, ".building"));
357
+ }
358
+ /** Release the claim — only if it is still ours. */
359
+ export function releaseBuild(root, token) {
360
+ if (ownsBuild(root, token))
361
+ fs.rmSync(path.join(root, ".building"), { recursive: true, force: true });
362
+ }
363
+ /** How long a failed shared build is news to the steps that waited on it.
364
+ * Older than this it describes an earlier attempt, and a new one is due. */
365
+ const FAILED_TTL_MS = 60_000;
366
+ /** Wait for another process's build, up to the point where it is abandoned.
367
+ * Waits for the claim to be released, not merely for `.ready` to exist: a
368
+ * stale entry being rebuilt still has its old marker for a moment, and
369
+ * reading that as "done" would wire the half-rebuilt directory up. A build
370
+ * that failed says so in `.failed`, and the wait ends there — it used to
371
+ * spin the full six minutes on a lock that was already gone. */
372
+ function awaitReady(root) {
243
373
  const until = Date.now() + buildTimeoutMs();
244
374
  while (Date.now() < until) {
245
- if (fs.existsSync(ready))
246
- return true;
375
+ if (!fs.existsSync(path.join(root, ".building"))) {
376
+ if (fs.existsSync(path.join(root, ".ready")))
377
+ return { ready: true };
378
+ const failed = readFailed(root);
379
+ if (failed !== null)
380
+ return { ready: false, failed };
381
+ }
247
382
  sleepSync(250);
248
383
  }
249
- return false;
384
+ return { ready: false, failed: null };
385
+ }
386
+ /** The error a recent failed build of `root` left, or null. */
387
+ function readFailed(root) {
388
+ const file = path.join(root, ".failed");
389
+ try {
390
+ if (Date.now() - fs.statSync(file).mtimeMs > FAILED_TTL_MS)
391
+ return null;
392
+ return JSON.parse(fs.readFileSync(file, "utf8")).error ?? "the build failed";
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /** A requirement's distribution name: `requests[socks]>=2` → `requests`. */
399
+ export function distName(req) {
400
+ return req.split(/[[<>=!~]/)[0].trim();
401
+ }
402
+ /** An npm requirement's package name: `@scope/pkg@^1` → `@scope/pkg`. */
403
+ export function npmName(req) {
404
+ const m = /^(@[^/]+\/)?[^@]+/.exec(req);
405
+ return m ? m[0] : req;
406
+ }
407
+ // Asks the venv's own interpreter which declared distributions it can find.
408
+ // importlib.metadata, not `import x`: the distribution and the module are
409
+ // often named differently (beautifulsoup4 is bs4, pillow is PIL), and the
410
+ // declaration names distributions.
411
+ const PY_CHECK = [
412
+ "import sys, importlib.metadata as m",
413
+ "miss = []",
414
+ "for n in sys.argv[1:]:",
415
+ " try: m.version(n)",
416
+ " except m.PackageNotFoundError: miss.append(n)",
417
+ "print(','.join(miss))",
418
+ "sys.exit(3 if miss else 0)",
419
+ ].join("\n");
420
+ /**
421
+ * Does a `.ready` entry still hold what the declaration asks for? Null when it
422
+ * does, else why not.
423
+ *
424
+ * `.ready` is written once and trusted for ever, and that trust was misplaced
425
+ * more than once: a build interrupted between install and marker, a cache
426
+ * volume restored without its contents, and — the quiet one — an image whose
427
+ * python moved (bookworm's 3.11 to trixie's 3.13), which leaves every venv
428
+ * with a valid-looking bin/python pointing at an interpreter that is gone.
429
+ * Each reported "cached" and failed later inside somebody's tool. One spawn
430
+ * of the venv's python per step is the price of never doing that again.
431
+ */
432
+ export function checkEntry(root, spec) {
433
+ const venvPython = path.join(root, "venv", "bin", "python");
434
+ if (wantsPython(spec)) {
435
+ // existsSync follows the link, so a bin/python pointing at an interpreter
436
+ // an image upgrade removed reads as missing here, which is the truth.
437
+ if (!fs.existsSync(venvPython)) {
438
+ return fs.lstatSync(venvPython, { throwIfNoEntry: false })
439
+ ? "the venv's python no longer runs (the image's python has changed)"
440
+ : "the python venv is missing";
441
+ }
442
+ const probe = () => spawnSync(venvPython, ["-c", PY_CHECK, ...spec.packages.map(distName)], {
443
+ encoding: "utf8",
444
+ timeout: checkTimeoutMs(),
445
+ });
446
+ // A check that ran out of time says nothing about the venv — only that
447
+ // the host is busy (a cold disk, twenty steps importing at once). Read as
448
+ // broken, it deleted a venv other steps were running in and rebuilt it.
449
+ // Asked once more; still no answer, and the entry is used as it stands.
450
+ const timedOut = (r) => r.error?.code === "ETIMEDOUT";
451
+ let res = probe();
452
+ if (timedOut(res))
453
+ res = probe();
454
+ if (timedOut(res))
455
+ return null;
456
+ if (res.status === 3)
457
+ return `the venv is missing ${String(res.stdout).trim().split(",").join(", ")}`;
458
+ if (res.status !== 0)
459
+ return "the venv's python no longer runs (the image's python has changed)";
460
+ }
461
+ for (const req of spec.npm) {
462
+ if (!fs.existsSync(path.join(root, "node_modules", npmName(req), "package.json"))) {
463
+ return `node_modules is missing ${npmName(req)}`;
464
+ }
465
+ }
466
+ return null;
467
+ }
468
+ /** How long the venv's python gets to answer checkEntry. Env-overridable
469
+ * for the same reason as the build timeout: tests cannot wait a minute. */
470
+ function checkTimeoutMs() {
471
+ const raw = Number(process.env.FOLDRUN_RUNTIME_CHECK_TIMEOUT_MS);
472
+ return Number.isFinite(raw) && raw > 0 ? raw : 60_000;
473
+ }
474
+ /** Days an entry may go unused before a later build prunes it. */
475
+ function maxAgeDays() {
476
+ const raw = Number(process.env.FOLDRUN_RUNTIME_MAX_AGE_DAYS);
477
+ return Number.isFinite(raw) && raw > 0 ? raw : 30;
478
+ }
479
+ /**
480
+ * Remove entries nobody has used for maxAgeDays. `.ready`'s mtime is the
481
+ * last-used clock (every hit touches it); an entry with no `.ready` is a
482
+ * failed build and ages from its directory's own mtime. Never an entry that
483
+ * is being built, and never `keep`. Runs after a successful build — the only
484
+ * moment a cache grows — so it costs nothing on the hot path.
485
+ */
486
+ export function pruneRuntimes(cacheRoot, keep) {
487
+ const cutoff = Date.now() - maxAgeDays() * 86_400_000;
488
+ const removed = [];
489
+ let names = [];
490
+ try {
491
+ names = fs.readdirSync(cacheRoot);
492
+ }
493
+ catch {
494
+ return removed;
495
+ }
496
+ for (const name of names) {
497
+ // .uv-cache and .python are shared by every entry; never an entry.
498
+ if (name.startsWith(".") || name === keep)
499
+ continue;
500
+ const dir = path.join(cacheRoot, name);
501
+ try {
502
+ if (fs.existsSync(path.join(dir, ".building")))
503
+ continue;
504
+ const ready = path.join(dir, ".ready");
505
+ const clock = fs.existsSync(ready) ? fs.statSync(ready).mtimeMs : fs.statSync(dir).mtimeMs;
506
+ if (clock < cutoff) {
507
+ fs.rmSync(dir, { recursive: true, force: true });
508
+ removed.push(name);
509
+ }
510
+ }
511
+ catch {
512
+ // Another process removed or claimed it meanwhile; its business.
513
+ }
514
+ }
515
+ return removed;
516
+ }
517
+ /** Is `uv` on this host? Asked once per process. FOLDRUN_UV=0 forces pip. */
518
+ let uvKnown;
519
+ function findUv() {
520
+ if (process.env.FOLDRUN_UV === "0")
521
+ return null;
522
+ if (uvKnown === undefined)
523
+ uvKnown = run("uv", ["--version"], os.tmpdir(), 30_000).ok ? "uv" : null;
524
+ return uvKnown;
250
525
  }
251
526
  // Build (or reuse) the environment for one runtime declaration.
252
527
  export function prepareRuntime(tenant, spec) {
253
528
  if (!spec)
254
529
  return EMPTY;
255
530
  const fp = fingerprint(spec);
256
- const shared = path.join(dataRoot(), tenant, ".runtimes", fp);
531
+ const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
532
+ const shared = path.join(cacheRoot, fp);
257
533
  // Loud, and before anything else: a dropped requirement surfaces later as
258
534
  // an import error inside a script, which points at the wrong thing entirely.
259
- const dropped = spec.rejected?.length
260
- ? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`]
261
- : [];
262
- // Already built: wire it up and skip the install. The whole point of the
263
- // cache — on the hosted path this directory is a mounted volume, so the
264
- // hit rate across a run is close to one.
535
+ const dropped = [
536
+ ...(spec.rejected?.length ? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`] : []),
537
+ ...(spec.ignored?.length
538
+ ? [
539
+ `runtime ${fp}: requirements.txt option(s) not applied: ${spec.ignored.join(", ")} — the requirements are installed${spec.ignored.includes("--hash") ? " without hash checking" : ""}`,
540
+ ]
541
+ : []),
542
+ ];
543
+ // The platform's shared layer first: environments built once, by the
544
+ // platform, for every account that declares exactly this — mounted
545
+ // read-only into the run (FOLDRUN_RUNTIME_SHARED, run-k8s.ts). Only a
546
+ // healthy entry is used; anything else falls through to the account's own
547
+ // cache, which is writable and builds what the shared layer lacks.
548
+ const sharedLayer = process.env.FOLDRUN_RUNTIME_SHARED;
549
+ if (sharedLayer) {
550
+ const entry = path.join(sharedLayer, fp);
551
+ if (fs.existsSync(path.join(entry, ".ready")) && !checkEntry(entry, spec)) {
552
+ const hit = wire(entry, spec, `runtime ${fp}: shared`);
553
+ return { ...hit, log: [...dropped, ...hit.log] };
554
+ }
555
+ }
556
+ // Already built, and still holding what it promises: wire it up and skip
557
+ // the install. On the hosted path this directory is a mounted volume, so
558
+ // the hit rate across a run is close to one. An entry that fails the check
559
+ // is rebuilt, not trusted and not merely reported.
560
+ let stale = null;
265
561
  if (fs.existsSync(path.join(shared, ".ready"))) {
266
- const hit = wire(shared, spec, `runtime ${fp}: cached`);
267
- return { ...hit, log: [...dropped, ...hit.log] };
562
+ stale = checkEntry(shared, spec);
563
+ if (!stale) {
564
+ touch(path.join(shared, ".ready"));
565
+ const hit = wire(shared, spec, `runtime ${fp}: cached`);
566
+ return { ...hit, log: [...dropped, ...hit.log] };
567
+ }
268
568
  }
269
569
  fs.mkdirSync(shared, { recursive: true });
270
570
  let root = shared;
@@ -272,32 +572,88 @@ export function prepareRuntime(tenant, spec) {
272
572
  if (!held) {
273
573
  // A concurrent step is building exactly this. Waiting for it beats
274
574
  // duplicating it — the work is identical and it is already underway.
275
- if (awaitReady(path.join(shared, ".ready"))) {
575
+ const waited = awaitReady(shared);
576
+ if (waited.ready && !checkEntry(shared, spec)) {
276
577
  const hit = wire(shared, spec, `runtime ${fp}: cached (built by a concurrent step)`);
277
578
  return { ...hit, log: [...dropped, ...hit.log] };
278
579
  }
580
+ // It failed, and just now: the same declaration would fail the same way
581
+ // here, so say what it said rather than spend another five minutes on it.
582
+ if (!waited.ready && waited.failed !== null) {
583
+ return { ...EMPTY, log: dropped, error: `runtime ${fp}: a concurrent step's build of this runtime failed — ${waited.failed}` };
584
+ }
279
585
  // It never finished. Build privately instead: slower and uncached, but a
280
586
  // step that runs is worth more than a cache entry, and a wedged lock must
281
587
  // never be able to stop work. In the tmpdir, not the cache — a private
282
588
  // build is by definition not worth keeping, and leaving these beside the
283
- // real entries would grow a directory nothing ever prunes.
589
+ // real entries would grow a directory nothing ever prunes. Removed by
590
+ // dispose() when the step is done with it.
284
591
  root = fs.mkdtempSync(path.join(os.tmpdir(), `foldrun-runtime-${fp}-`));
285
592
  }
593
+ else {
594
+ fs.rmSync(path.join(shared, ".failed"), { force: true });
595
+ if (stale || !fs.existsSync(path.join(shared, ".ready"))) {
596
+ // A clean slate: whatever a stale or half-finished build left behind is
597
+ // exactly what must not be built on top of.
598
+ for (const leftover of [".ready", "venv", "node_modules", "package.json", "package-lock.json"]) {
599
+ fs.rmSync(path.join(shared, leftover), { recursive: true, force: true });
600
+ }
601
+ }
602
+ }
603
+ const privateRoot = root === shared ? null : root;
604
+ const disposePrivate = () => {
605
+ if (privateRoot)
606
+ fs.rmSync(privateRoot, { recursive: true, force: true });
607
+ };
608
+ // Every installer run goes through here, so the claim is beaten on before
609
+ // and after each one — no run can go quieter than its own timeout.
610
+ const install = (...args) => {
611
+ if (held)
612
+ heartbeat(shared, held);
613
+ try {
614
+ return run(...args);
615
+ }
616
+ finally {
617
+ if (held)
618
+ heartbeat(shared, held);
619
+ }
620
+ };
286
621
  const ready = path.join(root, ".ready");
287
622
  const interpreters = {};
288
623
  const env = {};
289
624
  const log = [...dropped];
625
+ if (stale)
626
+ log.push(`runtime ${fp}: cached entry unusable (${stale}); rebuilding`);
290
627
  const venvPython = path.join(root, "venv", "bin", "python");
291
628
  const nodeModules = path.join(root, "node_modules");
292
629
  const wantsPy = wantsPython(spec);
293
630
  const wantsNd = wantsNode(spec);
631
+ const built = { built: new Date().toISOString() };
632
+ let succeeded = false;
633
+ let failure = "the build failed";
634
+ const fail = (error) => {
635
+ failure = error;
636
+ return { ...EMPTY, log, error };
637
+ };
294
638
  try {
295
639
  if (wantsPy) {
296
- const base = typeof spec.python === "string" ? `python${spec.python}` : "python3";
297
- const exe = [base, "python3"].find((c) => run("command", ["-v", c], root).ok || run(c, ["--version"], root).ok);
298
- if (!exe) {
299
- return { ...EMPTY, error: `python interpreter "${base}" is not available on this host` };
300
- }
640
+ const uv = findUv();
641
+ const pin = typeof spec.python === "string" ? spec.python : null;
642
+ // uv's own caches sit beside the entries they fill, so they live as long
643
+ // as the cache volume does: a second environment asking for pandas links
644
+ // it out of .uv-cache instead of downloading it again, and a pinned
645
+ // python fetched once into .python serves every later venv. Copy, not
646
+ // hardlink — the volume may be NFS or EFS, where links across the cache
647
+ // and the entry are not guaranteed.
648
+ const uvEnv = {
649
+ UV_CACHE_DIR: path.join(cacheRoot, ".uv-cache"),
650
+ // Inside the entry when the platform builds for the shared layer: that
651
+ // build may write only its own entry, and a venv whose interpreter
652
+ // lived beside it would point at nothing once mounted elsewhere.
653
+ UV_PYTHON_INSTALL_DIR: process.env.FOLDRUN_RUNTIME_PYTHON_IN_ENTRY === "1" ? path.join(root, ".python") : path.join(cacheRoot, ".python"),
654
+ UV_LINK_MODE: "copy",
655
+ UV_NO_PROGRESS: "1",
656
+ };
301
657
  // A sealed venv, never --system-site-packages. Inheriting the image's
302
658
  // packages would make a warm start cheaper, and it was measured on the
303
659
  // production box on 2026-08-29: a venv that shadows a baked `pandas` with
@@ -305,20 +661,45 @@ export function prepareRuntime(tenant, spec) {
305
661
  // ABI-compatible — `pandas<2` on top of numpy 2 dies at import with
306
662
  // "numpy.dtype size changed". A pin that silently produces a broken
307
663
  // interpreter is worse than any install it saves.
308
- const made = run(exe, ["-m", "venv", path.join(root, "venv")], root);
309
- if (!made.ok)
310
- return { ...EMPTY, error: `failed to create venv: ${made.out.slice(0, 300)}` };
311
- log.push(`runtime ${fp}: created venv (${exe})`);
664
+ if (uv) {
665
+ const want = pin ?? (hasCommand("python3", root) ? "python3" : "3");
666
+ // --seed puts pip in the venv as well, for the agent that types
667
+ // `pip install` in Bash; uv itself does not need it.
668
+ const made = install(uv, ["venv", "--seed", "-q", "--python", want, path.join(root, "venv")], root, 300_000, uvEnv);
669
+ if (!made.ok) {
670
+ return fail(pin
671
+ ? `python ${pin} could not be provided (uv tried the image and a download): ${made.out.slice(-400)}`
672
+ : `failed to create venv: ${made.out.slice(-400)}`);
673
+ }
674
+ }
675
+ else {
676
+ // No uv: the image's own interpreter or nothing. A pin the image cannot
677
+ // meet is an error, said as one — it used to fall back to python3 in
678
+ // silence, and "I asked for 3.12" then ran on 3.11.
679
+ const exe = pin ? (hasCommand(`python${pin}`, root) ? `python${pin}` : null) : hasCommand("python3", root) ? "python3" : null;
680
+ if (!exe) {
681
+ return fail(pin
682
+ ? `python ${pin} is not installed here, and uv (which could fetch it) is not available`
683
+ : `no python3 on this host, and uv (which could fetch one) is not available`);
684
+ }
685
+ const made = install(exe, ["-m", "venv", path.join(root, "venv")], root);
686
+ if (!made.ok)
687
+ return fail(`failed to create venv: ${made.out.slice(0, 300)}`);
688
+ }
689
+ const version = install(venvPython, ["-c", "import platform; print(platform.python_version())"], root, 30_000);
690
+ built.python = version.ok ? version.out : "unknown";
691
+ built.installer = uv ? "uv" : "pip";
692
+ log.push(`runtime ${fp}: created venv (python ${built.python}${uv ? " via uv" : ""})`);
312
693
  if (spec.packages.length) {
313
- const pip = path.join(root, "venv", "bin", "pip");
314
- const installed = run(pip, ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
694
+ const installed = uv
695
+ ? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
696
+ : install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
315
697
  if (!installed.ok) {
316
- return { ...EMPTY, error: `pip install failed: ${installed.out.slice(-500)}` };
698
+ return fail(`${uv ? "uv pip" : "pip"} install failed: ${installed.out.slice(-500)}`);
317
699
  }
700
+ built.packages = spec.packages;
318
701
  log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
319
702
  }
320
- interpreters[".py"] = venvPython;
321
- env.VIRTUAL_ENV = path.join(root, "venv");
322
703
  }
323
704
  if (wantsNd) {
324
705
  if (spec.npm.length) {
@@ -326,7 +707,7 @@ export function prepareRuntime(tenant, spec) {
326
707
  // Not --silent: it sets npm's loglevel to silent, which suppresses the
327
708
  // explanation along with the noise. A build log nobody reads is cheaper
328
709
  // than a failure nobody can explain.
329
- const installed = run("npm", ["install", "--no-fund", "--no-audit", "--no-progress", ...spec.npm], root, 300_000, {
710
+ const installed = install("npm", ["install", "--no-fund", "--no-audit", "--no-progress", ...spec.npm], root, 300_000, {
330
711
  // npm keeps its cache under $HOME/.npm. A sandbox runs as a user
331
712
  // with no home directory, so that resolves to /.npm, which it may
332
713
  // not create — and npm fails with ENOENT before it fetches
@@ -337,21 +718,75 @@ export function prepareRuntime(tenant, spec) {
337
718
  npm_config_cache: path.join(root, ".npm-cache"),
338
719
  });
339
720
  if (!installed.ok) {
340
- return { ...EMPTY, error: `npm install failed: ${installed.out.slice(-500)}` };
721
+ return fail(`npm install failed: ${installed.out.slice(-500)}`);
341
722
  }
723
+ built.npm = spec.npm;
342
724
  log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
343
725
  }
344
- if (fs.existsSync(nodeModules))
345
- env.NODE_PATH = nodeModules;
346
726
  }
347
- fs.writeFileSync(ready, new Date().toISOString());
348
- return { interpreters, env, log, error: null };
727
+ // Checked before it is marked ready, by the same test every later hit will
728
+ // apply: an installer that exits 0 without installing what was asked (it
729
+ // has happened, with npm and a cache it could not write) is caught here,
730
+ // where the log still has the build beside it.
731
+ if (held)
732
+ heartbeat(shared, held);
733
+ const problem = checkEntry(root, spec);
734
+ if (problem)
735
+ return fail(`runtime ${fp}: built but ${problem}`);
736
+ // A build that lost its claim — it went quiet past the timeout and another
737
+ // step took the entry over — must not mark ready what the new holder may
738
+ // be clearing out beneath it — nor use it, for the same reason.
739
+ if (held && !ownsBuild(shared, held)) {
740
+ 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`);
741
+ }
742
+ fs.writeFileSync(ready, JSON.stringify(built));
743
+ const wired = wire(root, spec, "");
744
+ Object.assign(interpreters, wired.interpreters);
745
+ Object.assign(env, wired.env);
746
+ if (held) {
747
+ const pruned = pruneRuntimes(cacheRoot, fp);
748
+ if (pruned.length)
749
+ log.push(`runtime: pruned ${pruned.length} entr${pruned.length === 1 ? "y" : "ies"} unused for ${maxAgeDays()} days`);
750
+ }
751
+ succeeded = true;
752
+ return { interpreters, env, log, error: null, ...(privateRoot ? { dispose: disposePrivate } : {}) };
753
+ }
754
+ catch (err) {
755
+ failure = err instanceof Error ? err.message : String(err);
756
+ throw err;
349
757
  }
350
758
  finally {
351
- // Whatever happened — built, failed, threw — the claim is released. A
352
- // failed build leaves no `.ready`, so the next step retries it rather
353
- // than inheriting a half-built environment.
354
- if (held)
355
- fs.rmSync(path.join(shared, ".building"), { recursive: true, force: true });
759
+ // Whatever happened — built, failed, threw — the claim is released, and
760
+ // only if it is still ours. A failed build leaves no `.ready`, so the
761
+ // next step retries it rather than inheriting a half-built environment;
762
+ // it leaves `.failed`, so the steps waiting on it stop waiting now. A
763
+ // failed private build is removed here: nothing will ever use it.
764
+ if (held) {
765
+ if (!succeeded && ownsBuild(shared, held)) {
766
+ try {
767
+ fs.writeFileSync(path.join(shared, ".failed"), JSON.stringify({ error: failure, at: new Date().toISOString() }));
768
+ }
769
+ catch {
770
+ // best effort — a waiter without it waits the timeout, as before
771
+ }
772
+ }
773
+ releaseBuild(shared, held);
774
+ }
775
+ if (!succeeded)
776
+ disposePrivate();
777
+ }
778
+ }
779
+ function hasCommand(cmd, cwd) {
780
+ return run(cmd, ["--version"], cwd, 30_000).ok;
781
+ }
782
+ /** Mark an entry used now; the clock pruneRuntimes reads. Best effort — a
783
+ * read-only cache mount is still a perfectly good cache. */
784
+ function touch(file) {
785
+ try {
786
+ const now = new Date();
787
+ fs.utimesSync(file, now, now);
788
+ }
789
+ catch {
790
+ // read-only or gone; neither is a reason to fail the step
356
791
  }
357
792
  }