niceeval 0.5.4 → 0.6.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 (342) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +5 -5
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +2 -2
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -1
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +3 -3
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  146. package/docs-site/zh/guides/{sandbox-backends.mdx → sandbox-providers.mdx} +11 -11
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +9 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +12 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +129 -42
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +9 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +54 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/control-flow.ts +1 -1
  178. package/src/context/session.test.ts +92 -1
  179. package/src/context/session.ts +23 -1
  180. package/src/context/types.ts +4 -4
  181. package/src/define.test.ts +8 -8
  182. package/src/define.ts +11 -11
  183. package/src/expect/index.ts +5 -5
  184. package/src/i18n/en.ts +34 -15
  185. package/src/i18n/zh-CN.ts +37 -21
  186. package/src/index.ts +1 -1
  187. package/src/o11y/execution-tree.test.ts +452 -0
  188. package/src/o11y/execution-tree.ts +367 -0
  189. package/src/o11y/otlp/receiver.ts +1 -1
  190. package/src/o11y/otlp/select.ts +7 -3
  191. package/src/o11y/parsers/claude-code.test.ts +175 -0
  192. package/src/o11y/parsers/claude-code.ts +32 -0
  193. package/src/o11y/types.ts +6 -1
  194. package/src/report/aggregate.ts +26 -20
  195. package/src/report/built-in-user-parity.test.tsx +643 -0
  196. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  197. package/src/report/built-ins/index.ts +2 -0
  198. package/src/report/components.tsx +223 -50
  199. package/src/report/compute.ts +358 -53
  200. package/src/report/dual-render.test.tsx +1125 -0
  201. package/src/report/flag.ts +1 -1
  202. package/src/report/format.ts +50 -2
  203. package/src/report/index.ts +48 -14
  204. package/src/report/load.ts +1 -1
  205. package/src/report/locale.ts +206 -0
  206. package/src/report/metrics.ts +39 -15
  207. package/src/report/primitives.tsx +100 -2
  208. package/src/report/react/AttemptList.tsx +115 -0
  209. package/src/report/react/DeltaTable.tsx +9 -6
  210. package/src/report/react/EvalList.tsx +0 -0
  211. package/src/report/react/ExperimentList.tsx +108 -0
  212. package/src/report/react/GroupSummary.tsx +66 -0
  213. package/src/report/react/MetricBars.tsx +24 -13
  214. package/src/report/react/MetricLine.tsx +21 -21
  215. package/src/report/react/MetricMatrix.tsx +8 -4
  216. package/src/report/react/MetricScatter.tsx +136 -96
  217. package/src/report/react/MetricTable.tsx +115 -15
  218. package/src/report/react/RunOverview.tsx +48 -26
  219. package/src/report/react/Scoreboard.tsx +15 -7
  220. package/src/report/react/cell.tsx +18 -9
  221. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  222. package/src/report/react/colors.ts +24 -15
  223. package/src/report/react/enhance.js +129 -0
  224. package/src/report/react/fixtures.ts +163 -34
  225. package/src/report/react/format.ts +1 -1
  226. package/src/report/react/index.tsx +22 -6
  227. package/src/report/react/render.test.tsx +164 -31
  228. package/src/report/react/styles.css +405 -213
  229. package/src/report/report.test.ts +403 -94
  230. package/src/report/report.ts +29 -11
  231. package/src/report/text/faces.ts +303 -95
  232. package/src/report/text/layout.ts +76 -11
  233. package/src/report/text/table.ts +98 -0
  234. package/src/report/tree.ts +103 -20
  235. package/src/report/types.ts +196 -25
  236. package/src/report/web.ts +46 -19
  237. package/src/results/annotated-source.test.ts +146 -0
  238. package/src/results/annotated-source.ts +128 -0
  239. package/src/results/attempt-evidence.test.ts +158 -0
  240. package/src/results/attempt-evidence.ts +158 -0
  241. package/src/results/attempt-source.ts +48 -0
  242. package/src/results/copy.ts +154 -139
  243. package/src/results/format.ts +54 -47
  244. package/src/results/host-equivalence.test.ts +527 -0
  245. package/src/results/index.ts +56 -14
  246. package/src/results/locator.test.ts +198 -0
  247. package/src/results/locator.ts +0 -0
  248. package/src/results/open.ts +392 -175
  249. package/src/results/results.test.ts +943 -494
  250. package/src/results/select.ts +129 -23
  251. package/src/results/skipped-notice.ts +0 -0
  252. package/src/results/source-hash.ts +28 -0
  253. package/src/results/types.ts +98 -62
  254. package/src/results/writer.ts +304 -176
  255. package/src/runner/attempt.test.ts +170 -0
  256. package/src/runner/attempt.ts +48 -13
  257. package/src/runner/discover.test.ts +65 -0
  258. package/src/runner/discover.ts +6 -2
  259. package/src/runner/eval-source.test.ts +121 -0
  260. package/src/runner/eval-source.ts +45 -0
  261. package/src/runner/fingerprint.ts +54 -1
  262. package/src/runner/remote-sandbox.ts +1 -1
  263. package/src/runner/report.test.ts +6 -8
  264. package/src/runner/report.ts +5 -4
  265. package/src/runner/reporters/artifacts.ts +27 -41
  266. package/src/runner/reporters/braintrust.test.ts +3 -3
  267. package/src/runner/reporters/braintrust.ts +1 -1
  268. package/src/runner/reporters/console.ts +6 -6
  269. package/src/runner/reporters/json.ts +4 -4
  270. package/src/runner/reporters/live.test.ts +56 -0
  271. package/src/runner/reporters/live.ts +36 -10
  272. package/src/runner/reporters/quiet.test.ts +66 -0
  273. package/src/runner/reporters/quiet.ts +49 -0
  274. package/src/runner/reporters/shared.ts +5 -5
  275. package/src/runner/reporters/table.ts +17 -17
  276. package/src/runner/run.ts +61 -102
  277. package/src/runner/types.ts +43 -32
  278. package/src/sandbox/checkpoint.ts +1 -1
  279. package/src/sandbox/docker.ts +15 -2
  280. package/src/sandbox/e2b.ts +11 -5
  281. package/src/sandbox/errors.ts +13 -0
  282. package/src/sandbox/index.ts +3 -3
  283. package/src/sandbox/local-files.ts +1 -1
  284. package/src/sandbox/registry.ts +6 -5
  285. package/src/sandbox/resolve.ts +38 -23
  286. package/src/sandbox/retry.test.ts +68 -0
  287. package/src/sandbox/retry.ts +44 -0
  288. package/src/sandbox/types.ts +24 -24
  289. package/src/sandbox/vercel.ts +17 -3
  290. package/src/scoring/collector.ts +3 -3
  291. package/src/scoring/judge.ts +2 -2
  292. package/src/scoring/scoped.ts +13 -1
  293. package/src/scoring/types.ts +7 -7
  294. package/src/scoring/verdict.ts +4 -4
  295. package/src/shared/aggregate.ts +23 -3
  296. package/src/shared/types.ts +2 -2
  297. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  298. package/src/show/compose.ts +12 -132
  299. package/src/show/index.ts +125 -94
  300. package/src/show/render.ts +660 -160
  301. package/src/show/show.test.ts +472 -253
  302. package/src/source-loc.ts +1 -1
  303. package/src/tty-line.ts +32 -0
  304. package/src/types.ts +2 -2
  305. package/src/util.test.ts +21 -1
  306. package/src/util.ts +5 -1
  307. package/src/view/app/App.tsx +33 -198
  308. package/src/view/app/components/AttemptModal.tsx +6 -6
  309. package/src/view/app/components/CodeView.tsx +2 -2
  310. package/src/view/app/components/CopyControls.tsx +32 -36
  311. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  312. package/src/view/app/components/ui/badge.tsx +1 -1
  313. package/src/view/app/components/ui/dialog.tsx +2 -2
  314. package/src/view/app/i18n.ts +40 -31
  315. package/src/view/app/lib/artifact-url.ts +1 -1
  316. package/src/view/app/lib/attempt-route.test.ts +37 -47
  317. package/src/view/app/lib/attempt-route.ts +26 -26
  318. package/src/view/app/lib/rows.ts +9 -112
  319. package/src/view/app/lib/verdict.ts +25 -0
  320. package/src/view/app/main.tsx +9 -12
  321. package/src/view/app/pages/RunsPage.tsx +4 -4
  322. package/src/view/app/pages/TracesPage.tsx +3 -3
  323. package/src/view/app/shared.ts +0 -1
  324. package/src/view/app/types.ts +6 -51
  325. package/src/view/artifact-serving.test.ts +136 -0
  326. package/src/view/client-dist/app.css +1 -1
  327. package/src/view/client-dist/app.js +21 -21
  328. package/src/view/data.test.ts +214 -143
  329. package/src/view/data.ts +166 -120
  330. package/src/view/index.ts +23 -11
  331. package/src/view/server.ts +52 -17
  332. package/src/view/shared/types.ts +22 -31
  333. package/src/view/styles.css +117 -327
  334. package/src/view/view-report.test.ts +146 -98
  335. package/src/report/default-report.tsx +0 -222
  336. package/src/report/dual-face.test.tsx +0 -527
  337. package/src/report/react/CaseList.tsx +0 -70
  338. package/src/view/app/components/CostScoreChart.tsx +0 -147
  339. package/src/view/app/components/ExperimentTable.tsx +0 -284
  340. package/src/view/app/components/GroupSelector.tsx +0 -61
  341. package/src/view/app/components/primitives.tsx +0 -43
  342. package/src/view/app/lib/outcome.ts +0 -82
