@intentius/chant 0.90.0 → 0.93.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 (402) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts +8 -0
  4. package/dist/cli/handlers/components.d.ts.map +1 -1
  5. package/dist/cli/handlers/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/operator.d.ts +20 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/mcp/workspace-tools.d.ts +7 -4
  11. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  12. package/dist/cli/plugins.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +42 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/config.d.ts +22 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/lexicon-module.d.ts +22 -0
  21. package/dist/lexicon-module.d.ts.map +1 -1
  22. package/dist/lifecycle/converge-ledger.d.ts +19 -0
  23. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/git.d.ts +40 -5
  25. package/dist/lifecycle/git.d.ts.map +1 -1
  26. package/dist/lifecycle/lease.d.ts +72 -16
  27. package/dist/lifecycle/lease.d.ts.map +1 -1
  28. package/dist/lifecycle/member-ledger.d.ts +3 -2
  29. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  30. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  31. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/symptoms.d.ts +51 -0
  35. package/dist/lifecycle/symptoms.d.ts.map +1 -1
  36. package/dist/lifecycle/work-lease.d.ts +140 -0
  37. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  38. package/dist/lint/rules/index.d.ts +4 -2
  39. package/dist/lint/rules/index.d.ts.map +1 -1
  40. package/dist/lint/rules/op/index.d.ts +14 -3
  41. package/dist/lint/rules/op/index.d.ts.map +1 -1
  42. package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
  43. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
  44. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
  45. package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
  46. package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
  47. package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
  48. package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
  49. package/dist/op/activities/activity-contracts.d.ts +203 -2
  50. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  51. package/dist/op/activities/apply.d.ts.map +1 -1
  52. package/dist/op/activities/change-coverage.d.ts +44 -0
  53. package/dist/op/activities/change-coverage.d.ts.map +1 -0
  54. package/dist/op/activities/converge.d.ts +24 -1
  55. package/dist/op/activities/converge.d.ts.map +1 -1
  56. package/dist/op/activities/decide.d.ts +107 -0
  57. package/dist/op/activities/decide.d.ts.map +1 -0
  58. package/dist/op/activities/index.d.ts +10 -0
  59. package/dist/op/activities/index.d.ts.map +1 -1
  60. package/dist/op/activities/shell.d.ts +32 -0
  61. package/dist/op/activities/shell.d.ts.map +1 -1
  62. package/dist/op/activities/source-release.d.ts +86 -0
  63. package/dist/op/activities/source-release.d.ts.map +1 -0
  64. package/dist/op/activities/source-rollback.d.ts +100 -0
  65. package/dist/op/activities/source-rollback.d.ts.map +1 -0
  66. package/dist/op/activities/work-evidence.d.ts +42 -0
  67. package/dist/op/activities/work-evidence.d.ts.map +1 -0
  68. package/dist/op/activity-contract.d.ts +1 -0
  69. package/dist/op/activity-contract.d.ts.map +1 -1
  70. package/dist/op/activity-profiles.d.ts +16 -0
  71. package/dist/op/activity-profiles.d.ts.map +1 -1
  72. package/dist/op/builders.d.ts +84 -2
  73. package/dist/op/builders.d.ts.map +1 -1
  74. package/dist/op/composites/converge-op.d.ts +31 -4
  75. package/dist/op/composites/converge-op.d.ts.map +1 -1
  76. package/dist/op/decide-backend.d.ts +116 -0
  77. package/dist/op/decide-backend.d.ts.map +1 -0
  78. package/dist/op/decide-config.d.ts +120 -0
  79. package/dist/op/decide-config.d.ts.map +1 -0
  80. package/dist/op/decide-read-inputs.d.ts +40 -0
  81. package/dist/op/decide-read-inputs.d.ts.map +1 -0
  82. package/dist/op/discover.d.ts +25 -0
  83. package/dist/op/discover.d.ts.map +1 -1
  84. package/dist/op/gate-wait.d.ts +43 -0
  85. package/dist/op/gate-wait.d.ts.map +1 -0
  86. package/dist/op/gate.d.ts +5 -2
  87. package/dist/op/gate.d.ts.map +1 -1
  88. package/dist/op/index.d.ts +18 -5
  89. package/dist/op/index.d.ts.map +1 -1
  90. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  91. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  92. package/dist/op/local-executor.d.ts +48 -4
  93. package/dist/op/local-executor.d.ts.map +1 -1
  94. package/dist/op/local-output.d.ts +2 -0
  95. package/dist/op/local-output.d.ts.map +1 -1
  96. package/dist/op/op-ir.d.ts +12 -1
  97. package/dist/op/op-ir.d.ts.map +1 -1
  98. package/dist/op/op-verb-class.d.ts.map +1 -1
  99. package/dist/op/operator.d.ts +126 -1
  100. package/dist/op/operator.d.ts.map +1 -1
  101. package/dist/op/runtime.d.ts +46 -5
  102. package/dist/op/runtime.d.ts.map +1 -1
  103. package/dist/op/runtimes/local.d.ts.map +1 -1
  104. package/dist/op/source-archive.d.ts +63 -0
  105. package/dist/op/source-archive.d.ts.map +1 -0
  106. package/dist/op/step-output-ref.d.ts +2 -2
  107. package/dist/op/step-output-ref.d.ts.map +1 -1
  108. package/dist/op/steward-points.d.ts +136 -0
  109. package/dist/op/steward-points.d.ts.map +1 -0
  110. package/dist/op/steward-turn.d.ts +53 -0
  111. package/dist/op/steward-turn.d.ts.map +1 -0
  112. package/dist/op/steward.d.ts +161 -0
  113. package/dist/op/steward.d.ts.map +1 -0
  114. package/dist/op/types.d.ts +58 -0
  115. package/dist/op/types.d.ts.map +1 -1
  116. package/dist/op/work-lease-decl.d.ts +18 -0
  117. package/dist/op/work-lease-decl.d.ts.map +1 -0
  118. package/dist/op/work-lease-run.d.ts +173 -0
  119. package/dist/op/work-lease-run.d.ts.map +1 -0
  120. package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
  121. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
  122. package/dist/workspace/answers-ledger.d.ts +76 -0
  123. package/dist/workspace/answers-ledger.d.ts.map +1 -0
  124. package/dist/workspace/box-isolation.d.ts +99 -0
  125. package/dist/workspace/box-isolation.d.ts.map +1 -0
  126. package/dist/workspace/changes-cli.d.ts +16 -0
  127. package/dist/workspace/changes-cli.d.ts.map +1 -0
  128. package/dist/workspace/changes.d.ts +200 -0
  129. package/dist/workspace/changes.d.ts.map +1 -0
  130. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
  131. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
  132. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
  133. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
  134. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
  135. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
  136. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
  137. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
  138. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
  139. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
  140. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
  141. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
  142. package/dist/workspace/chant-migrations.d.ts +99 -0
  143. package/dist/workspace/chant-migrations.d.ts.map +1 -0
  144. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  145. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  146. package/dist/workspace/checks/boxes.d.ts +104 -0
  147. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  148. package/dist/workspace/checks/diagrams.d.ts +31 -0
  149. package/dist/workspace/checks/diagrams.d.ts.map +1 -0
  150. package/dist/workspace/checks/records.d.ts +8 -0
  151. package/dist/workspace/checks/records.d.ts.map +1 -1
  152. package/dist/workspace/checks.d.ts +13 -1
  153. package/dist/workspace/checks.d.ts.map +1 -1
  154. package/dist/workspace/compose-graph.d.ts +2 -0
  155. package/dist/workspace/compose-graph.d.ts.map +1 -1
  156. package/dist/workspace/conformance/index.d.ts.map +1 -1
  157. package/dist/workspace/decide.d.ts +229 -0
  158. package/dist/workspace/decide.d.ts.map +1 -0
  159. package/dist/workspace/decision-points.schema.json +137 -0
  160. package/dist/workspace/declaration.d.ts +103 -0
  161. package/dist/workspace/declaration.d.ts.map +1 -1
  162. package/dist/workspace/declaration.schema.json +285 -0
  163. package/dist/workspace/declared-kinds.d.ts +32 -0
  164. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  165. package/dist/workspace/generated-files.d.ts +7 -0
  166. package/dist/workspace/generated-files.d.ts.map +1 -1
  167. package/dist/workspace/intent.d.ts.map +1 -1
  168. package/dist/workspace/lineage-check.d.ts +3 -0
  169. package/dist/workspace/lineage-check.d.ts.map +1 -1
  170. package/dist/workspace/lineage-upgrade.d.ts +6 -1
  171. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  172. package/dist/workspace/ls.d.ts +39 -1
  173. package/dist/workspace/ls.d.ts.map +1 -1
  174. package/dist/workspace/point-answer.schema.json +95 -0
  175. package/dist/workspace/points-cli.d.ts +113 -0
  176. package/dist/workspace/points-cli.d.ts.map +1 -0
  177. package/dist/workspace/points.d.ts +320 -0
  178. package/dist/workspace/points.d.ts.map +1 -0
  179. package/dist/workspace/reason-codes.d.ts +38 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/record-assets.d.ts.map +1 -1
  182. package/dist/workspace/records-cli.d.ts +23 -1
  183. package/dist/workspace/records-cli.d.ts.map +1 -1
  184. package/dist/workspace/records-close.d.ts +1 -1
  185. package/dist/workspace/records-close.d.ts.map +1 -1
  186. package/dist/workspace/records-write.d.ts +11 -5
  187. package/dist/workspace/records-write.d.ts.map +1 -1
  188. package/dist/workspace/records.d.ts +42 -6
  189. package/dist/workspace/records.d.ts.map +1 -1
  190. package/dist/workspace/status-gates.d.ts +7 -2
  191. package/dist/workspace/status-gates.d.ts.map +1 -1
  192. package/dist/workspace/status-stewards.d.ts +179 -0
  193. package/dist/workspace/status-stewards.d.ts.map +1 -0
  194. package/dist/workspace/status.d.ts +68 -1
  195. package/dist/workspace/status.d.ts.map +1 -1
  196. package/dist/workspace/work-cli.d.ts +189 -0
  197. package/dist/workspace/work-cli.d.ts.map +1 -0
  198. package/dist/workspace/work-evidence.d.ts +74 -0
  199. package/dist/workspace/work-evidence.d.ts.map +1 -0
  200. package/dist/workspace/work.d.ts +43 -1
  201. package/dist/workspace/work.d.ts.map +1 -1
  202. package/package.json +1 -1
  203. package/src/cli/commands/audit.test.ts +9 -1
  204. package/src/cli/commands/build.ts +3 -2
  205. package/src/cli/commands/lint.ts +7 -1
  206. package/src/cli/handlers/components.test.ts +93 -0
  207. package/src/cli/handlers/components.ts +44 -3
  208. package/src/cli/handlers/graph.test.ts +20 -0
  209. package/src/cli/handlers/graph.ts +16 -2
  210. package/src/cli/handlers/operator.ts +141 -4
  211. package/src/cli/handlers/run.test.ts +173 -2
  212. package/src/cli/handlers/run.ts +174 -3
  213. package/src/cli/main.test.ts +50 -0
  214. package/src/cli/main.ts +97 -2
  215. package/src/cli/mcp/workspace-tools.test.ts +48 -19
  216. package/src/cli/mcp/workspace-tools.ts +110 -8
  217. package/src/cli/plugins.ts +11 -3
  218. package/src/cli/registry.ts +42 -1
  219. package/src/codegen/pinned-upgrade.ts +2 -1
  220. package/src/codegen/release-wiring.test.ts +49 -2
  221. package/src/components/capability-plugin-loader.ts +3 -3
  222. package/src/components/cli-support.ts +2 -2
  223. package/src/components/verbs/vuln-scan.test.ts +124 -1
  224. package/src/components/verbs/vuln-scan.ts +142 -1
  225. package/src/config.ts +11 -0
  226. package/src/lexicon-module.ts +56 -1
  227. package/src/lexicon-package.test.ts +96 -0
  228. package/src/lifecycle/converge-ledger.ts +20 -0
  229. package/src/lifecycle/git.ts +65 -15
  230. package/src/lifecycle/lease.test.ts +22 -0
  231. package/src/lifecycle/lease.ts +133 -29
  232. package/src/lifecycle/member-ledger.ts +3 -2
  233. package/src/lifecycle/plan-ledger.test.ts +148 -0
  234. package/src/lifecycle/plan-ledger.ts +158 -0
  235. package/src/lifecycle/run-ledger.ts +6 -0
  236. package/src/lifecycle/symptoms.ts +95 -0
  237. package/src/lifecycle/work-lease.test.ts +236 -0
  238. package/src/lifecycle/work-lease.ts +426 -0
  239. package/src/lint/rules/index.ts +6 -2
  240. package/src/lint/rules/op/index.ts +17 -4
  241. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
  242. package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
  243. package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
  244. package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
  245. package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
  246. package/src/lint/rules/sys001-no-literal-key.ts +67 -0
  247. package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
  248. package/src/op/activities/activity-contracts.ts +190 -1
  249. package/src/op/activities/apply.ts +7 -6
  250. package/src/op/activities/change-coverage.ts +60 -0
  251. package/src/op/activities/converge.test.ts +55 -1
  252. package/src/op/activities/converge.ts +208 -29
  253. package/src/op/activities/decide.live.test.ts +78 -0
  254. package/src/op/activities/decide.test.ts +300 -0
  255. package/src/op/activities/decide.ts +267 -0
  256. package/src/op/activities/index.ts +21 -0
  257. package/src/op/activities/shell.test.ts +91 -1
  258. package/src/op/activities/shell.ts +91 -2
  259. package/src/op/activities/source-release.test.ts +118 -0
  260. package/src/op/activities/source-release.ts +168 -0
  261. package/src/op/activities/source-rollback.test.ts +131 -0
  262. package/src/op/activities/source-rollback.ts +239 -0
  263. package/src/op/activities/work-evidence.ts +62 -0
  264. package/src/op/activity-contract-registry.test.ts +2 -0
  265. package/src/op/activity-contract-registry.ts +2 -2
  266. package/src/op/activity-contract.test.ts +12 -0
  267. package/src/op/activity-contract.ts +6 -1
  268. package/src/op/activity-profiles.ts +29 -0
  269. package/src/op/activity-registry.test.ts +1 -1
  270. package/src/op/activity-registry.ts +2 -2
  271. package/src/op/builders.ts +124 -5
  272. package/src/op/command-gate.test.ts +192 -0
  273. package/src/op/composites/converge-op.test.ts +27 -1
  274. package/src/op/composites/converge-op.ts +69 -15
  275. package/src/op/decide-backend.test.ts +44 -0
  276. package/src/op/decide-backend.ts +212 -0
  277. package/src/op/decide-config.test.ts +52 -0
  278. package/src/op/decide-config.ts +80 -0
  279. package/src/op/decide-read-inputs.ts +117 -0
  280. package/src/op/discover.ts +71 -0
  281. package/src/op/gate-approval.ts +2 -2
  282. package/src/op/gate-wait.ts +57 -0
  283. package/src/op/gate.ts +8 -3
  284. package/src/op/generate-pipeline.ts +2 -2
  285. package/src/op/index.ts +34 -6
  286. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  287. package/src/op/lifecycle-receipt-store.ts +61 -0
  288. package/src/op/local-executor.ts +367 -33
  289. package/src/op/local-output.test.ts +33 -0
  290. package/src/op/local-output.ts +33 -2
  291. package/src/op/op-ir.test.ts +10 -0
  292. package/src/op/op-ir.ts +18 -0
  293. package/src/op/op-verb-class.ts +1 -0
  294. package/src/op/operator.ts +360 -10
  295. package/src/op/runtime.ts +37 -8
  296. package/src/op/runtimes/local.ts +6 -1
  297. package/src/op/source-archive.ts +161 -0
  298. package/src/op/step-output-ref.ts +6 -2
  299. package/src/op/steward-points.test.ts +313 -0
  300. package/src/op/steward-points.ts +194 -0
  301. package/src/op/steward-turn.ts +79 -0
  302. package/src/op/steward.test.ts +296 -0
  303. package/src/op/steward.ts +277 -0
  304. package/src/op/types.ts +60 -0
  305. package/src/op/work-lease-decl.ts +80 -0
  306. package/src/op/work-lease-run.test.ts +345 -0
  307. package/src/op/work-lease-run.ts +395 -0
  308. package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
  309. package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
  310. package/src/workspace/answers-ledger.ts +118 -0
  311. package/src/workspace/box-isolation.test.ts +261 -0
  312. package/src/workspace/box-isolation.ts +205 -0
  313. package/src/workspace/changes-cli.ts +56 -0
  314. package/src/workspace/changes.schema.json +472 -0
  315. package/src/workspace/changes.test.ts +315 -0
  316. package/src/workspace/changes.ts +512 -0
  317. package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
  318. package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
  319. package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
  320. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
  321. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
  322. package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
  323. package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
  324. package/src/workspace/chant-migrations.ts +164 -0
  325. package/src/workspace/check-contract.test.ts +5 -1
  326. package/src/workspace/check.schema.json +25 -7
  327. package/src/workspace/checks/box-isolation.ts +68 -0
  328. package/src/workspace/checks/boxes.test.ts +276 -0
  329. package/src/workspace/checks/boxes.ts +442 -0
  330. package/src/workspace/checks/diagrams.test.ts +134 -0
  331. package/src/workspace/checks/diagrams.ts +108 -0
  332. package/src/workspace/checks/records.ts +72 -0
  333. package/src/workspace/checks.test.ts +7 -0
  334. package/src/workspace/checks.ts +27 -3
  335. package/src/workspace/compose-graph.ts +2 -0
  336. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
  337. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
  338. package/src/workspace/conformance/index.ts +5 -0
  339. package/src/workspace/decide.test.ts +224 -0
  340. package/src/workspace/decide.ts +709 -0
  341. package/src/workspace/decision-points.schema.json +137 -0
  342. package/src/workspace/declaration.schema.json +285 -0
  343. package/src/workspace/declaration.test.ts +58 -0
  344. package/src/workspace/declaration.ts +241 -0
  345. package/src/workspace/declared-kinds.test.ts +5 -5
  346. package/src/workspace/declared-kinds.ts +71 -3
  347. package/src/workspace/generated-files.ts +14 -0
  348. package/src/workspace/graph-cli.ts +1 -1
  349. package/src/workspace/graph.schema.json +9 -1
  350. package/src/workspace/intent.schema.json +12 -3
  351. package/src/workspace/intent.ts +3 -10
  352. package/src/workspace/lineage-check.ts +16 -1
  353. package/src/workspace/lineage-upgrade-cli.ts +1 -1
  354. package/src/workspace/lineage-upgrade.test.ts +70 -0
  355. package/src/workspace/lineage-upgrade.ts +81 -10
  356. package/src/workspace/ls-contract.test.ts +57 -0
  357. package/src/workspace/ls.schema.json +46 -1
  358. package/src/workspace/ls.ts +62 -3
  359. package/src/workspace/point-answer.schema.json +95 -0
  360. package/src/workspace/points-cli.ts +284 -0
  361. package/src/workspace/points-write.schema.json +499 -0
  362. package/src/workspace/points.schema.json +743 -0
  363. package/src/workspace/points.test.ts +264 -0
  364. package/src/workspace/points.ts +564 -0
  365. package/src/workspace/read-contract.test.ts +18 -1
  366. package/src/workspace/reason-codes.test.ts +24 -1
  367. package/src/workspace/reason-codes.ts +49 -0
  368. package/src/workspace/record-assets.test.ts +3 -2
  369. package/src/workspace/record-assets.ts +4 -1
  370. package/src/workspace/records-amend.schema.json +2 -0
  371. package/src/workspace/records-cli.ts +28 -5
  372. package/src/workspace/records-close.schema.json +2 -0
  373. package/src/workspace/records-contract.test.ts +3 -2
  374. package/src/workspace/records-new.schema.json +2 -0
  375. package/src/workspace/records-review.schema.json +2 -0
  376. package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
  377. package/src/workspace/records-sessions-write.test.ts +2 -34
  378. package/src/workspace/records-write.e2e.test.ts +108 -0
  379. package/src/workspace/records-write.test.ts +32 -41
  380. package/src/workspace/records-write.ts +33 -15
  381. package/src/workspace/records.schema.json +53 -2
  382. package/src/workspace/records.test.ts +48 -3
  383. package/src/workspace/records.ts +142 -6
  384. package/src/workspace/status-contract.test.ts +189 -6
  385. package/src/workspace/status-gates.ts +9 -4
  386. package/src/workspace/status-stewards.ts +329 -0
  387. package/src/workspace/status.schema.json +335 -5
  388. package/src/workspace/status.ts +142 -6
  389. package/src/workspace/work-acceptance.test.ts +274 -0
  390. package/src/workspace/work-cli.e2e.test.ts +180 -0
  391. package/src/workspace/work-cli.ts +429 -0
  392. package/src/workspace/work-evidence.schema.json +212 -0
  393. package/src/workspace/work-evidence.ts +169 -0
  394. package/src/workspace/work-history.schema.json +349 -0
  395. package/src/workspace/work-history.test.ts +156 -0
  396. package/src/workspace/work-lease.schema.json +233 -0
  397. package/src/workspace/work-readiness-chud.test.ts +145 -0
  398. package/src/workspace/work.test.ts +3 -2
  399. package/src/workspace/work.ts +100 -1
  400. /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
  401. /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
  402. /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
