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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// `static-fire migrate`: a one-off import of an experiment in the 2026-11-06 file format
|
|
2
|
+
// (runs/<experiment>/{experiment.json, trials/<trial>.json, trials/<trial>/…}) into the record store, with its
|
|
3
|
+
// artifacts copied to the per-trial folders (<folder of the database>/trials/<trial-id>/). The old experiment
|
|
4
|
+
// folder is only read; delete it by hand once the dashboard shows the imported data.
|
|
5
|
+
//
|
|
6
|
+
// The import takes today's task files as the task the experiment ran, and today's grading hash with the
|
|
7
|
+
// experiment's judge as the grading its verdicts came from (inferred: the batch request says
|
|
8
|
+
// `gradingInferred: true`). Before writing anything it checks that this is true enough: the task version is
|
|
9
|
+
// unchanged, every trial has ended, and each trial's verdicts are today's criteria and give its score again.
|
|
10
|
+
//
|
|
11
|
+
// Three steps, each one safe to run again, so an interrupted migrate is finished by running it again:
|
|
12
|
+
// 1. copy each trial's workspace and agent log (to a staging folder, then one rename);
|
|
13
|
+
// 2. write every record in one transaction (skipped when the trials are already in the store);
|
|
14
|
+
// 3. copy each grading's judge logs to gradings/<grading-id>/ (staging folder, then one rename).
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { OUTPUT_GATE } from "./grade";
|
|
18
|
+
import { canonicalJson, configKey, gradingIdentity, sha256, taskIdentity } from "./identity";
|
|
19
|
+
import { openStore, trialDir, type BatchRecord, type GradingResult, type GradingSetupRecord, type TaskVersionRecord, type TrialRecord, type UserSetupRecord } from "./db";
|
|
20
|
+
import { scoreTrial } from "./score";
|
|
21
|
+
import { loadTask } from "./authored";
|
|
22
|
+
import { projectRoot } from "./project";
|
|
23
|
+
import type { AgentConfig, Experiment, Grades, Task, Trial } from "./types";
|
|
24
|
+
|
|
25
|
+
/** The experiment the spec imports. Older ones (task versions 3 to 12) are out of scope. */
|
|
26
|
+
export const DEFAULT_EXPERIMENT = "20261106-071029-writing";
|
|
27
|
+
const OLDEST = "2026-11-06";
|
|
28
|
+
/** Config ids renamed since: the human config `kenny` is `configs/user.yaml` today. */
|
|
29
|
+
const CONFIG_RENAMES: Record<string, string> = { kenny: "user" };
|
|
30
|
+
const ENDED = new Set(["done", "failed", "invalid"]);
|
|
31
|
+
const COPY = { recursive: true, preserveTimestamps: true, verbatimSymlinks: true } as const;
|
|
32
|
+
|
|
33
|
+
export interface MigrateOptions {
|
|
34
|
+
source: string; // the old experiment folder, runs/<experiment-id>
|
|
35
|
+
dbPath: string; // the store; artifacts go next to it, in trials/<trial-id>/
|
|
36
|
+
root?: string; // the project root that holds today's tasks (evals/<id>/task.yaml)
|
|
37
|
+
log?: (msg: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What this run added. A run that finds everything in place returns empty counts. */
|
|
41
|
+
export interface MigrateResult { batches: string[]; trials: number; gradings: number; copied: number }
|
|
42
|
+
|
|
43
|
+
interface PlannedTrial {
|
|
44
|
+
oldDir: string;
|
|
45
|
+
record: TrialRecord;
|
|
46
|
+
grading: { hash: string; votes: number; gradedAt: string; result: GradingResult } | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Plan {
|
|
50
|
+
experimentId: string;
|
|
51
|
+
batches: BatchRecord[];
|
|
52
|
+
trials: PlannedTrial[];
|
|
53
|
+
taskVersions: TaskVersionRecord[];
|
|
54
|
+
gradingSetups: GradingSetupRecord[];
|
|
55
|
+
userSetup: UserSetupRecord | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const refuse = (msg: string): never => { throw new Error(msg); };
|
|
59
|
+
const readJson = <T>(p: string): T => JSON.parse(readFileSync(p, "utf8"));
|
|
60
|
+
/** "2026-11-06T07:10:29.725Z" → "20261106-071029": the UTC stamp batch ids use. */
|
|
61
|
+
const stamp = (iso: string) => iso.replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
|
|
62
|
+
|
|
63
|
+
export function migrateExperiment(o: MigrateOptions): MigrateResult {
|
|
64
|
+
const log = o.log ?? (() => {});
|
|
65
|
+
const plan = planImport(o.source, o.root ?? projectRoot()); // reads only; throws before anything is written
|
|
66
|
+
const result: MigrateResult = { batches: [], trials: 0, gradings: 0, copied: 0 };
|
|
67
|
+
|
|
68
|
+
result.copied += copyTrialFolders(plan, o.dbPath);
|
|
69
|
+
const store = openStore(o.dbPath);
|
|
70
|
+
try {
|
|
71
|
+
writeRecords(store, plan, result);
|
|
72
|
+
result.copied += copyJudgeLogs(plan, o.dbPath, (id) => store.listGradings(id)[0]?.id ?? null);
|
|
73
|
+
} finally {
|
|
74
|
+
store.close();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!result.trials && !result.copied) log(`nothing to import: the ${plan.trials.length} trials of ${plan.experimentId} are in the store and their folders are copied`);
|
|
78
|
+
else log(`${plan.experimentId}: imported ${result.trials} trial(s) and ${result.gradings} grading(s)${result.batches.length ? ` in batch(es) ${result.batches.join(", ")}` : ""}; copied ${result.copied} folder(s)`);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- 1. Plan: read the old experiment, check it, build every record -------------------------------
|
|
83
|
+
|
|
84
|
+
function planImport(source: string, root: string): Plan {
|
|
85
|
+
const expPath = join(source, "experiment.json");
|
|
86
|
+
if (!existsSync(expPath)) refuse(`${source} is not an experiment folder: it has no experiment.json`);
|
|
87
|
+
const exp = readJson<Experiment>(expPath);
|
|
88
|
+
if (exp.schema !== 2) refuse(`experiment ${exp.id} has schema ${exp.schema}; only the 2026-11-06 format (schema 2) can be imported`);
|
|
89
|
+
if (exp.createdAt.slice(0, 10) < OLDEST) refuse(`experiment ${exp.id} started on ${exp.createdAt.slice(0, 10)}, older than ${OLDEST}: only experiments from ${OLDEST} on can be imported`);
|
|
90
|
+
const judge = exp.judge;
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
|
|
93
|
+
const tasks = new Map<string, Task>();
|
|
94
|
+
const taskVersions: TaskVersionRecord[] = [], gradingSetups: GradingSetupRecord[] = [];
|
|
95
|
+
const hashes = new Map<string, { task: string; grading: string }>();
|
|
96
|
+
for (const [id, ran] of Object.entries(exp.tasks)) {
|
|
97
|
+
let task: Task;
|
|
98
|
+
try { task = loadTask(id, root); } catch (e) { return refuse(`task ${id} cannot be loaded today: ${(e as Error).message}`); }
|
|
99
|
+
if (task.version !== ran) {
|
|
100
|
+
refuse(`the experiment ran task ${id} version ${ran}, but today's task file ${task.version == null ? "has no version" : `is version ${task.version}`}. The import takes today's task files as the task the experiment ran, so a task that changed since cannot be imported`);
|
|
101
|
+
}
|
|
102
|
+
const t = taskIdentity(task), g = gradingIdentity(task, judge);
|
|
103
|
+
tasks.set(id, task);
|
|
104
|
+
hashes.set(id, { task: t.hash, grading: g.hash });
|
|
105
|
+
taskVersions.push({ hash: t.hash, taskId: id, label: task.version ?? null, snapshot: { ...t.snapshot }, firstSeen: now });
|
|
106
|
+
gradingSetups.push({
|
|
107
|
+
hash: g.hash, taskId: id, taskHash: t.hash, judgeHarness: judge.harness, judgeModel: judge.model, judgeEffort: judge.effort ?? null,
|
|
108
|
+
judgeLabel: judge.label, snapshot: { ...g.snapshot }, firstSeen: now,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trialsDir = join(source, "trials");
|
|
113
|
+
const old = (existsSync(trialsDir) ? readdirSync(trialsDir) : []).filter((f) => f.endsWith(".json")).sort()
|
|
114
|
+
.map((f) => readJson<Trial>(join(trialsDir, f)));
|
|
115
|
+
if (!old.length) refuse(`experiment ${exp.id} has no trials`);
|
|
116
|
+
for (const t of old) {
|
|
117
|
+
if (!ENDED.has(t.status)) refuse(`trial ${t.id} has not ended (status ${t.status}): only a finished experiment can be imported`);
|
|
118
|
+
if (!tasks.has(t.taskId)) refuse(`trial ${t.id} is for task ${t.taskId}, which the experiment does not list`);
|
|
119
|
+
if (!exp.configs[t.configId]) refuse(`trial ${t.id} is for config ${t.configId}, which the experiment does not record`);
|
|
120
|
+
if (t.grades) checkGrades(t, t.grades, tasks.get(t.taskId)!);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One batch per UTC start date: the experiment's own run, and trials added to it later.
|
|
124
|
+
const day = (t: Trial) => (t.startedAt ?? t.endedAt ?? exp.createdAt).slice(0, 10);
|
|
125
|
+
const groups = new Map<string, Trial[]>();
|
|
126
|
+
for (const t of old) groups.set(day(t), [...(groups.get(day(t)) ?? []), t]);
|
|
127
|
+
|
|
128
|
+
// The user setup was recorded when the experiment started; it is known only for the trials of that day.
|
|
129
|
+
const userSetup: UserSetupRecord | null = exp.userSetup?.files ? { hash: sha256(canonicalJson(exp.userSetup.files)), files: exp.userSetup.files } : null;
|
|
130
|
+
const configId = (id: string) => CONFIG_RENAMES[id] ?? id;
|
|
131
|
+
|
|
132
|
+
const batches: BatchRecord[] = [], trials: PlannedTrial[] = [];
|
|
133
|
+
for (const [date, group] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
|
134
|
+
const starts = group.map((t) => t.startedAt ?? t.endedAt ?? exp.createdAt).sort();
|
|
135
|
+
const ends = group.map((t) => t.endedAt).filter((e): e is string => !!e).sort();
|
|
136
|
+
const batchId = stamp(starts[0]!);
|
|
137
|
+
const inBatch = new Set(group.map((t) => t.configId));
|
|
138
|
+
batches.push({
|
|
139
|
+
id: batchId, startedAt: starts[0]!, endedAt: ends.at(-1) ?? null, harnessVersions: {},
|
|
140
|
+
request: {
|
|
141
|
+
importedFrom: exp.id, suites: exp.suiteIds, configs: exp.configIds.filter((c) => inBatch.has(c)).map(configId),
|
|
142
|
+
trials: exp.trialsPerConfig, judge: judge.id, votes: judge.votes, gradingInferred: true,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
for (const t of group) {
|
|
146
|
+
const config: AgentConfig = { ...exp.configs[t.configId]!, id: configId(t.configId) };
|
|
147
|
+
const id = `${batchId}--${t.taskId}--${config.id}--${t.index}`;
|
|
148
|
+
const oldDir = join(trialsDir, t.id);
|
|
149
|
+
const usage = t.result?.usage;
|
|
150
|
+
const grades = t.grades;
|
|
151
|
+
trials.push({
|
|
152
|
+
oldDir,
|
|
153
|
+
record: {
|
|
154
|
+
id, batchId, taskId: t.taskId, taskHash: hashes.get(t.taskId)!.task,
|
|
155
|
+
configKey: configKey(config), configId: config.id,
|
|
156
|
+
harness: config.harness, model: config.model, effort: config.effort ?? null, profile: config.profile ?? null,
|
|
157
|
+
provider: config.provider ?? null, family: config.family ?? null,
|
|
158
|
+
n: t.index,
|
|
159
|
+
// An old "invalid" with grades was a grader error: today that is the grading's status, not the trial's.
|
|
160
|
+
status: t.status === "invalid" && grades ? "done" : t.status as TrialRecord["status"],
|
|
161
|
+
startedAt: t.startedAt ?? null, endedAt: t.endedAt ?? null, durationMs: t.durationMs ?? null,
|
|
162
|
+
inputTokens: usage?.inputTokens ?? null, outputTokens: usage?.outputTokens ?? null,
|
|
163
|
+
cacheReadTokens: usage?.cacheReadTokens ?? null, cacheWriteTokens: usage?.cacheWriteTokens ?? null,
|
|
164
|
+
costUsd: t.result?.costUsd ?? null, costSource: t.result?.costSource ?? null, turns: t.result?.turns ?? null,
|
|
165
|
+
setupHash: userSetup && date === exp.createdAt.slice(0, 10) ? userSetup.hash : null,
|
|
166
|
+
profileHash: null, harnessVersion: null, copiedShare: null,
|
|
167
|
+
blockedTools: config.harness === "human" || !exp.blockedTools ? null : (exp.blockedTools[config.harness] ?? []),
|
|
168
|
+
error: t.error ?? null,
|
|
169
|
+
dir: `trials/${id}`,
|
|
170
|
+
},
|
|
171
|
+
grading: grades ? {
|
|
172
|
+
hash: hashes.get(t.taskId)!.grading, votes: judge.votes, gradedAt: gradedAt(join(oldDir, "judge")) ?? t.endedAt ?? exp.createdAt,
|
|
173
|
+
result: {
|
|
174
|
+
status: grades.errors.length ? "invalid" : "done",
|
|
175
|
+
score: grades.score, gatesPassed: grades.gatesPassed, measures: grades.measures, judgeCostUsd: grades.judgeCostUsd ?? null,
|
|
176
|
+
error: null,
|
|
177
|
+
verdicts: Object.entries(grades.verdicts).map(([criterionId, v]) => ({ criterionId, score: v.score ?? null, error: v.error ?? null, votes: v.votes ?? [] })),
|
|
178
|
+
},
|
|
179
|
+
} : null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
for (const t of trials) {
|
|
185
|
+
if (seen.has(t.record.id)) refuse(`two trials would both be imported as ${t.record.id}`);
|
|
186
|
+
seen.add(t.record.id);
|
|
187
|
+
}
|
|
188
|
+
return { experimentId: exp.id, batches, trials, taskVersions, gradingSetups, userSetup };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The verdicts must be today's criteria and give the recorded score again, or today's grading hash would be untrue. */
|
|
192
|
+
function checkGrades(t: Trial, g: Grades, task: Task) {
|
|
193
|
+
const graded = { ...task, gates: [OUTPUT_GATE, ...task.gates] };
|
|
194
|
+
const today = new Set([...graded.gates, ...task.measures.flatMap((m) => m.criteria)].map((c) => c.id));
|
|
195
|
+
const had = new Set(Object.keys(g.verdicts));
|
|
196
|
+
const onlyOld = [...had].filter((id) => !today.has(id)), onlyNew = [...today].filter((id) => !had.has(id));
|
|
197
|
+
if (onlyOld.length || onlyNew.length) {
|
|
198
|
+
refuse(`trial ${t.id}: its criteria differ from today's task ${task.id} (only in the trial: ${onlyOld.join(", ") || "none"}; only in today's task: ${onlyNew.join(", ") || "none"})`);
|
|
199
|
+
}
|
|
200
|
+
const again = scoreTrial(graded, g.verdicts, g.judgeCostUsd);
|
|
201
|
+
const near = (a: number, b: number) => Math.abs(a - b) < 1e-9;
|
|
202
|
+
const measuresAgree = Object.keys(again.measures).length === Object.keys(g.measures).length
|
|
203
|
+
&& Object.entries(again.measures).every(([id, v]) => g.measures[id] != null && near(v, g.measures[id]!));
|
|
204
|
+
if (!near(again.score, g.score) || again.gatesPassed !== g.gatesPassed || !measuresAgree) {
|
|
205
|
+
refuse(`trial ${t.id}: today's task ${task.id} gives its verdicts the score ${again.score}, but the trial recorded ${g.score}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** When the judge logs were last written: the evidence for when the trial was graded. */
|
|
210
|
+
function gradedAt(judgeDir: string): string | null {
|
|
211
|
+
let latest = -1;
|
|
212
|
+
const walk = (p: string) => {
|
|
213
|
+
const st = statSync(p);
|
|
214
|
+
if (st.isDirectory()) for (const f of readdirSync(p)) walk(join(p, f));
|
|
215
|
+
else latest = Math.max(latest, st.mtimeMs);
|
|
216
|
+
};
|
|
217
|
+
if (existsSync(judgeDir)) walk(judgeDir);
|
|
218
|
+
return latest < 0 ? null : new Date(latest).toISOString();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- 2. Copy, write, copy: each step skips what is already done --------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Copies `fill` into a staging folder next to `dest`, then renames it to `dest`: a folder at `dest` is always
|
|
225
|
+
* complete. A staging folder left by an interrupted run is removed first. Returns false when `dest` exists.
|
|
226
|
+
*/
|
|
227
|
+
function copyOnce(dest: string, fill: (staging: string) => void): boolean {
|
|
228
|
+
const parent = dirname(dest), name = dest.slice(parent.length + 1);
|
|
229
|
+
if (existsSync(parent)) for (const f of readdirSync(parent)) {
|
|
230
|
+
if (f.startsWith(`.${name}.`) && f.endsWith(".partial")) rmSync(join(parent, f), { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
if (existsSync(dest)) return false;
|
|
233
|
+
const staging = join(parent, `.${name}.${process.pid}.partial`);
|
|
234
|
+
mkdirSync(staging, { recursive: true });
|
|
235
|
+
fill(staging);
|
|
236
|
+
renameSync(staging, dest);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function copyTrialFolders(plan: Plan, dbPath: string): number {
|
|
241
|
+
let copied = 0;
|
|
242
|
+
for (const t of plan.trials) {
|
|
243
|
+
const done = copyOnce(trialDir(dbPath, t.record.id), (staging) => {
|
|
244
|
+
if (existsSync(join(t.oldDir, "workspace"))) cpSync(join(t.oldDir, "workspace"), join(staging, "workspace"), COPY);
|
|
245
|
+
if (existsSync(join(t.oldDir, "agent.log"))) cpSync(join(t.oldDir, "agent.log"), join(staging, "agent.log"), COPY);
|
|
246
|
+
});
|
|
247
|
+
if (done) copied++;
|
|
248
|
+
}
|
|
249
|
+
return copied;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function writeRecords(store: ReturnType<typeof openStore>, plan: Plan, result: MigrateResult) {
|
|
253
|
+
store.transaction(() => {
|
|
254
|
+
const present = plan.trials.filter((t) => store.getTrial(t.record.id));
|
|
255
|
+
if (present.length === plan.trials.length) return;
|
|
256
|
+
if (present.length) {
|
|
257
|
+
throw new Error(`the store already has ${present.length} of the ${plan.trials.length} trials of ${plan.experimentId} (for example ${present[0]!.record.id}), but not all: it was not left so by migrate; check it before importing`);
|
|
258
|
+
}
|
|
259
|
+
if (plan.userSetup) store.putUserSetup(plan.userSetup);
|
|
260
|
+
for (const r of plan.taskVersions) store.putTaskVersion(r);
|
|
261
|
+
for (const r of plan.gradingSetups) store.putGradingSetup(r);
|
|
262
|
+
for (const b of plan.batches) { store.createBatch(b); result.batches.push(b.id); }
|
|
263
|
+
for (const t of plan.trials) {
|
|
264
|
+
store.insertTrial(t.record);
|
|
265
|
+
result.trials++;
|
|
266
|
+
if (!t.grading) continue;
|
|
267
|
+
const id = store.startGrading({ trialId: t.record.id, gradingHash: t.grading.hash, votes: t.grading.votes, gradedAt: t.grading.gradedAt });
|
|
268
|
+
store.finishGrading(id, t.grading.result);
|
|
269
|
+
result.gradings++;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `gradingId` gives the imported grading of a trial: its first, since later regrades add rows after it. */
|
|
275
|
+
function copyJudgeLogs(plan: Plan, dbPath: string, gradingId: (trialId: string) => number | null): number {
|
|
276
|
+
let copied = 0;
|
|
277
|
+
for (const t of plan.trials) {
|
|
278
|
+
const judge = join(t.oldDir, "judge");
|
|
279
|
+
if (!t.grading || !existsSync(judge)) continue;
|
|
280
|
+
const id = gradingId(t.record.id);
|
|
281
|
+
if (id == null) throw new Error(`trial ${t.record.id} has no grading in the store to put its judge logs under`);
|
|
282
|
+
const done = copyOnce(join(trialDir(dbPath, t.record.id), "gradings", String(id)), (staging) => {
|
|
283
|
+
for (const f of readdirSync(judge)) cpSync(join(judge, f), join(staging, f), COPY);
|
|
284
|
+
});
|
|
285
|
+
if (done) copied++;
|
|
286
|
+
}
|
|
287
|
+
return copied;
|
|
288
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `static-fire new task <id>`: a new task folder, evals/<id>/, with a commented task.yaml and an empty inputs/
|
|
2
|
+
// folder. The file has every part a task needs, empty, so `static-fire tasks` lists what is left to fill in.
|
|
3
|
+
// It has no `version`: a task file can leave it out (task-file.ts).
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const TASK_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
8
|
+
|
|
9
|
+
/** `new task` was given an id that is not kebab-case. Nothing was written. */
|
|
10
|
+
export class BadTaskId extends Error {
|
|
11
|
+
constructor(id: string) {
|
|
12
|
+
super(`task id ${JSON.stringify(id)} must be kebab-case: lowercase letters, digits and hyphens, for example thumbnail-ab`);
|
|
13
|
+
this.name = "BadTaskId";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `new task` was given an id whose folder evals/<id> exists. Nothing was changed. */
|
|
18
|
+
export class TaskExists extends Error {
|
|
19
|
+
constructor(id: string) {
|
|
20
|
+
super(`task ${id} exists: evals/${id} is already there. Nothing was changed. Pick another id, or edit evals/${id}/task.yaml`);
|
|
21
|
+
this.name = "TaskExists";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The commented task.yaml of a new task. Comments say what each part is for, not how to write it. */
|
|
26
|
+
export function taskTemplate(id: string): string {
|
|
27
|
+
return `# A task: one job for an agent, and how its output is graded.
|
|
28
|
+
# Fill in each empty part, then run \`static-fire tasks\` to check this file.
|
|
29
|
+
# A line that starts with # is optional: remove the # to use it.
|
|
30
|
+
|
|
31
|
+
id: ${id}
|
|
32
|
+
title: # a short name for the task
|
|
33
|
+
# description: # one line on what the task tests
|
|
34
|
+
|
|
35
|
+
# What the agent gets
|
|
36
|
+
prompt: # the instructions the agent gets, as the user would give them
|
|
37
|
+
inputs: ./inputs # the files the agent works from; this folder is copied into its workspace
|
|
38
|
+
output: # the path the agent writes, relative to its workspace, for example ./out/answer.md
|
|
39
|
+
# project: ./project # files the agent would find in the real project, copied into its workspace
|
|
40
|
+
|
|
41
|
+
# What only the graders get
|
|
42
|
+
# context: # one paragraph a judge reads before the criteria
|
|
43
|
+
# references: ./references # past work a judge can compare the output with
|
|
44
|
+
# sources: ./sources # saved copies of the pages the inputs link to, for fact checks
|
|
45
|
+
# heldOut: ./held-out.md # the real original; an output that copies it is invalid
|
|
46
|
+
|
|
47
|
+
# How the output is graded: criteria are yes or no questions about the output, and yes means pass
|
|
48
|
+
# gates: # criteria outside the measures; if one fails, the task score is 0
|
|
49
|
+
measures: # groups of criteria, each scored 0 to 100
|
|
50
|
+
- id: # a kebab-case name, for example clarity
|
|
51
|
+
label: # the measure's name on the dashboard
|
|
52
|
+
# weight: 1 # its share of the task score
|
|
53
|
+
criteria:
|
|
54
|
+
- id: # a kebab-case name, unique in the task
|
|
55
|
+
question: # what to look at, and where the line between pass and fail is
|
|
56
|
+
level: # floor (most configs pass), strong (good configs pass) or ceiling (only the best pass)
|
|
57
|
+
grader: # check (a rule run on the output), judge (a model reads it) or human
|
|
58
|
+
# check: { type: words, min: 100 } # for grader check: regex, words, frontmatter or command
|
|
59
|
+
# evidence: [output] # for grader judge: what it reads: output, inputs, project, references, sources
|
|
60
|
+
# derived: thumbnail.py # for grader judge: a script in grader/ that makes an image of the output
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NewTask { dir: string; file: string; inputs: string }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Makes evals/<id>/task.yaml and the empty evals/<id>/inputs/ under the project `root`. Throws BadTaskId or
|
|
68
|
+
* TaskExists before it writes anything; the task folder is made with one call that fails when it exists.
|
|
69
|
+
*/
|
|
70
|
+
export function newTask(root: string, id: string): NewTask {
|
|
71
|
+
if (!TASK_ID.test(id)) throw new BadTaskId(id);
|
|
72
|
+
const evals = join(root, "evals"), dir = join(evals, id);
|
|
73
|
+
if (existsSync(dir)) throw new TaskExists(id);
|
|
74
|
+
mkdirSync(evals, { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dir);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if ((e as { code?: string }).code === "EEXIST") throw new TaskExists(id);
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
const file = join(dir, "task.yaml"), inputs = join(dir, "inputs");
|
|
82
|
+
mkdirSync(inputs);
|
|
83
|
+
writeFileSync(file, taskTemplate(id), { flag: "wx" });
|
|
84
|
+
return { dir, file, inputs };
|
|
85
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The outside tools in each harness's user setup: the MCP servers, plugins and claude.ai connectors a trial loads,
|
|
2
|
+
// each with the exact entry that blocks it under `trials.blockedTools.<harness>` in static-fire.yaml (ADR-0009).
|
|
3
|
+
// Static Fire lists them and does not choose: the user's agent proposes which ones act on outside services, and
|
|
4
|
+
// the user approves the block list.
|
|
5
|
+
//
|
|
6
|
+
// Read only, and names only. Setup files can hold secrets (API keys in `env`, tokens in URLs, headers or command
|
|
7
|
+
// arguments), so this code takes the key names of each object and never reads a value into the report. A file
|
|
8
|
+
// that does not parse gets a note with its path only: a parse error message can quote the file's content.
|
|
9
|
+
//
|
|
10
|
+
// Which files, and what is left out on purpose:
|
|
11
|
+
// - Claude Code (`$CLAUDE_CONFIG_DIR`, else `~/.claude`): user MCP servers in `.claude.json` (in `$CLAUDE_CONFIG_DIR`
|
|
12
|
+
// when set, else in the home folder; that move is assumed, not confirmed), plugins turned on in `settings.json`
|
|
13
|
+
// with the MCP servers in their install folder, and the claude.ai connectors Claude Code last saw
|
|
14
|
+
// (`claudeAiMcpEverConnected`, a cache: it can hold a removed connector or miss a new one; the live list needs a
|
|
15
|
+
// network call). Managed (admin) MCP settings are not read.
|
|
16
|
+
// - Codex (`$CODEX_HOME`, else `~/.codex`): `[mcp_servers.*]` and `[plugins.*]` in `config.toml`, without the ones
|
|
17
|
+
// turned off (`enabled = false`), which a trial does not load.
|
|
18
|
+
// - Project-level setup (`.mcp.json`, Claude Code local scope, Codex project config) is not read: a trial runs in a
|
|
19
|
+
// fresh workspace outside any repo, so none of it loads.
|
|
20
|
+
// - Antigravity and Pi trials have no block list yet, so their setup is not read.
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
24
|
+
import type { AgentHarness } from "./types";
|
|
25
|
+
|
|
26
|
+
export type OutsideToolKind = "mcp-server" | "plugin" | "connector";
|
|
27
|
+
|
|
28
|
+
export interface OutsideTool {
|
|
29
|
+
/** The server, plugin ("name@marketplace") or connector name as the setup file has it. */
|
|
30
|
+
name: string;
|
|
31
|
+
kind: OutsideToolKind;
|
|
32
|
+
/** The file it was found in. */
|
|
33
|
+
foundIn: string;
|
|
34
|
+
/** The entries that block it, to paste under `trials.blockedTools.<harness>`. Empty: nothing to block. */
|
|
35
|
+
block: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HarnessTools {
|
|
39
|
+
/** false: this harness's tools cannot be blocked in trials yet. */
|
|
40
|
+
blockable: boolean;
|
|
41
|
+
tools: OutsideTool[];
|
|
42
|
+
notes: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const BLOCKABLE = new Set<AgentHarness>(["claude", "codex"]);
|
|
46
|
+
const NAMES: Partial<Record<AgentHarness, string>> = { agy: "Antigravity", pi: "Pi" };
|
|
47
|
+
|
|
48
|
+
type Obj = Record<string, unknown>;
|
|
49
|
+
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
50
|
+
const keys = (v: unknown) => (isObj(v) ? Object.keys(v) : []);
|
|
51
|
+
|
|
52
|
+
/** A JSON or TOML file as an object, or null when it is missing. A file that does not parse adds a note with its path only. */
|
|
53
|
+
function readFile(path: string, notes: string[], parse: (text: string) => unknown = JSON.parse): Obj | null {
|
|
54
|
+
if (!existsSync(path)) return null;
|
|
55
|
+
try {
|
|
56
|
+
const v = parse(readFileSync(path, "utf8"));
|
|
57
|
+
if (isObj(v)) return v;
|
|
58
|
+
} catch { /* the message can quote the file: say only which file */ }
|
|
59
|
+
notes.push(`${path} could not be read, so the tools in it are not listed.`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Claude Code's form of a server name in its tool names. */
|
|
64
|
+
const toolName = (server: string) => `mcp__${server.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
65
|
+
|
|
66
|
+
/** The MCP server names of a Claude Code plugin: its `.mcp.json` (with or without an `mcpServers` key) and `mcpServers` in its plugin.json. */
|
|
67
|
+
function pluginServers(installPath: string, notes: string[]): string[] {
|
|
68
|
+
const fromFile = (p: string) => {
|
|
69
|
+
const f = readFile(p, notes);
|
|
70
|
+
return f ? keys(isObj(f.mcpServers) ? f.mcpServers : f) : [];
|
|
71
|
+
};
|
|
72
|
+
const servers = fromFile(join(installPath, ".mcp.json"));
|
|
73
|
+
const manifest = readFile(join(installPath, ".claude-plugin", "plugin.json"), notes);
|
|
74
|
+
const field = manifest?.mcpServers;
|
|
75
|
+
if (isObj(field)) servers.push(...keys(field));
|
|
76
|
+
for (const p of [field].flat()) if (typeof p === "string") servers.push(...fromFile(resolve(installPath, p)));
|
|
77
|
+
return [...new Set(servers)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function claudeTools(home: string, env: Record<string, string | undefined>, notes: string[]): OutsideTool[] {
|
|
81
|
+
const dir = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
|
82
|
+
const globalFile = env.CLAUDE_CONFIG_DIR ? join(dir, ".claude.json") : join(home, ".claude.json");
|
|
83
|
+
const global = readFile(globalFile, notes);
|
|
84
|
+
const tools: OutsideTool[] = keys(global?.mcpServers).map((name) => ({ name, kind: "mcp-server", foundIn: globalFile, block: [toolName(name)] }));
|
|
85
|
+
|
|
86
|
+
const settingsFile = join(dir, "settings.json");
|
|
87
|
+
const enabled = readFile(settingsFile, notes)?.enabledPlugins;
|
|
88
|
+
const on = keys(enabled).filter((k) => (enabled as Obj)[k] === true);
|
|
89
|
+
if (on.length) {
|
|
90
|
+
const installed = readFile(join(dir, "plugins", "installed_plugins.json"), notes)?.plugins;
|
|
91
|
+
for (const name of on) {
|
|
92
|
+
const entries = isObj(installed) && Array.isArray(installed[name]) ? (installed[name] as unknown[]).filter(isObj) : [];
|
|
93
|
+
const path = (entries.find((e) => e.scope === "user") ?? entries[0])?.installPath;
|
|
94
|
+
const plugin = name.split("@")[0]!;
|
|
95
|
+
const servers = typeof path === "string" ? pluginServers(path, notes) : [];
|
|
96
|
+
tools.push({ name, kind: "plugin", foundIn: settingsFile, block: servers.map((s) => toolName(`plugin_${plugin}_${s}`)) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seen = global?.claudeAiMcpEverConnected;
|
|
101
|
+
const connectors = (Array.isArray(seen) ? seen : keys(seen)).filter((n): n is string => typeof n === "string");
|
|
102
|
+
for (const name of connectors) tools.push({ name, kind: "connector", foundIn: globalFile, block: [toolName(name)] });
|
|
103
|
+
if (connectors.length) {
|
|
104
|
+
notes.push(`the claude.ai connectors are the ones Claude Code last saw (${globalFile}), so the list can hold a connector that was removed or miss a new one.`);
|
|
105
|
+
}
|
|
106
|
+
return tools;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function codexTools(home: string, env: Record<string, string | undefined>, notes: string[]): OutsideTool[] {
|
|
110
|
+
const file = join(env.CODEX_HOME || join(home, ".codex"), "config.toml");
|
|
111
|
+
const config = readFile(file, notes, (t) => Bun.TOML.parse(t));
|
|
112
|
+
const on = (table: unknown) => keys(table).filter((k) => !(isObj((table as Obj)[k]) && ((table as Obj)[k] as Obj).enabled === false));
|
|
113
|
+
return [
|
|
114
|
+
...on(config?.mcp_servers).map((name): OutsideTool => ({ name, kind: "mcp-server", foundIn: file, block: [name] })),
|
|
115
|
+
...on(config?.plugins).map((name): OutsideTool => ({ name, kind: "plugin", foundIn: file, block: [name] })),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The outside tools of `harness`'s user setup, with their block entries. A harness that is not installed has none. */
|
|
120
|
+
export function findOutsideTools(harness: AgentHarness, installed: boolean, home = homedir(), env: Record<string, string | undefined> = process.env): HarnessTools {
|
|
121
|
+
const notes: string[] = [];
|
|
122
|
+
if (!BLOCKABLE.has(harness)) {
|
|
123
|
+
notes.push(`${NAMES[harness] ?? harness} tools cannot be blocked in trials yet, so they are not listed.`);
|
|
124
|
+
return { blockable: false, tools: [], notes };
|
|
125
|
+
}
|
|
126
|
+
if (!installed) return { blockable: true, tools: [], notes };
|
|
127
|
+
const tools = harness === "claude" ? claudeTools(home, env, notes) : codexTools(home, env, notes);
|
|
128
|
+
return { blockable: true, tools, notes };
|
|
129
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Materialize an apkit Profile once per batch and harness: install it into a scratch folder outside any
|
|
2
|
+
// Git repo, snapshot the files, then remove it so the user's apkit records stay clean. Each trial
|
|
3
|
+
// workspace gets a copy of the snapshot, so every config sees identical Profile material.
|
|
4
|
+
// apkit keeps one record of installs, so installs never overlap: within a process they wait in line, and
|
|
5
|
+
// across processes (two `run`s at once) they hold the lock file <cache>/apkit.lock. The install, copy and
|
|
6
|
+
// remove run in a helper process (apkit-install.ts) that holds the lock itself, so a `run` killed mid-install
|
|
7
|
+
// leaves the helper holding the lock until the sequence ends.
|
|
8
|
+
// Current apkit (0.239+): context goes in AGENTS.md, skills in each agent's own skills folder.
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { AgentHarness } from "./types";
|
|
11
|
+
|
|
12
|
+
const APKIT_AGENT: Record<AgentHarness, string> = { claude: "claude", codex: "codex", pi: "pi", agy: "antigravity" };
|
|
13
|
+
const HELPER = join(import.meta.dir, "apkit-install.ts");
|
|
14
|
+
const inflight = new Map<string, Promise<string>>();
|
|
15
|
+
let apkitLine: Promise<unknown> = Promise.resolve(); // this process's installs, one at a time
|
|
16
|
+
|
|
17
|
+
/** The lock file every process takes before it runs apkit. */
|
|
18
|
+
export const apkitLockPath = (cacheDir: string) => join(cacheDir, "apkit.lock");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The snapshot folder of `profile` for `harness` in batch `batchId`, built on first use and reused by every
|
|
22
|
+
* trial of the batch. `cacheDir` is the project's Profile cache (`.cache/`), or a temp folder in tests.
|
|
23
|
+
*/
|
|
24
|
+
export function profileSnapshot(profile: string, harness: AgentHarness, batchId: string, cacheDir: string): Promise<string> {
|
|
25
|
+
const key = `${batchId}/${profile}-${harness}`;
|
|
26
|
+
const id = `${cacheDir}\0${key}`;
|
|
27
|
+
if (!inflight.has(id)) {
|
|
28
|
+
const p = apkitLine.then(() => build(profile, harness, key, cacheDir));
|
|
29
|
+
apkitLine = p.catch(() => {});
|
|
30
|
+
inflight.set(id, p);
|
|
31
|
+
}
|
|
32
|
+
return inflight.get(id)!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs the helper (with this process's Bun) and waits for it; it takes the lock and builds the snapshot. */
|
|
36
|
+
async function build(profile: string, harness: AgentHarness, key: string, cacheDir: string) {
|
|
37
|
+
const snap = join(cacheDir, "profiles", key);
|
|
38
|
+
const p = Bun.spawn([process.execPath, HELPER, apkitLockPath(cacheDir), profile, APKIT_AGENT[harness], snap], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
39
|
+
const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited, new Response(p.stdout).text()]);
|
|
40
|
+
if (code !== 0) throw new Error(err.trim() || `the Profile install helper exited ${code}`);
|
|
41
|
+
return snap;
|
|
42
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The two roots. The engine root holds the engine's own files: its source, the dashboard and pricing.yaml. It
|
|
2
|
+
// comes from this file's location, never from the current folder, so it holds when the engine runs from
|
|
3
|
+
// node_modules. The project root is the user's folder: static-fire.yaml, configs/, judges.yaml, suites/,
|
|
4
|
+
// evals/, the record store and trial artifacts in runs/, and the Profile cache in .cache/. A command finds it
|
|
5
|
+
// by walking up from the current folder to static-fire.yaml. Nothing of a project is written to the engine root.
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const ENGINE_ROOT = resolve(import.meta.dir, "../..");
|
|
10
|
+
export const PROJECT_FILE = "static-fire.yaml";
|
|
11
|
+
|
|
12
|
+
/** A command that needs a project was run where no static-fire.yaml is in the folder or any folder above it. */
|
|
13
|
+
export class NoProjectError extends Error {
|
|
14
|
+
constructor(from: string) {
|
|
15
|
+
super(`no Static Fire project here: there is no ${PROJECT_FILE} in ${from} or in a folder above it. Run \`static-fire init\` to make a project.`);
|
|
16
|
+
this.name = "NoProjectError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The first folder from `from` upward that holds static-fire.yaml, or null. */
|
|
21
|
+
export function findProject(from = process.cwd()): string | null {
|
|
22
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
23
|
+
if (existsSync(join(dir, PROJECT_FILE))) return dir;
|
|
24
|
+
if (dirname(dir) === dir) return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The project root found from `from`. Throws NoProjectError when there is none. */
|
|
29
|
+
export function projectRoot(from = process.cwd()): string {
|
|
30
|
+
const root = findProject(from);
|
|
31
|
+
if (!root) throw new NoProjectError(resolve(from));
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProjectPaths {
|
|
36
|
+
root: string;
|
|
37
|
+
/** The record store's default folder and the trial artifacts' folder (runs/trials/<trial-id>). */
|
|
38
|
+
runs: string;
|
|
39
|
+
/** The Profile cache and its lock file. */
|
|
40
|
+
cache: string;
|
|
41
|
+
/** The record store: STATIC_FIRE_DB when set, else runs/static-fire.db. Trial artifacts go next to it. */
|
|
42
|
+
dbPath: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function projectPaths(root: string): ProjectPaths {
|
|
46
|
+
const runs = join(root, "runs");
|
|
47
|
+
return { root, runs, cache: join(root, ".cache"), dbPath: process.env.STATIC_FIRE_DB || join(runs, "static-fire.db") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The pricing file: the project's own pricing.yaml when it has one, else the engine's. */
|
|
51
|
+
export function pricingPath(project: string | null): string {
|
|
52
|
+
const own = project ? join(project, "pricing.yaml") : null;
|
|
53
|
+
return own && existsSync(own) ? own : join(ENGINE_ROOT, "pricing.yaml");
|
|
54
|
+
}
|