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.
Files changed (143) hide show
  1. package/INDEX.md +77 -45
  2. package/dist/agents/types.d.ts +28 -6
  3. package/dist/i18n/en.d.ts +2 -0
  4. package/dist/i18n/zh-CN.d.ts +2 -0
  5. package/dist/report/built-in/index.d.ts +3 -2
  6. package/dist/report/built-in/index.js +7 -8
  7. package/dist/report/built-in/standard.d.ts +1 -0
  8. package/dist/report/built-in/standard.js +30 -0
  9. package/dist/report/components.d.ts +72 -6
  10. package/dist/report/components.js +159 -10
  11. package/dist/report/compute.d.ts +31 -4
  12. package/dist/report/compute.js +131 -12
  13. package/dist/report/index.d.ts +4 -4
  14. package/dist/report/index.js +3 -2
  15. package/dist/report/locale.d.ts +39 -1
  16. package/dist/report/locale.js +69 -0
  17. package/dist/report/react/AttemptList.d.ts +3 -1
  18. package/dist/report/react/AttemptList.js +3 -3
  19. package/dist/report/react/CopyFixPrompt.d.ts +12 -0
  20. package/dist/report/react/CopyFixPrompt.js +12 -0
  21. package/dist/report/react/HeroCard.d.ts +13 -0
  22. package/dist/report/react/HeroCard.js +35 -0
  23. package/dist/report/react/PoweredBy.d.ts +5 -0
  24. package/dist/report/react/PoweredBy.js +7 -0
  25. package/dist/report/react/ScopeWarnings.d.ts +12 -0
  26. package/dist/report/react/ScopeWarnings.js +18 -0
  27. package/dist/report/react/TraceWaterfall.d.ts +14 -0
  28. package/dist/report/react/TraceWaterfall.js +22 -0
  29. package/dist/report/react/index.d.ts +6 -1
  30. package/dist/report/react/index.js +6 -0
  31. package/dist/report/report.d.ts +22 -6
  32. package/dist/report/report.js +66 -53
  33. package/dist/report/scope-warnings.d.ts +28 -0
  34. package/dist/report/scope-warnings.js +101 -0
  35. package/dist/report/text/faces.d.ts +19 -1
  36. package/dist/report/text/faces.js +61 -0
  37. package/dist/report/tree.js +6 -1
  38. package/dist/report/types.d.ts +45 -10
  39. package/dist/report/web.d.ts +5 -4
  40. package/dist/report/web.js +7 -22
  41. package/dist/results/select.d.ts +15 -2
  42. package/dist/results/select.js +75 -10
  43. package/dist/results/types.d.ts +26 -11
  44. package/dist/runner/fingerprint.d.ts +3 -3
  45. package/dist/runner/sandbox-selection.d.ts +12 -0
  46. package/dist/runner/types.d.ts +18 -6
  47. package/dist/sandbox/types.d.ts +12 -0
  48. package/docs-site/zh/explanation/evals.mdx +2 -1
  49. package/docs-site/zh/explanation/experiment.mdx +2 -0
  50. package/docs-site/zh/how-to/custom-reports.mdx +22 -6
  51. package/docs-site/zh/how-to/publish-report.mdx +6 -8
  52. package/docs-site/zh/how-to/viewing-results.mdx +3 -3
  53. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  54. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  55. package/docs-site/zh/reference/cli.mdx +1 -2
  56. package/docs-site/zh/reference/define-eval.mdx +8 -0
  57. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  58. package/docs-site/zh/reference/report-components.mdx +2 -2
  59. package/docs-site/zh/reference/results-data.mdx +2 -4
  60. package/package.json +2 -1
  61. package/src/agents/bub.ts +13 -1
  62. package/src/agents/claude-code.test.ts +43 -1
  63. package/src/agents/claude-code.ts +32 -14
  64. package/src/agents/codex.test.ts +168 -1
  65. package/src/agents/codex.ts +51 -15
  66. package/src/agents/mcp.ts +31 -0
  67. package/src/agents/post-setup.ts +33 -0
  68. package/src/agents/types.ts +28 -7
  69. package/src/cli.ts +9 -11
  70. package/src/context/context.ts +11 -5
  71. package/src/define.ts +3 -0
  72. package/src/i18n/en.ts +5 -2
  73. package/src/i18n/zh-CN.ts +5 -1
  74. package/src/index.ts +1 -0
  75. package/src/report/built-in/index.tsx +8 -7
  76. package/src/report/built-in/standard.tsx +59 -0
  77. package/src/report/components.tsx +231 -17
  78. package/src/report/compute.ts +146 -21
  79. package/src/report/dual-render.test.tsx +139 -12
  80. package/src/report/index.ts +20 -1
  81. package/src/report/locale.ts +83 -1
  82. package/src/report/react/AttemptList.tsx +13 -1
  83. package/src/report/react/CopyFixPrompt.tsx +37 -0
  84. package/src/report/react/HeroCard.tsx +59 -0
  85. package/src/report/react/PoweredBy.tsx +20 -0
  86. package/src/report/react/ScopeWarnings.tsx +74 -0
  87. package/src/report/react/TraceWaterfall.tsx +78 -0
  88. package/src/report/react/enhance.js +14 -0
  89. package/src/report/react/index.tsx +11 -0
  90. package/src/report/react/styles.css +187 -7
  91. package/src/report/report.test.ts +2 -20
  92. package/src/report/report.ts +97 -62
  93. package/src/report/scope-warnings.ts +155 -0
  94. package/src/report/site-components.test.tsx +526 -0
  95. package/src/report/text/faces.ts +66 -0
  96. package/src/report/tree.ts +8 -1
  97. package/src/report/types.ts +51 -11
  98. package/src/report/web.ts +7 -40
  99. package/src/results/copy.ts +15 -78
  100. package/src/results/host-equivalence.test.ts +5 -1
  101. package/src/results/open.ts +5 -4
  102. package/src/results/publish.ts +4 -146
  103. package/src/results/results.test.ts +86 -9
  104. package/src/results/select.ts +78 -10
  105. package/src/results/types.ts +27 -7
  106. package/src/runner/attempt.ts +10 -9
  107. package/src/runner/discover.test.ts +9 -1
  108. package/src/runner/discover.ts +3 -3
  109. package/src/runner/fingerprint.ts +9 -4
  110. package/src/runner/ledger.test.ts +30 -1
  111. package/src/runner/ledger.ts +26 -4
  112. package/src/runner/run.ts +5 -1
  113. package/src/runner/sandbox-selection.test.ts +131 -0
  114. package/src/runner/sandbox-selection.ts +110 -0
  115. package/src/runner/types.ts +19 -2
  116. package/src/sandbox/types.ts +6 -0
  117. package/src/show/index.ts +17 -10
  118. package/src/show/render.ts +11 -11
  119. package/src/show/report-host.test.ts +32 -15
  120. package/src/show/report-host.ts +5 -4
  121. package/src/show/show.test.ts +140 -3
  122. package/src/view/app/App.test.tsx +78 -17
  123. package/src/view/app/App.tsx +17 -78
  124. package/src/view/app/components/CopyControls.tsx +4 -42
  125. package/src/view/app/i18n.ts +5 -227
  126. package/src/view/app/lib/rows.ts +3 -21
  127. package/src/view/app/shared.ts +1 -3
  128. package/src/view/app/types.ts +2 -2
  129. package/src/view/artifact-serving.test.ts +1 -1
  130. package/src/view/client-dist/app.css +1 -1
  131. package/src/view/client-dist/app.js +20 -20
  132. package/src/view/data.ts +2 -13
  133. package/src/view/index.ts +1 -12
  134. package/src/view/server.ts +0 -2
  135. package/src/view/shared/types.ts +11 -6
  136. package/src/view/site-parity.test.ts +1 -1
  137. package/src/view/site.ts +1 -1
  138. package/src/view/styles.css +6 -266
  139. package/src/view/view-report.test.ts +64 -27
  140. package/src/view/app/components/LazyArtifact.tsx +0 -51
  141. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  142. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  143. 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, { ...{ artifacts: ["agentSetup"] }, redact: false });
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, { ...{ artifacts: ["events"] }, redact: false });
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, { redact: false })).rejects.toThrow(/not empty/);
854
+ await expect(copySnapshots(results.latest(), occupied)).rejects.toThrow(/not empty/);
779
855
 