@@ -13,7 +13,7 @@ import { z } from "zod";
13
13
  import { shellCmd } from "./shell";
14
14
  import { shellCmdContract } from "./activity-contracts";
15
15
  import { shell } from "../builders";
16
- import { phase } from "../builders";
16
+ import { activity, phase } from "../builders";
17
17
  import { collectStepOutputRefs, stepOutput, validateStepOutputRefs } from "../step-output-ref";
18
18
  import { ACTIVITY_PROFILES } from "../activity-profiles";
19
19
  import { runOpLocally } from "../local-executor";
@@ -154,3 +154,93 @@ describe("a reference reaching the command (#2414)", () => {
154
154
  expect(seen.map((s) => s.stdout)).toEqual(["db.internal", "reached db.internal"]);
155
155
  });
156
156
  });
157
+
158
+ describe("structured output (#2787)", () => {
159
+ test("with json, stdout is parsed and published as json; empty stdout is null", async () => {
160
+ expect(await shellCmd({ cmd: `echo '["W-3","W-7"]'`, json: true })).toMatchObject({ stdout: '["W-3","W-7"]', json: ["W-3", "W-7"] });
161
+ expect(await shellCmd({ cmd: `echo '{"ids":["W-1"],"tier":"small"}'`, json: true })).toMatchObject({ json: { ids: ["W-1"], tier: "small" } });
162
+ expect((await shellCmd({ cmd: "true", json: true })).json).toBeNull();
163
+ // Without json, nothing is parsed, and there is no json field.
164
+ expect(await shellCmd({ cmd: `echo '[1]'` })).toEqual({ stdout: "[1]", stderr: "", exitCode: 0 });
165
+ });
166
+
167
+ test("stdout that is not JSON fails the step rather than reaching the next one as a string", async () => {
168
+ await expect(shellCmd({ cmd: "echo W-3", json: true })).rejects.toThrow(/stdout is not JSON.*W-3/);
169
+ });
170
+
171
+ test("an okExit code still publishes the parsed output", async () => {
172
+ expect(await shellCmd({ cmd: `echo '[]'; exit 3`, okExit: [0, 3], json: true })).toMatchObject({ exitCode: 3, json: [] });
173
+ });
174
+
175
+ test("the contract takes json, and a reference to the whole parsed value passes OPS013", () => {
176
+ const pick = shell("node pick.mjs", { id: "pick", json: true });
177
+ expect(pick.args).toEqual({ cmd: "node pick.mjs", json: true });
178
+ expect(shellCmdContract.args.safeParse(pick.args).success).toBe(true);
179
+ const contracts = new Map([["shellCmd", shellCmdContract]]);
180
+ const whole = activity("recordItems", { items: pick.out.json });
181
+ expect(validateStepOutputRefs({ name: "dispatch", phases: [phase("Pick", [pick]), phase("Build", [whole])] }, contracts)).toEqual([]);
182
+ expect(stepOutput(pick, "json")).toMatchObject({ step: "pick", path: "json" });
183
+ // The parsed value's shape is the command's, so a path under it can't be checked at build time.
184
+ const deeper = activity("recordItems", { items: pick.out["json.ids"] });
185
+ const issues = validateStepOutputRefs({ name: "dispatch", phases: [phase("Pick", [pick]), phase("Build", [deeper])] }, contracts);
186
+ expect(issues.map((i) => i.message)).toEqual([expect.stringMatching(/path "json.ids", which does not exist/)]);
187
+ });
188
+ });
189
+
190
+ describe("a step's own timeout (#2787)", () => {
191
+ test("shell() carries it on the step, beside the profile it keeps", () => {
192
+ const build = shell("./build.sh", { id: "build", timeout: "45m" });
193
+ expect(build).toMatchObject({ fn: "shellCmd", profile: "atMostOnce", timeout: "45m", id: "build" });
194
+ expect(build.args).toEqual({ cmd: "./build.sh" });
195
+ });
196
+
197
+ test("the executor stops the one attempt at the step's timeout, not the profile's", async () => {
198
+ const slow = shell("sleep 5", { timeout: "300ms" });
199
+ const started = Date.now();
200
+ const result = await runOpLocally(
201
+ { name: "build", overview: "", phases: [phase("Go", [slow])] },
202
+ new Map([["shellCmd", shellCmd as unknown as ActivityFn]]),
203
+ ACTIVITY_PROFILES,
204
+ undefined,
205
+ { gates: memoryGateLedgerPort() },
206
+ ).catch((err: { result?: { status: string; records: { error?: string }[] } }) => err.result!);
207
+ expect(Date.now() - started).toBeLessThan(4_000);
208
+ expect(result.status).toBe("fail");
209
+ expect(result.records[0].error).toMatch(/timed out after 300ms/);
210
+ });
211
+
212
+ test("the profile's retries are kept: a retrying profile retries each attempt under the step's timeout", async () => {
213
+ let calls = 0;
214
+ const flaky: ActivityFn = async (_args, signal) => {
215
+ calls++;
216
+ await new Promise((resolve, reject) => {
217
+ const t = setTimeout(resolve, 1_000);
218
+ signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); });
219
+ });
220
+ return {};
221
+ };
222
+ const result = await runOpLocally(
223
+ { name: "retry", overview: "", phases: [{ name: "Go", steps: [{ kind: "activity", fn: "flaky", profile: "fastIdempotent", timeout: "100ms" }] }] },
224
+ new Map([["flaky", flaky]]),
225
+ { fastIdempotent: { timeout: "10s", retry: { maximumAttempts: 2, initialInterval: "10ms" } } },
226
+ undefined,
227
+ { gates: memoryGateLedgerPort() },
228
+ ).catch((err: { result?: { status: string } }) => err.result!);
229
+ expect(result.status).toBe("fail");
230
+ expect(calls).toBe(2);
231
+ });
232
+
233
+ test("a timeout that is not a duration, or longer than six hours, fails the step at run time", async () => {
234
+ for (const timeout of ["forever", "7h", "0m"]) {
235
+ const result = await runOpLocally(
236
+ { name: "bad", overview: "", phases: [phase("Go", [shell("true", { timeout })])] },
237
+ new Map([["shellCmd", shellCmd as unknown as ActivityFn]]),
238
+ ACTIVITY_PROFILES,
239
+ undefined,
240
+ { gates: memoryGateLedgerPort() },
241
+ ).catch((err: { result?: { status: string; records: { error?: string }[] } }) => err.result!);
242
+ expect(result.status, timeout).toBe("fail");
243
+ expect(result.records[0].error, timeout).toMatch(/timeout/);
244
+ }
245
+ });
246
+ });
@@ -1,5 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { GateWait, asPendingGate } from "../gate-wait";
4
+ import type { PendingGateRecord } from "../../lifecycle/gate-ledger";
3
5
 
