static-fire 0.0.0-stage → 0.54.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +30 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +250 -0
  8. package/src/cli.ts +383 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +450 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +43 -0
  16. package/src/core/compare.ts +206 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/coverage.ts +84 -0
  20. package/src/core/db.ts +608 -0
  21. package/src/core/derived.ts +74 -0
  22. package/src/core/engine.ts +85 -0
  23. package/src/core/facets.ts +143 -0
  24. package/src/core/grade.ts +197 -0
  25. package/src/core/grading-versions.ts +281 -0
  26. package/src/core/identity.ts +263 -0
  27. package/src/core/init.ts +321 -0
  28. package/src/core/judge-schema.ts +37 -0
  29. package/src/core/judge.ts +166 -0
  30. package/src/core/label.ts +43 -0
  31. package/src/core/lock.ts +40 -0
  32. package/src/core/migrate.ts +288 -0
  33. package/src/core/new-task.ts +85 -0
  34. package/src/core/outside-tools.ts +129 -0
  35. package/src/core/plan.ts +84 -0
  36. package/src/core/profiles.ts +42 -0
  37. package/src/core/project.ts +140 -0
  38. package/src/core/regrade.ts +242 -0
  39. package/src/core/score.ts +148 -0
  40. package/src/core/settings.ts +44 -0
  41. package/src/core/task-file.ts +95 -0
  42. package/src/core/task-page.ts +54 -0
  43. package/src/core/trial-detail.ts +73 -0
  44. package/src/core/types.ts +195 -0
  45. package/src/core/view-query.ts +102 -0
  46. package/src/core/view.ts +576 -0
  47. package/src/guide.md +624 -0
  48. package/src/server.ts +28 -0
  49. package/web/api.ts +6 -0
  50. package/web/app.tsx +1162 -0
  51. package/web/brand/offset-tokens.css +126 -0
  52. package/web/charts.tsx +190 -0
  53. package/web/compare.tsx +317 -0
  54. package/web/encoding.tsx +55 -0
  55. package/web/filter-menu.ts +195 -0
  56. package/web/how-it-works.tsx +92 -0
  57. package/web/index-switches.ts +29 -0
  58. package/web/index.html +17 -0
  59. package/web/levels.tsx +15 -0
  60. package/web/notices.ts +74 -0
  61. package/web/output-view.ts +61 -0
  62. package/web/pages.ts +42 -0
  63. package/web/run-status.ts +51 -0
  64. package/web/styles.css +368 -0
  65. package/web/task-card.ts +6 -0
  66. package/web/tasks.tsx +122 -0
  67. package/web/wording.ts +38 -0
