@foldrun/core 0.4.0 → 0.5.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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 +129 -0
- package/dist/src/browser-pod.js +284 -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/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 +6 -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/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 +245 -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 +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- 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 +61 -2
- package/dist/src/step-exec.js +414 -36
- 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 +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- 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 +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -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/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -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 +316 -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 +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- 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 +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- 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/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/dist/src/runtime.js
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
|
//
|
|
@@ -66,8 +69,9 @@ export function parseRuntime(raw) {
|
|
|
66
69
|
};
|
|
67
70
|
if (rejected.length)
|
|
68
71
|
spec.rejected = rejected;
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
const token = crypto.randomUUID();
|
|
250
|
+
const take = () => {
|
|
221
251
|
fs.mkdirSync(lock);
|
|
222
|
-
|
|
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 (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
242
|
-
|
|
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(
|
|
246
|
-
|
|
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
|
|
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 =
|
|
260
|
-
? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`]
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
314
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
352
|
-
// failed build leaves no `.ready`, so the
|
|
353
|
-
// than inheriting a half-built environment
|
|
354
|
-
|
|
355
|
-
|
|
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
|
}
|