@foldrun/core 1.4.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/index.d.ts +0 -3
  2. package/dist/index.js +0 -3
  3. package/dist/src/catalog.d.ts +7 -0
  4. package/dist/src/catalog.js +16 -1
  5. package/dist/src/deploy.d.ts +6 -27
  6. package/dist/src/deploy.js +14 -56
  7. package/dist/src/history.d.ts +30 -10
  8. package/dist/src/history.js +232 -99
  9. package/dist/src/library.d.ts +0 -13
  10. package/dist/src/library.js +0 -50
  11. package/dist/src/model-credential.js +1 -1
  12. package/dist/src/model-env.js +1 -1
  13. package/dist/src/platform.d.ts +1 -3
  14. package/dist/src/platform.js +0 -1
  15. package/dist/src/run-container.d.ts +31 -5
  16. package/dist/src/run-container.js +71 -3
  17. package/dist/src/runner.d.ts +2 -0
  18. package/dist/src/runner.js +20 -12
  19. package/dist/src/secrets.js +1 -12
  20. package/dist/src/starter.js +2 -2
  21. package/dist/src/step-exec.d.ts +23 -5
  22. package/dist/src/step-exec.js +30 -23
  23. package/dist/src/store.d.ts +61 -22
  24. package/dist/src/store.js +105 -25
  25. package/dist/src/tool-loop.d.ts +4 -1
  26. package/dist/src/tool-loop.js +2 -0
  27. package/dist/src/translator.d.ts +12 -0
  28. package/dist/src/translator.js +36 -13
  29. package/index.ts +0 -3
  30. package/package.json +1 -3
  31. package/src/catalog.ts +18 -2
  32. package/src/deploy.ts +13 -74
  33. package/src/history.ts +230 -108
  34. package/src/library.ts +0 -44
  35. package/src/model-credential.ts +1 -1
  36. package/src/model-env.ts +1 -1
  37. package/src/platform.ts +1 -4
  38. package/src/run-container.ts +78 -5
  39. package/src/runner.ts +22 -12
  40. package/src/secrets.ts +1 -11
  41. package/src/starter.ts +2 -2
  42. package/src/step-exec.ts +44 -27
  43. package/src/store.ts +145 -37
  44. package/src/tool-loop.ts +6 -1
  45. package/src/translator.ts +39 -15
  46. package/dist/src/git-hook.d.ts +0 -1
  47. package/dist/src/git-hook.js +0 -70
  48. package/dist/src/git.d.ts +0 -37
  49. package/dist/src/git.js +0 -101
  50. package/dist/src/gitrepo.d.ts +0 -113
  51. package/dist/src/gitrepo.js +0 -404
  52. package/dist/src/tar.d.ts +0 -30
  53. package/dist/src/tar.js +0 -112
  54. package/src/git-hook.ts +0 -73
  55. package/src/git.ts +0 -117
  56. package/src/gitrepo.ts +0 -439
  57. package/src/tar.ts +0 -125
package/src/history.ts CHANGED
@@ -15,28 +15,29 @@
15
15
  // git repository — and when one is connected later, its commits slot into
16
16
  // the same series.
17
17
  //
18
- // No git binary, no working copy, no repository on the server. The platform
19
- // image deliberately has none (deploy.ts explains why); this is a journal of
20
- // content, which is all the questions above need. Connecting a repository
21
- // is a separate step and pushes THIS history out, not the other way round.
18
+ // No git. This is a journal of content plus a snapshot of the whole tree at
19
+ // every revision — the platform's own deploy history, the way Vercel and
20
+ // Cloudflare keep theirs: rollback and restore point back to a snapshot and
21
+ // never need a repository. A customer's code history belongs in their own
22
+ // GitHub or GitLab, connected later, and is a separate thing. (Until 17 Oct
23
+ // 2026 this preferred a bare git repository per workspace whenever a git
24
+ // binary existed; that git is gone, and history started fresh from a
25
+ // baseline snapshot.)
22
26
  //
23
- // Storage: <data>/<tenant>/.history/<scope>/<id>.json, one file per revision,
24
- // plus index.jsonl — one line each, newest last — so listing does not open
25
- // every revision. Content is stored inline: the files are small, and a
26
- // content-addressed store would be machinery in search of a problem.
27
+ // Storage, under <data>/<tenant>/.history/<scope>/:
28
+ // <id>.json one revision: what changed, from what, to what (inline,
29
+ // clipped at 256 KB — what the history page diffs)
30
+ // index.jsonl one line per revision, newest last, so listing opens none
31
+ // trees/<id>.json the whole tree right after it: { path: sha256 }
32
+ // blobs/<aa>/<sha256> each version of a file, once — content-addressed,
33
+ // because a snapshot per save is mostly files that did not
34
+ // change, and storing them again would be the whole tree
35
+ // per save.
27
36
 