@@ -1,28 +1,35 @@
1
1
  // openResults:扫描结果目录,返回「实验 → 快照 → eval → attempt」的类型化层次
2
- // (定稿见 docs/results-lib.md「读:openResults)。
2
+ // (定稿见 docs/feature/results/library.md「读:openResults」、docs/feature/results/architecture.md「读取规则」)。
3
3
  //
4
4
  // 三条铁律:
5
5
  // - 忠实磁盘:快照与实验归组只切片,不合并、不聚合、不去重;合并/聚合永远发生在消费方。
6
- // - 读不了的落盘进 skipped(三种原因),不静默丢,也不抛错(单个坏 run 不拖垮整次扫描)。
7
- // - 重工件全部懒加载:缺失返回 null(存在性判断被方法语义吸收),同一 handle 内记忆化。
6
+ // - 读不了的落盘进 skipped(三种原因),不静默丢,也不抛错(单个坏快照不拖垮整次扫描)。
7
+ // - artifact 全部懒加载:缺失返回 null(存在性判断被方法语义吸收),同一 handle 内记忆化。
8
8
 
9
9
  import { readFile, readdir, stat } from "node:fs/promises";
10
- import { basename, dirname, join, resolve } from "node:path";
11
- import type { EvalResult, RunSummary } from "../types.ts";
10
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
11
+ import type { EvalResult } from "../types.ts";
12
12
  import type { O11ySummary, StreamEvent, TraceSpan } from "../types.ts";
13
- import type { DiffData, SourceArtifact } from "../types.ts";
14
- import { artifactFileOf, classifySummary, experimentKeyOf } from "./format.ts";
15
- import { isNewerRunDir, selectLatest } from "./select.ts";
13
+ import type { AgentSetupManifest, DiffData, SourceArtifact } from "../types.ts";
14
+ import { RESULT_FILE, SNAPSHOT_FILE, artifactFileOf, classifySnapshot } from "./format.ts";
15
+ import { isNewerSnapshot, selectLatest } from "./select.ts";
16
+ import {
17
+ encodeAttemptLocator,
18
+ resolveAttemptLocator,
19
+ LocatorCollisionError,
20
+ type AttemptIdentity,
21
+ type AttemptLocator,
22
+ } from "./locator.ts";
16
23
  import type {
17
24
  ArtifactKind,
18
25
  AttemptHandle,
19
26
  Eval,
20
27
  Experiment,
21
28
  Results,
22
- RunDir,
23
29
  Selection,
24
- SkippedRun,
30
+ SkippedDir,
25
31
  Snapshot,
32
+ SnapshotMeta,
26
33
  } from "./types.ts";
