devcontour-harness 0.0.0-stage → 0.1.0-alpha.6

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 (390) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -2
  3. package/docs/architecture.md +473 -0
  4. package/docs/compatibility-versions.md +56 -0
  5. package/docs/compatibility.md +192 -0
  6. package/docs/connectors.md +323 -0
  7. package/docs/contracts.md +603 -0
  8. package/docs/integrations/hindsight.md +148 -0
  9. package/docs/quickstart.md +182 -0
  10. package/docs/release-notes/0.1.0-alpha.6.md +238 -0
  11. package/docs/review-probes.md +120 -0
  12. package/docs/review-protocol.md +251 -0
  13. package/docs/toolchains.md +167 -0
  14. package/docs/turbo-starter.md +269 -0
  15. package/docs/upgrade.md +202 -0
  16. package/examples/README.md +27 -0
  17. package/examples/external-connector/README.md +19 -0
  18. package/examples/external-connector/connector.mjs +55 -0
  19. package/examples/files-only/README.md +28 -0
  20. package/examples/files-only/context/norms/rules.md +4 -0
  21. package/examples/files-only/graph.json +23 -0
  22. package/examples/files-only/policy.json +30 -0
  23. package/examples/one-vendor/README.md +19 -0
  24. package/examples/one-vendor/policy.json +27 -0
  25. package/examples/single-task/README.md +18 -0
  26. package/examples/single-task/graph.json +27 -0
  27. package/examples/single-task/policy-same-vendor.json +55 -0
  28. package/examples/single-task/policy.json +52 -0
  29. package/examples/single-task/src/.gitkeep +0 -0
  30. package/examples/single-task/test/run.js +19 -0
  31. package/examples/toolchains/README.md +32 -0
  32. package/examples/toolchains/go/bad/label/label.go +9 -0
  33. package/examples/toolchains/go/good/label/label.go +9 -0
  34. package/examples/toolchains/go/policy.json +79 -0
  35. package/examples/toolchains/go/repo/REQUIREMENTS.md +9 -0
  36. package/examples/toolchains/go/repo/_gitignore +1 -0
  37. package/examples/toolchains/go/repo/go.mod +5 -0
  38. package/examples/toolchains/go/repo/go.sum +2 -0
  39. package/examples/toolchains/go/repo/label/label.go +9 -0
  40. package/examples/toolchains/go/repo/label/label_test.go +27 -0
  41. package/examples/toolchains/go/repo/label/version.go +9 -0
  42. package/examples/toolchains/go/task.json +25 -0
  43. package/examples/toolchains/mixed-go/bad/backend/label/label.go +9 -0
  44. package/examples/toolchains/mixed-go/bad/frontend/src/label.js +4 -0
  45. package/examples/toolchains/mixed-go/good/backend/label/label.go +9 -0
  46. package/examples/toolchains/mixed-go/good/frontend/src/label.js +4 -0
  47. package/examples/toolchains/mixed-go/policy.json +126 -0
  48. package/examples/toolchains/mixed-go/repo/REQUIREMENTS.md +9 -0
  49. package/examples/toolchains/mixed-go/repo/_gitignore +6 -0
  50. package/examples/toolchains/mixed-go/repo/backend/go.mod +3 -0
  51. package/examples/toolchains/mixed-go/repo/backend/label/label.go +9 -0
  52. package/examples/toolchains/mixed-go/repo/backend/label/label_test.go +30 -0
  53. package/examples/toolchains/mixed-go/repo/contract/check.mjs +17 -0
  54. package/examples/toolchains/mixed-go/repo/contract/label.json +9 -0
  55. package/examples/toolchains/mixed-go/repo/frontend/package.json +15 -0
  56. package/examples/toolchains/mixed-go/repo/frontend/pnpm-lock.yaml +22 -0
  57. package/examples/toolchains/mixed-go/repo/frontend/src/label.js +4 -0
  58. package/examples/toolchains/mixed-go/repo/frontend/test/label.test.js +11 -0
  59. package/examples/toolchains/mixed-go/task.json +25 -0
  60. package/examples/toolchains/mixed-python/bad/backend/src/fx_backend/__init__.py +6 -0
  61. package/examples/toolchains/mixed-python/bad/frontend/src/label.js +4 -0
  62. package/examples/toolchains/mixed-python/good/backend/src/fx_backend/__init__.py +6 -0
  63. package/examples/toolchains/mixed-python/good/frontend/src/label.js +4 -0
  64. package/examples/toolchains/mixed-python/policy.json +121 -0
  65. package/examples/toolchains/mixed-python/repo/REQUIREMENTS.md +9 -0
  66. package/examples/toolchains/mixed-python/repo/_gitignore +6 -0
  67. package/examples/toolchains/mixed-python/repo/backend/.python-version +1 -0
  68. package/examples/toolchains/mixed-python/repo/backend/pyproject.toml +19 -0
  69. package/examples/toolchains/mixed-python/repo/backend/src/fx_backend/__init__.py +6 -0
  70. package/examples/toolchains/mixed-python/repo/backend/tests/test_label.py +11 -0
  71. package/examples/toolchains/mixed-python/repo/backend/uv.lock +108 -0
  72. package/examples/toolchains/mixed-python/repo/contract/check.mjs +17 -0
  73. package/examples/toolchains/mixed-python/repo/contract/label.json +9 -0
  74. package/examples/toolchains/mixed-python/repo/frontend/package.json +15 -0
  75. package/examples/toolchains/mixed-python/repo/frontend/pnpm-lock.yaml +22 -0
  76. package/examples/toolchains/mixed-python/repo/frontend/src/label.js +4 -0
  77. package/examples/toolchains/mixed-python/repo/frontend/test/label.test.js +11 -0
  78. package/examples/toolchains/mixed-python/task.json +25 -0
  79. package/examples/toolchains/npm/bad/src/label.js +4 -0
  80. package/examples/toolchains/npm/good/src/label.js +4 -0
  81. package/examples/toolchains/npm/policy.json +82 -0
  82. package/examples/toolchains/npm/repo/REQUIREMENTS.md +9 -0
  83. package/examples/toolchains/npm/repo/_gitignore +4 -0
  84. package/examples/toolchains/npm/repo/package-lock.json +22 -0
  85. package/examples/toolchains/npm/repo/package.json +13 -0
  86. package/examples/toolchains/npm/repo/src/label.js +4 -0
  87. package/examples/toolchains/npm/repo/test/label.test.js +13 -0
  88. package/examples/toolchains/npm/task.json +25 -0
  89. package/examples/toolchains/pnpm/bad/packages/label/src/label.js +4 -0
  90. package/examples/toolchains/pnpm/good/packages/label/src/label.js +4 -0
  91. package/examples/toolchains/pnpm/policy.json +85 -0
  92. package/examples/toolchains/pnpm/repo/REQUIREMENTS.md +9 -0
  93. package/examples/toolchains/pnpm/repo/_gitignore +4 -0
  94. package/examples/toolchains/pnpm/repo/package.json +10 -0
  95. package/examples/toolchains/pnpm/repo/packages/checker/check.mjs +5 -0
  96. package/examples/toolchains/pnpm/repo/packages/checker/package.json +9 -0
  97. package/examples/toolchains/pnpm/repo/packages/label/package.json +14 -0
  98. package/examples/toolchains/pnpm/repo/packages/label/src/label.js +4 -0
  99. package/examples/toolchains/pnpm/repo/packages/label/test/label.test.js +13 -0
  100. package/examples/toolchains/pnpm/repo/pnpm-lock.yaml +29 -0
  101. package/examples/toolchains/pnpm/repo/pnpm-workspace.yaml +2 -0
  102. package/examples/toolchains/pnpm/task.json +25 -0
  103. package/examples/toolchains/python/bad/src/fx_label/__init__.py +6 -0
  104. package/examples/toolchains/python/good/src/fx_label/__init__.py +6 -0
  105. package/examples/toolchains/python/policy.json +80 -0
  106. package/examples/toolchains/python/repo/.python-version +1 -0
  107. package/examples/toolchains/python/repo/REQUIREMENTS.md +9 -0
  108. package/examples/toolchains/python/repo/_gitignore +5 -0
  109. package/examples/toolchains/python/repo/pyproject.toml +19 -0
  110. package/examples/toolchains/python/repo/src/fx_label/__init__.py +6 -0
  111. package/examples/toolchains/python/repo/tests/test_label.py +13 -0
  112. package/examples/toolchains/python/repo/uv.lock +108 -0
  113. package/examples/toolchains/python/task.json +25 -0
  114. package/examples/toolchains/turbo/bad/packages/label/src/label.js +4 -0
  115. package/examples/toolchains/turbo/good/packages/label/src/label.js +4 -0
  116. package/examples/toolchains/turbo/policy.json +110 -0
  117. package/examples/toolchains/turbo/repo/REQUIREMENTS.md +9 -0
  118. package/examples/toolchains/turbo/repo/_gitignore +4 -0
  119. package/examples/toolchains/turbo/repo/package.json +13 -0
  120. package/examples/toolchains/turbo/repo/packages/checker/check.mjs +5 -0
  121. package/examples/toolchains/turbo/repo/packages/checker/package.json +9 -0
  122. package/examples/toolchains/turbo/repo/packages/label/package.json +14 -0
  123. package/examples/toolchains/turbo/repo/packages/label/src/label.js +4 -0
  124. package/examples/toolchains/turbo/repo/packages/label/test/label.test.js +13 -0
  125. package/examples/toolchains/turbo/repo/pnpm-lock.yaml +94 -0
  126. package/examples/toolchains/turbo/repo/pnpm-workspace.yaml +2 -0
  127. package/examples/toolchains/turbo/repo/turbo.json +7 -0
  128. package/examples/toolchains/turbo/task.json +25 -0
  129. package/examples/turbo-starter/README.md +16 -0
  130. package/examples/turbo-starter/changes/issued-at-consumer-only.patch +12 -0
  131. package/examples/turbo-starter/changes/issued-at.patch +130 -0
  132. package/examples/turbo-starter/cleanup.mjs +58 -0
  133. package/examples/turbo-starter/graph.json +32 -0
  134. package/examples/turbo-starter/harness/acceptance/db-isolation.test.mjs +97 -0
  135. package/examples/turbo-starter/harness/acceptance/env-probe.test.mjs +75 -0
  136. package/examples/turbo-starter/harness/acceptance/generation.test.mjs +54 -0
  137. package/examples/turbo-starter/harness/acceptance/issued-at.test.mjs +43 -0
  138. package/examples/turbo-starter/harness/acceptance/lifecycle-probe.test.mjs +47 -0
  139. package/examples/turbo-starter/harness/compose-bridge.mjs +593 -0
  140. package/examples/turbo-starter/harness/compose.acceptance.yml +23 -0
  141. package/examples/turbo-starter/harness/env/backend.env.template +7 -0
  142. package/examples/turbo-starter/harness/env/compose.env.template +6 -0
  143. package/examples/turbo-starter/policy.json +109 -0
  144. package/examples/turbo-starter/policy.lifecycle.json +114 -0
  145. package/examples/turbo-starter/setup.mjs +352 -0
  146. package/examples/two-vendors/policy.json +30 -0
  147. package/lib/agents/cli-adapter.d.ts +48 -0
  148. package/lib/agents/cli-adapter.js +386 -0
  149. package/lib/agents/inspect.d.ts +6 -0
  150. package/lib/agents/inspect.js +46 -0
  151. package/lib/agents/probe-server.d.ts +2 -0
  152. package/lib/agents/probe-server.js +13 -0
  153. package/lib/agents/review-probe.d.ts +295 -0
  154. package/lib/agents/review-probe.js +872 -0
  155. package/lib/agents/runtime-events.d.ts +11 -0
  156. package/lib/agents/runtime-events.js +21 -0
  157. package/lib/agents/schemas.d.ts +217 -0
  158. package/lib/agents/schemas.js +124 -0
  159. package/lib/agents/usage.d.ts +8 -0
  160. package/lib/agents/usage.js +83 -0
  161. package/lib/cli.d.ts +2 -0
  162. package/lib/cli.js +491 -0
  163. package/lib/connectors/files.d.ts +108 -0
  164. package/lib/connectors/files.js +133 -0
  165. package/lib/contracts/checks.d.ts +307 -0
  166. package/lib/contracts/checks.js +181 -0
  167. package/lib/contracts/cli.d.ts +3895 -0
  168. package/lib/contracts/cli.js +664 -0
  169. package/lib/contracts/context.d.ts +135 -0
  170. package/lib/contracts/context.js +129 -0
  171. package/lib/contracts/digest.d.ts +9 -0
  172. package/lib/contracts/digest.js +22 -0
  173. package/lib/contracts/errors.d.ts +81 -0
  174. package/lib/contracts/errors.js +70 -0
  175. package/lib/contracts/events.d.ts +827 -0
  176. package/lib/contracts/events.js +258 -0
  177. package/lib/contracts/execution.d.ts +3216 -0
  178. package/lib/contracts/execution.js +872 -0
  179. package/lib/contracts/failure.d.ts +67 -0
  180. package/lib/contracts/failure.js +127 -0
  181. package/lib/contracts/index.d.ts +18 -0
  182. package/lib/contracts/index.js +18 -0
  183. package/lib/contracts/policy.d.ts +1080 -0
  184. package/lib/contracts/policy.js +544 -0
  185. package/lib/contracts/ports.d.ts +417 -0
  186. package/lib/contracts/ports.js +1 -0
  187. package/lib/contracts/primitives.d.ts +63 -0
  188. package/lib/contracts/primitives.js +73 -0
  189. package/lib/contracts/profiles.d.ts +336 -0
  190. package/lib/contracts/profiles.js +109 -0
  191. package/lib/contracts/published.d.ts +3237 -0
  192. package/lib/contracts/published.js +34 -0
  193. package/lib/contracts/result.d.ts +1280 -0
  194. package/lib/contracts/result.js +311 -0
  195. package/lib/contracts/review.d.ts +441 -0
  196. package/lib/contracts/review.js +477 -0
  197. package/lib/contracts/toolchain.d.ts +96 -0
  198. package/lib/contracts/toolchain.js +89 -0
  199. package/lib/contracts/version.d.ts +17 -0
  200. package/lib/contracts/version.js +17 -0
  201. package/lib/contracts/work.d.ts +173 -0
  202. package/lib/contracts/work.js +178 -0
  203. package/lib/control/base-move.d.ts +44 -0
  204. package/lib/control/base-move.js +90 -0
  205. package/lib/control/doctor.d.ts +50 -0
  206. package/lib/control/doctor.js +113 -0
  207. package/lib/control/harness.d.ts +1125 -0
  208. package/lib/control/harness.js +741 -0
  209. package/lib/control/provenance.d.ts +17 -0
  210. package/lib/control/provenance.js +25 -0
  211. package/lib/control/recovery.d.ts +237 -0
  212. package/lib/control/recovery.js +236 -0
  213. package/lib/control/review.d.ts +62 -0
  214. package/lib/control/review.js +178 -0
  215. package/lib/control/workflow.d.ts +247 -0
  216. package/lib/control/workflow.js +259 -0
  217. package/lib/engine/budget.d.ts +345 -0
  218. package/lib/engine/budget.js +278 -0
  219. package/lib/engine/classify.d.ts +8 -0
  220. package/lib/engine/classify.js +32 -0
  221. package/lib/engine/engine.d.ts +1527 -0
  222. package/lib/engine/engine.js +2259 -0
  223. package/lib/engine/failure.d.ts +1 -0
  224. package/lib/engine/failure.js +3 -0
  225. package/lib/engine/joint-runner.d.ts +257 -0
  226. package/lib/engine/joint-runner.js +254 -0
  227. package/lib/engine/measured.d.ts +15 -0
  228. package/lib/engine/measured.js +82 -0
  229. package/lib/engine/prompt.d.ts +66 -0
  230. package/lib/engine/prompt.js +131 -0
  231. package/lib/engine/property-recovery.d.ts +50 -0
  232. package/lib/engine/property-recovery.js +171 -0
  233. package/lib/engine/repair.d.ts +33 -0
  234. package/lib/engine/repair.js +82 -0
  235. package/lib/engine/result.d.ts +9 -0
  236. package/lib/engine/result.js +218 -0
  237. package/lib/engine/reuse.d.ts +80 -0
  238. package/lib/engine/reuse.js +102 -0
  239. package/lib/engine/runner.d.ts +360 -0
  240. package/lib/engine/runner.js +1836 -0
  241. package/lib/engine/standalone.d.ts +6 -0
  242. package/lib/engine/standalone.js +48 -0
  243. package/lib/engine/usage.d.ts +87 -0
  244. package/lib/engine/usage.js +165 -0
  245. package/lib/environment/isolation.d.ts +110 -0
  246. package/lib/environment/isolation.js +238 -0
  247. package/lib/environment/lifecycle.d.ts +40 -0
  248. package/lib/environment/lifecycle.js +197 -0
  249. package/lib/environment/live-calls.d.ts +14 -0
  250. package/lib/environment/live-calls.js +73 -0
  251. package/lib/environment/os-tools.d.ts +16 -0
  252. package/lib/environment/os-tools.js +35 -0
  253. package/lib/environment/process.d.ts +32 -0
  254. package/lib/environment/process.js +369 -0
  255. package/lib/environment/redaction.d.ts +22 -0
  256. package/lib/environment/redaction.js +102 -0
  257. package/lib/environment/resources.d.ts +31 -0
  258. package/lib/environment/resources.js +120 -0
  259. package/lib/index.d.ts +65 -0
  260. package/lib/index.js +67 -0
  261. package/lib/integrations/connector/commands.d.ts +358 -0
  262. package/lib/integrations/connector/commands.js +428 -0
  263. package/lib/integrations/connector/config.d.ts +215 -0
  264. package/lib/integrations/connector/config.js +214 -0
  265. package/lib/integrations/connector/delivery.d.ts +107 -0
  266. package/lib/integrations/connector/delivery.js +591 -0
  267. package/lib/integrations/connector/fake.d.ts +291 -0
  268. package/lib/integrations/connector/fake.js +786 -0
  269. package/lib/integrations/connector/history.d.ts +124 -0
  270. package/lib/integrations/connector/history.js +497 -0
  271. package/lib/integrations/connector/index.d.ts +16 -0
  272. package/lib/integrations/connector/index.js +16 -0
  273. package/lib/integrations/connector/redact.d.ts +19 -0
  274. package/lib/integrations/connector/redact.js +91 -0
  275. package/lib/integrations/connector/registry.d.ts +15 -0
  276. package/lib/integrations/connector/registry.js +43 -0
  277. package/lib/integrations/connector/snapshot.d.ts +131 -0
  278. package/lib/integrations/connector/snapshot.js +422 -0
  279. package/lib/integrations/connector/source.d.ts +37 -0
  280. package/lib/integrations/connector/source.js +73 -0
  281. package/lib/integrations/connector/steps.d.ts +71 -0
  282. package/lib/integrations/connector/steps.js +188 -0
  283. package/lib/integrations/connector/store.d.ts +140 -0
  284. package/lib/integrations/connector/store.js +277 -0
  285. package/lib/integrations/connector/summary.d.ts +38 -0
  286. package/lib/integrations/connector/summary.js +59 -0
  287. package/lib/integrations/connector/testing-worker.d.ts +1 -0
  288. package/lib/integrations/connector/testing-worker.js +81 -0
  289. package/lib/integrations/connector/testing.d.ts +205 -0
  290. package/lib/integrations/connector/testing.js +1615 -0
  291. package/lib/integrations/connector/transport.d.ts +240 -0
  292. package/lib/integrations/connector/transport.js +28 -0
  293. package/lib/integrations/memory/context.d.ts +120 -0
  294. package/lib/integrations/memory/context.js +239 -0
  295. package/lib/integrations/memory/index.d.ts +8 -0
  296. package/lib/integrations/memory/index.js +8 -0
  297. package/lib/integrations/memory/provider.d.ts +79 -0
  298. package/lib/integrations/memory/provider.js +22 -0
  299. package/lib/integrations/memory/sink.d.ts +56 -0
  300. package/lib/integrations/memory/sink.js +116 -0
  301. package/lib/integrations/memory/testing.d.ts +44 -0
  302. package/lib/integrations/memory/testing.js +61 -0
  303. package/lib/integrations/relay-v1/index.d.ts +122 -0
  304. package/lib/integrations/relay-v1/index.js +860 -0
  305. package/lib/integrations/relay-v2/index.d.ts +145 -0
  306. package/lib/integrations/relay-v2/index.js +752 -0
  307. package/lib/integrations/relay-v2/process.d.ts +32 -0
  308. package/lib/integrations/relay-v2/process.js +112 -0
  309. package/lib/integrations/relay-v2/protocol.d.ts +172 -0
  310. package/lib/integrations/relay-v2/protocol.js +142 -0
  311. package/lib/package.d.ts +2 -0
  312. package/lib/package.js +3 -0
  313. package/lib/profiles/resolve.d.ts +190 -0
  314. package/lib/profiles/resolve.js +273 -0
  315. package/lib/storage/document.d.ts +1234 -0
  316. package/lib/storage/document.js +80 -0
  317. package/lib/storage/sqlite.d.ts +35 -0
  318. package/lib/storage/sqlite.js +260 -0
  319. package/lib/testing/index.d.ts +5 -0
  320. package/lib/testing/index.js +5 -0
  321. package/lib/testing/store-contract.d.ts +8 -0
  322. package/lib/testing/store-contract.js +167 -0
  323. package/lib/toolchains/index.d.ts +9 -0
  324. package/lib/toolchains/index.js +9 -0
  325. package/lib/toolchains/inspect.d.ts +105 -0
  326. package/lib/toolchains/inspect.js +831 -0
  327. package/lib/toolchains/manifests.d.ts +67 -0
  328. package/lib/toolchains/manifests.js +238 -0
  329. package/lib/toolchains/probe.d.ts +28 -0
  330. package/lib/toolchains/probe.js +54 -0
  331. package/lib/toolchains/recipes.d.ts +33 -0
  332. package/lib/toolchains/recipes.js +51 -0
  333. package/lib/validate.d.ts +36 -0
  334. package/lib/validate.js +175 -0
  335. package/lib/verification/acceptance.d.ts +284 -0
  336. package/lib/verification/acceptance.js +670 -0
  337. package/lib/verification/checks.d.ts +72 -0
  338. package/lib/verification/checks.js +190 -0
  339. package/lib/verification/confirm.d.ts +125 -0
  340. package/lib/verification/confirm.js +411 -0
  341. package/lib/verification/corpus.d.ts +1303 -0
  342. package/lib/verification/corpus.js +744 -0
  343. package/lib/verification/dependencies.d.ts +66 -0
  344. package/lib/verification/dependencies.js +144 -0
  345. package/lib/verification/gates.d.ts +160 -0
  346. package/lib/verification/gates.js +600 -0
  347. package/lib/verification/joint.d.ts +63 -0
  348. package/lib/verification/joint.js +121 -0
  349. package/lib/verification/proof.d.ts +54 -0
  350. package/lib/verification/proof.js +96 -0
  351. package/lib/verification/property.d.ts +86 -0
  352. package/lib/verification/property.js +214 -0
  353. package/lib/verification/requirements.d.ts +25 -0
  354. package/lib/verification/requirements.js +69 -0
  355. package/lib/verification/self-check.d.ts +29 -0
  356. package/lib/verification/self-check.js +99 -0
  357. package/lib/verification/spike.d.ts +79 -0
  358. package/lib/verification/spike.js +124 -0
  359. package/package.json +100 -4
  360. package/profiles/expo-mobile.json +15 -0
  361. package/profiles/go-api.json +15 -0
  362. package/profiles/go-gotestsum.json +21 -0
  363. package/profiles/mobile-maestro.json +29 -0
  364. package/profiles/nest-api.json +26 -0
  365. package/profiles/next-product.json +72 -0
  366. package/profiles/node-checks.json +77 -0
  367. package/profiles/npm-ci.json +22 -0
  368. package/profiles/pnpm-frozen.json +24 -0
  369. package/profiles/python-api.json +32 -0
  370. package/profiles/python-uv.json +26 -0
  371. package/profiles/react-native.json +50 -0
  372. package/profiles/react-vite-admin.json +71 -0
  373. package/schemas/context-snapshot.schema.json +231 -0
  374. package/schemas/contract-document.schema.json +385 -0
  375. package/schemas/execution-document.schema.json +3695 -0
  376. package/schemas/execution-policy.schema.json +2196 -0
  377. package/schemas/joint-verification.schema.json +617 -0
  378. package/schemas/result-envelope.schema.json +1652 -0
  379. package/schemas/review-record.schema.json +177 -0
  380. package/schemas/run-event.schema.json +2458 -0
  381. package/schemas/run-result.schema.json +1570 -0
  382. package/schemas/stack-profile.schema.json +535 -0
  383. package/schemas/work-graph.schema.json +381 -0
  384. package/schemas/work-order.schema.json +256 -0
  385. package/skills/harness/SKILL.md +64 -0
  386. package/templates/AGENTS.md +13 -0
  387. package/templates/controller-mode/AGENTS.controller.md +58 -0
  388. package/templates/controller-mode/README.md +44 -0
  389. package/templates/graph.json +22 -0
  390. package/templates/policy.json +28 -0
