@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
@@ -15,45 +15,33 @@
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
  import fs from "node:fs";
28
37
  import path from "node:path";
29
38
  import crypto from "node:crypto";
30
39
  import { dataRoot } from "./paths.js";
31
- import { commitChanges, gitAvailable, headSha, listCommits, readCommit, repoExists, } from "./gitrepo.js";
32
40
  let readTree = () => null;
33
41
  export function registerTreeReader(fn) {
34
42
  const prev = readTree;
35
43
  readTree = (tenant, scope) => fn(tenant, scope) ?? prev(tenant, scope);
36
44
  }
37
- const afterCommit = [];
38
- export function registerAfterCommit(fn) {
39
- afterCommit.push(fn);
40
- }
41
- export function notifyCommit(tenant, scope, sha) {
42
- for (const fn of afterCommit) {
43
- try {
44
- fn(tenant, scope, sha);
45
- }
46
- catch {
47
- // a mirror that fails must not fail the save that triggered it
48
- }
49
- }
50
- }
51
- /** Journal or git? Git whenever the binary exists; the journal is the
52
- * fallback for an install without one. An install that started on the
53
- * journal keeps it readable — both are consulted on read. */
54
- const useGit = () => process.env.FOLDRUN_HISTORY !== "journal" && gitAvailable();
55
- // Not store's assertSafeName: store imports this module, and a cycle between
56
- // the two is avoidable for one regex.
57
45
  function assertSafeName(v, what) {
58
46
  if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(v))
59
47
  throw new Error(`illegal ${what}: ${v}`);
