@intentius/chant 0.91.0 → 0.94.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 (361) 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/graph.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +6 -0
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  9. package/dist/cli/mcp/workspace-tools.d.ts +2 -1
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/cli/registry.d.ts +12 -2
  13. package/dist/cli/registry.d.ts.map +1 -1
  14. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/lexicon-module.d.ts +22 -0
  18. package/dist/lexicon-module.d.ts.map +1 -1
  19. package/dist/lifecycle/converge-ledger.d.ts +19 -0
  20. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  21. package/dist/lifecycle/run-ledger.d.ts +2 -0
  22. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/symptoms.d.ts +51 -0
  24. package/dist/lifecycle/symptoms.d.ts.map +1 -1
  25. package/dist/lint/rules/index.d.ts +4 -2
  26. package/dist/lint/rules/index.d.ts.map +1 -1
  27. package/dist/lint/rules/op/index.d.ts +14 -3
  28. package/dist/lint/rules/op/index.d.ts.map +1 -1
  29. package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
  30. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
  31. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
  32. package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
  33. package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
  34. package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
  35. package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
  36. package/dist/op/activities/activity-contracts.d.ts +201 -0
  37. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  38. package/dist/op/activities/apply.d.ts.map +1 -1
  39. package/dist/op/activities/change-coverage.d.ts +44 -0
  40. package/dist/op/activities/change-coverage.d.ts.map +1 -0
  41. package/dist/op/activities/converge.d.ts +24 -1
  42. package/dist/op/activities/converge.d.ts.map +1 -1
  43. package/dist/op/activities/decide.d.ts +107 -0
  44. package/dist/op/activities/decide.d.ts.map +1 -0
  45. package/dist/op/activities/index.d.ts +10 -0
  46. package/dist/op/activities/index.d.ts.map +1 -1
  47. package/dist/op/activities/shell.d.ts +32 -0
  48. package/dist/op/activities/shell.d.ts.map +1 -1
  49. package/dist/op/activities/source-release.d.ts +86 -0
  50. package/dist/op/activities/source-release.d.ts.map +1 -0
  51. package/dist/op/activities/source-rollback.d.ts +100 -0
  52. package/dist/op/activities/source-rollback.d.ts.map +1 -0
  53. package/dist/op/activities/work-evidence.d.ts +42 -0
  54. package/dist/op/activities/work-evidence.d.ts.map +1 -0
  55. package/dist/op/activity-contract.d.ts +1 -0
  56. package/dist/op/activity-contract.d.ts.map +1 -1
  57. package/dist/op/activity-profiles.d.ts +16 -0
  58. package/dist/op/activity-profiles.d.ts.map +1 -1
  59. package/dist/op/builders.d.ts +84 -2
  60. package/dist/op/builders.d.ts.map +1 -1
  61. package/dist/op/composites/converge-op.d.ts +31 -4
  62. package/dist/op/composites/converge-op.d.ts.map +1 -1
  63. package/dist/op/decide-backend.d.ts +116 -0
  64. package/dist/op/decide-backend.d.ts.map +1 -0
  65. package/dist/op/decide-config.d.ts +120 -0
  66. package/dist/op/decide-config.d.ts.map +1 -0
  67. package/dist/op/decide-read-inputs.d.ts +40 -0
  68. package/dist/op/decide-read-inputs.d.ts.map +1 -0
  69. package/dist/op/gate-wait.d.ts +43 -0
  70. package/dist/op/gate-wait.d.ts.map +1 -0
  71. package/dist/op/gate.d.ts +5 -2
  72. package/dist/op/gate.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +11 -4
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/op/local-executor.d.ts +29 -4
  76. package/dist/op/local-executor.d.ts.map +1 -1
  77. package/dist/op/local-output.d.ts +2 -0
  78. package/dist/op/local-output.d.ts.map +1 -1
  79. package/dist/op/op-ir.d.ts +2 -0
  80. package/dist/op/op-ir.d.ts.map +1 -1
  81. package/dist/op/op-verb-class.d.ts.map +1 -1
  82. package/dist/op/operator.d.ts +97 -1
  83. package/dist/op/operator.d.ts.map +1 -1
  84. package/dist/op/runtime.d.ts +37 -5
  85. package/dist/op/runtime.d.ts.map +1 -1
  86. package/dist/op/runtimes/local.d.ts.map +1 -1
  87. package/dist/op/source-archive.d.ts +63 -0
  88. package/dist/op/source-archive.d.ts.map +1 -0
  89. package/dist/op/steward-points.d.ts +136 -0
  90. package/dist/op/steward-points.d.ts.map +1 -0
  91. package/dist/op/steward-turn.d.ts +53 -0
  92. package/dist/op/steward-turn.d.ts.map +1 -0
  93. package/dist/op/steward.d.ts +22 -1
  94. package/dist/op/steward.d.ts.map +1 -1
  95. package/dist/op/types.d.ts +7 -0
  96. package/dist/op/types.d.ts.map +1 -1
  97. package/dist/op/work-lease-run.d.ts +1 -1
  98. package/dist/op/work-lease-run.d.ts.map +1 -1
  99. package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
  100. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
  101. package/dist/workspace/answers-ledger.d.ts +76 -0
  102. package/dist/workspace/answers-ledger.d.ts.map +1 -0
  103. package/dist/workspace/box-intent.d.ts +85 -0
  104. package/dist/workspace/box-intent.d.ts.map +1 -0
  105. package/dist/workspace/changes-cli.d.ts +16 -0
  106. package/dist/workspace/changes-cli.d.ts.map +1 -0
  107. package/dist/workspace/changes.d.ts +200 -0
  108. package/dist/workspace/changes.d.ts.map +1 -0
  109. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
  110. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
  111. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
  112. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
  113. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
  114. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
  115. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
  116. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
  117. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
  118. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
  119. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
  120. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
  121. package/dist/workspace/chant-migrations.d.ts +104 -0
  122. package/dist/workspace/chant-migrations.d.ts.map +1 -0
  123. package/dist/workspace/checks/boxes.d.ts +47 -1
  124. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  125. package/dist/workspace/checks/diagrams.d.ts +31 -0
  126. package/dist/workspace/checks/diagrams.d.ts.map +1 -0
  127. package/dist/workspace/checks/records.d.ts +7 -0
  128. package/dist/workspace/checks/records.d.ts.map +1 -1
  129. package/dist/workspace/checks.d.ts +7 -0
  130. package/dist/workspace/checks.d.ts.map +1 -1
  131. package/dist/workspace/compose-graph.d.ts +2 -0
  132. package/dist/workspace/compose-graph.d.ts.map +1 -1
  133. package/dist/workspace/conformance/index.d.ts.map +1 -1
  134. package/dist/workspace/decide.d.ts +49 -4
  135. package/dist/workspace/decide.d.ts.map +1 -1
  136. package/dist/workspace/decision-points.schema.json +3 -3
  137. package/dist/workspace/declaration.d.ts +58 -0
  138. package/dist/workspace/declaration.d.ts.map +1 -1
  139. package/dist/workspace/declaration.schema.json +119 -1
  140. package/dist/workspace/declared-kinds.d.ts +20 -0
  141. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  142. package/dist/workspace/generated-files.d.ts +7 -0
  143. package/dist/workspace/generated-files.d.ts.map +1 -1
  144. package/dist/workspace/intent-joins.d.ts +5 -5
  145. package/dist/workspace/intent.d.ts.map +1 -1
  146. package/dist/workspace/lineage-check.d.ts +3 -0
  147. package/dist/workspace/lineage-check.d.ts.map +1 -1
  148. package/dist/workspace/lineage-upgrade.d.ts +6 -1
  149. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  150. package/dist/workspace/ls.d.ts +39 -1
  151. package/dist/workspace/ls.d.ts.map +1 -1
  152. package/dist/workspace/point-answer.schema.json +95 -0
  153. package/dist/workspace/points-cli.d.ts +2 -0
  154. package/dist/workspace/points-cli.d.ts.map +1 -1
  155. package/dist/workspace/points.d.ts +4 -6
  156. package/dist/workspace/points.d.ts.map +1 -1
  157. package/dist/workspace/reason-codes.d.ts +16 -2
  158. package/dist/workspace/reason-codes.d.ts.map +1 -1
  159. package/dist/workspace/records-cli.d.ts +11 -1
  160. package/dist/workspace/records-cli.d.ts.map +1 -1
  161. package/dist/workspace/records-close.d.ts +1 -1
  162. package/dist/workspace/records-close.d.ts.map +1 -1
  163. package/dist/workspace/records-write.d.ts +11 -5
  164. package/dist/workspace/records-write.d.ts.map +1 -1
  165. package/dist/workspace/records.d.ts +34 -4
  166. package/dist/workspace/records.d.ts.map +1 -1
  167. package/dist/workspace/status-gates.d.ts +7 -2
  168. package/dist/workspace/status-gates.d.ts.map +1 -1
  169. package/dist/workspace/status-stewards.d.ts +65 -1
  170. package/dist/workspace/status-stewards.d.ts.map +1 -1
  171. package/dist/workspace/status.d.ts +24 -0
  172. package/dist/workspace/status.d.ts.map +1 -1
  173. package/dist/workspace/work-cli.d.ts +113 -2
  174. package/dist/workspace/work-cli.d.ts.map +1 -1
  175. package/dist/workspace/work-evidence.d.ts +74 -0
  176. package/dist/workspace/work-evidence.d.ts.map +1 -0
  177. package/dist/workspace/work.d.ts +43 -1
  178. package/dist/workspace/work.d.ts.map +1 -1
  179. package/package.json +1 -1
  180. package/src/cli/commands/audit.test.ts +9 -1
  181. package/src/cli/commands/build.ts +3 -2
  182. package/src/cli/commands/lint.ts +7 -1
  183. package/src/cli/handlers/graph.test.ts +20 -0
  184. package/src/cli/handlers/graph.ts +16 -2
  185. package/src/cli/handlers/operator.ts +34 -2
  186. package/src/cli/handlers/run.test.ts +154 -2
  187. package/src/cli/handlers/run.ts +121 -2
  188. package/src/cli/main.test.ts +10 -0
  189. package/src/cli/main.ts +23 -3
  190. package/src/cli/mcp/workspace-plugins.ts +6 -6
  191. package/src/cli/mcp/workspace-tools.test.ts +35 -19
  192. package/src/cli/mcp/workspace-tools.ts +62 -4
  193. package/src/cli/plugins.ts +11 -3
  194. package/src/cli/registry.ts +12 -2
  195. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  196. package/src/codegen/pinned-upgrade.ts +2 -1
  197. package/src/codegen/release-wiring.test.ts +49 -2
  198. package/src/components/capability-plugin-loader.ts +3 -3
  199. package/src/components/cli-support.ts +2 -2
  200. package/src/config.ts +11 -0
  201. package/src/lexicon-module.ts +56 -1
  202. package/src/lexicon-package.test.ts +96 -0
  203. package/src/lifecycle/converge-ledger.ts +20 -0
  204. package/src/lifecycle/run-ledger.ts +6 -0
  205. package/src/lifecycle/symptoms.ts +95 -0
  206. package/src/lint/rules/index.ts +6 -2
  207. package/src/lint/rules/op/index.ts +17 -4
  208. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
  209. package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
  210. package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
  211. package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
  212. package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
  213. package/src/lint/rules/sys001-no-literal-key.ts +67 -0
  214. package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
  215. package/src/op/activities/activity-contracts.ts +190 -1
  216. package/src/op/activities/apply.ts +7 -6
  217. package/src/op/activities/change-coverage.ts +60 -0
  218. package/src/op/activities/converge.test.ts +55 -1
  219. package/src/op/activities/converge.ts +208 -29
  220. package/src/op/activities/decide.live.test.ts +78 -0
  221. package/src/op/activities/decide.test.ts +308 -0
  222. package/src/op/activities/decide.ts +267 -0
  223. package/src/op/activities/index.ts +21 -0
  224. package/src/op/activities/shell.test.ts +91 -1
  225. package/src/op/activities/shell.ts +91 -2
  226. package/src/op/activities/source-release.test.ts +118 -0
  227. package/src/op/activities/source-release.ts +168 -0
  228. package/src/op/activities/source-rollback.test.ts +131 -0
  229. package/src/op/activities/source-rollback.ts +239 -0
  230. package/src/op/activities/work-evidence.ts +62 -0
  231. package/src/op/activity-contract-registry.test.ts +2 -0
  232. package/src/op/activity-contract-registry.ts +2 -2
  233. package/src/op/activity-contract.test.ts +12 -0
  234. package/src/op/activity-contract.ts +6 -1
  235. package/src/op/activity-profiles.ts +29 -0
  236. package/src/op/activity-registry.test.ts +1 -1
  237. package/src/op/activity-registry.ts +2 -2
  238. package/src/op/builders.ts +119 -5
  239. package/src/op/command-gate.test.ts +192 -0
  240. package/src/op/composites/converge-op.test.ts +27 -1
  241. package/src/op/composites/converge-op.ts +69 -15
  242. package/src/op/decide-backend.test.ts +44 -0
  243. package/src/op/decide-backend.ts +212 -0
  244. package/src/op/decide-config.test.ts +52 -0
  245. package/src/op/decide-config.ts +80 -0
  246. package/src/op/decide-read-inputs.ts +117 -0
  247. package/src/op/gate-approval.ts +2 -2
  248. package/src/op/gate-wait.ts +57 -0
  249. package/src/op/gate.ts +8 -3
  250. package/src/op/generate-pipeline.ts +2 -2
  251. package/src/op/index.ts +18 -3
  252. package/src/op/local-executor.ts +151 -15
  253. package/src/op/local-output.test.ts +33 -0
  254. package/src/op/local-output.ts +20 -2
  255. package/src/op/op-ir.test.ts +10 -0
  256. package/src/op/op-ir.ts +4 -0
  257. package/src/op/op-verb-class.ts +1 -0
  258. package/src/op/operator.ts +290 -11
  259. package/src/op/runtime.ts +31 -8
  260. package/src/op/runtimes/local.ts +3 -1
  261. package/src/op/source-archive.ts +161 -0
  262. package/src/op/steward-points.test.ts +364 -0
  263. package/src/op/steward-points.ts +194 -0
  264. package/src/op/steward-turn.ts +79 -0
  265. package/src/op/steward.test.ts +87 -3
  266. package/src/op/steward.ts +25 -1
  267. package/src/op/types.ts +7 -0
  268. package/src/op/work-lease-run.test.ts +20 -1
  269. package/src/op/work-lease-run.ts +2 -2
  270. package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
  271. package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
  272. package/src/workspace/answers-ledger.ts +118 -0
  273. package/src/workspace/box-intent.test.ts +205 -0
  274. package/src/workspace/box-intent.ts +159 -0
  275. package/src/workspace/changes-cli.ts +56 -0
  276. package/src/workspace/changes.schema.json +472 -0
  277. package/src/workspace/changes.test.ts +315 -0
  278. package/src/workspace/changes.ts +512 -0
  279. package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
  280. package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
  281. package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
  282. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
  283. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
  284. package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
  285. package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
  286. package/src/workspace/chant-migrations.ts +169 -0
  287. package/src/workspace/check-contract.test.ts +4 -2
  288. package/src/workspace/check.schema.json +19 -5
  289. package/src/workspace/checks/boxes.test.ts +82 -2
  290. package/src/workspace/checks/boxes.ts +202 -1
  291. package/src/workspace/checks/diagrams.test.ts +134 -0
  292. package/src/workspace/checks/diagrams.ts +108 -0
  293. package/src/workspace/checks/records.ts +47 -0
  294. package/src/workspace/checks.ts +20 -2
  295. package/src/workspace/compose-graph.ts +2 -0
  296. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
  297. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
  298. package/src/workspace/conformance/index.ts +5 -0
  299. package/src/workspace/decide.test.ts +1 -1
  300. package/src/workspace/decide.ts +156 -23
  301. package/src/workspace/decision-points.schema.json +3 -3
  302. package/src/workspace/declaration.schema.json +119 -1
  303. package/src/workspace/declaration.test.ts +58 -0
  304. package/src/workspace/declaration.ts +112 -0
  305. package/src/workspace/declared-kinds.test.ts +31 -5
  306. package/src/workspace/declared-kinds.ts +46 -1
  307. package/src/workspace/generated-files.ts +14 -0
  308. package/src/workspace/graph-cli.ts +1 -1
  309. package/src/workspace/graph.schema.json +9 -1
  310. package/src/workspace/intent-joins.test.ts +5 -5
  311. package/src/workspace/intent-joins.ts +5 -5
  312. package/src/workspace/intent.schema.json +8 -2
  313. package/src/workspace/intent.ts +3 -10
  314. package/src/workspace/lineage-check.ts +16 -1
  315. package/src/workspace/lineage-upgrade-cli.ts +1 -1
  316. package/src/workspace/lineage-upgrade.test.ts +70 -0
  317. package/src/workspace/lineage-upgrade.ts +81 -10
  318. package/src/workspace/ls-contract.test.ts +57 -0
  319. package/src/workspace/ls.schema.json +46 -1
  320. package/src/workspace/ls.ts +62 -3
  321. package/src/workspace/points-cli.ts +17 -3
  322. package/src/workspace/points-write.schema.json +11 -1
  323. package/src/workspace/points.schema.json +51 -0
  324. package/src/workspace/points.test.ts +16 -15
  325. package/src/workspace/points.ts +5 -17
  326. package/src/workspace/read-contract.test.ts +9 -1
  327. package/src/workspace/reason-codes.test.ts +18 -8
  328. package/src/workspace/reason-codes.ts +21 -2
  329. package/src/workspace/records-amend.schema.json +2 -0
  330. package/src/workspace/records-cli.ts +27 -4
  331. package/src/workspace/records-close.schema.json +2 -0
  332. package/src/workspace/records-formats.test.ts +15 -15
  333. package/src/workspace/records-new.schema.json +2 -0
  334. package/src/workspace/records-review.schema.json +2 -0
  335. package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
  336. package/src/workspace/records-sessions-write.test.ts +2 -34
  337. package/src/workspace/records-since.test.ts +8 -7
  338. package/src/workspace/records-write.e2e.test.ts +108 -0
  339. package/src/workspace/records-write.test.ts +32 -41
  340. package/src/workspace/records-write.ts +33 -15
  341. package/src/workspace/records.schema.json +36 -2
  342. package/src/workspace/records.test.ts +49 -4
  343. package/src/workspace/records.ts +109 -3
  344. package/src/workspace/status-contract.test.ts +14 -8
  345. package/src/workspace/status-gates.ts +9 -4
  346. package/src/workspace/status-stewards.ts +155 -5
  347. package/src/workspace/status.schema.json +129 -8
  348. package/src/workspace/status.ts +46 -1
  349. package/src/workspace/work-acceptance.test.ts +274 -0
  350. package/src/workspace/{work-cli.test.ts → work-cli.e2e.test.ts} +1 -1
  351. package/src/workspace/work-cli.ts +193 -10
  352. package/src/workspace/work-evidence.schema.json +212 -0
  353. package/src/workspace/work-evidence.ts +169 -0
  354. package/src/workspace/work-history.schema.json +349 -0
  355. package/src/workspace/work-history.test.ts +156 -0
  356. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  357. package/src/workspace/work.test.ts +20 -2
  358. package/src/workspace/work.ts +100 -1
  359. /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
  360. /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
  361. /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