@@ -0,0 +1,744 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { isAbsolute } from 'node:path';
5
+ import { HarnessError, WORKSPACE_OWNER, digestOf, } from "../contracts/index.js";
6
+ import { contractDocuments, obligationOf, suiteBasis } from "./checks.js";
7
+ /** Предел случая: вход и ожидание вместе. */
8
+ export const MAX_CASE_BYTES = 64 * 1024;
9
+ /** Предел корпуса целиком: запись переносится одним файлом. */
10
+ export const MAX_SUITE_BYTES = 1_500_000;
11
+ export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
12
+ /**
13
+ * Канонический JSON в рамках объявленного формата: ключи объектов
14
+ * сортируются (порядок ключей в JSON не несёт смысла), порядок массивов
15
+ * сохраняется, числа и строки не смешиваются — `1` и `"1"` разные входы.
16
+ */
17
+ export function canonicalJson(value) {
18
+ return JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
19
+ ? Object.fromEntries(Object.keys(item)
20
+ .sort()
21
+ .map((key) => [key, item[key]]))
22
+ : item);
23
+ }
24
+ export function parseCanonical(text, what) {
25
+ let value;
26
+ try {
27
+ value = JSON.parse(text);
28
+ }
29
+ catch {
30
+ throw new HarnessError('INVALID_INPUT', `${what}: не JSON`);
31
+ }
32
+ return canonicalJson(value);
33
+ }
34
+ /** Канонический файл корпуса версии — его байты дают digest и Git blob. */
35
+ export function corpusFile(suite, cases, number) {
36
+ return (canonicalJson({
37
+ version: 1,
38
+ suite: suite.id,
39
+ number,
40
+ cases: cases.map((id) => {
41
+ const c = suite.cases[id];
42
+ return {
43
+ id: c.id,
44
+ property: c.property,
45
+ input: JSON.parse(c.input),
46
+ expected: JSON.parse(c.expected),
47
+ };
48
+ }),
49
+ }) + '\n');
50
+ }
51
+ /*
52
+ * Убрать локальные пути из диагностики: исключение реализации или эталона
53
+ * называет рабочие каталоги, домашний каталог и временные файлы, а запись
54
+ * корпуса переносится между установками. Нормативные поля (вход, ожидание)
55
+ * не трогаются.
56
+ *
57
+ * Основа — точные локальные корни этой машины (репозитории, каталог
58
+ * состояния, временный и домашний каталоги): это подстановка строк, она не
59
+ * задевает маршруты, URL и соседний текст и идемпотентна. Дополнительно —
60
+ * узкая эвристика: токен без пробелов от типичного пользовательского корня и
61
+ * file://. Путь вне известных корней по тексту не отличить от маршрута API,
62
+ * поэтому он не очищается: в причины пишут смысл, а не пути.
63
+ */
64
+ const PATH_CHARS = String.raw `[^\s"'\x60<>|,;)\]}]`;
65
+ // Слева от корня — не буква/цифра любого алфавита (с комбинируемыми знаками),
66
+ // не часть пути и не уже поставленная метка. JSON-экранированный перевод
67
+ // строки или табуляция (\n, \r, \t) — граница: исключение часто называет
68
+ // путь с новой строки.
69
+ const LEFT = String.raw `(?:(?<=\\[nrt])|(?<![\p{L}\p{M}\p{N}_.~/\\>-]))`;
70
+ const SEP = String.raw `(?:\\{1,2}|\/)`;
71
+ const USER_PATH = new RegExp(LEFT +
72
+ String.raw `(?:\/(?:Users|home)\/|[A-Za-z]:${SEP}(?:Users|Documents and Settings)${SEP})${PATH_CHARS}*`, 'giu');
73
+ // URL целиком: file:// — локальный путь, остальные схемы не трогаются вовсе.
74
+ const URL_TOKEN = /(?:(?<=\\[nrt])|(?<![\p{L}\p{N}_+.\\-]))([a-z][a-z0-9+.-]*):\/\/[^\s"'\x60<>]*/giu;
75
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ function scrubPlain(text, known) {
77
+ let out = text;
78
+ for (const root of known)
79
+ out = out.replace(new RegExp(LEFT + escape(root) + String.raw `(?=$|[\\/\s"'\x60<>|,;:)\]}])`, 'gu'), '<local-path>');
80
+ return out.replace(USER_PATH, '<local-path>');
81
+ }
82
+ export function scrubLocalPaths(text, roots = []) {
83
+ const known = [
84
+ ...new Set(roots
85
+ .map((r) => r.replace(/[\\/]+$/, ''))
86
+ // Windows-корень встречается и в JSON-экранированном виде.
87
+ .flatMap((r) => (r.includes('\\') ? [r, r.replace(/\\/g, '\\\\')] : [r]))),
88
+ ]
89
+ .filter((r) => r.length > 1)
90
+ .sort((x, y) => y.length - x.length);
91
+ let out = '';
92
+ let last = 0;
93
+ for (const m of text.matchAll(URL_TOKEN)) {
94
+ out += scrubPlain(text.slice(last, m.index), known);
95
+ if (m[1].toLowerCase() === 'file') {
96
+ // Путь file:// кончается там же, где путь: обрамление ссылки остаётся.
97
+ const path = new RegExp(String.raw `^file:\/\/${PATH_CHARS}*`, 'iu').exec(m[0])[0];
98
+ out += '<local-path>' + scrubPlain(m[0].slice(path.length), known);
99
+ }
100
+ else
101
+ out += m[0];
102
+ last = m.index + m[0].length;
103
+ }
104
+ return out + scrubPlain(text.slice(last), known);
105
+ }
106
+ /** Локальные корни установки — и в заданном, и в разрешённом виде. */
107
+ export function localRoots(paths) {
108
+ const given = [...paths, tmpdir(), homedir()].filter((x) => !!x && isAbsolute(x));
109
+ const resolved = given.map((x) => {
110
+ try {
111
+ return realpathSync(x);
112
+ }
113
+ catch {
114
+ return x;
115
+ }
116
+ });
117
+ // macOS: /tmp, /var, /etc — псевдонимы /private/…; диагностика может
118
+ // назвать любое из двух написаний.
119
+ const aliases = [...given, ...resolved].flatMap((x) => x.startsWith('/private/')
120
+ ? [x.slice('/private'.length)]
121
+ : /^\/(?:tmp|var|etc)(?:\/|$)/.test(x)
122
+ ? ['/private' + x]
123
+ : []);
124
+ return [...new Set([...given, ...resolved, ...aliases])];
125
+ }
126
+ export const caseId = (property, input, basis) => 'K-' + sha256(JSON.stringify([property, input, basis])).slice(0, 32);
127
+ /**
128
+ * Версия корпуса по ссылке. Digest однозначен; номер — только если в
129
+ * истории одна версия с ним: после объединения клонов номера могут совпасть.
130
+ */
131
+ export function versionOf(suite, ref) {
132
+ if (ref.digest) {
133
+ const found = suite.versions.find((v) => v.digest === ref.digest);
134
+ if (!found)
135
+ throw new HarnessError('NOT_FOUND', `Версии ${ref.digest.slice(0, 12)} корпуса ${suite.id} нет`);
136
+ if (ref.number !== undefined && found.number !== ref.number)
137
+ throw new HarnessError('INVALID_INPUT', 'Номер и digest версии корпуса не совпадают');
138
+ return found;
139
+ }
140
+ const same = suite.versions.filter((v) => v.number === ref.number);
141
+ if (!same.length)
142
+ throw new HarnessError('NOT_FOUND', `Версии ${ref.number} корпуса ${suite.id} нет`);
143
+ if (same.length > 1)
144
+ throw new HarnessError('INVALID_INPUT', `Номер версии ${ref.number} неоднозначен после объединения клонов: укажите digest`);
145
+ return same[0];
146
+ }
147
+ export { suiteBasis } from "./checks.js";
148
+ const head = (suite) => [...suite.versions]
149
+ .sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt))
150
+ .at(-1);
151
+ /**
152
+ * Операции корпуса под транзакциями хранилища исполнения. Запись решения и
153
+ * версии — одна транзакция; принятие версии поручениями меняет их
154
+ * `checkVersion` и удержание в той же транзакции, что и запись корпуса.
155
+ */
156
+ export class CorpusStore {
157
+ engine;
158
+ roots;
159
+ constructor(engine,
160
+ /** Локальные корни для очистки переносимой диагностики. */
161
+ roots = []) {
162
+ this.engine = engine;
163
+ this.roots = roots;
164
+ }
165
+ get store() {
166
+ return this.engine.host.store;
167
+ }
168
+ now() {
169
+ return this.engine.clock();
170
+ }
171
+ leaseMs() {
172
+ return this.engine.policy.leaseMs * 4;
173
+ }
174
+ all(view, repositoryId) {
175
+ return view.locals('check-suites', repositoryId).map((x) => x.value);
176
+ }
177
+ get(view, id) {
178
+ const suite = this.all(view).find((s) => s.id === id);
179
+ if (!suite)
180
+ throw new HarnessError('NOT_FOUND', 'Корпус проверок не найден: ' + id);
181
+ return suite;
182
+ }
183
+ read(id) {
184
+ return this.store.read((v) => structuredClone(this.get(v, id)));
185
+ }
186
+ save(tx, suite) {
187
+ suite.revision++;
188
+ if (Buffer.byteLength(JSON.stringify(suite)) > MAX_SUITE_BYTES * 2)
189
+ throw new HarnessError('INVALID_INPUT', 'Запись корпуса превысила предел хранения');
190
+ tx.putLocal('check-suites', suite.repositoryId, suite.id, suite);
191
+ }
192
+ /** Корпус договора поручения; создаётся (в памяти) при первом обращении. */
193
+ suiteFor(view, work, property) {
194
+ const contracts = contractDocuments(work.order, view);
195
+ const found = obligationOf(contracts, property);
196
+ if (!found)
197
+ throw new HarnessError('INVALID_INPUT', `Свойство ${property} не объявлено в договорах поручения`);
198
+ const contract = found.contract;
199
+ const repositoryId = work.order.repositoryId;
200
+ // Корпус принадлежит нормативному основанию договора, а не его ID: та же
201
+ // редакция ID с другими правилами ответа, областью или эталоном получает
202
+ // свой корпус, а случаи прежнего основания остаются его историей.
203
+ const basis = this.engine.host.basis.suiteBasis?.(contract) ?? suiteBasis(contract);
204
+ const suites = this.all(view, repositoryId);
205
+ const existing = suites.find((x) => x.contractId === contract.id && x.basis === basis);
206
+ if (existing)
207
+ return { suite: structuredClone(existing), contract, obligation: found.obligation };
208
+ const first = 'KS-' + contract.id;
209
+ const id = suites.some((x) => x.id === first) ? `${first}-${basis.slice(0, 12)}` : first;
210
+ const suite = {
211
+ id,
212
+ repositoryId,
213
+ contractId: contract.id,
214
+ // Digest договора — ссылка поручения, под которой хранится документ (у
215
+ // хоста — его формула digest), а не пересчёт документа.
216
+ contractDigest: work.order.contracts.find((c) => c.id === contract.id)?.digest ?? digestOf(contract),
217
+ gate: contract.checks.gate,
218
+ basis,
219
+ cases: {},
220
+ versions: [],
221
+ decisions: [],
222
+ adoptions: [],
223
+ pending: [],
224
+ operations: {},
225
+ revision: 0,
226
+ };
227
+ return { suite, contract, obligation: found.obligation };
228
+ }
229
+ head(suite) {
230
+ return head(suite);
231
+ }
232
+ /** Случаи закреплённой версии и её канонический файл: их исполняет gate. */
233
+ snapshot(view, binding) {
234
+ const suite = this.get(view, binding.suite);
235
+ const version = suite.versions.find((v) => v.digest === binding.digest);
236
+ if (!version)
237
+ throw new HarnessError('APPROVAL_STALE', `Версия корпуса ${binding.suite}#${binding.number} не найдена или подменена`);
238
+ const content = corpusFile(suite, version.cases, version.number);
239
+ if (sha256(content) !== version.digest)
240
+ throw new HarnessError('APPROVAL_STALE', `Корпус ${binding.suite}#${binding.number} не совпадает с digest`);
241
+ return {
242
+ digest: version.digest,
243
+ content,
244
+ cases: version.cases.map((id) => ({ id, property: suite.cases[id].property })),
245
+ };
246
+ }
247
+ /** Начать операцию подтверждения или вернуть её прежний исход по ключу. */
248
+ begin(initial, key) {
249
+ return this.store.transact('checks.recorded', (tx) => {
250
+ const pending = tx.control().reconfigure;
251
+ if (pending && !pending.appliedAt)
252
+ throw new HarnessError('NOT_READY', `Операция ${pending.operation} применяет политику; повторите после неё`);
253
+ const suite = structuredClone(this.all(tx, initial.repositoryId).find((x) => x.id === initial.id) ?? initial);
254
+ const op = suite.operations[key];
255
+ if (op?.status === 'completed')
256
+ return { done: suite.decisions.find((d) => d.id === op.decision) };
257
+ if (op?.status === 'running' && op.leaseUntil > this.now())
258
+ throw new HarnessError('NOT_READY', 'Подтверждение с этим ключом уже выполняется');
259
+ const token = randomUUID();
260
+ suite.operations[key] = {
261
+ key,
262
+ status: 'running',
263
+ token,
264
+ leaseUntil: this.now() + this.leaseMs(),
265
+ startedAt: new Date(this.now()).toISOString(),
266
+ };
267
+ this.save(tx, suite);
268
+ return { token };
269
+ });
270
+ }
271
+ heartbeat(suiteId, key, token) {
272
+ this.store.transact('checks.recorded', (tx) => {
273
+ const suite = structuredClone(this.get(tx, suiteId));
274
+ const op = suite.operations[key];
275
+ // Истёкший lease не продлевается: владение уже потеряно, и продление
276
+ // вернуло бы право записи тому, кого мог сменить новый владелец.
277
+ if (op?.token !== token || op.status !== 'running' || op.leaseUntil <= this.now())
278
+ return;
279
+ op.leaseUntil = this.now() + this.leaseMs();
280
+ this.save(tx, suite);
281
+ });
282
+ }
283
+ /** Снять владение операцией без решения: она завершилась отказом до проверки. */
284
+ end(suiteId, key, token) {
285
+ this.store.transact('checks.recorded', (tx) => {
286
+ const found = this.all(tx).find((x) => x.id === suiteId);
287
+ const op = found?.operations[key];
288
+ if (!found || op?.token !== token || op.status !== 'running')
289
+ return;
290
+ const suite = structuredClone(found);
291
+ delete suite.operations[key];
292
+ this.save(tx, suite);
293
+ });
294
+ }
295
+ guard(suite, key, token) {
296
+ const op = suite.operations[key];
297
+ if (op?.status !== 'running' || op.token !== token || op.leaseUntil <= this.now())
298
+ throw new HarnessError('LEASE_STALE', 'Владение операцией подтверждения утрачено');
299
+ }
300
+ /**
301
+ * Записать решение и, если случай подтверждён, добавочную версию корпуса и
302
+ * отложенное принятие её поручениями. Одна транзакция: решение без версии
303
+ * или версия без решения не возникают. Конкурентная добавка не теряется:
304
+ * новая версия строится от текущей головы с повторной проверкой конфликта
305
+ * ожиданий.
306
+ */
307
+ record(initial, key, token, decision, blobFor, adopt) {
308
+ return this.store.transact('checks.recorded', (tx) => {
309
+ const suite = structuredClone(this.all(tx, initial.repositoryId).find((x) => x.id === initial.id) ?? initial);
310
+ this.guard(suite, key, token);
311
+ const at = new Date(this.now()).toISOString();
312
+ const made = { ...decision, id: 'CF-' + randomUUID(), at };
313
+ let pending;
314
+ if (made.outcome === 'confirmed') {
315
+ // Случай сравнивается в каноническом виде, кто бы его ни записал.
316
+ made.input = parseCanonical(made.input, 'вход случая');
317
+ const id = caseId(made.property, made.input, suite.basis);
318
+ const same = Object.values(suite.cases).find((c) => c.property === made.property && c.input === made.input);
319
+ if (same && same.expected !== made.observed.expected) {
320
+ // Одинаковый вход с другим ожиданием — конфликт, а не выбор одного.
321
+ made.outcome = 'inconclusive';
322
+ made.reason = `конфликт ожиданий: корпус уже содержит ${same.id} с другим ответом`;
323
+ }
324
+ else {
325
+ const top = head(suite);
326
+ const perProperty = Object.values(suite.cases).filter((c) => c.property === made.property).length;
327
+ const limit = this.declarationOf(tx, suite)?.corpus?.maxCases ?? 500;
328
+ if (!same && perProperty >= limit) {
329
+ made.outcome = 'rejected';
330
+ made.reason = `корпус свойства достиг предела ${limit} случаев: расширьте генератор или эталон обычным изменением договора`;
331
+ }
332
+ else {
333
+ if (!same)
334
+ suite.cases[id] = {
335
+ id,
336
+ property: made.property,
337
+ input: made.input,
338
+ expected: made.observed.expected,
339
+ source: {
340
+ kind: 'oracle',
341
+ decision: made.id,
342
+ sha: made.source.sha,
343
+ ...(made.source.runId ? { runId: made.source.runId } : {}),
344
+ ...(made.source.taskId ? { taskId: made.source.taskId } : {}),
345
+ ...(made.claimed?.reproduction
346
+ ? { reproduction: made.claimed.reproduction }
347
+ : {}),
348
+ },
349
+ addedAt: at,
350
+ };
351
+ made.caseId = same?.id ?? id;
352
+ if (!top?.cases.includes(made.caseId)) {
353
+ // Версия — все подтверждённые случаи корпуса.
354
+ const cases = Object.values(suite.cases)
355
+ .sort((a, b) => a.addedAt.localeCompare(b.addedAt) || a.id.localeCompare(b.id))
356
+ .map((c) => c.id);
357
+ const number = Math.max(0, ...suite.versions.map((v) => v.number)) + 1;
358
+ const content = corpusFile(suite, cases, number);
359
+ if (Buffer.byteLength(content) > MAX_SUITE_BYTES)
360
+ throw new HarnessError('INVALID_INPUT', 'Корпус превысил предел размера');
361
+ suite.versions.push({
362
+ number,
363
+ digest: sha256(content),
364
+ previous: top?.digest ?? null,
365
+ cases,
366
+ blob: blobFor(content),
367
+ createdAt: at,
368
+ reason: `подтверждён случай ${made.caseId} свойства ${made.property}`,
369
+ decisions: [made.id],
370
+ });
371
+ }
372
+ made.version = head(suite).number;
373
+ if (adopt.works.length) {
374
+ pending = {
375
+ id: 'AD-' + randomUUID(),
376
+ version: made.version,
377
+ digest: head(suite).digest,
378
+ ...(adopt.manual ? { awaitingOperator: true } : {}),
379
+ tasks: adopt.works,
380
+ status: 'waiting',
381
+ results: {},
382
+ actor: adopt.actor,
383
+ reason: made.reason,
384
+ requestedAt: at,
385
+ };
386
+ suite.pending.push(pending);
387
+ if (pending.awaitingOperator)
388
+ this.holdForOperator(tx, pending, suite.id);
389
+ }
390
+ }
391
+ }
392
+ }
393
+ suite.decisions.push(made);
394
+ suite.operations[key] = {
395
+ key,
396
+ status: 'completed',
397
+ startedAt: suite.operations[key].startedAt,
398
+ decision: made.id,
399
+ };
400
+ this.save(tx, suite);
401
+ return { decision: made, suite, pending };
402
+ });
403
+ }
404
+ declarationOf(view, suite) {
405
+ // Документ хранится под digest ссылки поручения (формула хоста).
406
+ const stored = view.local('contract-documents', WORKSPACE_OWNER, suite.contractDigest);
407
+ return stored?.id === suite.contractId ? stored.checks : undefined;
408
+ }
409
+ /**
410
+ * Принять версию корпуса явно перечисленными поручениями.
411
+ *
412
+ * Посередине активной попытки принятие недопустимо: выдача поручения
413
+ * удерживается, и принятие завершится после окончания попытки. Принятое
414
+ * поручение не меняется. Повтор идемпотентен; снимается только собственное
415
+ * удержание операции.
416
+ */
417
+ adopt(suiteId, pendingId) {
418
+ return this.store.transact('checks.adopted', (tx) => {
419
+ const suite = structuredClone(this.get(tx, suiteId));
420
+ const pending = suite.pending.find((p) => p.id === pendingId);
421
+ if (!pending)
422
+ throw new HarnessError('NOT_FOUND', 'Принятие версии не найдено');
423
+ if (pending.status === 'completed')
424
+ return { pending, suite };
425
+ if (pending.awaitingOperator)
426
+ throw new HarnessError('APPROVAL_REQUIRED', `Принятие ${pending.id} ждёт ручного технического подтверждения (operator mode)`);
427
+ const version = versionOf(suite, { number: pending.version, digest: pending.digest });
428
+ const operation = 'checks:' + pending.id;
429
+ const waiting = [];
430
+ const accepted = [];
431
+ for (const id of pending.tasks) {
432
+ if (pending.results[id] && !pending.results[id].startsWith('ждёт'))
433
+ continue;
434
+ const w = tx.work(id);
435
+ const verdict = this.adoptable(suite, w, version);
436
+ if (verdict === 'busy') {
437
+ if (w.hold && w.hold.operation !== operation) {
438
+ pending.results[id] = `ждёт: поручение удерживает ${w.hold.operation}`;
439
+ waiting.push(id);
440
+ continue;
441
+ }
442
+ if (!w.hold) {
443
+ tx.putWork({
444
+ ...w,
445
+ hold: {
446
+ operation,
447
+ reason: `Принимается версия корпуса ${suite.id}#${version.number}`,
448
+ },
449
+ });
450
+ tx.emit({
451
+ type: 'work.held',
452
+ graphId: w.graphId,
453
+ workId: id,
454
+ payload: {
455
+ operation,
456
+ reason: `Принимается версия корпуса ${suite.id}#${version.number}`,
457
+ released: false,
458
+ },
459
+ });
460
+ }
461
+ pending.results[id] = 'ждёт окончания попытки';
462
+ waiting.push(id);
463
+ continue;
464
+ }
465
+ if (verdict !== 'ok') {
466
+ pending.results[id] = verdict;
467
+ if (w?.hold?.operation === operation)
468
+ this.release(tx, w, operation);
469
+ continue;
470
+ }
471
+ const from = w.checkVersion
472
+ ? { number: w.checkVersion.number, digest: w.checkVersion.digest }
473
+ : null;
474
+ const held = w.hold?.operation === operation;
475
+ tx.putWork({
476
+ ...w,
477
+ checkVersion: { suite: suite.id, number: version.number, digest: version.digest },
478
+ ...(held ? { hold: undefined } : {}),
479
+ });
480
+ if (held)
481
+ tx.emit({
482
+ type: 'work.held',
483
+ graphId: w.graphId,
484
+ workId: id,
485
+ payload: { operation, reason: 'версия корпуса принята', released: true },
486
+ });
487
+ suite.adoptions.push({
488
+ id: 'AA-' + randomUUID(),
489
+ taskId: id,
490
+ from,
491
+ to: { number: version.number, digest: version.digest },
492
+ actor: pending.actor,
493
+ reason: scrubLocalPaths(pending.reason, this.roots),
494
+ operation: pending.id,
495
+ at: new Date(this.now()).toISOString(),
496
+ });
497
+ pending.results[id] = 'принята';
498
+ accepted.push(id);
499
+ }
500
+ if (!waiting.length) {
501
+ pending.status = 'completed';
502
+ pending.completedAt = new Date(this.now()).toISOString();
503
+ }
504
+ this.save(tx, suite);
505
+ tx.emit({
506
+ type: 'corpus.adopted',
507
+ payload: {
508
+ suite: suite.id,
509
+ pending: pending.id,
510
+ version: version.number,
511
+ digest: version.digest,
512
+ status: pending.status,
513
+ accepted,
514
+ },
515
+ });
516
+ return { pending, suite };
517
+ });
518
+ }
519
+ release(tx, w, operation) {
520
+ tx.putWork({ ...w, hold: undefined });
521
+ tx.emit({
522
+ type: 'work.held',
523
+ graphId: w.graphId,
524
+ workId: w.id,
525
+ payload: { operation, reason: 'удержание снято', released: true },
526
+ });
527
+ }
528
+ adoptable(suite, w, version) {
529
+ if (!w)
530
+ return 'поручение не найдено';
531
+ if (!w.order.contracts.some((c) => c.id === suite.contractId))
532
+ return 'поручение не привязано к контракту этого корпуса';
533
+ if (!w.order.properties?.length)
534
+ return 'механизм договора проверки для поручения не включён: включение проходит ревью плана';
535
+ if (w.status === 'done')
536
+ return 'поручение уже принято: его история не меняется';
537
+ if (w.status === 'cancelled')
538
+ return 'поручение отменено';
539
+ if (!w.approvedDigest)
540
+ return 'поручение не согласовано';
541
+ if (w.checkVersion && w.checkVersion.suite !== suite.id)
542
+ return `поручение закреплено за другим корпусом ${w.checkVersion.suite}`;
543
+ if (w.checkVersion) {
544
+ const current = suite.versions.find((v) => v.digest === w.checkVersion.digest);
545
+ if (current && version.cases.every((id) => current.cases.includes(id)))
546
+ return 'принята';
547
+ // Принятие не сужает корпус поручения: версия из другой ветки истории
548
+ // без её случаев понизила бы обязательный набор.
549
+ if (current && !current.cases.every((id) => version.cases.includes(id)))
550
+ return 'версия не содержит всех случаев, уже принятых поручением';
551
+ }
552
+ if (w.activeRunId)
553
+ return 'busy';
554
+ return 'ok';
555
+ }
556
+ /**
557
+ * Явно принять уже существующую версию корпуса перечисленными поручениями.
558
+ * Новых случаев не создаёт. В operator mode агент только запрашивает.
559
+ */
560
+ request(suiteId, ref, works, actor, reason, manual = false) {
561
+ const pending = this.store.transact('checks.awaiting-operator', (tx) => {
562
+ const suite = structuredClone(this.get(tx, suiteId));
563
+ const version = versionOf(suite, ref);
564
+ if (!works.length)
565
+ throw new HarnessError('INVALID_INPUT', 'Перечислите поручения, принимающие версию');
566
+ const made = {
567
+ id: 'AD-' + randomUUID(),
568
+ version: version.number,
569
+ digest: version.digest,
570
+ tasks: works,
571
+ status: 'waiting',
572
+ results: {},
573
+ actor,
574
+ reason: scrubLocalPaths(reason, this.roots),
575
+ requestedAt: new Date(this.now()).toISOString(),
576
+ ...(manual ? { awaitingOperator: true } : {}),
577
+ };
578
+ suite.pending.push(made);
579
+ if (manual)
580
+ this.holdForOperator(tx, made, suite.id);
581
+ this.save(tx, suite);
582
+ return made;
583
+ });
584
+ if (manual)
585
+ return { pending, suite: this.read(suiteId) };
586
+ return this.adopt(suiteId, pending.id);
587
+ }
588
+ /**
589
+ * В operator mode выдача перечисленных незавершённых поручений удерживается
590
+ * до ручного подтверждения: иначе следующая попытка шла бы без нового корпуса.
591
+ */
592
+ holdForOperator(tx, pending, suiteId) {
593
+ for (const id of pending.tasks) {
594
+ const w = tx.work(id);
595
+ if (!w || w.hold || ['done', 'cancelled'].includes(w.status))
596
+ continue;
597
+ const reason = `Принятие версии корпуса ${suiteId} ждёт подтверждения оператора`;
598
+ tx.putWork({ ...w, hold: { operation: 'checks:' + pending.id, reason } });
599
+ tx.emit({
600
+ type: 'work.held',
601
+ graphId: w.graphId,
602
+ workId: id,
603
+ payload: { operation: 'checks:' + pending.id, reason, released: false },
604
+ });
605
+ }
606
+ }
607
+ /** Ручное техническое подтверждение принятия (operator mode). */
608
+ approveAdoption(suiteId, pendingId) {
609
+ this.store.transact('checks.adopted', (tx) => {
610
+ const suite = structuredClone(this.get(tx, suiteId));
611
+ const pending = suite.pending.find((p) => p.id === pendingId);
612
+ if (!pending)
613
+ throw new HarnessError('NOT_FOUND', 'Принятие версии не найдено');
614
+ if (!pending.awaitingOperator)
615
+ return;
616
+ delete pending.awaitingOperator;
617
+ pending.actor += ' + operator';
618
+ this.save(tx, suite);
619
+ });
620
+ return this.adopt(suiteId, pendingId);
621
+ }
622
+ /** Продолжить отложенные принятия: поручение освободилось. Ничего не запускает. */
623
+ resume() {
624
+ const waiting = this.store.read((v) => this.all(v).flatMap((s) => s.pending
625
+ .filter((p) => p.status === 'waiting' && !p.awaitingOperator)
626
+ .map((p) => [s.id, p.id])));
627
+ for (const [suite, pending] of waiting)
628
+ try {
629
+ this.adopt(suite, pending);
630
+ }
631
+ catch {
632
+ // Принятие повторится на следующем шаге; причина остаётся в записи.
633
+ }
634
+ }
635
+ /**
636
+ * Снять удержание операции принятия. Только своё: чужая операция
637
+ * чужое удержание не снимает.
638
+ */
639
+ abandon(suiteId, pendingId, operation) {
640
+ return this.store.transact('checks.adoption-abandoned', (tx) => {
641
+ const suite = structuredClone(this.get(tx, suiteId));
642
+ const pending = suite.pending.find((p) => p.id === pendingId);
643
+ if (!pending)
644
+ throw new HarnessError('NOT_FOUND', 'Принятие версии не найдено');
645
+ if (operation !== 'checks:' + pending.id)
646
+ throw new HarnessError('INVALID_INPUT', 'Операция не владеет этим принятием');
647
+ for (const w of tx.works())
648
+ if (w.hold?.operation === operation)
649
+ this.release(tx, w, operation);
650
+ pending.status = 'completed';
651
+ pending.completedAt = new Date(this.now()).toISOString();
652
+ for (const id of pending.tasks)
653
+ if (pending.results[id]?.startsWith('ждёт'))
654
+ pending.results[id] = 'отменено';
655
+ this.save(tx, suite);
656
+ return { pending: pending.id };
657
+ });
658
+ }
659
+ }
660
+ export function portableSuite(suite, roots = []) {
661
+ return scrubPortable({
662
+ id: suite.id,
663
+ repositoryId: suite.repositoryId,
664
+ contractId: suite.contractId,
665
+ contractDigest: suite.contractDigest,
666
+ gate: suite.gate,
667
+ basis: suite.basis,
668
+ cases: Object.values(suite.cases).sort((a, b) => a.id.localeCompare(b.id)),
669
+ decisions: suite.decisions.map(({ method, ...d }) => method
670
+ ? {
671
+ ...d,
672
+ method: {
673
+ gate: method.gate,
674
+ artifacts: method.artifacts,
675
+ contract: method.contract,
676
+ basis: method.basis,
677
+ },
678
+ }
679
+ : d),
680
+ versions: suite.versions,
681
+ adoptions: suite.adoptions,
682
+ }, roots);
683
+ }
684
+ /**
685
+ * Диагностические поля проходят очистку путей и на экспорте, и на чтении:
686
+ * запись прежней версии могла сохранить их до появления очистки. Очистка
687
+ * идемпотентна, нормативные поля (вход, ожидание, digest) не меняются.
688
+ */
689
+ export function scrubPortable(suite, roots = []) {
690
+ const scrub = (text) => scrubLocalPaths(text, roots);
691
+ return {
692
+ ...suite,
693
+ decisions: suite.decisions.map((d) => ({
694
+ ...d,
695
+ reason: scrub(d.reason),
696
+ ...(d.observed
697
+ ? {
698
+ observed: {
699
+ ...d.observed,
700
+ ...(d.observed.actual !== undefined ? { actual: scrub(d.observed.actual) } : {}),
701
+ },
702
+ }
703
+ : {}),
704
+ ...(d.claimed
705
+ ? {
706
+ claimed: {
707
+ ...d.claimed,
708
+ actual: d.claimed.actual === null ? null : scrub(d.claimed.actual),
709
+ },
710
+ }
711
+ : {}),
712
+ })),
713
+ versions: suite.versions.map((v) => ({ ...v, reason: scrub(v.reason) })),
714
+ adoptions: suite.adoptions.map((a) => ({ ...a, reason: scrub(a.reason) })),
715
+ };
716
+ }
717
+ /**
718
+ * Проверка переносимого корпуса: каждая версия собирается из своих случаев в
719
+ * канонический файл, и его digest совпадает с записанным. Запись, где версия
720
+ * ссылается на отсутствующий случай или подменён digest, не принимается.
721
+ */
722
+ export function verifySuite(suite) {
723
+ const local = {
724
+ ...suite,
725
+ cases: Object.fromEntries(suite.cases.map((c) => [c.id, c])),
726
+ decisions: suite.decisions,
727
+ pending: [],
728
+ operations: {},
729
+ revision: 0,
730
+ };
731
+ const contents = new Map();
732
+ for (const v of suite.versions) {
733
+ if (v.cases.some((id) => !local.cases[id]))
734
+ throw new HarnessError('INVALID_INPUT', `Версия ${v.number} корпуса ${suite.id} ссылается на отсутствующие случаи`);
735
+ const content = corpusFile(local, v.cases, v.number);
736
+ if (sha256(content) !== v.digest)
737
+ throw new HarnessError('INVALID_INPUT', `Digest версии ${v.number} корпуса ${suite.id} не совпадает с её случаями`);
738
+ contents.set(v.digest, content);
739
+ }
740
+ for (const c of suite.cases)
741
+ if (c.id !== caseId(c.property, c.input, suite.basis))
742
+ throw new HarnessError('INVALID_INPUT', `Id случая ${c.id} корпуса ${suite.id} не соответствует его входу и основанию`);
743
+ return contents;
744
+ }