@foldrun/core 1.4.2 → 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.
@@ -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
- }
@@ -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" | "push" | "delete"): void;
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:
@@ -25,7 +25,6 @@ const local = {
25
25
  isolation: {},
26
26
  killRunSandboxes() { },
27
27
  syncPublicShares: async () => ({ added: [] }),
28
- previewSourceOf: () => null,
29
28
  tenantKey: () => null,
30
29
  egress: { lease: async () => null },
31
30
  workspaceChanged() { },
@@ -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
- return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total`;
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
@@ -374,7 +414,17 @@ const DRIVER = `// The runner container's entrypoint. Reads one step, executes i
374
414
  // same core the server uses, speaks JSONL on stdout, exits.
375
415
  import fs from "node:fs";
376
416
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
377
- const emit = (type, text, extra) => process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
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
+ };
378
428
  // What this sandbox actually touched, read at the last moment from the
379
429
  // kernel's own books: cgroup v2 for CPU busy-time and peak memory,
380
430
  // /proc/net/dev for bytes on the wire. Every read is best-effort — gVisor
@@ -430,6 +480,7 @@ try {
430
480
  const { prepareRuntime } = await import("@foldrun/core/runtime");
431
481
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
432
482
  const { paidWebActions } = await import("@foldrun/core/browser-pod");
483
+ mark("imports");
433
484
 
434
485
  const agentDir = "/workspace/" + input.agentRel;
435
486
  // HOME is where a bare relative path lands when anything resolves one
@@ -512,6 +563,7 @@ try {
512
563
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
513
564
  const inbox = channel ? inboxReader(channel, emit) : undefined;
514
565
 
566
+ mark("tools");
515
567
  const outcome = await executeStep({
516
568
  agentDir,
517
569
  workspaceRoot: "/workspace",
@@ -1260,6 +1312,8 @@ export async function runStepInContainer(args) {
1260
1312
  throw new Error(`container create failed:\n${create.stderr}`);
1261
1313
  containerId = create.stdout.trim();
1262
1314
  const sandboxMs = Date.now() - t0;
1315
+ // Docker's phases are measured from create, its rent clock.
1316
+ const phases = { ready: sandboxMs };
1263
1317
  const cp = (from, to) => {
1264
1318
  const out = spawnSync(cli(), ["cp", from, to], { encoding: "utf8" });
1265
1319
  if (out.status !== 0)
@@ -1268,6 +1322,7 @@ export async function runStepInContainer(args) {
1268
1322
  cp(`${wsIn}/.`, `${containerId}:/workspace`);
1269
1323
  cp(`${libIn}/.`, `${containerId}:/library`);
1270
1324
  cp(`${jobIn}/input.json`, `${containerId}:/opt/runner/job/input.json`);
1325
+ phases.files = Date.now() - t0;
1271
1326
  // Start attached and read the driver's JSONL. The step timeout gets a
1272
1327
  // backstop out here — a wedged container is killed, not waited on.
1273
1328
  let firstOutputAt = null;
@@ -1292,6 +1347,11 @@ export async function runStepInContainer(args) {
1292
1347
  break;
1293
1348
  const line = buffer.slice(0, nl);
1294
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
+ }
1295
1355
  const parsed = parseDriverLine(line);
1296
1356
  if (!parsed)
1297
1357
  continue;
@@ -1339,6 +1399,7 @@ export async function runStepInContainer(args) {
1339
1399
  sandboxMs,
1340
1400
  firstOutputMs: firstOutputAt === null ? null : firstOutputAt - t0 - sandboxMs,
1341
1401
  totalMs: Date.now() - t0,
1402
+ phases,
1342
1403
  };
1343
1404
  args.emit("info", timingLine(timing));
1344
1405
  return { ...outcome, timing };
@@ -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
- for (const summary of listRuns(tenant, workspace.name)) {
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
@@ -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] ?? inherited[name] ?? account[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" };
@@ -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 the
377
- git push endpoint.
376
+ foldrun repository — including secrets, keys, billing, OAuth, webhooks and
377
+ restore.
378
378
 
379
379
  ## Writing a flow
380
380
 
@@ -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" | "push" | "delete"): void;
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 listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
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 mtimeMs;
2800
+ let stamp;
2784
2801
  try {
2785
- mtimeMs = fs.statSync(file).mtimeMs;
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.mtimeMs === mtimeMs)
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, mtimeMs });
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, mtimeMs: fs.statSync(file).mtimeMs });
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) => !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
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
- index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
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 mtimeMs;
2892
+ let st;
2870
2893
  try {
2871
- mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
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.mtimeMs === mtimeMs)
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": "1.4.2",
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",