@@ -38,9 +38,12 @@
38
38
  import type { ActivityFn, ActivityProfile } from "./activity-registry";
39
39
  import { discoverOps, type DiscoveredOp } from "./discover";
40
40
  import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
41
- import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
- import { runEnvOf } from "../lifecycle/run-ledger";
43
- import { stewardLeaseName, type StewardDeclaration } from "./steward";
41
+ import { acquireLease, releaseLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
+ import { randomUUID } from "node:crypto";
43
+ import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
44
+ import { readGateLedger, latestResolutionSince } from "../lifecycle/gate-ledger";
45
+ import { enterStewardTurn } from "./steward-turn";
46
+ import { stewardLeaseName, stewardTurnLeaseName, type StewardDeclaration } from "./steward";
44
47
  import { stewardWorkHolder } from "./work-lease-run";
45
48
  import { StaleLockError } from "../lifecycle/git";
46
49
  import { cronMatches, cronDueBetween } from "./cron";
@@ -79,7 +82,36 @@ export async function discoverConvergeOps(
79
82
 
80
83
  /** One discovered ConvergeOp's outcome for one round — what `chant operator`'s one-line-per-tick log (and its tests) key off of. */
81
84
  export type OperatorTickEvent =
82
- | { kind: "ticked"; op: string; env: string; result: OpRunResult }
85
+ /**
86
+ * The Op ran. `resumed` names the decision point question it was waiting on
87
+ * (#2749) when a steward ran it because that question is now answered,
88
+ * rather than because its cron fired.
89
+ */
90
+ | {
91
+ kind: "ticked";
92
+ op: string;
93
+ env: string;
94
+ result: OpRunResult;
95
+ resumed?: string;
96
+ /**
97
+ * The gate the Op's last run stopped at (#2779), when a steward ran it
98
+ * because that gate is now approved rather than because its cron fired.
99
+ */
100
+ approved?: { op: string; gate: string };
101
+ }
102
+ /**
103
+ * A steward's Op whose last run stopped on an open decision point (#2749),
104
+ * and the question is still open, so the Op is not run this round. A person
105
+ * answers it through hud or `points answer`; the steward never does.
106
+ */
107
+ | { kind: "waiting-on-point"; op: string; env: string; point: string; question: string; state: string }
108
+ /**
109
+ * A steward's Op whose last run stopped at a gate (#2779), and nobody has
110
+ * approved it since, so the Op is not run this round. `gateOp` is the op
111
+ * the gate is recorded under: the Op's own, or a command's such as
112
+ * `workspace-upgrade`.
113
+ */
114
+ | { kind: "waiting-on-gate"; op: string; env: string; gateOp: string; gate: string }
83
115
  | { kind: "skipped-lease-held"; op: string; env: string; heldBy?: string }
84
116
  /** The Op declares its own `schedule.cron` (#2120) and this round did not land on a firing minute — the lease was never touched. An Op without a cron is never reported this way: it ticks every round, on `--interval`. */
85
117
  | { kind: "skipped-not-due"; op: string; env: string; cron: string }
@@ -104,7 +136,17 @@ export type OperatorTickEvent =
104
136
  * steward. Nothing ticked. `op` is the steward's lease name and `env` is
105
137
  * `-`, so a log line keyed on either still reads.
106
138
  */
107
- | { kind: "steward-busy"; op: string; env: string; steward: string; heldBy?: string };
139
+ | { kind: "steward-busy"; op: string; env: string; steward: string; heldBy?: string }
140
+ /**
141
+ * A local steward's round (#2750) found its turn lease
142
+ * (`stewardTurnLeaseName`) held by someone else already mid-run — a `chant
143
+ * run <op>` typed by hand, or another round somehow still finishing one.
144
+ * Nothing ticked: the round stops here rather than trying its remaining
145
+ * ops, since every one of them would fail the same check (the turn is one
146
+ * lease for the whole steward, not one per op). The next round tries
147
+ * again.
148
+ */
149
+ | { kind: "turn-busy"; op: string; env: string; steward: string; heldBy?: string };
108
150
 
109
151
  export interface OperatorRoundOptions {
110
152
  cwd?: string;
@@ -137,6 +179,98 @@ export interface OperatorRoundOptions {
137
179
  * lease, and ticks nothing when another holder has it.
138
180
  */
139
181
  steward?: StewardDeclaration;
182
+ /**
183
+ * How a steward's round reads the state of the questions its waiting runs
184
+ * stopped on (#2749): question id to state, or null when they can't be
185
+ * read. Defaults to the workspace's `points` read; a test injects one.
186
+ */
187
+ readQuestions?: (cwd: string) => Promise<Map<string, string> | null>;
188
+ }
189
+
190
+ /** A steward's Op whose last run waits on a decision point (#2749). */
191
+ interface WaitState {
192
+ /** The answer record's id. */
193
+ question: string;
194
+ point: string;
195
+ /** The question's state now, or the one the run recorded when it can't be read. */
196
+ state: string;
197
+ /** The question is answered, or its record is gone, so the Op runs again. */
198
+ answered: boolean;
199
+ }
200
+
201
+ /**
202
+ * The steward's Ops whose newest run is the steward's own and stopped on a
203
+ * decision point, with that question's state now. The questions are read
204
+ * from the workspace the way `points` reads them; a workspace that can't be
205
+ * read leaves every run waiting.
206
+ */
207
+ async function stewardWaits(steward: StewardDeclaration, opts: OperatorRoundOptions): Promise<Map<string, WaitState>> {
208
+ const out = new Map<string, WaitState>();
209
+ const runs: { op: string; question: string; point: string; state: string }[] = [];
210
+ for (const op of steward.ops) {
211
+ try {
212
+ const newest = (await readRunLedger(runEnvOf(op), op.name, { cwd: opts.cwd })).records.at(-1);
213
+ if (newest?.status === "waiting" && newest.point && newest.steward === steward.name) {
214
+ runs.push({ op: op.name, question: newest.point.id, point: newest.point.point, state: newest.point.state });
215
+ }
216
+ } catch {
217
+ // A ledger that can't be read has no waiting run to resume.
218
+ }
219
+ }
220
+ if (runs.length === 0) return out;
221
+ const states = await (opts.readQuestions ?? readQuestionStates)(opts.cwd ?? process.cwd());
222
+ for (const r of runs) {
223
+ const now = states?.get(r.question);
224
+ const answered = states !== null && (now === undefined || now === "answered");
225
+ out.set(r.op, { question: r.question, point: r.point, state: now ?? r.state, answered });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** A steward's Op whose last run stopped at a gate (#2779). */
231
+ interface GateWaitState {
232
+ /** The op the gate is recorded under. */
233
+ gateOp: string;
234
+ gate: string;
235
+ /** Someone approved the gate since the run stopped there, so the Op runs again. */
236
+ approved: boolean;
237
+ }
238
+
239
+ /**
240
+ * The steward's Ops whose newest run is the steward's own and stopped at a
241
+ * gate, and whether the gate has been approved since. A gate ledger that
242
+ * can't be read leaves the run where it stopped.
243
+ */
244
+ async function stewardGates(steward: StewardDeclaration, opts: OperatorRoundOptions): Promise<Map<string, GateWaitState>> {
245
+ const out = new Map<string, GateWaitState>();
246
+ for (const op of steward.ops) {
247
+ try {
248
+ const newest = (await readRunLedger(runEnvOf(op), op.name, { cwd: opts.cwd })).records.at(-1);
249
+ if (newest?.status !== "gated" || !newest.gate || newest.steward !== steward.name) continue;
250
+ const gateOp = newest.gate.op ?? op.name;
251
+ const { resolutions } = await readGateLedger(gateOp, { cwd: opts.cwd });
252
+ const approved = latestResolutionSince(resolutions, newest.gate.name, newest.gate.since) !== undefined;
253
+ out.set(op.name, { gateOp, gate: newest.gate.name, approved });
254
+ } catch {
255
+ // A ledger that can't be read has no gated run to resume.
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /** Every question's state in the workspace, by id, or null when the workspace's points can't be read. */
262
+ async function readQuestionStates(cwd: string): Promise<Map<string, string> | null> {
263
+ try {
264
+ const { workspacePoints } = await import("../workspace/points-cli");
265
+ const doc = await workspacePoints({ cwd });
266
+ if (!("questions" in doc)) return null;
267
+ // An answer kind that could not be read might hold the question: say
268
+ // nothing rather than resume a run whose question may still be open.
269
+ if (doc.sources.some((s) => s.reason !== null)) return null;
270
+ return new Map(doc.questions.map((q) => [q.id, q.state]));
271
+ } catch {
272
+ return null;
273
+ }
140
274
  }
141
275
 
142
276
  /** Take or renew a local steward's own lease (#2731). */
@@ -148,9 +282,66 @@ export async function acquireStewardLease(
148
282
  return acquireLease(stewardLeaseName(steward), holder, { ...opts, ttlMs: opts.ttlMs ?? DEFAULT_LEASE_TTL_MS });
149
283
  }
150
284
 
151
- /** The Ops one round considers: a steward's scheduled ones, or every ConvergeOp. */
285
+ /**
286
+ * How long a caller contending for a steward's turn lease
287
+ * (`acquireStewardTurn`) retries before giving up (#2750). Long enough to
288
+ * ride out a race with a turn that is just ending; short enough that a
289
+ * genuinely busy steward is reported back promptly. A round never waits
290
+ * (`waitMs` omitted there): it already runs on its own timer and simply
291
+ * tries the turn again next round, exactly as it already does for a busy
292
+ * per-op lease. `chant run <op>` (`../cli/handlers/run.ts`) is the caller
293
+ * that waits, since a human is standing at the terminal for the answer.
294
+ */
295
+ export const STEWARD_TURN_WAIT_MS = 2_000;
296
+
297
+ /** How often {@link acquireStewardTurn} retries while it waits. */
298
+ const STEWARD_TURN_POLL_MS = 150;
299
+
300
+ /**
301
+ * Take the steward's turn lease (#2750): the lease every run of one of its
302
+ * Ops — a round's scheduled tick, or `chant run <op>` typed by hand — holds
303
+ * for exactly as long as that one run takes, so the two are never beside each
304
+ * other. Distinct from {@link acquireStewardLease}, which one `chant operator
305
+ * --steward` process holds for its whole life: this one is held per-run, so a
306
+ * hand run succeeds the moment a round's tick ends, not only once the daemon
307
+ * itself stops.
308
+ *
309
+ * Contention never queues past `waitMs` (0 by default): consistent with a
310
+ * round's own leases, which skip and report rather than wait (see this
311
+ * module's doc), and with the fountain form, whose busy teammate is likewise
312
+ * refused rather than retried. A caller that wants to ride out a race with a
313
+ * turn about to end passes `waitMs`, and this polls every
314
+ * {@link STEWARD_TURN_POLL_MS} until it acquires or the wait runs out.
315
+ */
316
+ export async function acquireStewardTurn(
317
+ steward: string,
318
+ holder: string,
319
+ opts: { cwd?: string; ttlMs?: number; now?: () => Date; waitMs?: number } = {},
320
+ ): Promise<AcquireLeaseResult> {
321
+ const deadline = Date.now() + (opts.waitMs ?? 0);
322
+ for (;;) {
323
+ const result = await acquireLease(stewardTurnLeaseName(steward), holder, {
324
+ cwd: opts.cwd,
325
+ ttlMs: opts.ttlMs ?? DEFAULT_LEASE_TTL_MS,
326
+ now: opts.now,
327
+ });
328
+ if (result.acquired || result.reason !== "held" || Date.now() >= deadline) return result;
329
+ await new Promise((r) => setTimeout(r, STEWARD_TURN_POLL_MS));
330
+ }
331
+ }
332
+
333
+ /** Release a steward's turn lease, best-effort — a run whose turn was never actually acquired (or already lost) has nothing to release. */
334
+ async function releaseStewardTurn(steward: string, holder: string, token: string, opts: { cwd?: string } = {}): Promise<void> {
335
+ await releaseLease(stewardTurnLeaseName(steward), holder, token, opts).catch(() => false);
336
+ }
337
+
338
+ /**
339
+ * The Ops one round considers: a steward's Ops, or every ConvergeOp. A
340
+ * steward's Op with no schedule is considered only to resume a run of the
341
+ * steward's that waits on a decision point.
342
+ */
152
343
  async function roundOps(opts: OperatorRoundOptions): Promise<DiscoveredOp["config"][]> {
153
- if (opts.steward) return opts.steward.ops.filter((op) => op.schedule !== undefined);
344
+ if (opts.steward) return [...opts.steward.ops];
154
345
  const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
155
346
  return ops.map((d) => d.config);
156
347
  }
@@ -197,8 +388,32 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
197
388
 
198
389
  if (!(await holdSteward())) return events;
199
390
 
391
+ // A steward's Ops whose last run waits on a decision point (#2749), read
392
+ // once per round, and the state of those questions now.
393
+ const waiting = steward ? await stewardWaits(steward, opts) : new Map<string, WaitState>();
394
+ // And those whose last run stopped at a gate (#2779), with whether it has
395
+ // been approved since.
396
+ const gated = steward ? await stewardGates(steward, opts) : new Map<string, GateWaitState>();
397
+
200
398
  for (const config of await roundOps(opts)) {
201
399
  const env = steward ? runEnvOf(config) : (envOf(config) ?? "unknown");
400
+ const wait = waiting.get(config.name);
401
+ const stopped = gated.get(config.name);
402
+ let resumed: string | undefined;
403
+ let approved: { op: string; gate: string } | undefined;
404
+
405
+ // A run waiting on a question that is now answered is resumed on this
406
+ // round, whatever its cron says. One whose question is still open is left
407
+ // until a person answers it, unless its cron fires, which asks again.
408
+ if (wait?.answered) resumed = wait.question;
409
+ // The same for a run stopped at a gate that is now approved (#2779).
410
+ if (stopped?.approved) approved = { op: stopped.gateOp, gate: stopped.gate };
411
+ const standing: OperatorTickEvent | undefined = wait
412
+ ? { kind: "waiting-on-point", op: config.name, env, point: wait.point, question: wait.question, state: wait.state }
413
+ : stopped
414
+ ? { kind: "waiting-on-gate", op: config.name, env, gateOp: stopped.gateOp, gate: stopped.gate }
415
+ : undefined;
416
+ const resuming = resumed !== undefined || approved !== undefined;
202
417
 
203
418
  // An Op that declares its own cadence (#2120) is ticked on that cron
204
419
  // rather than on every round. Level-triggered: the question is whether a
@@ -212,10 +427,15 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
212
427
  const lastSeen = opts.scheduleState?.get(config.name);
213
428
  const due = lastSeen === undefined ? cronMatches(cron, now) : cronDueBetween(cron, lastSeen, now);
214
429
  opts.scheduleState?.set(config.name, now);
215
- if (!due) {
216
- events.push({ kind: "skipped-not-due", op: config.name, env, cron });
430
+ if (!due && !resuming) {
431
+ events.push(standing ?? { kind: "skipped-not-due", op: config.name, env, cron });
217
432
  continue;
218
433
  }
434
+ } else if (steward && !resuming) {
435
+ // An unscheduled Op runs when someone asks for it; the steward runs it
436
+ // only to resume its own waiting or gated run.
437
+ if (standing) events.push(standing);
438
+ continue;
219
439
  }
220
440
 
221
441
  let acquired: AcquireLeaseResult;
@@ -242,8 +462,44 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
242
462
  }
243
463
 
244
464
  const lease = acquired.lease as LeaseRecord;
465
+
466
+ // A local steward is one turn at a time (#2750): a hand run (`chant run
467
+ // <op>`) holds this same lease for as long as it runs, so a round never
468
+ // ticks beside one. It is one lease for the whole steward, not one per
469
+ // op, so finding it held stops the round outright — every op still ahead
470
+ // of it in `roundOps` would fail the identical check.
471
+ let turn: AcquireLeaseResult | undefined;
472
+ if (steward) {
473
+ try {
474
+ turn = await acquireStewardTurn(steward.name, holder, { cwd: opts.cwd, ttlMs: opts.leaseTtlMs, now: opts.now });
475
+ } catch (err) {
476
+ // Not turn contention — the acquire attempt itself failed (most
477
+ // likely a StaleLockError). Same shape as the per-op acquire's own
478
+ // catch above: report it and stop, rather than let it propagate and
479
+ // leak the per-op lease this round just took.
480
+ await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
481
+ const message = err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
482
+ events.push({ kind: "lease-error", op: stewardTurnLeaseName(steward.name), env, error: message });
483
+ break;
484
+ }
485
+ if (!turn.acquired) {
486
+ await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
487
+ events.push({ kind: "turn-busy", op: config.name, env, steward: steward.name, heldBy: turn.heldBy?.holder });
488
+ break;
489
+ }
490
+ }
491
+
492
+ // The run is the steward's turn (#2749): a decision point it asks names the
493
+ // steward and makes its model call through the steward's broker, and an
494
+ // answer from inside the turn is refused.
495
+ const runId = randomUUID();
496
+ const restoreTurn = steward
497
+ ? enterStewardTurn({ steward: steward.name, capabilities: steward.capabilities, vault: steward.vault, run: runId })
498
+ : undefined;
245
499
  try {
246
500
  const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal, {
501
+ runId,
502
+ ...(steward ? { steward: steward.name } : {}),
247
503
  ledger: { cwd: opts.cwd },
248
504
  // A steward's turn claims work leases as `<steward>/<op>@<holder>`
249
505
  // (#2748), which is how `workspace status` finds the lease it holds.
@@ -263,7 +519,18 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
263
519
  }),
264
520
  });
265
521
  const held = await stillHoldsLease(config.name, holder, lease.token, { cwd: opts.cwd });
266
- events.push(held ? { kind: "ticked", op: config.name, env, result } : { kind: "fenced", op: config.name, env });
522
+ events.push(
523
+ held
524
+ ? {
525
+ kind: "ticked",
526
+ op: config.name,
527
+ env,
528
+ result,
529
+ ...(resumed !== undefined ? { resumed } : {}),
530
+ ...(approved !== undefined ? { approved } : {}),
531
+ }
532
+ : { kind: "fenced", op: config.name, env },
533
+ );
267
534
  } catch (err) {
268
535
  // #2301: "see its ledger record" was the whole message, and it sent the
269
536
  // reader to an artifact that a failed ledger append means is missing —
@@ -277,6 +544,9 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
277
544
  ? err.message
278
545
  : String(err);
279
546
  events.push({ kind: "tick-failed", op: config.name, env, error: message });
547
+ } finally {
548
+ restoreTurn?.();
549
+ if (steward && turn?.acquired) await releaseStewardTurn(steward.name, holder, turn.lease!.token, { cwd: opts.cwd });
280
550
  }
281
551
  if (!(await holdSteward())) break;
282
552
  }
@@ -488,7 +758,14 @@ export function formatRoundLine(event: OperatorTickEvent): string {
488
758
  switch (event.kind) {
489
759
  case "ticked":
490
760
  return `operator: ${event.op}@${event.env} ticked=1 status=${event.result.status}` +
491
- (event.result.gate ? ` gate="${event.result.gate.gate}"` : "");
761
+ (event.result.gate ? ` gate="${event.result.gate.gate}"` : "") +
762
+ (event.result.point ? ` point="${event.result.point.id}"` : "") +
763
+ (event.resumed ? ` resumed="${event.resumed}"` : "") +
764
+ (event.approved ? ` approved="${event.approved.op}/${event.approved.gate}"` : "");
765
+ case "waiting-on-point":
766
+ return `operator: ${event.op}@${event.env} waiting=1(point:${event.question}:${event.state})`;
767
+ case "waiting-on-gate":
768
+ return `operator: ${event.op}@${event.env} gated=1(gate:${event.gateOp}/${event.gate})`;
492
769
  case "skipped-lease-held":
493
770
  return `operator: ${event.op}@${event.env} skipped=1(lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
494
771
  case "skipped-not-due":
@@ -501,5 +778,7 @@ export function formatRoundLine(event: OperatorTickEvent): string {
501
778
  return `operator: ${event.op}@${event.env} error=1(lease acquire failed — ${event.error})`;
502
779
  case "steward-busy":
503
780
  return `operator: steward ${event.steward} skipped=1(steward-lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
781
+ case "turn-busy":
782
+ return `operator: ${event.op}@${event.env} skipped=1(turn-held${event.heldBy ? `:${event.heldBy}` : ""}, steward "${event.steward}" is already mid-turn)`;
504
783
  }
505
784
  }
package/src/op/runtime.ts CHANGED
@@ -18,23 +18,27 @@
18
18
 
19
19
  import type { OpConfig } from "./types";
20
20
  import type { StepRecord, OpRunResult } from "./local-executor";
21
+ import type { WaitingPoint } from "./steward-points";
21
22
  import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
22
23
  import type { RunComponentsOptions, RunComponentsResult } from "../components/cli-support";
23
24
 
24
25
  /**
25
26
  * How a run ended, or that it has not. `gated` is the state a run reaches when
26
- * a gate has no recorded resolution — a fact, not a wait (#2119).
27
+ * a gate has no recorded resolution — a fact, not a wait (#2119). `waiting` is
28
+ * the state it reaches when an activity asked a decision point that is still
29
+ * open (#2749), a fact of the same kind: a person answers the question, and
30
+ * the next run reads the answer.
27
31
  *
28
32
  * This is the coarse state a *runtime* reports. The ledger records the
29
33
  * executor's own three-state outcome instead ({@link OpRunRecord.status}),
30
34
  * which has no `running` or `cancelled`: a record is only written once a run
31
35
  * has settled.
32
36
  */
33
- export type OpRunState = "running" | "completed" | "failed" | "gated" | "cancelled";
37
+ export type OpRunState = "running" | "completed" | "failed" | "gated" | "waiting" | "cancelled";
34
38
 
35
39
  /** Map a settled run's ledger status onto the coarser runtime state. */
36
40
  export function runStateOf(status: OpRunRecord["status"]): OpRunState {
37
- return status === "ok" ? "completed" : status === "gated" ? "gated" : "failed";
41
+ return status === "ok" ? "completed" : status === "gated" ? "gated" : status === "waiting" ? "waiting" : "failed";
38
42
  }
39
43
 
40
44
  /** One step's outcome as the run ledger keeps it — `StepRecord` minus the fields only a live renderer needs. */
@@ -51,6 +55,10 @@ export interface OpRunStepRecord {
51
55
  error?: string;
52
56
  /** Why a gate declined a standing approval that was for another plan (#2300) — see {@link StepRecord.refusal}. */
53
57
  refusal?: string;
58
+ /** The open decision point this step asked (#2749), for a step skipped because it is waiting on one. */
59
+ point?: WaitingPoint;
60
+ /** The gate this step's command stopped at (#2779), for a step skipped because of it. */
61
+ gate?: { op: string; gate: string };
54
62
  }
55
63
 
56
64
  /** One phase's steps and the verdict they add up to. */
@@ -81,8 +89,10 @@ export interface OpRunStatus {
81
89
  records?: StepRecord[];
82
90
  /** The executor's own result, when the runtime is executing in this process. */
83
91
  result?: OpRunResult;
84
- /** The gate this run is waiting on, when `state` is `gated`. */
85
- gate?: { name: string; since: string };
92
+ /** The gate this run is waiting on, when `state` is `gated`; `op` names the op it is recorded under when that isn't the run's own (#2779). */
93
+ gate?: { name: string; since: string; op?: string };
94
+ /** The open decision point this run is waiting on, when `state` is `waiting` (#2749). */
95
+ point?: WaitingPoint & { since: string };
86
96
  /** Free-text detail for a failed run. */
87
97
  error?: string;
88
98
  }
@@ -117,15 +127,28 @@ export interface OpRunRecord {
117
127
  * failure: the run reached a gate nobody has approved, recorded the fact,
118
128
  * and stopped (#2119).
119
129
  */
120
- status: "ok" | "fail" | "gated";
130
+ status: "ok" | "fail" | "gated" | "waiting";
121
131
  /** The Op's own `labels`, copied at run time. */
122
132
  labels: Record<string, string>;
123
133
  /** Every `outcomeAttribute` the run captured, name → value. */
124
134
  outcomes: Record<string, unknown>;
125
135
  /** Per-phase, per-step status, in execution order. */
126
136
  phases: OpRunPhaseRecord[];
127
- /** The gate the run stopped on, for `status: "gated"`. */
128
- gate?: { name: string; since: string };
137
+ /**
138
+ * The gate the run stopped on, for `status: "gated"`. `op` is set when the
139
+ * gate is recorded under another op than the run's (#2779): a step whose
140
+ * command stopped at its own gate, such as `chant workspace upgrade`'s
141
+ * `workspace-upgrade` / `<scope>`.
142
+ */
143
+ gate?: { name: string; since: string; op?: string };
144
+ /**
145
+ * The open decision point the run stopped on, for `status: "waiting"`
146
+ * (#2749): the answer record's id, the point, its state then, and when the
147
+ * run stopped. `points --open` and hud read the question itself.
148
+ */
149
+ point?: WaitingPoint & { since: string };
150
+ /** The steward whose turn the run was, when a steward ran it (#2749). */
151
+ steward?: string;
129
152
  }
130
153
 
131
154
  /** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
@@ -52,7 +52,8 @@ function statusFrom(
52
52
  endedAt: new Date().toISOString(),
53
53
  records: result.records,
54
54
  result,
55
- ...(result.gate ? { gate: { name: result.gate.gate, since: result.gate.timestamp } } : {}),
55
+ ...(result.gate ? { gate: { name: result.gate.gate, since: result.gate.timestamp, ...(result.gate.op !== op ? { op: result.gate.op } : {}) } } : {}),
56
+ ...(result.record.point ? { point: result.record.point } : {}),
56
57
  };
57
58
  }
58
59
 
@@ -70,6 +71,7 @@ function statusFromRecord(record: OpRunRecord): OpRunStatus {
70
71
  startedAt: record.started,
71
72
  endedAt: record.ended,
72
73
  ...(record.gate ? { gate: record.gate } : {}),
74
+ ...(record.point ? { point: record.point } : {}),
73
75
  };
74
76
  }
75
77
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * A source tree as a release artifact (#2782): one directory of a commit,
3
+ * archived with `git archive`, and read back only when its bytes still hash
4
+ * to the digest a gate approved.
5
+ *
6
+ * `git archive <commit> -- <dir>` gives every entry the commit's own
7
+ * timestamp, so the same commit always archives to the same bytes, and the
8
+ * archive's sha256 names the tree exactly. A release plan carries that digest,
9
+ * the ship gate approves the plan, and whatever ships the release reads the
10
+ * archive through {@link readSourceArchive}, which refuses bytes that do not
11
+ * hash to the approved digest.
12
+ *
13
+ * Pure except for the git and file reads each function names.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { createHash } from "node:crypto";
18
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
19
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
20
+
21
+ /** One file of an archived tree. */
22
+ export interface SourceFile {
23
+ /** Its path inside the archived directory, with `/` separators. */
24
+ path: string;
25
+ /** Whether git records it executable. */
26
+ executable: boolean;
27
+ data: Buffer;
28
+ }
29
+
30
+ /** A directory of a commit, archived. */
31
+ export interface SourceArchive {
32
+ /** `sha256:<hex>` of the archive's bytes. */
33
+ digest: string;
34
+ /** Where the archive was written. */
35
+ archive: string;
36
+ /** The commit it was archived from. */
37
+ commit: string;
38
+ /** The archived directory, relative to the repository root (`.` for the root). */
39
+ dir: string;
40
+ /** Files in it. */
41
+ files: number;
42
+ /** Size of the archive in bytes. */
43
+ bytes: number;
44
+ }
45
+
46
+ export const sha256Digest = (data: Buffer | string): string => `sha256:${createHash("sha256").update(data).digest("hex")}`;
47
+
48
+ function git(cwd: string, args: string[]): Buffer {
49
+ return execFileSync("git", args, { cwd, maxBuffer: 1024 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
50
+ }
51
+
52
+ /**
53
+ * Archive `dir` (relative to `cwd`) as committed at `ref` (default HEAD) to
54
+ * `out` (default `<cwd>/dist/releases/<commit>.tar`). Uncommitted changes are
55
+ * not in it: a release ships what is committed. Running it again for the same
56
+ * commit writes the same bytes.
57
+ */
58
+ export function archiveSourceTree(opts: { dir: string; ref?: string; out?: string; cwd?: string }): SourceArchive {
59
+ const cwd = resolve(opts.cwd ?? process.cwd());
60
+ // git names the root by its real path, so the directory is resolved the same way.
61
+ const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]).toString("utf-8").trim());
62
+ const commit = git(cwd, ["rev-parse", "--verify", `${opts.ref ?? "HEAD"}^{commit}`]).toString("utf-8").trim();
63
+ const target = resolve(realpathSync(cwd), opts.dir);
64
+ const dir = relative(root, target).split("\\").join("/") || ".";
65
+ if (dir.startsWith("..") || isAbsolute(dir)) throw new Error(`${opts.dir} is outside the repository at ${root}`);
66
+ const spec = dir === "." ? commit : `${commit}:${dir}`;
67
+ try {
68
+ git(root, ["cat-file", "-e", spec]);
69
+ } catch {
70
+ throw new Error(`${dir} is not in commit ${commit.slice(0, 12)}: commit it before releasing it`);
71
+ }
72
+ const data = git(root, ["archive", "--format=tar", commit, "--", dir]);
73
+ const archive = resolve(cwd, opts.out ?? join("dist", "releases", `${commit}.tar`));
74
+ mkdirSync(dirname(archive), { recursive: true });
75
+ writeFileSync(archive, data);
76
+ const files = tarEntries(data).filter((e) => e.type === "file").length;
77
+ return { digest: sha256Digest(data), archive, commit, dir, files, bytes: data.length };
78
+ }
79
+
80
+ interface TarEntry {
81
+ type: "file" | "dir" | "symlink" | "other";
82
+ path: string;
83
+ mode: number;
84
+ data: Buffer;
85
+ }
86
+
87
+ const field = (block: Buffer, at: number, len: number): string => {
88
+ const raw = block.subarray(at, at + len);
89
+ const end = raw.indexOf(0);
90
+ return raw.subarray(0, end < 0 ? raw.length : end).toString("utf-8");
91
+ };
92
+ const octal = (block: Buffer, at: number, len: number): number => parseInt(field(block, at, len).trim() || "0", 8);
93
+
94
+ /** The records of a pax header: `<len> key=value\n`. */
95
+ function paxRecords(data: Buffer): Record<string, string> {
96
+ const out: Record<string, string> = {};
97
+ let at = 0;
98
+ while (at < data.length) {
99
+ const space = data.indexOf(0x20, at);
100
+ if (space < 0) break;
101
+ const len = parseInt(data.subarray(at, space).toString("utf-8"), 10);
102
+ if (!Number.isFinite(len) || len <= 0) break;
103
+ const record = data.subarray(space + 1, at + len - 1).toString("utf-8");
104
+ const eq = record.indexOf("=");
105
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
106
+ at += len;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** The entries of a ustar/pax archive, as `git archive` writes one. */
112
+ function tarEntries(tar: Buffer): TarEntry[] {
113
+ const out: TarEntry[] = [];
114
+ let at = 0;
115
+ let pax: Record<string, string> = {};
116
+ while (at + 512 <= tar.length) {
117
+ const header = tar.subarray(at, at + 512);
118
+ if (header.every((b) => b === 0)) break;
119
+ const size = octal(header, 124, 12);
120
+ const flag = String.fromCharCode(header[156] || 0x30);
121
+ const data = tar.subarray(at + 512, at + 512 + size);
122
+ at += 512 + Math.ceil(size / 512) * 512;
123
+ if (flag === "x") {
124
+ pax = paxRecords(data);
125
+ continue;
126
+ }
127
+ if (flag === "g") continue;
128
+ const prefix = field(header, 345, 155);
129
+ const name = pax.path ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100));
130
+ pax = {};
131
+ const type = flag === "0" || flag === "\0" || flag === "7" ? "file" : flag === "5" ? "dir" : flag === "2" ? "symlink" : "other";
132
+ out.push({ type, path: name, mode: octal(header, 100, 8), data: Buffer.from(data) });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * The files of an archive `archiveSourceTree` wrote, relative to the directory
139
+ * it archived, once its bytes hash to `digest`. Throws, naming both digests,
140
+ * when they do not: whatever would ship them is not what was approved.
141
+ *
142
+ * A symbolic link in the tree is refused rather than followed or dropped.
143
+ */
144
+ export function readSourceArchive(opts: { archive: string; digest: string; dir?: string }): SourceFile[] {
145
+ const data = readFileSync(opts.archive);
146
+ const actual = sha256Digest(data);
147
+ if (actual !== opts.digest) {
148
+ throw new Error(`${opts.archive} is ${actual}, not the approved ${opts.digest}: it is not the tree the release was planned from`);
149
+ }
150
+ const entries = tarEntries(data);
151
+ const dir = opts.dir && opts.dir !== "." ? `${opts.dir.replace(/\/+$/, "")}/` : "";
152
+ const files: SourceFile[] = [];
153
+ for (const e of entries) {
154
+ if (e.type === "dir") continue;
155
+ if (!e.path.startsWith(dir)) throw new Error(`${opts.archive} holds ${e.path}, outside ${dir || "its root"}`);
156
+ const path = e.path.slice(dir.length);
157
+ if (e.type !== "file") throw new Error(`${opts.archive} holds ${path}, which is not a regular file; a source release ships regular files only`);
158
+ files.push({ path, executable: (e.mode & 0o111) !== 0, data: e.data });
159
+ }
160
+ return files;
161
+ }