28
37
  import fs from "node:fs";
29
38
  import path from "node:path";
30
39
  import crypto from "node:crypto";
31
40
  import { dataRoot } from "./paths.ts";
32
- import {
33
- commitChanges,
34
- gitAvailable,
35
- headSha,
36
- listCommits,
37
- readCommit,
38
- repoExists,
39
- } from "./gitrepo.ts";
40
41
 
41
42
  /**
42
43
  * The current tree of a scope, so the first commit can be a full import.
@@ -54,30 +55,6 @@ export function registerTreeReader(fn: TreeReader) {
54
55
  readTree = (tenant, scope) => fn(tenant, scope) ?? prev(tenant, scope);
55
56
  }
56
57
 
57
- /** Something to do after a commit lands on main — the mirror push. Registered
58
- * by mirror.ts; this module cannot import it (secrets, settings). */
59
- type AfterCommit = (tenant: string, scope: string, sha: string) => void;
60
- const afterCommit: AfterCommit[] = [];
61
- export function registerAfterCommit(fn: AfterCommit) {
62
- afterCommit.push(fn);
63
- }
64
- export function notifyCommit(tenant: string, scope: string, sha: string) {
65
- for (const fn of afterCommit) {
66
- try {
67
- fn(tenant, scope, sha);
68
- } catch {
69
- // a mirror that fails must not fail the save that triggered it
70
- }
71
- }
72
- }
73
-
74
- /** Journal or git? Git whenever the binary exists; the journal is the
75
- * fallback for an install without one. An install that started on the
76
- * journal keeps it readable — both are consulted on read. */
77
- const useGit = () => process.env.FOLDRUN_HISTORY !== "journal" && gitAvailable();
78
-
79
- // Not store's assertSafeName: store imports this module, and a cycle between
80
- // the two is avoidable for one regex.
81
58
  function assertSafeName(v: string, what: string) {
82
59
  if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(v)) throw new Error(`illegal ${what}: ${v}`);
83
60
  }
