niceeval 0.6.1 → 0.7.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 (378) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +69 -7
  4. package/dist/context/types.d.ts +32 -12
  5. package/dist/i18n/en.d.ts +54 -0
  6. package/dist/i18n/zh-CN.d.ts +57 -3
  7. package/dist/o11y/types.d.ts +16 -2
  8. package/dist/report/aggregate.d.ts +32 -24
  9. package/dist/report/aggregate.js +158 -50
  10. package/dist/report/built-in/index.d.ts +2 -0
  11. package/dist/report/built-in/index.js +8 -0
  12. package/dist/report/components.d.ts +93 -160
  13. package/dist/report/components.js +377 -114
  14. package/dist/report/compute.d.ts +87 -81
  15. package/dist/report/compute.js +597 -417
  16. package/dist/report/flag.d.ts +32 -6
  17. package/dist/report/flag.js +92 -4
  18. package/dist/report/format.d.ts +19 -11
  19. package/dist/report/format.js +30 -13
  20. package/dist/report/index.d.ts +16 -16
  21. package/dist/report/index.js +20 -21
  22. package/dist/report/load.js +3 -2
  23. package/dist/report/locale.d.ts +57 -33
  24. package/dist/report/locale.js +122 -56
  25. package/dist/report/metrics.d.ts +23 -4
  26. package/dist/report/metrics.js +110 -25
  27. package/dist/report/primitives.d.ts +48 -15
  28. package/dist/report/primitives.js +135 -26
  29. package/dist/report/react/AttemptList.d.ts +9 -7
  30. package/dist/report/react/AttemptList.js +17 -10
  31. package/dist/report/react/DeltaTable.js +19 -18
  32. package/dist/report/react/EvalList.d.ts +4 -4
  33. package/dist/report/react/EvalList.js +0 -0
  34. package/dist/report/react/ExperimentComparison.d.ts +10 -0
  35. package/dist/report/react/ExperimentComparison.js +12 -0
  36. package/dist/report/react/ExperimentList.d.ts +4 -3
  37. package/dist/report/react/ExperimentList.js +17 -18
  38. package/dist/report/react/MetricBars.js +5 -4
  39. package/dist/report/react/MetricLine.js +12 -5
  40. package/dist/report/react/MetricMatrix.js +1 -1
  41. package/dist/report/react/MetricScatter.js +59 -28
  42. package/dist/report/react/MetricTable.js +2 -12
  43. package/dist/report/react/ScopeSummary.d.ts +10 -0
  44. package/dist/report/react/ScopeSummary.js +17 -0
  45. package/dist/report/react/Scoreboard.js +6 -6
  46. package/dist/report/react/cell.js +2 -2
  47. package/dist/report/react/chart-math.d.ts +23 -6
  48. package/dist/report/react/chart-math.js +71 -19
  49. package/dist/report/react/fixtures.d.ts +5 -9
  50. package/dist/report/react/fixtures.js +110 -147
  51. package/dist/report/react/index.d.ts +15 -5
  52. package/dist/report/react/index.js +18 -7
  53. package/dist/report/report.d.ts +137 -16
  54. package/dist/report/report.js +259 -28
  55. package/dist/report/text/faces.d.ts +17 -19
  56. package/dist/report/text/faces.js +253 -184
  57. package/dist/report/text/plot.js +1 -1
  58. package/dist/report/text/table.js +38 -7
  59. package/dist/report/tree.d.ts +90 -40
  60. package/dist/report/tree.js +252 -94
  61. package/dist/report/types.d.ts +247 -284
  62. package/dist/report/types.js +4 -3
  63. package/dist/report/web.d.ts +21 -5
  64. package/dist/report/web.js +42 -16
  65. package/dist/results/select.d.ts +38 -16
  66. package/dist/results/select.js +73 -25
  67. package/dist/results/types.d.ts +49 -14
  68. package/dist/runner/feedback/sink.d.ts +110 -0
  69. package/dist/runner/types.d.ts +513 -22
  70. package/dist/sandbox/docker.d.ts +23 -2
  71. package/dist/sandbox/e2b.d.ts +15 -1
  72. package/dist/sandbox/errors.d.ts +30 -3
  73. package/dist/sandbox/io-retry.d.ts +17 -0
  74. package/dist/sandbox/registry.d.ts +2 -0
  75. package/dist/sandbox/resolve.d.ts +18 -5
  76. package/dist/sandbox/retry.d.ts +11 -1
  77. package/dist/sandbox/types.d.ts +39 -5
  78. package/dist/sandbox/vercel.d.ts +7 -1
  79. package/dist/scoring/coverage.d.ts +30 -0
  80. package/dist/scoring/display.d.ts +21 -0
  81. package/dist/scoring/display.js +120 -0
  82. package/dist/scoring/types.d.ts +103 -20
  83. package/dist/shared/aggregate.d.ts +4 -2
  84. package/dist/shared/aggregate.js +8 -7
  85. package/dist/shared/types.d.ts +28 -0
  86. package/dist/tty-line.d.ts +0 -4
  87. package/dist/util.d.ts +23 -0
  88. package/docs-site/zh/README.md +44 -0
  89. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  90. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  91. package/docs-site/zh/examples/index.mdx +50 -0
  92. package/docs-site/zh/{concepts → explanation}/adapter.mdx +31 -13
  93. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  94. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  95. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  96. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  97. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  98. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  99. package/docs-site/zh/{concepts → explanation}/overview.mdx +11 -11
  100. package/docs-site/zh/{guides → explanation}/runner.mdx +18 -8
  101. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  102. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +35 -33
  103. package/docs-site/zh/{guides → how-to}/authoring.mdx +35 -2
  104. package/docs-site/zh/{guides → how-to}/ci-integration.mdx +23 -12
  105. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  106. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +47 -21
  107. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +34 -39
  108. package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +25 -3
  109. package/docs-site/zh/{guides → how-to}/experiments.mdx +12 -5
  110. package/docs-site/zh/how-to/publish-report.mdx +105 -0
  111. package/docs-site/zh/{guides → how-to}/reporters.mdx +2 -2
  112. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +56 -7
  113. package/docs-site/zh/how-to/sandbox-providers.mdx +350 -0
  114. package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +4 -4
  115. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +82 -39
  116. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +6 -4
  117. package/docs-site/zh/{guides → how-to}/write-send.mdx +30 -14
  118. package/docs-site/zh/index.mdx +24 -26
  119. package/docs-site/zh/introduction.mdx +8 -8
  120. package/docs-site/zh/reference/builtin-agents.mdx +32 -5
  121. package/docs-site/zh/reference/capabilities.mdx +8 -8
  122. package/docs-site/zh/reference/cli.mdx +40 -12
  123. package/docs-site/zh/reference/define-agent.mdx +58 -5
  124. package/docs-site/zh/reference/define-config.mdx +1 -1
  125. package/docs-site/zh/reference/define-eval.mdx +42 -9
  126. package/docs-site/zh/reference/events.mdx +3 -3
  127. package/docs-site/zh/reference/expect.mdx +26 -1
  128. package/docs-site/zh/{guides → reference}/official-adapters.mdx +32 -8
  129. package/docs-site/zh/{guides → reference}/report-components.mdx +45 -33
  130. package/docs-site/zh/{guides → reference}/results-data.mdx +21 -13
  131. package/docs-site/zh/troubleshooting/debug-sandbox.mdx +57 -0
  132. package/docs-site/zh/troubleshooting/debugging.mdx +212 -0
  133. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  134. package/package.json +10 -2
  135. package/src/agents/ai-sdk-otel.test.ts +1 -0
  136. package/src/agents/ai-sdk.test.ts +3 -0
  137. package/src/agents/ai-sdk.ts +3 -0
  138. package/src/agents/bub-install-spec.test.ts +34 -0
  139. package/src/agents/bub-install-spec.ts +32 -0
  140. package/src/agents/bub.ts +31 -32
  141. package/src/agents/claude-code.test.ts +130 -9
  142. package/src/agents/claude-code.ts +76 -4
  143. package/src/agents/codex.test.ts +189 -40
  144. package/src/agents/codex.ts +155 -14
  145. package/src/agents/coding-cli-versions.test.ts +15 -0
  146. package/src/agents/coding-cli-versions.ts +3 -0
  147. package/src/agents/index.ts +13 -2
  148. package/src/agents/langgraph.test.ts +204 -0
  149. package/src/agents/langgraph.ts +495 -0
  150. package/src/agents/marketplace.ts +85 -0
  151. package/src/agents/native-config.test.ts +179 -0
  152. package/src/agents/native-config.ts +267 -0
  153. package/src/agents/openai-compat.test.ts +1 -0
  154. package/src/agents/openai-compat.ts +1 -1
  155. package/src/agents/openclaw.test.ts +31 -0
  156. package/src/agents/openclaw.ts +171 -0
  157. package/src/agents/plugin-config.test.ts +1 -0
  158. package/src/agents/sdk-streams.test.ts +79 -0
  159. package/src/agents/sdk-streams.ts +55 -10
  160. package/src/agents/skills.test.ts +1 -0
  161. package/src/agents/streaming.test.ts +3 -9
  162. package/src/agents/streaming.ts +2 -2
  163. package/src/agents/types.ts +71 -8
  164. package/src/agents/ui-message-stream.test.ts +3 -0
  165. package/src/cli.ts +446 -124
  166. package/src/context/context.test.ts +51 -12
  167. package/src/context/context.ts +162 -30
  168. package/src/context/session.test.ts +2 -1
  169. package/src/context/session.ts +115 -7
  170. package/src/context/types.ts +30 -12
  171. package/src/define.test.ts +13 -8
  172. package/src/define.ts +25 -4
  173. package/src/expect/index.ts +53 -23
  174. package/src/i18n/en.ts +81 -17
  175. package/src/i18n/zh-CN.ts +80 -17
  176. package/src/o11y/cost.test.ts +1 -0
  177. package/src/o11y/execution-tree.test.ts +1 -20
  178. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  179. package/src/o11y/otlp/parse.test.ts +1 -0
  180. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  181. package/src/o11y/parsers/bub.test.ts +1 -0
  182. package/src/o11y/parsers/claude-code.test.ts +1 -34
  183. package/src/o11y/parsers/openclaw.test.ts +154 -0
  184. package/src/o11y/parsers/openclaw.ts +310 -0
  185. package/src/o11y/prices.json +746 -311
  186. package/src/o11y/tool-names.test.ts +1 -0
  187. package/src/o11y/types.ts +16 -2
  188. package/src/report/aggregate.ts +178 -61
  189. package/src/report/built-in/index.tsx +9 -0
  190. package/src/report/components.tsx +625 -279
  191. package/src/report/compute.ts +723 -491
  192. package/src/report/dual-render.test.tsx +741 -1024
  193. package/src/report/flag.ts +104 -12
  194. package/src/report/format.ts +32 -12
  195. package/src/report/index.ts +119 -46
  196. package/src/report/load.ts +3 -2
  197. package/src/report/locale.ts +136 -65
  198. package/src/report/metrics.ts +108 -25
  199. package/src/report/primitives.tsx +196 -45
  200. package/src/report/react/AttemptList.tsx +30 -43
  201. package/src/report/react/DeltaTable.tsx +63 -45
  202. package/src/report/react/EvalList.tsx +0 -0
  203. package/src/report/react/ExperimentComparison.tsx +73 -0
  204. package/src/report/react/ExperimentList.tsx +50 -32
  205. package/src/report/react/MetricBars.tsx +5 -4
  206. package/src/report/react/MetricLine.tsx +13 -8
  207. package/src/report/react/MetricMatrix.tsx +2 -2
  208. package/src/report/react/MetricScatter.tsx +86 -34
  209. package/src/report/react/MetricTable.tsx +4 -76
  210. package/src/report/react/ScopeSummary.tsx +86 -0
  211. package/src/report/react/Scoreboard.tsx +28 -10
  212. package/src/report/react/cell.tsx +2 -2
  213. package/src/report/react/chart-math.test.ts +85 -0
  214. package/src/report/react/chart-math.ts +101 -22
  215. package/src/report/react/enhance.js +89 -5
  216. package/src/report/react/fixtures.ts +114 -154
  217. package/src/report/react/index.tsx +24 -39
  218. package/src/report/react/render.test.tsx +138 -158
  219. package/src/report/react/styles.css +243 -82
  220. package/src/report/report.test.ts +779 -841
  221. package/src/report/report.ts +423 -41
  222. package/src/report/text/faces.ts +290 -193
  223. package/src/report/text/plot.ts +1 -1
  224. package/src/report/text/table.ts +44 -7
  225. package/src/report/tree.ts +362 -104
  226. package/src/report/types.ts +261 -271
  227. package/src/report/web.ts +63 -20
  228. package/src/results/annotated-source.test.ts +62 -9
  229. package/src/results/annotated-source.ts +64 -6
  230. package/src/results/attempt-evidence.test.ts +13 -11
  231. package/src/results/attempt-evidence.ts +20 -13
  232. package/src/results/attempt-source.ts +6 -3
  233. package/src/results/copy.ts +150 -60
  234. package/src/results/host-equivalence.test.ts +34 -20
  235. package/src/results/index.ts +12 -4
  236. package/src/results/locator.test.ts +1 -22
  237. package/src/results/open.ts +15 -5
  238. package/src/results/publish.ts +149 -0
  239. package/src/results/results.test.ts +89 -54
  240. package/src/results/select.ts +104 -34
  241. package/src/results/truncate.ts +90 -0
  242. package/src/results/types.ts +43 -14
  243. package/src/results/writer.ts +31 -13
  244. package/src/runner/attempt.test.ts +138 -7
  245. package/src/runner/attempt.ts +603 -104
  246. package/src/runner/discover.test.ts +47 -0
  247. package/src/runner/discover.ts +36 -2
  248. package/src/runner/eval-source.test.ts +1 -27
  249. package/src/runner/feedback/agent.test.ts +504 -0
  250. package/src/runner/feedback/agent.ts +409 -0
  251. package/src/runner/feedback/ci.test.ts +562 -0
  252. package/src/runner/feedback/ci.ts +401 -0
  253. package/src/runner/feedback/coordinator.test.ts +317 -0
  254. package/src/runner/feedback/coordinator.ts +397 -0
  255. package/src/runner/feedback/failure.ts +40 -0
  256. package/src/runner/feedback/human.test.ts +616 -0
  257. package/src/runner/feedback/human.ts +535 -0
  258. package/src/runner/feedback/index.ts +66 -0
  259. package/src/runner/feedback/io.ts +78 -0
  260. package/src/runner/feedback/profile.test.ts +50 -0
  261. package/src/runner/feedback/profile.ts +58 -0
  262. package/src/runner/feedback/reducer.test.ts +395 -0
  263. package/src/runner/feedback/reducer.ts +260 -0
  264. package/src/runner/feedback/renderer.ts +82 -0
  265. package/src/runner/feedback/sink.ts +203 -0
  266. package/src/runner/feedback/testing.ts +106 -0
  267. package/src/runner/ledger.test.ts +230 -0
  268. package/src/runner/ledger.ts +329 -0
  269. package/src/runner/report.test.ts +128 -3
  270. package/src/runner/report.ts +33 -9
  271. package/src/runner/reporters/artifacts.ts +8 -2
  272. package/src/runner/reporters/braintrust.test.ts +8 -7
  273. package/src/runner/reporters/braintrust.ts +9 -2
  274. package/src/runner/reporters/index.ts +2 -2
  275. package/src/runner/reporters/json.test.ts +162 -0
  276. package/src/runner/reporters/json.ts +35 -8
  277. package/src/runner/reporters/shared.ts +1 -5
  278. package/src/runner/run.test.ts +760 -3
  279. package/src/runner/run.ts +243 -37
  280. package/src/runner/sandbox-prep.ts +3 -42
  281. package/src/runner/timing.ts +158 -0
  282. package/src/runner/types.ts +518 -22
  283. package/src/sandbox/checkpoint.test.ts +55 -0
  284. package/src/sandbox/checkpoint.ts +29 -8
  285. package/src/sandbox/cli-commands.ts +407 -0
  286. package/src/sandbox/docker.ts +115 -16
  287. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  288. package/src/sandbox/e2b-agent-template.ts +94 -0
  289. package/src/sandbox/e2b.ts +74 -9
  290. package/src/sandbox/errors.ts +111 -4
  291. package/src/sandbox/index.ts +2 -0
  292. package/src/sandbox/io-retry.test.ts +58 -0
  293. package/src/sandbox/io-retry.ts +45 -0
  294. package/src/sandbox/keep-registry.test.ts +86 -0
  295. package/src/sandbox/keep-registry.ts +142 -0
  296. package/src/sandbox/keep.ts +178 -0
  297. package/src/sandbox/paths.test.ts +1 -0
  298. package/src/sandbox/paths.ts +19 -8
  299. package/src/sandbox/registry.ts +20 -3
  300. package/src/sandbox/resolve.ts +76 -11
  301. package/src/sandbox/retry.test.ts +70 -0
  302. package/src/sandbox/retry.ts +46 -4
  303. package/src/sandbox/types.ts +44 -6
  304. package/src/sandbox/vercel.ts +43 -20
  305. package/src/scoring/collector.ts +60 -17
  306. package/src/scoring/coverage.ts +95 -0
  307. package/src/scoring/diff.ts +81 -0
  308. package/src/scoring/display.test.ts +121 -0
  309. package/src/scoring/display.ts +133 -0
  310. package/src/scoring/evidence.test.ts +189 -0
  311. package/src/scoring/judge.test.ts +142 -0
  312. package/src/scoring/judge.ts +15 -18
  313. package/src/scoring/scoped.ts +217 -50
  314. package/src/scoring/types.ts +117 -20
  315. package/src/scoring/verdict.ts +16 -4
  316. package/src/shared/aggregate.ts +8 -6
  317. package/src/shared/types.ts +31 -0
  318. package/src/show/compose.ts +50 -67
  319. package/src/show/index.ts +127 -56
  320. package/src/show/render.ts +662 -131
  321. package/src/show/report-host.test.ts +188 -0
  322. package/src/show/report-host.ts +375 -0
  323. package/src/show/show.test.ts +320 -54
  324. package/src/tty-line.ts +8 -26
  325. package/src/util.test.ts +1 -0
  326. package/src/util.ts +41 -0
  327. package/src/view/app/App.test.tsx +69 -0
  328. package/src/view/app/App.tsx +144 -48
  329. package/src/view/app/components/AttemptModal.tsx +423 -11
  330. package/src/view/app/components/CodeView.tsx +41 -14
  331. package/src/view/app/components/CopyControls.tsx +2 -2
  332. package/src/view/app/i18n.ts +37 -17
  333. package/src/view/app/lib/attempt-route.test.ts +1 -0
  334. package/src/view/app/lib/verdict.ts +7 -9
  335. package/src/view/app/main.tsx +13 -8
  336. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  337. package/src/view/app/types.ts +4 -1
  338. package/src/view/artifact-serving.test.ts +2 -1
  339. package/src/view/client-dist/app.css +1 -1
  340. package/src/view/client-dist/app.js +17 -17
  341. package/src/view/data.test.ts +10 -3
  342. package/src/view/data.ts +155 -49
  343. package/src/view/index.ts +56 -41
  344. package/src/view/server.ts +37 -15
  345. package/src/view/shared/types.ts +34 -5
  346. package/src/view/styles.css +227 -0
  347. package/src/view/view-report.test.ts +167 -62
  348. package/dist/report/built-ins/experiment-comparison.d.ts +0 -1
  349. package/dist/report/built-ins/experiment-comparison.js +0 -13
  350. package/dist/report/built-ins/index.d.ts +0 -1
  351. package/dist/report/built-ins/index.js +0 -2
  352. package/dist/report/react/GroupSummary.d.ts +0 -8
  353. package/dist/report/react/GroupSummary.js +0 -8
  354. package/dist/report/react/RunOverview.d.ts +0 -8
  355. package/dist/report/react/RunOverview.js +0 -12
  356. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  357. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  358. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  359. package/docs-site/zh/example/showcase.mdx +0 -39
  360. package/docs-site/zh/guides/publish-report.mdx +0 -91
  361. package/docs-site/zh/guides/sandbox-providers.mdx +0 -102
  362. package/src/report/built-in-user-parity.test.tsx +0 -640
  363. package/src/report/built-ins/experiment-comparison.tsx +0 -19
  364. package/src/report/built-ins/index.ts +0 -2
  365. package/src/report/react/GroupSummary.tsx +0 -66
  366. package/src/report/react/RunOverview.tsx +0 -109
  367. package/src/runner/reporters/console.ts +0 -70
  368. package/src/runner/reporters/live.test.ts +0 -56
  369. package/src/runner/reporters/live.ts +0 -247
  370. package/src/runner/reporters/quiet.test.ts +0 -66
  371. package/src/runner/reporters/quiet.ts +0 -49
  372. package/src/runner/reporters/table.ts +0 -277
  373. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  374. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  375. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  376. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  377. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  378. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
