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/core/db.ts ADDED
@@ -0,0 +1,608 @@
1
+ // The record store: one SQLite file (runs/static-fire.db) holds every batch, trial, grading and verdict.
2
+ // It is the only copy of the records (authored files stay YAML). Plain SQL, no ORM.
3
+ //
4
+ // Several CLI processes can write at once: WAL mode, a 5000 ms busy timeout, and every write in a
5
+ // BEGIN IMMEDIATE transaction (a deferred one that reads and then writes can fail at once in WAL mode).
6
+ // The record rules live in the schema as triggers, so they hold for every writer:
7
+ // - a trial row never changes after the trial has ended (status done, failed or invalid);
8
+ // - a grading is never overwritten: once done or invalid, its row and its verdicts are read-only.
9
+ // Regrading adds a new grading row; currentGrading() returns the latest finished one.
10
+ import { Database } from "bun:sqlite";
11
+ import { mkdirSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { projectPaths, projectRoot } from "./project";
14
+ import type { Vote } from "./types";
15
+
16
+ /** The schema version, kept in PRAGMA user_version. */
17
+ export const SCHEMA_VERSION = 4;
18
+
19
+ const BUSY_TIMEOUT_MS = 5000;
20
+
21
+ const ENDED = "('done', 'failed', 'invalid')";
22
+
23
+ /**
24
+ * MIGRATIONS[i] takes the schema from version i to version i + 1. Never edit a released step: add one.
25
+ * Every step runs with foreign keys off, in one write transaction with the others, and a foreign key check must
26
+ * be clean before it commits (migrate()). Exported for the upgrade tests, which build older stores with them.
27
+ */
28
+ export const MIGRATIONS: readonly string[] = [
29
+ `
30
+ CREATE TABLE task_versions (
31
+ hash TEXT PRIMARY KEY,
32
+ task_id TEXT NOT NULL,
33
+ label INTEGER NOT NULL,
34
+ snapshot TEXT NOT NULL,
35
+ first_seen TEXT NOT NULL
36
+ );
37
+ CREATE TABLE grading_setups (
38
+ hash TEXT PRIMARY KEY,
39
+ task_id TEXT NOT NULL,
40
+ task_hash TEXT NOT NULL REFERENCES task_versions(hash),
41
+ judge_harness TEXT NOT NULL,
42
+ judge_model TEXT NOT NULL,
43
+ judge_effort TEXT,
44
+ judge_label TEXT NOT NULL,
45
+ grader_rev INTEGER NOT NULL,
46
+ snapshot TEXT NOT NULL,
47
+ first_seen TEXT NOT NULL
48
+ );
49
+ CREATE TABLE user_setups (
50
+ hash TEXT PRIMARY KEY,
51
+ files TEXT NOT NULL
52
+ );
53
+ CREATE TABLE batches (
54
+ id TEXT PRIMARY KEY,
55
+ started_at TEXT NOT NULL,
56
+ ended_at TEXT,
57
+ request TEXT NOT NULL,
58
+ harness_versions TEXT NOT NULL
59
+ );
60
+ CREATE TABLE trials (
61
+ id TEXT PRIMARY KEY,
62
+ batch_id TEXT NOT NULL REFERENCES batches(id),
63
+ task_id TEXT NOT NULL,
64
+ task_hash TEXT NOT NULL REFERENCES task_versions(hash),
65
+ config_key TEXT NOT NULL,
66
+ config_id TEXT NOT NULL,
67
+ harness TEXT NOT NULL,
68
+ model TEXT NOT NULL,
69
+ effort TEXT,
70
+ profile TEXT,
71
+ provider TEXT,
72
+ family TEXT,
73
+ n INTEGER NOT NULL,
74
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'done', 'failed', 'invalid')),
75
+ started_at TEXT,
76
+ ended_at TEXT,
77
+ duration_ms INTEGER,
78
+ input_tokens INTEGER,
79
+ output_tokens INTEGER,
80
+ cache_read_tokens INTEGER,
81
+ cache_write_tokens INTEGER,
82
+ cost_usd REAL,
83
+ cost_source TEXT,
84
+ turns INTEGER,
85
+ setup_hash TEXT REFERENCES user_setups(hash),
86
+ profile_hash TEXT,
87
+ blocked_tools TEXT,
88
+ harness_version TEXT,
89
+ copied_share REAL,
90
+ error TEXT,
91
+ dir TEXT NOT NULL
92
+ );
93
+ CREATE TABLE gradings (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ trial_id TEXT NOT NULL REFERENCES trials(id),
96
+ grading_hash TEXT NOT NULL REFERENCES grading_setups(hash),
97
+ votes INTEGER NOT NULL,
98
+ graded_at TEXT NOT NULL,
99
+ status TEXT NOT NULL CHECK (status IN ('grading', 'done', 'invalid')),
100
+ score REAL,
101
+ gates_passed INTEGER,
102
+ measures TEXT,
103
+ judge_cost_usd REAL,
104
+ error TEXT
105
+ );
106
+ CREATE TABLE verdicts (
107
+ grading_id INTEGER NOT NULL REFERENCES gradings(id),
108
+ criterion_id TEXT NOT NULL,
109
+ score REAL,
110
+ error TEXT,
111
+ votes TEXT NOT NULL,
112
+ PRIMARY KEY (grading_id, criterion_id)
113
+ );
114
+
115
+ CREATE INDEX trials_task_config ON trials(task_id, task_hash, config_key);
116
+ CREATE INDEX gradings_trial_hash_time ON gradings(trial_id, grading_hash, graded_at);
117
+ CREATE INDEX verdicts_grading ON verdicts(grading_id);
118
+
119
+ CREATE TRIGGER trials_ended_no_update BEFORE UPDATE ON trials WHEN OLD.status IN ${ENDED}
120
+ BEGIN SELECT RAISE(ABORT, 'trial row is read-only: the trial has ended (status done, failed or invalid)'); END;
121
+ CREATE TRIGGER trials_ended_no_delete BEFORE DELETE ON trials WHEN OLD.status IN ${ENDED}
122
+ BEGIN SELECT RAISE(ABORT, 'trial row cannot be deleted: the trial has ended (status done, failed or invalid)'); END;
123
+ -- INSERT OR REPLACE deletes the old row without firing the DELETE trigger (recursive_triggers is off by
124
+ -- default), so an insert over an existing read-only row is refused here.
125
+ CREATE TRIGGER trials_ended_no_replace BEFORE INSERT ON trials
126
+ WHEN EXISTS (SELECT 1 FROM trials WHERE id = NEW.id AND status IN ${ENDED})
127
+ BEGIN SELECT RAISE(ABORT, 'trial row is read-only: the trial has ended (status done, failed or invalid)'); END;
128
+
129
+ CREATE TRIGGER gradings_finished_no_update BEFORE UPDATE ON gradings WHEN OLD.status <> 'grading'
130
+ BEGIN SELECT RAISE(ABORT, 'grading row is read-only: the grading has finished (status done or invalid); regrade to add a new grading'); END;
131
+ CREATE TRIGGER gradings_finished_no_delete BEFORE DELETE ON gradings WHEN OLD.status <> 'grading'
132
+ BEGIN SELECT RAISE(ABORT, 'grading row cannot be deleted: the grading has finished (status done or invalid)'); END;
133
+ CREATE TRIGGER gradings_finished_no_replace BEFORE INSERT ON gradings
134
+ WHEN EXISTS (SELECT 1 FROM gradings WHERE id = NEW.id AND status <> 'grading')
135
+ BEGIN SELECT RAISE(ABORT, 'grading row is read-only: the grading has finished (status done or invalid); regrade to add a new grading'); END;
136
+
137
+ CREATE TRIGGER verdicts_no_update BEFORE UPDATE ON verdicts
138
+ BEGIN SELECT RAISE(ABORT, 'verdict row is read-only: verdicts never change once written'); END;
139
+ CREATE TRIGGER verdicts_no_delete BEFORE DELETE ON verdicts
140
+ BEGIN SELECT RAISE(ABORT, 'verdict row cannot be deleted: verdicts never change once written'); END;
141
+ CREATE TRIGGER verdicts_finished_no_insert BEFORE INSERT ON verdicts
142
+ WHEN (SELECT status FROM gradings WHERE id = NEW.grading_id) <> 'grading'
143
+ BEGIN SELECT RAISE(ABORT, 'cannot add a verdict: its grading has finished (status done or invalid)'); END;
144
+ CREATE TRIGGER verdicts_no_replace BEFORE INSERT ON verdicts
145
+ WHEN EXISTS (SELECT 1 FROM verdicts WHERE grading_id = NEW.grading_id AND criterion_id = NEW.criterion_id)
146
+ BEGIN SELECT RAISE(ABORT, 'verdict row is read-only: verdicts never change once written'); END;
147
+ `,
148
+ // How many runs of a batch (its `run`, and each `rerun`) are still working: running trials or grading them.
149
+ // Bookkeeping for the batch's end time only, so it is not part of BatchRecord.
150
+ `ALTER TABLE batches ADD COLUMN runs_in_flight INTEGER NOT NULL DEFAULT 0;`,
151
+ // The grader revision was one number per grading version. The engine revision is a set of parts that the
152
+ // stored snapshot holds (identity.ts, engineOf), so the column has nothing left to say. Snapshots are not touched.
153
+ `ALTER TABLE grading_setups DROP COLUMN grader_rev;`,
154
+ // A task file's `version` is optional, so a task version can have no label. SQLite cannot drop NOT NULL in
155
+ // place: the table is built again with the same columns in the same order, and every row (rowid included) is
156
+ // copied. grading_setups and trials reference task_versions by name, so their foreign keys hold after the rename.
157
+ `
158
+ CREATE TABLE task_versions_new (
159
+ hash TEXT PRIMARY KEY,
160
+ task_id TEXT NOT NULL,
161
+ label INTEGER,
162
+ snapshot TEXT NOT NULL,
163
+ first_seen TEXT NOT NULL
164
+ );
165
+ INSERT INTO task_versions_new (rowid, hash, task_id, label, snapshot, first_seen)
166
+ SELECT rowid, hash, task_id, label, snapshot, first_seen FROM task_versions ORDER BY rowid;
167
+ DROP TABLE task_versions;
168
+ ALTER TABLE task_versions_new RENAME TO task_versions;
169
+ `,
170
+ ];
171
+
172
+ /** Where the store is: STATIC_FIRE_DB when set, else runs/static-fire.db in the project found from the current folder. */
173
+ export function defaultDbPath(): string {
174
+ return process.env.STATIC_FIRE_DB || projectPaths(projectRoot()).dbPath;
175
+ }
176
+
177
+ /**
178
+ * A trial's artifact folder: `<folder of the database>/trials/<trial-id>`. The `dir` column holds the relative
179
+ * `trials/<trial-id>`, so the records and artifacts survive a moved checkout.
180
+ */
181
+ export function trialDir(dbPath: string, trialId: string): string {
182
+ return join(dirname(resolve(dbPath)), "trials", trialId);
183
+ }
184
+
185
+ // ---- Records -------------------------------------------------------------------------------------
186
+ // Fields are camelCase; each maps to the snake_case column of the same name. Absent values are null.
187
+
188
+ type JsonObject = Record<string, unknown>;
189
+
190
+ /** One task hash: what the agent was given. */
191
+ export interface TaskVersionRecord {
192
+ hash: string; taskId: string;
193
+ label: number | null; // the task file's `version`, a readable label only; null when the file has none
194
+ snapshot: JsonObject; // prompt, output, file hashes
195
+ firstSeen: string;
196
+ }
197
+
198
+ /** One grading hash: how an output was graded. */
199
+ export interface GradingSetupRecord {
200
+ hash: string; taskId: string; taskHash: string;
201
+ judgeHarness: string; judgeModel: string; judgeEffort: string | null; judgeLabel: string;
202
+ snapshot: JsonObject; // gates, measures, context, file hashes, and the engine revision (identity.ts, engineOf)
203
+ firstSeen: string;
204
+ }
205
+
206
+ /** One user setup: path → content hash of each file. */
207
+ export interface UserSetupRecord { hash: string; files: Record<string, string> }
208
+
209
+ /** One `run` invocation. */
210
+ export interface BatchRecord {
211
+ id: string; startedAt: string; endedAt: string | null;
212
+ request: JsonObject; // what was asked for
213
+ harnessVersions: Record<string, string | null>; // harness → CLI version, read once per batch (null: could not be read)
214
+ }
215
+
216
+ export type TrialRecordStatus = "queued" | "running" | "done" | "failed" | "invalid";
217
+
218
+ /** One agent run. Read-only once its status is done, failed or invalid. */
219
+ export interface TrialRecord {
220
+ id: string; batchId: string; taskId: string; taskHash: string;
221
+ configKey: string; configId: string;
222
+ harness: string; model: string; effort: string | null; profile: string | null; provider: string | null; family: string | null;
223
+ n: number;
224
+ status: TrialRecordStatus;
225
+ startedAt: string | null; endedAt: string | null; durationMs: number | null;
226
+ inputTokens: number | null; outputTokens: number | null; cacheReadTokens: number | null; cacheWriteTokens: number | null;
227
+ costUsd: number | null; costSource: string | null; turns: number | null;
228
+ setupHash: string | null; profileHash: string | null; blockedTools: string[] | null; harnessVersion: string | null;
229
+ copiedShare: number | null;
230
+ error: string | null;
231
+ dir: string; // the trial's artifact folder
232
+ }
233
+
234
+ export type GradingStatus = "grading" | "done" | "invalid";
235
+
236
+ /** One grading of one trial under one grading hash. Read-only once done or invalid. */
237
+ export interface GradingRecord {
238
+ id: number; trialId: string; gradingHash: string; votes: number; gradedAt: string;
239
+ status: GradingStatus;
240
+ score: number | null; gatesPassed: boolean | null; measures: Record<string, number> | null;
241
+ judgeCostUsd: number | null; error: string | null;
242
+ }
243
+
244
+ /** One criterion in one grading. */
245
+ export interface VerdictRecord {
246
+ gradingId: number; criterionId: string;
247
+ score: number | null; error: string | null;
248
+ votes: Vote[];
249
+ }
250
+
251
+ export interface NewGrading { trialId: string; gradingHash: string; votes: number; gradedAt: string }
252
+
253
+ /** How a grading ended, with its verdicts. */
254
+ export interface GradingResult {
255
+ status: "done" | "invalid";
256
+ score: number | null; gatesPassed: boolean | null; measures: Record<string, number> | null;
257
+ judgeCostUsd: number | null; error: string | null;
258
+ verdicts: Omit<VerdictRecord, "gradingId">[];
259
+ }
260
+
261
+ export interface TrialFilter { batchId?: string; taskId?: string; taskHash?: string; configKey?: string }
262
+
263
+ /** Every record, for backup (`static-fire export`). Tables in an order a restore could insert them. */
264
+ export interface ExportDoc {
265
+ format: "static-fire-export";
266
+ schemaVersion: number;
267
+ exportedAt: string;
268
+ tables: {
269
+ task_versions: TaskVersionRecord[]; grading_setups: GradingSetupRecord[]; user_setups: UserSetupRecord[];
270
+ batches: BatchRecord[]; trials: TrialRecord[]; gradings: GradingRecord[]; verdicts: VerdictRecord[];
271
+ };
272
+ }
273
+
274
+ export interface Store {
275
+ /** Stored once per hash: a hash that exists keeps its first record, which is returned. */
276
+ putTaskVersion(r: TaskVersionRecord): TaskVersionRecord;
277
+ putGradingSetup(r: GradingSetupRecord): GradingSetupRecord;
278
+ putUserSetup(r: UserSetupRecord): UserSetupRecord;
279
+ getTaskVersion(hash: string): TaskVersionRecord | null;
280
+ getGradingSetup(hash: string): GradingSetupRecord | null;
281
+ getUserSetup(hash: string): UserSetupRecord | null;
282
+ /**
283
+ * Every grading setup, oldest first by first_seen, then hash. With a task id: the setups of that task, which
284
+ * are the ones recorded for it and the ones a grading of its trials used (two tasks with the same content
285
+ * share one row, stored with the first task's id).
286
+ */
287
+ listGradingSetups(taskId?: string): GradingSetupRecord[];
288
+ /** The tasks a grading setup belongs to: the task it was recorded for and the tasks of the trials graded under it. */
289
+ gradingSetupTaskIds(hash: string): string[];
290
+
291
+ createBatch(r: BatchRecord): void;
292
+ endBatch(id: string, endedAt: string): void;
293
+ /** A run of the batch starts (its `run` or a `rerun`): counts it as in flight and clears the batch's end time. */
294
+ startBatchRun(id: string): void;
295
+ /** A run of the batch has finished its trials and their gradings: stops counting it. Returns the runs still in flight. */
296
+ finishBatchRun(id: string): number;
297
+ getBatch(id: string): BatchRecord | null;
298
+ /** Newest first. */
299
+ listBatches(): BatchRecord[];
300
+
301
+ /** Inserts a trial in any status (an import inserts ended trials). */
302
+ insertTrial(r: TrialRecord): void;
303
+ /** Changes a trial that has not ended. Throws once its status is done, failed or invalid. */
304
+ updateTrial(id: string, patch: Partial<Omit<TrialRecord, "id">>): void;
305
+ getTrial(id: string): TrialRecord | null;
306
+ /** In id order. */
307
+ listTrials(filter?: TrialFilter): TrialRecord[];
308
+
309
+ /** Adds a grading row in status `grading` and returns its id. A regrade adds a row; none is overwritten. */
310
+ startGrading(g: NewGrading): number;
311
+ /** Ends a grading with its verdicts, in one transaction. Throws when the grading has already finished. */
312
+ finishGrading(id: number, r: GradingResult): void;
313
+ /** Every grading of a trial (optionally under one grading hash), oldest first, finished or not. */
314
+ listGradings(trialId: string, gradingHash?: string): GradingRecord[];
315
+ /** The grading that counts: the latest finished (done or invalid) one under that hash, by graded_at, then id. */
316
+ currentGrading(trialId: string, gradingHash: string): GradingRecord | null;
317
+ listVerdicts(gradingId: number): VerdictRecord[];
318
+
319
+ /** Runs `fn` in one write transaction (BEGIN IMMEDIATE), for several writes that must land together. */
320
+ transaction<T>(fn: () => T): T;
321
+ /** Every record, read in one transaction so the copy is consistent. */
322
+ exportAll(): ExportDoc;
323
+ /** Reads one PRAGMA of this connection, for example "busy_timeout". */
324
+ pragma(name: string): unknown;
325
+ close(): void;
326
+ }
327
+
328
+ /**
329
+ * Opens the store at `path`, creating the folder and the database when missing and bringing an older
330
+ * schema up to SCHEMA_VERSION. Opening a current database writes nothing.
331
+ */
332
+ export function openStore(path: string): Store {
333
+ mkdirSync(dirname(path), { recursive: true });
334
+ const db = new Database(path, { create: true, strict: true });
335
+ try {
336
+ db.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); // first, so the statements below wait for other writers too
337
+ useWal(db, path);
338
+ db.run("PRAGMA foreign_keys = ON");
339
+ migrate(db, path);
340
+ } catch (e) {
341
+ db.close();
342
+ throw e;
343
+ }
344
+ return storeOn(db);
345
+ }
346
+
347
+ /** A new, empty store in memory, with no file: what a plan reads when the project has no record store yet. */
348
+ export function emptyStore(): Store {
349
+ const db = new Database(":memory:", { strict: true });
350
+ db.run("PRAGMA foreign_keys = ON");
351
+ migrate(db, ":memory:");
352
+ return storeOn(db);
353
+ }
354
+
355
+ /**
356
+ * A copy in memory of the store at `path`, for a plan, which must write nothing (#74). The file is opened read-only
357
+ * and read in one go; an older schema is brought up to SCHEMA_VERSION in the copy only, so the file keeps its
358
+ * schema, its journal mode and its content. Writes to the copy are lost when it is closed.
359
+ */
360
+ export function snapshotStore(path: string): Store {
361
+ const file = new Database(path, { readonly: true, strict: true });
362
+ let bytes: Uint8Array;
363
+ try {
364
+ file.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
365
+ bytes = file.serialize();
366
+ } finally {
367
+ file.close();
368
+ }
369
+ // Bytes 18 and 19 of the header say WAL mode; an image in memory cannot use WAL, so mark it as a rollback journal.
370
+ bytes[18] = 1;
371
+ bytes[19] = 1;
372
+ const db = Database.deserialize(bytes, { strict: true });
373
+ try {
374
+ db.run("PRAGMA foreign_keys = ON");
375
+ migrate(db, path);
376
+ } catch (e) {
377
+ db.close();
378
+ throw e;
379
+ }
380
+ return storeOn(db);
381
+ }
382
+
383
+ /**
384
+ * Switches a new database to WAL mode (the mode is kept in the file, so later opens only read it).
385
+ * SQLite does not apply the busy timeout to this switch: when another process is creating the same file
386
+ * it fails at once with SQLITE_BUSY. So it is retried for up to the busy timeout.
387
+ */
388
+ function useWal(db: Database, path: string) {
389
+ const mode = () => (db.query("PRAGMA journal_mode").get() as { journal_mode: string }).journal_mode;
390
+ const until = Date.now() + BUSY_TIMEOUT_MS;
391
+ for (let wait = 5; mode() !== "wal"; wait = Math.min(wait * 2, 100)) {
392
+ try {
393
+ db.run("PRAGMA journal_mode = WAL");
394
+ } catch (e) {
395
+ if ((e as { code?: string }).code !== "SQLITE_BUSY" || Date.now() > until) throw e;
396
+ Bun.sleepSync(wait);
397
+ continue;
398
+ }
399
+ if (mode() !== "wal") throw new Error(`could not switch ${path} to WAL mode (it is in ${mode()} mode)`);
400
+ }
401
+ }
402
+
403
+ const userVersion = (db: Database) => (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
404
+
405
+ function migrate(db: Database, path: string) {
406
+ const tooNew = (v: number) =>
407
+ new Error(`${path} has schema version ${v}, newer than this Static Fire (${SCHEMA_VERSION}); update Static Fire to use it`);
408
+ const seen = userVersion(db);
409
+ if (seen > SCHEMA_VERSION) throw tooNew(seen);
410
+ if (seen === SCHEMA_VERSION) return;
411
+ // A step that builds a table again drops the old one, which foreign keys on would refuse. The setting cannot
412
+ // change inside a transaction, so it is off for the whole upgrade and checked by hand before the commit.
413
+ db.run("PRAGMA foreign_keys = OFF");
414
+ try {
415
+ // Read the version again inside the write lock: another process may have migrated since.
416
+ db.transaction(() => {
417
+ const from = userVersion(db);
418
+ if (from > SCHEMA_VERSION) throw tooNew(from);
419
+ if (from === SCHEMA_VERSION) return;
420
+ for (let v = from; v < SCHEMA_VERSION; v++) db.run(MIGRATIONS[v]!);
421
+ const broken = db.query("PRAGMA foreign_key_check").all() as { table: string; rowid: number; parent: string }[];
422
+ if (broken.length) {
423
+ const where = [...new Set(broken.map((b) => `${b.table} → ${b.parent}`))].join(", ");
424
+ throw new Error(`${path}: the upgrade to schema version ${SCHEMA_VERSION} would leave ${broken.length} foreign key reference(s) broken (${where}); the store was left as it was`);
425
+ }
426
+ db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
427
+ }).immediate();
428
+ } finally {
429
+ db.run("PRAGMA foreign_keys = ON");
430
+ }
431
+ }
432
+
433
+ // ---- Row mapping ---------------------------------------------------------------------------------
434
+
435
+ type Kind = "plain" | "json" | "bool";
436
+ interface Table { name: string; order: string; fields: [field: string, column: string, kind: Kind][] }
437
+
438
+ const snake = (field: string) => field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
439
+
440
+ /** `spec` lists the fields, camelCase, with ":json" or ":bool" for columns that need converting. */
441
+ const table = (name: string, order: string, spec: string): Table => ({
442
+ name, order,
443
+ fields: spec.trim().split(/\s+/).map((f) => {
444
+ const [field, kind = "plain"] = f.split(":") as [string, Kind?];
445
+ return [field, snake(field), kind];
446
+ }),
447
+ });
448
+
449
+ const T = {
450
+ task_versions: table("task_versions", "hash", "hash taskId label snapshot:json firstSeen"),
451
+ grading_setups: table("grading_setups", "hash",
452
+ "hash taskId taskHash judgeHarness judgeModel judgeEffort judgeLabel snapshot:json firstSeen"),
453
+ user_setups: table("user_setups", "hash", "hash files:json"),
454
+ batches: table("batches", "id", "id startedAt endedAt request:json harnessVersions:json"),
455
+ trials: table("trials", "id", `
456
+ id batchId taskId taskHash configKey configId harness model effort profile provider family n status
457
+ startedAt endedAt durationMs inputTokens outputTokens cacheReadTokens cacheWriteTokens costUsd costSource turns
458
+ setupHash profileHash blockedTools:json harnessVersion copiedShare error dir`),
459
+ gradings: table("gradings", "id",
460
+ "id trialId gradingHash votes gradedAt status score gatesPassed:bool measures:json judgeCostUsd error"),
461
+ verdicts: table("verdicts", "grading_id, criterion_id", "gradingId criterionId score error votes:json"),
462
+ } as const;
463
+
464
+ /** Record → named parameters (column → value). Only the fields present in `r` are included. */
465
+ function encode(t: Table, r: Record<string, unknown>): Record<string, unknown> {
466
+ const known = new Set(t.fields.map(([f]) => f));
467
+ for (const k of Object.keys(r)) if (!known.has(k)) throw new Error(`${t.name} has no field ${k}`);
468
+ const out: Record<string, unknown> = {};
469
+ for (const [field, column, kind] of t.fields) {
470
+ if (!(field in r)) continue;
471
+ const v = r[field] ?? null;
472
+ out[column] = v === null ? null : kind === "json" ? JSON.stringify(v) : kind === "bool" ? (v ? 1 : 0) : v;
473
+ }
474
+ return out;
475
+ }
476
+
477
+ function decode<R>(t: Table, row: Record<string, unknown> | null): R | null {
478
+ if (!row) return null;
479
+ const out: Record<string, unknown> = {};
480
+ for (const [field, column, kind] of t.fields) {
481
+ const v = row[column] ?? null;
482
+ out[field] = v === null ? null : kind === "json" ? JSON.parse(v as string) : kind === "bool" ? v === 1 : v;
483
+ }
484
+ return out as R;
485
+ }
486
+
487
+ const ENDED_TRIAL = new Set<string>(["done", "failed", "invalid"]);
488
+ /** The grading hashes the gradings of one task's trials ($taskId) used. */
489
+ const GRADING_HASHES_OF_TASK = "SELECT g.grading_hash FROM gradings g JOIN trials t ON t.id = g.trial_id WHERE t.task_id = $taskId";
490
+
491
+ function storeOn(db: Database): Store {
492
+ const write = <X>(fn: () => X): X => db.transaction(fn).immediate();
493
+ const insert = (t: Table, r: object, verb = "INSERT") => {
494
+ const p = encode(t, r as Record<string, unknown>);
495
+ const cols = Object.keys(p);
496
+ return db.query(`${verb} INTO ${t.name} (${cols.join(", ")}) VALUES (${cols.map((c) => `$${c}`).join(", ")})`).run(p as any);
497
+ };
498
+ const one = <R>(t: Table, where: string, params: Record<string, unknown>) =>
499
+ decode<R>(t, db.query(`SELECT * FROM ${t.name} WHERE ${where}`).get(params as any) as Record<string, unknown> | null);
500
+ const many = <R>(t: Table, where: string, params: Record<string, unknown>, order = t.order) =>
501
+ (db.query(`SELECT * FROM ${t.name} ${where ? `WHERE ${where}` : ""} ORDER BY ${order}`).all(params as any) as Record<string, unknown>[])
502
+ .map((row) => decode<R>(t, row)!);
503
+ /** INSERT OR IGNORE, then the stored row: the first record for a hash stays. */
504
+ const putOnce = <R extends { hash: string }>(t: Table, r: R): R => write(() => {
505
+ insert(t, r, "INSERT OR IGNORE");
506
+ return one<R>(t, "hash = $hash", { hash: r.hash })!;
507
+ });
508
+ const getGrading = (id: number) => one<GradingRecord>(T.gradings, "id = $id", { id });
509
+
510
+ const store: Store = {
511
+ putTaskVersion: (r) => putOnce(T.task_versions, r),
512
+ putGradingSetup: (r) => putOnce(T.grading_setups, r),
513
+ putUserSetup: (r) => putOnce(T.user_setups, r),
514
+ getTaskVersion: (hash) => one(T.task_versions, "hash = $hash", { hash }),
515
+ getGradingSetup: (hash) => one(T.grading_setups, "hash = $hash", { hash }),
516
+ getUserSetup: (hash) => one(T.user_setups, "hash = $hash", { hash }),
517
+ listGradingSetups: (taskId) => taskId === undefined
518
+ ? many(T.grading_setups, "", {}, "first_seen, hash")
519
+ : many(T.grading_setups, `task_id = $taskId OR hash IN (${GRADING_HASHES_OF_TASK})`, { taskId }, "first_seen, hash"),
520
+ gradingSetupTaskIds: (hash) => (db.query(`SELECT task_id FROM grading_setups WHERE hash = $hash
521
+ UNION SELECT t.task_id FROM gradings g JOIN trials t ON t.id = g.trial_id WHERE g.grading_hash = $hash ORDER BY 1`)
522
+ .all({ hash }) as { task_id: string }[]).map((r) => r.task_id),
523
+
524
+ createBatch: (r) => { write(() => insert(T.batches, r)); },
525
+ endBatch(id, endedAt) {
526
+ const { changes } = write(() => db.query("UPDATE batches SET ended_at = $endedAt WHERE id = $id").run({ id, endedAt }));
527
+ if (!changes) throw new Error(`no batch ${id}`);
528
+ },
529
+ startBatchRun(id) {
530
+ const { changes } = write(() => db.query("UPDATE batches SET ended_at = NULL, runs_in_flight = runs_in_flight + 1 WHERE id = $id").run({ id }));
531
+ if (!changes) throw new Error(`no batch ${id}`);
532
+ },
533
+ finishBatchRun: (id) => write(() => {
534
+ const row = db.query("UPDATE batches SET runs_in_flight = MAX(runs_in_flight - 1, 0) WHERE id = $id RETURNING runs_in_flight").get({ id }) as { runs_in_flight: number } | null;
535
+ if (!row) throw new Error(`no batch ${id}`);
536
+ return row.runs_in_flight;
537
+ }),
538
+ getBatch: (id) => one(T.batches, "id = $id", { id }),
539
+ listBatches: () => many(T.batches, "", {}, "started_at DESC, id DESC"),
540
+
541
+ insertTrial: (r) => { write(() => insert(T.trials, r)); },
542
+ updateTrial(id, patch) {
543
+ if ("id" in patch) throw new Error(`a trial's id cannot change (trial ${id})`);
544
+ const p = encode(T.trials, patch);
545
+ const cols = Object.keys(p);
546
+ if (!cols.length) return;
547
+ write(() => {
548
+ const cur = one<TrialRecord>(T.trials, "id = $id", { id });
549
+ if (!cur) throw new Error(`no trial ${id}`);
550
+ if (ENDED_TRIAL.has(cur.status)) throw new Error(`trial ${id} has ended (status ${cur.status}); its row cannot change`);
551
+ db.query(`UPDATE trials SET ${cols.map((c) => `${c} = $${c}`).join(", ")} WHERE id = $__id`).run({ ...p, __id: id } as any);
552
+ });
553
+ },
554
+ getTrial: (id) => one(T.trials, "id = $id", { id }),
555
+ listTrials(f = {}) {
556
+ const where = (["batchId", "taskId", "taskHash", "configKey"] as const)
557
+ .filter((k) => f[k] !== undefined)
558
+ .map((k) => `${snake(k)} = $${k}`);
559
+ const params = Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined));
560
+ return many(T.trials, where.join(" AND "), params);
561
+ },
562
+
563
+ startGrading(g) {
564
+ const { lastInsertRowid } = write(() => insert(T.gradings, { ...g, status: "grading" }));
565
+ return Number(lastInsertRowid);
566
+ },
567
+ finishGrading(id, r) {
568
+ if (r.status !== "done" && r.status !== "invalid") throw new Error(`a grading ends as done or invalid, not ${r.status}`);
569
+ write(() => {
570
+ const cur = getGrading(id);
571
+ if (!cur) throw new Error(`no grading ${id}`);
572
+ if (cur.status !== "grading") {
573
+ throw new Error(`grading ${id} has finished (status ${cur.status}); a grading is never overwritten: start a new grading to regrade`);
574
+ }
575
+ for (const v of r.verdicts) insert(T.verdicts, { ...v, gradingId: id }); // before the status changes: verdicts of a finished grading are refused
576
+ const { verdicts, ...rest } = r;
577
+ const p = encode(T.gradings, rest);
578
+ db.query(`UPDATE gradings SET ${Object.keys(p).map((c) => `${c} = $${c}`).join(", ")} WHERE id = $__id`).run({ ...p, __id: id } as any);
579
+ });
580
+ },
581
+ listGradings: (trialId, gradingHash) => gradingHash === undefined
582
+ ? many(T.gradings, "trial_id = $trialId", { trialId }, "graded_at, id")
583
+ : many(T.gradings, "trial_id = $trialId AND grading_hash = $gradingHash", { trialId, gradingHash }, "graded_at, id"),
584
+ currentGrading: (trialId, gradingHash) => many<GradingRecord>(T.gradings,
585
+ "trial_id = $trialId AND grading_hash = $gradingHash AND status IN ('done', 'invalid')", { trialId, gradingHash },
586
+ "graded_at DESC, id DESC LIMIT 1")[0] ?? null,
587
+ listVerdicts: (gradingId) => many(T.verdicts, "grading_id = $gradingId", { gradingId }, "criterion_id"),
588
+
589
+ transaction: (fn) => write(fn),
590
+ exportAll: () => db.transaction((): ExportDoc => ({
591
+ format: "static-fire-export",
592
+ schemaVersion: SCHEMA_VERSION,
593
+ exportedAt: new Date().toISOString(),
594
+ tables: {
595
+ task_versions: many(T.task_versions, "", {}), grading_setups: many(T.grading_setups, "", {}),
596
+ user_setups: many(T.user_setups, "", {}), batches: many(T.batches, "", {}), trials: many(T.trials, "", {}),
597
+ gradings: many(T.gradings, "", {}), verdicts: many(T.verdicts, "", {}),
598
+ },
599
+ }))(),
600
+ pragma(name) {
601
+ if (!/^[a-z_]+$/.test(name)) throw new Error(`not a pragma name: ${name}`);
602
+ const row = db.query(`PRAGMA ${name}`).get() as Record<string, unknown> | null;
603
+ return row ? Object.values(row)[0] : null;
604
+ },
605
+ close: () => db.close(),
606
+ };
607
+ return store;
608
+ }