@@ -142,37 +119,6 @@ export function recordRevision(
142
119
  const changed = files.filter((f) => f.before !== f.after);
143
120
  if (changed.length === 0) return null;
144
121
 
145
- if (useGit()) {
146
- // First commit ever for this scope: import the whole tree as it stands
147
- // now (the writer has already written), so the repository is complete
148
- // from its first commit rather than starting with one file.
149
- const first = !headSha(tenant, scope);
150
- // Generated bundle files (knowledge/index.md, memory/log.md) are rewritten
151
- // by the same save that changed a concept, outside this call. Commit them
152
- // as they now stand, or a clone's index disagrees with its own files —
153
- // the exact drift the template tests exist to catch.
154
- const generated = first
155
- ? []
156
- : (readTree(tenant, scope) ?? []).filter((f) => /(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/.test(f.path));
157
- const toCommit = first ? (readTree(tenant, scope) ?? changed) : [...changed, ...generated];
158
- const sha = commitChanges(tenant, scope, toCommit, {
159
- by: meta.by,
160
- // Named after what the caller changed, not the generated files that
161
- // rode along — "edited knowledge/style.md", not "changed 3 files".
162
- message: first ? `initial import${meta.message ? ` — ${meta.message}` : ""}` : (meta.message ?? describe(changed)),
163
- });
164
- if (!sha) return null;
165
- notifyCommit(tenant, scope, sha);
166
- return {
167
- id: meta.commit ?? sha,
168
- at: new Date().toISOString(),
169
- by: meta.by ?? "system",
170
- message: meta.message ?? describe(changed),
171
- commit: sha,
172
- files: changed,
173
- };
174
- }
175
-
176
122
  const dir = historyDir(tenant, scope);
177
123
  fs.mkdirSync(dir, { recursive: true });
178
124
  const rev: Revision = {
@@ -197,10 +143,169 @@ export function recordRevision(
197
143
  commit: rev.commit,
198
144
  paths: rev.files.map((f) => f.path),
199
145
  };
146
+ // The tree as it stands now (the writer has already written), so this
147
+ // revision can be restored to exactly — including files a save rewrote
148
+ // outside this call, such as a bundle's generated index.
149
+ snapshot(tenant, scope, rev.id);
200
150
  fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
201
151
  return rev;
202
152
  }
203
153
 
154
+ // ----------------------------------------------------------------- snapshots
155
+
156
+ const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex");
157
+ const treePath = (tenant: string, scope: string, id: string) => path.join(historyDir(tenant, scope), "trees", `${id}.json`);
158
+ const blobPath = (tenant: string, scope: string, sha: string) => path.join(historyDir(tenant, scope), "blobs", sha.slice(0, 2), sha);
159
+
160
+ /** Write the scope's whole tree as revision `id`'s snapshot. False when there
161
+ * is no tree to read (a workspace being deleted).
162
+ *
163
+ * What a snapshot is: the tree as it stood just after this revision was
164
+ * written — point in time, which is what a restore wants. A revision's
165
+ * diff names only its own change; its snapshot is everything on disk at
166
+ * that moment, including another writer's change that landed in between,
167
+ * part of one if it was mid-write (saves are not transactional across
168
+ * files, and never were). Reviewed with Codex, 17 Oct 2026. */
169
+ function snapshot(tenant: string, scope: string, id: string): boolean {
170
+ const tree = readTree(tenant, scope);
171
+ if (!tree) return false;
172
+ const files: Record<string, string> = {};
173
+ for (const f of tree) {
174
+ if (f.after === null) continue;
175
+ const sha = sha256(f.after);
176
+ const blob = blobPath(tenant, scope, sha);
177
+ if (!fs.existsSync(blob)) {
178
+ fs.mkdirSync(path.dirname(blob), { recursive: true });
179
+ const tmp = `${blob}.${process.pid}.tmp`;
180
+ fs.writeFileSync(tmp, f.after);
181
+ fs.renameSync(tmp, blob);
182
+ }
183
+ files[f.path] = sha;
184
+ }
185
+ const file = treePath(tenant, scope, id);
186
+ fs.mkdirSync(path.dirname(file), { recursive: true });
187
+ const tmp = `${file}.tmp`;
188
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, files }));
189
+ fs.renameSync(tmp, file);
190
+ return true;
191
+ }
192
+
193
+ /** Whether a revision can be restored to: it has a snapshot. */
194
+ export function hasSnapshot(tenant: string, scope: string, id: string): boolean {
195
+ assertSafeName(id, "revision id");
196
+ return fs.existsSync(treePath(tenant, scope, id));
197
+ }
198
+
199
+ /** The whole tree at revision `id`, or null when it has no snapshot. */
200
+ export function snapshotAt(tenant: string, scope: string, id: string): { path: string; content: string }[] | null {
201
+ assertSafeName(id, "revision id");
202
+ let manifest: { v: number; files: Record<string, string> };
203
+ try {
204
+ manifest = JSON.parse(fs.readFileSync(treePath(tenant, scope, id), "utf8"));
205
+ } catch {
206
+ return null;
207
+ }
208
+ return Object.entries(manifest.files ?? {})
209
+ .map(([p, sha]) => ({ path: p, content: fs.readFileSync(blobPath(tenant, scope, sha), "utf8") }))
210
+ .sort((a, b) => a.path.localeCompare(b.path));
211
+ }
212
+
213
+ /** Every revision of the scope that has a snapshot — one listing, rather
214
+ * than a stat per revision. */
215
+ function snapshotIds(tenant: string, scope: string): Set<string> {
216
+ try {
217
+ return new Set(fs.readdirSync(path.join(historyDir(tenant, scope), "trees")).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)));
218
+ } catch {
219
+ return new Set();
220
+ }
221
+ }
222
+
223
+ /** The newest revision that can be restored to — what "now" is in history. */
224
+ export function headRevision(tenant: string, scope: string): RevisionSummary | null {
225
+ const have = snapshotIds(tenant, scope);
226
+ return journalRevisions(tenant, scope, { limit: Infinity }).find((r) => have.has(r.id)) ?? null;
227
+ }
228
+
229
+ /**
230
+ * A scope with no snapshot yet gets one: its tree as it stands, recorded as
231
+ * a revision of its own — where its history starts. Every workspace that
232
+ * existed when history moved off git starts here, and so does one created
233
+ * by a path that never recorded a change. Returns the head's id, or null
234
+ * when there is nothing to snapshot.
235
+ */
236
+ export function ensureBaseline(tenant: string, scope: string, by = "foldrun"): string | null {
237
+ const head = headRevision(tenant, scope);
238
+ if (head) return head.id;
239
+ const tree = readTree(tenant, scope);
240
+ if (!tree || tree.length === 0) return null;
241
+ const dir = historyDir(tenant, scope);
242
+ fs.mkdirSync(dir, { recursive: true });
243
+ const id = newRevisionId();
244
+ // One baseline even when the web tier and the worker both find none at
245
+ // once: whoever creates the marker first writes it, the other reads it.
246
+ const marker = path.join(dir, ".baseline");
247
+ try {
248
+ fs.writeFileSync(marker, id, { flag: "wx" });
249
+ } catch {
250
+ const held = headRevision(tenant, scope)?.id ?? null;
251
+ // A marker with no baseline behind it, minutes old, is a process that
252
+ // died writing one: clear it, and the next call writes the baseline.
253
+ try {
254
+ if (!held && Date.now() - fs.statSync(marker).mtimeMs > 5 * 60_000) fs.rmSync(marker, { force: true });
255
+ } catch {
256
+ // gone already
257
+ }
258
+ return held;
259
+ }
260
+ const rev: Revision = {
261
+ id,
262
+ at: new Date().toISOString(),
263
+ by,
264
+ message: "history starts here",
265
+ commit: null,
266
+ files: [],
267
+ };
268
+ fs.writeFileSync(path.join(dir, `${rev.id}.json`), JSON.stringify(rev));
269
+ if (!snapshot(tenant, scope, rev.id)) return null;
270
+ const line: RevisionSummary = { id: rev.id, at: rev.at, by: rev.by, message: rev.message, commit: null, paths: [] };
271
+ fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
272
+ return rev.id;
273
+ }
274
+
275
+ /**
276
+ * The revision a restore means: an id (or the commit a deploy recorded), a
277
+ * date (`2026-10-17`, `2026-10-17T09:00Z`) or a span back from now (`90m`,
278
+ * `12h`, `3d`, `2w`) — the newest revision with a snapshot at or before that
279
+ * moment. Null when history holds nothing that old. Throws (status 400) on
280
+ * something that is none of those.
281
+ */
282
+ export function resolveRevision(
283
+ tenant: string,
284
+ scope: string,
285
+ to: string,
286
+ now = Date.now(),
287
+ ): { id: string; at: string; by: string; message: string } | null {
288
+ const t = to.trim();
289
+ if (!t || t.startsWith("-")) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
290
+ const have = snapshotIds(tenant, scope);
291
+ const all = journalRevisions(tenant, scope, { limit: Infinity }).filter((r) => have.has(r.id));
292
+ const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
293
+ const moment = rel
294
+ ? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2] as "m" | "h" | "d" | "w"]
295
+ : /^\d{4}-\d{2}-\d{2}/.test(t)
296
+ ? Date.parse(t)
297
+ : null;
298
+ let hit: RevisionSummary | undefined;
299
+ if (moment !== null) {
300
+ if (Number.isNaN(moment)) throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
301
+ hit = all.find((r) => Date.parse(r.at) <= moment);
302
+ } else {
303
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t)) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
304
+ hit = all.find((r) => r.id === t || r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
305
+ }
306
+ return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
307
+ }
308
+
204
309
  const clip = (s: string | null) =>