4
6
  const execAsync = promisify(exec);
5
7
 
@@ -19,6 +21,33 @@ export interface ShellCmdArgs {
19
21
  * step can branch on instead of a failed Op.
20
22
  */
21
23
  okExit?: number[];
24
+ /**
25
+ * The exit code that means the command stopped at a gate of its own
26
+ * (#2779), such as `chant workspace upgrade`'s 3. On it the step does not
27
+ * fail and does not pass: the run ends `gated` at the command's gate, as it
28
+ * would at a pending `gate` step, and a work lease is released `gated`.
29
+ *
30
+ * The gate is read from the command's stdout when that is JSON carrying a
31
+ * `pending` fact, as `chant workspace upgrade --json` prints; otherwise
32
+ * `gate` names it, and the newest pending fact for it on the gate ledger is
33
+ * the one the run stops at. It is read before `okExit`, so a code in both
34
+ * stops the run.
35
+ */
36
+ gatedExit?: number;
37
+ /**
38
+ * The gate the command records, for `gatedExit`, when its stdout doesn't
39
+ * carry it: the op it is recorded under and its name, the two arguments
40
+ * `chant approve` takes (`{ op: "workspace-upgrade", gate: "." }`).
41
+ */
42
+ gate?: { op: string; gate: string };
43
+ /**
44
+ * Parse stdout as JSON and publish it as `json` (#2787), so a later step,
45
+ * or `workLease.item`, reads a list or an object rather than a string:
46
+ * `pick.out.json`. Stdout that is not JSON fails the step, since a value
47
+ * the author said was JSON and isn't would otherwise reach the next step as
48
+ * something else. Empty stdout is `null`.
49
+ */
50
+ json?: boolean;
22
51
  }