package/src/api.ts ADDED
@@ -0,0 +1,250 @@
1
+ // The JSON API the dashboard reads. `serve` (src/server.ts) mounts it next to the page; tests mount it on
2
+ // their own server with a temporary store and catalog, so they never touch runs/.
3
+ import { parseRunRequest, planBatch, runBatch, startBatch, type BatchPlan } from "./core/batch";
4
+ import { batchState } from "./core/batch-state";
5
+ import { listBatchSummaries, summarizeBatch, type BatchSummary } from "./core/batches";
6
+ import { openStore } from "./core/db";
7
+ import type { Store } from "./core/db";
8
+ import { listTasks, loadConfigs, loadJudges, loadTask } from "./core/authored";
9
+ import { projectRoot } from "./core/project";
10
+ import { readTrialOutputFile, trialDetail } from "./core/trial-detail";
11
+ import { taskPage } from "./core/task-page";
12
+ import type { Task } from "./core/types";
13
+ import { compareGradings } from "./core/compare";
14
+ import { buildFacets } from "./core/facets";
15
+ import { buildView } from "./core/view";
16
+ import type { Catalog } from "./core/view";
17
+ import { parseViewQuery, toViewFilters } from "./core/view-query";
18
+ import { failureMessage, planRegrade, regrade, type RegradePlan } from "./core/regrade";
19
+
20
+ export interface ApiOptions {
21
+ /** The store the routes read (and, from later routes, write). */
22
+ store: Store;
23
+ /** The path of that store's database. The event stream opens its own connection to it. */
24
+ dbPath: string;
25
+ /** The authored definitions, read per request so an edited task or suite file shows on the next load. */
26
+ catalog: () => Catalog;
27
+ /** How often the event stream checks the database, in ms. Default 1000. */
28
+ pollMs?: number;
29
+ /** The project folder POST /api/runs plans against, and the configs, judges and tasks lists read. Default: the project found from the current folder. */
30
+ root?: string;
31
+ /** Where a started batch keeps its Profile snapshots. Default the project's .cache. */
32
+ cacheDir?: string;
33
+ /** Called on a checked plan before anything is written; throws to refuse it. Test servers refuse paid calls with it. */
34
+ checkPlan?: (plan: BatchPlan) => void;
35
+ /** The clock that decides whether a batch has stalled. */
36
+ now?: () => Date;
37
+ /**
38
+ * Called on a regrade's plan, with the tasks it grades, before anything is written; throws to refuse it.
39
+ * Test servers refuse a plan with a judge criterion in it (a real judge call).
40
+ */
41
+ checkRegrade?: (plan: RegradePlan, tasks: Task[]) => void;
42
+ /** The function a regrade runs. Default regrade.ts's; tests pass one that fails. */
43
+ runRegrade?: typeof regrade;
44
+ }
45
+
46
+ /** The regrade this server is running: one at a time. A page reload and a second tab read it from GET /api/regrade. */
47
+ export interface RegradeRun {
48
+ /** The view query it was started from, as /api/view's query string. */
49
+ query: string;
50
+ taskIds: string[];
51
+ /** The judge's label. */
52
+ judge: string;
53
+ startedAt: string;
54
+ /** Trials to grade, and gradings added so far. */
55
+ total: number;
56
+ graded: number;
57
+ }
58
+
59
+ /** The last regrade that ended here: its counts, or the error that stopped it. */
60
+ export interface RegradeEnded extends RegradeRun {
61
+ endedAt: string; done: number; invalid: number;
62
+ /** Trials that could not be graded; `error` names each one. */
63
+ failed: number;
64
+ error: string | null;
65
+ }
66
+
67
+ const bad = (e: unknown) => Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
68
+
69
+ /**
70
+ * A request that may change something (any method but GET and HEAD) from another site: its `Origin` is not the
71
+ * page's own (the Host it was sent to), or `Sec-Fetch-Site` says cross-site. A trial's HTML output runs in a sandboxed
72
+ * frame whose requests carry `Origin: null`, and any web site open in the browser can post to localhost: neither may
73
+ * start a run or a regrade (#68). The CLI and tests send no Origin and pass.
74
+ */
75
+ export function fromAnotherSite(req: Request): boolean {
76
+ if (req.method === "GET" || req.method === "HEAD") return false;
77
+ if (req.headers.get("sec-fetch-site") === "cross-site") return true;
78
+ const origin = req.headers.get("origin"), host = req.headers.get("host");
79
+ return origin !== null && (!host || (origin !== `http://${host}` && origin !== `https://${host}`));
80
+ }
81
+ const ANOTHER_SITE = "This request came from another site. The dashboard's API takes changes only from its own page.";
82
+
83
+ /** Every handler of `routes`, refusing a request from another site (403) before it runs. */
84
+ function ownPageOnly<R extends Record<string, unknown>>(routes: R): R {
85
+ type Handler = (req: Request, ...rest: unknown[]) => unknown;
86
+ const wrap = (h: Handler): Handler => (req, ...rest) => (fromAnotherSite(req) ? Response.json({ error: ANOTHER_SITE }, { status: 403 }) : h(req, ...rest));
87
+ return Object.fromEntries(Object.entries(routes).map(([path, r]) => [path, typeof r === "function" ? wrap(r as Handler)
88
+ : Object.fromEntries(Object.entries(r as Record<string, Handler>).map(([method, h]) => [method, wrap(h)]))])) as R;
89
+ }
90
+
91
+ /** The API routes, and `close` to release the event stream's database connection. */
92
+ export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRoot, cacheDir, checkPlan, now = () => new Date(), checkRegrade, runRegrade = regrade }: ApiOptions) {
93
+ const root = () => givenRoot ?? projectRoot(); // looked up per request, so a server built with only a catalog needs no project
94
+ // A connection of its own that only reads PRAGMA data_version. The value moves when any other connection
95
+ // commits, including the one the routes write through, so every write to the store emits an event.
96
+ const watcher = openStore(dbPath);
97
+ const dataVersion = () => Number(watcher.pragma("data_version"));
98
+ // Batches this process is running. A batch here is running even when no trial has moved for a while.
99
+ const live = new Set<string>();
100
+ let regrading: RegradeRun | null = null, lastRegrade: RegradeEnded | null = null;
101
+ const withState = (s: BatchSummary) => ({ ...s, ...batchState(s.batch, store.listTrials({ batchId: s.batch.id }), { live: live.has(s.batch.id), now: now() }) });
102
+
103
+ const routes = {
104
+ "/api/configs": () => Response.json(loadConfigs(root())),
105
+ "/api/judges": () => Response.json(loadJudges(root())),
106
+ "/api/suites": () => Response.json(catalog().suites), // the suites the view reads, so the scope selector matches it
107
+ "/api/tasks": () => Response.json(listTasks(root())),
108
+ /** One task as the Tasks page shows it (core/task-page.ts): never the graders' own material. 404 for a task the project does not have, 400 for an invalid task file. */
109
+ "/api/tasks/:id": (req: Request & { params: { id: string } }) => {
110
+ const r = root(), id = req.params.id;
111
+ if (!listTasks(r).some((t) => t.id === id)) return Response.json({ error: `no task ${id}` }, { status: 404 });
112
+ try { return Response.json(taskPage(loadTask(id, r))); } catch (e) { return bad(e); }
113
+ },
114
+ "/api/view": (req: Request) => {
115
+ try { return Response.json(buildView(store, toViewFilters(parseViewQuery(new URL(req.url).searchParams)), catalog())); } catch (e) { return bad(e); }
116
+ },
117
+ "/api/facets": (req: Request) => {
118
+ try { return Response.json(buildFacets(store, toViewFilters(parseViewQuery(new URL(req.url).searchParams)), catalog())); } catch (e) { return bad(e); }
119
+ },
120
+ /** Two grading setups of one task compared over the trials valid under both: the view parameters plus `a` and `b`. */
121
+ "/api/compare": (req: Request) => {
122
+ try {
123
+ const params = new URL(req.url).searchParams;
124
+ const a = params.get("a") ?? "", b = params.get("b") ?? "";
125
+ params.delete("a"); params.delete("b");
126
+ return Response.json(compareGradings(store, toViewFilters(parseViewQuery(params)), a, b, catalog()));
127
+ } catch (e) { return bad(e); }
128
+ },
129
+ /** Starts a batch: checks the request (nothing is written when it is bad), records the batch, returns its id, then runs it here. */
130
+ "/api/runs": {
131
+ POST: async (req: Request) => {
132
+ let plan: BatchPlan;
133
+ try {
134
+ const body = await req.json().catch(() => { throw new Error("the request body is not JSON"); });
135
+ plan = planBatch(parseRunRequest(body), root()); // the shape first: nothing is chosen from a malformed field
136
+ checkPlan?.(plan);
137
+ } catch (e) { return bad(e); }
138
+ const started = await startBatch(store, plan, { dbPath, root: root(), cacheDir });
139
+ const id = started.batch.id;
140
+ live.add(id);
141
+ runBatch(store, started, { onEvent: (m) => console.log(m) })
142
+ .catch((e) => console.error(`batch ${id}: ${e instanceof Error ? e.message : String(e)}`))
143
+ .finally(() => live.delete(id));
144
+ return Response.json({ id });
145
+ },
146
+ },
147
+ /**
148
+ * Regrades the trials a view lists as gradable (view.leftOut.gradable), with judge calls only. The query is
149
+ * /api/view's. Refused (400, nothing written) for a view on an earlier grading setup, a view with nothing to
150
+ * regrade, or a plan checkRegrade refuses; 409 while another regrade runs. Trials of a running batch are
151
+ * left to that batch, which is grading them. Returns 202, then grades in this process.
152
+ */
153
+ "/api/regrade": {
154
+ GET: () => Response.json({ running: regrading, last: lastRegrade }),
155
+ POST: (req: Request) => {
156
+ // No await before `regrading` is set: two requests cannot both pass this check.
157
+ if (regrading) return Response.json({ error: "A regrade is already running.", running: regrading }, { status: 409 });
158
+ let plan: RegradePlan, query: string, taskIds: string[], cat: Catalog, leftForBatch: number;
159
+ try {
160
+ const params = new URL(req.url).searchParams;
161
+ cat = catalog();
162
+ const view = buildView(store, toViewFilters(parseViewQuery(params)), cat);
163
+ query = params.toString();
164
+ taskIds = view.filters.taskIds;
165
+ const earlier = Object.entries(view.tasks).filter(([, t]) => !t.current).map(([id]) => id);
166
+ if (earlier.length) throw new Error(`This view is on an earlier grading version (${earlier.join(", ")}). A regrade always uses today's criteria.`);
167
+ const runningBatch = new Map<string, boolean>();
168
+ const isRunning = (batchId: string) => {
169
+ if (!runningBatch.has(batchId)) {
170
+ const b = store.getBatch(batchId);
171
+ runningBatch.set(batchId, !!b && batchState(b, store.listTrials({ batchId }), { live: live.has(batchId), now: now() }).state === "running");
172
+ }
173
+ return runningBatch.get(batchId)!;
174
+ };
175
+ const ids = view.leftOut.gradable.filter((id) => !isRunning(store.getTrial(id)!.batchId));
176
+ leftForBatch = view.leftOut.gradable.length - ids.length;
177
+ if (!ids.length) throw new Error(leftForBatch ? "Nothing in this view to regrade: its trials with no grading belong to a running batch." : "Nothing in this view to regrade.");
178
+ plan = planRegrade(store, { tasks: taskIds, trials: ids }, { dbPath, catalog: cat, judge: view.filters.judge.id });
179
+ if (!plan.todo.length) throw new Error("Nothing in this view to regrade.");
180
+ checkRegrade?.(plan, [...new Set(plan.todo.map((x) => x.taskId))].map((id) => cat.loadTask(id)));
181
+ } catch (e) { return bad(e); }
182
+ const run: RegradeRun = { query, taskIds, judge: plan.judge.label, startedAt: now().toISOString(), total: plan.todo.length, graded: 0 };
183
+ regrading = run;
184
+ const end = (c: { done: number; invalid: number; failed: number }, error: string | null) => {
185
+ lastRegrade = { ...run, endedAt: now().toISOString(), done: c.done, invalid: c.invalid, failed: c.failed, error };
186
+ regrading = null;
187
+ };
188
+ // An async function runs to its first await at once; a throw there must still clear the running state.
189
+ Promise.resolve()
190
+ .then(() => runRegrade(store, { tasks: taskIds, trials: plan.todo.map((x) => x.trialId) }, {
191
+ dbPath, catalog: cat, judge: plan.judge.id, votes: plan.votes,
192
+ onEvent: (m) => console.log(m), onGraded: () => { run.graded++; },
193
+ }))
194
+ .then((c) => {
195
+ const error = failureMessage(c);
196
+ if (error) console.error(`regrade: ${error}`);
197
+ end(c, error);
198
+ }, (e) => {
199
+ const error = e instanceof Error ? e.message : String(e);
200
+ console.error(`regrade: ${error}`);
201
+ end({ done: 0, invalid: 0, failed: 0 }, error);
202
+ });
203
+ return Response.json({ total: run.total, leftForBatch }, { status: 202 });
204
+ },
205
+ },
206
+ /** Every batch, newest first, with its progress and whether it is running, stalled or ended. */
207
+ "/api/batches": () => Response.json(listBatchSummaries(store).map(withState)),
208
+ /** One batch with the status of each trial. */
209
+ "/api/batches/:id": (req: Request & { params: { id: string } }) => {
210
+ const batch = store.getBatch(req.params.id);
211
+ if (!batch) return Response.json({ error: `no batch ${req.params.id}` }, { status: 404 });
212
+ const trials = store.listTrials({ batchId: batch.id }).map(({ id, taskId, configId, n, status, startedAt, endedAt, error }) => ({ id, taskId, configId, n, status, startedAt, endedAt, error }));
213
+ return Response.json({ ...withState(summarizeBatch(store, batch)), trials });
214
+ },
215
+ /** One trial with its run conditions and every grading it has, each with its verdicts. */
216
+ "/api/trials/:id": (req: Request & { params: { id: string } }) => {
217
+ const d = trialDetail(store, req.params.id);
218
+ return d ? Response.json(d) : Response.json({ error: `no trial ${req.params.id}` }, { status: 404 });
219
+ },
220
+ /** The trial's output file from its saved workspace, with its output path in `x-output-path`: an image with its media type, else text (served as Markdown, never as a page); 404 with the reason when there is none. */
221
+ "/api/trials/:id/output": (req: Request & { params: { id: string } }) => {
222
+ const out = readTrialOutputFile(store, dbPath, req.params.id);
223
+ if ("missing" in out) return new Response(out.missing, { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
224
+ // The output path (URI-encoded) tells the drawer how to show the text: an HTML page, Markdown or other text (#68).
225
+ const path = { "x-output-path": encodeURIComponent(out.path) };
226
+ return "image" in out ? new Response(out.image, { headers: { "content-type": out.mediaType, ...path } })
227
+ // Text a browser never runs as a page, even when this URL is opened on its own: the drawer frames it.
228
+ : new Response(out.text, { headers: { "content-type": "text/markdown; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox", ...path } });
229
+ },
230
+ /** Server-sent events: one on connect, then one each time the database changes. The data is the data_version. */
231
+ "/api/events": () => {
232
+ let last = -1, timer: Timer;
233
+ const stream = new ReadableStream({
234
+ start(ctrl) {
235
+ const tick = () => {
236
+ try {
237
+ const v = dataVersion();
238
+ if (v !== last) { last = v; ctrl.enqueue(`data: ${v}\n\n`); }
239
+ } catch { clearInterval(timer); }
240
+ };
241
+ tick();
242
+ timer = setInterval(tick, pollMs);
243
+ },
244
+ cancel() { clearInterval(timer); },
245
+ });
246
+ return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
247
+ },
248
+ };
249
+ return { routes: ownPageOnly(routes), close: () => watcher.close() };
250
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env bun
2
+ // static-fire — the same primitives the dashboard uses, for people and agents.
3
+ // static-fire --version the installed version
4
+ // static-fire init [<folder>] [--json] make the home (~/static-fire, or <folder>), report the home that is set, or
5
+ // make a second project in <folder> (core/init.ts); never overwrites a file
6
+ // static-fire guide the guide for agents (src/guide.md); needs no project
7
+ // static-fire home [<folder>] show the home, or save a project folder as the home; needs no project
8
+ // static-fire serve [--port 4477] [--dev]
9
+ // the dashboard, in production mode; --dev for development mode (hot reload)
10
+ // static-fire configs | judges | suites | tasks
11
+ // static-fire new task <id> a new task folder: evals/<id>/task.yaml (commented, to fill in) and inputs/
12
+ // static-fire run --suites writing[,visual] | --tasks a[,b] [--configs a,b|all] [--trials 3] [--judge id] [--votes 3] [--concurrency 4]
13
+ // one batch in the store; trial artifacts in the project's runs/trials/<trial-id>/
14
+ // static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid] [--concurrency 4]
15
+ // grade stored outputs again (judge calls only) where the current grading is missing
16
+ // static-fire run|regrade ... --plan [--json]
17
+ // the plan in runs (core/plan.ts): agent runs per config and judge calls; starts
18
+ // nothing and writes nothing. Without --plan, run and regrade print it first.
19
+ // static-fire rerun <batch> run its failed trials again, as new trials in the same batch
20
+ // static-fire list every batch in the store, newest first, with its trial counts
21
+ // static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
22
+ // the comparable trials, pooled across batches, from the store
23
+ // static-fire export [path] every record in the store as JSON (to stdout without a path)
24
+ // static-fire migrate [--from <folder>] one-off import of the 2026-11-06 experiment into the store
25
+ //
26
+ // The commands and the flags each takes are listed in core/commands.ts; a flag a command does not take is an error.
27
+ // The project is the nearest folder at or above the current folder with static-fire.yaml; outside any project it
28
+ // is the saved home (core/project.ts, ADR-0010). Each command that needs one calls needProject() before it reads
29
+ // or writes anything. When it uses the home, it says so in one line on stderr, so stdout (and --json) is
30
+ // unchanged. With no home it stops with an error that names `static-fire init`, and when the saved home is not a
31
+ // project any more, with an error that says how to set it again. The record store is the project's runs/static-fire.db,
32
+ // or STATIC_FIRE_DB when set; trial artifacts go next to the store, and the Profile cache in the project's .cache/.
33
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { parseArgs } from "node:util";
36
+ import { configLabel } from "./core/label";
37
+ import { coverageLine } from "./core/coverage";
38
+ import { listTasks, loadConfigs, loadJudges, loadSuites } from "./core/authored";
39
+ import { COMMANDS, OPTIONS, unknownFlags, USAGE } from "./core/commands";
40
+ import { ENGINE_ROOT, HomeNotProjectError, NoProjectError, projectPaths, resolveProject, type ProjectPaths } from "./core/project";
41
+
42
+ const LABEL_WIDTH = 52; // fits the longest built config label, so `show` columns line up
43
+
44
+ const [cmd, ...rest] = Bun.argv.slice(2);
45
+ if (cmd === "--version" || cmd === "-v") {
46
+ console.log((await import("../package.json")).default.version);
47
+ process.exit(0);
48
+ }
49
+ const { values, positionals, tokens } = parseArgs({ args: rest, allowPositionals: true, tokens: true, options: OPTIONS });
50
+ // A flag the command does not take stops it, so a flag is never ignored in silence.
51
+ if (cmd && COMMANDS[cmd]) {
52
+ const extra = unknownFlags(cmd, tokens.flatMap((t) => (t.kind === "option" ? [t.name] : [])));
53
+ if (extra.length) {
54
+ const takes = COMMANDS[cmd]!.flags;
55
+ console.error(`${cmd}: ${extra.join(", ")} ${extra.length === 1 ? "is not a flag" : "are not flags"} of \`static-fire ${cmd}\`; it takes ${takes.length ? takes.map((f) => `--${f}`).join(", ") : "no flags"}`);
56
+ process.exit(1);
57
+ }
58
+ }
59
+ // run and regrade print the plan as JSON only on its own: their progress lines are not JSON.
60
+ if ((cmd === "run" || cmd === "regrade") && values.json && !values.plan) {
61
+ console.error(`${cmd}: --json needs --plan (static-fire ${cmd} ... --plan --json prints the plan as JSON and starts nothing)`);
62
+ process.exit(1);
63
+ }
64
+
65
+ const fmt = (n: number | null | undefined, d = 1) => (n == null ? "—" : n.toFixed(d));
66
+ const log = (m: string) => console.log(m);
67
+
68
+ let saidHome = false;
69
+ /**
70
+ * The project of this command, or exit 1 before anything is written: with an error that names `static-fire init`
71
+ * when there is no project and no home, or that says how to set the home again when it is not a project any more.
72
+ * A command that uses the home from outside it says so, once, on stderr.
73
+ */
74
+ function needProject(): ProjectPaths {
75
+ let found;
76
+ try {
77
+ found = resolveProject();
78
+ } catch (e) {
79
+ if (!(e instanceof NoProjectError || e instanceof HomeNotProjectError)) throw e;
80
+ console.error(`${cmd}: ${e.message}`);
81
+ process.exit(1);
82
+ }
83
+ if (found.home && !saidHome) {
84
+ saidHome = true;
85
+ console.error(`using the home project: ${found.root}`);
86
+ }
87
+ return projectPaths(found.root);
88
+ }
89
+
90
+ switch (cmd) {
91
+ case "init": {
92
+ // Needs no project: it makes one, or reports the home or the project it runs in.
93
+ const { formatReport, NotAFolderError, runInit } = await import("./core/init");
94
+ if (positionals.length > 1) {
95
+ console.error("usage: static-fire init [<folder>] [--json]");
96
+ process.exit(1);
97
+ }
98
+ let report;
99
+ try {
100
+ report = runInit({ folder: positionals[0] });
101
+ } catch (e) {
102
+ if (e instanceof HomeNotProjectError) {
103
+ console.error(`init: ${e.message} Or run \`static-fire init <folder>\` to make a new project there, then \`static-fire home <folder>\`.`);
104
+ process.exit(1);
105
+ }
106
+ if (!(e instanceof NotAFolderError)) throw e;
107
+ console.error(`init: ${e.message}`);
108
+ process.exit(1);
109
+ }
110
+ process.stdout.write(values.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report));
111
+ break;
112
+ }
113
+ case "guide":
114
+ // An engine file, so it matches the installed version. It needs no project.
115
+ process.stdout.write(readFileSync(join(ENGINE_ROOT, "src", "guide.md"), "utf8"));
116
+ break;
117
+ case "home": {
118
+ // Needs no project. With a folder, saves it as the home when it is a project; the home changes only here.
119
+ const { NotAProjectError, readHome, saveHome, PROJECT_FILE } = await import("./core/project");
120
+ if (positionals.length > 1) {
121
+ console.error("usage: static-fire home [<folder>]");
122
+ process.exit(1);
123
+ }
124
+ const folder = positionals[0];
125
+ if (folder !== undefined) {
126
+ try {
127
+ log(`the home is now ${saveHome(folder)}`);
128
+ } catch (e) {
129
+ if (!(e instanceof NotAProjectError)) throw e;
130
+ console.error(`home: ${e.message}`);
131
+ process.exit(1);
132
+ }
133
+ break;
134
+ }
135
+ const home = readHome();
136
+ if (!home) {
137
+ log("no home is set. Run `static-fire home <folder>` with a project folder to set one, or `static-fire init` to make a project.");
138
+ break;
139
+ }
140
+ log(home);
141
+ const { existsSync } = await import("node:fs");
142
+ if (!existsSync(join(home, PROJECT_FILE))) {
143
+ console.error(`home: ${new HomeNotProjectError(home).message}`);
144
+ process.exitCode = 1;
145
+ }
146
+ break;
147
+ }
148
+ case "serve": {
149
+ const project = needProject();
150
+ const { serve } = await import("./server");
151
+ serve(Number(values.port), project, { development: values.dev === true });
152
+ break;
153
+ }
154
+ case "configs":
155
+ for (const c of loadConfigs(needProject().root)) console.log(`${c.id.padEnd(24)} ${configLabel(c)}`);
156
+ break;
157
+ case "judges":
158
+ loadJudges(needProject().root).forEach((j, i) => console.log(`${j.id.padEnd(24)} ${j.label}${i === 0 ? " (default)" : ""}`));
159
+ break;
160
+ case "suites":
161
+ for (const s of loadSuites(needProject().root)) console.log(`${s.id.padEnd(24)} ${s.title}: ${s.tasks.join(", ")}`);
162
+ break;
163
+ case "tasks":
164
+ for (const t of listTasks(needProject().root)) {
165
+ console.log(`${t.id.padEnd(24)} ${t.title}`);
166
+ for (const e of t.errors) console.log(` error: ${e}`);
167
+ for (const w of t.warnings) console.log(` warning: ${w}`);
168
+ }
169
+ break;
170
+ case "new": {
171
+ const [what, id] = positionals;
172
+ if (what !== "task" || !id || positionals.length > 2) {
173
+ console.error("usage: static-fire new task <id>");
174
+ process.exit(1);
175
+ }
176
+ const { root } = needProject();
177
+ const { BadTaskId, TaskExists, newTask } = await import("./core/new-task");
178
+ try {
179
+ newTask(root, id);
180
+ } catch (e) {
181
+ if (!(e instanceof BadTaskId || e instanceof TaskExists)) throw e;
182
+ console.error(`new task: ${e.message}`);
183
+ process.exit(1);
184
+ }
185
+ log(`made evals/${id}/task.yaml and evals/${id}/inputs/`);
186
+ log(`next: put the files the agent works from in evals/${id}/inputs/, fill in task.yaml, then run \`static-fire tasks\` to check it`);
187
+ break;
188
+ }
189
+ case "run": {
190
+ const { root, dbPath, cache } = needProject();
191
+ const { planBatch, runBatch, startBatch } = await import("./core/batch");
192
+ const { openStore } = await import("./core/db");
193
+ const list = (v?: string) => (v === undefined ? undefined : v.split(",").map((x) => x.trim()).filter(Boolean));
194
+ const plan = planBatch({
195
+ tasks: list(values.tasks), suites: values.suites === "all" ? undefined : list(values.suites), configs: list(values.configs),
196
+ trials: Number(values.trials), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined,
197
+ }, root);
198
+ const { batchPlanInRuns, formatPlan } = await import("./core/plan");
199
+ const inRuns = batchPlanInRuns(plan);
200
+ if (values.plan) { log(values.json ? JSON.stringify(inRuns, null, 2) : formatPlan(inRuns)); break; }
201
+ log(formatPlan(inRuns));
202
+ const store = openStore(dbPath);
203
+ try {
204
+ const started = await startBatch(store, plan, { dbPath, root, cacheDir: cache });
205
+ log(`batch ${started.batch.id}: ${plan.tasks.length} task(s) × ${plan.configs.length} config(s), ${started.trials.length} trial(s), judge ${plan.judge.label} × ${plan.votes} votes`);
206
+ await runBatch(store, started, { concurrency: Number(values.concurrency), onEvent: log });
207
+ } finally {
208
+ store.close();
209
+ }
210
+ break;
211
+ }
212
+ case "regrade": {
213
+ const { root, dbPath } = needProject();
214
+ const { formatCounts, planRegrade, regrade } = await import("./core/regrade");
215
+ const { emptyStore, openStore, snapshotStore } = await import("./core/db");
216
+ const { projectCatalog } = await import("./core/view");
217
+ const { formatPlan, regradePlanInRuns } = await import("./core/plan");
218
+ const list = (v?: string) => (v === undefined ? undefined : v.split(",").map((x) => x.trim()).filter(Boolean));
219
+ const filters = { tasks: list(values.tasks), configs: list(values.configs), batch: values.batch };
220
+ const opts = {
221
+ dbPath, catalog: projectCatalog(root), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined, onlyInvalid: values["only-invalid"],
222
+ concurrency: Number(values.concurrency), onEvent: log,
223
+ };
224
+ // The plan writes nothing: it is planned on a copy of the store in memory (an empty one when there is no store
225
+ // yet), so no file is made and an existing store keeps its schema and content.
226
+ const store = !values.plan ? openStore(dbPath) : existsSync(dbPath) ? snapshotStore(dbPath) : emptyStore();
227
+ try {
228
+ const inRuns = regradePlanInRuns(planRegrade(store, filters, opts));
229
+ if (values.plan) { log(values.json ? JSON.stringify(inRuns, null, 2) : formatPlan(inRuns)); break; }
230
+ log(formatPlan(inRuns));
231
+ const counts = await regrade(store, filters, opts);
232
+ log(formatCounts(counts));
233
+ if (counts.failed) {
234
+ console.error(`regrade: ${counts.failed} trial(s) could not be graded (listed above)`);
235
+ process.exitCode = 1;
236
+ }
237
+ } catch (e) {
238
+ console.error(`regrade: ${e instanceof Error ? e.message : String(e)}`);
239
+ process.exitCode = 1;
240
+ } finally {
241
+ store.close();
242
+ }
243
+ break;
244
+ }
245
+ case "rerun": {
246
+ const id = positionals[0];
247
+ if (!id) throw new Error("usage: static-fire rerun <batch>");
248
+ const { root, dbPath, cache } = needProject();
249
+ const { runBatch } = await import("./core/batch");
250
+ const { RerunRefused, planRerun, startRerun } = await import("./core/batches");
251
+ const { openStore } = await import("./core/db");
252
+ const store = openStore(dbPath);
253
+ try {
254
+ let plan;
255
+ try { plan = planRerun(store, id, root); } catch (e) {
256
+ if (!(e instanceof RerunRefused)) throw e;
257
+ console.error(`rerun: ${e.message}`);
258
+ process.exitCode = 1;
259
+ break;
260
+ }
261
+ if (plan.busy.length) log(`still queued or running, not rerun: ${plan.busy.join(", ")}`);
262
+ if (!plan.slots.length) { log(`no failed trials to rerun in ${id}`); break; }
263
+ const started = await startRerun(store, plan, { dbPath, root, cacheDir: cache });
264
+ for (const t of started.trials) log(`+ ${t.id} (for ${started.replaces[t.id]})`);
265
+ await runBatch(store, started, { concurrency: Number(values.concurrency), onEvent: log });
266
+ } finally {
267
+ store.close();
268
+ }
269
+ break;
270
+ }
271
+ case "list": {
272
+ const { dbPath } = needProject();
273
+ const { listBatchSummaries } = await import("./core/batches");
274
+ const { openStore } = await import("./core/db");
275
+ const store = openStore(dbPath);
276
+ let all;
277
+ try { all = listBatchSummaries(store); } finally { store.close(); }
278
+ if (!all.length) { log(`no batches in ${dbPath}`); break; }
279
+ const when = (iso: string) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
280
+ for (const { batch, request: r, counts: c, toRerun, ended } of all) {
281
+ log(`${batch.id} ${when(batch.startedAt)} ${ended ? `ended ${when(batch.endedAt!)}` : "not ended"}`);
282
+ const what = r.tasks ? `tasks ${r.tasks.join(", ")}` : `suites ${r.suites?.join(", ") ?? "all"}`;
283
+ log(` ${what} · configs ${r.configs.join(", ")} · ${r.trials} trial${r.trials === 1 ? "" : "s"} · judge ${r.judge} × ${r.votes} votes`);
284
+ const busy = [c.queued ? `queued ${c.queued}` : "", c.running ? `running ${c.running}` : ""].filter(Boolean);
285
+ log(` ${[`done ${c.done}`, `failed ${c.failed}`, `invalid ${c.invalid}`, ...busy, ...(toRerun ? [`${toRerun} to rerun`] : [])].join(" · ")}`);
286
+ }
287
+ break;
288
+ }
289
+ case "show": {
290
+ const { root, dbPath } = needProject();
291
+ const { openStore } = await import("./core/db");
292
+ const { buildView, projectCatalog } = await import("./core/view");
293
+ const list = (v: string | undefined) => v?.split(",").map((x) => x.trim()).filter(Boolean);
294
+ const store = openStore(dbPath);
295
+ let view;
296
+ try {
297
+ view = buildView(store, { suites: list(values.suites), tasks: list(values.tasks), judge: values.judge, grading: values.grading, since: values.since, until: values.until }, projectCatalog(root));
298
+ } finally { store.close(); }
299
+ if (values.json) { console.log(JSON.stringify(view, null, 2)); break; }
300
+ printView(view);
301
+ break;
302
+ }
303
+ case "export": {
304
+ const { dbPath } = needProject();
305
+ const { openStore } = await import("./core/db");
306
+ const store = openStore(dbPath);
307
+ let doc;
308
+ try { doc = store.exportAll(); } finally { store.close(); }
309
+ const json = JSON.stringify(doc, null, 2);
310
+ const path = positionals[0];
311
+ if (!path) { console.log(json); break; }
312
+ const tmp = `${path}.tmp`; // a reader never sees half a file
313
+ writeFileSync(tmp, json);
314
+ renameSync(tmp, path);
315
+ const records = Object.values(doc.tables).reduce((n, rows) => n + rows.length, 0);
316
+ console.log(`exported ${records} records to ${path}`);
317
+ break;
318
+ }
319
+ case "migrate": {
320
+ // The store is STATIC_FIRE_DB (else the project's runs/static-fire.db); the artifacts go next to it, in trials/.
321
+ const { root, runs, dbPath } = needProject();
322
+ const { DEFAULT_EXPERIMENT, migrateExperiment } = await import("./core/migrate");
323
+ try {
324
+ migrateExperiment({ source: values.from ?? `${runs}/${DEFAULT_EXPERIMENT}`, dbPath, root, log });
325
+ } catch (e) {
326
+ console.error(`migrate: ${e instanceof Error ? e.message : String(e)}`);
327
+ process.exit(1);
328
+ }
329
+ break;
330
+ }
331
+ default:
332
+ console.log(USAGE);
333
+ }
334
+
335
+ type View = import("./core/view").View;
336
+
337
+ /** `show` as text: what is in view, one row per config, partial suites, left-out counts, dead criteria. */
338
+ function printView(v: View) {
339
+ const { filters: f } = v;
340
+ const range = f.since || f.until ? ` · ${f.since?.slice(0, 10) ?? "…"} to ${f.until?.slice(0, 10) ?? "…"}` : "";
341
+ log(`${f.suiteIds.length ? `suites ${f.suiteIds.join(", ")}` : `tasks ${f.taskIds.join(", ")}`} · judge ${f.judge.label}${range}`);
342
+ const covers = coverageLine(v.coverage, f.judge.label); // the line the dashboard shows under its index buttons
343
+ if (covers) log(covers);
344
+ for (const [id, t] of Object.entries(v.tasks)) {
345
+ const grading = t.gradingName ? `${t.gradingName} (${t.gradingHash.slice(0, 8)})` : t.gradingHash.slice(0, 8);
346
+ log(` ${id}${t.label != null ? ` v${t.label}` : ""} · task ${t.taskHash.slice(0, 8)} · grading ${grading}${t.current ? "" : " (earlier)"}`);
347
+ }
348
+ const bySuites = f.suiteIds.length > 0;
349
+ const day = (s: string | null) => s?.slice(0, 10) ?? "";
350
+ log(`${"config".padEnd(LABEL_WIDTH)} ${bySuites ? "index" : "score"} ±95% n dates $/task time valid`);
351
+ const headline = (c: View["summary"]["configs"][number]) => (bySuites ? c.index : c.tasks[f.taskIds[0]!]!.score);
352
+ for (const c of [...v.summary.configs].sort((a, b) => (headline(b).mean ?? -1) - (headline(a).mean ?? -1))) {
353
+ const pooled = Object.values(c.pooled);
354
+ const n = pooled.reduce((a, p) => a + p.n, 0);
355
+ const from = pooled.map((p) => p.from).filter((x): x is string => !!x).sort()[0] ?? null;
356
+ const to = pooled.map((p) => p.to).filter((x): x is string => !!x).sort().at(-1) ?? null;
357
+ const dates = from ? (day(from) === day(to) ? day(from) : `${day(from)} to ${day(to)}`) : "—";
358
+ const ts = Object.values(c.tasks);
359
+ const valid = `${ts.reduce((a, t) => a + t.valid, 0)}/${ts.reduce((a, t) => a + t.total, 0)}`;
360
+ const h = headline(c);
361
+ log(`${c.label.padEnd(LABEL_WIDTH)} ${fmt(h.mean).padStart(5)} ${fmt(h.ci).padStart(5)} ${String(n).padStart(4)} ${dates.padEnd(24)} ${fmt(c.costUsd, 3).padStart(6)} ${(c.durationMs == null ? "—" : `${fmt(c.durationMs / 1000, 0)}s`).padStart(5)} ${valid}`);
362
+ if (bySuites && f.suiteIds.length > 1) {
363
+ log(` ${f.suiteIds.map((s) => `${s} ${c.partial[s] ? `partial (${c.partial[s]!.have} of ${c.partial[s]!.of} tasks)` : fmt(c.suites[s]!.mean)}`).join(" · ")}`);
364
+ } else if (bySuites) {
365
+ for (const s of f.suiteIds) if (c.partial[s]) log(` ${s}: partial (${c.partial[s]!.have} of ${c.partial[s]!.of} tasks)`);
366
+ } else if (f.taskIds.length > 1) {
367
+ log(` ${f.taskIds.map((t) => `${t} ${fmt(c.tasks[t]!.score.mean)}`).join(" · ")}`);
368
+ }
369
+ }
370
+ if (!v.summary.configs.length) log("no comparable trials in this view");
371
+ const { otherGrading: og, otherTask: ot, ungraded: ug } = v.leftOut;
372
+ const of = (x: { trials: number; configs: number }) => `${x.trials} trial${x.trials === 1 ? "" : "s"} from ${x.configs} config${x.configs === 1 ? "" : "s"}`;
373
+ if (og.trials) log(`left out: ${of(og)} graded under other criteria or another judge (regrade to bring them in)`);
374
+ if (ot.trials) {
375
+ log(`left out: ${of(ot)} ran on another version of the task`);
376
+ for (const ver of ot.versions) {
377
+ const versions = ver.gradingSetups.map((g) => `${g.hash.slice(0, 12)} (${g.name}, judge ${g.judgeLabel})`).join(", ") || "none recorded";
378
+ log(` ${ver.taskId}${ver.label != null ? ` v${ver.label}` : ""} · task ${ver.taskHash.slice(0, 8)} · ${ver.trials} trial(s) · show it with --grading: ${versions}`);
379
+ }
380
+ }
381
+ if (ug.trials) log(`left out: ${of(ug)} not graded yet`);
382
+ for (const [t, ids] of Object.entries(v.summary.deadCriteria)) log(`dead criteria in ${t}: ${ids.join(", ")}`);
383
+ }