@foldrun/core 1.4.1 → 2.0.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 +0 -3
- package/dist/index.js +0 -3
- package/dist/src/deploy.d.ts +0 -26
- package/dist/src/deploy.js +10 -53
- package/dist/src/history.d.ts +30 -10
- package/dist/src/history.js +232 -99
- package/dist/src/library.d.ts +0 -13
- package/dist/src/library.js +0 -50
- package/dist/src/platform.d.ts +1 -3
- package/dist/src/platform.js +0 -1
- package/dist/src/run-container.d.ts +29 -0
- package/dist/src/run-container.js +74 -2
- package/dist/src/runner.js +10 -2
- package/dist/src/secrets.js +1 -12
- package/dist/src/starter.js +2 -2
- package/dist/src/store.d.ts +15 -10
- package/dist/src/store.js +45 -21
- package/index.ts +0 -3
- package/package.json +1 -3
- package/src/deploy.ts +5 -71
- package/src/history.ts +230 -108
- package/src/library.ts +0 -44
- package/src/platform.ts +1 -4
- package/src/run-container.ts +78 -2
- package/src/runner.ts +10 -1
- package/src/secrets.ts +1 -11
- package/src/starter.ts +2 -2
- package/src/store.ts +55 -27
- package/dist/src/git-hook.d.ts +0 -1
- package/dist/src/git-hook.js +0 -70
- package/dist/src/git.d.ts +0 -37
- package/dist/src/git.js +0 -101
- package/dist/src/gitrepo.d.ts +0 -113
- package/dist/src/gitrepo.js +0 -404
- package/dist/src/tar.d.ts +0 -30
- package/dist/src/tar.js +0 -112
- package/src/git-hook.ts +0 -73
- package/src/git.ts +0 -117
- package/src/gitrepo.ts +0 -439
- package/src/tar.ts +0 -125
package/dist/src/library.js
CHANGED
|
@@ -344,53 +344,3 @@ registerTreeReader((tenant, scope) => {
|
|
|
344
344
|
}
|
|
345
345
|
return out;
|
|
346
346
|
});
|
|
347
|
-
/**
|
|
348
|
-
* Make the shelf match a tree — what a push to the library repository does.
|
|
349
|
-
* Written with plain fs and recorded as ONE revision at the end: going
|
|
350
|
-
* through writeLibraryFile per file would commit each one on top of the
|
|
351
|
-
* pushed commit, and the history would show a push as fifty edits.
|
|
352
|
-
*/
|
|
353
|
-
export function syncLibraryFromTree(tenant, files, meta = {}) {
|
|
354
|
-
const wanted = new Map();
|
|
355
|
-
for (const f of files) {
|
|
356
|
-
const [kind, ...rest] = f.path.split("/");
|
|
357
|
-
if (!LIBRARY_KINDS.includes(kind) || rest.length === 0)
|
|
358
|
-
continue;
|
|
359
|
-
wanted.set(f.path, f.content);
|
|
360
|
-
}
|
|
361
|
-
const changes = [];
|
|
362
|
-
for (const kind of LIBRARY_KINDS) {
|
|
363
|
-
const dir = libraryDir(tenant, kind);
|
|
364
|
-
if (!fs.existsSync(dir))
|
|
365
|
-
continue;
|
|
366
|
-
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
367
|
-
const rel = `${kind}/${String(entry).split(path.sep).join("/")}`;
|
|
368
|
-
const full = path.join(dir, String(entry));
|
|
369
|
-
try {
|
|
370
|
-
if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel))
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
catch {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
if (!wanted.has(rel)) {
|
|
377
|
-
changes.push({ path: rel, before: fs.readFileSync(full, "utf8"), after: null });
|
|
378
|
-
fs.rmSync(full, { force: true });
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
for (const [rel, content] of wanted) {
|
|
383
|
-
const [kind, ...rest] = rel.split("/");
|
|
384
|
-
const full = path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rest.join("/")));
|
|
385
|
-
const before = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
|
|
386
|
-
if (before === content)
|
|
387
|
-
continue;
|
|
388
|
-
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
389
|
-
fs.writeFileSync(full, content);
|
|
390
|
-
if (kind === "scripts" || /scripts\//.test(rel) || (kind === "tools" && !rel.endsWith(".md")))
|
|
391
|
-
fs.chmodSync(full, 0o755);
|
|
392
|
-
changes.push({ path: rel, before, after: content });
|
|
393
|
-
}
|
|
394
|
-
recordRevision(tenant, "@library", changes, { by: meta.by ?? "deploy", message: meta.commit ? `deployed ${meta.commit.slice(0, 7)}` : "synced from push", commit: meta.commit ?? null });
|
|
395
|
-
return changes.length;
|
|
396
|
-
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -90,8 +90,6 @@ export interface PlatformHooks {
|
|
|
90
90
|
syncPublicShares(tenant: string, workspace: string): Promise<{
|
|
91
91
|
added: string[];
|
|
92
92
|
}>;
|
|
93
|
-
/** The workspace a preview was branched from, for inherited secrets. Default: none. */
|
|
94
|
-
previewSourceOf(tenant: string, workspace: string): string | null;
|
|
95
93
|
/** This account's own data key, or null to use the install key. */
|
|
96
94
|
tenantKey(tenant: string): Buffer | null;
|
|
97
95
|
/** The egress proxy — a per-step lease that keeps credentials out of the
|
|
@@ -101,7 +99,7 @@ export interface PlatformHooks {
|
|
|
101
99
|
* workspace deleted. What a scheduler that keeps flows in memory rather
|
|
102
100
|
* than re-reading every workspace each tick needs to hear. Default:
|
|
103
101
|
* nothing — locally the scheduler reads the disk. Never throws. */
|
|
104
|
-
workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "
|
|
102
|
+
workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "delete"): void;
|
|
105
103
|
/** Can a step in this isolation mode be re-attached to after the driver
|
|
106
104
|
* that started it is gone? True means an orphaned step with a `sandbox`
|
|
107
105
|
* on its record is resumed rather than destroyed and run again. Default:
|
package/dist/src/platform.js
CHANGED
|
@@ -97,7 +97,36 @@ export interface StepTiming {
|
|
|
97
97
|
sandboxMs: number;
|
|
98
98
|
firstOutputMs: number | null;
|
|
99
99
|
totalMs: number;
|
|
100
|
+
/** Where the start-up went, phase by phase — see STARTUP_PHASES. Absent
|
|
101
|
+
* from an executor that marks none, and from a resumed driver (the
|
|
102
|
+
* first driver's phases are on the record already). */
|
|
103
|
+
phases?: StartupPhases;
|
|
100
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The start-up of one step, in the order it happens. Each is the moment the
|
|
107
|
+
* phase ENDED, in ms since the executor began the step, so the gap between
|
|
108
|
+
* two neighbours is what the later one cost (FOL-29: "pod ready" was the
|
|
109
|
+
* only number, and the six seconds after it had no names).
|
|
110
|
+
*
|
|
111
|
+
* The executor marks its own: `services` (the account's search/browser pods
|
|
112
|
+
* up), `room` (room on the cluster to start — platform pod-room.ts),
|
|
113
|
+
* `applied` (pod created), `ready`, `files` (the workspace copied in),
|
|
114
|
+
* `go` (the step released). The sandbox marks the rest as `{"e":"mark"}`
|
|
115
|
+
* lines: `shim` (the shim saw go), `system` (system packages restored),
|
|
116
|
+
* `driver` (node up), `imports` (core loaded), `tools` (runtime and tools
|
|
117
|
+
* built, the SDK about to start) and `model` (the model's first text or tool
|
|
118
|
+
* call). A worker that predates a mark ignores the line — parseDriverLine
|
|
119
|
+
* answers null for an `e` it does not know.
|
|
120
|
+
*/
|
|
121
|
+
export declare const STARTUP_PHASES: readonly ["services", "room", "applied", "ready", "files", "go", "shim", "system", "driver", "imports", "tools", "model"];
|
|
122
|
+
export type StartupPhase = (typeof STARTUP_PHASES)[number];
|
|
123
|
+
export type StartupPhases = Partial<Record<StartupPhase, number>>;
|
|
124
|
+
/** A sandbox-side mark: which phase ended, and when (epoch ms, the
|
|
125
|
+
* sandbox's clock — the same machine's as the worker's on one node). */
|
|
126
|
+
export declare function parseDriverMark(line: string): {
|
|
127
|
+
name: StartupPhase;
|
|
128
|
+
at: number;
|
|
129
|
+
} | null;
|
|
101
130
|
export interface StepActuals {
|
|
102
131
|
busyCpuSecs: number | null;
|
|
103
132
|
peakMemBytes: number | null;
|
|
@@ -30,6 +30,42 @@ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKE
|
|
|
30
30
|
import { egressCaSh } from "./network.js";
|
|
31
31
|
import { isOperatorEvent } from "./operator.js";
|
|
32
32
|
import { parseBrowserPod } from "./browser-pod.js";
|
|
33
|
+
/**
|
|
34
|
+
* The start-up of one step, in the order it happens. Each is the moment the
|
|
35
|
+
* phase ENDED, in ms since the executor began the step, so the gap between
|
|
36
|
+
* two neighbours is what the later one cost (FOL-29: "pod ready" was the
|
|
37
|
+
* only number, and the six seconds after it had no names).
|
|
38
|
+
*
|
|
39
|
+
* The executor marks its own: `services` (the account's search/browser pods
|
|
40
|
+
* up), `room` (room on the cluster to start — platform pod-room.ts),
|
|
41
|
+
* `applied` (pod created), `ready`, `files` (the workspace copied in),
|
|
42
|
+
* `go` (the step released). The sandbox marks the rest as `{"e":"mark"}`
|
|
43
|
+
* lines: `shim` (the shim saw go), `system` (system packages restored),
|
|
44
|
+
* `driver` (node up), `imports` (core loaded), `tools` (runtime and tools
|
|
45
|
+
* built, the SDK about to start) and `model` (the model's first text or tool
|
|
46
|
+
* call). A worker that predates a mark ignores the line — parseDriverLine
|
|
47
|
+
* answers null for an `e` it does not know.
|
|
48
|
+
*/
|
|
49
|
+
export const STARTUP_PHASES = [
|
|
50
|
+
"services", "room", "applied", "ready", "files", "go",
|
|
51
|
+
"shim", "system", "driver", "imports", "tools", "model",
|
|
52
|
+
];
|
|
53
|
+
/** A sandbox-side mark: which phase ended, and when (epoch ms, the
|
|
54
|
+
* sandbox's clock — the same machine's as the worker's on one node). */
|
|
55
|
+
export function parseDriverMark(line) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed.startsWith('{"e":"mark"'))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(trimmed);
|
|
61
|
+
if (parsed.e !== "mark" || typeof parsed.at !== "number" || !Number.isFinite(parsed.at))
|
|
62
|
+
return null;
|
|
63
|
+
return STARTUP_PHASES.includes(parsed.name) ? { name: parsed.name, at: parsed.at } : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
33
69
|
/**
|
|
34
70
|
* The one place the timing line is worded, so the docker and k8s traces
|
|
35
71
|
* read the same and a cold-start regression is greppable across both.
|
|
@@ -37,7 +73,11 @@ import { parseBrowserPod } from "./browser-pod.js";
|
|
|
37
73
|
export function timingLine(t) {
|
|
38
74
|
const s = (ms) => `${(ms / 1000).toFixed(2)}s`;
|
|
39
75
|
const first = t.firstOutputMs === null ? "no output" : `first output +${s(t.firstOutputMs)}`;
|
|
40
|
-
|
|
76
|
+
const phases = t.phases
|
|
77
|
+
? STARTUP_PHASES.filter((p) => typeof t.phases[p] === "number").map((p) => `${p} ${s(t.phases[p])}`)
|
|
78
|
+
: [];
|
|
79
|
+
return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total` +
|
|
80
|
+
(phases.length ? ` · start-up: ${phases.join(" → ")}` : "");
|
|
41
81
|
}
|
|
42
82
|
const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
|
|
43
83
|
// Paths that must never come back from a container, whatever happened in
|
|
@@ -59,6 +99,10 @@ const DENY_BACK_GIT_RE = /(^|\/)\.git(\/|$)/;
|
|
|
59
99
|
const DENY_BACK_SECRET_FILES = /(^|\/)\.secret-files\//;
|
|
60
100
|
// And per-agent knowledge, at any depth under agents/<name>/.
|
|
61
101
|
const DENY_BACK_RE = /^agents\/[^/]+\/knowledge\//;
|
|
102
|
+
// A run's caches are not workspace content either: HOME is the agent's own
|
|
103
|
+
// folder, so a tool that ignores XDG_CACHE_HOME (set by the driver) still
|
|
104
|
+
// writes ~/.cache or ~/.npm there. Never copied back.
|
|
105
|
+
const DENY_BACK_CACHE = /^agents\/[^/]+\/\.(?:cache|npm)(?:\/|$)/;
|
|
62
106
|
// A dependency tree is not workspace content. Nothing authored lives in
|
|
63
107
|
// node_modules, it is enormous, and a runtime linked into a tool's directory
|
|
64
108
|
// so ESM can resolve it would otherwise be copied back file by file.
|
|
@@ -80,6 +124,8 @@ export function allowedBack(rel) {
|
|
|
80
124
|
return false;
|
|
81
125
|
if (DENY_BACK_NODE_MODULES.test(norm))
|
|
82
126
|
return false;
|
|
127
|
+
if (DENY_BACK_CACHE.test(norm))
|
|
128
|
+
return false;
|
|
83
129
|
return true;
|
|
84
130
|
}
|
|
85
131
|
/**
|
|
@@ -368,7 +414,17 @@ const DRIVER = `// The runner container's entrypoint. Reads one step, executes i
|
|
|
368
414
|
// same core the server uses, speaks JSONL on stdout, exits.
|
|
369
415
|
import fs from "node:fs";
|
|
370
416
|
const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
|
|
371
|
-
|
|
417
|
+
// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,
|
|
418
|
+
// on the sandbox's clock. A worker that does not know a mark skips the line.
|
|
419
|
+
const mark = (name) => process.stdout.write(JSON.stringify({ e: "mark", name, at: Date.now() }) + "\\n");
|
|
420
|
+
mark("driver");
|
|
421
|
+
let modelSpoke = false;
|
|
422
|
+
const emit = (type, text, extra) => {
|
|
423
|
+
// The model's first text or tool call is where start-up ends and the
|
|
424
|
+
// step's own work begins — runtime and tool lines before it are set-up.
|
|
425
|
+
if (!modelSpoke && (type === "text" || type === "tool")) { modelSpoke = true; mark("model"); }
|
|
426
|
+
process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
|
|
427
|
+
};
|
|
372
428
|
// What this sandbox actually touched, read at the last moment from the
|
|
373
429
|
// kernel's own books: cgroup v2 for CPU busy-time and peak memory,
|
|
374
430
|
// /proc/net/dev for bytes on the wire. Every read is best-effort — gVisor
|
|
@@ -424,6 +480,7 @@ try {
|
|
|
424
480
|
const { prepareRuntime } = await import("@foldrun/core/runtime");
|
|
425
481
|
const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
|
|
426
482
|
const { paidWebActions } = await import("@foldrun/core/browser-pod");
|
|
483
|
+
mark("imports");
|
|
427
484
|
|
|
428
485
|
const agentDir = "/workspace/" + input.agentRel;
|
|
429
486
|
// HOME is where a bare relative path lands when anything resolves one
|
|
@@ -433,6 +490,11 @@ try {
|
|
|
433
490
|
// @file secrets crossed as content; materialise them to 0600 paths inside
|
|
434
491
|
// the container (a host path from the other side would not exist here).
|
|
435
492
|
const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;
|
|
493
|
+
// Caches go to the sandbox user's own home, not to HOME: the Claude CLI's
|
|
494
|
+
// logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under
|
|
495
|
+
// agents/<name>/.cache they were copied back after every step — 3,855 files
|
|
496
|
+
// across 179 agent folders on the box by 17 Oct 2026, read by nothing.
|
|
497
|
+
env.XDG_CACHE_HOME = "/home/agent/.cache";
|
|
436
498
|
|
|
437
499
|
// The declared runtime, built in here — pip and npm have the network, which
|
|
438
500
|
// the host side may not. It lands in .foldrun/runner/.runtimes, which the
|
|
@@ -501,6 +563,7 @@ try {
|
|
|
501
563
|
const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
|
|
502
564
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
503
565
|
|
|
566
|
+
mark("tools");
|
|
504
567
|
const outcome = await executeStep({
|
|
505
568
|
agentDir,
|
|
506
569
|
workspaceRoot: "/workspace",
|
|
@@ -1249,6 +1312,8 @@ export async function runStepInContainer(args) {
|
|
|
1249
1312
|
throw new Error(`container create failed:\n${create.stderr}`);
|
|
1250
1313
|
containerId = create.stdout.trim();
|
|
1251
1314
|
const sandboxMs = Date.now() - t0;
|
|
1315
|
+
// Docker's phases are measured from create, its rent clock.
|
|
1316
|
+
const phases = { ready: sandboxMs };
|
|
1252
1317
|
const cp = (from, to) => {
|
|
1253
1318
|
const out = spawnSync(cli(), ["cp", from, to], { encoding: "utf8" });
|
|
1254
1319
|
if (out.status !== 0)
|
|
@@ -1257,6 +1322,7 @@ export async function runStepInContainer(args) {
|
|
|
1257
1322
|
cp(`${wsIn}/.`, `${containerId}:/workspace`);
|
|
1258
1323
|
cp(`${libIn}/.`, `${containerId}:/library`);
|
|
1259
1324
|
cp(`${jobIn}/input.json`, `${containerId}:/opt/runner/job/input.json`);
|
|
1325
|
+
phases.files = Date.now() - t0;
|
|
1260
1326
|
// Start attached and read the driver's JSONL. The step timeout gets a
|
|
1261
1327
|
// backstop out here — a wedged container is killed, not waited on.
|
|
1262
1328
|
let firstOutputAt = null;
|
|
@@ -1281,6 +1347,11 @@ export async function runStepInContainer(args) {
|
|
|
1281
1347
|
break;
|
|
1282
1348
|
const line = buffer.slice(0, nl);
|
|
1283
1349
|
buffer = buffer.slice(nl + 1);
|
|
1350
|
+
const marked = parseDriverMark(line);
|
|
1351
|
+
if (marked) {
|
|
1352
|
+
phases[marked.name] ??= Math.max(0, marked.at - t0);
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1284
1355
|
const parsed = parseDriverLine(line);
|
|
1285
1356
|
if (!parsed)
|
|
1286
1357
|
continue;
|
|
@@ -1328,6 +1399,7 @@ export async function runStepInContainer(args) {
|
|
|
1328
1399
|
sandboxMs,
|
|
1329
1400
|
firstOutputMs: firstOutputAt === null ? null : firstOutputAt - t0 - sandboxMs,
|
|
1330
1401
|
totalMs: Date.now() - t0,
|
|
1402
|
+
phases,
|
|
1331
1403
|
};
|
|
1332
1404
|
args.emit("info", timingLine(timing));
|
|
1333
1405
|
return { ...outcome, timing };
|
package/dist/src/runner.js
CHANGED
|
@@ -33,7 +33,7 @@ import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskToo
|
|
|
33
33
|
import { sendPlainNotification } from "./notify.js";
|
|
34
34
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
35
35
|
import { refNames } from "./refs.js";
|
|
36
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
36
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
37
37
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
38
38
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
39
39
|
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
@@ -2687,6 +2687,12 @@ stepIndex) {
|
|
|
2687
2687
|
const secs = (ms) => Math.round(ms) / 1000;
|
|
2688
2688
|
step.computeSecs = outcome.timing ? secs(outcome.timing.totalMs) : null;
|
|
2689
2689
|
step.startupSecs = outcome.timing ? secs(outcome.timing.sandboxMs) : null;
|
|
2690
|
+
// Where the start-up went, phase by phase (run-container.ts#STARTUP_PHASES),
|
|
2691
|
+
// in seconds — what the cold-start numbers are computed from.
|
|
2692
|
+
const phases = outcome.timing?.phases;
|
|
2693
|
+
step.startup = phases && Object.keys(phases).length
|
|
2694
|
+
? Object.fromEntries(Object.entries(phases).map(([k, v]) => [k, secs(v)]))
|
|
2695
|
+
: null;
|
|
2690
2696
|
// Which reservation those seconds held — as numbers, because the bill
|
|
2691
2697
|
// is per core-second and per GiB-second, and what was held must come
|
|
2692
2698
|
// from the record, never from what the config says at billing time.
|
|
@@ -4768,7 +4774,9 @@ function lastActivity(run) {
|
|
|
4768
4774
|
export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
4769
4775
|
const closed = [];
|
|
4770
4776
|
for (const workspace of listWorkspaces(tenant)) {
|
|
4771
|
-
|
|
4777
|
+
// Live runs only, read whole — every other status is passed over below,
|
|
4778
|
+
// and parsing the workspace's whole history every sweep was the cost.
|
|
4779
|
+
for (const summary of liveRuns(tenant, workspace.name)) {
|
|
4772
4780
|
if (drivingRuns.has(summary.id))
|
|
4773
4781
|
continue;
|
|
4774
4782
|
// Driven by another process — a second worker pod, on a platform with
|
package/dist/src/secrets.js
CHANGED
|
@@ -218,11 +218,9 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
218
218
|
export function secretSites(tenant, names, workspace) {
|
|
219
219
|
const out = {};
|
|
220
220
|
const own = workspace ? read(tenant, workspace) : {};
|
|
221
|
-
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
222
|
-
const inherited = source ? read(tenant, source) : {};
|
|
223
221
|
const account = read(tenant);
|
|
224
222
|
for (const name of names) {
|
|
225
|
-
const rec = own[name] ??
|
|
223
|
+
const rec = own[name] ?? account[name];
|
|
226
224
|
if (rec?.sites?.length)
|
|
227
225
|
out[name] = [...rec.sites];
|
|
228
226
|
}
|
|
@@ -298,15 +296,6 @@ export function getSecret(tenant, name, workspace) {
|
|
|
298
296
|
const own = decrypt(tenant, read(tenant, workspace)[name]);
|
|
299
297
|
if (own !== null)
|
|
300
298
|
return { value: own, scope: "workspace" };
|
|
301
|
-
// A preview is a branch of its source workspace, and the branch's
|
|
302
|
-
// agents need the same credentials the source's do. Its own store is
|
|
303
|
-
// still consulted first, so a preview can override one on purpose.
|
|
304
|
-
const source = platform.previewSourceOf(tenant, workspace);
|
|
305
|
-
if (source) {
|
|
306
|
-
const inherited = decrypt(tenant, read(tenant, source)[name]);
|
|
307
|
-
if (inherited !== null)
|
|
308
|
-
return { value: inherited, scope: "workspace" };
|
|
309
|
-
}
|
|
310
299
|
}
|
|
311
300
|
const shared = decrypt(tenant, read(tenant)[name]);
|
|
312
301
|
return shared === null ? null : { value: shared, scope: "account" };
|
package/dist/src/starter.js
CHANGED
|
@@ -373,8 +373,8 @@ Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read wh
|
|
|
373
373
|
step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
|
|
374
374
|
|
|
375
375
|
Every route, with request and response shapes, is in \`docs/api.md\` in the
|
|
376
|
-
foldrun repository — including secrets, keys, billing, OAuth, webhooks and
|
|
377
|
-
|
|
376
|
+
foldrun repository — including secrets, keys, billing, OAuth, webhooks and
|
|
377
|
+
restore.
|
|
378
378
|
|
|
379
379
|
## Writing a flow
|
|
380
380
|
|
package/dist/src/store.d.ts
CHANGED
|
@@ -754,15 +754,6 @@ export interface WorkspaceSummary {
|
|
|
754
754
|
flows: number;
|
|
755
755
|
deployedAt: string;
|
|
756
756
|
runCount: number;
|
|
757
|
-
/** Set when this workspace is a branch's preview of another (preview.ts):
|
|
758
|
-
* which one, which branch, which commit. Read from the marker beside the
|
|
759
|
-
* directory here rather than through preview.ts, which imports this file. */
|
|
760
|
-
preview?: {
|
|
761
|
-
source: string;
|
|
762
|
-
branch: string;
|
|
763
|
-
commit: string;
|
|
764
|
-
at: string;
|
|
765
|
-
};
|
|
766
757
|
/** The workspace's picture, as a URL, when the page that lists it looked
|
|
767
758
|
* one up (avatars.ts) — the store itself does not. */
|
|
768
759
|
avatar?: string | null;
|
|
@@ -824,7 +815,7 @@ export interface ChangeMeta {
|
|
|
824
815
|
}
|
|
825
816
|
export declare function writeWorkspaceFile(tenant: string, workspace: string, rel: string, content: string, meta?: ChangeMeta): void;
|
|
826
817
|
/** Tell the platform, and never let its listener fail a write. */
|
|
827
|
-
declare function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "
|
|
818
|
+
declare function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "delete"): void;
|
|
828
819
|
export { workspaceChanged as notifyWorkspaceChanged };
|
|
829
820
|
export declare function syncBundleFor(file: string, change?: "Creation" | "Update"): void;
|
|
830
821
|
/**
|
|
@@ -1144,6 +1135,12 @@ export interface StepRecord {
|
|
|
1144
1135
|
*/
|
|
1145
1136
|
computeSecs?: number | null;
|
|
1146
1137
|
startupSecs?: number | null;
|
|
1138
|
+
/** When each start-up phase ended, in seconds since the executor began
|
|
1139
|
+
* the step — `services`, `applied`, `ready`, `files`, `go`, `shim`,
|
|
1140
|
+
* `system`, `driver`, `imports`, `tools`, `model` (run-container.ts
|
|
1141
|
+
* #STARTUP_PHASES). Only the phases this step went through; null from
|
|
1142
|
+
* an executor that marks none, absent on steps from before 17 Oct 2026. */
|
|
1143
|
+
startup?: Record<string, number> | null;
|
|
1147
1144
|
/** Which reservation class the sandbox held (small | large), and the
|
|
1148
1145
|
* actual limits it held, as numbers. Facts of the step: a price is per
|
|
1149
1146
|
* core-second, so what was reserved must come from the record, never
|
|
@@ -1288,6 +1285,14 @@ export declare function listRunOutputs(tenant: string, workspace: string, runId:
|
|
|
1288
1285
|
export declare function runOutputPath(tenant: string, workspace: string, runId: string, agent: string, rel: string): string | null;
|
|
1289
1286
|
export declare function readRun(tenant: string, workspace: string, runId: string): RunRecord | null;
|
|
1290
1287
|
export declare function writeRun(tenant: string, workspace: string, run: RunRecord): void;
|
|
1288
|
+
/**
|
|
1289
|
+
* The workspace's live runs — queued, running, or parked at a gate — read
|
|
1290
|
+
* whole, newest first. Off the run index: only the live records are opened,
|
|
1291
|
+
* where listRuns parses every record the workspace ever kept. What the
|
|
1292
|
+
* periodic sweeps want (reconcile, the watchdog), and on a box that keeps
|
|
1293
|
+
* ninety days of runs that is a handful of files instead of thousands.
|
|
1294
|
+
*/
|
|
1295
|
+
export declare function liveRuns(tenant: string, workspace: string): RunRecord[];
|
|
1291
1296
|
export interface RunWithWorkspace extends RunRecord {
|
|
1292
1297
|
workspace: string;
|
|
1293
1298
|
}
|
package/dist/src/store.js
CHANGED
|
@@ -1681,7 +1681,7 @@ export function mayApprove(flow, who) {
|
|
|
1681
1681
|
}
|
|
1682
1682
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1683
1683
|
export function flowHasLiveRun(tenant, workspace, flowName) {
|
|
1684
|
-
return
|
|
1684
|
+
return listRunSummaries(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
|
|
1685
1685
|
}
|
|
1686
1686
|
// Split a flow file into its frontmatter, any prose before the first step,
|
|
1687
1687
|
// and one block per step. Blocks come back in the same order as parseFlow's
|
|
@@ -2012,13 +2012,6 @@ export function listWorkspaces(tenant) {
|
|
|
2012
2012
|
}
|
|
2013
2013
|
}
|
|
2014
2014
|
const runsDir = path.join(pDir, "runs");
|
|
2015
|
-
let preview;
|
|
2016
|
-
try {
|
|
2017
|
-
preview = JSON.parse(fs.readFileSync(path.join(dir, `.${name}.preview.json`), "utf8"));
|
|
2018
|
-
}
|
|
2019
|
-
catch {
|
|
2020
|
-
// not a preview
|
|
2021
|
-
}
|
|
2022
2015
|
return {
|
|
2023
2016
|
name,
|
|
2024
2017
|
description,
|
|
@@ -2026,7 +2019,6 @@ export function listWorkspaces(tenant) {
|
|
|
2026
2019
|
flows: listFlows(tenant, name).length,
|
|
2027
2020
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
2028
2021
|
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
|
|
2029
|
-
...(preview ? { preview } : {}),
|
|
2030
2022
|
};
|
|
2031
2023
|
})
|
|
2032
2024
|
.sort((a, b) => b.deployedAt.localeCompare(a.deployedAt));
|
|
@@ -2697,6 +2689,24 @@ export function writeRun(tenant, workspace, run) {
|
|
|
2697
2689
|
fs.renameSync(tmp, p);
|
|
2698
2690
|
noteRunWritten(path.dirname(p), run, p);
|
|
2699
2691
|
}
|
|
2692
|
+
/**
|
|
2693
|
+
* The workspace's live runs — queued, running, or parked at a gate — read
|
|
2694
|
+
* whole, newest first. Off the run index: only the live records are opened,
|
|
2695
|
+
* where listRuns parses every record the workspace ever kept. What the
|
|
2696
|
+
* periodic sweeps want (reconcile, the watchdog), and on a box that keeps
|
|
2697
|
+
* ninety days of runs that is a handful of files instead of thousands.
|
|
2698
|
+
*/
|
|
2699
|
+
export function liveRuns(tenant, workspace) {
|
|
2700
|
+
const out = [];
|
|
2701
|
+
for (const s of listRunSummaries(tenant, workspace)) {
|
|
2702
|
+
if (!LIVE_STATUSES.has(s.status))
|
|
2703
|
+
continue;
|
|
2704
|
+
const run = readRun(tenant, workspace, s.id);
|
|
2705
|
+
if (run)
|
|
2706
|
+
out.push(run);
|
|
2707
|
+
}
|
|
2708
|
+
return out;
|
|
2709
|
+
}
|
|
2700
2710
|
/**
|
|
2701
2711
|
* Every run in the account, newest first, off the per-workspace index.
|
|
2702
2712
|
*
|
|
@@ -2721,6 +2731,13 @@ export function listAllRuns(tenant) {
|
|
|
2721
2731
|
}))
|
|
2722
2732
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2723
2733
|
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Which version of a file this is. mtime alone missed a rewrite inside one
|
|
2736
|
+
* mtime tick (coarse on some NFS servers), leaving a stale row that the
|
|
2737
|
+
* sweeps act on; every write here is a rename to a new file, so the inode
|
|
2738
|
+
* moves on each write whatever the clock says, and the size usually does.
|
|
2739
|
+
*/
|
|
2740
|
+
const fileStamp = (st) => `${st.mtimeMs}:${st.ino}:${st.size}`;
|
|
2724
2741
|
const RUN_INDEX = ".index.json";
|
|
2725
2742
|
const LIVE_STATUSES = new Set(["queued", "running", "awaiting-approval"]);
|
|
2726
2743
|
/** One run's row. */
|
|
@@ -2780,22 +2797,22 @@ export function runFromSummary(s) {
|
|
|
2780
2797
|
const runIndexCache = new Map();
|
|
2781
2798
|
function readRunIndex(dir) {
|
|
2782
2799
|
const file = path.join(dir, RUN_INDEX);
|
|
2783
|
-
let
|
|
2800
|
+
let stamp;
|
|
2784
2801
|
try {
|
|
2785
|
-
|
|
2802
|
+
stamp = fileStamp(fs.statSync(file));
|
|
2786
2803
|
}
|
|
2787
2804
|
catch {
|
|
2788
2805
|
runIndexCache.delete(dir);
|
|
2789
2806
|
return null;
|
|
2790
2807
|
}
|
|
2791
2808
|
const cached = runIndexCache.get(dir);
|
|
2792
|
-
if (cached && cached.
|
|
2809
|
+
if (cached && cached.stamp === stamp)
|
|
2793
2810
|
return cached.index;
|
|
2794
2811
|
try {
|
|
2795
2812
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2796
2813
|
if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs)
|
|
2797
2814
|
return null;
|
|
2798
|
-
runIndexCache.set(dir, { index: parsed,
|
|
2815
|
+
runIndexCache.set(dir, { index: parsed, stamp });
|
|
2799
2816
|
return parsed;
|
|
2800
2817
|
}
|
|
2801
2818
|
catch {
|
|
@@ -2808,13 +2825,18 @@ function writeRunIndex(dir, index) {
|
|
|
2808
2825
|
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
2809
2826
|
fs.renameSync(tmp, file);
|
|
2810
2827
|
try {
|
|
2811
|
-
runIndexCache.set(dir, { index,
|
|
2828
|
+
runIndexCache.set(dir, { index, stamp: fileStamp(fs.statSync(file)) });
|
|
2812
2829
|
}
|
|
2813
2830
|
catch {
|
|
2814
2831
|
runIndexCache.delete(dir);
|
|
2815
2832
|
}
|
|
2816
2833
|
}
|
|
2817
|
-
const sameRow = (a, b) =>
|
|
2834
|
+
const sameRow = (a, b) => {
|
|
2835
|
+
if (!a)
|
|
2836
|
+
return false;
|
|
2837
|
+
const { mtimeMs: _m, stamp: _s, ...row } = a;
|
|
2838
|
+
return JSON.stringify(row) === JSON.stringify(b);
|
|
2839
|
+
};
|
|
2818
2840
|
/** writeRun just saved `run` at `file`: bring its row up to date. The file
|
|
2819
2841
|
* is rewritten only when the row's content moved — a live step appends an
|
|
2820
2842
|
* event several times a second, and the row does not change for that;
|
|
@@ -2828,7 +2850,8 @@ function noteRunWritten(dir, run, file) {
|
|
|
2828
2850
|
const row = summarizeRun(run);
|
|
2829
2851
|
if (sameRow(index.runs[run.id], row))
|
|
2830
2852
|
return;
|
|
2831
|
-
|
|
2853
|
+
const st = fs.statSync(file);
|
|
2854
|
+
index.runs[run.id] = { ...row, mtimeMs: st.mtimeMs, stamp: fileStamp(st) };
|
|
2832
2855
|
writeRunIndex(dir, index);
|
|
2833
2856
|
}
|
|
2834
2857
|
catch {
|
|
@@ -2866,19 +2889,20 @@ export function listRunSummaries(tenant, workspace) {
|
|
|
2866
2889
|
continue;
|
|
2867
2890
|
const id = f.slice(0, -".json".length);
|
|
2868
2891
|
present.add(id);
|
|
2869
|
-
let
|
|
2892
|
+
let st;
|
|
2870
2893
|
try {
|
|
2871
|
-
|
|
2894
|
+
st = fs.statSync(path.join(dir, f));
|
|
2872
2895
|
}
|
|
2873
2896
|
catch {
|
|
2874
2897
|
continue; // pruned between the listing and the stat
|
|
2875
2898
|
}
|
|
2899
|
+
const stamp = fileStamp(st);
|
|
2876
2900
|
const row = index.runs[id];
|
|
2877
|
-
if (row && row.
|
|
2901
|
+
if (row && row.stamp === stamp)
|
|
2878
2902
|
continue;
|
|
2879
2903
|
try {
|
|
2880
2904
|
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2881
|
-
index.runs[id] = { ...summarizeRun(run), mtimeMs };
|
|
2905
|
+
index.runs[id] = { ...summarizeRun(run), mtimeMs: st.mtimeMs, stamp };
|
|
2882
2906
|
changed = true;
|
|
2883
2907
|
}
|
|
2884
2908
|
catch {
|
|
@@ -2900,7 +2924,7 @@ export function listRunSummaries(tenant, workspace) {
|
|
|
2900
2924
|
}
|
|
2901
2925
|
}
|
|
2902
2926
|
return Object.values(index.runs)
|
|
2903
|
-
.map(({ mtimeMs: _m, ...row }) => row)
|
|
2927
|
+
.map(({ mtimeMs: _m, stamp: _s, ...row }) => row)
|
|
2904
2928
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2905
2929
|
}
|
|
2906
2930
|
/** Every workspace's runs as summaries, tagged with the workspace. */
|
package/index.ts
CHANGED
|
@@ -36,7 +36,6 @@ export * from "./src/tool-names.ts";
|
|
|
36
36
|
export * from "./src/history.ts";
|
|
37
37
|
export * from "./src/zip.ts";
|
|
38
38
|
export * from "./src/package.ts";
|
|
39
|
-
export * from "./src/gitrepo.ts";
|
|
40
39
|
export * from "./src/storage.ts";
|
|
41
40
|
export * from "./src/secrets.ts";
|
|
42
41
|
export * from "./src/oauth-presets.ts";
|
|
@@ -47,5 +46,3 @@ export * from "./src/approvals.ts";
|
|
|
47
46
|
export * from "./src/deploy.ts";
|
|
48
47
|
export * from "./src/agent-import.ts";
|
|
49
48
|
export * from "./src/account.ts";
|
|
50
|
-
export * from "./src/tar.ts";
|
|
51
|
-
export * from "./src/git.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
11
|
},
|
|
12
|
-
"./git-hook": "./dist/src/git-hook.js",
|
|
13
12
|
"./*": {
|
|
14
13
|
"types": "./dist/src/*.d.ts",
|
|
15
14
|
"default": "./dist/src/*.js"
|
|
@@ -18,7 +17,6 @@
|
|
|
18
17
|
"foldrun": {
|
|
19
18
|
"publicApi": [
|
|
20
19
|
".",
|
|
21
|
-
"./git-hook",
|
|
22
20
|
"./agents-md",
|
|
23
21
|
"./browser-pod",
|
|
24
22
|
"./flow-patterns",
|