@foldrun/core 2.10.0 → 2.11.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 (65) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/catalog.d.ts +3 -0
  4. package/dist/src/catalog.js +4 -1
  5. package/dist/src/deploy.js +16 -0
  6. package/dist/src/eval-compare.d.ts +74 -0
  7. package/dist/src/eval-compare.js +187 -0
  8. package/dist/src/evals.d.ts +62 -3
  9. package/dist/src/evals.js +161 -36
  10. package/dist/src/flow-lint.js +4 -0
  11. package/dist/src/frontmatter-fields.js +4 -1
  12. package/dist/src/git-hook.d.ts +1 -0
  13. package/dist/src/git-hook.js +70 -0
  14. package/dist/src/git.d.ts +37 -0
  15. package/dist/src/git.js +101 -0
  16. package/dist/src/gitrepo.d.ts +113 -0
  17. package/dist/src/gitrepo.js +404 -0
  18. package/dist/src/grades.d.ts +118 -0
  19. package/dist/src/grades.js +481 -0
  20. package/dist/src/judge-verdict.d.ts +1 -0
  21. package/dist/src/judge-verdict.js +3 -1
  22. package/dist/src/online-checks.d.ts +4 -0
  23. package/dist/src/online-checks.js +5 -1
  24. package/dist/src/openapi.d.ts +4 -0
  25. package/dist/src/openapi.js +5 -1
  26. package/dist/src/platform.d.ts +12 -3
  27. package/dist/src/platform.js +9 -1
  28. package/dist/src/providers.js +8 -1
  29. package/dist/src/starter.js +5 -0
  30. package/dist/src/store.d.ts +8 -0
  31. package/dist/src/store.js +5 -1
  32. package/dist/src/tar.d.ts +30 -0
  33. package/dist/src/tar.js +112 -0
  34. package/dist/src/tool-test.js +19 -7
  35. package/dist/src/translator.d.ts +4 -0
  36. package/dist/src/translator.js +22 -3
  37. package/dist/src/trigger-filter.d.ts +28 -0
  38. package/dist/src/trigger-filter.js +272 -0
  39. package/dist/src/trigger-log.d.ts +1 -1
  40. package/dist/src/triggers.d.ts +2 -0
  41. package/dist/src/triggers.js +13 -2
  42. package/dist/src/untrusted-fetch.d.ts +16 -0
  43. package/dist/src/untrusted-fetch.js +60 -2
  44. package/index.ts +3 -0
  45. package/package.json +2 -2
  46. package/src/catalog.ts +5 -1
  47. package/src/deploy.ts +10 -0
  48. package/src/eval-compare.ts +243 -0
  49. package/src/evals.ts +195 -38
  50. package/src/flow-lint.ts +4 -0
  51. package/src/frontmatter-fields.ts +4 -1
  52. package/src/grades.ts +532 -0
  53. package/src/judge-verdict.ts +4 -2
  54. package/src/online-checks.ts +5 -1
  55. package/src/openapi.ts +6 -1
  56. package/src/platform.ts +16 -4
  57. package/src/providers.ts +8 -1
  58. package/src/starter.ts +5 -0
  59. package/src/store.ts +11 -1
  60. package/src/tool-test.ts +21 -7
  61. package/src/translator.ts +24 -3
  62. package/src/trigger-filter.ts +264 -0
  63. package/src/trigger-log.ts +1 -0
  64. package/src/triggers.ts +14 -2
  65. package/src/untrusted-fetch.ts +65 -2