205
310
  s === null ? null : s.length > MAX_INLINE ? `[${s.length} bytes — too large to keep inline]` : s;
206
311
 
@@ -225,12 +330,6 @@ export function listRevisions(
225
330
  scope: string,
226
331
  opts: { forPath?: string; limit?: number } = {},
227
332
  ): RevisionSummary[] {
228
- if (useGit() && repoExists(tenant, scope)) {
229
- const fromGit = listCommits(tenant, scope, opts);
230
- // Anything the journal holds from before the repository existed comes
231
- // after git's rows: older, and still worth showing.
232
- return [...fromGit, ...journalRevisions(tenant, scope, opts)].slice(0, opts.limit ?? 200);
233
- }
234
333
  return journalRevisions(tenant, scope, opts);
235
334
  }
236
335
 
@@ -239,45 +338,68 @@ function journalRevisions(
239
338
  scope: string,
240
339
  opts: { forPath?: string; limit?: number } = {},
241
340
  ): RevisionSummary[] {
242
- const file = path.join(historyDir(tenant, scope), "index.jsonl");
243
- if (!fs.existsSync(file)) return [];
244
- const all = fs
245
- .readFileSync(file, "utf8")
246
- .split("\n")
247
- .filter(Boolean)
248
- .flatMap((l) => {
249
- try {
250
- return [JSON.parse(l) as RevisionSummary];
251
- } catch {
252
- return []; // a torn line from a crash mid-append loses one entry, not the file
253
- }
254
- })
255
- .reverse();
341
+ const dir = historyDir(tenant, scope);
342
+ const file = path.join(dir, "index.jsonl");
343
+ if (!fs.existsSync(dir)) return [];
344
+ let all = fs.existsSync(file)
345
+ ? fs
346
+ .readFileSync(file, "utf8")
347
+ .split("\n")
348
+ .filter(Boolean)
349
+ .flatMap((l) => {
350
+ try {
351
+ return [JSON.parse(l) as RevisionSummary];
352
+ } catch {
353
+ return []; // a torn line — the rebuild below brings it back
354
+ }
355
+ })
356
+ .reverse()
357
+ : [];
358
+ // The revision files are the record; index.jsonl is a cache of them. An
359
+ // append from two processes on NFS is not atomic across clients, and a
360
+ // process can die between a revision's file and its line — so when the
361
+ // two disagree, the index is rebuilt from the files.
362
+ const recorded = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).length;
363
+ if (recorded !== new Set(all.map((r) => r.id)).size) all = rebuildIndex(dir);
364
+ // A line appended just after a rebuild wrote it too: once each.
365
+ const seen = new Set<string>();
366
+ all = all.filter((r) => !seen.has(r.id) && seen.add(r.id));
256
367
  const narrowed = opts.forPath ? all.filter((r) => r.paths.includes(opts.forPath!)) : all;