23
52
 
24
53
  /**
@@ -51,6 +80,20 @@ export interface ShellCmdResult {
51
80
  stdout: string;
52
81
  stderr: string;
53
82
  exitCode: number;
83
+ /** Stdout parsed as JSON, present when the step set `json` (#2787). */
84
+ json?: unknown;
85
+ }
86
+
87
+ /** `result` with its stdout parsed as JSON, when the step asked for it. */
88
+ function withJson(result: ShellCmdResult, args: ShellCmdArgs): ShellCmdResult {
89
+ if (!args.json) return result;
90
+ if (result.stdout === "") return { ...result, json: null };
91
+ try {
92
+ return { ...result, json: JSON.parse(result.stdout) };
93
+ } catch (err) {
94
+ const shown = result.stdout.length > 200 ? `${result.stdout.slice(0, 200)}...` : result.stdout;
95
+ throw new Error(`the step sets json, and its stdout is not JSON (${err instanceof Error ? err.message : String(err)}): ${shown}`);
96
+ }
54
97
  }
55
98
 
56
99
  /** Node hangs the exit status off the error as `code`, and a signal kill as `signal`. */
@@ -78,7 +121,7 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
78
121
  signal,
79
122
  });
80
123
  if (stderr) console.error(stderr);
81
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
124
+ return withJson({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }, args);
82
125
  } catch (err) {
83
126
  const failure = err as ExecFailure;
84
127
  const exitCode = typeof failure.code === "number" ? failure.code : undefined;
@@ -86,10 +129,15 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
86
129
  // not an exit status the author declared anything about, so it rethrows
87
130
  // even if `okExit` happens to contain the code.
88
131
  const died = failure.killed === true || typeof failure.signal === "string" || signal?.aborted === true;
132
+ if (exitCode !== undefined && !died && args.gatedExit !== undefined && exitCode === args.gatedExit) {
133
+ const stderrText = (failure.stderr ?? "").trim();
134
+ if (stderrText) console.error(stderrText);
135
+ throw new GateWait(await commandGate(args, (failure.stdout ?? "").trim(), exitCode));
136
+ }
89
137
  if (exitCode !== undefined && !died && okExit.includes(exitCode)) {
90
138
  const stderrText = (failure.stderr ?? "").trim();
91
139
  if (stderrText) console.error(stderrText);
92
- return { stdout: (failure.stdout ?? "").trim(), stderr: stderrText, exitCode };
140
+ return withJson({ stdout: (failure.stdout ?? "").trim(), stderr: stderrText, exitCode }, args);
93
141
  }
94
142
  if (exitCode !== undefined && !died) {
95
143
  // `local-executor` records `error` as message text, so the code has to
@@ -99,3 +147,44 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
99
147
  throw failure;
100
148
  }
101
149
  }
