static-fire 0.0.0-stage → 0.44.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.
- package/LICENSE +21 -0
- package/README.md +230 -2
- package/bin/static-fire.js +46 -0
- package/package.json +29 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +218 -0
- package/src/cli.ts +297 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +435 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +42 -0
- package/src/core/compare.ts +203 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/db.ts +572 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +128 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +231 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +54 -0
- package/src/core/regrade.ts +233 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +85 -0
- package/src/core/view.ts +498 -0
- package/src/guide.md +547 -0
- package/src/server.ts +24 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1019 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +164 -0
- package/web/index.html +17 -0
- package/web/pages.ts +16 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +333 -0
- package/web/suite-notes.ts +20 -0
- package/web/wording.ts +38 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
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 } from "./core/authored";
|
|
9
|
+
import { projectRoot } from "./core/project";
|
|
10
|
+
import { readTrialOutputFile, trialDetail } from "./core/trial-detail";
|
|
11
|
+
import type { Task } from "./core/types";
|
|
12
|
+
import { compareGradings } from "./core/compare";
|
|
13
|
+
import { buildFacets } from "./core/facets";
|
|
14
|
+
import { buildView } from "./core/view";
|
|
15
|
+
import type { Catalog } from "./core/view";
|
|
16
|
+
import { parseViewQuery, toViewFilters } from "./core/view-query";
|
|
17
|
+
import { failureMessage, planRegrade, regrade, type RegradePlan } from "./core/regrade";
|
|
18
|
+
|
|
19
|
+
export interface ApiOptions {
|
|
20
|
+
/** The store the routes read (and, from later routes, write). */
|
|
21
|
+
store: Store;
|
|
22
|
+
/** The path of that store's database. The event stream opens its own connection to it. */
|
|
23
|
+
dbPath: string;
|
|
24
|
+
/** The authored definitions, read per request so an edited task or suite file shows on the next load. */
|
|
25
|
+
catalog: () => Catalog;
|
|
26
|
+
/** How often the event stream checks the database, in ms. Default 1000. */
|
|
27
|
+
pollMs?: number;
|
|
28
|
+
/** The project folder POST /api/runs plans against, and the configs, judges and tasks lists read. Default: the project found from the current folder. */
|
|
29
|
+
root?: string;
|
|
30
|
+
/** Where a started batch keeps its Profile snapshots. Default the project's .cache. */
|
|
31
|
+
cacheDir?: string;
|
|
32
|
+
/** Called on a checked plan before anything is written; throws to refuse it. Test servers refuse paid calls with it. */
|
|
33
|
+
checkPlan?: (plan: BatchPlan) => void;
|
|
34
|
+
/** The clock that decides whether a batch has stalled. */
|
|
35
|
+
now?: () => Date;
|
|
36
|
+
/**
|
|
37
|
+
* Called on a regrade's plan, with the tasks it grades, before anything is written; throws to refuse it.
|
|
38
|
+
* Test servers refuse a plan with a judge criterion in it (a real judge call).
|
|
39
|
+
*/
|
|
40
|
+
checkRegrade?: (plan: RegradePlan, tasks: Task[]) => void;
|
|
41
|
+
/** The function a regrade runs. Default regrade.ts's; tests pass one that fails. */
|
|
42
|
+
runRegrade?: typeof regrade;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The regrade this server is running: one at a time. A page reload and a second tab read it from GET /api/regrade. */
|
|
46
|
+
export interface RegradeRun {
|
|
47
|
+
/** The view query it was started from, as /api/view's query string. */
|
|
48
|
+
query: string;
|
|
49
|
+
taskIds: string[];
|
|
50
|
+
/** The judge's label. */
|
|
51
|
+
judge: string;
|
|
52
|
+
startedAt: string;
|
|
53
|
+
/** Trials to grade, and gradings added so far. */
|
|
54
|
+
total: number;
|
|
55
|
+
graded: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The last regrade that ended here: its counts, or the error that stopped it. */
|
|
59
|
+
export interface RegradeEnded extends RegradeRun {
|
|
60
|
+
endedAt: string; done: number; invalid: number;
|
|
61
|
+
/** Trials that could not be graded; `error` names each one. */
|
|
62
|
+
failed: number;
|
|
63
|
+
error: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const bad = (e: unknown) => Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
|
|
67
|
+
|
|
68
|
+
/** The API routes, and `close` to release the event stream's database connection. */
|
|
69
|
+
export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRoot, cacheDir, checkPlan, now = () => new Date(), checkRegrade, runRegrade = regrade }: ApiOptions) {
|
|
70
|
+
const root = () => givenRoot ?? projectRoot(); // looked up per request, so a server built with only a catalog needs no project
|
|
71
|
+
// A connection of its own that only reads PRAGMA data_version. The value moves when any other connection
|
|
72
|
+
// commits, including the one the routes write through, so every write to the store emits an event.
|
|
73
|
+
const watcher = openStore(dbPath);
|
|
74
|
+
const dataVersion = () => Number(watcher.pragma("data_version"));
|
|
75
|
+
// Batches this process is running. A batch here is running even when no trial has moved for a while.
|
|
76
|
+
const live = new Set<string>();
|
|
77
|
+
let regrading: RegradeRun | null = null, lastRegrade: RegradeEnded | null = null;
|
|
78
|
+
const withState = (s: BatchSummary) => ({ ...s, ...batchState(s.batch, store.listTrials({ batchId: s.batch.id }), { live: live.has(s.batch.id), now: now() }) });
|
|
79
|
+
|
|
80
|
+
const routes = {
|
|
81
|
+
"/api/configs": () => Response.json(loadConfigs(root())),
|
|
82
|
+
"/api/judges": () => Response.json(loadJudges(root())),
|
|
83
|
+
"/api/suites": () => Response.json(catalog().suites), // the suites the view reads, so the scope selector matches it
|
|
84
|
+
"/api/tasks": () => Response.json(listTasks(root())),
|
|
85
|
+
"/api/view": (req: Request) => {
|
|
86
|
+
try { return Response.json(buildView(store, toViewFilters(parseViewQuery(new URL(req.url).searchParams)), catalog())); } catch (e) { return bad(e); }
|
|
87
|
+
},
|
|
88
|
+
"/api/facets": (req: Request) => {
|
|
89
|
+
try { return Response.json(buildFacets(store, toViewFilters(parseViewQuery(new URL(req.url).searchParams)), catalog())); } catch (e) { return bad(e); }
|
|
90
|
+
},
|
|
91
|
+
/** Two grading setups of one task compared over the trials valid under both: the view parameters plus `a` and `b`. */
|
|
92
|
+
"/api/compare": (req: Request) => {
|
|
93
|
+
try {
|
|
94
|
+
const params = new URL(req.url).searchParams;
|
|
95
|
+
const a = params.get("a") ?? "", b = params.get("b") ?? "";
|
|
96
|
+
params.delete("a"); params.delete("b");
|
|
97
|
+
return Response.json(compareGradings(store, toViewFilters(parseViewQuery(params)), a, b, catalog()));
|
|
98
|
+
} catch (e) { return bad(e); }
|
|
99
|
+
},
|
|
100
|
+
/** Starts a batch: checks the request (nothing is written when it is bad), records the batch, returns its id, then runs it here. */
|
|
101
|
+
"/api/runs": {
|
|
102
|
+
POST: async (req: Request) => {
|
|
103
|
+
let plan: BatchPlan;
|
|
104
|
+
try {
|
|
105
|
+
const body = await req.json().catch(() => { throw new Error("the request body is not JSON"); });
|
|
106
|
+
plan = planBatch(parseRunRequest(body), root()); // the shape first: nothing is chosen from a malformed field
|
|
107
|
+
checkPlan?.(plan);
|
|
108
|
+
} catch (e) { return bad(e); }
|
|
109
|
+
const started = await startBatch(store, plan, { dbPath, root: root(), cacheDir });
|
|
110
|
+
const id = started.batch.id;
|
|
111
|
+
live.add(id);
|
|
112
|
+
runBatch(store, started, { onEvent: (m) => console.log(m) })
|
|
113
|
+
.catch((e) => console.error(`batch ${id}: ${e instanceof Error ? e.message : String(e)}`))
|
|
114
|
+
.finally(() => live.delete(id));
|
|
115
|
+
return Response.json({ id });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
/**
|
|
119
|
+
* Regrades the trials a view lists as gradable (view.leftOut.gradable), with judge calls only. The query is
|
|
120
|
+
* /api/view's. Refused (400, nothing written) for a view on an earlier grading setup, a view with nothing to
|
|
121
|
+
* regrade, or a plan checkRegrade refuses; 409 while another regrade runs. Trials of a running batch are
|
|
122
|
+
* left to that batch, which is grading them. Returns 202, then grades in this process.
|
|
123
|
+
*/
|
|
124
|
+
"/api/regrade": {
|
|
125
|
+
GET: () => Response.json({ running: regrading, last: lastRegrade }),
|
|
126
|
+
POST: (req: Request) => {
|
|
127
|
+
// No await before `regrading` is set: two requests cannot both pass this check.
|
|
128
|
+
if (regrading) return Response.json({ error: "A regrade is already running.", running: regrading }, { status: 409 });
|
|
129
|
+
let plan: RegradePlan, query: string, taskIds: string[], cat: Catalog, leftForBatch: number;
|
|
130
|
+
try {
|
|
131
|
+
const params = new URL(req.url).searchParams;
|
|
132
|
+
cat = catalog();
|
|
133
|
+
const view = buildView(store, toViewFilters(parseViewQuery(params)), cat);
|
|
134
|
+
query = params.toString();
|
|
135
|
+
taskIds = view.filters.taskIds;
|
|
136
|
+
const earlier = Object.entries(view.tasks).filter(([, t]) => !t.current).map(([id]) => id);
|
|
137
|
+
if (earlier.length) throw new Error(`This view is on an earlier grading version (${earlier.join(", ")}). A regrade always uses today's criteria.`);
|
|
138
|
+
const runningBatch = new Map<string, boolean>();
|
|
139
|
+
const isRunning = (batchId: string) => {
|
|
140
|
+
if (!runningBatch.has(batchId)) {
|
|
141
|
+
const b = store.getBatch(batchId);
|
|
142
|
+
runningBatch.set(batchId, !!b && batchState(b, store.listTrials({ batchId }), { live: live.has(batchId), now: now() }).state === "running");
|
|
143
|
+
}
|
|
144
|
+
return runningBatch.get(batchId)!;
|
|
145
|
+
};
|
|
146
|
+
const ids = view.leftOut.gradable.filter((id) => !isRunning(store.getTrial(id)!.batchId));
|
|
147
|
+
leftForBatch = view.leftOut.gradable.length - ids.length;
|
|
148
|
+
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.");
|
|
149
|
+
plan = planRegrade(store, { tasks: taskIds, trials: ids }, { dbPath, catalog: cat, judge: view.filters.judge.id });
|
|
150
|
+
if (!plan.todo.length) throw new Error("Nothing in this view to regrade.");
|
|
151
|
+
checkRegrade?.(plan, [...new Set(plan.todo.map((x) => x.taskId))].map((id) => cat.loadTask(id)));
|
|
152
|
+
} catch (e) { return bad(e); }
|
|
153
|
+
const run: RegradeRun = { query, taskIds, judge: plan.judge.label, startedAt: now().toISOString(), total: plan.todo.length, graded: 0 };
|
|
154
|
+
regrading = run;
|
|
155
|
+
const end = (c: { done: number; invalid: number; failed: number }, error: string | null) => {
|
|
156
|
+
lastRegrade = { ...run, endedAt: now().toISOString(), done: c.done, invalid: c.invalid, failed: c.failed, error };
|
|
157
|
+
regrading = null;
|
|
158
|
+
};
|
|
159
|
+
// An async function runs to its first await at once; a throw there must still clear the running state.
|
|
160
|
+
Promise.resolve()
|
|
161
|
+
.then(() => runRegrade(store, { tasks: taskIds, trials: plan.todo.map((x) => x.trialId) }, {
|
|
162
|
+
dbPath, catalog: cat, judge: plan.judge.id, votes: plan.votes,
|
|
163
|
+
onEvent: (m) => console.log(m), onGraded: () => { run.graded++; },
|
|
164
|
+
}))
|
|
165
|
+
.then((c) => {
|
|
166
|
+
const error = failureMessage(c);
|
|
167
|
+
if (error) console.error(`regrade: ${error}`);
|
|
168
|
+
end(c, error);
|
|
169
|
+
}, (e) => {
|
|
170
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
171
|
+
console.error(`regrade: ${error}`);
|
|
172
|
+
end({ done: 0, invalid: 0, failed: 0 }, error);
|
|
173
|
+
});
|
|
174
|
+
return Response.json({ total: run.total, leftForBatch }, { status: 202 });
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
/** Every batch, newest first, with its progress and whether it is running, stalled or ended. */
|
|
178
|
+
"/api/batches": () => Response.json(listBatchSummaries(store).map(withState)),
|
|
179
|
+
/** One batch with the status of each trial. */
|
|
180
|
+
"/api/batches/:id": (req: Request & { params: { id: string } }) => {
|
|
181
|
+
const batch = store.getBatch(req.params.id);
|
|
182
|
+
if (!batch) return Response.json({ error: `no batch ${req.params.id}` }, { status: 404 });
|
|
183
|
+
const trials = store.listTrials({ batchId: batch.id }).map(({ id, taskId, configId, n, status, startedAt, endedAt, error }) => ({ id, taskId, configId, n, status, startedAt, endedAt, error }));
|
|
184
|
+
return Response.json({ ...withState(summarizeBatch(store, batch)), trials });
|
|
185
|
+
},
|
|
186
|
+
/** One trial with its run conditions and every grading it has, each with its verdicts. */
|
|
187
|
+
"/api/trials/:id": (req: Request & { params: { id: string } }) => {
|
|
188
|
+
const d = trialDetail(store, req.params.id);
|
|
189
|
+
return d ? Response.json(d) : Response.json({ error: `no trial ${req.params.id}` }, { status: 404 });
|
|
190
|
+
},
|
|
191
|
+
/** The trial's output file from its saved workspace: an image with its media type, else Markdown text; 404 with the reason when there is none. */
|
|
192
|
+
"/api/trials/:id/output": (req: Request & { params: { id: string } }) => {
|
|
193
|
+
const out = readTrialOutputFile(store, dbPath, req.params.id);
|
|
194
|
+
return "missing" in out ? new Response(out.missing, { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
195
|
+
: "image" in out ? new Response(out.image, { headers: { "content-type": out.mediaType } })
|
|
196
|
+
: new Response(out.text, { headers: { "content-type": "text/markdown; charset=utf-8" } });
|
|
197
|
+
},
|
|
198
|
+
/** Server-sent events: one on connect, then one each time the database changes. The data is the data_version. */
|
|
199
|
+
"/api/events": () => {
|
|
200
|
+
let last = -1, timer: Timer;
|
|
201
|
+
const stream = new ReadableStream({
|
|
202
|
+
start(ctrl) {
|
|
203
|
+
const tick = () => {
|
|
204
|
+
try {
|
|
205
|
+
const v = dataVersion();
|
|
206
|
+
if (v !== last) { last = v; ctrl.enqueue(`data: ${v}\n\n`); }
|
|
207
|
+
} catch { clearInterval(timer); }
|
|
208
|
+
};
|
|
209
|
+
tick();
|
|
210
|
+
timer = setInterval(tick, pollMs);
|
|
211
|
+
},
|
|
212
|
+
cancel() { clearInterval(timer); },
|
|
213
|
+
});
|
|
214
|
+
return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
return { routes, close: () => watcher.close() };
|
|
218
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
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 [--json] make a project in the current folder (core/init.ts); never overwrites a file
|
|
5
|
+
// static-fire guide the guide for agents (src/guide.md); needs no project
|
|
6
|
+
// static-fire serve [--port 4477]
|
|
7
|
+
// static-fire configs | judges | suites | tasks
|
|
8
|
+
// static-fire new task <id> a new task folder: evals/<id>/task.yaml (commented, to fill in) and inputs/
|
|
9
|
+
// static-fire run --suites writing[,visual] | --tasks a[,b] [--configs a,b|all] [--trials 3] [--judge id] [--votes 3] [--concurrency 4]
|
|
10
|
+
// one batch in the store; trial artifacts in the project's runs/trials/<trial-id>/
|
|
11
|
+
// static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid] [--concurrency 4]
|
|
12
|
+
// grade stored outputs again (judge calls only) where the current grading is missing
|
|
13
|
+
// static-fire rerun <batch> run its failed trials again, as new trials in the same batch
|
|
14
|
+
// static-fire list every batch in the store, newest first, with its trial counts
|
|
15
|
+
// static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
|
|
16
|
+
// the comparable trials, pooled across batches, from the store
|
|
17
|
+
// static-fire export [path] every record in the store as JSON (to stdout without a path)
|
|
18
|
+
// static-fire migrate [--from <folder>] one-off import of the 2026-11-06 experiment into the store
|
|
19
|
+
//
|
|
20
|
+
// The commands and the flags each takes are listed in core/commands.ts; a flag a command does not take is an error.
|
|
21
|
+
// The project is the folder found by walking up from the current folder to static-fire.yaml (core/project.ts).
|
|
22
|
+
// Each command that needs one calls needProject() before it reads or writes anything; outside a project it
|
|
23
|
+
// stops with an error that names `static-fire init`. The record store is the project's runs/static-fire.db,
|
|
24
|
+
// or STATIC_FIRE_DB when set; trial artifacts go next to the store, and the Profile cache in the project's .cache/.
|
|
25
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { parseArgs } from "node:util";
|
|
28
|
+
import { configLabel } from "./core/label";
|
|
29
|
+
import { listTasks, loadConfigs, loadJudges, loadSuites } from "./core/authored";
|
|
30
|
+
import { COMMANDS, OPTIONS, unknownFlags, USAGE } from "./core/commands";
|
|
31
|
+
import { ENGINE_ROOT, NoProjectError, projectPaths, projectRoot, type ProjectPaths } from "./core/project";
|
|
32
|
+
|
|
33
|
+
const LABEL_WIDTH = 52; // fits the longest built config label, so `show` columns line up
|
|
34
|
+
|
|
35
|
+
const [cmd, ...rest] = Bun.argv.slice(2);
|
|
36
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
37
|
+
console.log((await import("../package.json")).default.version);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
const { values, positionals, tokens } = parseArgs({ args: rest, allowPositionals: true, tokens: true, options: OPTIONS });
|
|
41
|
+
// A flag the command does not take stops it, so a flag is never ignored in silence.
|
|
42
|
+
if (cmd && COMMANDS[cmd]) {
|
|
43
|
+
const extra = unknownFlags(cmd, tokens.flatMap((t) => (t.kind === "option" ? [t.name] : [])));
|
|
44
|
+
if (extra.length) {
|
|
45
|
+
const takes = COMMANDS[cmd]!.flags;
|
|
46
|
+
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"}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fmt = (n: number | null | undefined, d = 1) => (n == null ? "—" : n.toFixed(d));
|
|
52
|
+
const log = (m: string) => console.log(m);
|
|
53
|
+
|
|
54
|
+
/** The project of this command, or exit 1 with an error that names `static-fire init`, before anything is written. */
|
|
55
|
+
function needProject(): ProjectPaths {
|
|
56
|
+
try {
|
|
57
|
+
return projectPaths(projectRoot());
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (!(e instanceof NoProjectError)) throw e;
|
|
60
|
+
console.error(`${cmd}: ${e.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
switch (cmd) {
|
|
66
|
+
case "init": {
|
|
67
|
+
const { formatReport, initProject } = await import("./core/init");
|
|
68
|
+
const report = initProject();
|
|
69
|
+
process.stdout.write(values.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report));
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "guide":
|
|
73
|
+
// An engine file, so it matches the installed version. It needs no project.
|
|
74
|
+
process.stdout.write(readFileSync(join(ENGINE_ROOT, "src", "guide.md"), "utf8"));
|
|
75
|
+
break;
|
|
76
|
+
case "serve": {
|
|
77
|
+
const project = needProject();
|
|
78
|
+
const { serve } = await import("./server");
|
|
79
|
+
serve(Number(values.port), project);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "configs":
|
|
83
|
+
for (const c of loadConfigs(needProject().root)) console.log(`${c.id.padEnd(24)} ${configLabel(c)}`);
|
|
84
|
+
break;
|
|
85
|
+
case "judges":
|
|
86
|
+
loadJudges(needProject().root).forEach((j, i) => console.log(`${j.id.padEnd(24)} ${j.label}${i === 0 ? " (default)" : ""}`));
|
|
87
|
+
break;
|
|
88
|
+
case "suites":
|
|
89
|
+
for (const s of loadSuites(needProject().root)) console.log(`${s.id.padEnd(24)} ${s.title}: ${s.tasks.join(", ")}`);
|
|
90
|
+
break;
|
|
91
|
+
case "tasks":
|
|
92
|
+
for (const t of listTasks(needProject().root)) {
|
|
93
|
+
console.log(`${t.id.padEnd(24)} ${t.title}`);
|
|
94
|
+
for (const e of t.errors) console.log(` error: ${e}`);
|
|
95
|
+
for (const w of t.warnings) console.log(` warning: ${w}`);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case "new": {
|
|
99
|
+
const [what, id] = positionals;
|
|
100
|
+
if (what !== "task" || !id || positionals.length > 2) {
|
|
101
|
+
console.error("usage: static-fire new task <id>");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const { root } = needProject();
|
|
105
|
+
const { BadTaskId, TaskExists, newTask } = await import("./core/new-task");
|
|
106
|
+
try {
|
|
107
|
+
newTask(root, id);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (!(e instanceof BadTaskId || e instanceof TaskExists)) throw e;
|
|
110
|
+
console.error(`new task: ${e.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
log(`made evals/${id}/task.yaml and evals/${id}/inputs/`);
|
|
114
|
+
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`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "run": {
|
|
118
|
+
const { root, dbPath, cache } = needProject();
|
|
119
|
+
const { planBatch, runBatch, startBatch } = await import("./core/batch");
|
|
120
|
+
const { openStore } = await import("./core/db");
|
|
121
|
+
const list = (v?: string) => (v === undefined ? undefined : v.split(",").map((x) => x.trim()).filter(Boolean));
|
|
122
|
+
const plan = planBatch({
|
|
123
|
+
tasks: list(values.tasks), suites: values.suites === "all" ? undefined : list(values.suites), configs: list(values.configs),
|
|
124
|
+
trials: Number(values.trials), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined,
|
|
125
|
+
}, root);
|
|
126
|
+
const store = openStore(dbPath);
|
|
127
|
+
try {
|
|
128
|
+
const started = await startBatch(store, plan, { dbPath, root, cacheDir: cache });
|
|
129
|
+
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`);
|
|
130
|
+
await runBatch(store, started, { concurrency: Number(values.concurrency), onEvent: log });
|
|
131
|
+
} finally {
|
|
132
|
+
store.close();
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "regrade": {
|
|
137
|
+
const { root, dbPath } = needProject();
|
|
138
|
+
const { formatCounts, regrade } = await import("./core/regrade");
|
|
139
|
+
const { openStore } = await import("./core/db");
|
|
140
|
+
const { projectCatalog } = await import("./core/view");
|
|
141
|
+
const list = (v?: string) => (v === undefined ? undefined : v.split(",").map((x) => x.trim()).filter(Boolean));
|
|
142
|
+
const store = openStore(dbPath);
|
|
143
|
+
try {
|
|
144
|
+
const counts = await regrade(store, { tasks: list(values.tasks), configs: list(values.configs), batch: values.batch }, {
|
|
145
|
+
dbPath, catalog: projectCatalog(root), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined, onlyInvalid: values["only-invalid"],
|
|
146
|
+
concurrency: Number(values.concurrency), onEvent: log,
|
|
147
|
+
});
|
|
148
|
+
log(formatCounts(counts));
|
|
149
|
+
if (counts.failed) {
|
|
150
|
+
console.error(`regrade: ${counts.failed} trial(s) could not be graded (listed above)`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error(`regrade: ${e instanceof Error ? e.message : String(e)}`);
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
} finally {
|
|
157
|
+
store.close();
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "rerun": {
|
|
162
|
+
const id = positionals[0];
|
|
163
|
+
if (!id) throw new Error("usage: static-fire rerun <batch>");
|
|
164
|
+
const { root, dbPath, cache } = needProject();
|
|
165
|
+
const { runBatch } = await import("./core/batch");
|
|
166
|
+
const { RerunRefused, planRerun, startRerun } = await import("./core/batches");
|
|
167
|
+
const { openStore } = await import("./core/db");
|
|
168
|
+
const store = openStore(dbPath);
|
|
169
|
+
try {
|
|
170
|
+
let plan;
|
|
171
|
+
try { plan = planRerun(store, id, root); } catch (e) {
|
|
172
|
+
if (!(e instanceof RerunRefused)) throw e;
|
|
173
|
+
console.error(`rerun: ${e.message}`);
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (plan.busy.length) log(`still queued or running, not rerun: ${plan.busy.join(", ")}`);
|
|
178
|
+
if (!plan.slots.length) { log(`no failed trials to rerun in ${id}`); break; }
|
|
179
|
+
const started = await startRerun(store, plan, { dbPath, root, cacheDir: cache });
|
|
180
|
+
for (const t of started.trials) log(`+ ${t.id} (for ${started.replaces[t.id]})`);
|
|
181
|
+
await runBatch(store, started, { concurrency: Number(values.concurrency), onEvent: log });
|
|
182
|
+
} finally {
|
|
183
|
+
store.close();
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "list": {
|
|
188
|
+
const { dbPath } = needProject();
|
|
189
|
+
const { listBatchSummaries } = await import("./core/batches");
|
|
190
|
+
const { openStore } = await import("./core/db");
|
|
191
|
+
const store = openStore(dbPath);
|
|
192
|
+
let all;
|
|
193
|
+
try { all = listBatchSummaries(store); } finally { store.close(); }
|
|
194
|
+
if (!all.length) { log(`no batches in ${dbPath}`); break; }
|
|
195
|
+
const when = (iso: string) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
196
|
+
for (const { batch, request: r, counts: c, toRerun, ended } of all) {
|
|
197
|
+
log(`${batch.id} ${when(batch.startedAt)} ${ended ? `ended ${when(batch.endedAt!)}` : "not ended"}`);
|
|
198
|
+
const what = r.tasks ? `tasks ${r.tasks.join(", ")}` : `suites ${r.suites?.join(", ") ?? "all"}`;
|
|
199
|
+
log(` ${what} · configs ${r.configs.join(", ")} · ${r.trials} trial${r.trials === 1 ? "" : "s"} · judge ${r.judge} × ${r.votes} votes`);
|
|
200
|
+
const busy = [c.queued ? `queued ${c.queued}` : "", c.running ? `running ${c.running}` : ""].filter(Boolean);
|
|
201
|
+
log(` ${[`done ${c.done}`, `failed ${c.failed}`, `invalid ${c.invalid}`, ...busy, ...(toRerun ? [`${toRerun} to rerun`] : [])].join(" · ")}`);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "show": {
|
|
206
|
+
const { root, dbPath } = needProject();
|
|
207
|
+
const { openStore } = await import("./core/db");
|
|
208
|
+
const { buildView, projectCatalog } = await import("./core/view");
|
|
209
|
+
const list = (v: string | undefined) => v?.split(",").map((x) => x.trim()).filter(Boolean);
|
|
210
|
+
const store = openStore(dbPath);
|
|
211
|
+
let view;
|
|
212
|
+
try {
|
|
213
|
+
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));
|
|
214
|
+
} finally { store.close(); }
|
|
215
|
+
if (values.json) { console.log(JSON.stringify(view, null, 2)); break; }
|
|
216
|
+
printView(view);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "export": {
|
|
220
|
+
const { dbPath } = needProject();
|
|
221
|
+
const { openStore } = await import("./core/db");
|
|
222
|
+
const store = openStore(dbPath);
|
|
223
|
+
let doc;
|
|
224
|
+
try { doc = store.exportAll(); } finally { store.close(); }
|
|
225
|
+
const json = JSON.stringify(doc, null, 2);
|
|
226
|
+
const path = positionals[0];
|
|
227
|
+
if (!path) { console.log(json); break; }
|
|
228
|
+
const tmp = `${path}.tmp`; // a reader never sees half a file
|
|
229
|
+
writeFileSync(tmp, json);
|
|
230
|
+
renameSync(tmp, path);
|
|
231
|
+
const records = Object.values(doc.tables).reduce((n, rows) => n + rows.length, 0);
|
|
232
|
+
console.log(`exported ${records} records to ${path}`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "migrate": {
|
|
236
|
+
// The store is STATIC_FIRE_DB (else the project's runs/static-fire.db); the artifacts go next to it, in trials/.
|
|
237
|
+
const { root, runs, dbPath } = needProject();
|
|
238
|
+
const { DEFAULT_EXPERIMENT, migrateExperiment } = await import("./core/migrate");
|
|
239
|
+
try {
|
|
240
|
+
migrateExperiment({ source: values.from ?? `${runs}/${DEFAULT_EXPERIMENT}`, dbPath, root, log });
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(`migrate: ${e instanceof Error ? e.message : String(e)}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
console.log(USAGE);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type View = import("./core/view").View;
|
|
252
|
+
|
|
253
|
+
/** `show` as text: what is in view, one row per config, partial suites, left-out counts, dead criteria. */
|
|
254
|
+
function printView(v: View) {
|
|
255
|
+
const { filters: f } = v;
|
|
256
|
+
const range = f.since || f.until ? ` · ${f.since?.slice(0, 10) ?? "…"} to ${f.until?.slice(0, 10) ?? "…"}` : "";
|
|
257
|
+
log(`${f.suiteIds.length ? `suites ${f.suiteIds.join(", ")}` : `tasks ${f.taskIds.join(", ")}`} · judge ${f.judge.label}${range}`);
|
|
258
|
+
for (const [id, t] of Object.entries(v.tasks)) {
|
|
259
|
+
const grading = t.gradingName ? `${t.gradingName} (${t.gradingHash.slice(0, 8)})` : t.gradingHash.slice(0, 8);
|
|
260
|
+
log(` ${id}${t.label != null ? ` v${t.label}` : ""} · task ${t.taskHash.slice(0, 8)} · grading ${grading}${t.current ? "" : " (earlier)"}`);
|
|
261
|
+
}
|
|
262
|
+
const bySuites = f.suiteIds.length > 0;
|
|
263
|
+
const day = (s: string | null) => s?.slice(0, 10) ?? "";
|
|
264
|
+
log(`${"config".padEnd(LABEL_WIDTH)} ${bySuites ? "index" : "score"} ±95% n dates $/task time valid`);
|
|
265
|
+
const headline = (c: View["summary"]["configs"][number]) => (bySuites ? c.index : c.tasks[f.taskIds[0]!]!.score);
|
|
266
|
+
for (const c of [...v.summary.configs].sort((a, b) => (headline(b).mean ?? -1) - (headline(a).mean ?? -1))) {
|
|
267
|
+
const pooled = Object.values(c.pooled);
|
|
268
|
+
const n = pooled.reduce((a, p) => a + p.n, 0);
|
|
269
|
+
const from = pooled.map((p) => p.from).filter((x): x is string => !!x).sort()[0] ?? null;
|
|
270
|
+
const to = pooled.map((p) => p.to).filter((x): x is string => !!x).sort().at(-1) ?? null;
|
|
271
|
+
const dates = from ? (day(from) === day(to) ? day(from) : `${day(from)} to ${day(to)}`) : "—";
|
|
272
|
+
const ts = Object.values(c.tasks);
|
|
273
|
+
const valid = `${ts.reduce((a, t) => a + t.valid, 0)}/${ts.reduce((a, t) => a + t.total, 0)}`;
|
|
274
|
+
const h = headline(c);
|
|
275
|
+
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}`);
|
|
276
|
+
if (bySuites && f.suiteIds.length > 1) {
|
|
277
|
+
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(" · ")}`);
|
|
278
|
+
} else if (bySuites) {
|
|
279
|
+
for (const s of f.suiteIds) if (c.partial[s]) log(` ${s}: partial (${c.partial[s]!.have} of ${c.partial[s]!.of} tasks)`);
|
|
280
|
+
} else if (f.taskIds.length > 1) {
|
|
281
|
+
log(` ${f.taskIds.map((t) => `${t} ${fmt(c.tasks[t]!.score.mean)}`).join(" · ")}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!v.summary.configs.length) log("no comparable trials in this view");
|
|
285
|
+
const { otherGrading: og, otherTask: ot, ungraded: ug } = v.leftOut;
|
|
286
|
+
const of = (x: { trials: number; configs: number }) => `${x.trials} trial${x.trials === 1 ? "" : "s"} from ${x.configs} config${x.configs === 1 ? "" : "s"}`;
|
|
287
|
+
if (og.trials) log(`left out: ${of(og)} graded under other criteria or another judge (regrade to bring them in)`);
|
|
288
|
+
if (ot.trials) {
|
|
289
|
+
log(`left out: ${of(ot)} ran on another version of the task`);
|
|
290
|
+
for (const ver of ot.versions) {
|
|
291
|
+
const versions = ver.gradingSetups.map((g) => `${g.hash.slice(0, 12)} (${g.name}, judge ${g.judgeLabel})`).join(", ") || "none recorded";
|
|
292
|
+
log(` ${ver.taskId}${ver.label != null ? ` v${ver.label}` : ""} · task ${ver.taskHash.slice(0, 8)} · ${ver.trials} trial(s) · show it with --grading: ${versions}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (ug.trials) log(`left out: ${of(ug)} not graded yet`);
|
|
296
|
+
for (const [t, ids] of Object.entries(v.summary.deadCriteria)) log(`dead criteria in ${t}: ${ids.join(", ")}`);
|
|
297
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Builds one Profile snapshot in a process of its own, which holds the apkit lock for the whole sequence:
|
|
2
|
+
// install into a scratch folder, copy it to the snapshot, remove the install. The `run` that starts it
|
|
3
|
+
// (profiles.ts) waits for it. If that `run` is killed, this process goes on to the end of the sequence and only
|
|
4
|
+
// then lets go of the lock, so another install never overlaps a running one.
|
|
5
|
+
// bun src/core/apkit-install.ts <lock file> <profile> <apkit agent> <snapshot folder>
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
|
+
import { withFileLock } from "./lock";
|
|
10
|
+
|
|
11
|
+
async function apkit(args: string[]) {
|
|
12
|
+
const p = Bun.spawn(["apkit", ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
13
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
|
14
|
+
if (code !== 0) throw new Error(`apkit ${args[0]} failed (exit ${code}): ${(err || out).trim().slice(-400)}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function build(profile: string, agent: string, snap: string) {
|
|
18
|
+
if (existsSync(snap)) return;
|
|
19
|
+
const scratch = join(tmpdir(), "static-fire-apkit", `${process.pid}`, basename(dirname(snap)), basename(snap));
|
|
20
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
21
|
+
mkdirSync(scratch, { recursive: true });
|
|
22
|
+
try {
|
|
23
|
+
// --yes: apkit asks before creating AGENTS.md; this scratch folder is ours.
|
|
24
|
+
await apkit(["install", profile, scratch, "--agents", agent, "--yes"]);
|
|
25
|
+
// Copied beside the snapshot, then renamed: a snapshot folder is always complete.
|
|
26
|
+
const part = `${snap}.part`;
|
|
27
|
+
rmSync(part, { recursive: true, force: true });
|
|
28
|
+
mkdirSync(part, { recursive: true });
|
|
29
|
+
cpSync(scratch, part, { recursive: true, dereference: true });
|
|
30
|
+
renameSync(part, snap);
|
|
31
|
+
} finally {
|
|
32
|
+
await apkit(["remove", scratch, "--yes"]).catch(() => {});
|
|
33
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [lock, profile, agent, snap] = Bun.argv.slice(2);
|
|
38
|
+
if (!lock || !profile || !agent || !snap) {
|
|
39
|
+
console.error("usage: apkit-install.ts <lock file> <profile> <apkit agent> <snapshot folder>");
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await withFileLock(lock, () => build(profile, agent, snap));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The authored files, read as YAML (records are in the SQLite record store, src/core/db.ts):
|
|
2
|
+
// configs/<id>.yaml · judges.yaml · suites/<id>.yaml · evals/<task>/task.yaml
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { normalizeConfig } from "./config";
|
|
6
|
+
import { projectRoot } from "./project";
|
|
7
|
+
import { validateTask } from "./task-file";
|
|
8
|
+
import type { AgentConfig, JudgeConfig, Suite, Task } from "./types";
|
|
9
|
+
|
|
10
|
+
const readYaml = (p: string) => Bun.YAML.parse(readFileSync(p, "utf8")) as any;
|
|
11
|
+
const yamlIds = (dir: string) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".yaml")).map((f) => f.slice(0, -5)).sort() : []);
|
|
12
|
+
|
|
13
|
+
// Each loader reads from the project root (`root`: configs/, judges.yaml, suites/, evals/). The default is the
|
|
14
|
+
// project found from the current folder (project.ts); it throws outside a project. Tests pass a temp project.
|
|
15
|
+
export function loadConfigs(root = projectRoot()): AgentConfig[] {
|
|
16
|
+
return yamlIds(join(root, "configs")).map((id) => normalizeConfig(id, readYaml(join(root, "configs", `${id}.yaml`)), root));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadJudges(root = projectRoot()): JudgeConfig[] {
|
|
20
|
+
return (readYaml(join(root, "judges.yaml")).judges ?? []) as JudgeConfig[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadSuites(root = projectRoot()): Suite[] {
|
|
24
|
+
return yamlIds(join(root, "suites")).map((id) => ({ id, ...readYaml(join(root, "suites", `${id}.yaml`)) }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskListing { id: string; title: string; description: string; errors: string[]; warnings: string[] }
|
|
28
|
+
|
|
29
|
+
/** Loads and validates a task from `<root>/evals/<id>/task.yaml`. Throws when the file has errors. */
|
|
30
|
+
export function loadTask(id: string, root = projectRoot()): Task & { warnings: string[] } {
|
|
31
|
+
const dir = join(root, "evals", id);
|
|
32
|
+
const raw = readYaml(join(dir, "task.yaml"));
|
|
33
|
+
const { errors, warnings } = validateTask(raw, dir);
|
|
34
|
+
if (errors.length) throw new Error(`task ${id} is invalid:\n- ${errors.join("\n- ")}`);
|
|
35
|
+
// YAML `derived:` and `derived: null` mean no derived evidence: drop the key here, so the grader sees one form.
|
|
36
|
+
for (const c of [...(raw.gates ?? []), ...raw.measures.flatMap((m: any) => m.criteria)]) if (c.derived == null) delete c.derived;
|
|
37
|
+
return { gates: [], ...raw, dir, warnings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listTasks(root = projectRoot()): TaskListing[] {
|
|
41
|
+
const dir = join(root, "evals");
|
|
42
|
+
return readdirSync(dir).filter((d) => existsSync(join(dir, d, "task.yaml"))).map((id) => {
|
|
43
|
+
const raw = readYaml(join(dir, id, "task.yaml"));
|
|
44
|
+
const v = validateTask(raw, join(dir, id));
|
|
45
|
+
return { id, title: raw.title ?? id, description: raw.description ?? "", ...v };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Whether a batch is still running, for the batches panel. A batch whose process died never gets its end
|
|
2
|
+
// time, so "not ended" alone does not mean "running": a batch also needs a trial that moved recently, or
|
|
3
|
+
// this server process running it.
|
|
4
|
+
import type { BatchRecord, TrialRecord } from "./db";
|
|
5
|
+
|
|
6
|
+
/** The agent timeout (20 min) plus a margin: a long agent trial or a slow grading is not called stopped. */
|
|
7
|
+
export const STALLED_AFTER_MS = 25 * 60_000;
|
|
8
|
+
|
|
9
|
+
export type BatchRunState = "running" | "stalled" | "ended";
|
|
10
|
+
|
|
11
|
+
export interface BatchState {
|
|
12
|
+
total: number;
|
|
13
|
+
/** The latest start or end time of its trials; the batch start when no trial has moved. */
|
|
14
|
+
lastMoveAt: string;
|
|
15
|
+
/** ended: it has its end time. running: this server runs it, or a trial moved in the last 25 min. stalled: neither; its process may have stopped. */
|
|
16
|
+
state: BatchRunState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function batchState(batch: BatchRecord, trials: TrialRecord[], o: { live: boolean; now: Date }): BatchState {
|
|
20
|
+
let lastMoveAt = batch.startedAt;
|
|
21
|
+
for (const t of trials) for (const at of [t.startedAt, t.endedAt]) if (at && at > lastMoveAt) lastMoveAt = at;
|
|
22
|
+
const state: BatchRunState = batch.endedAt != null ? "ended"
|
|
23
|
+
: o.live || o.now.getTime() - Date.parse(lastMoveAt) < STALLED_AFTER_MS ? "running" : "stalled";
|
|
24
|
+
return { total: trials.length, lastMoveAt, state };
|
|
25
|
+
}
|