@@ -82,37 +70,6 @@ export function recordRevision(tenant, scope, files, meta = {}) {
82
70
  const changed = files.filter((f) => f.before !== f.after);
83
71
  if (changed.length === 0)
84
72
  return null;
85
- if (useGit()) {
86
- // First commit ever for this scope: import the whole tree as it stands
87
- // now (the writer has already written), so the repository is complete
88
- // from its first commit rather than starting with one file.
89
- const first = !headSha(tenant, scope);
90
- // Generated bundle files (knowledge/index.md, memory/log.md) are rewritten
91
- // by the same save that changed a concept, outside this call. Commit them
92
- // as they now stand, or a clone's index disagrees with its own files —
93
- // the exact drift the template tests exist to catch.
94
- const generated = first
95
- ? []
96
- : (readTree(tenant, scope) ?? []).filter((f) => /(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/.test(f.path));
97
- const toCommit = first ? (readTree(tenant, scope) ?? changed) : [...changed, ...generated];
98
- const sha = commitChanges(tenant, scope, toCommit, {
99
- by: meta.by,
100
- // Named after what the caller changed, not the generated files that
101
- // rode along — "edited knowledge/style.md", not "changed 3 files".
102
- message: first ? `initial import${meta.message ? ` — ${meta.message}` : ""}` : (meta.message ?? describe(changed)),
103
- });
104
- if (!sha)
105
- return null;
106
- notifyCommit(tenant, scope, sha);
107
- return {
108
- id: meta.commit ?? sha,
109
- at: new Date().toISOString(),
110
- by: meta.by ?? "system",
111
- message: meta.message ?? describe(changed),
112
- commit: sha,
113
- files: changed,
114
- };
115
- }
116
73
  const dir = historyDir(tenant, scope);
117
74
  fs.mkdirSync(dir, { recursive: true });
118
75
  const rev = {
@@ -137,9 +94,168 @@ export function recordRevision(tenant, scope, files, meta = {}) {
137
94
  commit: rev.commit,
138
95
  paths: rev.files.map((f) => f.path),
139
96
  };
97
+ // The tree as it stands now (the writer has already written), so this
98
+ // revision can be restored to exactly — including files a save rewrote
99
+ // outside this call, such as a bundle's generated index.
100
+ snapshot(tenant, scope, rev.id);
140
101
  fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
141
102
  return rev;
142
103
  }
104
+ // ----------------------------------------------------------------- snapshots
105
+ const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
106
+ const treePath = (tenant, scope, id) => path.join(historyDir(tenant, scope), "trees", `${id}.json`);
107
+ const blobPath = (tenant, scope, sha) => path.join(historyDir(tenant, scope), "blobs", sha.slice(0, 2), sha);
108
+ /** Write the scope's whole tree as revision `id`'s snapshot. False when there
109
+ * is no tree to read (a workspace being deleted).
110
+ *
111
+ * What a snapshot is: the tree as it stood just after this revision was
112
+ * written — point in time, which is what a restore wants. A revision's
113
+ * diff names only its own change; its snapshot is everything on disk at
114
+ * that moment, including another writer's change that landed in between,
115
+ * part of one if it was mid-write (saves are not transactional across
116
+ * files, and never were). Reviewed with Codex, 17 Oct 2026. */
117
+ function snapshot(tenant, scope, id) {
118
+ const tree = readTree(tenant, scope);
119
+ if (!tree)
120
+ return false;
121
+ const files = {};
122
+ for (const f of tree) {
123
+ if (f.after === null)
124
+ continue;
125
+ const sha = sha256(f.after);
126
+ const blob = blobPath(tenant, scope, sha);
127
+ if (!fs.existsSync(blob)) {
128
+ fs.mkdirSync(path.dirname(blob), { recursive: true });
129
+ const tmp = `${blob}.${process.pid}.tmp`;
130
+ fs.writeFileSync(tmp, f.after);
131
+ fs.renameSync(tmp, blob);
132
+ }
133
+ files[f.path] = sha;
134
+ }
135
+ const file = treePath(tenant, scope, id);
136
+ fs.mkdirSync(path.dirname(file), { recursive: true });
137
+ const tmp = `${file}.tmp`;
138
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, files }));
139
+ fs.renameSync(tmp, file);
140
+ return true;
141
+ }
142
+ /** Whether a revision can be restored to: it has a snapshot. */
143
+ export function hasSnapshot(tenant, scope, id) {
144
+ assertSafeName(id, "revision id");
145
+ return fs.existsSync(treePath(tenant, scope, id));
146
+ }
147
+ /** The whole tree at revision `id`, or null when it has no snapshot. */
148
+ export function snapshotAt(tenant, scope, id) {
149
+ assertSafeName(id, "revision id");
150
+ let manifest;
151
+ try {
152
+ manifest = JSON.parse(fs.readFileSync(treePath(tenant, scope, id), "utf8"));
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ return Object.entries(manifest.files ?? {})
158
+ .map(([p, sha]) => ({ path: p, content: fs.readFileSync(blobPath(tenant, scope, sha), "utf8") }))
159
+ .sort((a, b) => a.path.localeCompare(b.path));
160
+ }
161
+ /** Every revision of the scope that has a snapshot — one listing, rather
162
+ * than a stat per revision. */
163
+ function snapshotIds(tenant, scope) {
164
+ try {
165
+ return new Set(fs.readdirSync(path.join(historyDir(tenant, scope), "trees")).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)));
166
+ }
167
+ catch {
168
+ return new Set();
169
+ }
170
+ }
171
+ /** The newest revision that can be restored to — what "now" is in history. */
172
+ export function headRevision(tenant, scope) {
173
+ const have = snapshotIds(tenant, scope);
174
+ return journalRevisions(tenant, scope, { limit: Infinity }).find((r) => have.has(r.id)) ?? null;
175
+ }
176
+ /**
177
+ * A scope with no snapshot yet gets one: its tree as it stands, recorded as
178
+ * a revision of its own — where its history starts. Every workspace that
179
+ * existed when history moved off git starts here, and so does one created
180
+ * by a path that never recorded a change. Returns the head's id, or null
181
+ * when there is nothing to snapshot.
182
+ */
183
+ export function ensureBaseline(tenant, scope, by = "foldrun") {
184
+ const head = headRevision(tenant, scope);
185
+ if (head)
186
+ return head.id;
187
+ const tree = readTree(tenant, scope);
188
+ if (!tree || tree.length === 0)
189
+ return null;
190
+ const dir = historyDir(tenant, scope);
191
+ fs.mkdirSync(dir, { recursive: true });
192
+ const id = newRevisionId();
193
+ // One baseline even when the web tier and the worker both find none at
194
+ // once: whoever creates the marker first writes it, the other reads it.
195
+ const marker = path.join(dir, ".baseline");
196
+ try {
197
+ fs.writeFileSync(marker, id, { flag: "wx" });
198
+ }
199
+ catch {
200
+ const held = headRevision(tenant, scope)?.id ?? null;
201
+ // A marker with no baseline behind it, minutes old, is a process that
202
+ // died writing one: clear it, and the next call writes the baseline.
203
+ try {
204
+ if (!held && Date.now() - fs.statSync(marker).mtimeMs > 5 * 60_000)
205
+ fs.rmSync(marker, { force: true });
206
+ }
207
+ catch {
208
+ // gone already
209
+ }
210
+ return held;
211
+ }
212
+ const rev = {
213
+ id,
214
+ at: new Date().toISOString(),
215
+ by,
216
+ message: "history starts here",
217
+ commit: null,
218
+ files: [],
219
+ };
220
+ fs.writeFileSync(path.join(dir, `${rev.id}.json`), JSON.stringify(rev));
221
+ if (!snapshot(tenant, scope, rev.id))
222
+ return null;
223
+ const line = { id: rev.id, at: rev.at, by: rev.by, message: rev.message, commit: null, paths: [] };
224
+ fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
225
+ return rev.id;
226
+ }
227
+ /**
228
+ * The revision a restore means: an id (or the commit a deploy recorded), a
229
+ * date (`2026-10-17`, `2026-10-17T09:00Z`) or a span back from now (`90m`,
230
+ * `12h`, `3d`, `2w`) — the newest revision with a snapshot at or before that
231
+ * moment. Null when history holds nothing that old. Throws (status 400) on
232
+ * something that is none of those.
233
+ */
234
+ export function resolveRevision(tenant, scope, to, now = Date.now()) {
235
+ const t = to.trim();
236
+ if (!t || t.startsWith("-"))
237
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
238
+ const have = snapshotIds(tenant, scope);
239
+ const all = journalRevisions(tenant, scope, { limit: Infinity }).filter((r) => have.has(r.id));
240
+ const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
241
+ const moment = rel
242
+ ? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]]
243
+ : /^\d{4}-\d{2}-\d{2}/.test(t)
244
+ ? Date.parse(t)
245
+ : null;
246
+ let hit;
247
+ if (moment !== null) {
248
+ if (Number.isNaN(moment))
249
+ throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
250
+ hit = all.find((r) => Date.parse(r.at) <= moment);
251
+ }
252
+ else {
253
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
254
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
255
+ hit = all.find((r) => r.id === t || r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
256
+ }
257
+ return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
258
+ }
143
259
  const clip = (s) => s === null ? null : s.length > MAX_INLINE ? `[${s.length} bytes — too large to keep inline]` : s;