27
34
  import { ARTIFACT_KINDS } from "./types.ts";
28
35
 
@@ -35,224 +42,332 @@ export function experimentOfSnapshot(snapshot: Snapshot): Experiment | undefined
35
42
  return experimentBySnapshot.get(snapshot);
36
43
  }
37
44
 
45
+ // locator → AttemptHandle 索引同样挂在 openResults() 产出的 Results 上,不进公开类型
46
+ // (Results 接口保持精简,索引经 resolveLocator() 这个自由函数取用,与 experimentOfSnapshot
47
+ // 同一种「WeakMap 记归属」模式)。openResults() 之外手工拼出来的 Results 对象查不到索引,
48
+ // resolveLocator() 对此的处理是「查不到 = 空索引」,一律 not-found,不抛意外错误。
49
+ const locatorIndexByResults = new WeakMap<Results, Map<AttemptLocator, AttemptHandle>>();
50
+
51
+ /** locator 语法合法、但索引里没有这个 key——落盘已被清理、复制时没带上,或纯粹打错。 */
52
+ export class LocatorNotFoundError extends Error {
53
+ constructor(public readonly locator: string) {
54
+ super(
55
+ `No attempt found for locator "${locator}" in this results root. It may be stale ` +
56
+ "(the snapshot was deleted, or copySnapshots didn't include it) or mistyped.",
57
+ );
58
+ this.name = "LocatorNotFoundError";
59
+ }
60
+ }
61
+
62
+ /** locator 字符串本身不合法(前缀 / scheme 字符 / body 字符集或长度不对)。 */
63
+ export class MalformedLocatorError extends Error {
64
+ constructor(
65
+ public readonly input: string,
66
+ public readonly reason: string,
67
+ ) {
68
+ super(`"${input}" is not a valid attempt locator: ${reason}`);
69
+ this.name = "MalformedLocatorError";
70
+ }
71
+ }
72
+
38
73
  /**
39
- * 打开 `.niceeval/` 根目录、单个 run 目录,或直接指向某个 summary.json 的路径。
74
+ * CLI 位置参数里的原始 `@...` 字符串,在 `openResults()` 建好的 locator 索引里查找。
75
+ * 找不到 / 语法不对是两种不同的用户错误(打错 vs 过期),用两个可判别的 Error 子类分开抛,
76
+ * 不折叠成一句通用报错——上层(CLI)按 `instanceof` 决定提示文案。
77
+ */
78
+ export function resolveLocator(results: Results, input: string): AttemptHandle {
79
+ const index = locatorIndexByResults.get(results) ?? new Map<AttemptLocator, AttemptHandle>();
80
+ const resolution = resolveAttemptLocator(index, input);
81
+ switch (resolution.kind) {
82
+ case "found":
83
+ return resolution.handle;
84
+ case "malformed":
85
+ throw new MalformedLocatorError(resolution.input, resolution.reason);
86
+ case "not-found":
87
+ throw new LocatorNotFoundError(resolution.locator);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 扫描出的全部 attempt 建一份 locator → AttemptHandle 索引(openResults() 收尾时调一次)。
93
+ * 遍历顺序 = experiments(字典序)→ exp.snapshots(新→旧,buildExperiments 已排好序)→
94
+ * snapshot.attempts;「先遇到的赢」自然保留最新快照里的那份(--resume 携带条目在新旧两个
95
+ * 快照里都能扫到时,新快照排在前面先被记进索引,旧快照那份被跳过——同一份 locator 重复
96
+ * 出现不是撞车)。三元组(experimentId/evalId/attempt 序号)不同却撞出同一个 locator
97
+ * 字符串,才是真撞车,直接抛 LocatorCollisionError,不静默覆盖。
98
+ */
99
+ function buildAttemptLocatorIndex(experiments: Experiment[]): Map<AttemptLocator, AttemptHandle> {
100
+ const index = new Map<AttemptLocator, AttemptHandle>();
101
+ for (const exp of experiments) {
102
+ for (const snapshot of exp.snapshots) {
103
+ for (const attempt of snapshot.attempts) {
104
+ const locator = attempt.locator;
105
+ if (locator === undefined) continue; // 理论上不会发生(makeAttempt 恒回填),防御性跳过
106
+ const existing = index.get(locator);
107
+ if (existing === undefined) {
108
+ index.set(locator, attempt);
109
+ continue;
110
+ }
111
+ if (
112
+ existing.experimentId !== attempt.experimentId ||
113
+ existing.evalId !== attempt.evalId ||
114
+ existing.result.attempt !== attempt.result.attempt
115
+ ) {
116
+ throw new LocatorCollisionError(locator, [identityForError(existing), identityForError(attempt)]);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return index;
122
+ }
123
+
124
+ /** LocatorCollisionError 诊断信息用:携带条目场景下这不一定是「真」身份(snapshotStartedAt
125
+ * 取当前所在快照的值,携带条目原本可能来自更早的快照),但足够定位是哪两个 attempt 撞的。 */
126
+ function identityForError(attempt: AttemptHandle): AttemptIdentity {
127
+ return {
128
+ experimentId: attempt.experimentId,
129
+ snapshotStartedAt: attempt.snapshot.startedAt,
130
+ evalId: attempt.evalId,
131
+ attempt: attempt.result.attempt,
132
+ };
133
+ }
134
+
135
+ interface ScanState {
136
+ snapshots: Snapshot[];
137
+ skipped: SkippedDir[];
138
+ }
139
+
140
+ /**
141
+ * 打开结果根、实验目录、快照目录,或直接指向某个 snapshot.json(/ 历史版本 summary.json)的路径。
40
142
  * 目录不存在返回空集合(还没跑过 eval 不是错误);任何读不了的落盘进 skipped,不抛错。
41
143
  */
42
144
  export async function openResults(dir: string): Promise<Results> {
43
145
  const target = resolve(dir);
44
- const runDirs: RunDir[] = [];
45
- const skipped: SkippedRun[] = [];
146
+ const state: ScanState = { snapshots: [], skipped: [] };
46
147
 
47
148
  let targetStat;
48
149
  try {
49
150
  targetStat = await stat(target);
50
151
  } catch {
51
- return makeResults([], skipped, runDirs);
152
+ return makeResults([], []);
52
153
  }
53
154
 
54
- const summaryPaths: string[] = [];
55
155
  if (targetStat.isFile()) {
56
- summaryPaths.push(target);
156
+ // 单文件模式:直指 snapshot.json(或历史版本 summary.json)→ 读它所在目录为快照。
157
+ await handleMetaFile(target, state);
57
158
  } else {
58
- const direct = join(target, "summary.json");
59
- if (await fileExists(direct)) {
60
- // target 本身是 run 目录:它的子目录是 attempt 工件目录,不做 incomplete 探测。
61
- summaryPaths.push(direct, ...(await findSummaryFiles(target, direct)));
62
- } else {
63
- // target 是结果根:逐个 immediate child 判定 —— 有 summary 的收 run;
64
- // 没有 summary 但有 attempt 工件的 = crash 没收尾,进 skipped("incomplete")。
65
- const entries = await readdir(target, { withFileTypes: true });
66
- for (const entry of entries.filter((e) => e.isDirectory())) {
67
- const childDir = join(target, entry.name);
68
- const found = await findSummaryFiles(childDir);
69
- if (found.length > 0) {
70
- summaryPaths.push(...found);
71
- } else if (await hasArtifactFiles(childDir)) {
72
- skipped.push({ dir: childDir, reason: "incomplete" });
73
- }
74
- }
75
- }
76
- }
77
-
78
- for (const path of summaryPaths) {
79
- const outcome = await readRun(path);
80
- if (outcome.kind === "run") runDirs.push(outcome.run);
81
- else if (outcome.kind === "skipped") skipped.push(outcome.entry);
82
- // not-a-report:无关 JSON,静默忽略。
159
+ await scan(target, 0, state);
83
160
  }
84
161
 
85
- // 最新在前;run 目录名是时间戳,startedAt 同刻时按目录名降序兜底。
86
- runDirs.sort((a, b) => (isNewerRunDir(a, b) ? -1 : 1));
87
- skipped.sort((a, b) => b.dir.localeCompare(a.dir));
88
-
89
- const snapshots: Snapshot[] = [];
90
- for (const run of runDirs) snapshots.push(...sliceSnapshots(run));
91
- return makeResults(buildExperiments(snapshots), skipped, runDirs);
162
+ state.skipped.sort((a, b) => b.dir.localeCompare(a.dir));
163
+ const experiments = buildExperiments(state.snapshots);
164
+ // locator 索引:必须在全部快照扫完、Experiment 归组完成之后,返回 Results 之前——
165
+ // 撞车(LocatorCollisionError)在这里抛,不静默吞、不拖到消费方第一次 resolveLocator() 才发现。
166
+ const locatorIndex = buildAttemptLocatorIndex(experiments);
167
+ const results = makeResults(experiments, state.skipped);
168
+ locatorIndexByResults.set(results, locatorIndex);
169
+ return results;
92
170
  }
93
171
 
94
- function makeResults(experiments: Experiment[], skipped: SkippedRun[], runDirs: RunDir[]): Results {
172
+ function makeResults(experiments: Experiment[], skipped: SkippedDir[]): Results {
95
173
  return {
96
174
  experiments,
97
175
  skipped,
98
- runDirs,
99
176
  latest(opts?: { experiments?: string | string[] }): Selection {
100
177
  return selectLatest(experiments, opts);
101
178
  },
102
179
  };
103
180
  }
104
181
 
105
- // ───────────────────────── 单个 run 的读取 ─────────────────────────
182
+ // ───────────────────────── 目录扫描 ─────────────────────────
183
+
184
+ /**
185
+ * 递归扫描:目录里直接有 snapshot.json 或 summary.json → 处理并计 found,不再向下找;
186
+ * 否则递归子目录;子树全部未 found 且 depth ≤ 2 且该目录(递归)含 artifact/result 文件 →
187
+ * 折叠成 skipped("incomplete"),计 found —— 把 attempt 级噪音折叠到实验/快照层,
188
+ * 旧版(v3 及更早)run 目录直接 crash 在 depth 1 也被这条规则覆盖。
189
+ */
190
+ async function scan(dir: string, depth: number, state: ScanState): Promise<boolean> {
191
+ if (await hasFile(dir, SNAPSHOT_FILE)) {
192
+ await handleMetaFile(join(dir, SNAPSHOT_FILE), state);
193
+ return true;
194
+ }
195
+ if (await hasFile(dir, "summary.json")) {
196
+ await handleMetaFile(join(dir, "summary.json"), state);
197
+ return true;
198
+ }
199
+
200
+ let entries;
201
+ try {
202
+ entries = await readdir(dir, { withFileTypes: true });
203
+ } catch {
204
+ return false;
205
+ }
106
206
 
107
- type ReadRunOutcome =
108
- | { kind: "run"; run: RunDir }
109
- | { kind: "skipped"; entry: SkippedRun }
110
- | { kind: "not-a-report" };
207
+ let anyFound = false;
208
+ for (const entry of entries) {
209
+ if (!entry.isDirectory()) continue;
210
+ const found = await scan(join(dir, entry.name), depth + 1, state);
211
+ anyFound = anyFound || found;
212
+ }
111
213
 
112
- async function readRun(path: string): Promise<ReadRunOutcome> {
113
- const runDir = dirname(path);
114
- const skippedEntry = (entry: Omit<SkippedRun, "dir">): ReadRunOutcome => ({
115
- kind: "skipped",
116
- entry: { dir: runDir, ...entry },
117
- });
214
+ if (!anyFound && depth <= 2 && (await hasArtifactOrResultFiles(dir))) {
215
+ state.skipped.push({ dir, reason: "incomplete" });
216
+ return true;
217
+ }
218
+ return anyFound;
219
+ }
118
220
 
221
+ /** 读一份元数据文件(snapshot.json 或历史版本 summary.json),按分类结果分流。 */
222
+ async function handleMetaFile(path: string, state: ScanState): Promise<void> {
223
+ const dir = dirname(path);
119
224
  let text: string;
120
225
  try {
121
226
  text = await readFile(path, "utf-8");
122
227
  } catch (e) {
123
- return skippedEntry({ reason: "malformed", detail: `cannot read file (${e instanceof Error ? e.message : String(e)})` });
228
+ state.skipped.push({ dir, reason: "malformed", detail: `cannot read file (${e instanceof Error ? e.message : String(e)})` });
229
+ return;
124
230
  }
125
231
  let raw: unknown;
126
232
  try {
127
233
  raw = JSON.parse(text);
128
234
  } catch {
129
- return skippedEntry({ reason: "malformed", detail: "invalid JSON" });
235
+ state.skipped.push({ dir, reason: "malformed", detail: "invalid JSON" });
236
+ return;
130
237
  }
131
238
 
132
- const classified = classifySummary(raw);
239
+ const classified = classifySnapshot(raw);
133
240
  switch (classified.kind) {
134
241
  case "not-a-report":
135
- return { kind: "not-a-report" };
242
+ return; // 无关 JSON,静默忽略(调用方仍把此目录计为 found,不触发 incomplete 折叠)。
136
243
  case "malformed":
137
- return skippedEntry({ reason: "malformed", detail: classified.detail });
244
+ state.skipped.push({ dir, reason: "malformed", detail: classified.detail });
245
+ return;
138
246
  case "incompatible":
139
- return skippedEntry({
247
+ state.skipped.push({
248
+ dir,
140
249
  reason: "incompatible-version",
141
250
  schemaVersion: classified.schemaVersion,
142
251
  ...(classified.producer ? { producer: classified.producer } : {}),
143
252
  });
253
+ return;
144
254
  case "ok": {
145
- const run: RunDir = { dir: runDir, summary: classified.summary, attempts: [] };
146
- return { kind: "run", run };
255
+ const snapshot = await readSnapshotDir(dir, classified.meta, state);
256
+ state.snapshots.push(snapshot);
257
+ return;
147
258
  }
148
259
  }
149
260
  }
150
261
 
151
- // ───────────────────────── 快照切片与实验归组 ─────────────────────────
262
+ // ───────────────────────── 快照读取 ─────────────────────────
152
263
 
153
- /**
154
- * 把一个 run experiment 身份切成快照,同时填充 run.attempts(按 results[] 下标顺序)。
155
- * 只切片、不合并、不去重;experimentId 缺失时以 "<agent>/<model>" 合成键,synthetic: true
156
- * (对应警告由 latest() 生成,归属 Selection)。
157
- */
158
- function sliceSnapshots(run: RunDir): Snapshot[] {
159
- const summary = run.summary;
160
- const byExperiment = new Map<string, Snapshot>();
161
- const evalsByExperiment = new Map<string, Map<string, Eval>>();
162
-
163
- summary.results.forEach((result, index) => {
164
- const key = experimentKeyOf(result);
165
- let snapshot = byExperiment.get(key.id);
166
- if (!snapshot) {
167
- const meta = summary.snapshots?.[key.id];
168
- snapshot = {
169
- experimentId: key.id,
170
- startedAt: meta?.startedAt ?? summary.startedAt,
171
- agent: result.agent,
172
- ...(result.model !== undefined ? { model: result.model } : {}),
173
- ...(summary.producer ? { producer: summary.producer } : {}),
174
- schemaVersion: summary.schemaVersion ?? 1,
175
- evals: [],
176
- attempts: [],
177
- runDir: run,
178
- ...(key.synthesized ? { synthetic: true } : {}),
179
- ...(meta?.knownEvalIds ? { knownEvalIds: [...meta.knownEvalIds] } : {}),
180
- };
181
- byExperiment.set(key.id, snapshot);
182
- evalsByExperiment.set(key.id, new Map());
264
+ /** 快照目录:递归收集全部 result.json,组装成 evals / attempts;单个 result.json 坏 JSON 不拖垮快照。 */
265
+ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState): Promise<Snapshot> {
266
+ const snapshot: Snapshot = {
267
+ experimentId: meta.experimentId,
268
+ startedAt: meta.startedAt,
269
+ ...(meta.completedAt !== undefined ? { completedAt: meta.completedAt } : {}),
270
+ agent: meta.agent,
271
+ ...(meta.model !== undefined ? { model: meta.model } : {}),
272
+ ...(meta.experiment !== undefined ? { experiment: meta.experiment } : {}),
273
+ producer: meta.producer,
274
+ schemaVersion: meta.schemaVersion,
275
+ ...(meta.name !== undefined ? { name: meta.name } : {}),
276
+ evals: [],
277
+ attempts: [],
278
+ dir,
279
+ ...(meta.knownEvalIds?.length ? { knownEvalIds: [...meta.knownEvalIds] } : {}),
280
+ };
281
+
282
+ const resultPaths = (await findResultFiles(dir)).sort();
283
+ const evalsById = new Map<string, Eval>();
284
+
285
+ for (const resultPath of resultPaths) {
286
+ const attemptDir = dirname(resultPath);
287
+ let record: EvalResult;
288
+ try {
289
+ const text = await readFile(resultPath, "utf-8");
290
+ record = JSON.parse(text) as EvalResult;
291
+ } catch (e) {
292
+ state.skipped.push({ dir: attemptDir, reason: "malformed", detail: `invalid result.json (${e instanceof Error ? e.message : String(e)})` });
293
+ continue;
183
294
  }
184
- const attempt = makeAttempt(run, key.id, result, index);
185
- run.attempts.push(attempt);
186
- const evals = evalsByExperiment.get(key.id)!;
187
- let ev = evals.get(result.id);
295
+
296
+ // 快照级字段拼合:「缺才补」,条目自带的值(携带条目的 startedAt)优先。
297
+ record.experimentId ??= meta.experimentId;
298
+ record.agent ??= meta.agent;
299
+ if (record.model === undefined && meta.model !== undefined) record.model = meta.model;
300
+ record.startedAt ??= meta.startedAt;
301
+ if (record.experiment === undefined && meta.experiment !== undefined) record.experiment = meta.experiment;
302
+ // locator 同理「缺才补」:niceeval 自己的 writer(schemaVersion 5 起)恒会写这个字段,
303
+ // 携带条目原样携带上一轮的值——只有真缺失(第三方 harness 没实现 locator,或手工构造的
304
+ // 落盘)才按当前身份兜底算一份;这份兜底不保证跨未来的 --resume 稳定,但至少确定性、
305
+ // 可解析,不比完全没有 locator 差。
306
+ record.locator ??= encodeAttemptLocator({
307
+ experimentId: record.experimentId,
308
+ snapshotStartedAt: meta.startedAt,
309
+ evalId: record.id,
310
+ attempt: record.attempt,
311
+ });
312
+
313
+ const attempt = makeAttempt(snapshot, dir, attemptDir, record);
314
+ let ev = evalsById.get(record.id);
188
315
  if (!ev) {
189
- ev = { id: result.id, attempts: [] };
190
- evals.set(result.id, ev);
316
+ ev = { id: record.id, attempts: [] };
317
+ evalsById.set(record.id, ev);
191
318
  snapshot.evals.push(ev);
192
319
  }
193
320
  ev.attempts.push(attempt);
194
- });
195
-
196
- for (const snapshot of byExperiment.values()) {
197
- // attempts 平铺 = evals 逐题展开(同题的重试相邻)。
198
- snapshot.attempts = snapshot.evals.flatMap((ev) => ev.attempts);
199
321
  }
200
- return [...byExperiment.values()];
201
- }
202
322
 
203
- /** 同一 experiment id 的历次快照归在一起;实验按 id 字典序,快照最新在前。 */
204
- function buildExperiments(snapshots: Snapshot[]): Experiment[] {
205
- const byId = new Map<string, Snapshot[]>();
206
- for (const snapshot of snapshots) {
207
- const group = byId.get(snapshot.experimentId);
208
- if (group) group.push(snapshot);
209
- else byId.set(snapshot.experimentId, [snapshot]);
323
+ for (const ev of snapshot.evals) {
324
+ // attempt 序号升序,同号按 startedAt。
325
+ ev.attempts.sort((a, b) => a.result.attempt - b.result.attempt || (a.result.startedAt ?? "").localeCompare(b.result.startedAt ?? ""));
210
326
  }
211
-
212
- const experiments: Experiment[] = [];
213
- for (const [id, group] of byId) {
214
- group.sort((a, b) => b.startedAt.localeCompare(a.startedAt) || b.runDir.dir.localeCompare(a.runDir.dir));
215
- // 已知并集 = 本地历史(各快照覆盖的题)∪ 各快照携带的 knownEvalIds ——
216
- // 不是「优先字段」:把快照复制进已有历史的目录时,本地并集可能更大,优先字段会让分母缩水。
217
- const ids = new Set<string>();
218
- for (const snapshot of group) {
219
- for (const ev of snapshot.evals) ids.add(ev.id);
220
- for (const known of snapshot.knownEvalIds ?? []) ids.add(known);
221
- }
222
- const experiment: Experiment = { id, snapshots: group, latest: group[0], evalIds: [...ids].sort() };
223
- for (const snapshot of group) experimentBySnapshot.set(snapshot, experiment);
224
- experiments.push(experiment);
225
- }
226
- experiments.sort((a, b) => a.id.localeCompare(b.id));
227
- return experiments;
327
+ snapshot.attempts = snapshot.evals.flatMap((ev) => ev.attempts);
328
+ return snapshot;
228
329
  }
229
330
 
230
- // ───────────────────────── attempt 懒加载 ─────────────────────────
331
+ function makeAttempt(snapshot: Snapshot, snapshotDir: string, attemptDir: string, record: EvalResult): AttemptHandle {
332
+ // 候选 artifact 目录:本 attempt 目录为主;--resume 携带条目的 artifact 留在原快照里,
333
+ // artifactBase(相对结果根 = 快照目录的上两级)指向那里,作为回退。
334
+ const candidates: string[] = [attemptDir];
335
+ // sources 的去重仓库(sources/<sha256>.json)挂在「快照根」,不是 attempt 目录——每个候选
336
+ // attempt 目录都要配一个对应的快照根,顺序与 candidates 一一对应,lazySources 按下标取用。
337
+ const candidateSnapshotRoots: string[] = [snapshotDir];
338
+ if (record.artifactBase) {
339
+ const resultsRoot = dirname(dirname(snapshotDir));
340
+ candidates.push(resolve(resultsRoot, record.artifactBase));
341
+ // artifactBase 恒为 `<实验目录>/<快照目录>/<evalId 路径>/a<n>`;experimentDirOf/快照目录名
342
+ // 都不含 `/`,所以前两段就是原快照根,即便 evalId 自己带 `/`(多段)也不影响这个切法。
343
+ const [expDir, snapDirName] = record.artifactBase.split("/");
344
+ candidateSnapshotRoots.push(resolve(resultsRoot, expDir ?? "", snapDirName ?? ""));
345
+ }
231
346
 
232
- function makeAttempt(run: RunDir, experimentId: string, result: EvalResult, index: number): AttemptHandle {
233
- // 候选工件目录:本 run 下的 artifactsDir 为主;--resume / 跨实验携带合入的条目,其工件
234
- // 留在原 run 目录里,summary 里的 artifactBase(相对结果根目录)指向那里,作为回退。
235
- const candidates: string[] = [];
236
- if (result.artifactsDir) candidates.push(join(run.dir, result.artifactsDir));
237
- if (result.artifactBase) candidates.push(join(dirname(run.dir), result.artifactBase));
347
+ const ref = {
348
+ snapshot: `${basename(dirname(snapshotDir))}/${basename(snapshotDir)}`,
349
+ attempt: relative(snapshotDir, attemptDir).split(sep).join("/"),
350
+ };
238
351
 
239
352
  return {
240
- evalId: result.id,
241
- experimentId,
242
- result,
243
- ref: { run: basename(run.dir), result: index },
244
- runDir: run,
245
- events: lazyArtifact<StreamEvent[]>(candidates, "events", result.events),
246
- trace: lazyArtifact<TraceSpan[]>(candidates, "trace", result.trace),
247
- o11y: lazyArtifact<O11ySummary>(candidates, "o11y", result.o11y),
248
- diff: lazyArtifact<DiffData>(candidates, "diff", result.diff),
249
- sources: lazyArtifact<SourceArtifact[]>(candidates, "sources", result.sources),
353
+ evalId: record.id,
354
+ experimentId: record.experimentId!,
355
+ result: record,
356
+ ref,
357
+ snapshot,
358
+ locator: record.locator as AttemptLocator,
359
+ events: lazyArtifact<StreamEvent[]>(candidates, "events", record.events),
360
+ trace: lazyArtifact<TraceSpan[]>(candidates, "trace", record.trace),
361
+ o11y: lazyArtifact<O11ySummary>(candidates, "o11y", record.o11y),
362
+ agentSetup: lazyArtifact<AgentSetupManifest>(candidates, "agentSetup", record.agentSetup),
363
+ diff: lazyArtifact<DiffData>(candidates, "diff", record.diff),
364
+ sources: lazySources(candidates, candidateSnapshotRoots, record.sources),
250
365
  };
251
366
  }
252
367
 
253
368
  /**
254
- * 单个工件的懒加载器:缺失返回 null(不抛错);同一 handle 内记忆化,diff 这类可达百 MB 的
255
- * 文件绝不读两遍。summary 里内联了该字段时直接用(Json reporter 全量输出/外部工具转换的场景)。
369
+ * 单个 artifact 的懒加载器:缺失返回 null(不抛错);同一 handle 内记忆化,diff 这类可达百 MB 的
370
+ * 文件绝不读两遍。result.json 里内联了该字段时直接用(外部工具转换/全量输出的场景)。
256
371
  * 文件存在但 JSON 损坏是真错误,抛英文错误而不是伪装成缺失;失败不缓存,允许重试。
257
372
  */
258
373
  function lazyArtifact<T>(candidateDirs: string[], kind: ArtifactKind, inline: T | undefined): () => Promise<T | null> {
@@ -285,42 +400,144 @@ function lazyArtifact<T>(candidateDirs: string[], kind: ArtifactKind, inline: T
285
400
  };
286
401
  }
287
402
 
403
+ /** 一条 sources 引用条目(attempt 级 `sources.json` 的落盘形状,schemaVersion 5 起)。 */
404
+ interface SourceRef {
405
+ path: string;
406
+ sha256: string;
407
+ }
408
+
409
+ /**
410
+ * sources 的懒加载器:与 lazyArtifact 同样的存在性/记忆化/坏 JSON 语义,但多一层——
411
+ * attempt 目录下的 `sources.json` 只是引用(`{path, sha256}[]`),真内容按 sha256 在对应
412
+ * 快照根的 `sources/<sha256>.json` 里(去重仓库,见 writer.ts 的 writeSourcesRef)。
413
+ * candidateSnapshotRoots 与 candidateDirs 下标一一对应:命中哪个候选 attempt 目录的引用,
414
+ * 就去哪个候选对应的快照根找仓库——本 attempt 目录对本快照根,artifactBase 回退对原快照根。
415
+ * 仓库里缺单条 blob(理论不该发生,引用与仓库应同时存在)如实跳过那一条,不让整个方法失败。
416
+ */
417
+ function lazySources(
418
+ candidateDirs: string[],
419
+ candidateSnapshotRoots: string[],
420
+ inline: SourceArtifact[] | undefined,
421
+ ): () => Promise<SourceArtifact[] | null> {
422
+ let memo: Promise<SourceArtifact[] | null> | undefined;
423
+ const load = async (): Promise<SourceArtifact[] | null> => {
424
+ if (inline !== undefined) return inline;
425
+ for (let i = 0; i < candidateDirs.length; i++) {
426
+ const refFile = join(candidateDirs[i]!, artifactFileOf("sources"));
427
+ let text: string;
428
+ try {
429
+ text = await readFile(refFile, "utf-8");
430
+ } catch (e) {
431
+ if (isMissingFile(e)) continue;
432
+ throw new Error(`Cannot read artifact ${refFile} (${e instanceof Error ? e.message : String(e)}).`);
433
+ }
434
+ let refs: SourceRef[];
435
+ try {
436
+ refs = JSON.parse(text) as SourceRef[];
437
+ } catch {
438
+ throw new Error(`Artifact ${refFile} is not valid JSON. The file may be corrupted; re-run the eval or delete this attempt directory.`);
439
+ }
440
+ const storeDir = join(candidateSnapshotRoots[i]!, "sources");
441
+ const out: SourceArtifact[] = [];
442
+ for (const ref of refs) {
443
+ const blobFile = join(storeDir, `${ref.sha256}.json`);
444
+ let blobText: string;
445
+ try {
446
+ blobText = await readFile(blobFile, "utf-8");
447
+ } catch (e) {
448
+ if (isMissingFile(e)) continue; // 仓库缺这一条(极端情况):跳过,不拖垮其它条目
449
+ throw new Error(`Cannot read source blob ${blobFile} (${e instanceof Error ? e.message : String(e)}).`);
450
+ }
451
+ let blob: { content: string };
452
+ try {
453
+ blob = JSON.parse(blobText) as { content: string };
454
+ } catch {
455
+ throw new Error(`Source blob ${blobFile} is not valid JSON. It may be corrupted.`);
456
+ }
457
+ out.push({ path: ref.path, content: blob.content });
458
+ }
459
+ return out;
460
+ }
461
+ return null;
462
+ };
463
+ return () => {
464
+ memo ??= load().catch((e: unknown) => {
465
+ memo = undefined;
466
+ throw e;
467
+ });
468
+ return memo;
469
+ };
470
+ }
471
+
288
472
  function isMissingFile(e: unknown): boolean {
289
473
  const code = (e as NodeJS.ErrnoException | null)?.code;
290
474
  return code === "ENOENT" || code === "ENOTDIR";
291
475
  }
292
476
 
293
- // ───────────────────────── 目录扫描 ─────────────────────────
477
+ // ───────────────────────── 实验归组 ─────────────────────────
478
+
479
+ /** 同一 experiment id 的历次快照归在一起;实验按 id 字典序,快照最新在前。 */
480
+ function buildExperiments(snapshots: Snapshot[]): Experiment[] {
481
+ const byId = new Map<string, Snapshot[]>();
482
+ for (const snapshot of snapshots) {
483
+ const group = byId.get(snapshot.experimentId);
484
+ if (group) group.push(snapshot);
485
+ else byId.set(snapshot.experimentId, [snapshot]);
486
+ }
487
+
488
+ const experiments: Experiment[] = [];
489
+ for (const [id, group] of byId) {
490
+ group.sort((a, b) => (isNewerSnapshot(a, b) ? -1 : 1));
491
+ // 已知并集 = 本地历史(各快照覆盖的题)∪ 各快照携带的 knownEvalIds ——
492
+ // 不是「优先字段」:把快照复制进已有历史的目录时,本地并集可能更大,优先字段会让分母缩水。
493
+ const ids = new Set<string>();
494
+ for (const snapshot of group) {
495
+ for (const ev of snapshot.evals) ids.add(ev.id);
496
+ for (const known of snapshot.knownEvalIds ?? []) ids.add(known);
497
+ }
498
+ const experiment: Experiment = { id, snapshots: group, latest: group[0], evalIds: [...ids].sort() };
499
+ for (const snapshot of group) experimentBySnapshot.set(snapshot, experiment);
500
+ experiments.push(experiment);
501
+ }
502
+ experiments.sort((a, b) => a.id.localeCompare(b.id));
503
+ return experiments;
504
+ }
505
+
506
+ // ───────────────────────── 文件系统助手 ─────────────────────────
294
507
 
295
- /** 递归找出全部 summary.json(run 目录嵌套深度不做假设);skip 用于排除已收录的直达文件。 */
296
- async function findSummaryFiles(dir: string, skip?: string): Promise<string[]> {
297
- const entries = await readdir(dir, { withFileTypes: true });
298
- const direct = entries
299
- .filter((e) => e.isFile() && e.name === "summary.json")
300
- .map((e) => join(dir, e.name))
301
- .filter((p) => p !== skip);
302
- const nested = await Promise.all(
303
- entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name), skip)),
304
- );
508
+ async function findResultFiles(dir: string): Promise<string[]> {
509
+ let entries;
510
+ try {
511
+ entries = await readdir(dir, { withFileTypes: true });
512
+ } catch {
513
+ return [];
514
+ }
515
+ const direct = entries.filter((e) => e.isFile() && e.name === RESULT_FILE).map((e) => join(dir, e.name));
516
+ const nested = await Promise.all(entries.filter((e) => e.isDirectory()).map((e) => findResultFiles(join(dir, e.name))));
305
517
  return [...direct, ...nested.flat()];
306
518
  }
307
519
 
308
- /** 目录下(递归)是否存在任何 attempt 工件文件 —— incomplete 判定的依据。 */
309
- async function hasArtifactFiles(dir: string): Promise<boolean> {
310
- const artifactNames = new Set<string>(ARTIFACT_KINDS.map((kind) => artifactFileOf(kind)));
311
- const entries = await readdir(dir, { withFileTypes: true });
520
+ /** 目录下(递归)是否存在 result.json 或任何 attempt artifact 文件 —— incomplete 判定的依据。 */
521
+ async function hasArtifactOrResultFiles(dir: string): Promise<boolean> {
522
+ const names = new Set<string>([RESULT_FILE, ...ARTIFACT_KINDS.map((kind) => artifactFileOf(kind))]);
523
+ let entries;
524
+ try {
525
+ entries = await readdir(dir, { withFileTypes: true });
526
+ } catch {
527
+ return false;
528
+ }
312
529
  for (const entry of entries) {
313
- if (entry.isFile() && artifactNames.has(entry.name)) return true;
530
+ if (entry.isFile() && names.has(entry.name)) return true;
314
531
  }
315
532
  for (const entry of entries) {
316
- if (entry.isDirectory() && (await hasArtifactFiles(join(dir, entry.name)))) return true;
533
+ if (entry.isDirectory() && (await hasArtifactOrResultFiles(join(dir, entry.name)))) return true;
317
534
  }
318
535
  return false;
319
536
  }
320
537
 
321
- async function fileExists(path: string): Promise<boolean> {
538
+ async function hasFile(dir: string, name: string): Promise<boolean> {
322
539
  try {
323
- return (await stat(path)).isFile();
540
+ return (await stat(join(dir, name))).isFile();
324
541
  } catch {
325
542
  return false;
326
543
  }