@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.
- package/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- 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
|
|
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
|
|
13
|
-
// gets
|
|
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
|
-
|
|
83
|
-
|
|
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))
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
const token = crypto.randomUUID();
|
|
289
|
+
const take = () => {
|
|
254
290
|
fs.mkdirSync(lock);
|
|
255
|
-
|
|
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 (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
274
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
353
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
400
|
-
// failed build leaves no `.ready`, so the
|
|
401
|
-
// than inheriting a half-built environment
|
|
402
|
-
|
|
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
|
}
|