144
260
  /** "edited tools/x/run.py", "created 3 files", "deleted flows/old.md". */
145
261
  function describe(files) {
@@ -157,59 +273,76 @@ function describe(files) {
157
273
  }
158
274
  /** Newest first. `forPath` narrows to revisions that touched one file. */
159
275
  export function listRevisions(tenant, scope, opts = {}) {
160
- if (useGit() && repoExists(tenant, scope)) {
161
- const fromGit = listCommits(tenant, scope, opts);
162
- // Anything the journal holds from before the repository existed comes
163
- // after git's rows: older, and still worth showing.
164
- return [...fromGit, ...journalRevisions(tenant, scope, opts)].slice(0, opts.limit ?? 200);
165
- }
166
276
  return journalRevisions(tenant, scope, opts);
167
277
  }
168
278
  function journalRevisions(tenant, scope, opts = {}) {
169
- const file = path.join(historyDir(tenant, scope), "index.jsonl");
170
- if (!fs.existsSync(file))
279
+ const dir = historyDir(tenant, scope);
280
+ const file = path.join(dir, "index.jsonl");
281
+ if (!fs.existsSync(dir))
171
282
  return [];
172
- const all = fs
173
- .readFileSync(file, "utf8")
174
- .split("\n")
175
- .filter(Boolean)
176
- .flatMap((l) => {
283
+ let all = fs.existsSync(file)
284
+ ? fs
285
+ .readFileSync(file, "utf8")
286
+ .split("\n")
287
+ .filter(Boolean)
288
+ .flatMap((l) => {
289
+ try {
290
+ return [JSON.parse(l)];
291
+ }
292
+ catch {
293
+ return []; // a torn line — the rebuild below brings it back
294
+ }
295
+ })
296
+ .reverse()
297
+ : [];
298
+ // The revision files are the record; index.jsonl is a cache of them. An
299
+ // append from two processes on NFS is not atomic across clients, and a
300
+ // process can die between a revision's file and its line — so when the
301
+ // two disagree, the index is rebuilt from the files.
302
+ const recorded = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).length;
303
+ if (recorded !== new Set(all.map((r) => r.id)).size)
304
+ all = rebuildIndex(dir);
305
+ // A line appended just after a rebuild wrote it too: once each.
306
+ const seen = new Set();
307
+ all = all.filter((r) => !seen.has(r.id) && seen.add(r.id));
308
+ const narrowed = opts.forPath ? all.filter((r) => r.paths.includes(opts.forPath)) : all;
309
+ return narrowed.slice(0, opts.limit ?? 200);
310
+ }
311
+ /** index.jsonl again, from the revision files themselves, newest first. */
312
+ function rebuildIndex(dir) {
313
+ const rows = [];
314
+ for (const f of fs.readdirSync(dir)) {
315
+ if (!f.endsWith(".json"))
316
+ continue;
177
317
  try {
178
- return [JSON.parse(l)];
318
+ const file = path.join(dir, f);
319
+ const rev = JSON.parse(fs.readFileSync(file, "utf8"));
320
+ 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 });
179
321
  }
180
322
  catch {
181
- return []; // a torn line from a crash mid-append loses one entry, not the file
323
+ // a revision half-written by a process that died: not one
182
324
  }
183
- })
184
- .reverse();
185
- const narrowed = opts.forPath ? all.filter((r) => r.paths.includes(opts.forPath)) : all;
186
- return narrowed.slice(0, opts.limit ?? 200);
325
+ }
326
+ // Two saves in one millisecond share an `at`; the order they were written
327
+ // in breaks the tie (an id's suffix is random, so it cannot).
328
+ rows.sort((a, b) => a.at.localeCompare(b.at) || a.written - b.written || a.id.localeCompare(b.id));
329
+ try {
330
+ const tmp = path.join(dir, `index.jsonl.${process.pid}.tmp`);
331
+ fs.writeFileSync(tmp, rows.map(({ written: _w, ...r }) => JSON.stringify(r) + "\n").join(""));
332
+ fs.renameSync(tmp, path.join(dir, "index.jsonl"));
333
+ }
334
+ catch {
335
+ // a read-only volume still gets the answer
336
+ }
337
+ return rows.map(({ written: _w, ...r }) => r).reverse();
187
338
  }
