@cef-ai/cli 2.7.5 → 2.8.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/README.md +15 -0
- package/dist/cli-program.d.ts +2 -0
- package/dist/cli-program.d.ts.map +1 -1
- package/dist/cli-program.js +17 -0
- package/dist/cli-program.js.map +1 -1
- package/dist/commands/cubby.d.ts +7 -0
- package/dist/commands/cubby.d.ts.map +1 -1
- package/dist/commands/cubby.js +8 -4
- package/dist/commands/cubby.js.map +1 -1
- package/dist/commands/eval.d.ts +217 -0
- package/dist/commands/eval.d.ts.map +1 -0
- package/dist/commands/eval.js +860 -0
- package/dist/commands/eval.js.map +1 -0
- package/dist/commands/eval.test.d.ts +2 -0
- package/dist/commands/eval.test.d.ts.map +1 -0
- package/dist/commands/eval.test.js +474 -0
- package/dist/commands/eval.test.js.map +1 -0
- package/dist/commands/push.d.ts +23 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +70 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/lib/access-token.d.ts +33 -0
- package/dist/lib/access-token.d.ts.map +1 -1
- package/dist/lib/access-token.js +89 -20
- package/dist/lib/access-token.js.map +1 -1
- package/dist/lib/bucket-owner.d.ts +45 -0
- package/dist/lib/bucket-owner.d.ts.map +1 -0
- package/dist/lib/bucket-owner.js +107 -0
- package/dist/lib/bucket-owner.js.map +1 -0
- package/dist/lib/ddc.d.ts +22 -0
- package/dist/lib/ddc.d.ts.map +1 -1
- package/dist/lib/ddc.js +72 -20
- package/dist/lib/ddc.js.map +1 -1
- package/dist/lib/eval-auth.d.ts +139 -0
- package/dist/lib/eval-auth.d.ts.map +1 -0
- package/dist/lib/eval-auth.js +385 -0
- package/dist/lib/eval-auth.js.map +1 -0
- package/dist/lib/eval-auth.test.d.ts +2 -0
- package/dist/lib/eval-auth.test.d.ts.map +1 -0
- package/dist/lib/eval-auth.test.js +192 -0
- package/dist/lib/eval-auth.test.js.map +1 -0
- package/dist/lib/eval-store.d.ts +62 -0
- package/dist/lib/eval-store.d.ts.map +1 -0
- package/dist/lib/eval-store.js +334 -0
- package/dist/lib/eval-store.js.map +1 -0
- package/dist/lib/eval-store.test.d.ts +2 -0
- package/dist/lib/eval-store.test.d.ts.map +1 -0
- package/dist/lib/eval-store.test.js +155 -0
- package/dist/lib/eval-store.test.js.map +1 -0
- package/dist/lib/eval-target.d.ts +122 -0
- package/dist/lib/eval-target.d.ts.map +1 -0
- package/dist/lib/eval-target.js +320 -0
- package/dist/lib/eval-target.js.map +1 -0
- package/dist/lib/eval-target.test.d.ts +2 -0
- package/dist/lib/eval-target.test.d.ts.map +1 -0
- package/dist/lib/eval-target.test.js +209 -0
- package/dist/lib/eval-target.test.js.map +1 -0
- package/dist/lib/eval-workflow.d.ts +24 -0
- package/dist/lib/eval-workflow.d.ts.map +1 -0
- package/dist/lib/eval-workflow.js +91 -0
- package/dist/lib/eval-workflow.js.map +1 -0
- package/package.json +8 -4
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cef eval` — workflow evaluations from the command line.
|
|
3
|
+
*
|
|
4
|
+
* cef eval datasets list the workflow's datasets
|
|
5
|
+
* cef eval create <dataset> create one
|
|
6
|
+
* cef eval pull <dataset> [--dir datasets] [--force] bucket → repo dir (refuses to overwrite unpushed edits)
|
|
7
|
+
* cef eval push <dataset> [--dir datasets] [--cut note] repo dir → bucket (new revisions for changed cases)
|
|
8
|
+
* cef eval run <dataset> [--dataset-version N] [--workflow-version v] [--repeats 3] [--concurrency 4] [--baseline exp]
|
|
9
|
+
* cef eval experiments [dataset] experiment history
|
|
10
|
+
* cef eval compare <a> <b> how experiment b differs from a
|
|
11
|
+
* cef eval migrate copy v1 storage (evals/datasets/…) into the v2 layout
|
|
12
|
+
*
|
|
13
|
+
* Every command is about one workflow: `--workflow <id>`, by default the one
|
|
14
|
+
* `cef.config.ts` (or the build output) in the current directory declares.
|
|
15
|
+
*
|
|
16
|
+
* REPO DIR, one directory per dataset, in the workflow's folder:
|
|
17
|
+
*
|
|
18
|
+
* datasets/<dataset>/header.json the Header (without the baseline, which is chosen on the platform)
|
|
19
|
+
* datasets/<dataset>/cases/<caseId>.json each case at its latest revision
|
|
20
|
+
* datasets/<dataset>/.versions.json what was pulled: each case's revision and content hash, and the versions cut so far
|
|
21
|
+
*
|
|
22
|
+
* Experiments are not in the repo; their results stay on the platform.
|
|
23
|
+
*
|
|
24
|
+
* The bucket keeps every revision; the repo holds the latest, so a git diff of a
|
|
25
|
+
* case reads as the edit it is. `push` writes a new revision only for a case
|
|
26
|
+
* whose content changed, and refuses one that changed in the bucket since it was
|
|
27
|
+
* pulled (someone edited it in ROC) unless `--force`, and one that is deleted or
|
|
28
|
+
* archived there unless `--restore`. `pull` and `push --prune` only ever remove
|
|
29
|
+
* what the last pull wrote (`.versions.json`), never a case the other side has
|
|
30
|
+
* not seen. `pull` refuses to overwrite or remove a case file edited since the
|
|
31
|
+
* last pull/push (its content matches neither what was pulled nor what the
|
|
32
|
+
* bucket now holds) unless `--force`.
|
|
33
|
+
*
|
|
34
|
+
* The functions below take an `EvalStore` (and a `WorkflowTarget` for `run`) so
|
|
35
|
+
* they run unchanged against a memory store in tests.
|
|
36
|
+
*/
|
|
37
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { canonicalJson, checkResultCompatibility, compareExperiments, createDataset, cutVersion, deleteCase, findExperiment, getCaseRevision, getHeader, getVersion, listArchivedCaseIds, listCaseRevisions, listDatasets, listDeletedCaseIds, listExperiments, listRunRecords, listVersions, listWorkflows, loadWorkingSet, migrateLegacyLayout, parseCase, parseHeader, resultFieldsFromGraph, runExperiment, saveCase, sha256Hex, unarchiveCase, undeleteCase, updateHeader, } from "@cef-ai/eval";
|
|
40
|
+
import { DelegationAuthProvider, WalletAuthProvider } from "@cef-ai/vault-sdk";
|
|
41
|
+
import { Browse, Events, VaultHttpClient, Vaults } from "@cef-ai/vault-sdk/internal";
|
|
42
|
+
import { loadWallet, resolveGatewayAccess } from "../lib/eval-auth.js";
|
|
43
|
+
import { gatewayStore } from "../lib/eval-store.js";
|
|
44
|
+
import { fetchOrchestrator, getDeploymentSet, nodeWorkflowTarget, workflowAgentId, } from "../lib/eval-target.js";
|
|
45
|
+
import { localGraph, resolveWorkflowId } from "../lib/eval-workflow.js";
|
|
46
|
+
import { envConfig } from "../lib/env.js";
|
|
47
|
+
/* ------------------------------------------------------------------------ */
|
|
48
|
+
/* Repo dir */
|
|
49
|
+
/* ------------------------------------------------------------------------ */
|
|
50
|
+
export const VERSIONS_FILE = ".versions.json";
|
|
51
|
+
/** Where datasets live in a workflow's folder. */
|
|
52
|
+
export const DEFAULT_DATASETS_DIR = "datasets";
|
|
53
|
+
const datasetDir = (dir, dataset) => path.join(dir, dataset);
|
|
54
|
+
async function readJsonFile(file) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (e.code === "ENOENT")
|
|
60
|
+
return undefined;
|
|
61
|
+
throw new Error(`[cef eval] ${file} is not valid JSON: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function writeState(store, workflowId, dir, dataset, cases, hashes) {
|
|
65
|
+
const state = {
|
|
66
|
+
dataset,
|
|
67
|
+
pulledAt: new Date().toISOString(),
|
|
68
|
+
cases,
|
|
69
|
+
hashes,
|
|
70
|
+
versions: (await listVersions(store, workflowId, dataset)).map((m) => ({
|
|
71
|
+
version: m.version, createdAt: m.createdAt, ...(m.note ? { note: m.note } : {}), cases: m.cases.length,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
await writeFile(path.join(datasetDir(dir, dataset), VERSIONS_FILE), canonicalJson(state));
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read the repo dir: header and every case file, each checked. A header written
|
|
79
|
+
* by hand need not carry `createdAt` (the bucket's wins on push) nor
|
|
80
|
+
* `workflowId` (the folder's workflow is meant); a `baselineId` in it is
|
|
81
|
+
* ignored — the baseline is chosen on the platform.
|
|
82
|
+
*/
|
|
83
|
+
export async function readRepoDataset(dir, dataset, workflowId) {
|
|
84
|
+
const root = datasetDir(dir, dataset);
|
|
85
|
+
const rawHeader = await readJsonFile(path.join(root, "header.json"));
|
|
86
|
+
const header = rawHeader === undefined
|
|
87
|
+
? undefined
|
|
88
|
+
: withoutBaseline(parseHeader({ createdAt: new Date().toISOString(), workflowId, ...rawHeader }));
|
|
89
|
+
if (header && header.dataset !== dataset) {
|
|
90
|
+
throw new Error(`[cef eval] ${root}/header.json names dataset "${header.dataset}", not "${dataset}"`);
|
|
91
|
+
}
|
|
92
|
+
const cases = [];
|
|
93
|
+
let files = [];
|
|
94
|
+
try {
|
|
95
|
+
files = (await readdir(path.join(root, "cases"))).filter((f) => f.endsWith(".json")).sort();
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e.code !== "ENOENT")
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
const c = parseCase(await readJsonFile(path.join(root, "cases", f)));
|
|
103
|
+
if (`${c.id}.json` !== f)
|
|
104
|
+
throw new Error(`[cef eval] cases/${f} holds case "${c.id}" — the file name must be <id>.json`);
|
|
105
|
+
cases.push(c);
|
|
106
|
+
}
|
|
107
|
+
const state = (await readJsonFile(path.join(root, VERSIONS_FILE)));
|
|
108
|
+
return { header, cases, state };
|
|
109
|
+
}
|
|
110
|
+
/* ------------------------------------------------------------------------ */
|
|
111
|
+
/* Commands, over an injected store */
|
|
112
|
+
/* ------------------------------------------------------------------------ */
|
|
113
|
+
const withoutBaseline = ({ baselineId: _b, ...h }) => h;
|
|
114
|
+
export async function evalCreate(opts) {
|
|
115
|
+
return createDataset(opts.store, {
|
|
116
|
+
dataset: opts.dataset,
|
|
117
|
+
workflowId: opts.workflowId,
|
|
118
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
119
|
+
...(opts.limits ? { limits: opts.limits } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** The pulled state in the repo dir, when it is this dataset's. */
|
|
123
|
+
async function readState(dir, dataset) {
|
|
124
|
+
const raw = (await readJsonFile(path.join(datasetDir(dir, dataset), VERSIONS_FILE)));
|
|
125
|
+
return raw?.dataset === dataset && raw.cases && typeof raw.cases === "object" ? raw : undefined;
|
|
126
|
+
}
|
|
127
|
+
const has = (o, id) => Object.prototype.hasOwnProperty.call(o, id);
|
|
128
|
+
/** sha256 of a case's canonical JSON — what a pulled case file holds byte for byte. */
|
|
129
|
+
const caseHash = (c) => sha256Hex(canonicalJson(c));
|
|
130
|
+
/**
|
|
131
|
+
* sha256 of a case file's content, canonicalised so a reformat alone is not an
|
|
132
|
+
* edit; of the raw text when it does not parse (an edit in progress). undefined
|
|
133
|
+
* when there is no file.
|
|
134
|
+
*/
|
|
135
|
+
async function caseFileHash(file) {
|
|
136
|
+
let raw;
|
|
137
|
+
try {
|
|
138
|
+
raw = await readFile(file, "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (e.code === "ENOENT")
|
|
142
|
+
return undefined;
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return await sha256Hex(canonicalJson(JSON.parse(raw)));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return sha256Hex(raw);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bucket → repo dir.
|
|
154
|
+
*
|
|
155
|
+
* A case file is removed only when the LAST PULL wrote it (its id is in
|
|
156
|
+
* `.versions.json`) and it is no longer live in the bucket — deleted or
|
|
157
|
+
* archived there since. A file the last pull did not write is a case someone
|
|
158
|
+
* is adding in the repo and has not pushed yet; it is reported, never touched.
|
|
159
|
+
*
|
|
160
|
+
* UNPUSHED EDITS ARE NOT OVERWRITTEN. Before writing anything, every case file
|
|
161
|
+
* pull would rewrite or remove is compared with what the last pull/push left
|
|
162
|
+
* there (the hash in `.versions.json`; for an older file without hashes, the
|
|
163
|
+
* pulled revision's content) and with what the bucket now holds. A file that
|
|
164
|
+
* matches neither was edited locally and not pushed: the pull is refused
|
|
165
|
+
* unless `force`.
|
|
166
|
+
*/
|
|
167
|
+
export async function evalPull(opts) {
|
|
168
|
+
const root = datasetDir(opts.dir, opts.dataset);
|
|
169
|
+
// The same guard as push: a repo dir holding another workflow's dataset of this name is not overwritten.
|
|
170
|
+
const localHeader = await readJsonFile(path.join(root, "header.json"));
|
|
171
|
+
const localWorkflow = localHeader?.workflowId;
|
|
172
|
+
if (typeof localWorkflow === "string" && localWorkflow !== opts.workflowId) {
|
|
173
|
+
throw new Error(`[cef eval] ${root}/header.json is for workflow "${localWorkflow}", not "${opts.workflowId}". `
|
|
174
|
+
+ `A dataset belongs to one workflow — pull it with --workflow ${localWorkflow}, or into another --dir.`);
|
|
175
|
+
}
|
|
176
|
+
const { header, cases } = await loadWorkingSet(opts.store, opts.workflowId, opts.dataset);
|
|
177
|
+
const before = await readState(opts.dir, opts.dataset);
|
|
178
|
+
const pulledBefore = before?.cases ?? {};
|
|
179
|
+
const hashesBefore = before?.hashes ?? {};
|
|
180
|
+
await mkdir(path.join(root, "cases"), { recursive: true });
|
|
181
|
+
const live = new Map(cases.map(({ case: c }) => [c.id, c]));
|
|
182
|
+
const toRemove = [];
|
|
183
|
+
const localOnly = [];
|
|
184
|
+
for (const f of (await readdir(path.join(root, "cases"))).sort()) {
|
|
185
|
+
if (!f.endsWith(".json"))
|
|
186
|
+
continue;
|
|
187
|
+
const id = f.slice(0, -".json".length);
|
|
188
|
+
if (live.has(id))
|
|
189
|
+
continue;
|
|
190
|
+
if (has(pulledBefore, id))
|
|
191
|
+
toRemove.push(id);
|
|
192
|
+
else
|
|
193
|
+
localOnly.push(id);
|
|
194
|
+
}
|
|
195
|
+
/** What the repo file held after the last pull/push, as a hash; undefined when unknown. */
|
|
196
|
+
const lastSynced = async (id) => {
|
|
197
|
+
if (has(hashesBefore, id))
|
|
198
|
+
return hashesBefore[id];
|
|
199
|
+
if (!has(pulledBefore, id))
|
|
200
|
+
return undefined;
|
|
201
|
+
const c = await getCaseRevision(opts.store, opts.workflowId, opts.dataset, id, pulledBefore[id]);
|
|
202
|
+
return c ? caseHash(c) : undefined;
|
|
203
|
+
};
|
|
204
|
+
const edited = [];
|
|
205
|
+
if (!opts.force) {
|
|
206
|
+
for (const [id, c] of [...live, ...toRemove.map((x) => [x, undefined])]) {
|
|
207
|
+
const local = await caseFileHash(path.join(root, "cases", `${id}.json`));
|
|
208
|
+
if (local === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
if (c && local === (await caseHash(c)))
|
|
211
|
+
continue;
|
|
212
|
+
if (local === (await lastSynced(id)))
|
|
213
|
+
continue;
|
|
214
|
+
edited.push(c ? id : `${id} (removed in the bucket)`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (edited.length) {
|
|
218
|
+
throw new Error(`[cef eval] these case files were edited since the last pull/push and differ from the bucket:\n ${edited.join("\n ")}\n`
|
|
219
|
+
+ "Nothing was written. Push them first (cef eval push), move the edits aside, "
|
|
220
|
+
+ "or pull with --force to overwrite them with the bucket's version.");
|
|
221
|
+
}
|
|
222
|
+
// The baseline stays on the platform: in git it would be stale the moment someone picks another in ROC.
|
|
223
|
+
await writeFile(path.join(root, "header.json"), canonicalJson(withoutBaseline(header)));
|
|
224
|
+
for (const id of toRemove)
|
|
225
|
+
await rm(path.join(root, "cases", `${id}.json`));
|
|
226
|
+
const hashes = {};
|
|
227
|
+
for (const { case: c } of cases) {
|
|
228
|
+
const body = canonicalJson(c);
|
|
229
|
+
await writeFile(path.join(root, "cases", `${c.id}.json`), body);
|
|
230
|
+
hashes[c.id] = await sha256Hex(body);
|
|
231
|
+
}
|
|
232
|
+
const state = await writeState(opts.store, opts.workflowId, opts.dir, opts.dataset, Object.fromEntries(cases.map(({ case: c, rev }) => [c.id, rev])), hashes);
|
|
233
|
+
return { dir: root, cases: cases.length, removed: toRemove, localOnly, state };
|
|
234
|
+
}
|
|
235
|
+
/** The newest revision of a case whatever its flags say, or undefined when it has none. */
|
|
236
|
+
async function latestRevision(store, workflowId, dataset, id) {
|
|
237
|
+
const revs = await listCaseRevisions(store, workflowId, dataset, id);
|
|
238
|
+
const rev = revs[revs.length - 1];
|
|
239
|
+
if (rev === undefined)
|
|
240
|
+
return undefined;
|
|
241
|
+
const c = await getCaseRevision(store, workflowId, dataset, id, rev);
|
|
242
|
+
return c ? { case: c, rev } : undefined;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Repo dir → bucket.
|
|
246
|
+
*
|
|
247
|
+
* EVERY CHECK BEFORE ANY WRITE, so a refused push leaves the bucket exactly as
|
|
248
|
+
* it was — header included:
|
|
249
|
+
* - a repo case that is deleted or archived in the bucket is refused unless
|
|
250
|
+
* `restore` (an explicit undelete/unarchive, then the repo's version is
|
|
251
|
+
* written as a new revision). `force` does NOT imply it: overwriting an
|
|
252
|
+
* edit and resurrecting a case someone removed are different decisions.
|
|
253
|
+
* - a case edited in the bucket since the pull, whose content differs from
|
|
254
|
+
* the repo's, is a conflict unless `force`.
|
|
255
|
+
* - `prune` deletes only cases the last pull wrote and the repo has since
|
|
256
|
+
* dropped; a case edited in the bucket since that pull is a conflict
|
|
257
|
+
* unless `force`. Cases added in the bucket after the pull are never
|
|
258
|
+
* pruned — the repo never knew them.
|
|
259
|
+
*/
|
|
260
|
+
export async function evalPush(opts) {
|
|
261
|
+
const wf = opts.workflowId;
|
|
262
|
+
const local = await readRepoDataset(opts.dir, opts.dataset, wf);
|
|
263
|
+
if (!local.header) {
|
|
264
|
+
throw new Error(`[cef eval] ${datasetDir(opts.dir, opts.dataset)}/header.json is missing — pull the dataset or create it first`);
|
|
265
|
+
}
|
|
266
|
+
if (local.header.workflowId !== wf) {
|
|
267
|
+
throw new Error(`[cef eval] ${datasetDir(opts.dir, opts.dataset)}/header.json is for workflow "${local.header.workflowId}", `
|
|
268
|
+
+ `not "${wf}". A dataset belongs to one workflow — push it with --workflow ${local.header.workflowId}.`);
|
|
269
|
+
}
|
|
270
|
+
const remoteHeader = await getHeader(opts.store, wf, opts.dataset);
|
|
271
|
+
// The baseline is the platform's; the repo neither carries nor overwrites it.
|
|
272
|
+
const headerChanged = !!remoteHeader
|
|
273
|
+
&& canonicalJson({ ...withoutBaseline(remoteHeader), createdAt: "" }) !== canonicalJson({ ...local.header, createdAt: "" });
|
|
274
|
+
const remote = new Map(remoteHeader ? (await loadWorkingSet(opts.store, wf, opts.dataset)).cases.map((x) => [x.case.id, x]) : []);
|
|
275
|
+
const deleted = new Set(remoteHeader ? await listDeletedCaseIds(opts.store, wf, opts.dataset) : []);
|
|
276
|
+
const archived = new Set(remoteHeader ? await listArchivedCaseIds(opts.store, wf, opts.dataset) : []);
|
|
277
|
+
const pulled = local.state?.dataset === opts.dataset ? local.state.cases : {};
|
|
278
|
+
// 1. Repo cases the bucket's working set hides.
|
|
279
|
+
const hidden = local.cases.filter((c) => deleted.has(c.id) || archived.has(c.id));
|
|
280
|
+
if (hidden.length && !opts.restore) {
|
|
281
|
+
throw new Error(`[cef eval] these cases are ${hidden.length === 1 ? "" : "each "}deleted or archived in the bucket, and still in the repo dir:\n `
|
|
282
|
+
+ hidden.map((c) => `${c.id} (${deleted.has(c.id) ? "deleted" : "archived"})`).join("\n ")
|
|
283
|
+
+ "\nNothing was written. Pull to drop them from the repo dir, give a case a new id to add it as a new case, "
|
|
284
|
+
+ "or push with --restore to undelete/unarchive them and write the repo's version.");
|
|
285
|
+
}
|
|
286
|
+
// 2. Edits in the bucket since the pull.
|
|
287
|
+
const conflicts = [];
|
|
288
|
+
for (const c of local.cases) {
|
|
289
|
+
if (opts.force)
|
|
290
|
+
break;
|
|
291
|
+
const r = remote.get(c.id) ?? (hidden.includes(c) ? await latestRevision(opts.store, wf, opts.dataset, c.id) : undefined);
|
|
292
|
+
if (!r)
|
|
293
|
+
continue;
|
|
294
|
+
const base = has(pulled, c.id) ? pulled[c.id] : undefined;
|
|
295
|
+
const editedRemotely = base === undefined || r.rev !== base;
|
|
296
|
+
if (editedRemotely && canonicalJson(r.case) !== canonicalJson(c))
|
|
297
|
+
conflicts.push(`${c.id} (bucket r${r.rev}, pulled ${base === undefined ? "never" : `r${base}`})`);
|
|
298
|
+
}
|
|
299
|
+
// 3. What prune would delete.
|
|
300
|
+
const localIds = new Set(local.cases.map((c) => c.id));
|
|
301
|
+
const pruneConflicts = [];
|
|
302
|
+
const missingLocally = [];
|
|
303
|
+
const notPulled = [];
|
|
304
|
+
for (const [id, r] of remote) {
|
|
305
|
+
if (localIds.has(id))
|
|
306
|
+
continue;
|
|
307
|
+
if (!has(pulled, id)) {
|
|
308
|
+
notPulled.push(id);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
missingLocally.push(id);
|
|
312
|
+
if (opts.prune && !opts.force && r.rev !== pulled[id]) {
|
|
313
|
+
pruneConflicts.push(`${id} (removed from the repo, but bucket r${r.rev} was edited since the pull of r${pulled[id]})`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (conflicts.length || pruneConflicts.length) {
|
|
317
|
+
throw new Error([
|
|
318
|
+
"[cef eval] Nothing was written.",
|
|
319
|
+
...(conflicts.length
|
|
320
|
+
? [
|
|
321
|
+
`These cases changed in the bucket since they were pulled, and differ from the repo:\n ${conflicts.join("\n ")}`,
|
|
322
|
+
"Pull first and redo the edit, or push with --force to write the repo's version over them as a new revision.",
|
|
323
|
+
]
|
|
324
|
+
: []),
|
|
325
|
+
...(pruneConflicts.length
|
|
326
|
+
? [
|
|
327
|
+
`--prune would delete these cases, edited in the bucket since they were pulled:\n ${pruneConflicts.join("\n ")}`,
|
|
328
|
+
"Pull to get the edited case back into the repo dir, or push with --prune --force to delete it, edit and all "
|
|
329
|
+
+ "(--restore brings a deleted case back).",
|
|
330
|
+
]
|
|
331
|
+
: []),
|
|
332
|
+
].join("\n"));
|
|
333
|
+
}
|
|
334
|
+
// Writes.
|
|
335
|
+
const result = {
|
|
336
|
+
created: false, headerUpdated: false, added: [], updated: [], unchanged: [], restored: [],
|
|
337
|
+
missingLocally: [], notPulled, deleted: [],
|
|
338
|
+
};
|
|
339
|
+
if (!remoteHeader) {
|
|
340
|
+
await createDataset(opts.store, local.header);
|
|
341
|
+
result.created = true;
|
|
342
|
+
}
|
|
343
|
+
else if (headerChanged) {
|
|
344
|
+
await updateHeader(opts.store, {
|
|
345
|
+
...local.header,
|
|
346
|
+
createdAt: remoteHeader.createdAt,
|
|
347
|
+
...(remoteHeader.baselineId ? { baselineId: remoteHeader.baselineId } : {}),
|
|
348
|
+
});
|
|
349
|
+
result.headerUpdated = true;
|
|
350
|
+
}
|
|
351
|
+
for (const c of hidden) {
|
|
352
|
+
if (deleted.has(c.id))
|
|
353
|
+
await undeleteCase(opts.store, wf, opts.dataset, c.id);
|
|
354
|
+
if (archived.has(c.id))
|
|
355
|
+
await unarchiveCase(opts.store, wf, opts.dataset, c.id);
|
|
356
|
+
result.restored.push(c.id);
|
|
357
|
+
}
|
|
358
|
+
const revs = {};
|
|
359
|
+
const hashes = {};
|
|
360
|
+
const hashesBefore = local.state?.dataset === opts.dataset ? local.state.hashes ?? {} : {};
|
|
361
|
+
for (const c of local.cases) {
|
|
362
|
+
const saved = await saveCase(opts.store, wf, opts.dataset, c);
|
|
363
|
+
revs[c.id] = saved.rev;
|
|
364
|
+
hashes[c.id] = await caseHash(c);
|
|
365
|
+
if (saved.created)
|
|
366
|
+
result.added.push(c.id);
|
|
367
|
+
else if (saved.changed)
|
|
368
|
+
result.updated.push({ id: c.id, rev: saved.rev });
|
|
369
|
+
else
|
|
370
|
+
result.unchanged.push(c.id);
|
|
371
|
+
}
|
|
372
|
+
for (const id of missingLocally) {
|
|
373
|
+
if (opts.prune) {
|
|
374
|
+
await deleteCase(opts.store, wf, opts.dataset, id);
|
|
375
|
+
result.deleted.push(id);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
result.missingLocally.push(id);
|
|
379
|
+
// Still "pulled" — the repo dropped it on purpose, so a later --prune may delete it.
|
|
380
|
+
revs[id] = pulled[id];
|
|
381
|
+
if (has(hashesBefore, id))
|
|
382
|
+
hashes[id] = hashesBefore[id];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (opts.cut !== undefined && opts.cut !== false) {
|
|
386
|
+
const m = await cutVersion(opts.store, wf, opts.dataset, typeof opts.cut === "string" && opts.cut ? { note: opts.cut } : {});
|
|
387
|
+
result.cut = { version: m.version, cases: m.cases.length };
|
|
388
|
+
}
|
|
389
|
+
await writeState(opts.store, wf, opts.dir, opts.dataset, revs, hashes);
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
export async function evalRun(opts) {
|
|
393
|
+
const header = await getHeader(opts.store, opts.workflowId, opts.dataset);
|
|
394
|
+
if (!header)
|
|
395
|
+
throw new Error(`[cef eval] workflow "${opts.workflowId}" has no dataset "${opts.dataset}"`);
|
|
396
|
+
return runExperiment({
|
|
397
|
+
store: opts.store,
|
|
398
|
+
workflowId: opts.workflowId,
|
|
399
|
+
dataset: opts.dataset,
|
|
400
|
+
...(opts.version !== undefined ? { datasetVersion: opts.version } : {}),
|
|
401
|
+
workflowVersion: opts.workflowVersion,
|
|
402
|
+
live: opts.live,
|
|
403
|
+
target: opts.target,
|
|
404
|
+
origin: "cli",
|
|
405
|
+
...(opts.resultFields ? { resultFields: opts.resultFields } : {}),
|
|
406
|
+
...(opts.repeats !== undefined ? { repeats: opts.repeats } : {}),
|
|
407
|
+
...(opts.concurrency !== undefined ? { concurrency: opts.concurrency } : {}),
|
|
408
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
409
|
+
...(opts.baselineId ? { baselineId: opts.baselineId } : {}),
|
|
410
|
+
...(opts.experimentId ? { experimentId: opts.experimentId } : {}),
|
|
411
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
412
|
+
...(opts.createdBy ? { createdBy: opts.createdBy } : {}),
|
|
413
|
+
...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/** Whether the dataset's live cases fit the Result `resultFields` declares. */
|
|
417
|
+
export async function evalCheckResult(opts) {
|
|
418
|
+
const { cases } = await loadWorkingSet(opts.store, opts.workflowId, opts.dataset);
|
|
419
|
+
return checkResultCompatibility(opts.resultFields, cases.map((x) => x.case));
|
|
420
|
+
}
|
|
421
|
+
/** The Result pre-check of `cef eval run`, for both output formats. */
|
|
422
|
+
export async function evalResultCheck(opts) {
|
|
423
|
+
const { fields, from, why } = opts.result;
|
|
424
|
+
if (!fields)
|
|
425
|
+
return { workflowVersion: opts.workflowVersion, ...(why ? { unknown: why } : {}) };
|
|
426
|
+
const compatibility = await evalCheckResult({ store: opts.store, workflowId: opts.workflowId, dataset: opts.dataset, resultFields: fields });
|
|
427
|
+
return { workflowVersion: opts.workflowVersion, fields, ...(from ? { from } : {}), compatibility };
|
|
428
|
+
}
|
|
429
|
+
export async function evalCompare(opts) {
|
|
430
|
+
const load = async (id) => {
|
|
431
|
+
const experiment = await findExperiment(opts.store, id);
|
|
432
|
+
if (!experiment)
|
|
433
|
+
throw new Error(`[cef eval] experiment "${id}" not found`);
|
|
434
|
+
// The version's own case list, so a case with no run still counts as present.
|
|
435
|
+
const cases = await getVersion(opts.store, experiment.workflowId, experiment.dataset, experiment.datasetVersion)
|
|
436
|
+
.then((m) => m.cases, () => undefined);
|
|
437
|
+
return { experiment, runs: await listRunRecords(opts.store, experiment.workflowId, id), ...(cases ? { cases } : {}) };
|
|
438
|
+
};
|
|
439
|
+
return compareExperiments(await load(opts.base), await load(opts.candidate));
|
|
440
|
+
}
|
|
441
|
+
export async function evalMigrate(opts) {
|
|
442
|
+
return migrateLegacyLayout(opts.store);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The Result a workflow version declares, read off its graph:
|
|
446
|
+
* - the live deployment row's `params.graph` when the version is the live
|
|
447
|
+
* one (a deployment may override the graph — that one is what runs);
|
|
448
|
+
* - else the project's own graph of exactly that version (`cef.config.ts`
|
|
449
|
+
* or `dist/<id>/manifest.json`).
|
|
450
|
+
* undefined (with the reason) when neither is available: scoring then judges
|
|
451
|
+
* every expected field, as before Result declarations.
|
|
452
|
+
*/
|
|
453
|
+
export async function resultFieldsFor(opts) {
|
|
454
|
+
const rowGraph = opts.liveRow?.version === opts.workflowVersion ? opts.liveRow.params?.graph : undefined;
|
|
455
|
+
if (rowGraph !== undefined) {
|
|
456
|
+
const fields = resultFieldsFromGraph(rowGraph);
|
|
457
|
+
return fields ? { fields, from: "the live deployment" } : { why: "the live deployment's graph declares no Result" };
|
|
458
|
+
}
|
|
459
|
+
const local = await localGraph(opts.workflowId, opts.workflowVersion, opts.local);
|
|
460
|
+
if (local) {
|
|
461
|
+
const fields = resultFieldsFromGraph(local.graph);
|
|
462
|
+
return fields ? { fields, from: local.from } : { why: `${local.from} declares no Result` };
|
|
463
|
+
}
|
|
464
|
+
return { why: `no graph of ${opts.workflowId}@${opts.workflowVersion} here (cef.config.ts or dist/) or on the live deployment` };
|
|
465
|
+
}
|
|
466
|
+
/* ------------------------------------------------------------------------ */
|
|
467
|
+
/* Output */
|
|
468
|
+
/* ------------------------------------------------------------------------ */
|
|
469
|
+
/** A plain fixed-width table. */
|
|
470
|
+
export function table(headers, rows) {
|
|
471
|
+
const cells = rows.map((r) => r.map((c) => (c === null || c === undefined ? "–" : String(c))));
|
|
472
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((r) => (r[i] ?? "").length)));
|
|
473
|
+
const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
|
|
474
|
+
return [line(headers), line(widths.map((w) => "-".repeat(w))), ...cells.map(line)].join("\n");
|
|
475
|
+
}
|
|
476
|
+
const pct = (n) => (n === null || n === undefined ? null : `${Math.round(n * 1000) / 10}%`);
|
|
477
|
+
const ms = (n) => (n === null || n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${n}ms`);
|
|
478
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
479
|
+
export function formatDatasets(list) {
|
|
480
|
+
if (!list.length)
|
|
481
|
+
return "No datasets.";
|
|
482
|
+
return table(["DATASET", "WORKFLOW", "CASES", "VERSION", "EXPERIMENTS", "BASELINE"], list.map((d) => [
|
|
483
|
+
d.dataset, d.workflowId, d.cases, d.latestVersion === null ? null : `v${d.latestVersion}`, d.experiments, d.baselineId,
|
|
484
|
+
]));
|
|
485
|
+
}
|
|
486
|
+
export function formatExperiments(list, baselineId) {
|
|
487
|
+
if (!list.length)
|
|
488
|
+
return "No experiments.";
|
|
489
|
+
return table(["EXPERIMENT", "STARTED", "DATASET", "RUN ON", "STATUS", "ACCURACY", "P50", "TOKENS/RUN", "COST/RUN", "RUNS"], list.map((e) => [
|
|
490
|
+
`${e.id === baselineId ? "★ " : ""}${e.id}`, e.startedAt.replace("T", " ").slice(0, 16), e.dataset,
|
|
491
|
+
`workflow ${e.workflowVersion} · dataset v${e.datasetVersion}`, e.status,
|
|
492
|
+
pct(e.summary?.passRate), ms(e.summary?.durationMs.p50), e.summary?.tokens.meanPerRun, e.summary?.cost.meanPerRun,
|
|
493
|
+
e.summary?.runs,
|
|
494
|
+
]));
|
|
495
|
+
}
|
|
496
|
+
export function formatExperiment(e) {
|
|
497
|
+
const s = e.summary;
|
|
498
|
+
const fields = Object.entries(s?.fields ?? {});
|
|
499
|
+
return [
|
|
500
|
+
`${e.id} ${e.status} dataset ${e.dataset} v${e.datasetVersion} × workflow ${e.workflowVersion}${e.live ? " (live)" : ""}`
|
|
501
|
+
+ ` × ${plural(e.repeats, "repeat")}`,
|
|
502
|
+
...(e.error ? [`error: ${e.error}`] : []),
|
|
503
|
+
...(s
|
|
504
|
+
? [
|
|
505
|
+
`accuracy ${pct(s.passRate) ?? "–"} (${s.passed} passed, ${s.failed} failed, ${s.errored} errored, ${s.unscored} unscored of ${s.runs} runs)`,
|
|
506
|
+
...fields.map(([name, f]) => ` ${name}: ${pct(f.rate) ?? "–"} (${f.passed}/${f.passed + f.failed}${f.notApplicable ? `, ${f.notApplicable} n/a` : ""})`),
|
|
507
|
+
`duration p50 ${ms(s.durationMs.p50) ?? "–"} p95 ${ms(s.durationMs.p95) ?? "–"} · tokens ${s.tokens.total} (${s.tokens.meanPerRun}/run)`
|
|
508
|
+
+ ` · cost ${s.cost.total} (${s.cost.meanPerRun}/run) · steps ${s.steps.meanPerRun}/run · limit breaches ${s.limitBreaches}`,
|
|
509
|
+
]
|
|
510
|
+
: []),
|
|
511
|
+
].join("\n");
|
|
512
|
+
}
|
|
513
|
+
/** "Dataset changed between these experiments: …" — or nothing when it did not. */
|
|
514
|
+
export function formatDatasetDiff(c) {
|
|
515
|
+
const d = c.datasetDiff;
|
|
516
|
+
if (!d)
|
|
517
|
+
return null;
|
|
518
|
+
const parts = [
|
|
519
|
+
d.added.length ? `${plural(d.added.length, "case")} added` : "",
|
|
520
|
+
d.removed.length ? `${plural(d.removed.length, "case")} removed` : "",
|
|
521
|
+
d.edited.length ? `${plural(d.edited.length, "expectation")} edited` : "",
|
|
522
|
+
].filter(Boolean);
|
|
523
|
+
if (!parts.length)
|
|
524
|
+
return null;
|
|
525
|
+
return `Dataset changed between these experiments: ${parts.join(", ")}. `
|
|
526
|
+
+ `Compared on the ${plural(c.comparedOn, "unchanged case")}.`;
|
|
527
|
+
}
|
|
528
|
+
export function formatResultCheck(r) {
|
|
529
|
+
if (!("fields" in r))
|
|
530
|
+
return [`Result of ${r.workflowVersion} unknown (${r.unknown ?? "no graph"}); every expected field is judged`];
|
|
531
|
+
return [
|
|
532
|
+
`Result of ${r.workflowVersion}${r.from ? ` (from ${r.from})` : ""}: ${r.fields.map((f) => f.name).join(", ")}`,
|
|
533
|
+
...formatCompatibility(r.compatibility, r.workflowVersion).map((l) => `! ${l}`),
|
|
534
|
+
];
|
|
535
|
+
}
|
|
536
|
+
export function formatCompatibility(r, version) {
|
|
537
|
+
return [
|
|
538
|
+
...r.missing.map((m) => `${plural(m.caseIds.length, "dataset case")} expect \`${m.field}\`, which ${version} does not return`
|
|
539
|
+
+ ` — scored n/a (${m.caseIds.join(", ")})`),
|
|
540
|
+
...r.typeChanged.map((t) => `${plural(t.caseIds.length, "dataset case")} expect \`${t.field}\` as ${t.expectedType}; `
|
|
541
|
+
+ `${version} declares ${t.declaredType} — scored n/a (${t.caseIds.join(", ")})`),
|
|
542
|
+
...(r.unexpectedNew.length ? [`${version} returns ${r.unexpectedNew.map((f) => `\`${f}\``).join(", ")}, which no case expects yet`] : []),
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
const signed = (d, f = String) => d.delta === null ? "–" : `${d.delta > 0 ? "+" : ""}${f(d.delta)}${d.relative === null ? "" : ` (${d.relative > 0 ? "+" : ""}${Math.round(d.relative * 100)}%)`}`;
|
|
546
|
+
export function formatComparison(c) {
|
|
547
|
+
const m = c.metrics;
|
|
548
|
+
const head = `${c.base.id} (${c.base.workflowVersion ?? "?"}) → ${c.candidate.id} (${c.candidate.workflowVersion ?? "?"})`;
|
|
549
|
+
const counts = Object.entries(c.counts).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k}`).join(", ") || "no runs";
|
|
550
|
+
const diff = formatDatasetDiff(c);
|
|
551
|
+
const metrics = table(["METRIC", "BASE", "CANDIDATE", "DELTA"], [
|
|
552
|
+
["accuracy", pct(m.passRate.base), pct(m.passRate.candidate), signed(m.passRate, (n) => pct(n))],
|
|
553
|
+
["duration p50", ms(m.durationP50.base), ms(m.durationP50.candidate), signed(m.durationP50, (n) => ms(n))],
|
|
554
|
+
["duration p95", ms(m.durationP95.base), ms(m.durationP95.candidate), signed(m.durationP95, (n) => ms(n))],
|
|
555
|
+
["tokens/run", m.tokensPerRun.base, m.tokensPerRun.candidate, signed(m.tokensPerRun)],
|
|
556
|
+
["cost/run", m.costPerRun.base, m.costPerRun.candidate, signed(m.costPerRun)],
|
|
557
|
+
["steps/run", m.stepsPerRun.base, m.stepsPerRun.candidate, signed(m.stepsPerRun)],
|
|
558
|
+
["limit breaches", m.limitBreaches.base, m.limitBreaches.candidate, signed(m.limitBreaches)],
|
|
559
|
+
]);
|
|
560
|
+
const changed = c.runs.filter((r) => r.change !== "unchanged");
|
|
561
|
+
const runs = changed.length
|
|
562
|
+
? table(["CASE", "REPEAT", "CHANGE", "BASE", "CANDIDATE", "DURATION Δ", "TOKENS Δ"], changed.map((r) => [
|
|
563
|
+
r.caseId, r.repeat, r.change,
|
|
564
|
+
r.base ? `${r.base.status}/${r.base.pass === null ? "–" : r.base.pass ? "pass" : "fail"}` : null,
|
|
565
|
+
r.candidate ? `${r.candidate.status}/${r.candidate.pass === null ? "–" : r.candidate.pass ? "pass" : "fail"}` : null,
|
|
566
|
+
signed(r.durationMs, (n) => ms(n)), signed(r.totalTokens),
|
|
567
|
+
]))
|
|
568
|
+
: "Every run unchanged.";
|
|
569
|
+
return [head, ...(diff ? [diff] : []), counts, "", metrics, "", runs].join("\n");
|
|
570
|
+
}
|
|
571
|
+
export function formatMigration(r) {
|
|
572
|
+
if (r.alreadyMigrated)
|
|
573
|
+
return "Already migrated (evals/MIGRATED.json is there); nothing to do.";
|
|
574
|
+
return [
|
|
575
|
+
`✓ ${r.datasets.length ? `Copied ${plural(r.datasets.length, "dataset")}` : "No legacy datasets"}: `
|
|
576
|
+
+ `${r.copied} keys written, ${r.existing} already in place`,
|
|
577
|
+
...r.datasets.map((d) => ` ${d.dataset} → workflows/${d.workflowId}/ (${d.keys} keys, ${plural(d.experiments, "experiment")})`),
|
|
578
|
+
...r.skipped.map((x) => ` skipped ${x.dataset}: ${x.reason}`),
|
|
579
|
+
...(r.skipped.length ? ["Not marked migrated: fix the skipped datasets and run it again."] : []),
|
|
580
|
+
].join("\n");
|
|
581
|
+
}
|
|
582
|
+
const int = (v, flag) => {
|
|
583
|
+
if (v === undefined)
|
|
584
|
+
return undefined;
|
|
585
|
+
const n = Number(v);
|
|
586
|
+
if (!Number.isInteger(n) || n < 1)
|
|
587
|
+
throw new Error(`[cef eval] ${flag} must be a whole number ≥ 1 (got "${v}")`);
|
|
588
|
+
return n;
|
|
589
|
+
};
|
|
590
|
+
/** A finite number ≥ 0 (a cost limit may be fractional). */
|
|
591
|
+
const number = (v, flag) => {
|
|
592
|
+
if (v === undefined)
|
|
593
|
+
return undefined;
|
|
594
|
+
const n = v.trim() === "" ? NaN : Number(v);
|
|
595
|
+
if (!Number.isFinite(n) || n < 0)
|
|
596
|
+
throw new Error(`[cef eval] ${flag} must be a number ≥ 0 (got "${v}")`);
|
|
597
|
+
return n;
|
|
598
|
+
};
|
|
599
|
+
function withStoreOptions(cmd) {
|
|
600
|
+
return cmd
|
|
601
|
+
.option("--env <env>", "Environment: dev | stage | prod (default dev; overrides $CEF_ENV)")
|
|
602
|
+
.option("--as-pubkey <hex>", "Agent service pubkey — names the bucket ($CEF_AS_PUBKEY)")
|
|
603
|
+
.option("--keystore <file>", "Owner wallet keystore JSON, ed25519 ($CEF_KEYSTORE)")
|
|
604
|
+
.option("--password <pw>", "Keystore password ($CEF_KEYSTORE_PASSWORD)")
|
|
605
|
+
.option("--secret-phrase <phrase>", "Owner wallet secret phrase, ed25519 ($CEF_SECRET_PHRASE)")
|
|
606
|
+
.option("--gateway <url>", "S3 gateway base URL ($CEF_EVAL_ENDPOINT; default from --env)")
|
|
607
|
+
.option("--bucket <name>", "Bucket override ($CEF_EVAL_BUCKET)")
|
|
608
|
+
.option("--access-key <id>", "Explicit S3 access key ($CEF_EVAL_KEY)")
|
|
609
|
+
.option("--secret <key>", "Explicit S3 secret ($CEF_EVAL_SECRET)")
|
|
610
|
+
.option("--json", "Print JSON instead of a table");
|
|
611
|
+
}
|
|
612
|
+
const WORKFLOW_HELP = "Workflow id (agent alias); default: the workflow cef.config.ts (or dist/) in this directory declares";
|
|
613
|
+
function withWorkflow(cmd) {
|
|
614
|
+
return withStoreOptions(cmd).option("--workflow <id>", WORKFLOW_HELP);
|
|
615
|
+
}
|
|
616
|
+
function withDir(cmd) {
|
|
617
|
+
return cmd.option("--dir <dir>", "Directory holding the workflow's datasets", DEFAULT_DATASETS_DIR);
|
|
618
|
+
}
|
|
619
|
+
async function openStore(o, wallet) {
|
|
620
|
+
return gatewayStore(await resolveGatewayAccess({ ...o, ...(o.gateway ? { endpoint: o.gateway } : {}) }, wallet));
|
|
621
|
+
}
|
|
622
|
+
/** The Node target: vault-api for events, the orchestrator for jobs and deployments. */
|
|
623
|
+
async function openTarget(o, wallet, workflowId, fetchImpl) {
|
|
624
|
+
const env = envConfig(o.env);
|
|
625
|
+
const asPubKey = o.asPubkey ?? process.env.CEF_AS_PUBKEY;
|
|
626
|
+
if (!asPubKey)
|
|
627
|
+
throw new Error("[cef eval] --as-pubkey (or $CEF_AS_PUBKEY) is required");
|
|
628
|
+
const accessToken = o.accessToken ?? process.env.CEF_ACCESS_TOKEN;
|
|
629
|
+
const auth = wallet
|
|
630
|
+
? new WalletAuthProvider(wallet)
|
|
631
|
+
: accessToken
|
|
632
|
+
? new DelegationAuthProvider(accessToken)
|
|
633
|
+
: undefined;
|
|
634
|
+
if (!auth)
|
|
635
|
+
throw new Error("[cef eval] run needs a wallet (--keystore/--secret-phrase) or --access-token to reach the vault");
|
|
636
|
+
const http = new VaultHttpClient({ url: o.vaultApi ?? env.vault, auth, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
637
|
+
const vaultId = o.vault ?? process.env.CEF_VAULT_ID ?? (await new Vaults(http).current()).vaultId;
|
|
638
|
+
const events = new Events(http);
|
|
639
|
+
const browse = new Browse(http);
|
|
640
|
+
const orchestrator = fetchOrchestrator({
|
|
641
|
+
endpoint: o.orchestrator ?? process.env.CEF_ENDPOINT ?? env.orchestrator,
|
|
642
|
+
...(accessToken ? { accessToken } : {}),
|
|
643
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
644
|
+
});
|
|
645
|
+
const target = nodeWorkflowTarget({
|
|
646
|
+
vaultId,
|
|
647
|
+
asPubKey,
|
|
648
|
+
workflowId,
|
|
649
|
+
orchestrator,
|
|
650
|
+
vault: {
|
|
651
|
+
async publish(ev) {
|
|
652
|
+
const res = await events.publish(vaultId, o.scope, ev);
|
|
653
|
+
const rejected = res.rejected ?? [];
|
|
654
|
+
if (rejected.length)
|
|
655
|
+
throw new Error(`the vault rejected ${ev.type}: ${rejected[0].reason}`);
|
|
656
|
+
},
|
|
657
|
+
events: (context, cursor) => browse.listStreamEvents(vaultId, o.scope, context, { limit: 200, ...(cursor ? { cursor } : {}) }),
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
return { target, orchestrator, agentId: workflowAgentId(asPubKey, workflowId), vaultId };
|
|
661
|
+
}
|
|
662
|
+
const print = (json, data, text) => console.log(json ? JSON.stringify(data, null, 2) : text());
|
|
663
|
+
export function addEvalCommands(program, opts) {
|
|
664
|
+
const local = () => ({ cwd: opts.cwd?.() ?? process.cwd() });
|
|
665
|
+
const workflowOf = (o) => resolveWorkflowId(o.workflow, local());
|
|
666
|
+
const ev = program
|
|
667
|
+
.command("eval")
|
|
668
|
+
.description("Workflow evaluations: datasets, experiments, comparisons");
|
|
669
|
+
withWorkflow(ev.command("datasets").description("List the workflow's datasets (every workflow's, when none is named or found)"))
|
|
670
|
+
.action(async (o) => {
|
|
671
|
+
try {
|
|
672
|
+
const workflowId = await workflowOf(o).catch(() => undefined);
|
|
673
|
+
const store = await openStore(o);
|
|
674
|
+
const list = [];
|
|
675
|
+
for (const wf of workflowId ? [workflowId] : await listWorkflows(store))
|
|
676
|
+
list.push(...await listDatasets(store, wf));
|
|
677
|
+
print(o.json, list, () => formatDatasets(list));
|
|
678
|
+
}
|
|
679
|
+
catch (e) {
|
|
680
|
+
opts.onError(e);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
withWorkflow(ev.command("create <dataset>").description("Create a dataset for the workflow"))
|
|
684
|
+
.option("--description <text>", "What the dataset covers")
|
|
685
|
+
.option("--max-duration-ms <n>", "Warn when a run takes longer")
|
|
686
|
+
.option("--max-tokens <n>", "Warn when a run uses more tokens")
|
|
687
|
+
.option("--max-steps <n>", "Warn when a run takes more steps")
|
|
688
|
+
.option("--max-cost <n>", "Warn when a run costs more")
|
|
689
|
+
.action(async (dataset, o) => {
|
|
690
|
+
try {
|
|
691
|
+
// Validated first, so a typo fails before any credential is minted; NaN would serialize as null.
|
|
692
|
+
const limits = {};
|
|
693
|
+
const set = (key, n) => {
|
|
694
|
+
if (n !== undefined)
|
|
695
|
+
limits[key] = n;
|
|
696
|
+
};
|
|
697
|
+
set("maxDurationMs", int(o.maxDurationMs, "--max-duration-ms"));
|
|
698
|
+
set("maxTokens", int(o.maxTokens, "--max-tokens"));
|
|
699
|
+
set("maxSteps", int(o.maxSteps, "--max-steps"));
|
|
700
|
+
set("maxCost", number(o.maxCost, "--max-cost"));
|
|
701
|
+
const workflowId = await workflowOf(o);
|
|
702
|
+
const header = await evalCreate({
|
|
703
|
+
store: await openStore(o), dataset, workflowId,
|
|
704
|
+
...(o.description ? { description: o.description } : {}),
|
|
705
|
+
...(Object.keys(limits).length ? { limits } : {}),
|
|
706
|
+
});
|
|
707
|
+
print(o.json, header, () => `✓ Created dataset ${header.dataset} for workflow ${header.workflowId}`);
|
|
708
|
+
}
|
|
709
|
+
catch (e) {
|
|
710
|
+
opts.onError(e);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
withDir(withWorkflow(ev.command("pull <dataset>").description("Write a dataset's live cases to the repo dir")))
|
|
714
|
+
.option("--force", "Overwrite case files edited locally since the last pull/push")
|
|
715
|
+
.action(async (dataset, o) => {
|
|
716
|
+
try {
|
|
717
|
+
const workflowId = await workflowOf(o);
|
|
718
|
+
const r = await evalPull({ store: await openStore(o), workflowId, dataset, dir: o.dir, force: !!o.force });
|
|
719
|
+
print(o.json, r, () => [
|
|
720
|
+
`✓ Pulled ${r.cases} case(s) into ${r.dir}`,
|
|
721
|
+
...(r.removed.length ? [` removed (no longer live): ${r.removed.join(", ")}`] : []),
|
|
722
|
+
...(r.localOnly.length ? [` local only (never pulled; kept — push adds them): ${r.localOnly.join(", ")}`] : []),
|
|
723
|
+
...(r.state.versions.length ? [` versions: v${r.state.versions.map((v) => v.version).join(", v")}`] : [" no versions cut yet"]),
|
|
724
|
+
].join("\n"));
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
opts.onError(e);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
withDir(withWorkflow(ev.command("push <dataset>").description("Write the repo dir's cases to the bucket as new revisions")))
|
|
731
|
+
.option("--cut [note]", "Cut a new version after pushing (runs cut one automatically when the cases changed)")
|
|
732
|
+
.option("--force", "Write over cases that changed in the bucket since the last pull")
|
|
733
|
+
.option("--prune", "Delete cases the last pull wrote and the repo dir has since removed")
|
|
734
|
+
.option("--restore", "Undelete/unarchive cases that are deleted or archived in the bucket but present in the repo dir")
|
|
735
|
+
.action(async (dataset, o) => {
|
|
736
|
+
try {
|
|
737
|
+
const workflowId = await workflowOf(o);
|
|
738
|
+
const r = await evalPush({
|
|
739
|
+
store: await openStore(o), workflowId, dataset, dir: o.dir,
|
|
740
|
+
...(o.cut !== undefined ? { cut: o.cut } : {}), force: !!o.force, prune: !!o.prune, restore: !!o.restore,
|
|
741
|
+
});
|
|
742
|
+
print(o.json, r, () => [
|
|
743
|
+
r.created ? `✓ Created dataset ${dataset}` : r.headerUpdated ? "✓ Updated the header" : null,
|
|
744
|
+
`✓ ${r.added.length} added, ${r.updated.length} updated, ${r.unchanged.length} unchanged`,
|
|
745
|
+
...r.updated.map((u) => ` ${u.id} → r${u.rev}`),
|
|
746
|
+
...(r.added.length ? [` added: ${r.added.join(", ")}`] : []),
|
|
747
|
+
...(r.restored.length ? [` restored (were deleted/archived): ${r.restored.join(", ")}`] : []),
|
|
748
|
+
...(r.missingLocally.length ? [` removed from the repo, still in the bucket (kept; --prune deletes): ${r.missingLocally.join(", ")}`] : []),
|
|
749
|
+
...(r.notPulled.length ? [` added in the bucket since the last pull (kept; pull to get them): ${r.notPulled.join(", ")}`] : []),
|
|
750
|
+
...(r.deleted.length ? [` deleted: ${r.deleted.join(", ")}`] : []),
|
|
751
|
+
r.cut ? `✓ Cut v${r.cut.version} (${r.cut.cases} cases)` : null,
|
|
752
|
+
].filter((l) => l !== null).join("\n"));
|
|
753
|
+
}
|
|
754
|
+
catch (e) {
|
|
755
|
+
opts.onError(e);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
withWorkflow(ev.command("run <dataset>").description("Run a dataset version against a workflow version"))
|
|
759
|
+
// Not `--version`: the root program's `.version()` claims that flag on every
|
|
760
|
+
// subcommand, printing the CLI's version and exiting before the action runs.
|
|
761
|
+
.option("--dataset-version <n>", "Dataset version (default: the latest, cut automatically when the cases changed)")
|
|
762
|
+
.option("--workflow-version <v>", "Workflow version (default: the live one); a non-live version is pinned for this experiment only")
|
|
763
|
+
.option("--repeats <n>", "How many times each case runs", "1")
|
|
764
|
+
.option("--concurrency <n>", "Runs in flight at once", "4")
|
|
765
|
+
.option("--timeout <ms>", "How long one run may take", "300000")
|
|
766
|
+
.option("--baseline <exp>", "Experiment to compare against when done (default: the dataset's baseline)")
|
|
767
|
+
.option("--resume <exp>", "Resume an experiment that did not finish")
|
|
768
|
+
.option("--note <text>", "Note recorded on the experiment")
|
|
769
|
+
.option("--vault <id>", "Vault to run in ($CEF_VAULT_ID; default: the wallet's own)")
|
|
770
|
+
.option("--scope <scope>", "Vault scope to publish into", "default")
|
|
771
|
+
.option("--orchestrator <url>", "Orchestrator base URL ($CEF_ENDPOINT; default from --env)")
|
|
772
|
+
.option("--vault-api <url>", "vault-api base URL (default from --env)")
|
|
773
|
+
.option("--access-token <token>", "CLI access token for the orchestrator ($CEF_ACCESS_TOKEN)")
|
|
774
|
+
.action(async (dataset, o) => {
|
|
775
|
+
try {
|
|
776
|
+
// Numbers first, so a typo fails before any credential is minted or run started.
|
|
777
|
+
const numbers = {
|
|
778
|
+
...(o.datasetVersion ? { version: int(o.datasetVersion, "--dataset-version") } : {}),
|
|
779
|
+
repeats: int(o.repeats, "--repeats"),
|
|
780
|
+
concurrency: int(o.concurrency, "--concurrency"),
|
|
781
|
+
timeoutMs: int(o.timeout, "--timeout"),
|
|
782
|
+
};
|
|
783
|
+
const workflowId = await workflowOf(o);
|
|
784
|
+
const wallet = await loadWallet(o);
|
|
785
|
+
const store = await openStore(o, wallet);
|
|
786
|
+
const header = await getHeader(store, workflowId, dataset);
|
|
787
|
+
if (!header)
|
|
788
|
+
throw new Error(`[cef eval] workflow "${workflowId}" has no dataset "${dataset}"`);
|
|
789
|
+
const { target, orchestrator, agentId } = await openTarget(o, wallet, workflowId, opts.fetchImpl);
|
|
790
|
+
const liveRow = (await getDeploymentSet(orchestrator, agentId))?.deployments
|
|
791
|
+
.find((d) => d.targeting === "" && d.enabled !== false);
|
|
792
|
+
const live = liveRow?.version ?? null;
|
|
793
|
+
const workflowVersion = o.workflowVersion ?? live ?? "latest";
|
|
794
|
+
const isLive = o.workflowVersion === undefined || o.workflowVersion === live;
|
|
795
|
+
const result = await resultFieldsFor({
|
|
796
|
+
workflowId, workflowVersion, ...(liveRow ? { liveRow } : {}), local: local(),
|
|
797
|
+
});
|
|
798
|
+
// Checked whatever the output format: --json carries it instead of printing it.
|
|
799
|
+
const resultCheck = await evalResultCheck({ store, workflowId, dataset, workflowVersion, result });
|
|
800
|
+
if (!o.json)
|
|
801
|
+
for (const line of formatResultCheck(resultCheck))
|
|
802
|
+
console.error(` ${line}`);
|
|
803
|
+
const baseline = o.baseline ?? header.baselineId;
|
|
804
|
+
const exp = await evalRun({
|
|
805
|
+
store, target, workflowId, dataset, workflowVersion, live: isLive, ...numbers,
|
|
806
|
+
...(result.fields ? { resultFields: result.fields } : {}),
|
|
807
|
+
...(baseline ? { baselineId: baseline } : {}),
|
|
808
|
+
...(o.resume ? { experimentId: o.resume } : {}),
|
|
809
|
+
...(o.note ? { note: o.note } : {}),
|
|
810
|
+
onProgress: (p) => {
|
|
811
|
+
if (!o.json) {
|
|
812
|
+
const verdict = p.run.pass === null ? p.run.status : p.run.pass ? "pass" : "FAIL";
|
|
813
|
+
console.error(` [${p.done}/${p.total}] ${p.run.caseId}#${p.run.repeat} ${verdict}${p.reused ? " (reused)" : ""}`);
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
const comparison = baseline && baseline !== exp.id
|
|
818
|
+
? await evalCompare({ store, base: baseline, candidate: exp.id })
|
|
819
|
+
: undefined;
|
|
820
|
+
print(o.json, { experiment: exp, result: resultCheck, ...(comparison ? { comparison } : {}) }, () => [formatExperiment(exp), ...(comparison ? ["", formatComparison(comparison)] : [])].join("\n"));
|
|
821
|
+
}
|
|
822
|
+
catch (e) {
|
|
823
|
+
opts.onError(e);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
withWorkflow(ev.command("experiments [dataset]").description("The workflow's experiment history (one dataset's, when named)"))
|
|
827
|
+
.action(async (dataset, o) => {
|
|
828
|
+
try {
|
|
829
|
+
const workflowId = await workflowOf(o);
|
|
830
|
+
const store = await openStore(o);
|
|
831
|
+
const list = await listExperiments(store, workflowId, dataset ? { dataset } : {});
|
|
832
|
+
const baselineId = dataset ? (await getHeader(store, workflowId, dataset))?.baselineId : undefined;
|
|
833
|
+
print(o.json, list, () => formatExperiments(list, baselineId));
|
|
834
|
+
}
|
|
835
|
+
catch (e) {
|
|
836
|
+
opts.onError(e);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
withStoreOptions(ev.command("compare <base> <candidate>").description("How one experiment differs from another"))
|
|
840
|
+
.action(async (base, candidate, o) => {
|
|
841
|
+
try {
|
|
842
|
+
const c = await evalCompare({ store: await openStore(o), base, candidate });
|
|
843
|
+
print(o.json, c, () => formatComparison(c));
|
|
844
|
+
}
|
|
845
|
+
catch (e) {
|
|
846
|
+
opts.onError(e);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
withStoreOptions(ev.command("migrate").description("Copy v1 storage (evals/datasets/…) into the per-workflow layout"))
|
|
850
|
+
.action(async (o) => {
|
|
851
|
+
try {
|
|
852
|
+
const r = await evalMigrate({ store: await openStore(o) });
|
|
853
|
+
print(o.json, r, () => formatMigration(r));
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
opts.onError(e);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
//# sourceMappingURL=eval.js.map
|