@@ -0,0 +1,230 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
2
+ // 变更分类账的集成测试:用宿主 shell 扮演沙箱(真实 git),验证
3
+ // - .git 不在 workdir 内(agent 看不到分类账;eval 自己 git init 不冲突)
4
+ // - eval 归因(send 前写入)不进 agent diff;send 窗口内写入逐窗口归因
5
+ // - 排除清单(默认 + ignore)与 include 打洞
6
+ // - 「创建又删除」「改回原样」净效果为 none,但触及窗口仍留痕(fileChanged 语义)
7
+
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+ import { exec } from "node:child_process";
10
+ import { mkdtemp, rm, writeFile, mkdir, readdir, readFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { createChangeLedger } from "./ledger.ts";
15
+ import { deriveDiffData } from "../scoring/diff.ts";
16
+ import type { CommandResult, Sandbox } from "../types.ts";
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ /** 宿主目录扮演沙箱 workdir;runShell 用真实 shell 跑,downloadFile 读宿主文件(ledger 只用这两个 + env)。 */
21
+ function hostSandbox(
22
+ workdir: string,
23
+ ledgerDir: string,
24
+ counters?: { shells?: string[]; downloads?: string[] },
25
+ ): Sandbox {
26
+ // 把 ledger 的固定 /tmp 路径前缀重定向到本测试的私有目录,测试之间互不污染
27
+ // (导出目录 /tmp/.niceeval-ledger-export 共享同一前缀,一条规则同时覆盖)。
28
+ const patchPath = (s: string) => s.replaceAll("/tmp/.niceeval-ledger", ledgerDir);
29
+ const runShell = async (script: string, opts?: { env?: Record<string, string> }): Promise<CommandResult> => {
30
+ counters?.shells?.push(script);
31
+ const env = { ...process.env, ...opts?.env };
32
+ if (env.GIT_DIR === "/tmp/.niceeval-ledger") env.GIT_DIR = ledgerDir;
33
+ try {
34
+ const { stdout, stderr } = await execAsync(patchPath(script), { cwd: workdir, env, maxBuffer: 64 * 1024 * 1024 });
35
+ return { stdout, stderr, exitCode: 0 };
36
+ } catch (e) {
37
+ const err = e as { stdout?: string; stderr?: string; code?: number };
38
+ return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.code ?? 1 };
39
+ }
40
+ };
41
+ const downloadFile = async (path: string): Promise<Buffer> => {
42
+ counters?.downloads?.push(path);
43
+ return Buffer.from(await readFile(patchPath(path)));
44
+ };
45
+ return {
46
+ workdir,
47
+ sandboxId: "host-test",
48
+ otlpHost: null,
49
+ runShell,
50
+ runCommand: async () => {
51
+ throw new Error("not used");
52
+ },
53
+ readFile: async () => "",
54
+ fileExists: async () => false,
55
+ readSourceFiles: async () => {
56
+ throw new Error("not used");
57
+ },
58
+ writeFiles: async () => {},
59
+ uploadFiles: async () => {},
60
+ uploadDirectory: async () => {},
61
+ downloadFile,
62
+ uploadFile: async () => {},
63
+ stop: async () => {},
64
+ } as unknown as Sandbox;
65
+ }
66
+
67
+ let roots: string[] = [];
68
+ async function makeDirs(): Promise<{ workdir: string; ledgerDir: string }> {
69
+ const base = await mkdtemp(join(tmpdir(), "niceeval-ledger-"));
70
+ roots.push(base);
71
+ const workdir = join(base, "work");
72
+ await mkdir(workdir, { recursive: true });
73
+ return { workdir, ledgerDir: join(base, "ledger") };
74
+ }
75
+
76
+ afterEach(async () => {
77
+ await Promise.all(roots.map((r) => rm(r, { recursive: true, force: true })));
78
+ roots = [];
79
+ });
80
+
81
+ describe("createChangeLedger", () => {
82
+ it("锚点后 workdir 素净(无 .git);逐窗口归因,eval 侧写入不进 agent diff", async () => {
83
+ const { workdir, ledgerDir } = await makeDirs();
84
+ await writeFile(join(workdir, "start.txt"), "fixture\n");
85
+ const sandbox = hostSandbox(workdir, ledgerDir);
86
+ const ledger = await createChangeLedger(sandbox);
87
+
88
+ // workdir 保持素净:分类账的 git 目录在 workdir 外。
89
+ expect(await readdir(workdir)).not.toContain(".git");
90
+
91
+ // eval 侧写入(send 前):进 eval 归因,不进 agent diff。
92
+ await writeFile(join(workdir, "fixture.json"), "{}\n");
93
+ await ledger.commitEvalWindow("s1/t1");
94
+
95
+ // 窗口 1:agent 改 start.txt、新建 out.txt。
96
+ await writeFile(join(workdir, "start.txt"), "changed by agent\n");
97
+ await writeFile(join(workdir, "out.txt"), "hello\n");
98
+ await writeFile(join(workdir, "with space.txt"), "space-safe\n");
99
+ await writeFile(join(workdir, "binary.bin"), Buffer.from([0, 1, 2, 3]));
100
+ await ledger.commitAgentWindow("s1/t1");
101
+
102
+ // 窗口间 eval 写入(隐藏校验文件):不得计入任何 agent 窗口。
103
+ await writeFile(join(workdir, "hidden-check.txt"), "verify\n");
104
+ await ledger.commitEvalWindow("s1/t2");
105
+
106
+ // 窗口 2:agent 删除 out.txt(创建又删除 → 净 none,但两个窗口都留痕)。
107
+ await rm(join(workdir, "out.txt"));
108
+ await ledger.commitAgentWindow("s1/t2");
109
+
110
+ const windows = await ledger.exportWindows();
111
+ expect(windows.map((w) => w.window)).toEqual(["s1/t1", "s1/t2"]);
112
+ expect(windows[0]!.changes["start.txt"]).toMatchObject({ status: "modified", after: "changed by agent\n" });
113
+ expect(windows[0]!.changes["out.txt"]).toMatchObject({ status: "added", after: "hello\n" });
114
+ expect(windows[0]!.changes["with space.txt"]).toMatchObject({ status: "added", after: "space-safe\n" });
115
+ expect(windows[0]!.changes["binary.bin"]).toEqual({ status: "added", binary: { afterBytes: 4 } });
116
+ expect(windows[0]!.changes["fixture.json"]).toBeUndefined();
117
+ expect(windows[1]!.changes["out.txt"]).toMatchObject({ status: "deleted", before: "hello\n" });
118
+ expect(windows[1]!.changes["hidden-check.txt"]).toBeUndefined();
119
+
120
+ const diff = deriveDiffData(windows);
121
+ // fileChanged 语义:任一窗口触及即算发生过;net=none(创建又删除)仍留痕。
122
+ expect(diff.files["out.txt"]).toEqual({ net: "none", windows: ["s1/t1", "s1/t2"] });
123
+ expect(diff.files["start.txt"]).toEqual({ net: "modified", windows: ["s1/t1"] });
124
+ expect(diff.get("start.txt")).toBe("changed by agent\n");
125
+ expect(diff.get("out.txt")).toBeUndefined();
126
+ });
127
+
128
+ it("eval 可以在 workdir 自己 git init,不与分类账冲突;agent 的 .git 不进归因", async () => {
129
+ const { workdir, ledgerDir } = await makeDirs();
130
+ const sandbox = hostSandbox(workdir, ledgerDir);
131
+ const ledger = await createChangeLedger(sandbox);
132
+
133
+ // eval 在 workdir 里建真实 git repo(agent 视角的项目仓库)。
134
+ await execAsync('git init -q && git config user.email t@t && git config user.name t', { cwd: workdir });
135
+ await ledger.commitEvalWindow("s1/t1");
136
+
137
+ await writeFile(join(workdir, "app.ts"), "export {};\n");
138
+ await ledger.commitAgentWindow("s1/t1");
139
+
140
+ const windows = await ledger.exportWindows();
141
+ expect(Object.keys(windows[0]!.changes)).toEqual(["app.ts"]);
142
+ });
143
+
144
+ it("项目 .gitignore 不参与归因(被 ignore 的文件照常记录);默认排除 + ignore + include 打洞", async () => {
145
+ const { workdir, ledgerDir } = await makeDirs();
146
+ const sandbox = hostSandbox(workdir, ledgerDir);
147
+ // secret/ 追加排除;node_modules/keep.js 显式加回。
148
+ const ledger = await createChangeLedger(sandbox, { ignore: ["secret"], include: ["node_modules/keep.js"] });
149
+
150
+ // agent 写 .gitignore 忽略 output.txt:影响不了分类账(项目 ignore 不参与归因)。
151
+ await writeFile(join(workdir, ".gitignore"), "output.txt\n");
152
+ await writeFile(join(workdir, "output.txt"), "ignored by project, recorded by ledger\n");
153
+ await mkdir(join(workdir, "node_modules"), { recursive: true });
154
+ await writeFile(join(workdir, "node_modules", "dep.js"), "excluded\n");
155
+ await writeFile(join(workdir, "node_modules", "keep.js"), "included back\n");
156
+ await mkdir(join(workdir, "secret"), { recursive: true });
157
+ await writeFile(join(workdir, "secret", "token.txt"), "excluded via ignore\n");
158
+ // Python 工具链目录不依赖项目 .gitignore:任意 *venv*/ 名字都由 runner 私有清单排除。
159
+ for (const dir of ["venv", ".venv", ".testing-venv", "tools/pypi-venv"]) {
160
+ await mkdir(join(workdir, dir), { recursive: true });
161
+ await writeFile(join(workdir, dir, "dependency.py"), "excluded virtualenv dependency\n");
162
+ }
163
+ await ledger.commitAgentWindow("s1/t1");
164
+
165
+ const windows = await ledger.exportWindows();
166
+ const paths = Object.keys(windows[0]!.changes).sort();
167
+ expect(paths).toContain("output.txt");
168
+ expect(paths).toContain("node_modules/keep.js");
169
+ expect(paths).not.toContain("node_modules/dep.js");
170
+ expect(paths).not.toContain("secret/token.txt");
171
+ expect(paths.some((path) => path.includes("venv"))).toBe(false);
172
+ });
173
+
174
+ it("整相导出只用一条 shell 命令 + 一次文件下载,不随文件数与窗口数增长", async () => {
175
+ const { workdir, ledgerDir } = await makeDirs();
176
+ const counters = { shells: [] as string[], downloads: [] as string[] };
177
+ const sandbox = hostSandbox(workdir, ledgerDir, counters);
178
+ const ledger = await createChangeLedger(sandbox);
179
+ await mkdir(join(workdir, "generated"), { recursive: true });
180
+ await Promise.all(
181
+ Array.from({ length: 500 }, (_, i) => writeFile(join(workdir, "generated", `${i}.txt`), `file ${i}\n`)),
182
+ );
183
+ await ledger.commitAgentWindow("s1/t1");
184
+ await writeFile(join(workdir, "second.txt"), "second window\n");
185
+ await ledger.commitAgentWindow("s1/t2");
186
+
187
+ const beforeExport = counters.shells.length;
188
+ const windows = await ledger.exportWindows();
189
+
190
+ // 全部窗口一条沙箱内命令导出 + 一次导出文件下载;不随 500 个文件或窗口数增长。
191
+ expect(counters.shells.length - beforeExport).toBe(1);
192
+ expect(counters.downloads).toHaveLength(1);
193
+ expect(windows).toHaveLength(2);
194
+ expect(Object.keys(windows[0]!.changes)).toHaveLength(500);
195
+ expect(windows[0]!.changes["generated/499.txt"]).toEqual({ status: "added", after: "file 499\n" });
196
+ expect(windows[1]!.changes).toEqual({ "second.txt": { status: "added", after: "second window\n" } });
197
+ });
198
+
199
+ it("单窗口超过路径上限时明确失败,不伪造成空 diff", async () => {
200
+ const { workdir, ledgerDir } = await makeDirs();
201
+ const sandbox = hostSandbox(workdir, ledgerDir);
202
+ const ledger = await createChangeLedger(sandbox);
203
+ await mkdir(join(workdir, "generated"), { recursive: true });
204
+ await Promise.all(
205
+ Array.from({ length: 10_001 }, (_, i) => writeFile(join(workdir, "generated", `${i}.txt`), "")),
206
+ );
207
+ await ledger.commitAgentWindow("s1/t1");
208
+
209
+ await expect(ledger.exportWindows()).rejects.toThrow("contains 10001 paths; limit is 10000");
210
+ }, 30_000);
211
+
212
+ it("单窗口文本证据超过字节上限时明确失败,尺寸核算先于内容传输", async () => {
213
+ const { workdir, ledgerDir } = await makeDirs();
214
+ const sandbox = hostSandbox(workdir, ledgerDir);
215
+ const ledger = await createChangeLedger(sandbox);
216
+ await writeFile(join(workdir, "huge.txt"), "x".repeat(65 * 1024 * 1024));
217
+ await ledger.commitAgentWindow("s1/t1");
218
+
219
+ await expect(ledger.exportWindows()).rejects.toThrow("blob bytes");
220
+ }, 30_000);
221
+
222
+ it("窗口内没有变化时仍落一条空窗口(changes 为空对象)", async () => {
223
+ const { workdir, ledgerDir } = await makeDirs();
224
+ const sandbox = hostSandbox(workdir, ledgerDir);
225
+ const ledger = await createChangeLedger(sandbox);
226
+ await ledger.commitAgentWindow("s1/t1");
227
+ const windows = await ledger.exportWindows();
228
+ expect(windows).toEqual([{ window: "s1/t1", changes: {} }]);
229
+ });
230
+ });
@@ -0,0 +1,329 @@
1
+ // 变更分类账(私有 git ledger):回答「**agent** 改了什么」,不是「workspace 相对空目录变了什么」。
2
+ // 契约见 docs/feature/sandbox/architecture.md「变更归因:send 窗口与分类账」:
3
+ // - ledger 的 git 目录在沙箱内、workdir 外(runner 私有路径),以 workdir 为 work-tree——
4
+ // workdir 保持素净:agent 看不到 runner 的 .git,eval 需要真实 git repo 时自己 git init,
5
+ // agent 在 workdir 里的任何 git 操作都碰不到分类账。
6
+ // - 三类 commit 时点:锚点一笔(workspace.baseline);每次 t.send() 进入前 workdir 有未记录
7
+ // 变化就落一笔 eval 归因;t.send() 返回后落一笔 agent 归因(send 窗口内的全部变化)。
8
+ // - 归因排除清单 runner 私有、锚点时冻结:项目自己的 .gitignore 不参与归因判断(add -f 绕过),
9
+ // 排除靠 pathspec,include 显式打洞加回。
10
+ // - agent 归因增量 = 逐窗口 delta 序列(DiffWindow[]),不做跨窗口压缩。
11
+
12
+ import type { DiffArtifact, DiffWindow, Sandbox, WindowChange } from "../types.ts";
13
+
14
+ /** ledger 的私有 git 目录:workdir 之外、runner 控制;agent 的工具默认不会去 /tmp 翻它。 */
15
+ const LEDGER_GIT_DIR = "/tmp/.niceeval-ledger";
16
+
17
+ /** 整相导出文件的落点(与 ledger 同前缀,同样是 runner 私有路径)。 */
18
+ const EXPORT_DIR = "/tmp/.niceeval-ledger-export";
19
+
20
+ /** 默认归因排除清单(锚点时冻结):依赖、构建产物、包管理器缓存与 niceeval 自己的落位。 */
21
+ const DEFAULT_EXCLUDES = [
22
+ ".git",
23
+ "node_modules",
24
+ ".next",
25
+ "dist",
26
+ "build",
27
+ ".turbo",
28
+ ".niceeval",
29
+ "__niceeval__",
30
+ "coverage",
31
+ ".cache",
32
+ ".pnpm-store",
33
+ ".npm",
34
+ ".yarn",
35
+ "*venv*/",
36
+ ".venv",
37
+ "__pycache__",
38
+ ];
39
+
40
+ /** 单个 send 窗口的证据安全上限。越界必须让 workspace.diff 失败,不能产出误导性的空窗口。 */
41
+ const MAX_WINDOW_PATHS = 10_000;
42
+ const MAX_WINDOW_BLOB_BYTES = 64 * 1024 * 1024;
43
+
44
+ /**
45
+ * 整相导出:一条 POSIX shell 命令枚举**全部** agent 窗口并把证据写进沙箱内导出文件,宿主随后
46
+ * 经文件通道一次下载——provider 往返数与窗口数、文件数都无关。对沙箱环境的全部要求是 git 与
47
+ * POSIX 工具(sh/awk/wc/sort/grep),不依赖 node、python 等运行时。
48
+ *
49
+ * 沙箱内不组装 JSON:导出文件直接拼接 git 原生输出——每窗口四段,`diff-tree -z`(状态 + 前后
50
+ * blob sha + 路径,NUL 安全)、`numstat -z`(二进制识别)、`cat-file --batch-check`(全部 blob
51
+ * 尺寸)、`cat-file --batch`(文本 blob 内容,输出自带长度帧)——解析与校验全部在宿主侧完成。
52
+ * 需要在沙箱内做的两个判断都不解析路径列:sha 提取用 awk 取 diff-tree 原始输出的第 3/4 列
53
+ * (路径在其后,含空格也不影响);二进制排除靠 numstat 与 diff-tree 输出的行号对齐。
54
+ * 尺寸核算(--batch-check)先于内容读取(--batch),越界在产出任何内容前失败。
55
+ */
56
+ const EXPORT_SCRIPT = [
57
+ "set -eu",
58
+ `D=${EXPORT_DIR}`,
59
+ 'rm -rf "$D" && mkdir -p "$D"',
60
+ 'OUT=$D/export.bin',
61
+ ': > "$OUT"',
62
+ 'fail() { printf "%s\\n" "$1" >&2; exit 2; }',
63
+ // 帧格式:`section <name> <bytes>` 头一行,后跟原始字节;长度前缀让内容里的任意字节都安全。
64
+ 'emit() { printf "section %s %s\\n" "$1" "$(($(wc -c < "$2")))" >> "$OUT"; cat "$2" >> "$OUT"; }',
65
+ 'git log --reverse --format="%H %s" > "$D/log.txt"',
66
+ "while IFS= read -r line; do",
67
+ ' hash=${line%% *}',
68
+ ' subject=${line#* }',
69
+ ' case $subject in "agent "*) ;; *) continue ;; esac',
70
+ ' label=${subject#agent }',
71
+ // 非 -z 的 diff-tree 把特殊路径引号转义成单行,一行 = 一个条目:wc -l 即路径数,awk 列取 sha 不碰路径。
72
+ ' git diff-tree -r --no-renames "$hash^" "$hash" > "$D/dt.txt"',
73
+ ' n=$(($(wc -l < "$D/dt.txt")))',
74
+ ` [ "$n" -le ${MAX_WINDOW_PATHS} ] || fail "niceeval diff window contains $n paths; limit is ${MAX_WINDOW_PATHS}"`,
75
+ ' git diff-tree -r --no-renames --numstat "$hash^" "$hash" > "$D/ns.txt"',
76
+ // 全部 blob 出现次数(before + after,零 sha = 无此侧,排除)→ 逐次尺寸核算。
77
+ " awk '$3 !~ /^0+$/ { print $3 } $4 !~ /^0+$/ { print $4 }' \"$D/dt.txt\" > \"$D/occ.txt\"",
78
+ ' git cat-file --batch-check < "$D/occ.txt" > "$D/sizes.txt"',
79
+ ' if grep -q " missing$" "$D/sizes.txt"; then fail "niceeval ledger export: blob object missing"; fi',
80
+ ` awk '{ s += $3 } END { exit (s > ${MAX_WINDOW_BLOB_BYTES}) ? 1 : 0 }' "$D/sizes.txt" || fail "niceeval diff window contains more than ${MAX_WINDOW_BLOB_BYTES} blob bytes; narrow defineEval({ diff }) include/ignore rules"`,
81
+ // 文本 blob 内容请求:numstat 第 1 列为 "-" 的行是二进制,按行号对齐排除;去重后交给 --batch。
82
+ ' awk \'NR==FNR { bin[FNR] = ($1 == "-"); next } bin[FNR] != 1 { if ($3 !~ /^0+$/) print $3; if ($4 !~ /^0+$/) print $4 }\' "$D/ns.txt" "$D/dt.txt" | sort -u > "$D/text.txt"',
83
+ ' git cat-file --batch < "$D/text.txt" > "$D/blobs.bin"',
84
+ ' git diff-tree -r --no-renames -z "$hash^" "$hash" > "$D/dtz.bin"',
85
+ ' git diff-tree -r --no-renames --numstat -z "$hash^" "$hash" > "$D/nsz.bin"',
86
+ ' printf "window %s %s\\n" "$hash" "$label" >> "$OUT"',
87
+ ' emit difftree "$D/dtz.bin"',
88
+ ' emit numstat "$D/nsz.bin"',
89
+ ' emit sizes "$D/sizes.txt"',
90
+ ' emit blobs "$D/blobs.bin"',
91
+ 'done < "$D/log.txt"',
92
+ ].join("\n");
93
+
94
+ export interface ChangeLedger {
95
+ /** send 进入前:workdir 有未记录变化就落一笔 eval 归因(fixture / setup / runCommand 副作用)。 */
96
+ commitEvalWindow(label: string): Promise<void>;
97
+ /** send 返回后:落一笔 agent 归因——这个 send 窗口内的全部 workspace 变化(无变化也落空窗口)。 */
98
+ commitAgentWindow(label: string): Promise<void>;
99
+ /** workspace.diff 阶段:从分类账导出每个 send 窗口自己的 before/after,按时序。 */
100
+ exportWindows(): Promise<DiffArtifact>;
101
+ }
102
+
103
+ interface LedgerOptions {
104
+ /** defineEval({ diff }) 的归因调整:ignore 追加排除,include 打洞加回(优先级最高)。 */
105
+ include?: string[];
106
+ ignore?: string[];
107
+ }
108
+
109
+ /** 每条 git 命令都带上私有 GIT_DIR + workdir work-tree;项目/全局 gitignore 一律不参与。 */
110
+ function gitEnv(sandbox: Sandbox): Record<string, string> {
111
+ return {
112
+ GIT_DIR: LEDGER_GIT_DIR,
113
+ GIT_WORK_TREE: sandbox.workdir,
114
+ GIT_AUTHOR_NAME: "niceeval",
115
+ GIT_AUTHOR_EMAIL: "niceeval@localhost",
116
+ GIT_COMMITTER_NAME: "niceeval",
117
+ GIT_COMMITTER_EMAIL: "niceeval@localhost",
118
+ HOME: "/tmp",
119
+ };
120
+ }
121
+
122
+ function shellQuote(s: string): string {
123
+ return `'${s.replaceAll("'", `'\\''`)}'`;
124
+ }
125
+
126
+ /** 打分类账锚点(workspace.baseline 阶段,环境层钩子之后):git init + 冻结排除清单 + 首笔 commit。 */
127
+ export async function createChangeLedger(sandbox: Sandbox, opts?: LedgerOptions): Promise<ChangeLedger> {
128
+ const excludes = [...DEFAULT_EXCLUDES, ...(opts?.ignore ?? [])];
129
+ const includes = opts?.include ?? [];
130
+ const env = gitEnv(sandbox);
131
+
132
+ // add -A -f:绕过项目自己的 .gitignore(项目 ignore 的文件照常记录);排除靠 pathspec
133
+ // (runner 私有清单,agent / fixture 写 .gitignore 影响不了它);include 用第二次 add 打洞加回。
134
+ const excludeSpecs = excludes.map((e) => shellQuote(`:(exclude)${e}`)).join(" ");
135
+ // include 打洞:路径此刻可能还不存在(如 agent 之后才写),unmatched pathspec 不算错。
136
+ 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}`;
139
+
140
+ const anchor = await sandbox.runShell(`git init -q "${LEDGER_GIT_DIR}" && ${addAll} && git commit -q --allow-empty -m "anchor"`, {
141
+ env,
142
+ });
143
+ ensureCommandSucceeded(anchor, "create change ledger anchor");
144
+
145
+ return {
146
+ async commitEvalWindow(label: string): Promise<void> {
147
+ // 有未记录变化才落这一笔;干净时不产生空的 eval 归因 commit。
148
+ const result = await sandbox.runShell(`${addAll} && (git diff --cached --quiet || git commit -q -m ${shellQuote(`eval ${label}`)})`, {
149
+ env,
150
+ });
151
+ ensureCommandSucceeded(result, `commit eval window ${label}`);
152
+ },
153
+ async commitAgentWindow(label: string): Promise<void> {
154
+ // 窗口内没有变化时也落一条(--allow-empty),diff.json 里该窗口 changes 为空对象。
155
+ const result = await sandbox.runShell(`${addAll} && git commit -q --allow-empty -m ${shellQuote(`agent ${label}`)}`, { env });
156
+ ensureCommandSucceeded(result, `commit agent window ${label}`);
157
+ },
158
+ async exportWindows(): Promise<DiffArtifact> {
159
+ return exportAgentWindows(sandbox, env);
160
+ },
161
+ };
162
+ }
163
+
164
+ async function exportAgentWindows(sandbox: Sandbox, env: Record<string, string>): Promise<DiffArtifact> {
165
+ const result = await sandbox.runShell(EXPORT_SCRIPT, { env });
166
+ ensureCommandSucceeded(result, "export agent windows");
167
+ const payload = await sandbox.downloadFile(`${EXPORT_DIR}/export.bin`);
168
+ return parseExportPayload(payload);
169
+ }
170
+
171
+ function ensureCommandSucceeded(result: { exitCode: number; stderr: string }, operation: string): void {
172
+ if (result.exitCode === 0) return;
173
+ const detail = result.stderr.trim().split("\n")[0];
174
+ throw new Error(`${operation} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
175
+ }
176
+
177
+ // ---------- 导出文件的宿主侧解析(严格校验:帧、git 输出形状、尺寸与内容的完备性) ----------
178
+
179
+ const ZERO_SHA = /^0+$/;
180
+
181
+ function parseExportPayload(payload: Buffer): DiffArtifact {
182
+ const windows: DiffWindow[] = [];
183
+ let offset = 0;
184
+
185
+ const readLine = (): string => {
186
+ const newline = payload.indexOf(0x0a, offset);
187
+ if (newline === -1) throw new Error("niceeval ledger export: truncated header");
188
+ const line = payload.subarray(offset, newline).toString("utf8");
189
+ offset = newline + 1;
190
+ return line;
191
+ };
192
+ const readSection = (expected: string): Buffer => {
193
+ const header = readLine().split(" ");
194
+ if (header.length !== 3 || header[0] !== "section" || header[1] !== expected) {
195
+ throw new Error(`niceeval ledger export: expected section ${expected}, got ${JSON.stringify(header.join(" "))}`);
196
+ }
197
+ const length = Number(header[2]);
198
+ if (!isNonNegativeInteger(length) || offset + length > payload.length) {
199
+ throw new Error(`niceeval ledger export: invalid ${expected} section length`);
200
+ }
201
+ const body = payload.subarray(offset, offset + length);
202
+ offset += length;
203
+ return body;
204
+ };
205
+
206
+ while (offset < payload.length) {
207
+ const header = readLine();
208
+ if (!header.startsWith("window ")) throw new Error(`niceeval ledger export: expected window header, got ${JSON.stringify(header)}`);
209
+ const rest = header.slice("window ".length);
210
+ const space = rest.indexOf(" ");
211
+ const label = space === -1 ? "" : rest.slice(space + 1);
212
+ const entries = parseDiffTree(readSection("difftree"), label);
213
+ const binaryPaths = parseBinaryPaths(readSection("numstat"), label);
214
+ const sizeBySha = parseSizes(readSection("sizes"), label);
215
+ const contentBySha = parseBlobBatch(readSection("blobs"), label);
216
+
217
+ const changes: Record<string, WindowChange> = {};
218
+ for (const entry of entries) {
219
+ const change: WindowChange = { status: entry.status };
220
+ if (binaryPaths.has(entry.path)) {
221
+ const binary: NonNullable<WindowChange["binary"]> = {};
222
+ if (entry.status !== "added") binary.beforeBytes = requireSize(sizeBySha, entry.beforeSha, label, entry.path);
223
+ if (entry.status !== "deleted") binary.afterBytes = requireSize(sizeBySha, entry.afterSha, label, entry.path);
224
+ change.binary = binary;
225
+ } else {
226
+ if (entry.status !== "added") change.before = requireContent(contentBySha, entry.beforeSha, label, entry.path);
227
+ if (entry.status !== "deleted") change.after = requireContent(contentBySha, entry.afterSha, label, entry.path);
228
+ }
229
+ changes[entry.path] = change;
230
+ }
231
+ windows.push({ window: label, changes });
232
+ }
233
+ return windows;
234
+ }
235
+
236
+ interface DiffTreeEntry {
237
+ path: string;
238
+ status: WindowChange["status"];
239
+ beforeSha: string;
240
+ afterSha: string;
241
+ }
242
+
243
+ /** `git diff-tree -r --no-renames -z` 原始输出:`:mode mode sha sha status NUL path NUL` 重复。 */
244
+ function parseDiffTree(section: Buffer, label: string): DiffTreeEntry[] {
245
+ const parts = section.toString("utf8").split("\0");
246
+ const entries: DiffTreeEntry[] = [];
247
+ for (let i = 0; i < parts.length; ) {
248
+ const meta = parts[i++];
249
+ if (!meta) continue;
250
+ const path = parts[i++];
251
+ const fields = meta.split(" ");
252
+ if (path === undefined || fields.length !== 5 || !fields[0]!.startsWith(":")) {
253
+ throw new Error(`export window ${label}: malformed git diff-tree output`);
254
+ }
255
+ const code = fields[4]!;
256
+ const status = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified";
257
+ entries.push({ path, status, beforeSha: fields[2]!, afterSha: fields[3]! });
258
+ }
259
+ return entries;
260
+ }
261
+
262
+ /** `git diff-tree --numstat -z`:二进制条目的 added/removed 计数是 `-`。 */
263
+ function parseBinaryPaths(section: Buffer, label: string): Set<string> {
264
+ const paths = new Set<string>();
265
+ for (const record of section.toString("utf8").split("\0")) {
266
+ if (!record) continue;
267
+ const firstTab = record.indexOf("\t");
268
+ const secondTab = record.indexOf("\t", firstTab + 1);
269
+ if (firstTab === -1 || secondTab === -1) throw new Error(`export window ${label}: malformed git --numstat output`);
270
+ if (record.slice(0, firstTab) === "-" && record.slice(firstTab + 1, secondTab) === "-") {
271
+ paths.add(record.slice(secondTab + 1));
272
+ }
273
+ }
274
+ return paths;
275
+ }
276
+
277
+ /** `git cat-file --batch-check`:每行 `sha type size`(输入是逐出现次数的 sha,重复无害)。 */
278
+ function parseSizes(section: Buffer, label: string): Map<string, number> {
279
+ const sizes = new Map<string, number>();
280
+ for (const line of section.toString("utf8").split("\n")) {
281
+ if (!line) continue;
282
+ const fields = line.split(" ");
283
+ const size = Number(fields[fields.length - 1]);
284
+ if (fields.length < 3 || !isNonNegativeInteger(size)) {
285
+ throw new Error(`export window ${label}: malformed git cat-file --batch-check output`);
286
+ }
287
+ sizes.set(fields[0]!, size);
288
+ }
289
+ return sizes;
290
+ }
291
+
292
+ /** `git cat-file --batch`:自带长度帧——`sha type size\n` + size 字节内容 + `\n`。 */
293
+ function parseBlobBatch(section: Buffer, label: string): Map<string, string> {
294
+ const contents = new Map<string, string>();
295
+ let offset = 0;
296
+ while (offset < section.length) {
297
+ const newline = section.indexOf(0x0a, offset);
298
+ if (newline === -1) throw new Error(`export window ${label}: malformed git cat-file --batch header`);
299
+ const fields = section.subarray(offset, newline).toString("utf8").split(" ");
300
+ const size = Number(fields[fields.length - 1]);
301
+ if (fields.length < 3 || !isNonNegativeInteger(size)) {
302
+ throw new Error(`export window ${label}: malformed git cat-file --batch header`);
303
+ }
304
+ const contentStart = newline + 1;
305
+ const contentEnd = contentStart + size;
306
+ if (contentEnd >= section.length || section[contentEnd] !== 0x0a) {
307
+ throw new Error(`export window ${label}: truncated git cat-file --batch content`);
308
+ }
309
+ contents.set(fields[0]!, section.subarray(contentStart, contentEnd).toString("utf8"));
310
+ offset = contentEnd + 1;
311
+ }
312
+ return contents;
313
+ }
314
+
315
+ function requireSize(sizes: Map<string, number>, sha: string, label: string, path: string): number {
316
+ const size = sizes.get(sha);
317
+ if (size === undefined || ZERO_SHA.test(sha)) throw new Error(`export window ${label}: missing blob size for ${JSON.stringify(path)}`);
318
+ return size;
319
+ }
320
+
321
+ function requireContent(contents: Map<string, string>, sha: string, label: string, path: string): string {
322
+ const content = contents.get(sha);
323
+ if (content === undefined || ZERO_SHA.test(sha)) throw new Error(`export window ${label}: missing blob content for ${JSON.stringify(path)}`);
324
+ return content;
325
+ }
326
+
327
+ function isNonNegativeInteger(value: unknown): value is number {
328
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
329
+ }