188
339
  export function readRevision(tenant, scope, id, forPath) {
189
340
  assertSafeName(id, "revision id");
190
- if (useGit() && repoExists(tenant, scope) && /^[0-9a-f]{7,40}$/.test(id)) {
191
- const fromGit = readCommit(tenant, scope, id, forPath);
192
- if (fromGit)
193
- return fromGit;
194
- }
195
341
  const file = path.join(historyDir(tenant, scope), `${id}.json`);
196
342
  if (!fs.existsSync(file))
197
343
  return null;
198
- return JSON.parse(fs.readFileSync(file, "utf8"));
199
- }
200
- /**
201
- * Make sure a scope's repository has its initial import, so a clone made
202
- * right now is complete. A no-op once any commit exists.
203
- */
204
- export function ensureImported(tenant, scope) {
205
- if (!useGit())
206
- return null;
207
- if (headSha(tenant, scope))
208
- return headSha(tenant, scope);
209
- const tree = readTree(tenant, scope);
210
- if (!tree || tree.length === 0)
211
- return null;
212
- return commitChanges(tenant, scope, tree, { by: "foldrun", message: "initial import" });
344
+ const rev = JSON.parse(fs.readFileSync(file, "utf8"));
345
+ return forPath ? { ...rev, files: rev.files.filter((f) => f.path === forPath) } : rev;
213
346
  }