@@ -0,0 +1,113 @@
1
+ import type { DiffOp, Revision, RevisionFile, RevisionSummary } from "./history.ts";
2
+ /** Where a workspace's bare repository lives. `@library` is the account shelf. */
3
+ export declare function repoDir(tenant: string, scope: string): string;
4
+ /** Is there a git binary? Checked once; an install without one keeps the journal. */
5
+ export declare function gitAvailable(): boolean;
6
+ export declare function repoExists(tenant: string, scope: string): boolean;
7
+ /** Create the repository if it is not there. Idempotent. */
8
+ export declare function ensureRepo(tenant: string, scope: string): string;
9
+ export declare function headSha(tenant: string, scope: string, ref?: string): string | null;
10
+ /**
11
+ * Apply a change set as one commit on main, using plumbing against a scratch
12
+ * index seeded from HEAD. No working copy, no checkout: each changed path is
13
+ * hashed straight from its content into the object store.
14
+ *
15
+ * Returns the new commit, or null when the tree did not change.
16
+ */
17
+ export declare function commitChanges(tenant: string, scope: string, files: RevisionFile[], meta?: {
18
+ by?: string;
19
+ message?: string;
20
+ at?: string;
21
+ }): string | null;
22
+ /** Commits on main, newest first, as the same summary the journal gave. */
23
+ export declare function listCommits(tenant: string, scope: string, opts?: {
24
+ forPath?: string;
25
+ limit?: number;
26
+ ref?: string;
27
+ }): RevisionSummary[];
28
+ /** One commit in full: every changed file with its before and after. */
29
+ export declare function readCommit(tenant: string, scope: string, sha: string, forPath?: string): Revision | null;
30
+ export interface RefInfo {
31
+ name: string;
32
+ sha: string;
33
+ /** Commit subject and time, for a branch list that says what each is. */
34
+ subject: string;
35
+ at: string;
36
+ }
37
+ export declare function listRefs(tenant: string, scope: string, kind: "heads" | "tags"): RefInfo[];
38
+ /**
39
+ * The commit a restore means by `to`, on main's history:
40
+ *
41
+ * a sha or a prefix of one that commit
42
+ * a branch or tag name where it points
43
+ * an ISO date or time the last commit on main at or before it
44
+ * "3d", "12h", "90m", "2w" the same, that long ago
45
+ *
46
+ * Null when nothing matches — before the first commit, or a name that is
47
+ * not there. Throws on a `to` that cannot be any of these, so a typo is a
48
+ * 400 rather than a silent "nothing found".
49
+ */
50
+ export declare function resolveRestorePoint(tenant: string, scope: string, to: string, now?: number): {
51
+ sha: string;
52
+ at: string;
53
+ by: string;
54
+ message: string;
55
+ } | null;
56
+ /** The files at a commit, for a browser or a deploy. */
57
+ export declare function listTree(tenant: string, scope: string, ref?: string): {
58
+ path: string;
59
+ mode: string;
60
+ }[];
61
+ export declare function readBlob(tenant: string, scope: string, ref: string, p: string): string | null;
62
+ /** The whole tree at a ref as deploy files — what a push turns into a workspace. */
63
+ export declare function filesAt(tenant: string, scope: string, ref: string): {
64
+ path: string;
65
+ content: string;
66
+ }[];
67
+ /**
68
+ * What a ref would change on main: every file that differs, with both sides.
69
+ * The branch's own commits are `listCommits({ ref })`; this is the net
70
+ * effect, which is what a reviewer reads.
71
+ */
72
+ export declare function diffRefs(tenant: string, scope: string, base: string, head: string): RevisionFile[];
73
+ /**
74
+ * Merge a branch into main and return the new main. A fast-forward when it
75
+ * is one; otherwise a merge commit whose tree is the BRANCH's — the branch
76
+ * is what the person reviewed and chose, and a three-way textual merge of
77
+ * markdown with no one to resolve conflicts would deploy text nobody wrote.
78
+ * Both parents are recorded, so history shows the merge for what it was.
79
+ */
80
+ export declare function mergeBranch(tenant: string, scope: string, branch: string, meta?: {
81
+ by?: string;
82
+ }): {
83
+ sha: string;
84
+ fastForward: boolean;
85
+ };
86
+ export declare function deleteBranch(tenant: string, scope: string, branch: string): void;
87
+ /** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
88
+ * run it off the request path. The token never touches disk: it is in the
89
+ * URL for this one process and nowhere else. */
90
+ export declare function pushMirror(tenant: string, scope: string, url: string): {
91
+ ok: boolean;
92
+ detail: string;
93
+ };
94
+ /**
95
+ * Where the compiled pre-receive check is, found on disk rather than through
96
+ * require.resolve — a bundler rewrites require.resolve("pkg") into a module
97
+ * id, and the hook was once told to run a script called "51113". Searched
98
+ * from the working directory upward: the monorepo layout, then an installed
99
+ * package. Empty when there is no compiled check (a dev server on the source
100
+ * tree), in which case a push is checked at deploy instead of refused — the
101
+ * same rules, one step later.
102
+ */
103
+ export declare function hookScriptPath(): string;
104
+ /**
105
+ * The pre-receive hook: a push that fails the deploy checks is refused with
106
+ * the errors in the pusher's terminal, instead of accepted and then not
107
+ * deployed. The hook shells out to node with the compiled check, so it runs
108
+ * the same rules as a dashboard deploy; which node and which script are
109
+ * passed in the environment by the HTTP route, because a hook inherits git's
110
+ * environment and nothing else.
111
+ */
112
+ export declare function installHooks(dir: string): void;
113
+ export { type DiffOp };
@@ -0,0 +1,404 @@
1
+ // A git repository per workspace — the platform as the remote.
2
+ //
3
+ // git remote add foldrun https://app.foldrun.io/git/<tenant>/<workspace>.git
4
+ // git push foldrun main → deploys, commit on record
5
+ // git clone …/<workspace>.git → the workspace, with its whole history
6
+ //
7
+ // One bare repository per workspace on the data volume. Every change the
8
+ // platform makes — a dashboard save, a deploy, a deletion — is a commit into
9
+ // it, made with git's own plumbing against a scratch index so no working copy
10
+ // is ever kept. Every change a person makes is a push into it. So there is
11
+ // one history, it is real git, and anyone can take it away with `git clone`.
12
+ //
13
+ // This deliberately stops at being a remote. Pull requests, reviews and
14
+ // issues are a forge, which is a product; GitHub and GitLab remain a fine
15
+ // mirror for anyone who wants that on top.
16
+ //
17
+ // history.ts delegates here when a repository exists, so the History page,
18
+ // the per-file panel and Observe's eval attribution all read git without
19
+ // knowing it. The journal it kept before stays as the fallback for an install
20
+ // without a git binary.
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { spawnSync } from "node:child_process";
25
+ import { dataRoot } from "./paths.js";
26
+ const SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
27
+ const safe = (v, what) => {
28
+ if (!SAFE.test(v))
29
+ throw new Error(`illegal ${what}: ${v}`);
30
+ return v;
31
+ };
32
+ /** Where a workspace's bare repository lives. `@library` is the account shelf. */
33
+ export function repoDir(tenant, scope) {
34
+ safe(tenant, "tenant");
35
+ const name = scope === "@library" ? "_library" : safe(scope, "workspace");
36
+ return path.join(dataRoot(), tenant, ".git", `${name}.git`);
37
+ }
38
+ let gitChecked = null;
39
+ /** Is there a git binary? Checked once; an install without one keeps the journal. */
40
+ export function gitAvailable() {
41
+ if (gitChecked === null) {
42
+ gitChecked = spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0;
43
+ }
44
+ return gitChecked;
45
+ }
46
+ function git(dir, args, opts = {}) {
47
+ const r = spawnSync("git", ["--git-dir", dir, ...args], {
48
+ input: opts.input,
49
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...opts.env },
50
+ maxBuffer: 64 * 1024 * 1024,
51
+ });
52
+ if (r.status !== 0) {
53
+ throw new Error(`git ${args[0]} failed: ${(r.stderr?.toString() ?? "").trim().slice(0, 500)}`);
54
+ }
55
+ return r.stdout.toString();
56
+ }
57
+ export function repoExists(tenant, scope) {
58
+ return fs.existsSync(path.join(repoDir(tenant, scope), "HEAD"));
59
+ }
60
+ /** Create the repository if it is not there. Idempotent. */
61
+ export function ensureRepo(tenant, scope) {
62
+ const dir = repoDir(tenant, scope);
63
+ if (!fs.existsSync(path.join(dir, "HEAD"))) {
64
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
65
+ spawnSync("git", ["init", "--bare", "--quiet", "--initial-branch=main", dir], { stdio: "ignore" });
66
+ // Pushes over HTTP need this; it is the one config a bare remote wants.
67
+ git(dir, ["config", "http.receivepack", "true"]);
68
+ }
69
+ installHooks(dir);
70
+ return dir;
71
+ }
72
+ export function headSha(tenant, scope, ref = "refs/heads/main") {
73
+ if (!repoExists(tenant, scope))
74
+ return null;
75
+ const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "rev-parse", "--verify", "--quiet", ref]);
76
+ return r.status === 0 ? r.stdout.toString().trim() : null;
77
+ }
78
+ /**
79
+ * Apply a change set as one commit on main, using plumbing against a scratch
80
+ * index seeded from HEAD. No working copy, no checkout: each changed path is
81
+ * hashed straight from its content into the object store.
82
+ *
83
+ * Returns the new commit, or null when the tree did not change.
84
+ */
85
+ export function commitChanges(tenant, scope, files, meta = {}) {
86
+ const dir = ensureRepo(tenant, scope);
87
+ const parent = headSha(tenant, scope);
88
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-index-"));
89
+ const index = path.join(scratch, "index");
90
+ // update-index insists on a work tree even when every entry arrives as
91
+ // --cacheinfo and nothing on disk is read. The scratch directory is one.
92
+ const env = { GIT_INDEX_FILE: index, GIT_WORK_TREE: scratch };
93
+ try {
94
+ if (parent)
95
+ git(dir, ["read-tree", parent], { env });
96
+ for (const f of files) {
97
+ if (f.after === null) {
98
+ if (parent)
99
+ git(dir, ["update-index", "--force-remove", "--", f.path], { env });
100
+ continue;
101
+ }
102
+ const blob = git(dir, ["hash-object", "-w", "--stdin"], { input: f.after }).trim();
103
+ // Executable where code lives, matching what the writers chmod on disk.
104
+ const mode = /\.(py|sh|mjs|js|rb)$/.test(f.path) && /(^|\/)(scripts|tools)\//.test(f.path) ? "100755" : "100644";
105
+ git(dir, ["update-index", "--add", "--cacheinfo", `${mode},${blob},${f.path}`], { env });
106
+ }
107
+ const tree = git(dir, ["write-tree"], { env }).trim();
108
+ if (parent && git(dir, ["rev-parse", `${parent}^{tree}`]).trim() === tree)
109
+ return null;
110
+ const who = meta.by ?? "system";
111
+ const email = who.includes("@") ? who : `${who}@foldrun`;
112
+ const name = who.includes("@") ? who.split("@")[0] : who;
113
+ const date = meta.at ? new Date(meta.at).toISOString() : new Date().toISOString();
114
+ const commit = git(dir, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", meta.message ?? describe(files)], {
115
+ env: {
116
+ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
117
+ GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
118
+ },
119
+ }).trim();
120
+ git(dir, ["update-ref", "refs/heads/main", commit, ...(parent ? [parent] : [])]);
121
+ return commit;
122
+ }
123
+ finally {
124
+ fs.rmSync(scratch, { recursive: true, force: true });
125
+ }
126
+ }
127
+ function describe(files) {
128
+ if (files.length === 1) {
129
+ const f = files[0];
130
+ return `${f.before === null ? "created" : f.after === null ? "deleted" : "edited"} ${f.path}`;
131
+ }
132
+ return `changed ${files.length} files`;
133
+ }
134
+ // ------------------------------------------------------------------ reading
135
+ const SEP = "";
136
+ /** Commits on main, newest first, as the same summary the journal gave. */
137
+ export function listCommits(tenant, scope, opts = {}) {
138
+ if (!headSha(tenant, scope, opts.ref))
139
+ return [];
140
+ const dir = repoDir(tenant, scope);
141
+ const out = git(dir, [
142
+ "log",
143
+ opts.ref ?? "refs/heads/main",
144
+ `--max-count=${opts.limit ?? 200}`,
145
+ `--format=%H${SEP}%aI${SEP}%ae${SEP}%s`,
146
+ "--name-only",
147
+ ...(opts.forPath ? ["--", opts.forPath] : []),
148
+ ]);
149
+ const rows = [];
150
+ let current = null;
151
+ for (const line of out.split("\n")) {
152
+ if (line.includes(SEP)) {
153
+ const [id, at, by, message] = line.split(SEP);
154
+ current = { id, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message, commit: id, paths: [] };
155
+ rows.push(current);
156
+ }
157
+ else if (line.trim() && current) {
158
+ current.paths.push(line.trim());
159
+ }
160
+ }
161
+ return rows;
162
+ }
163
+ /** One commit in full: every changed file with its before and after. */
164
+ export function readCommit(tenant, scope, sha, forPath) {
165
+ if (!repoExists(tenant, scope) || !/^[0-9a-f]{4,40}$/.test(sha))
166
+ return null;
167
+ const dir = repoDir(tenant, scope);
168
+ const head = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${sha}^{commit}`]);
169
+ if (head.status !== 0)
170
+ return null;
171
+ const full = head.stdout.toString().trim();
172
+ const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%aI${SEP}%ae${SEP}%s`, full]).trim().split(SEP);
173
+ const hasParent = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${full}^`]).status === 0;
174
+ const names = git(dir, [
175
+ "diff-tree", "--no-commit-id", "--name-status", "-r", "--root", full,
176
+ ...(forPath ? ["--", forPath] : []),
177
+ ]);
178
+ const files = [];
179
+ for (const line of names.split("\n").filter(Boolean)) {
180
+ const [status, p] = line.split("\t");
181
+ const before = status === "A" || !hasParent ? null : blob(dir, `${full}^`, p);
182
+ const after = status === "D" ? null : blob(dir, full, p);
183
+ files.push({ path: p, before, after });
184
+ }
185
+ return {
186
+ id: full,
187
+ at,
188
+ by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by,
189
+ message: msg.join(SEP),
190
+ commit: full,
191
+ files,
192
+ };
193
+ }
194
+ function blob(dir, rev, p) {
195
+ const r = spawnSync("git", ["--git-dir", dir, "show", `${rev}:${p}`], { maxBuffer: 64 * 1024 * 1024 });
196
+ return r.status === 0 ? r.stdout.toString() : null;
197
+ }
198
+ export function listRefs(tenant, scope, kind) {
199
+ if (!repoExists(tenant, scope))
200
+ return [];
201
+ const out = git(repoDir(tenant, scope), [
202
+ "for-each-ref", `refs/${kind}`, "--sort=-committerdate",
203
+ `--format=%(refname:short)${SEP}%(objectname)${SEP}%(subject)${SEP}%(committerdate:iso-strict)`,
204
+ ]);
205
+ return out.split("\n").filter(Boolean).map((l) => {
206
+ const [name, sha, subject, at] = l.split(SEP);
207
+ return { name, sha, subject, at };
208
+ });
209
+ }
210
+ /**
211
+ * The commit a restore means by `to`, on main's history:
212
+ *
213
+ * a sha or a prefix of one that commit
214
+ * a branch or tag name where it points
215
+ * an ISO date or time the last commit on main at or before it
216
+ * "3d", "12h", "90m", "2w" the same, that long ago
217
+ *
218
+ * Null when nothing matches — before the first commit, or a name that is
219
+ * not there. Throws on a `to` that cannot be any of these, so a typo is a
220
+ * 400 rather than a silent "nothing found".
221
+ */
222
+ export function resolveRestorePoint(tenant, scope, to, now = Date.now()) {
223
+ const t = to.trim();
224
+ if (!t || t.startsWith("-"))
225
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
226
+ if (!headSha(tenant, scope))
227
+ return null;
228
+ const dir = repoDir(tenant, scope);
229
+ let sha = null;
230
+ const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
231
+ const moment = rel
232
+ ? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]]
233
+ : /^\d{4}-\d{2}-\d{2}/.test(t)
234
+ ? Date.parse(t)
235
+ : null;
236
+ if (moment !== null) {
237
+ if (Number.isNaN(moment))
238
+ throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
239
+ sha = git(dir, ["log", "-1", "--format=%H", `--until=@${Math.floor(moment / 1000)}`, "refs/heads/main"]).trim() || null;
240
+ }
241
+ else {
242
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$/.test(t))
243
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
244
+ for (const ref of [/^[0-9a-f]{4,40}$/.test(t) ? `${t}^{commit}` : null, `refs/heads/${t}^{commit}`, `refs/tags/${t}^{commit}`]) {
245
+ if (!ref)
246
+ continue;
247
+ const r = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", ref]);
248
+ if (r.status === 0) {
249
+ sha = r.stdout.toString().trim();
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ if (!sha)
255
+ return null;
256
+ const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%cI${SEP}%ae${SEP}%s`, sha]).trim().split(SEP);
257
+ return { sha, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message: msg.join(SEP) };
258
+ }
259
+ /** The files at a commit, for a browser or a deploy. */
260
+ export function listTree(tenant, scope, ref = "refs/heads/main") {
261
+ if (!headSha(tenant, scope, ref))
262
+ return [];
263
+ return git(repoDir(tenant, scope), ["ls-tree", "-r", "--format=%(objectmode) %(path)", ref])
264
+ .split("\n")
265
+ .filter(Boolean)
266
+ .map((l) => {
267
+ const [mode, ...rest] = l.split(" ");
268
+ return { mode, path: rest.join(" ") };
269
+ });
270
+ }
271
+ export function readBlob(tenant, scope, ref, p) {
272
+ if (!repoExists(tenant, scope))
273
+ return null;
274
+ return blob(repoDir(tenant, scope), ref, p);
275
+ }
276
+ /** The whole tree at a ref as deploy files — what a push turns into a workspace. */
277
+ export function filesAt(tenant, scope, ref) {
278
+ const dir = repoDir(tenant, scope);
279
+ return listTree(tenant, scope, ref).flatMap((t) => {
280
+ const content = blob(dir, ref, t.path);
281
+ return content === null ? [] : [{ path: t.path, content }];
282
+ });
283
+ }
284
+ /**
285
+ * What a ref would change on main: every file that differs, with both sides.
286
+ * The branch's own commits are `listCommits({ ref })`; this is the net
287
+ * effect, which is what a reviewer reads.
288
+ */
289
+ export function diffRefs(tenant, scope, base, head) {
290
+ if (!headSha(tenant, scope, base) || !headSha(tenant, scope, head))
291
+ return [];
292
+ const dir = repoDir(tenant, scope);
293
+ const out = git(dir, ["diff", "--name-status", `${base}...${head}`]);
294
+ return out.split("\n").filter(Boolean).map((line) => {
295
+ const [status, p] = line.split("\t");
296
+ return {
297
+ path: p,
298
+ before: status === "A" ? null : blob(dir, base, p),
299
+ after: status === "D" ? null : blob(dir, head, p),
300
+ };
301
+ });
302
+ }
303
+ /**
304
+ * Merge a branch into main and return the new main. A fast-forward when it
305
+ * is one; otherwise a merge commit whose tree is the BRANCH's — the branch
306
+ * is what the person reviewed and chose, and a three-way textual merge of
307
+ * markdown with no one to resolve conflicts would deploy text nobody wrote.
308
+ * Both parents are recorded, so history shows the merge for what it was.
309
+ */
310
+ export function mergeBranch(tenant, scope, branch, meta = {}) {
311
+ const dir = repoDir(tenant, scope);
312
+ const main = headSha(tenant, scope);
313
+ const head = headSha(tenant, scope, `refs/heads/${safe(branch, "branch")}`);
314
+ if (!head)
315
+ throw new Error(`no branch "${branch}"`);
316
+ if (!main) {
317
+ git(dir, ["update-ref", "refs/heads/main", head]);
318
+ return { sha: head, fastForward: true };
319
+ }
320
+ const isAncestor = spawnSync("git", ["--git-dir", dir, "merge-base", "--is-ancestor", main, head]).status === 0;
321
+ if (isAncestor) {
322
+ git(dir, ["update-ref", "refs/heads/main", head, main]);
323
+ return { sha: head, fastForward: true };
324
+ }
325
+ const tree = git(dir, ["rev-parse", `${head}^{tree}`]).trim();
326
+ const who = meta.by ?? "foldrun";
327
+ const email = who.includes("@") ? who : `${who}@foldrun`;
328
+ const name = who.includes("@") ? who.split("@")[0] : who;
329
+ const date = new Date().toISOString();
330
+ const sha = git(dir, ["commit-tree", tree, "-p", main, "-p", head, "-m", `merge ${branch} into main`], {
331
+ env: {
332
+ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
333
+ GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
334
+ },
335
+ }).trim();
336
+ git(dir, ["update-ref", "refs/heads/main", sha, main]);
337
+ return { sha, fastForward: false };
338
+ }
339
+ export function deleteBranch(tenant, scope, branch) {
340
+ if (branch === "main")
341
+ throw new Error("main is the default branch");
342
+ git(repoDir(tenant, scope), ["update-ref", "-d", `refs/heads/${safe(branch, "branch")}`]);
343
+ }
344
+ /** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
345
+ * run it off the request path. The token never touches disk: it is in the
346
+ * URL for this one process and nowhere else. */
347
+ export function pushMirror(tenant, scope, url) {
348
+ if (!headSha(tenant, scope))
349
+ return { ok: false, detail: "nothing to push yet" };
350
+ const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "push", "--quiet", url, "+refs/heads/main:refs/heads/main"], {
351
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
352
+ timeout: 60_000,
353
+ });
354
+ const detail = (r.stderr?.toString() ?? "").trim()
355
+ // Never echo the URL back: it carries the token.
356
+ .replaceAll(url, "<mirror>")
357
+ .slice(0, 500);
358
+ return { ok: r.status === 0, detail: r.status === 0 ? "pushed" : detail || "push failed" };
359
+ }
360
+ /**
361
+ * Where the compiled pre-receive check is, found on disk rather than through
362
+ * require.resolve — a bundler rewrites require.resolve("pkg") into a module
363
+ * id, and the hook was once told to run a script called "51113". Searched
364
+ * from the working directory upward: the monorepo layout, then an installed
365
+ * package. Empty when there is no compiled check (a dev server on the source
366
+ * tree), in which case a push is checked at deploy instead of refused — the
367
+ * same rules, one step later.
368
+ */
369
+ export function hookScriptPath() {
370
+ let dir = process.cwd();
371
+ for (let i = 0; i < 6; i++) {
372
+ for (const rel of ["packages/core/dist/src/git-hook.js", "node_modules/@foldrun/core/dist/src/git-hook.js"]) {
373
+ const candidate = path.join(dir, rel);
374
+ if (fs.existsSync(candidate))
375
+ return candidate;
376
+ }
377
+ const parent = path.dirname(dir);
378
+ if (parent === dir)
379
+ break;
380
+ dir = parent;
381
+ }
382
+ return "";
383
+ }
384
+ /**
385
+ * The pre-receive hook: a push that fails the deploy checks is refused with
386
+ * the errors in the pusher's terminal, instead of accepted and then not
387
+ * deployed. The hook shells out to node with the compiled check, so it runs
388
+ * the same rules as a dashboard deploy; which node and which script are
389
+ * passed in the environment by the HTTP route, because a hook inherits git's
390
+ * environment and nothing else.
391
+ */
392
+ export function installHooks(dir) {
393
+ const hooks = path.join(dir, "hooks");
394
+ fs.mkdirSync(hooks, { recursive: true });
395
+ const hook = path.join(hooks, "pre-receive");
396
+ const script = `#!/bin/sh
397
+ # Installed by foldrun. Refuses a push to main whose tree fails the checks.
398
+ [ -n "$FOLDRUN_HOOK_NODE" ] && [ -n "$FOLDRUN_HOOK_SCRIPT" ] || exit 0
399
+ exec "$FOLDRUN_HOOK_NODE" "$FOLDRUN_HOOK_SCRIPT"
400
+ `;
401
+ if (!fs.existsSync(hook) || fs.readFileSync(hook, "utf8") !== script) {
402
+ fs.writeFileSync(hook, script, { mode: 0o755 });
403
+ }
404
+ }
@@ -0,0 +1,118 @@
1
+ import { type RunRecord } from "./store.ts";
2
+ import { judgeCall, type EvalInfo, type Assertion } from "./evals.ts";
3
+ /** How far back the pass looks for runs to grade. */
4
+ export declare const GRADE_WINDOW_MS: number;
5
+ /** How long grades are kept, and how many per eval. */
6
+ export declare const GRADE_KEEP_MS: number;
7
+ export declare const GRADE_KEEP_LINES = 5000;
8
+ export interface GradeAssertion {
9
+ type: Assertion["type"];
10
+ value: string;
11
+ passed: boolean;
12
+ detail: string;
13
+ }
14
+ export interface GradeCheck {
15
+ /** The `## heading` of the check. */
16
+ name: string;
17
+ passed: boolean;
18
+ /** Set when the check could not be decided — the judge failed to answer.
19
+ * An error is not a failure: nobody's work was found wanting. */
20
+ error?: string;
21
+ assertions: GradeAssertion[];
22
+ }
23
+ export interface Grade {
24
+ eval: string;
25
+ runId: string;
26
+ /** The run's flow (`adhoc:<agent>` for a direct agent run). */
27
+ flow: string;
28
+ at: string;
29
+ /** pass: every check passed. fail: at least one failed. error: none
30
+ * failed but at least one could not be decided. */
31
+ status: "pass" | "fail" | "error";
32
+ checks: GradeCheck[];
33
+ /** What the judges cost, USD — the run's own cost is on the run. */
34
+ costUsd: number;
35
+ /** The judge's model. */
36
+ model: string;
37
+ /** Which version of the grader graded it (a hash of the eval file), and
38
+ * which version of the workspace the run ran (RunRecord.revision). */
39
+ evalRevision: string;
40
+ runRevision: string | null;
41
+ runFinishedAt: string | null;
42
+ /** `sample` when the pass picked it; a person's email (or key label)
43
+ * when someone asked for it. */
44
+ by: string;
45
+ }
46
+ /** The graders of a workspace: its `trigger: runs` evals that can be read. */
47
+ export declare function listGraders(tenant: string, workspace: string): EvalInfo[];
48
+ /** The version of a grader, as its file reads now. */
49
+ export declare function evalRevision(tenant: string, workspace: string, info: EvalInfo): string;
50
+ /** Is this run in this grader's sample? Stable: a hash, not a coin. */
51
+ export declare function sampled(runId: string, evalName: string, percent: number): boolean;
52
+ /** The text a grader grades: every step's reply for a flow, the agent's
53
+ * replies for an agent. Null when the run has nothing of it. */
54
+ export declare function gradedOutput(info: EvalInfo, run: RunRecord): string | null;
55
+ /** Grade one finished run. Writes nothing; `recordGrade` keeps it. */
56
+ export declare function gradeRun(tenant: string, workspace: string, info: EvalInfo, run: RunRecord, opts?: {
57
+ by?: string;
58
+ now?: number;
59
+ judge?: typeof judgeCall;
60
+ }): Promise<Grade>;
61
+ /** Keep a grade: one line, appended. */
62
+ export declare function recordGrade(tenant: string, workspace: string, grade: Grade): void;
63
+ /** A grader's grades, newest first — one per run, the newest line for it. */
64
+ export declare function readGrades(tenant: string, workspace: string, name: string, opts?: {
65
+ sinceMs?: number;
66
+ }): Grade[];
67
+ /** The grades of one run, across every grader of the workspace. */
68
+ export declare function gradesOfRun(tenant: string, workspace: string, runId: string): Grade[];
69
+ export interface GradeSummary {
70
+ graded: number;
71
+ passed: number;
72
+ failed: number;
73
+ errors: number;
74
+ /** passed / (passed + failed); null when nothing was decided. */
75
+ rate: number | null;
76
+ costUsd: number;
77
+ /** One row per UTC day that has grades, oldest first. */
78
+ byDay: {
79
+ day: string;
80
+ graded: number;
81
+ passed: number;
82
+ failed: number;
83
+ }[];
84
+ }
85
+ export declare function summariseGrades(grades: Grade[]): GradeSummary;
86
+ /** When the pass first saw this grader — runs that finished before it are
87
+ * never sampled, so a new grader starts from now rather than back-filling. */
88
+ export declare function graderSince(tenant: string, workspace: string, name: string, now?: number): number;
89
+ /** When a grader last had a run graded by the pass (0: never) — the pass
90
+ * serves the longest-waiting grader first, so fairness holds across passes. */
91
+ export declare function graderLastServed(tenant: string, workspace: string, name: string): number;
92
+ /** How long an undecided grade (the judge did not answer — a spent usage
93
+ * cap, a provider outage) waits before the pass tries the run again, and
94
+ * how many tries it gets. */
95
+ export declare const GRADE_RETRY_MS = 3600000;
96
+ export declare const GRADE_TRIES = 3;
97
+ /** The runs the pass should grade for one grader, oldest first. */
98
+ export declare function gradeCandidates(tenant: string, workspace: string, info: EvalInfo, now?: number): string[];
99
+ /** Drop grades past the keeping window or the line cap. Rewritten whole
100
+ * (temp, then rename); only the pass prunes. */
101
+ export declare function pruneGrades(tenant: string, workspace: string, name: string, now?: number): number;
102
+ /**
103
+ * Grade what is due in one workspace: for each grader, the sampled runs that
104
+ * finished since it was first seen and are not graded yet. At most `limit`
105
+ * judges' worth of runs; `keepGoing` is asked before each run, so a pass
106
+ * that lost its lease stops between runs, never mid-grade.
107
+ */
108
+ export declare function gradeWorkspace(tenant: string, workspace: string, opts?: {
109
+ now?: number;
110
+ limit?: number;
111
+ keepGoing?: () => Promise<boolean> | boolean;
112
+ onGrade?: (g: Grade, info: EvalInfo) => void | Promise<void>;
113
+ judge?: typeof judgeCall;
114
+ }): Promise<{
115
+ graded: number;
116
+ failed: number;
117
+ errors: string[];
118
+ }>;