niceeval 0.8.0 → 0.9.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/INDEX.md +77 -45
- package/dist/agents/types.d.ts +28 -6
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/zh-CN.d.ts +2 -0
- package/dist/report/built-in/index.d.ts +3 -2
- package/dist/report/built-in/index.js +7 -8
- package/dist/report/built-in/standard.d.ts +1 -0
- package/dist/report/built-in/standard.js +30 -0
- package/dist/report/components.d.ts +72 -6
- package/dist/report/components.js +159 -10
- package/dist/report/compute.d.ts +31 -4
- package/dist/report/compute.js +131 -12
- package/dist/report/index.d.ts +4 -4
- package/dist/report/index.js +3 -2
- package/dist/report/locale.d.ts +39 -1
- package/dist/report/locale.js +69 -0
- package/dist/report/react/AttemptList.d.ts +3 -1
- package/dist/report/react/AttemptList.js +3 -3
- package/dist/report/react/CopyFixPrompt.d.ts +12 -0
- package/dist/report/react/CopyFixPrompt.js +12 -0
- package/dist/report/react/HeroCard.d.ts +13 -0
- package/dist/report/react/HeroCard.js +35 -0
- package/dist/report/react/PoweredBy.d.ts +5 -0
- package/dist/report/react/PoweredBy.js +7 -0
- package/dist/report/react/ScopeWarnings.d.ts +12 -0
- package/dist/report/react/ScopeWarnings.js +18 -0
- package/dist/report/react/TraceWaterfall.d.ts +14 -0
- package/dist/report/react/TraceWaterfall.js +22 -0
- package/dist/report/react/index.d.ts +6 -1
- package/dist/report/react/index.js +6 -0
- package/dist/report/report.d.ts +22 -6
- package/dist/report/report.js +66 -53
- package/dist/report/scope-warnings.d.ts +28 -0
- package/dist/report/scope-warnings.js +101 -0
- package/dist/report/text/faces.d.ts +19 -1
- package/dist/report/text/faces.js +61 -0
- package/dist/report/tree.js +6 -1
- package/dist/report/types.d.ts +45 -10
- package/dist/report/web.d.ts +5 -4
- package/dist/report/web.js +7 -22
- package/dist/results/select.d.ts +15 -2
- package/dist/results/select.js +75 -10
- package/dist/results/types.d.ts +26 -11
- package/dist/runner/fingerprint.d.ts +3 -3
- package/dist/runner/sandbox-selection.d.ts +12 -0
- package/dist/runner/types.d.ts +18 -6
- package/dist/sandbox/types.d.ts +12 -0
- package/docs-site/zh/explanation/evals.mdx +2 -1
- package/docs-site/zh/explanation/experiment.mdx +2 -0
- package/docs-site/zh/how-to/custom-reports.mdx +22 -6
- package/docs-site/zh/how-to/publish-report.mdx +6 -8
- package/docs-site/zh/how-to/viewing-results.mdx +3 -3
- package/docs-site/zh/how-to/write-experiment.mdx +40 -1
- package/docs-site/zh/reference/builtin-agents.mdx +40 -3
- package/docs-site/zh/reference/cli.mdx +1 -2
- package/docs-site/zh/reference/define-eval.mdx +8 -0
- package/docs-site/zh/reference/official-adapters.mdx +10 -5
- package/docs-site/zh/reference/report-components.mdx +2 -2
- package/docs-site/zh/reference/results-data.mdx +2 -4
- package/package.json +2 -1
- package/src/agents/bub.ts +13 -1
- package/src/agents/claude-code.test.ts +43 -1
- package/src/agents/claude-code.ts +32 -14
- package/src/agents/codex.test.ts +168 -1
- package/src/agents/codex.ts +51 -15
- package/src/agents/mcp.ts +31 -0
- package/src/agents/post-setup.ts +33 -0
- package/src/agents/types.ts +28 -7
- package/src/cli.ts +9 -11
- package/src/context/context.ts +11 -5
- package/src/define.ts +3 -0
- package/src/i18n/en.ts +5 -2
- package/src/i18n/zh-CN.ts +5 -1
- package/src/index.ts +1 -0
- package/src/report/built-in/index.tsx +8 -7
- package/src/report/built-in/standard.tsx +59 -0
- package/src/report/components.tsx +231 -17
- package/src/report/compute.ts +146 -21
- package/src/report/dual-render.test.tsx +139 -12
- package/src/report/index.ts +20 -1
- package/src/report/locale.ts +83 -1
- package/src/report/react/AttemptList.tsx +13 -1
- package/src/report/react/CopyFixPrompt.tsx +37 -0
- package/src/report/react/HeroCard.tsx +59 -0
- package/src/report/react/PoweredBy.tsx +20 -0
- package/src/report/react/ScopeWarnings.tsx +74 -0
- package/src/report/react/TraceWaterfall.tsx +78 -0
- package/src/report/react/enhance.js +14 -0
- package/src/report/react/index.tsx +11 -0
- package/src/report/react/styles.css +187 -7
- package/src/report/report.test.ts +2 -20
- package/src/report/report.ts +97 -62
- package/src/report/scope-warnings.ts +155 -0
- package/src/report/site-components.test.tsx +526 -0
- package/src/report/text/faces.ts +66 -0
- package/src/report/tree.ts +8 -1
- package/src/report/types.ts +51 -11
- package/src/report/web.ts +7 -40
- package/src/results/copy.ts +15 -78
- package/src/results/host-equivalence.test.ts +5 -1
- package/src/results/open.ts +5 -4
- package/src/results/publish.ts +4 -146
- package/src/results/results.test.ts +86 -9
- package/src/results/select.ts +78 -10
- package/src/results/types.ts +27 -7
- package/src/runner/attempt.ts +10 -9
- package/src/runner/discover.test.ts +9 -1
- package/src/runner/discover.ts +3 -3
- package/src/runner/fingerprint.ts +9 -4
- package/src/runner/ledger.test.ts +30 -1
- package/src/runner/ledger.ts +26 -4
- package/src/runner/run.ts +5 -1
- package/src/runner/sandbox-selection.test.ts +131 -0
- package/src/runner/sandbox-selection.ts +110 -0
- package/src/runner/types.ts +19 -2
- package/src/sandbox/types.ts +6 -0
- package/src/show/index.ts +17 -10
- package/src/show/render.ts +11 -11
- package/src/show/report-host.test.ts +32 -15
- package/src/show/report-host.ts +5 -4
- package/src/show/show.test.ts +140 -3
- package/src/view/app/App.test.tsx +78 -17
- package/src/view/app/App.tsx +17 -78
- package/src/view/app/components/CopyControls.tsx +4 -42
- package/src/view/app/i18n.ts +5 -227
- package/src/view/app/lib/rows.ts +3 -21
- package/src/view/app/shared.ts +1 -3
- package/src/view/app/types.ts +2 -2
- package/src/view/artifact-serving.test.ts +1 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +2 -13
- package/src/view/index.ts +1 -12
- package/src/view/server.ts +0 -2
- package/src/view/shared/types.ts +11 -6
- package/src/view/site-parity.test.ts +1 -1
- package/src/view/site.ts +1 -1
- package/src/view/styles.css +6 -266
- package/src/view/view-report.test.ts +64 -27
- package/src/view/app/components/LazyArtifact.tsx +0 -51
- package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
- package/src/view/app/pages/AttemptsPage.tsx +0 -80
- package/src/view/app/pages/TracesPage.tsx +0 -35
|
@@ -375,13 +375,89 @@ describe("results.latest() · Selection", () => {
|
|
|
375
375
|
|
|
376
376
|
const filtered = latest.filter((s) => s.experimentId !== "mid/b");
|
|
377
377
|
expect(filtered.snapshots.map((s) => s.experimentId)).toEqual(["mid/a"]);
|
|
378
|
-
expect(filtered.warnings.map((w) => w.experimentId)).toEqual(["mid/a"]);
|
|
378
|
+
expect(filtered.warnings.map((w) => ("experimentId" in w ? w.experimentId : undefined))).toEqual(["mid/a"]);
|
|
379
379
|
// 原 Selection 不被改动。
|
|
380
380
|
expect(latest.snapshots).toHaveLength(2);
|
|
381
381
|
expect(latest.warnings).toHaveLength(2);
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
// ───────────────────────── unreadable-snapshot 警告 ─────────────────────────
|
|
386
|
+
|
|
387
|
+
describe("results.latest() / results.current() · unreadable-snapshot", () => {
|
|
388
|
+
it("malformed 快照产生 warning 且其余快照照常计入;无关 JSON 不产生 warning", async () => {
|
|
389
|
+
const root = await makeRoot();
|
|
390
|
+
const okDir = await writeSnapshot(root, "ok-exp", "2026-07-04T08-00-00-000Z-oooo", meta({ experimentId: "ok", agent: "bub", startedAt: "2026-07-04T08:00:00.000Z", completedAt: "2026-07-04T08:10:00.000Z" }));
|
|
391
|
+
await writeResultFile(okDir, "q1/a1", record({ id: "q1", attempt: 1 }));
|
|
392
|
+
|
|
393
|
+
const badDir = join(root, "bad-exp", "2026-07-02T08-00-00-000Z-zzzz");
|
|
394
|
+
await mkdir(badDir, { recursive: true });
|
|
395
|
+
await writeFile(join(badDir, "snapshot.json"), "not json {", "utf-8");
|
|
396
|
+
|
|
397
|
+
const alienDir = join(root, "alien-exp", "2026-07-06T08-00-00-000Z-alien");
|
|
398
|
+
await mkdir(alienDir, { recursive: true });
|
|
399
|
+
await writeFile(join(alienDir, "summary.json"), JSON.stringify({ hello: 1 }), "utf-8");
|
|
400
|
+
|
|
401
|
+
const results = await openResults(root);
|
|
402
|
+
const latest = results.latest();
|
|
403
|
+
// 其余快照照常计入,不被坏落盘拖垮。
|
|
404
|
+
expect(latest.snapshots.map((s) => s.experimentId)).toEqual(["ok"]);
|
|
405
|
+
|
|
406
|
+
const unreadable = latest.warnings.filter((w) => w.kind === "unreadable-snapshot");
|
|
407
|
+
expect(unreadable).toHaveLength(1); // 无关 JSON(alien)不产生 warning
|
|
408
|
+
expect(unreadable[0]).toMatchObject({ kind: "unreadable-snapshot", dir: badDir, reason: "malformed" });
|
|
409
|
+
expect(unreadable[0].message).toContain(badDir);
|
|
410
|
+
expect(unreadable[0].message).toMatch(/inspect .*snapshot\.json/);
|
|
411
|
+
expect((unreadable[0] as { command?: string }).command).toBeUndefined();
|
|
412
|
+
|
|
413
|
+
// results.current() 同一份事实(show / view 报告槽走的是它)。
|
|
414
|
+
const current = results.current();
|
|
415
|
+
expect(current.warnings.filter((w) => w.kind === "unreadable-snapshot")).toHaveLength(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("incompatible(schemaVersion 不兼容,niceeval producer)的 warning 带版本化 command", async () => {
|
|
419
|
+
const root = await makeRoot();
|
|
420
|
+
const oldDir = join(root, "old-exp", "2026-06-01T08-00-00-000Z");
|
|
421
|
+
await mkdir(oldDir, { recursive: true });
|
|
422
|
+
await writeFile(
|
|
423
|
+
join(oldDir, "snapshot.json"),
|
|
424
|
+
JSON.stringify({
|
|
425
|
+
format: RESULTS_FORMAT,
|
|
426
|
+
schemaVersion: RESULTS_SCHEMA_VERSION - 1,
|
|
427
|
+
producer: { name: "niceeval", version: "0.4.6" },
|
|
428
|
+
experimentId: "old",
|
|
429
|
+
agent: "bub",
|
|
430
|
+
startedAt: "2026-06-01T08:00:00.000Z",
|
|
431
|
+
}),
|
|
432
|
+
"utf-8",
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const latest = (await openResults(root)).latest();
|
|
436
|
+
const warn = latest.warnings.find((w) => w.kind === "unreadable-snapshot")!;
|
|
437
|
+
expect(warn).toMatchObject({ kind: "unreadable-snapshot", dir: oldDir, reason: "incompatible-version" });
|
|
438
|
+
expect((warn as { command?: string }).command).toBe(`npx niceeval@0.4.6 show --results ${root}`);
|
|
439
|
+
expect(warn.message).toContain("npx niceeval@0.4.6 show --results");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("非实验作用域:Selection.filter 收窄后 unreadable-snapshot warning 仍在", async () => {
|
|
443
|
+
const root = await makeRoot();
|
|
444
|
+
const aDir = await writeSnapshot(root, "mid_a", "s1", meta({ experimentId: "mid/a", agent: "bub", startedAt: "2026-07-01T08:00:00.000Z", completedAt: "2026-07-01T08:10:00.000Z" }));
|
|
445
|
+
await writeResultFile(aDir, "q1/a1", record({ id: "q1", attempt: 1 }));
|
|
446
|
+
|
|
447
|
+
const badDir = join(root, "bad-exp", "2026-07-02T08-00-00-000Z-zzzz");
|
|
448
|
+
await mkdir(badDir, { recursive: true });
|
|
449
|
+
await writeFile(join(badDir, "snapshot.json"), "not json {", "utf-8");
|
|
450
|
+
|
|
451
|
+
const latest = (await openResults(root)).latest();
|
|
452
|
+
expect(latest.warnings.filter((w) => w.kind === "unreadable-snapshot")).toHaveLength(1);
|
|
453
|
+
|
|
454
|
+
// filter 只删快照,不删非实验作用域的警告 —— 即便过滤条件与该 warning 无关的实验都不复存在。
|
|
455
|
+
const filtered = latest.filter((s) => s.experimentId === "mid/a");
|
|
456
|
+
expect(filtered.snapshots).toHaveLength(1);
|
|
457
|
+
expect(filtered.warnings.filter((w) => w.kind === "unreadable-snapshot")).toHaveLength(1);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
385
461
|
// ───────────────────────── 身份键去重 ─────────────────────────
|
|
386
462
|
|
|
387
463
|
function fakeSnapshot(over: { experimentId: string; startedAt: string; dir: string }): Snapshot {
|
|
@@ -552,7 +628,7 @@ describe("createResultsWriter", () => {
|
|
|
552
628
|
expect(await q2.agentSetup()).toBeNull();
|
|
553
629
|
|
|
554
630
|
const dest = join(await makeRoot(), "published");
|
|
555
|
-
await copySnapshots(results.latest(), dest, {
|
|
631
|
+
await copySnapshots(results.latest(), dest, { artifacts: ["agentSetup"] });
|
|
556
632
|
const copied = join(dest, "skill-ab_claude-effect", basename(snap.dir), "q1", "a1", "agent-setup.json");
|
|
557
633
|
expect(JSON.parse(await readFile(copied, "utf-8"))).toEqual(manifest);
|
|
558
634
|
});
|
|
@@ -737,7 +813,7 @@ describe("copySnapshots", () => {
|
|
|
737
813
|
|
|
738
814
|
const results = await openResults(root);
|
|
739
815
|
const dest = join(await makeRoot(), "site/data/run");
|
|
740
|
-
const copied = await copySnapshots(results.latest(), dest, {
|
|
816
|
+
const copied = await copySnapshots(results.latest(), dest, { artifacts: ["events"] });
|
|
741
817
|
|
|
742
818
|
expect(copied.warnings).toHaveLength(0);
|
|
743
819
|
expect(copied.dir).toBe(dest);
|
|
@@ -775,15 +851,15 @@ describe("copySnapshots", () => {
|
|
|
775
851
|
|
|
776
852
|
const occupied = await makeRoot();
|
|
777
853
|
await writeFile(join(occupied, "existing.txt"), "x", "utf-8");
|
|
778
|
-
await expect(copySnapshots(results.latest(), occupied
|
|
854
|
+
await expect(copySnapshots(results.latest(), occupied)).rejects.toThrow(/not empty/);
|
|
779
855
|
|
|
780
|
-
await expect(copySnapshots(results.latest(), join(await makeRoot(), "out"), {
|
|
856
|
+
await expect(copySnapshots(results.latest(), join(await makeRoot(), "out"), { artifacts: ["evnets" as never] })).rejects.toThrow(/Unknown artifact kind/);
|
|
781
857
|
|
|
782
|
-
await expect(copySnapshots([], join(await makeRoot(), "out")
|
|
858
|
+
await expect(copySnapshots([], join(await makeRoot(), "out"))).rejects.toThrow(/no snapshots/);
|
|
783
859
|
|
|
784
860
|
// 手工传入同一 experiment 的两个快照(未走 latest 去重):只带最新,记 warning。
|
|
785
861
|
const dest2 = join(await makeRoot(), "run2");
|
|
786
|
-
const collided = await copySnapshots(results.experiments[0].snapshots, dest2
|
|
862
|
+
const collided = await copySnapshots(results.experiments[0].snapshots, dest2);
|
|
787
863
|
expect(collided.warnings).toHaveLength(1);
|
|
788
864
|
expect(collided.warnings[0]).toMatch(/multiple snapshots selected/);
|
|
789
865
|
const destDirs = await readdir(join(dest2, "e"));
|
|
@@ -974,6 +1050,7 @@ describe("AttemptLocator · 落盘 / 读取 / 携带 / 撞车", () => {
|
|
|
974
1050
|
snapshot.attempts = [attempt];
|
|
975
1051
|
snapshot.evals = [{ id: "q1", attempts: [attempt] }];
|
|
976
1052
|
const handMadeResults: Results = {
|
|
1053
|
+
root: "/tmp/e",
|
|
977
1054
|
experiments: [{ id: "e", snapshots: [snapshot], latest: snapshot, evalIds: ["q1"] }],
|
|
978
1055
|
skipped: [],
|
|
979
1056
|
// filter() 本测试不调用,用不到,给个占位实现即可满足 Scope 接口。
|
|
@@ -1005,7 +1082,7 @@ describe("AttemptLocator · 落盘 / 读取 / 携带 / 撞车", () => {
|
|
|
1005
1082
|
const locator1 = a1!.locator!;
|
|
1006
1083
|
|
|
1007
1084
|
const dest = join(await makeRoot(), "published");
|
|
1008
|
-
await copySnapshots(results.latest(), dest, {
|
|
1085
|
+
await copySnapshots(results.latest(), dest, { artifacts: [] });
|
|
1009
1086
|
|
|
1010
1087
|
const destResults = await openResults(dest);
|
|
1011
1088
|
expect(resolveLocator(destResults, locator0).result.attempt).toBe(0);
|
|
@@ -1192,7 +1269,7 @@ describe("sources · 快照级去重仓库", () => {
|
|
|
1192
1269
|
|
|
1193
1270
|
const results = await openResults(root);
|
|
1194
1271
|
const dest = join(await makeRoot(), "published");
|
|
1195
|
-
await copySnapshots(results.latest(), dest, {
|
|
1272
|
+
await copySnapshots(results.latest(), dest, { artifacts: ["sources"] });
|
|
1196
1273
|
|
|
1197
1274
|
const destSnapDir = join(dest, "e", basename(snap.dir));
|
|
1198
1275
|
const destStoreFiles = await readdir(join(destSnapDir, "sources"));
|
package/src/results/select.ts
CHANGED
|
@@ -12,17 +12,23 @@ import type {
|
|
|
12
12
|
Results,
|
|
13
13
|
Scope,
|
|
14
14
|
ScopeWarning,
|
|
15
|
+
SkippedDir,
|
|
15
16
|
Snapshot,
|
|
16
17
|
} from "./types.ts";
|
|
17
18
|
import type { ExperimentRunInfo, JsonValue } from "../types.ts";
|
|
18
19
|
import { evalPrefixPredicate } from "../shared/aggregate.ts";
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Results.latest() 的实现:每个实验取最新一次快照(= exp.snapshots[0]),生成挑选警告。
|
|
23
|
+
* 收整个 `Results` 而不是裸 `Experiment[]`,是为了同时取 `skipped` / `root` 生成
|
|
24
|
+
* `unreadable-snapshot` 警告(非实验作用域,不受 `opts.experiments` 过滤 —— 那些落盘
|
|
25
|
+
* 本来就没能解析出 experimentId,没有前缀可过滤)。
|
|
26
|
+
*/
|
|
21
27
|
export function selectLatest(
|
|
22
|
-
|
|
28
|
+
results: Pick<Results, "experiments" | "skipped" | "root">,
|
|
23
29
|
opts?: { experiments?: string | string[] },
|
|
24
30
|
): Scope {
|
|
25
|
-
const selected = filterExperiments(experiments, opts?.experiments);
|
|
31
|
+
const selected = filterExperiments(results.experiments, opts?.experiments);
|
|
26
32
|
const snapshots = selected.map((exp) => exp.latest);
|
|
27
33
|
const warnings: ScopeWarning[] = [];
|
|
28
34
|
|
|
@@ -69,6 +75,7 @@ export function selectLatest(
|
|
|
69
75
|
});
|
|
70
76
|
}
|
|
71
77
|
}
|
|
78
|
+
warnings.push(...unreadableSnapshotWarnings(results.skipped, results.root));
|
|
72
79
|
return makeScope("latest-snapshots", snapshots, warnings);
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -235,9 +242,63 @@ export function selectCurrentResults(results: Results, scope: ResultScope = {}):
|
|
|
235
242
|
}
|
|
236
243
|
}
|
|
237
244
|
|
|
245
|
+
warnings.push(...unreadableSnapshotWarnings(results.skipped, results.root));
|
|
238
246
|
return makeScope("current-evals", snapshots, warnings);
|
|
239
247
|
}
|
|
240
248
|
|
|
249
|
+
/**
|
|
250
|
+
* `results.skipped` 里每一条不可读落盘 → 一条 `unreadable-snapshot` ScopeWarning。
|
|
251
|
+
* 非实验作用域(没有 experimentId 字段):`latest()` / `current()` 都原样带上全部
|
|
252
|
+
* `skipped` 条目,不受 `opts.experiments` 前缀过滤影响(那些落盘本来就没能解析出
|
|
253
|
+
* experimentId,没有前缀可比);`makeScope().filter()` 按「非实验作用域的警告保留」
|
|
254
|
+
* 规则自动放行,不需要额外分支。
|
|
255
|
+
*/
|
|
256
|
+
function unreadableSnapshotWarnings(skipped: readonly SkippedDir[], root: string): ScopeWarning[] {
|
|
257
|
+
return skipped.map((s): ScopeWarning => {
|
|
258
|
+
switch (s.reason) {
|
|
259
|
+
case "incompatible-version": {
|
|
260
|
+
const producer = s.producer;
|
|
261
|
+
const schemaText = s.schemaVersion !== undefined ? ` (schemaVersion ${s.schemaVersion})` : "";
|
|
262
|
+
if (producer?.name === "niceeval" && producer.version) {
|
|
263
|
+
const command = `npx niceeval@${producer.version} show --results ${root}`;
|
|
264
|
+
return {
|
|
265
|
+
kind: "unreadable-snapshot",
|
|
266
|
+
dir: s.dir,
|
|
267
|
+
reason: s.reason,
|
|
268
|
+
message: `snapshot at "${s.dir}" was written by niceeval ${producer.version}${schemaText} and cannot be read by this version; run \`${command}\` to open it`,
|
|
269
|
+
command,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const writtenBy = producer?.name
|
|
273
|
+
? `${producer.name}${producer.version ? ` ${producer.version}` : ""}`
|
|
274
|
+
: "an incompatible tool version";
|
|
275
|
+
return {
|
|
276
|
+
kind: "unreadable-snapshot",
|
|
277
|
+
dir: s.dir,
|
|
278
|
+
reason: s.reason,
|
|
279
|
+
message: `snapshot at "${s.dir}" was written by ${writtenBy}${schemaText} and cannot be read by this version; open it with the tool version that produced it`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
case "malformed": {
|
|
283
|
+
const detail = s.detail ? ` (${s.detail})` : "";
|
|
284
|
+
return {
|
|
285
|
+
kind: "unreadable-snapshot",
|
|
286
|
+
dir: s.dir,
|
|
287
|
+
reason: s.reason,
|
|
288
|
+
message: `snapshot at "${s.dir}" is malformed${detail} and was skipped; inspect snapshot.json in that directory for corrupted JSON or a missing required field`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
case "incomplete":
|
|
292
|
+
return {
|
|
293
|
+
kind: "unreadable-snapshot",
|
|
294
|
+
dir: s.dir,
|
|
295
|
+
reason: s.reason,
|
|
296
|
+
message: `snapshot at "${s.dir}" has attempt data but no snapshot.json (likely interrupted before metadata was written) and was skipped; inspect ${s.dir} — completed attempts remain on disk for manual review`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
241
302
|
/**
|
|
242
303
|
* Scope 构造:attempts 按口径物化(快照 attempts 的平铺);filter 只删不换 —— 快照删减,
|
|
243
304
|
* attempts 随之同步修剪,warnings 修剪规则是「experimentId 不在幸存快照中的丢弃,
|
|
@@ -315,15 +376,22 @@ export function filterExperiments(experiments: Experiment[], filter?: string | s
|
|
|
315
376
|
return experiments.filter((exp) => prefixes.some((p) => exp.id === p || exp.id.startsWith(p + "/")));
|
|
316
377
|
}
|
|
317
378
|
|
|
318
|
-
/**
|
|
319
|
-
|
|
379
|
+
/**
|
|
380
|
+
* stale 警告的人话时距:选粒度最大的单位,四舍五入。结构化形态是单源——message 的英文时距
|
|
381
|
+
* 与 ScopeWarnings 徽标的本地化时距都从这里出,阈值不写两份。
|
|
382
|
+
*/
|
|
383
|
+
export function gapParts(fromIso: string, toIso: string): { n: number; unit: "second" | "minute" | "hour" | "day" } {
|
|
320
384
|
const ms = Math.max(0, Date.parse(toIso) - Date.parse(fromIso));
|
|
321
385
|
const seconds = Math.round(ms / 1000);
|
|
322
|
-
if (seconds < 90) return
|
|
386
|
+
if (seconds < 90) return { n: seconds, unit: "second" };
|
|
323
387
|
const minutes = Math.round(seconds / 60);
|
|
324
|
-
if (minutes < 90) return
|
|
388
|
+
if (minutes < 90) return { n: minutes, unit: "minute" };
|
|
325
389
|
const hours = Math.round(minutes / 60);
|
|
326
|
-
if (hours < 36) return
|
|
327
|
-
|
|
328
|
-
|
|
390
|
+
if (hours < 36) return { n: hours, unit: "hour" };
|
|
391
|
+
return { n: Math.round(hours / 24), unit: "day" };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function humanizeGap(fromIso: string, toIso: string): string {
|
|
395
|
+
const { n, unit } = gapParts(fromIso, toIso);
|
|
396
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
329
397
|
}
|
package/src/results/types.ts
CHANGED
|
@@ -44,11 +44,6 @@ export interface SnapshotMeta {
|
|
|
44
44
|
completedAt?: string;
|
|
45
45
|
/** 写入时刻该实验已知的 eval 并集 —— 残缺检测的分母随数据走(copySnapshots 自动补记,writer 可声明)。 */
|
|
46
46
|
knownEvalIds?: string[];
|
|
47
|
-
/**
|
|
48
|
-
* 发布拷贝的自描述标记:copySnapshots 补记,消毒函数 → "applied"、redact: false → "none";
|
|
49
|
-
* 本地事实根没有此字段。只声明流程,不证明无秘密;view --out 据此分级防呆。
|
|
50
|
-
*/
|
|
51
|
-
publish?: { redaction: "applied" | "none" };
|
|
52
47
|
/** 项目名(来自 config.name),透传给 `niceeval view` 顶部 hero 显示。 */
|
|
53
48
|
name?: LocalizedText;
|
|
54
49
|
}
|
|
@@ -130,8 +125,6 @@ export interface Snapshot {
|
|
|
130
125
|
dir: string;
|
|
131
126
|
/** 写入时刻该实验已知的 eval 并集(可选);copySnapshots 自动补记,writer.snapshot() 也可声明。 */
|
|
132
127
|
knownEvalIds?: string[];
|
|
133
|
-
/** 发布拷贝的自描述标记(见 SnapshotMeta.publish);本地事实根没有此字段。 */
|
|
134
|
-
publish?: { redaction: "applied" | "none" };
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
/** 一个实验的全部历史:同一 experiment id 的历次快照归在一起。 */
|
|
@@ -167,6 +160,12 @@ export interface SkippedDir {
|
|
|
167
160
|
|
|
168
161
|
/** openResults 的返回:experiments 分层;skipped 不静默丢。 */
|
|
169
162
|
export interface Results {
|
|
163
|
+
/**
|
|
164
|
+
* 结果根目录的绝对路径(`openResults()` 入参解析后的原样值,不论传入的是结果根、
|
|
165
|
+
* 实验目录、快照目录还是某个 snapshot.json)。`unreadable-snapshot` 警告拼版本化
|
|
166
|
+
* `command`(`npx niceeval@<version> show --results <root>`)时取它。
|
|
167
|
+
*/
|
|
168
|
+
root: string;
|
|
170
169
|
/** 每个实验一项,挂着自己的全部历史(id 字典序)。 */
|
|
171
170
|
experiments: Experiment[];
|
|
172
171
|
skipped: SkippedDir[];
|
|
@@ -244,6 +243,27 @@ export type ScopeWarning =
|
|
|
244
243
|
message: string;
|
|
245
244
|
/** 一条可复制即跑的推进命令:`niceeval exp <experimentId>`。 */
|
|
246
245
|
command: string;
|
|
246
|
+
}
|
|
247
|
+
| {
|
|
248
|
+
/**
|
|
249
|
+
* 扫描结果根遇到的不可读快照:schema 不兼容、JSON 损坏 / 必需字段错误(malformed)、
|
|
250
|
+
* attempt 已写入但缺 `snapshot.json`(incomplete)。该快照被跳过,不挡其余结果
|
|
251
|
+
* (非 niceeval JSON 静默忽略,不产生这个 kind)。非实验作用域(没有 experimentId
|
|
252
|
+
* 字段) —— `Scope.filter()` 修剪时恒保留。
|
|
253
|
+
*/
|
|
254
|
+
kind: "unreadable-snapshot";
|
|
255
|
+
/** 该快照目录的绝对路径。 */
|
|
256
|
+
dir: string;
|
|
257
|
+
/** 与 `SkippedDir.reason` 同一取值集,原样透传。 */
|
|
258
|
+
reason: "incompatible-version" | "malformed" | "incomplete";
|
|
259
|
+
message: string;
|
|
260
|
+
/**
|
|
261
|
+
* 只有 reason 为 `incompatible-version` 且能确定是 niceeval 自己产出(`producer.name
|
|
262
|
+
* === "niceeval"` 且带 `producer.version`)时给出:`npx niceeval@<version> show --results
|
|
263
|
+
* <root>`。第三方 producer、版本信息缺失,或 reason 为 malformed / incomplete 时省略——
|
|
264
|
+
* 这些情况没有单条命令能解决,message 改给定位动作。
|
|
265
|
+
*/
|
|
266
|
+
command?: string;
|
|
247
267
|
};
|
|
248
268
|
|
|
249
269
|
/** dedupeAttempts 的警告:身份键缺 startedAt,宁可不去重也不误删。 */
|
package/src/runner/attempt.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { resolve as resolvePath } from "node:path";
|
|
7
7
|
import { readFile as readSourceFile } from "node:fs/promises";
|
|
8
8
|
import { Effect, Cause, Duration } from "effect";
|
|
9
|
-
import { createSandbox, resolveSandbox
|
|
9
|
+
import { createSandbox, resolveSandbox } from "../sandbox/resolve.ts";
|
|
10
10
|
import { stopSandbox, unregisterSandbox } from "../sandbox/registry.ts";
|
|
11
11
|
import { KEEPABLE_PROVIDERS, nativeEnterCommand, suspendSandbox } from "../sandbox/keep.ts";
|
|
12
12
|
import { keptEntryId, updateKeptEntry, writeKeptEntry } from "../sandbox/keep-registry.ts";
|
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
import { reportAttemptLifecycle, reportDiagnostic, reportKept } from "./feedback/sink.ts";
|
|
52
52
|
import { encodeAttemptKey, runWho } from "./types.ts";
|
|
53
53
|
import { commandDisplay, commandNode, createTimingRecorder, type TimingRecorder } from "./timing.ts";
|
|
54
|
+
import { sandboxForEval, sandboxProjection } from "./sandbox-selection.ts";
|
|
54
55
|
import type {
|
|
55
56
|
AgentRun,
|
|
56
57
|
Attempt,
|
|
@@ -81,7 +82,7 @@ export function runAttemptEffect(
|
|
|
81
82
|
id: evalDef.id,
|
|
82
83
|
description: evalDef.description,
|
|
83
84
|
experimentId: run.experimentId,
|
|
84
|
-
experiment: experimentRunInfo(run),
|
|
85
|
+
experiment: experimentRunInfo(run, config.sandbox),
|
|
85
86
|
agent: run.agent.name,
|
|
86
87
|
model: run.model,
|
|
87
88
|
verdict: "errored",
|
|
@@ -178,9 +179,9 @@ export function runAttemptEffect(
|
|
|
178
179
|
|
|
179
180
|
return Effect.scoped(
|
|
180
181
|
Effect.gen(function* () {
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
const sandboxSpec =
|
|
182
|
+
// 规划期按当前 eval 解析出的同一个 SandboxSpec,既用来起 provider,也作为
|
|
183
|
+
// sandbox.setup / sandbox.teardown 钩子(SandboxSpec.setup()/.teardown() 链式挂的)来源。
|
|
184
|
+
const sandboxSpec = a.sandboxSpec ?? sandboxForEval(run, evalDef, config.sandbox);
|
|
184
185
|
// defineSandbox 自定义 provider 不参与留存(事后命令不执行用户项目代码,新进程无法安全
|
|
185
186
|
// 找回用户对象上的 stopDetached);组合使用在创建沙箱前报清晰错误。
|
|
186
187
|
if (
|
|
@@ -809,7 +810,7 @@ async function runAttemptBody(
|
|
|
809
810
|
id: evalDef.id,
|
|
810
811
|
description: evalDef.description,
|
|
811
812
|
experimentId: run.experimentId,
|
|
812
|
-
experiment: experimentRunInfo(run),
|
|
813
|
+
experiment: experimentRunInfo(run, config.sandbox),
|
|
813
814
|
agent: run.agent.name,
|
|
814
815
|
model: run.model,
|
|
815
816
|
verdict,
|
|
@@ -832,7 +833,7 @@ async function runAttemptBody(
|
|
|
832
833
|
...(usesSandbox
|
|
833
834
|
? {
|
|
834
835
|
sandbox: {
|
|
835
|
-
provider: resolveSandbox(
|
|
836
|
+
provider: resolveSandbox(a.sandboxSpec ?? sandboxForEval(run, evalDef, config.sandbox)).provider,
|
|
836
837
|
sandboxId: sandbox.sandboxId,
|
|
837
838
|
},
|
|
838
839
|
}
|
|
@@ -1016,7 +1017,7 @@ async function collectSources(
|
|
|
1016
1017
|
|
|
1017
1018
|
/** 解析后运行配置的穷尽投影(ExperimentRunInfo,见 docs/feature/results/architecture.md):
|
|
1018
1019
|
* agent/model 只在快照顶层,这里不复制;sandbox 只经 provider 的公开参数投影落盘。 */
|
|
1019
|
-
function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
|
|
1020
|
+
function experimentRunInfo(run: AgentRun, configSandbox?: Config["sandbox"]): EvalResult["experiment"] {
|
|
1020
1021
|
return {
|
|
1021
1022
|
...(run.description !== undefined ? { description: run.description } : {}),
|
|
1022
1023
|
...(run.reasoningEffort !== undefined ? { reasoningEffort: run.reasoningEffort } : {}),
|
|
@@ -1028,7 +1029,7 @@ function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
|
|
|
1028
1029
|
...(run.maxConcurrency !== undefined ? { maxConcurrency: run.maxConcurrency } : {}),
|
|
1029
1030
|
selectedEvalIds: run.selectedEvalIds ?? [],
|
|
1030
1031
|
...(run.evalFilterFingerprint !== undefined ? { evalFilterFingerprint: run.evalFilterFingerprint } : {}),
|
|
1031
|
-
...(run
|
|
1032
|
+
...sandboxProjection(run, configSandbox),
|
|
1032
1033
|
};
|
|
1033
1034
|
}
|
|
1034
1035
|
|
|
@@ -6,7 +6,7 @@ import { describe, expect, it, afterEach } from "vitest";
|
|
|
6
6
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { discoverEvals } from "./discover.ts";
|
|
9
|
+
import { discoverEvals, makeFilter } from "./discover.ts";
|
|
10
10
|
import { captureEvalSource } from "./eval-source.ts";
|
|
11
11
|
|
|
12
12
|
const roots: string[] = [];
|
|
@@ -20,6 +20,14 @@ afterEach(async () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
describe("discoverEvals · 源码捕获", () => {
|
|
23
|
+
// bug: memory/exp-eval-prefix-segment-drift.md
|
|
24
|
+
it("eval 位置参数按裸字面前缀命中 sibling,不要求路径段边界", () => {
|
|
25
|
+
const filter = makeFilter(["memory/terminal-swe-bench"]);
|
|
26
|
+
expect(filter("memory/terminal-swe-bench-astropy-1")).toBe(true);
|
|
27
|
+
expect(filter("memory/terminal-swe-bench-astropy-2")).toBe(true);
|
|
28
|
+
expect(filter("memory/other")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
23
31
|
it("单个默认导出:source 与 captureEvalSource() 直接调出来的一致(路径/内容/哈希)", async () => {
|
|
24
32
|
const root = await makeRoot();
|
|
25
33
|
await mkdir(join(root, "evals"), { recursive: true });
|
package/src/runner/discover.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join, relative, sep } from "node:path";
|
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { pad4 } from "../util.ts";
|
|
8
8
|
import { captureEvalSource } from "./eval-source.ts";
|
|
9
|
+
import { evalPrefixPredicate } from "../shared/aggregate.ts";
|
|
9
10
|
import type { DiscoveredEval, DiscoveredExperiment, EvalDef, ExperimentDef } from "../types.ts";
|
|
10
11
|
|
|
11
12
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".niceeval", "dist", ".next"]);
|
|
@@ -108,8 +109,7 @@ export async function discoverExperiments(root: string): Promise<DiscoveredExper
|
|
|
108
109
|
return out;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
/** id
|
|
112
|
+
/** eval id 的裸字面前缀过滤;exp / show / view 共用 shared helper,避免路径段语义漂移。 */
|
|
112
113
|
export function makeFilter(patterns: string[]): (id: string) => boolean {
|
|
113
|
-
|
|
114
|
-
return (id) => patterns.some((p) => id === p || id.startsWith(p + "/"));
|
|
114
|
+
return evalPrefixPredicate(patterns.length > 0 ? patterns : undefined);
|
|
115
115
|
}
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
|
-
import {
|
|
7
|
-
import type { DiscoveredEval, EvalResult } from "../types.ts";
|
|
6
|
+
import { sandboxRunInfo } from "../sandbox/resolve.ts";
|
|
7
|
+
import type { DiscoveredEval, EvalResult, SandboxOption } from "../types.ts";
|
|
8
8
|
import type { AgentRun } from "./types.ts";
|
|
9
|
+
import { prepareRunSandboxes, sandboxForEval } from "./sandbox-selection.ts";
|
|
9
10
|
|
|
10
11
|
export function cacheKey(run: AgentRun, evalId: string): string {
|
|
11
12
|
return `${run.experimentId ?? ""}|${evalId}`;
|
|
@@ -19,6 +20,7 @@ export async function computeFingerprint(
|
|
|
19
20
|
evalDef: DiscoveredEval,
|
|
20
21
|
run: AgentRun,
|
|
21
22
|
sourceCache?: Map<string, Promise<string>>,
|
|
23
|
+
configSandbox?: SandboxOption,
|
|
22
24
|
): Promise<string> {
|
|
23
25
|
let sourcePromise = sourceCache?.get(evalDef.sourcePath);
|
|
24
26
|
if (!sourcePromise) {
|
|
@@ -31,6 +33,7 @@ export async function computeFingerprint(
|
|
|
31
33
|
eval: {
|
|
32
34
|
id: evalDef.id,
|
|
33
35
|
tags: evalDef.tags ?? [],
|
|
36
|
+
environment: evalDef.environment,
|
|
34
37
|
metadata: evalDef.metadata ?? {},
|
|
35
38
|
timeoutMs: evalDef.timeoutMs,
|
|
36
39
|
},
|
|
@@ -39,7 +42,7 @@ export async function computeFingerprint(
|
|
|
39
42
|
agent: run.agent.name,
|
|
40
43
|
model: run.model,
|
|
41
44
|
flags: run.flags,
|
|
42
|
-
sandbox: run
|
|
45
|
+
sandbox: sandboxRunInfo(sandboxForEval(run, evalDef, configSandbox)),
|
|
43
46
|
timeoutMs: run.timeoutMs,
|
|
44
47
|
strict: run.strict,
|
|
45
48
|
},
|
|
@@ -66,14 +69,16 @@ export async function planCarry(
|
|
|
66
69
|
evals: DiscoveredEval[],
|
|
67
70
|
agentRuns: AgentRun[],
|
|
68
71
|
priorResults: EvalResult[] | undefined,
|
|
72
|
+
configSandbox?: SandboxOption,
|
|
69
73
|
): Promise<CarryPlan> {
|
|
74
|
+
prepareRunSandboxes(evals, agentRuns, configSandbox);
|
|
70
75
|
const sourceCache = new Map<string, Promise<string>>();
|
|
71
76
|
const plannedFingerprints = new Map<string, string>();
|
|
72
77
|
const jobs: Promise<void>[] = [];
|
|
73
78
|
for (const run of agentRuns) {
|
|
74
79
|
for (const evalDef of evals.filter((e) => run.evalFilter(e.id))) {
|
|
75
80
|
jobs.push(
|
|
76
|
-
computeFingerprint(evalDef, run, sourceCache).then((fp) => {
|
|
81
|
+
computeFingerprint(evalDef, run, sourceCache, configSandbox).then((fp) => {
|
|
77
82
|
plannedFingerprints.set(cacheKey(run, evalDef.id), fp);
|
|
78
83
|
}),
|
|
79
84
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// cases: docs/engineering/unit-tests/
|
|
1
|
+
// cases: docs/engineering/unit-tests/sandbox/cases.md
|
|
2
2
|
// 变更分类账的集成测试:用宿主 shell 扮演沙箱(真实 git),验证
|
|
3
3
|
// - .git 不在 workdir 内(agent 看不到分类账;eval 自己 git init 不冲突)
|
|
4
4
|
// - eval 归因(send 前写入)不进 agent diff;send 窗口内写入逐窗口归因
|
|
@@ -153,6 +153,10 @@ describe("createChangeLedger", () => {
|
|
|
153
153
|
await mkdir(join(workdir, "node_modules"), { recursive: true });
|
|
154
154
|
await writeFile(join(workdir, "node_modules", "dep.js"), "excluded\n");
|
|
155
155
|
await writeFile(join(workdir, "node_modules", "keep.js"), "included back\n");
|
|
156
|
+
await mkdir(join(workdir, "packages/app/node_modules/dep"), { recursive: true });
|
|
157
|
+
await writeFile(join(workdir, "packages/app/node_modules/dep/index.js"), "nested dependency\n");
|
|
158
|
+
await mkdir(join(workdir, "packages/app/__pycache__"), { recursive: true });
|
|
159
|
+
await writeFile(join(workdir, "packages/app/__pycache__/mod.pyc"), "nested cache\n");
|
|
156
160
|
await mkdir(join(workdir, "secret"), { recursive: true });
|
|
157
161
|
await writeFile(join(workdir, "secret", "token.txt"), "excluded via ignore\n");
|
|
158
162
|
// Python 工具链目录不依赖项目 .gitignore:任意 *venv*/ 名字都由 runner 私有清单排除。
|
|
@@ -167,10 +171,35 @@ describe("createChangeLedger", () => {
|
|
|
167
171
|
expect(paths).toContain("output.txt");
|
|
168
172
|
expect(paths).toContain("node_modules/keep.js");
|
|
169
173
|
expect(paths).not.toContain("node_modules/dep.js");
|
|
174
|
+
expect(paths).not.toContain("packages/app/node_modules/dep/index.js");
|
|
175
|
+
expect(paths).not.toContain("packages/app/__pycache__/mod.pyc");
|
|
170
176
|
expect(paths).not.toContain("secret/token.txt");
|
|
171
177
|
expect(paths.some((path) => path.includes("venv"))).toBe(false);
|
|
172
178
|
});
|
|
173
179
|
|
|
180
|
+
// bug: memory/ledger-gitignore-pathspec-and-gitlinks.md
|
|
181
|
+
it("未排除的 nested repo 明确失败;整目录 ignore 后允许作为无关环境存在", async () => {
|
|
182
|
+
const first = await makeDirs();
|
|
183
|
+
const checkout = join(first.workdir, "checkout");
|
|
184
|
+
await mkdir(checkout, { recursive: true });
|
|
185
|
+
await execAsync("git init -q && git config user.email t@t && git config user.name t", { cwd: checkout });
|
|
186
|
+
await writeFile(join(checkout, "app.py"), "print('hello')\n");
|
|
187
|
+
await execAsync("git add app.py && git commit -qm baseline", { cwd: checkout });
|
|
188
|
+
|
|
189
|
+
await expect(createChangeLedger(hostSandbox(first.workdir, first.ledgerDir))).rejects.toThrow(
|
|
190
|
+
/nested Git repository checkout.*sandbox\.workdir root.*diff.*ignore/,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const second = await makeDirs();
|
|
194
|
+
const ignoredCheckout = join(second.workdir, "checkout");
|
|
195
|
+
await mkdir(ignoredCheckout, { recursive: true });
|
|
196
|
+
await execAsync("git init -q && git config user.email t@t && git config user.name t", { cwd: ignoredCheckout });
|
|
197
|
+
await writeFile(join(ignoredCheckout, "app.py"), "print('ignored')\n");
|
|
198
|
+
await execAsync("git add app.py && git commit -qm baseline", { cwd: ignoredCheckout });
|
|
199
|
+
|
|
200
|
+
await expect(createChangeLedger(hostSandbox(second.workdir, second.ledgerDir), { ignore: ["checkout/"] })).resolves.toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
174
203
|
it("整相导出只用一条 shell 命令 + 一次文件下载,不随文件数与窗口数增长", async () => {
|
|
175
204
|
const { workdir, ledgerDir } = await makeDirs();
|
|
176
205
|
const counters = { shells: [] as string[], downloads: [] as string[] };
|
package/src/runner/ledger.ts
CHANGED
|
@@ -123,6 +123,23 @@ function shellQuote(s: string): string {
|
|
|
123
123
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 把仓库根语义的 gitignore 风格规则编译成 ledger pathspec。
|
|
128
|
+
* 无斜杠的名字匹配任意深度;有斜杠的规则相对 workdir 根;目录本身与后代一起处理。
|
|
129
|
+
*/
|
|
130
|
+
function gitignorePathspecs(pattern: string, exclude: boolean): string[] {
|
|
131
|
+
let normalized = pattern;
|
|
132
|
+
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
|
133
|
+
if (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
134
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
135
|
+
if (!normalized) return [];
|
|
136
|
+
|
|
137
|
+
const glob = normalized.includes("/") ? normalized : `**/${normalized}`;
|
|
138
|
+
const globs = exclude && !glob.endsWith("/**") ? [glob, `${glob}/**`] : [glob];
|
|
139
|
+
const magic = exclude ? ":(glob,exclude)" : ":(glob)";
|
|
140
|
+
return [...new Set(globs)].map((value) => `${magic}${value}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/** 打分类账锚点(workspace.baseline 阶段,环境层钩子之后):git init + 冻结排除清单 + 首笔 commit。 */
|
|
127
144
|
export async function createChangeLedger(sandbox: Sandbox, opts?: LedgerOptions): Promise<ChangeLedger> {
|
|
128
145
|
const excludes = [...DEFAULT_EXCLUDES, ...(opts?.ignore ?? [])];
|
|
@@ -131,11 +148,15 @@ export async function createChangeLedger(sandbox: Sandbox, opts?: LedgerOptions)
|
|
|
131
148
|
|
|
132
149
|
// add -A -f:绕过项目自己的 .gitignore(项目 ignore 的文件照常记录);排除靠 pathspec
|
|
133
150
|
// (runner 私有清单,agent / fixture 写 .gitignore 影响不了它);include 用第二次 add 打洞加回。
|
|
134
|
-
const excludeSpecs = excludes.
|
|
151
|
+
const excludeSpecs = excludes.flatMap((pattern) => gitignorePathspecs(pattern, true)).map(shellQuote).join(" ");
|
|
135
152
|
// include 打洞:路径此刻可能还不存在(如 agent 之后才写),unmatched pathspec 不算错。
|
|
153
|
+
const includeSpecs = includes.flatMap((pattern) => gitignorePathspecs(pattern, false)).map(shellQuote).join(" ");
|
|
136
154
|
const includeAdd =
|
|
137
|
-
|
|
138
|
-
const
|
|
155
|
+
includeSpecs.length > 0 ? ` && { git -c advice.addEmbeddedRepo=false add -A -f -- ${includeSpecs} 2>/dev/null || true; }` : "";
|
|
156
|
+
const rejectGitlinks =
|
|
157
|
+
" && nested=$(git ls-files --stage | awk '$1 == \"160000\" { sub(/^[^\\t]*\\t/, \"\"); print; exit }')" +
|
|
158
|
+
' && if [ -n "$nested" ]; then printf \'%s\\n\' "niceeval ledger cannot track nested Git repository $nested as file-level evidence; move the checkout to sandbox.workdir root, or add the whole path to defineEval({ diff: { ignore: [...] } }) when it is intentionally out of scope" >&2; exit 2; fi';
|
|
159
|
+
const addAll = `git -c advice.addEmbeddedRepo=false add -A -f -- . ${excludeSpecs}${includeAdd}${rejectGitlinks}`;
|
|
139
160
|
|
|
140
161
|
const anchor = await sandbox.runShell(`git init -q "${LEDGER_GIT_DIR}" && ${addAll} && git commit -q --allow-empty -m "anchor"`, {
|
|
141
162
|
env,
|
|
@@ -170,7 +191,8 @@ async function exportAgentWindows(sandbox: Sandbox, env: Record<string, string>)
|
|
|
170
191
|
|
|
171
192
|
function ensureCommandSucceeded(result: { exitCode: number; stderr: string }, operation: string): void {
|
|
172
193
|
if (result.exitCode === 0) return;
|
|
173
|
-
|
|
194
|
+
// git 可能先输出 advisory warning,再输出 niceeval 的可操作诊断;最后一行最接近失败根因。
|
|
195
|
+
const detail = result.stderr.trim().split("\n").at(-1);
|
|
174
196
|
throw new Error(`${operation} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
|
|
175
197
|
}
|
|
176
198
|
|