150
+
151
+ /** The pending fact a command's stdout carries: the whole of it as JSON, or its last line. */
152
+ function pendingFromStdout(stdout: string): PendingGateRecord | undefined {
153
+ const lines = stdout.split("\n");
154
+ for (const text of [stdout, lines[lines.length - 1] ?? ""]) {
155
+ try {
156
+ const parsed = JSON.parse(text) as { pending?: unknown } | null;
157
+ const pending = asPendingGate(parsed?.pending);
158
+ if (pending) return pending;
159
+ } catch {
160
+ // Not JSON: try the next reading, then the step's own `gate`.
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ /**
167
+ * The gate a command stopped at (#2779): the pending fact its stdout
168
+ * carries, or the newest one the gate ledger holds for the step's `gate`.
169
+ * Throws when neither names one, since a run can't be `gated` at a gate
170
+ * nobody can approve.
171
+ */
172
+ async function commandGate(args: ShellCmdArgs, stdout: string, exitCode: number): Promise<PendingGateRecord> {
173
+ const fromStdout = pendingFromStdout(stdout);
174
+ if (fromStdout) return fromStdout;
175
+ const named = args.gate;
176
+ if (!named) {
177
+ throw new Error(
178
+ `command exited ${exitCode}, its gatedExit, and named no gate: its stdout carries no \`pending\` gate fact as JSON, and the step has no \`gate\``,
179
+ );
180
+ }
181
+ const { readGateLedger, latestPendingGate } = await import("../../lifecycle/gate-ledger");
182
+ const { pending } = await readGateLedger(named.op, args.cwd ? { cwd: args.cwd } : undefined);
183
+ const latest = latestPendingGate(pending, named.gate);
184
+ if (!latest) {
185
+ throw new Error(
186
+ `command exited ${exitCode}, its gatedExit, but the gate ledger holds no pending fact for ${named.op} / ${named.gate}, so there is no gate to stop at`,
187
+ );
188
+ }
189
+ return latest;
190
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * chant #2782 — the source-release activities: a tree of HEAD archived to the
3
+ * same bytes every time, a release plan named by its own digest, and a release
4
+ * recorded in the ledger once.
5
+ */
6
+ import { describe, test, expect } from "vitest";
7
+ import { withTestDir } from "@intentius/chant-test-utils";
8
+ import { spawnSync } from "node:child_process";
9
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { readSourceArchive } from "../source-archive";
12
+ import { sourceArchive, releasePlan, releaseRecord, readReleasePlan, releasePlanDigest } from "./source-release";
13
+ import { readReleaseLedger } from "../../lifecycle/release-ledger";
14
+ import { readReleasePlan as readPersistedPlan } from "../../lifecycle/plan-ledger";
15
+ import { appendGateResolution } from "../../lifecycle/gate-ledger";
16
+
17
+ function git(args: string[], cwd: string): string {
18
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
19
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
20
+ return r.stdout.trim();
21
+ }
22
+
23
+ function repo(dir: string): void {
24
+ git(["init", "-q", "-b", "main"], dir);
25
+ git(["config", "user.email", "test@chant.dev"], dir);
26
+ git(["config", "user.name", "Test"], dir);
27
+ mkdirSync(join(dir, "app/migrations"), { recursive: true });
28
+ mkdirSync(join(dir, "delivery"), { recursive: true });
29
+ writeFileSync(join(dir, "app/server.js"), "console.log('hi');\n");
30
+ writeFileSync(join(dir, "app/run.sh"), "#!/bin/sh\nnode server.js\n");
31
+ chmodSync(join(dir, "app/run.sh"), 0o755);
32
+ writeFileSync(join(dir, "app/migrations/0001_init.sql"), "CREATE TABLE t (id INTEGER);\n");
33
+ writeFileSync(join(dir, "delivery/README.md"), "delivery\n");
34
+ git(["add", "-A"], dir);
35
+ git(["commit", "-q", "-m", "init"], dir);
36
+ }
37
+
38
+ describe("sourceArchive", () => {
39
+ test("archives a directory of HEAD to the same bytes each time, and reads back only at its digest", async () => {
40
+ await withTestDir(async (dir) => {
41
+ repo(dir);
42
+ const cwd = join(dir, "delivery");
43
+ writeFileSync(join(dir, "app/uncommitted.js"), "not shipped\n");
44
+ const a = await sourceArchive({ path: "../app", cwd });
45
+ const b = await sourceArchive({ path: "../app", cwd, out: "dist/again.tar" });
46
+ expect(a.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
47
+ expect(b.digest).toBe(a.digest);
48
+ expect(a.commit).toBe(git(["rev-parse", "HEAD"], dir));
49
+ expect(a.dir).toBe("app");
50
+ expect(a.files).toBe(3);
51
+ expect(a.archive).toBe(join(cwd, "dist/releases", `${a.commit}.tar`));
52
+
53
+ const files = readSourceArchive({ archive: a.archive, digest: a.digest, dir: a.dir });
54
+ expect(files.map((f) => f.path).sort()).toEqual(["migrations/0001_init.sql", "run.sh", "server.js"]);
55
+ expect(files.find((f) => f.path === "run.sh")!.executable).toBe(true);
56
+ expect(files.find((f) => f.path === "server.js")!.executable).toBe(false);
57
+ expect(files.find((f) => f.path === "server.js")!.data.toString()).toBe("console.log('hi');\n");
58
+
59
+ // Any other bytes are refused, naming both digests.
60
+ writeFileSync(a.archive, Buffer.concat([readFileSync(a.archive), Buffer.from("x")]));
61
+ expect(() => readSourceArchive({ archive: a.archive, digest: a.digest, dir: a.dir })).toThrow(/not the approved sha256:/);
62
+ });
63
+ });
64
+
65
+ test("a directory not in the commit is refused", async () => {
66
+ await withTestDir(async (dir) => {
67
+ repo(dir);
68
+ await expect(sourceArchive({ path: "../nope", cwd: join(dir, "delivery") })).rejects.toThrow(/not in commit/);
69
+ });
70
+ });
71
+ });
72
+
73
+ describe("releasePlan", () => {
74
+ test("is named by its own canonical content, so the same release plans the same digest", async () => {
75
+ await withTestDir(async (dir) => {
76
+ const one = await releasePlan({ component: "app", env: "fly", gitSha: "abc", content: { artifact: { digest: "sha256:1" }, shipSkip: false }, cwd: dir });
77
+ const two = await releasePlan({ component: "app", env: "fly", gitSha: "abc", content: { shipSkip: false, artifact: { digest: "sha256:1" } }, cwd: dir });
78
+ expect(two.digest).toBe(one.digest);
79
+ const other = await releasePlan({ component: "app", env: "fly", gitSha: "abd", content: { artifact: { digest: "sha256:1" }, shipSkip: false }, cwd: dir });
80
+ expect(other.digest).not.toBe(one.digest);
81
+ const plan = readReleasePlan(one.file, one.digest);
82
+ expect(plan).toMatchObject({ digest: one.digest, component: "app", env: "fly", gitSha: "abc", shipSkip: false });
83
+ expect(releasePlanDigest(plan)).toBe(one.digest);
84
+ expect(() => readReleasePlan(one.file, other.digest)).toThrow(/not the approved/);
85
+ });
86
+ });
87
+ });
88
+
89
+ describe("releaseRecord", () => {
90
+ test("persists the plan, records the release with the gate's approver, and records a retry once", async () => {
91
+ await withTestDir(async (dir) => {
92
+ repo(dir);
93
+ const gitSha = git(["rev-parse", "HEAD"], dir);
94
+ const plan = await releasePlan({ component: "app", env: "fly", gitSha, content: { artifact: { digest: "sha256:1" } }, cwd: dir });
95
+ await appendGateResolution(
96
+ { op: "release", gate: "ship", resolvedBy: "alice", timestamp: "2026-09-25T00:00:00Z", planDigest: plan.digest },
97
+ { cwd: dir },
98
+ );
99
+ const first = await releaseRecord({ plan: plan.file, digest: plan.digest, approval: { op: "release", gate: "ship" }, actor: "bob", cwd: dir });
100
+ expect(first).toEqual({ recorded: true, digest: plan.digest, env: "fly", component: "app", approver: "alice" });
101
+ const again = await releaseRecord({ plan: plan.file, digest: plan.digest, approval: { op: "release", gate: "ship" }, actor: "bob", cwd: dir });
102
+ expect(again.recorded).toBe(false);
103
+
104
+ const { records } = await readReleaseLedger("fly", { cwd: dir });
105
+ expect(records).toHaveLength(1);
106
+ expect(records[0]).toMatchObject({ component: "app", env: "fly", digest: plan.digest, gitSha, actor: "bob", approver: "alice" });
107
+ expect(await readPersistedPlan(plan.digest, { cwd: dir })).toMatchObject({ digest: plan.digest, component: "app" });
108
+ });
109
+ });
110
+
111
+ test("refuses a plan that is not the approved digest", async () => {
112
+ await withTestDir(async (dir) => {
113
+ repo(dir);
114
+ const plan = await releasePlan({ component: "app", env: "fly", gitSha: "abc", content: {}, cwd: dir });
115
+ await expect(releaseRecord({ plan: plan.file, digest: "sha256:other", actor: "bob", cwd: dir })).rejects.toThrow(/not the approved/);
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The release activities an Op ships a source tree with (#2782): archive the
3
+ * tree, plan the release, and record it once it serves.
4
+ *
5
+ * - {@link sourceArchive}: one directory of HEAD as a tar (../source-archive.ts),
6
+ * named by its sha256. The same commit archives to the same bytes.
7
+ * - {@link releasePlan}: what a release will ship, as a JSON object named by
8
+ * the sha256 of its own canonical form. The ship gate binds to that digest
9
+ * (`gate("ship", { plan: plan.out.digest })`), the step that ships reads the
10
+ * plan back by it, and the release ledger records it (ws-055: a release
11
+ * record's digest names its plan). Nothing in a plan changes from one run of
12
+ * the same commit to the next, so a retried release plans the same digest
13
+ * and its approval still holds.
14
+ * - {@link releaseRecord}: persist the plan to `_plans/<digest>.json` on
15
+ * chant/lifecycle and append the release record, as `chant components
16
+ * release --release-plan` does. When the environment's latest record for
17
+ * the component already names this digest, nothing is written, so a
18
+ * retried release records once.
19
+ */
20
+
21
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join, resolve } from "node:path";
23
+ import { sortedJsonReplacer } from "../../utils";
24
+ import { archiveSourceTree, sha256Digest, type SourceArchive } from "../source-archive";
25
+
26
+ // ── sourceArchive ─────────────────────────────────────────────────────────────
27
+
28
+ export interface SourceArchiveArgs {
29
+ /** The directory to archive, relative to the Op's working directory (the app member: `../app`). */
30
+ path: string;
31
+ /** The commit. Default: HEAD. */
32
+ ref?: string;
33
+ /** Where to write the archive. Default: `dist/releases/<commit>.tar`. */
34
+ out?: string;
35
+ cwd?: string;
36
+ }
37
+
38
+ export type SourceArchiveResult = SourceArchive;
39
+
40
+ /** Archive a directory of a commit (default HEAD); see ../source-archive.ts. */
41
+ export async function sourceArchive(args: SourceArchiveArgs): Promise<SourceArchiveResult> {
42
+ return archiveSourceTree({ dir: args.path, ref: args.ref, out: args.out, cwd: args.cwd });
43
+ }
44
+
45
+ // ── releasePlan ───────────────────────────────────────────────────────────────
46
+
47
+ export interface ReleasePlanArgs {
48
+ /** The component the release is recorded under. */
49
+ component: string;
50
+ /** The environment it ships to. */
51
+ env: string;
52
+ /** The commit it ships. */
53
+ gitSha: string;
54
+ /** What ships and why: the artifact, the answers the release asked, the target. Any JSON. */
55
+ content: Record<string, unknown>;
56
+ /** Where plans are written. Default: `dist/plans`. */
57
+ dir?: string;
58
+ cwd?: string;
59
+ }
60
+
61
+ export interface ReleasePlanResult {
62
+ /** `sha256:` of the plan's canonical JSON, without this field. */
63
+ digest: string;
64
+ /** Where the plan was written: `<dir>/<digest>.json`. (Not `path`: that name is reserved on a step's `.out`.) */
65
+ file: string;
66
+ gitSha: string;
67
+ }
68
+
69
+ /** The digest of a plan: sha256 of its canonical JSON (keys sorted), `digest` left out. Pure. */
70
+ export function releasePlanDigest(plan: Record<string, unknown>): string {
71
+ const { digest: _digest, ...rest } = plan;
72
+ return sha256Digest(JSON.stringify(rest, sortedJsonReplacer));
73
+ }
74
+
75
+ /** Write a release plan, named by its own digest. */
76
+ export async function releasePlan(args: ReleasePlanArgs): Promise<ReleasePlanResult> {
77
+ if (!args.component || !args.env || !args.gitSha) throw new Error("releasePlan: a plan names its component, its environment and its commit");
78
+ const body = { component: args.component, env: args.env, gitSha: args.gitSha, ...args.content };
79
+ const digest = releasePlanDigest(body);
80
+ const dir = resolve(args.cwd ?? process.cwd(), args.dir ?? join("dist", "plans"));
81
+ mkdirSync(dir, { recursive: true });
82
+ const file = join(dir, `${digest.replace(":", "_")}.json`);
83
+ writeFileSync(file, JSON.stringify({ digest, ...body }, sortedJsonReplacer, 2) + "\n");
84
+ return { digest, file, gitSha: args.gitSha };
85
+ }
86
+
87
+ /** A plan `releasePlan` wrote, checked against its digest. */
88
+ export function readReleasePlan(path: string, digest?: string): Record<string, unknown> & { digest: string } {
89
+ const plan = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown> & { digest: string };
90
+ const actual = releasePlanDigest(plan);
91
+ if (plan.digest !== actual) throw new Error(`${path} names digest ${plan.digest}, but its content is ${actual}`);
92
+ if (digest !== undefined && digest !== actual) throw new Error(`${path} is plan ${actual}, not the approved ${digest}`);
93
+ return plan;
94
+ }
95
+
96
+ // ── releaseRecord ─────────────────────────────────────────────────────────────
97
+
98
+ export interface ReleaseRecordArgs {
99
+ /** The plan `releasePlan` wrote. Its component, environment, commit and digest are what is recorded. */
100
+ plan: string;
101
+ /** The digest the gate approved. The plan must be that one. */
102
+ digest?: string;
103
+ /** Who approved it: the resolution bound to this digest on this Op's gate is looked up. */
104
+ approval?: { op: string; gate: string };
105
+ /** Who ran the release. Default: GITHUB_ACTOR, GITLAB_USER_LOGIN, USER. */
106
+ actor?: string;
107
+ runId?: string;
108
+ cwd?: string;
109
+ }
110
+
111
+ export interface ReleaseRecordResult {
112
+ /** False when the latest record already named this release. */
113
+ recorded: boolean;
114
+ digest: string;
115
+ env: string;
116
+ component: string;
117
+ approver: string | null;
118
+ }
119
+
120
+ /** Persist the plan and record the release in the ledger, once. */
121
+ export async function releaseRecord(args: ReleaseRecordArgs): Promise<ReleaseRecordResult> {
122
+ const cwd = args.cwd;
123
+ const opts = cwd ? { cwd } : undefined;
124
+ const plan = readReleasePlan(resolve(cwd ?? process.cwd(), args.plan), args.digest);
125
+ const component = plan.component as string;
126
+ const env = plan.env as string;
127
+ const gitSha = plan.gitSha as string;
128
+
129
+ const { readReleaseLedger, latestPerComponent, appendReleaseRecord, resolveRunId } = await import("../../lifecycle/release-ledger");
130
+ const { persistReleasePlan } = await import("../../lifecycle/plan-ledger");
131
+ const { pushLifecycle } = await import("../../lifecycle/git");
132
+
133
+ let approver: string | null = null;
134
+ if (args.approval) {
135
+ const { readGateLedger } = await import("../../lifecycle/gate-ledger");
136
+ const { resolutions } = await readGateLedger(args.approval.op, opts);
137
+ const bound = resolutions.filter((r) => r.gate === args.approval!.gate && r.planDigest === plan.digest);
138
+ approver = bound.length > 0 ? [...new Set(bound.map((r) => r.resolvedBy))].join(", ") : null;
139
+ }
140
+
141
+ const latest = latestPerComponent((await readReleaseLedger(env, opts)).records).get(component);
142
+ if (latest?.digest === plan.digest && latest.gitSha === gitSha) {
143
+ await persistReleasePlan(plan, opts);
144
+ await pushLifecycle(opts);
145
+ return { recorded: false, digest: plan.digest, env, component, approver };
146
+ }
147
+
148
+ const actor = args.actor || process.env.GITHUB_ACTOR || process.env.GITLAB_USER_LOGIN || process.env.USER;
149
+ if (!actor) throw new Error("releaseRecord: no actor: pass one, or set GITHUB_ACTOR, GITLAB_USER_LOGIN or USER");
150
+ const { runId, runOrigin } = resolveRunId(args.runId);
151
+ await persistReleasePlan(plan, opts);
152
+ await appendReleaseRecord(
153
+ {
154
+ component,
155
+ env,
156
+ digest: plan.digest,
157
+ gitSha,
158
+ runId,
159
+ ...(runOrigin ? { runOrigin } : {}),
160
+ timestamp: new Date().toISOString(),
161
+ actor,
162
+ ...(approver ? { approver } : {}),
163
+ },
164
+ opts,
165
+ );
166
+ await pushLifecycle(opts);
167
+ return { recorded: true, digest: plan.digest, env, component, approver };
168
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * chant #2800 — the rollback activities for a source release: the release
3
+ * before the latest one picked from the ledger, its tree archived again and
4
+ * checked against the digest its plan recorded, and the rollback recorded
5
+ * once with the release it restores.
6
+ */
7
+ import { describe, test, expect } from "vitest";
8
+ import { withTestDir } from "@intentius/chant-test-utils";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { sourceArchive, releasePlan, releaseRecord } from "./source-release";
13
+ import { pickRollback, releaseRollbackPlan, releaseRollbackRecord } from "./source-rollback";
14
+ import { readReleaseLedger, type ReleaseRecord } from "../../lifecycle/release-ledger";
15
+ import { appendGateResolution } from "../../lifecycle/gate-ledger";
16
+
17
+ function git(args: string[], cwd: string): string {
18
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
19
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
20
+ return r.stdout.trim();
21
+ }
22
+
23
+ function repo(dir: string): void {
24
+ git(["init", "-q", "-b", "main"], dir);
25
+ git(["config", "user.email", "test@chant.dev"], dir);
26
+ git(["config", "user.name", "Test"], dir);
27
+ mkdirSync(join(dir, "app"), { recursive: true });
28
+ mkdirSync(join(dir, "delivery"), { recursive: true });
29
+ writeFileSync(join(dir, "delivery/README.md"), "delivery\n");
30
+ }
31
+
32
+ /** Commit the app as `body`, then plan and record its release as the release Op does. */
33
+ async function release(dir: string, body: string, recordedDigest?: string): Promise<{ digest: string; archive: string; gitSha: string }> {
34
+ writeFileSync(join(dir, "app/server.js"), body);
35
+ git(["add", "-A"], dir);
36
+ git(["commit", "-q", "-m", body], dir);
37
+ const cwd = join(dir, "delivery");
38
+ const archive = await sourceArchive({ path: "../app", cwd });
39
+ const plan = await releasePlan({ component: "app", env: "fly", gitSha: archive.commit, content: { artifact: { kind: "source-tree", digest: recordedDigest ?? archive.digest, dir: archive.dir } }, cwd });
40
+ await releaseRecord({ plan: plan.file, digest: plan.digest, actor: "releaser", cwd });
41
+ return { digest: plan.digest, archive: archive.digest, gitSha: archive.commit };
42
+ }
43
+
44
+ const rec = (digest: string, timestamp: string, extra: Partial<ReleaseRecord> = {}): ReleaseRecord => ({
45
+ version: 1,
46
+ component: "app",
47
+ env: "fly",
48
+ digest,
49
+ gitSha: digest.slice(-4),
50
+ runId: `run-${timestamp}`,
51
+ timestamp,
52
+ actor: "a",
53
+ ...extra,
54
+ });
55
+
56
+ describe("pickRollback", () => {
57
+ test("goes back to what the site served before the latest release, and a rollback's own record does not move it", () => {
58
+ const a = rec("sha256:a", "1");
59
+ const b = rec("sha256:b", "2");
60
+ expect(pickRollback([a, b], "app", "fly")).toEqual({ target: a, replaced: b });
61
+ // After the rollback, the same rollback is planned again.
62
+ const back = rec("sha256:a", "3", { restores: { env: "fly", runId: a.runId, timestamp: a.timestamp } });
63
+ expect(pickRollback([a, b, back], "app", "fly")).toEqual({ target: a, replaced: b });
64
+ // A release after the rollback goes back to what the rollback served.
65
+ const c = rec("sha256:c", "4");
66
+ expect(pickRollback([a, b, back, c], "app", "fly")).toMatchObject({ target: { digest: "sha256:a", timestamp: "3" }, replaced: c });
67
+ // A redeploy of the same digest is not an earlier release.
68
+ expect(pickRollback([a, b, rec("sha256:b", "5")], "app", "fly")).toMatchObject({ target: a });
69
+ });
70
+
71
+ test("names what is missing", () => {
72
+ expect(pickRollback([], "app", "fly")).toEqual({ error: 'no release of "app" is recorded in "fly"' });
73
+ expect(pickRollback([rec("sha256:a", "1")], "app", "fly")).toMatchObject({ error: expect.stringMatching(/no release in "fly" before sha256:a/) });
74
+ expect(pickRollback([rec("sha256:a", "1"), rec("sha256:b", "2")], "app", "fly", "sha256:z")).toMatchObject({ error: expect.stringMatching(/has digest sha256:z/) });
75
+ expect(pickRollback([rec("sha256:a", "1"), rec("sha256:b", "2")], "app", "fly", "sha256:a")).toMatchObject({ target: { digest: "sha256:a" } });
76
+ });
77
+ });
78
+
79
+ describe("releaseRollbackPlan and releaseRollbackRecord", () => {
80
+ test("plan the previous release with its tree archived again, record it once with the approver, and plan the same rollback after", async () => {
81
+ await withTestDir(async (dir) => {
82
+ repo(dir);
83
+ const cwd = join(dir, "delivery");
84
+ const a = await release(dir, "console.log('a');\n");
85
+ const b = await release(dir, "console.log('b');\n");
86
+
87
+ const plan = await releaseRollbackPlan({ component: "app", env: "fly", cwd });
88
+ expect(plan).toMatchObject({ to: a.digest, from: b.digest, gitSha: a.gitSha, archiveDigest: a.archive, dir: "app" });
89
+ expect(plan.archive).toBe(join(cwd, "dist/releases", `${a.gitSha}.tar`));
90
+
91
+ await appendGateResolution({ op: "rollback", gate: "rollback", resolvedBy: "alice", timestamp: "2026-09-25T00:00:03Z", planDigest: plan.digest }, { cwd });
92
+ const first = await releaseRollbackRecord({ plan: plan.file, digest: plan.digest, approval: { op: "rollback", gate: "rollback" }, actor: "bob", cwd });
93
+ expect(first).toEqual({ recorded: true, digest: a.digest, env: "fly", component: "app", approver: "alice" });
94
+
95
+ const { records } = await readReleaseLedger("fly", { cwd });
96
+ expect(records).toHaveLength(3);
97
+ expect(records[2]).toMatchObject({ component: "app", digest: a.digest, gitSha: a.gitSha, actor: "bob", approver: "alice", restores: { env: "fly", runId: records[0].runId, timestamp: records[0].timestamp } });
98
+
99
+ // Again: the same plan digest, so the approval holds, and nothing more is recorded.
100
+ const again = await releaseRollbackPlan({ component: "app", env: "fly", cwd });
101
+ expect(again.digest).toBe(plan.digest);
102
+ expect((await releaseRollbackRecord({ plan: again.file, digest: again.digest, actor: "bob", cwd })).recorded).toBe(false);
103
+ expect((await readReleaseLedger("fly", { cwd })).records).toHaveLength(3);
104
+ });
105
+ });
106
+
107
+ test("a release whose tree does not archive to the digest its plan recorded is refused", async () => {
108
+ await withTestDir(async (dir) => {
109
+ repo(dir);
110
+ const cwd = join(dir, "delivery");
111
+ await release(dir, "console.log('a');\n", "sha256:0000");
112
+ await release(dir, "console.log('b');\n");
113
+ await expect(releaseRollbackPlan({ component: "app", env: "fly", cwd })).rejects.toThrow(/archives to sha256:[0-9a-f]{64}, not the sha256:0000 release sha256:[0-9a-f]{64} recorded/);
114
+ });
115
+ });
116
+
117
+ test("a release with no recorded plan is refused by name", async () => {
118
+ await withTestDir(async (dir) => {
119
+ repo(dir);
120
+ writeFileSync(join(dir, "app/server.js"), "x\n");
121
+ git(["add", "-A"], dir);
122
+ git(["commit", "-q", "-m", "x"], dir);
123
+ const cwd = join(dir, "delivery");
124
+ const { appendReleaseRecord } = await import("../../lifecycle/release-ledger");
125
+ for (const [d, t] of [["sha256:aa", "2026-09-25T00:00:01Z"], ["sha256:bb", "2026-09-25T00:00:02Z"]]) {
126
+ await appendReleaseRecord({ component: "app", env: "fly", digest: d, gitSha: "abc", runId: t, timestamp: t, actor: "a" }, { cwd });
127
+ }
128
+ await expect(releaseRollbackPlan({ component: "app", env: "fly", cwd })).rejects.toThrow(/no plan is recorded for sha256:aa/);
129
+ });
130
+ });
131
+ });