257
368
  return narrowed.slice(0, opts.limit ?? 200);
258
369
  }
259
370
 
371
+ /** index.jsonl again, from the revision files themselves, newest first. */
372
+ function rebuildIndex(dir: string): RevisionSummary[] {
373
+ const rows: (RevisionSummary & { written: number })[] = [];
374
+ for (const f of fs.readdirSync(dir)) {
375
+ if (!f.endsWith(".json")) continue;
376
+ try {
377
+ const file = path.join(dir, f);
378
+ const rev = JSON.parse(fs.readFileSync(file, "utf8")) as Revision;
379
+ rows.push({ id: rev.id, at: rev.at, by: rev.by, message: rev.message, commit: rev.commit, paths: rev.files.map((x) => x.path), written: fs.statSync(file).mtimeMs });
380
+ } catch {
381
+ // a revision half-written by a process that died: not one
382
+ }
383
+ }
384
+ // Two saves in one millisecond share an `at`; the order they were written
385
+ // in breaks the tie (an id's suffix is random, so it cannot).
386
+ rows.sort((a, b) => a.at.localeCompare(b.at) || a.written - b.written || a.id.localeCompare(b.id));
387
+ try {
388
+ const tmp = path.join(dir, `index.jsonl.${process.pid}.tmp`);
389
+ fs.writeFileSync(tmp, rows.map(({ written: _w, ...r }) => JSON.stringify(r) + "\n").join(""));
390
+ fs.renameSync(tmp, path.join(dir, "index.jsonl"));
391
+ } catch {
392
+ // a read-only volume still gets the answer
393
+ }
394
+ return rows.map(({ written: _w, ...r }) => r).reverse();
395
+ }
396
+
260
397
  export function readRevision(tenant: string, scope: string, id: string, forPath?: string): Revision | null {
261
398
  assertSafeName(id, "revision id");
262
- if (useGit() && repoExists(tenant, scope) && /^[0-9a-f]{7,40}$/.test(id)) {
263
- const fromGit = readCommit(tenant, scope, id, forPath);
264
- if (fromGit) return fromGit;
265
- }
266
399
  const file = path.join(historyDir(tenant, scope), `${id}.json`);
267
400
  if (!fs.existsSync(file)) return null;
268
- return JSON.parse(fs.readFileSync(file, "utf8")) as Revision;
269
- }
270
-
271
- /**
272
- * Make sure a scope's repository has its initial import, so a clone made
273
- * right now is complete. A no-op once any commit exists.
274
- */
275
- export function ensureImported(tenant: string, scope: string): string | null {
276
- if (!useGit()) return null;
277
- if (headSha(tenant, scope)) return headSha(tenant, scope);
278
- const tree = readTree(tenant, scope);
279
- if (!tree || tree.length === 0) return null;
280
- return commitChanges(tenant, scope, tree, { by: "foldrun", message: "initial import" });
401
+ const rev = JSON.parse(fs.readFileSync(file, "utf8")) as Revision;
402
+ return forPath ? { ...rev, files: rev.files.filter((f) => f.path === forPath) } : rev;
281
403
  }
282
404
 