780
- await expect(copySnapshots(results.latest(), join(await makeRoot(), "out"), { ...{ artifacts: ["evnets" as never] }, redact: false })).rejects.toThrow(/Unknown artifact kind/);
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"), { redact: false })).rejects.toThrow(/no snapshots/);
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, { redact: false });
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, { ...{ artifacts: [] }, redact: false });
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, { ...{ artifacts: ["sources"] }, redact: false });
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"));
@@ -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
- /** Results.latest() 的实现:每个实验取最新一次快照(= exp.snapshots[0]),生成挑选警告。 */
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
- experiments: Experiment[],
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
- /** stale 警告的人话时距:选粒度最大的单位,四舍五入。 */
319
- function humanizeGap(fromIso: string, toIso: string): string {
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 `${seconds} second${seconds === 1 ? "" : "s"}`;
386
+ if (seconds < 90) return { n: seconds, unit: "second" };
323
387
  const minutes = Math.round(seconds / 60);
324
- if (minutes < 90) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
388
+ if (minutes < 90) return { n: minutes, unit: "minute" };
325
389
  const hours = Math.round(minutes / 60);
326
- if (hours < 36) return `${hours} hour${hours === 1 ? "" : "s"}`;
327
- const days = Math.round(hours / 24);
328
- return `${days} day${days === 1 ? "" : "s"}`;
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
  }
@@ -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,宁可不去重也不误删。 */
@@ -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, sandboxRunInfo } from "../sandbox/resolve.ts";
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
- // run.sandbox ?? config.sandbox 是同一个 SandboxSpec 对象,既用来起沙箱 provider,
182
- // 也是 sandbox.setup / sandbox.teardown 钩子(SandboxSpec.setup()/.teardown() 链式挂的)的来源。
183
- const sandboxSpec = run.sandbox ?? config.sandbox;
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(run.sandbox ?? config.sandbox).provider,
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.sandbox !== undefined ? { sandbox: sandboxRunInfo(run.sandbox) } : {}),
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 });
@@ -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 前缀过滤:精确匹配或目录前缀(weather 命中 weather weather/*)。 */
112
+ /** eval id 的裸字面前缀过滤;exp / show / view 共用 shared helper,避免路径段语义漂移。 */
112
113
  export function makeFilter(patterns: string[]): (id: string) => boolean {
113
- if (patterns.length === 0) return () => true;
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 { sandboxLabel } from "../sandbox/resolve.ts";
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.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
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/experiments-runner/cases.md
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[] };
@@ -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.map((e) => shellQuote(`:(exclude)${e}`)).join(" ");
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
- includes.length > 0 ? ` && { git add -A -f -- ${includes.map(shellQuote).join(" ")} 2>/dev/null || true; }` : "";
138
- const addAll = `git add -A -f -- . ${excludeSpecs}${includeAdd}`;
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
- const detail = result.stderr.trim().split("\n")[0];
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