214
347
  /** The most recent revision id for a scope — what a change is attributed
215
348
  * to when nothing more specific (a deployed commit) is on record. */
@@ -88,16 +88,3 @@ export interface LibraryUse {
88
88
  relation: "use" | "shadowed";
89
89
  }
90
90
  export declare function libraryUsage(tenant: string, kind: LibraryKind, name: string): LibraryUse[];
91
- /**
92
- * Make the shelf match a tree — what a push to the library repository does.
93
- * Written with plain fs and recorded as ONE revision at the end: going
94
- * through writeLibraryFile per file would commit each one on top of the
95
- * pushed commit, and the history would show a push as fifty edits.
96
- */
97
- export declare function syncLibraryFromTree(tenant: string, files: {
98
- path: string;
99
- content: string;
100
- }[], meta?: {
101
- by?: string;
102
- commit?: string | null;
103
- }): number;
@@ -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
- }
@@ -60,7 +60,7 @@ export function credentialLine(c) {
60
60
  //
61
61
  // An account brings its own model key without writing a provider: block:
62
62
  // three secrets in the ACCOUNT vault (set from Settings → Model, the API's
63
- // /api/account/model, or `foldrun model set`). A provider: block at any
63
+ // /api/account/model, or `foldrun account model set`). A provider: block at any
64
64
  // level still wins; this is what a step falls to when none names one, before
65
65
  // the platform's own credential.
66
66
  /** The account vault names the account's own model key lives under. */
@@ -19,7 +19,7 @@ export class ModelKeyError extends Error {
19
19
  }
20
20
  }
21
21
  export const NO_MODEL_KEY = "no model key: this account runs on its own API key, and none is set — add one in Settings → Model " +
22
- "(or `foldrun model set <provider> --key …`), or write a provider: block in AGENTS.md";
22
+ "(or `foldrun account model set <provider> --key …`), or write a provider: block in AGENTS.md";
23
23
  /**
24
24
  * `platform` is a function: the caller's own way to build the platform's env
25
25
  * (a step's, or the SDK's host env), called only when this account may use
@@ -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() { },
@@ -1,7 +1,7 @@
1
1
  import { type HookRecord } from "./hooks.ts";
2
2
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { type ApiSpec, type Effort } from "./store.ts";
4
- import type { EventExtra } from "./step-exec.ts";
4
+ import type { EventExtra, ExecUsage } from "./step-exec.ts";
5
5
  import { type RuntimeSpec } from "./runtime.ts";
6
6
  import { type NetworkLevel } from "./network.ts";
7
7
  import type { ScriptSpec } from "./script-tools.ts";
@@ -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;
@@ -117,10 +146,7 @@ export interface ContainerStepOutcome {
117
146
  /** The parsed JSON of an `output: json` step, crossing back as a value. */
118
147
  data?: unknown;
119
148
  costUsd: number | null;
120
- usage?: {
121
- inputTokens: number;
122
- outputTokens: number;
123
- } | null;
149
+ usage?: ExecUsage | null;
124
150
  /** Every turn priced at its own model, when sub-agents ran on others —
125
151
  * see ExecOutcome.turnsCostUsd. */
126
152
  turnsCostUsd?: number;