283
405
  /** The most recent revision id for a scope — what a change is attributed
package/src/library.ts CHANGED
@@ -435,47 +435,3 @@ registerTreeReader((tenant, scope) => {
435
435
  });
436
436
 
437
437
 
438
- /**
439
- * Make the shelf match a tree — what a push to the library repository does.
440
- * Written with plain fs and recorded as ONE revision at the end: going
441
- * through writeLibraryFile per file would commit each one on top of the
442
- * pushed commit, and the history would show a push as fifty edits.
443
- */
444
- export function syncLibraryFromTree(tenant: string, files: { path: string; content: string }[], meta: { by?: string; commit?: string | null } = {}) {
445
- const wanted = new Map<string, string>();
446
- for (const f of files) {
447
- const [kind, ...rest] = f.path.split("/");
448
- if (!LIBRARY_KINDS.includes(kind as LibraryKind) || rest.length === 0) continue;
449
- wanted.set(f.path, f.content);
450
- }
451
- const changes: { path: string; before: string | null; after: string | null }[] = [];
452
- for (const kind of LIBRARY_KINDS) {
453
- const dir = libraryDir(tenant, kind);
454
- if (!fs.existsSync(dir)) continue;
455
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
456
- const rel = `${kind}/${String(entry).split(path.sep).join("/")}`;
457
- const full = path.join(dir, String(entry));
458
- try {
459
- if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel)) continue;
460
- } catch {
461
- continue;
462
- }
463
- if (!wanted.has(rel)) {
464
- changes.push({ path: rel, before: fs.readFileSync(full, "utf8"), after: null });
465
- fs.rmSync(full, { force: true });
466
- }
467
- }
468
- }
469
- for (const [rel, content] of wanted) {
470
- const [kind, ...rest] = rel.split("/");
471
- const full = path.join(libraryDir(tenant, kind as LibraryKind), assertLibraryPath(kind as LibraryKind, rest.join("/")));
472
- const before = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
473
- if (before === content) continue;
474
- fs.mkdirSync(path.dirname(full), { recursive: true });
475
- fs.writeFileSync(full, content);
476
- if (kind === "scripts" || /scripts\//.test(rel) || (kind === "tools" && !rel.endsWith(".md"))) fs.chmodSync(full, 0o755);
477
- changes.push({ path: rel, before, after: content });
478
- }
479
- recordRevision(tenant, "@library", changes, { by: meta.by ?? "deploy", message: meta.commit ? `deployed ${meta.commit.slice(0, 7)}` : "synced from push", commit: meta.commit ?? null });
480
- return changes.length;
481
- }
@@ -75,7 +75,7 @@ export function credentialLine(c: ModelCredential): { type: "info" | "error"; te
75
75
  //
76
76
  // An account brings its own model key without writing a provider: block:
77
77
  // three secrets in the ACCOUNT vault (set from Settings → Model, the API's
78
- // /api/account/model, or `foldrun model set`). A provider: block at any
78
+ // /api/account/model, or `foldrun account model set`). A provider: block at any
79
79
  // level still wins; this is what a step falls to when none names one, before
80
80
  // the platform's own credential.
81
81
 
package/src/model-env.ts CHANGED
@@ -23,7 +23,7 @@ export class ModelKeyError extends Error {
23
23
 
24
24
  export const NO_MODEL_KEY =
25
25
  "no model key: this account runs on its own API key, and none is set — add one in Settings → Model " +
26
- "(or `foldrun model set <provider> --key …`), or write a provider: block in AGENTS.md";
26
+ "(or `foldrun account model set <provider> --key …`), or write a provider: block in AGENTS.md";
27
27
 
28
28
  /**
29
29
  * `platform` is a function: the caller's own way to build the platform's env
package/src/platform.ts CHANGED
@@ -109,8 +109,6 @@ export interface PlatformHooks {
109
109
  killRunSandboxes(runId: string): void;
110
110
  /** Publish storage/public/ as share links after a run. Default: no links. */
111
111
  syncPublicShares(tenant: string, workspace: string): Promise<{ added: string[] }>;
112
- /** The workspace a preview was branched from, for inherited secrets. Default: none. */
113
- previewSourceOf(tenant: string, workspace: string): string | null;
114
112
  /** This account's own data key, or null to use the install key. */
115
113
  tenantKey(tenant: string): Buffer | null;
116
114
  /** The egress proxy — a per-step lease that keeps credentials out of the
@@ -120,7 +118,7 @@ export interface PlatformHooks {
120
118
  * workspace deleted. What a scheduler that keeps flows in memory rather
121
119
  * than re-reading every workspace each tick needs to hear. Default:
122
120
  * nothing — locally the scheduler reads the disk. Never throws. */
123
- workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "push" | "delete"): void;
121
+ workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "delete"): void;
124
122
  /** Can a step in this isolation mode be re-attached to after the driver
125
123
  * that started it is gone? True means an orphaned step with a `sandbox`
126
124
  * on its record is resumed rather than destroyed and run again. Default:
@@ -204,7 +202,6 @@ const local: PlatformHooks = {
204
202
  isolation: {},
205
203
  killRunSandboxes() {},
206
204
  syncPublicShares: async () => ({ added: [] }),
207
- previewSourceOf: () => null,
208
205
  tenantKey: () => null,
209
206
  egress: { lease: async () => null },
210
207
  workspaceChanged() {},
@@ -27,7 +27,7 @@ import { spawn, spawnSync } from "node:child_process";
27
27
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
28
28
  import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
29
29
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
30
- import type { EventExtra } from "./step-exec.ts";
30
+ import type { EventExtra, ExecUsage } from "./step-exec.ts";
31
31
  import { isFileValue, fileContent } from "./secrets.ts";
32
32
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
33
33
  import { egressCaSh, type NetworkLevel } from "./network.ts";
@@ -121,6 +121,47 @@ export interface StepTiming {
121
121
  sandboxMs: number;
122
122
  firstOutputMs: number | null;
123
123
  totalMs: number;
124
+ /** Where the start-up went, phase by phase — see STARTUP_PHASES. Absent
125
+ * from an executor that marks none, and from a resumed driver (the
126
+ * first driver's phases are on the record already). */
127
+ phases?: StartupPhases;
128
+ }
129
+
130
+ /**
131
+ * The start-up of one step, in the order it happens. Each is the moment the
132
+ * phase ENDED, in ms since the executor began the step, so the gap between
133
+ * two neighbours is what the later one cost (FOL-29: "pod ready" was the
134
+ * only number, and the six seconds after it had no names).
135
+ *
136
+ * The executor marks its own: `services` (the account's search/browser pods
137
+ * up), `room` (room on the cluster to start — platform pod-room.ts),
138
+ * `applied` (pod created), `ready`, `files` (the workspace copied in),
139
+ * `go` (the step released). The sandbox marks the rest as `{"e":"mark"}`
140
+ * lines: `shim` (the shim saw go), `system` (system packages restored),
141
+ * `driver` (node up), `imports` (core loaded), `tools` (runtime and tools
142
+ * built, the SDK about to start) and `model` (the model's first text or tool
143
+ * call). A worker that predates a mark ignores the line — parseDriverLine
144
+ * answers null for an `e` it does not know.
145
+ */
146
+ export const STARTUP_PHASES = [
147
+ "services", "room", "applied", "ready", "files", "go",
148
+ "shim", "system", "driver", "imports", "tools", "model",
149
+ ] as const;
150
+ export type StartupPhase = (typeof STARTUP_PHASES)[number];
151
+ export type StartupPhases = Partial<Record<StartupPhase, number>>;
152
+
153
+ /** A sandbox-side mark: which phase ended, and when (epoch ms, the
154
+ * sandbox's clock — the same machine's as the worker's on one node). */
155
+ export function parseDriverMark(line: string): { name: StartupPhase; at: number } | null {
156
+ const trimmed = line.trim();
157
+ if (!trimmed.startsWith('{"e":"mark"')) return null;
158
+ try {
159
+ const parsed = JSON.parse(trimmed);
160
+ if (parsed.e !== "mark" || typeof parsed.at !== "number" || !Number.isFinite(parsed.at)) return null;
161
+ return (STARTUP_PHASES as readonly string[]).includes(parsed.name) ? { name: parsed.name, at: parsed.at } : null;
162
+ } catch {
163
+ return null;
164
+ }
124
165
  }
125
166
 
126
167
  export interface StepActuals {
@@ -143,7 +184,7 @@ export interface ContainerStepOutcome {
143
184
  /** The parsed JSON of an `output: json` step, crossing back as a value. */
144
185
  data?: unknown;
145
186
  costUsd: number | null;
146
- usage?: { inputTokens: number; outputTokens: number } | null;
187
+ usage?: ExecUsage | null;
147
188
  /** Every turn priced at its own model, when sub-agents ran on others —
148
189
  * see ExecOutcome.turnsCostUsd. */
149
190
  turnsCostUsd?: number;
@@ -168,7 +209,11 @@ export interface ContainerStepOutcome {
168
209
  export function timingLine(t: StepTiming): string {
169
210
  const s = (ms: number) => `${(ms / 1000).toFixed(2)}s`;
170
211
  const first = t.firstOutputMs === null ? "no output" : `first output +${s(t.firstOutputMs)}`;
171
- return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total`;
212
+ const phases = t.phases
213
+ ? STARTUP_PHASES.filter((p) => typeof t.phases![p] === "number").map((p) => `${p} ${s(t.phases![p]!)}`)
214
+ : [];
215
+ return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total` +
216
+ (phases.length ? ` · start-up: ${phases.join(" → ")}` : "");
172
217
  }
173
218
 
174
219
  const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
@@ -504,7 +549,17 @@ const DRIVER = `// The runner container's entrypoint. Reads one step, executes i
504
549
  // same core the server uses, speaks JSONL on stdout, exits.
505
550
  import fs from "node:fs";
506
551
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
507
- const emit = (type, text, extra) => process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
552
+ // Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,
553
+ // on the sandbox's clock. A worker that does not know a mark skips the line.
554
+ const mark = (name) => process.stdout.write(JSON.stringify({ e: "mark", name, at: Date.now() }) + "\\n");
555
+ mark("driver");
556
+ let modelSpoke = false;
557
+ const emit = (type, text, extra) => {
558
+ // The model's first text or tool call is where start-up ends and the
559
+ // step's own work begins — runtime and tool lines before it are set-up.
560
+ if (!modelSpoke && (type === "text" || type === "tool")) { modelSpoke = true; mark("model"); }
561
+ process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
562
+ };
508
563
  // What this sandbox actually touched, read at the last moment from the
509
564
  // kernel's own books: cgroup v2 for CPU busy-time and peak memory,
510
565
  // /proc/net/dev for bytes on the wire. Every read is best-effort — gVisor
@@ -560,6 +615,7 @@ try {
560
615
  const { prepareRuntime } = await import("@foldrun/core/runtime");
561
616
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
562
617
  const { paidWebActions } = await import("@foldrun/core/browser-pod");
618
+ mark("imports");
563
619
 
564
620
  const agentDir = "/workspace/" + input.agentRel;
565
621
  // HOME is where a bare relative path lands when anything resolves one
@@ -642,6 +698,7 @@ try {
642
698
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
643
699
  const inbox = channel ? inboxReader(channel, emit) : undefined;
644
700
 
701
+ mark("tools");
645
702
  const outcome = await executeStep({
646
703
  agentDir,
647
704
  workspaceRoot: "/workspace",
@@ -1159,7 +1216,14 @@ export function parseDriverLine(
1159
1216
  parsed.usage &&
1160
1217
  typeof parsed.usage.inputTokens === "number" &&
1161
1218
  typeof parsed.usage.outputTokens === "number"
1162
- ? { inputTokens: parsed.usage.inputTokens, outputTokens: parsed.usage.outputTokens }
1219
+ ? {
1220
+ inputTokens: parsed.usage.inputTokens,
1221
+ outputTokens: parsed.usage.outputTokens,
1222
+ // A driver from before the split sends neither; zero, not
1223
+ // unknown, keeps the record's shape one thing.
1224
+ cacheReadTokens: typeof parsed.usage.cacheReadTokens === "number" ? parsed.usage.cacheReadTokens : 0,
1225
+ cacheWriteTokens: typeof parsed.usage.cacheWriteTokens === "number" ? parsed.usage.cacheWriteTokens : 0,
1226
+ }
1163
1227
  : null,
1164
1228
  res:
1165
1229
  parsed.res && typeof parsed.res === "object"
@@ -1470,6 +1534,8 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1470
1534
  if (create.status !== 0) throw new Error(`container create failed:\n${create.stderr}`);
1471
1535
  containerId = create.stdout.trim();
1472
1536
  const sandboxMs = Date.now() - t0;
1537
+ // Docker's phases are measured from create, its rent clock.
1538
+ const phases: StartupPhases = { ready: sandboxMs };
1473
1539
 
1474
1540
  const cp = (from: string, to: string) => {
1475
1541
  const out = spawnSync(cli(), ["cp", from, to], { encoding: "utf8" });
@@ -1478,6 +1544,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1478
1544
  cp(`${wsIn}/.`, `${containerId}:/workspace`);
1479
1545
  cp(`${libIn}/.`, `${containerId}:/library`);
1480
1546
  cp(`${jobIn}/input.json`, `${containerId}:/opt/runner/job/input.json`);
1547
+ phases.files = Date.now() - t0;
1481
1548
 
1482
1549
  // Start attached and read the driver's JSONL. The step timeout gets a
1483
1550
  // backstop out here — a wedged container is killed, not waited on.
@@ -1503,6 +1570,11 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1503
1570
  if (nl < 0) break;
1504
1571
  const line = buffer.slice(0, nl);
1505
1572
  buffer = buffer.slice(nl + 1);
1573
+ const marked = parseDriverMark(line);
1574
+ if (marked) {
1575
+ phases[marked.name] ??= Math.max(0, marked.at - t0);
1576
+ continue;
1577
+ }
1506
1578
  const parsed = parseDriverLine(line);
1507
1579
  if (!parsed) continue;
1508
1580
  firstOutputAt ??= Date.now();
@@ -1546,6 +1618,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1546
1618
  sandboxMs,
1547
1619
  firstOutputMs: firstOutputAt === null ? null : firstOutputAt - t0 - sandboxMs,
1548
1620
  totalMs: Date.now() - t0,
1621
+ phases,
1549
1622
  };
1550
1623
  args.emit("info", timingLine(timing));
1551
1624
  return { ...outcome, timing };