@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
@@ -0,0 +1,192 @@
1
+ /**
2
+ * #2779 — a step whose command stops at a gate of its own stops its run
3
+ * there. The stand-in below behaves like `chant workspace upgrade --json` in
4
+ * the part an Op sees: it reads its gate (`workspace-upgrade` / `<scope>`) on
5
+ * the gate ledger, prints `{ outcome: "gated", pending }` and exits 3 while
6
+ * nobody has approved the pending fact, and applies and commits once someone
7
+ * has.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { cleanScratch, commitAll, contract, git, repo } from "../workspace/__fixtures__/contract-repo";
14
+ import statusSchema from "../workspace/status.schema.json";
15
+ import { readMemberStewards } from "../workspace/status-stewards";
16
+ import { appendGateResolution, appendPendingGate } from "../lifecycle/gate-ledger";
17
+ import { readRunLedger } from "../lifecycle/run-ledger";
18
+ import { readLeaseHistory } from "../lifecycle/work-lease";
19
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
20
+ import { shellCmd } from "./activities/shell";
21
+ import { runOpLocally } from "./local-executor";
22
+ import { formatRoundLine, runOperatorRound } from "./operator";
23
+ import { declareSteward } from "./steward";
24
+ import { isGateWait } from "./gate-wait";
25
+ import { workLeaseOutput } from "./work-lease-run";
26
+ import { WORKSPACE_UPGRADE_GATE_OP } from "./gate-name";
27
+ import type { OpConfig } from "./types";
28
+
29
+ const PROFILES: Record<string, ActivityProfile> = { atMostOnce: { timeout: "60s" } };
30
+ const ACTIVITIES = new Map<string, ActivityFn>([["shellCmd", ((args, signal) => shellCmd(args as never, signal)) as ActivityFn]]);
31
+
32
+ /** `chant workspace upgrade <scope> --json`, as far as its gate goes. */
33
+ const STAND_IN = `
34
+ import { execFileSync } from "node:child_process";
35
+ import { writeFileSync } from "node:fs";
36
+ const scope = process.argv[2];
37
+ let text = "";
38
+ try { text = execFileSync("git", ["show", "chant/lifecycle:_gates/workspace-upgrade.jsonl"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); } catch {}
39
+ const lines = text.split("\\n").filter(Boolean).map((l) => JSON.parse(l)).filter((l) => l.gate === scope);
40
+ const pending = lines.filter((l) => l.kind === "pending").at(-1);
41
+ if (!pending) { console.error("no pending fact: the test records it"); process.exit(1); }
42
+ const approved = lines.some((l) => l.kind !== "pending" && Date.parse(l.timestamp) >= Date.parse(pending.timestamp));
43
+ if (!approved) {
44
+ console.log(JSON.stringify({ outcome: "gated", scope, pending }, null, 2));
45
+ process.exit(3);
46
+ }
47
+ writeFileSync("upgraded-" + scope + ".txt", "upgraded\\n");
48
+ execFileSync("git", ["add", "-A"]);
49
+ execFileSync("git", ["commit", "-q", "-m", "upgrade " + scope]);
50
+ console.log(JSON.stringify({ outcome: "applied", scope }, null, 2));
51
+ `;
52
+
53
+ let root: string;
54
+
55
+ beforeAll(() => {
56
+ root = repo({ "chant.config.json": "{}\n", "tools/upgrade.mjs": STAND_IN }, false);
57
+ git(root, "config", "user.name", "Gate Test");
58
+ git(root, "config", "user.email", "gate@example.com");
59
+ commitAll(root, "c0");
60
+ });
61
+ afterAll(cleanScratch);
62
+
63
+ function upgradeOp(name: string, scope: string, extra: Partial<OpConfig> = {}): OpConfig {
64
+ return {
65
+ name,
66
+ overview: "chant workspace upgrade in a work-lease worktree",
67
+ changesCheckout: true,
68
+ workLease: { item: scope === "." ? "workspace-upgrade" : `upgrade-${scope}` },
69
+ phases: [
70
+ {
71
+ name: "Upgrade",
72
+ steps: [
73
+ {
74
+ kind: "activity",
75
+ fn: "shellCmd",
76
+ id: "upgrade",
77
+ args: { cmd: `node ${JSON.stringify(join(root, "tools", "upgrade.mjs"))} ${scope}`, cwd: workLeaseOutput("worktree"), gatedExit: 3 },
78
+ },
79
+ ],
80
+ },
81
+ { name: "After", steps: [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }] },
82
+ ],
83
+ ...extra,
84
+ };
85
+ }
86
+
87
+ /** What `chant workspace upgrade` records when it stages a patch nobody has approved. */
88
+ async function stage(scope: string): Promise<string> {
89
+ const timestamp = new Date().toISOString();
90
+ await appendPendingGate(
91
+ { op: WORKSPACE_UPGRADE_GATE_OP, gate: scope, timestamp, expiresAt: new Date(Date.now() + 3_600_000).toISOString(), description: `upgrade ${scope}` },
92
+ { cwd: root },
93
+ );
94
+ return timestamp;
95
+ }
96
+
97
+ async function approve(scope: string): Promise<void> {
98
+ await new Promise((r) => setTimeout(r, 5));
99
+ await appendGateResolution({ op: WORKSPACE_UPGRADE_GATE_OP, gate: scope, resolvedBy: "alice", timestamp: new Date().toISOString() }, { cwd: root });
100
+ }
101
+
102
+ describe("shell's gatedExit (#2779)", () => {
103
+ test("the gate comes from the command's JSON, else from the step's gate on the ledger, else the step fails", async () => {
104
+ const pending = { version: 1, kind: "pending", op: "workspace-upgrade", gate: "web", timestamp: "2027-01-01T00:00:00.000Z", expiresAt: "2027-01-03T00:00:00.000Z" };
105
+ const printed = JSON.stringify({ outcome: "gated", pending }, null, 2).replace(/'/g, "");
106
+ const fromJson = await shellCmd({ cmd: `printf '%s' '${printed}'; exit 3`, gatedExit: 3 }).catch((e: unknown) => e);
107
+ expect(isGateWait(fromJson)).toBe(true);
108
+ expect((fromJson as { pending: unknown }).pending).toMatchObject({ op: "workspace-upgrade", gate: "web" });
109
+
110
+ const since = await stage("named");
111
+ const named = await shellCmd({ cmd: "exit 3", cwd: root, gatedExit: 3, gate: { op: "workspace-upgrade", gate: "named" } }).catch((e: unknown) => e);
112
+ expect(isGateWait(named)).toBe(true);
113
+ expect((named as { pending: { timestamp: string } }).pending.timestamp).toBe(since);
114
+
115
+ await expect(shellCmd({ cmd: "exit 3", gatedExit: 3 })).rejects.toThrow(/named no gate/);
116
+ await expect(shellCmd({ cmd: "exit 3", cwd: root, gatedExit: 3, gate: { op: "workspace-upgrade", gate: "never" } })).rejects.toThrow(/no pending fact/);
117
+ // Another code still fails, and okExit still passes.
118
+ await expect(shellCmd({ cmd: "exit 4", gatedExit: 3 })).rejects.toThrow(/command exited 4/);
119
+ await expect(shellCmd({ cmd: "exit 1", gatedExit: 3, okExit: [0, 1] })).resolves.toMatchObject({ exitCode: 1 });
120
+ });
121
+ });
122
+
123
+ describe("an Op whose step runs chant workspace upgrade in a work-lease worktree (#2779)", () => {
124
+ test("records its first run gated with the gate, releases the lease gated, and applies once approved", async () => {
125
+ const since = await stage(".");
126
+ const config = upgradeOp("upgrade", ".");
127
+ const first = await runOpLocally(config, ACTIVITIES, PROFILES, undefined, { cwd: root, ledger: { cwd: root } });
128
+
129
+ expect(first.status).toBe("gated");
130
+ expect(first.gate).toMatchObject({ op: "workspace-upgrade", gate: ".", timestamp: since });
131
+ expect(first.records.map((r) => [r.fn, r.status])).toEqual([["workLease:claim", "ok"], ["shellCmd", "skipped"], ["shellCmd", "skipped"]]);
132
+ expect(first.records[1].gate).toEqual({ op: "workspace-upgrade", gate: "." });
133
+ expect(first.workLease).toMatchObject({ item: "workspace-upgrade", released: true, outcome: "gated" });
134
+
135
+ const newest = (await readRunLedger("local", "upgrade", { cwd: root })).records.at(-1)!;
136
+ expect(newest).toMatchObject({ status: "gated", gate: { name: ".", since, op: "workspace-upgrade" } });
137
+ expect(newest.phases[0].steps[1]).toMatchObject({ fn: "shellCmd", status: "skipped", gate: { op: "workspace-upgrade", gate: "." } });
138
+ expect((await readLeaseHistory("workspace-upgrade", { cwd: root })).records.at(-1)).toMatchObject({ event: "release", outcome: "gated" });
139
+
140
+ // workspace status shows the gate the Op's run waits on, with the command that approves it.
141
+ const steward = declareSteward({ name: "box-steward", ops: [config] });
142
+ mkdirSync(join(root, "ops"), { recursive: true });
143
+ writeFileSync(join(root, "ops", "steward.op.ts"), `export const steward = ${JSON.stringify(steward)};\n`);
144
+ const status = await readMemberStewards(root, "local", new Date().toISOString());
145
+ expect(status.reasons).toEqual([]);
146
+ const entry = status.stewards.find((s) => s.name === "box-steward")!;
147
+ contract({ $schema: statusSchema.$schema, $id: "urn:test:status-steward-gate", $defs: statusSchema.$defs, $ref: "#/$defs/steward" }).expectValid(entry);
148
+ expect(entry.ops[0].lastRun).toMatchObject({
149
+ status: "gated",
150
+ gate: { name: ".", since, op: "workspace-upgrade", approve: "chant approve workspace-upgrade ." },
151
+ });
152
+
153
+ // `chant approve workspace-upgrade .`, and the next run applies the patch in the worktree.
154
+ await approve(".");
155
+ const second = await runOpLocally(config, ACTIVITIES, PROFILES, undefined, { cwd: root, ledger: { cwd: root } });
156
+ expect(second.status).toBe("ok");
157
+ expect(second.records.map((r) => [r.fn, r.status])).toEqual([["workLease:claim", "ok"], ["shellCmd", "ok"], ["shellCmd", "ok"]]);
158
+ expect(second.workLease).toMatchObject({ released: true, outcome: "done" });
159
+ expect(git(root, "show", `${second.workLease!.branch}:upgraded-..txt`)).toBe("upgraded");
160
+ expect(existsSync(join(root, "upgraded-..txt"))).toBe(false);
161
+ const status2 = await readMemberStewards(root, "local", new Date().toISOString());
162
+ expect(status2.stewards[0].ops[0].lastRun).toMatchObject({ status: "ok", gate: null });
163
+ });
164
+
165
+ test("a local steward leaves its gated run while the gate stands, and runs it again once a person approves", async () => {
166
+ await stage("web");
167
+ const config = upgradeOp("upgrade-web", "web", { schedule: { cron: "0 0 1 1 *" } });
168
+ const steward = declareSteward({ name: "web-steward", ops: [config] });
169
+ const scheduleState = new Map<string, Date>();
170
+ const round = (minute: number) =>
171
+ runOperatorRound({ cwd: root, steward, activities: ACTIVITIES, profiles: PROFILES, holder: "box", now: () => new Date(2027, 0, 1, 0, minute, 10), scheduleState });
172
+
173
+ const first = await round(0);
174
+ expect(first[0]).toMatchObject({ kind: "ticked", op: "upgrade-web" });
175
+ expect((first[0] as { result: { status: string } }).result.status).toBe("gated");
176
+ expect(formatRoundLine(first[0])).toContain('status=gated gate="web"');
177
+ expect((await readRunLedger("local", "upgrade-web", { cwd: root })).records.at(-1)).toMatchObject({ status: "gated", steward: "web-steward" });
178
+
179
+ const second = await round(5);
180
+ expect(second).toEqual([{ kind: "waiting-on-gate", op: "upgrade-web", env: "local", gateOp: "workspace-upgrade", gate: "web" }]);
181
+ expect(formatRoundLine(second[0])).toBe("operator: upgrade-web@local gated=1(gate:workspace-upgrade/web)");
182
+
183
+ await approve("web");
184
+ const third = await round(10);
185
+ expect(third[0]).toMatchObject({ kind: "ticked", op: "upgrade-web", approved: { op: "workspace-upgrade", gate: "web" } });
186
+ expect((third[0] as { result: { status: string } }).result.status).toBe("ok");
187
+ expect(formatRoundLine(third[0])).toContain('approved="workspace-upgrade/web"');
188
+
189
+ // Done: the next round waits for the cron again.
190
+ expect(await round(15)).toEqual([{ kind: "skipped-not-due", op: "upgrade-web", env: "local", cron: "0 0 1 1 *" }]);
191
+ });
192
+ });
@@ -3,7 +3,8 @@ import { runOpLocally } from "../local-executor";
3
3
  import type { ActivityFn, ActivityProfile } from "../activity-registry";
4
4
  import type { OpConfig } from "../types";
5
5
  import { eq, gt, run, report, when } from "../converge-rule";
6
- import type { ConvergeSymptom } from "../../lifecycle/symptoms";
6
+ import type { ConvergeSymptom, ResourceSymptom } from "../../lifecycle/symptoms";
7
+ import { activity } from "../builders";
7
8
  import { ConvergeOp } from "./converge-op";
8
9
 
9
10
  function props(op: unknown): OpConfig {
@@ -166,3 +167,28 @@ describe("ConvergeOp — end-to-end local run (#1484)", () => {
166
167
  expect(result.status).toBe("ok");
167
168
  });
168
169
  });
170
+
171
+ describe("ConvergeOp with an observer step (#2778)", () => {
172
+ const restart = when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), { id: "restart-drifted", why: "Restart a drifted service." });
173
+ const observer = activity("spriteServicesObserve", { services: [{ name: "app" }] });
174
+
175
+ test("the observer is the Observe phase, and its whole result is the tick's observation", () => {
176
+ const { op } = ConvergeOp({ name: "box-converge", env: "box", dial: "apply", observe: observer, rules: [restart], schedule: "* * * * *" });
177
+ const config = props(op);
178
+ expect(config.labels).toEqual({ Converge: "true", Env: "box", Dial: "apply", Observe: "step" });
179
+ expect(config.schedule).toEqual({ cron: "* * * * *", overlap: "skip" });
180
+ expect(phaseNamed(op, "Observe").steps).toEqual([{ ...observer, id: "observe" }]);
181
+ const tickStep = phaseNamed(op, "Converge").steps[0] as { fn: string; args: Record<string, unknown> };
182
+ expect(tickStep.fn).toBe("convergeTick");
183
+ expect(tickStep.args.observed).toMatchObject({ kind: "step-output-ref", step: "observe" });
184
+ expect(tickStep.args.preflightDrift).toBeUndefined();
185
+ expect(tickStep.args.rules).toEqual([restart]);
186
+ });
187
+
188
+ test("an observer's own id is kept, and a rule over a field ResourceSymptom doesn't produce is refused", () => {
189
+ const { op } = ConvergeOp({ name: "box-converge", env: "box", observe: activity("shellCmd", { cmd: "./probe" }, { id: "probe" }), rules: [restart] });
190
+ expect((phaseNamed(op, "Converge").steps[0] as unknown as { args: { observed: { step: string } } }).args.observed.step).toBe("probe");
191
+ const envRule = when<ConvergeSymptom>(gt("adoptCount", 0), report("x"), { id: "adopt", why: "x" });
192
+ expect(() => ConvergeOp({ name: "box-converge", env: "box", observe: observer, rules: [envRule as never] })).toThrow(/ResourceSymptom/);
193
+ });
194
+ });
@@ -45,9 +45,11 @@
45
45
 
46
46
  import { Op, phase, activity } from "../builders";
47
47
  import type { OpResource } from "../resource";
48
+ import type { ActivityStep } from "../types";
49
+ import { stepOutput } from "../step-output-ref";
48
50
  import { isWellFormedPredicate, duplicateRuleIds, type ConvergeRule } from "../converge-rule";
49
- import type { ConvergeSymptom } from "../../lifecycle/symptoms";
50
- import { CONVERGE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms";
51
+ import type { ConvergeSymptom, ResourceSymptom } from "../../lifecycle/symptoms";
52
+ import { CONVERGE_SYMPTOM_FIELDS, RESOURCE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms";
51
53
 
52
54
  /**
53
55
  * Authority level for this ConvergeOp's environment — issue #1484's own
@@ -96,7 +98,7 @@ import { CONVERGE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms";
96
98
  */
97
99
  export type ConvergeDial = "observe" | "reconcile" | "apply";
98
100
 
99
- export interface ConvergeOpConfig {
101
+ interface ConvergeOpCommon {
100
102
  /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */
101
103
  name: string;
102
104
  /** Environment to converge (e.g. "staging"). */
@@ -105,8 +107,6 @@ export interface ConvergeOpConfig {
105
107
  dial?: ConvergeDial;
106
108
  /** Max number of `run` dispatches per tick — the issue's "the worst tick is bounded" (Accessible Ops factor VI). Rules matched beyond the budget are recorded, not run. @default 3 */
107
109
  budget?: number;
108
- /** The rule table — build with `when()`/`eq()`/`run()`/`report()` from `@intentius/chant/op`. Non-empty; every rule id must be unique. */
109
- rules: ConvergeRule<ConvergeSymptom>[];
110
110
  /**
111
111
  * Cron expression. When set, it lands on the Op as `schedule` — the cadence
112
112
  * `chant operator` ticks this Op on and the CI generators render; omit for
@@ -114,10 +114,40 @@ export interface ConvergeOpConfig {
114
114
  * "one-shot runnable locally for a single tick").
115
115
  */
116
116
  schedule?: string;
117
+ }
118
+
119
+ /** A ConvergeOp that observes a chant environment through its lexicons. */
120
+ export interface EnvironmentConvergeOpConfig extends ConvergeOpCommon {
121
+ /** The rule table — build with `when()`/`eq()`/`run()`/`report()` from `@intentius/chant/op`. Non-empty; every rule id must be unique. */
122
+ rules: ConvergeRule<ConvergeSymptom>[];
117
123
  /** Run `chant lifecycle diff --live` in the Observe phase (queries live state). @default true */
118
124
  live?: boolean;
125
+ observe?: undefined;
119
126
  }
120
127
 
128
+ /**
129
+ * A ConvergeOp whose Observe phase is a step of its own (#2778), for
130
+ * resources no lexicon declares or observes, such as the services a
131
+ * supervisor runs in a box (the fly lexicon's `spriteServicesObserve`).
132
+ */
133
+ export interface StepConvergeOpConfig extends ConvergeOpCommon {
134
+ /**
135
+ * The observer: an activity step that returns a `ResourceObservation`,
136
+ * `{ resources: [{ name, status: "in-sync" | "drifted" | "unknown", detail? }] }`,
137
+ * or a `shell()` step that prints one as JSON. Its id defaults to `observe`.
138
+ */
139
+ observe: ActivityStep;
140
+ /**
141
+ * The rule table, evaluated once per observed resource against a
142
+ * `ResourceSymptom` (`env`, `resource`, `status`, `detail`). A fired
143
+ * `run()` is dispatched for that resource: the dispatched Op reads its
144
+ * name from `CHANT_CONVERGE_RESOURCE`.
145
+ */
146
+ rules: ConvergeRule<ResourceSymptom>[];
147
+ }
148
+
149
+ export type ConvergeOpConfig = EnvironmentConvergeOpConfig | StepConvergeOpConfig;
150
+
121
151
  export interface ConvergeOpResources {
122
152
  /** Op resource — the observe-then-converge Op. */
123
153
  op: InstanceType<typeof OpResource>;
@@ -126,7 +156,8 @@ export interface ConvergeOpResources {
126
156
  export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources {
127
157
  const dial = config.dial ?? "observe";
128
158
  const budget = config.budget ?? 3;
129
- const live = config.live ?? true;
159
+ const observer = config.observe !== undefined ? config.observe : undefined;
160
+ const fields = observer ? RESOURCE_SYMPTOM_FIELDS : CONVERGE_SYMPTOM_FIELDS;
130
161
 
131
162
  // ── Build-time refusals (#1484) ─────────────────────────────────────────
132
163
  // Thrown here, at Op-construction time — the same "refuse in the factory,
@@ -142,7 +173,7 @@ export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources {
142
173
  if (config.rules.length === 0) {
143
174
  throw new Error(`ConvergeOp "${config.name}": at least one rule is required — an empty table has nothing to converge.`);
144
175
  }
145
- const dupes = duplicateRuleIds(config.rules);
176
+ const dupes = duplicateRuleIds<unknown>(config.rules as ConvergeRule<unknown>[]);
146
177
  if (dupes.length > 0) {
147
178
  throw new Error(
148
179
  `ConvergeOp "${config.name}": duplicate rule id(s) [${dupes.join(", ")}] — flap-damping counters are keyed by id, so ids must be unique.`,
@@ -157,13 +188,40 @@ export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources {
157
188
  // assembled by hand (bypassing when()) reaches here too.
158
189
  throw new Error(`ConvergeOp "${config.name}", rule "${rule.id}": every rule must carry its why — refused at build.`);
159
190
  }
160
- if (!isWellFormedPredicate(rule.when, CONVERGE_SYMPTOM_FIELDS)) {
191
+ if (!isWellFormedPredicate(rule.when, fields)) {
161
192
  throw new Error(
162
193
  `ConvergeOp "${config.name}", rule "${rule.id}": predicate is outside the evaluable subset — build it from ` +
163
- `eq/neq/gt/gte/lt/lte/truthy/falsy/allOf/anyOf over a field ConvergeSymptom actually produces.`,
194
+ `eq/neq/gt/gte/lt/lte/truthy/falsy/allOf/anyOf over a field ${observer ? "ResourceSymptom" : "ConvergeSymptom"} actually produces.`,
164
195
  );
165
196
  }
166
197
  }
198
+ if (observer && observer.kind !== "activity") {
199
+ throw new Error(`ConvergeOp "${config.name}": observe must be an activity step, such as shell() or spriteServicesObserve()`);
200
+ }
201
+
202
+ const labels = { Converge: "true", Env: config.env, Dial: dial, ...(observer ? { Observe: "step" } : {}) };
203
+ const schedule = config.schedule ? { schedule: { cron: config.schedule, overlap: "skip" as const } } : {};
204
+
205
+ if (observer) {
206
+ // #2778: the observer step is the Observe phase, and its whole result is
207
+ // the tick's observation.
208
+ const observeStep: ActivityStep = { ...observer, id: observer.id ?? "observe" };
209
+ const tickStep = activity(
210
+ "convergeTick",
211
+ { opName: config.name, env: config.env, dial, budget, rules: config.rules, observed: stepOutput(observeStep.id!) },
212
+ "longInfra",
213
+ );
214
+ tickStep.outcomeAttribute = { name: "Remediated", from: "remediated" };
215
+ const op = Op({
216
+ name: config.name,
217
+ overview: `Converge ${config.env}'s observed resources toward their declaration (dial: ${dial})`,
218
+ labels,
219
+ ...schedule,
220
+ phases: [phase("Observe", [observeStep]), phase("Converge", [tickStep])],
221
+ });
222
+ return { op };
223
+ }
224
+ const live = (config as EnvironmentConvergeOpConfig).live ?? true;
167
225
 
168
226
  const snapshotStep = activity("lifecycleSnapshot", { env: config.env }, { id: "snapshot" });
169
227
  const diffStep = activity("lifecycleDiff", { env: config.env, live }, { id: "diff", profile: "fastIdempotent" });
@@ -194,11 +252,7 @@ export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources {
194
252
  const op = Op({
195
253
  name: config.name,
196
254
  overview: `Converge the ${config.env} environment toward its declaration (dial: ${dial})`,
197
- labels: {
198
- Converge: "true",
199
- Env: config.env,
200
- Dial: dial,
201
- },
255
+ labels,
202
256
  // Overlap (#1484 acceptance criterion: "skip-and-report when a prior
203
257
  // remediation is in flight ... never queue"). `"skip"` is the only value
204
258
  // `OpSchedule` has, and it means exactly that: a fire arriving while the
@@ -206,7 +260,7 @@ export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources {
206
260
  // alongside. There is no per-tick "in flight" ledger record for the
207
261
  // skipped fire to produce — nothing ran, so nothing observed, classified,
208
262
  // or dispatched.
209
- ...(config.schedule ? { schedule: { cron: config.schedule, overlap: "skip" as const } } : {}),
263
+ ...schedule,
210
264
  phases: [phase("Observe", [snapshotStep, diffStep]), phase("Converge", [tickStep])],
211
265
  });
212
266
 
@@ -0,0 +1,44 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
2
+ import { backendAsk, postQuestion, questionName } from "./decide-backend";
3
+ import { requestProblem, startStubBackend, type StubBackend } from "./__fixtures__/decide-stub-backend";
4
+
5
+ let stub: StubBackend;
6
+ beforeAll(async () => {
7
+ stub = await startStubBackend({ model: "jev-1.13.0" });
8
+ });
9
+ afterAll(() => stub.close());
10
+
11
+ const request = { point: "slice-tier", backend: "local", model: "jev-1.13.0", question: { type: "choice" as const, instructions: "Pick", criteria: { small: "s", large: "l" } }, state: { size: 3 } };
12
+
13
+ describe("the wire-format client", () => {
14
+ test("a point's name is the question's name, as points ask --response reads it", () => {
15
+ expect(questionName("slice-tier")).toBe("slice-tier");
16
+ });
17
+
18
+ test("posts to /v1/systemone and reads the answer back by the question's name", async () => {
19
+ const r = await postQuestion({ url: `${stub.url}/` }, "local", request, { cwd: process.cwd() });
20
+ expect(r.model).toBe("jev-1.13.0");
21
+ expect(r.answer).toMatchObject({ type: "choice", choice: "small" });
22
+ expect(Object.keys(stub.requests[stub.requests.length - 1].questions)).toEqual(["slice-tier"]);
23
+ });
24
+
25
+ test("a name no backend has, and a timeout, throw", async () => {
26
+ await expect(backendAsk({ backends: {}, cwd: process.cwd() })(request)).rejects.toThrow("no backend named local is configured");
27
+ const hang = (_url: unknown, init?: { signal?: AbortSignal }) => new Promise<Response>((_, reject) => init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))));
28
+ await expect(postQuestion({ url: "http://127.0.0.1:9", timeoutMs: 20 }, "local", request, { cwd: process.cwd(), transport: hang as never })).rejects.toThrow("no answer within 20 ms");
29
+ });
30
+
31
+ test("a response without the answer, or not JSON, throws", async () => {
32
+ const fake = (body: string) => (async () => new Response(body, { status: 200 })) as never;
33
+ await expect(postQuestion({ url: "http://x" }, "b", request, { cwd: process.cwd(), transport: fake('{"model":"jev-1.13.0","answers":{}}') })).rejects.toThrow("has no answer to slice-tier");
34
+ await expect(postQuestion({ url: "http://x" }, "b", request, { cwd: process.cwd(), transport: fake("<html>") })).rejects.toThrow("not JSON");
35
+ });
36
+ });
37
+
38
+ describe("the stub", () => {
39
+ test("refuses a request outside the wire format with 422", async () => {
40
+ expect(requestProblem({ model: "m", state: {}, questions: { q: { type: "score", instructions: "i", criteria: ["one"] } } })).toContain("2 to 10 levels");
41
+ const res = await fetch(`${stub.url}/v1/systemone`, { method: "POST", body: JSON.stringify({ model: "m" }) });
42
+ expect(res.status).toBe(422);
43
+ });
44
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The client for the `POST /v1/systemone` wire format (#2491): TypeSafe's Jev
3
+ * API, and every server that implements the same request and response.
4
+ *
5
+ * ```
6
+ * POST <url>/v1/systemone Authorization: Bearer <key>
7
+ * { "model": "jev-1.13.0", "state": {...}, "questions": { "<name>": { "type": "noul" | "choice" | "score", "instructions": ..., "criteria": ... } } }
8
+ *
9
+ * { "model": "jev-1.13.0", "answers": { "<name>": { "type": "noul", "noul": 0.22 } | { "type": "choice", "choice": ..., "probabilities": {...}, "confidence": ... } | { "type": "score", ... } } }
10
+ * ```
11
+ *
12
+ * {@link backendAsk} turns a set of backends into core's `ModelAsk`, the
13
+ * model call `askPoint` takes (`../workspace/points.ts`). This file
14
+ * only speaks the wire format: whether an answer is observed, proposed or
15
+ * escalated, and what a point does when the backend is unreachable, are
16
+ * core's (`runChain`), so they are the same for this client, for a response
17
+ * handed to `points ask --response`, and for any runtime's decider.
18
+ *
19
+ * The model id is sent as the point pins it. An alias such as `jev-latest` is
20
+ * refused where the point is declared, and the response's `model` is compared
21
+ * with the pin by core, so an alias that moved is never recorded as the pin.
22
+ *
23
+ * Anything that stops an answer arriving throws: no key, a network error, a
24
+ * timeout, a non-2xx status (Jev's are 401, 422, 429 and 529), or a response
25
+ * without an answer to the question asked. Core reads a throw as the backend
26
+ * being unreachable and follows the point's `unreachable`.
27
+ *
28
+ * It reads the workspace declaration for a brokered key, so it loads code
29
+ * under `workspace/`: the `decide` activity imports it on first call, never
30
+ * at module load (#2525 rule 5).
31
+ */
32
+
33
+ import type { ModelAsk, ModelRequest, WireAnswer } from "../workspace/points";
34
+ import { readDeclaration } from "../workspace/declaration";
35
+ import { locateWorkspace } from "../workspace/which-chant";
36
+ import type { BackendKey, BrokeredKey, DecideBackend } from "./decide-config";
37
+
38
+ /** The path every Jev-compatible server serves. */
39
+ export const SYSTEMONE_PATH = "/v1/systemone";
40
+
41
+ /** How long a call may take before the backend counts as unreachable, when the backend names no `timeoutMs`. */
42
+ export const DEFAULT_TIMEOUT_MS = 30_000;
43
+
44
+ /** The request body. */
45
+ export interface BackendRequest {
46
+ model: string;
47
+ state: unknown;
48
+ questions: Record<string, { type: string; instructions: string; criteria: Record<string, string> | string[] }>;
49
+ }
50
+
51
+ /** The response body, as far as this client reads it. */
52
+ export interface BackendResponse {
53
+ model: string;
54
+ answers: Record<string, WireAnswer>;
55
+ usage?: { input_tokens?: number; output_tokens?: number };
56
+ }
57
+
58
+ /**
59
+ * The name a point's question is sent under: the point's own name, so a
60
+ * response reads back the way `points ask --response` reads one, with the
61
+ * answer under `answers["<point>"]`.
62
+ */
63
+ export const questionName = (point: string): string => point;
64
+
65
+ /** A backend or key that is configured wrongly: the activity refuses before asking anything. */
66
+ export class DecideConfigError extends Error {
67
+ constructor(message: string) {
68
+ super(message);
69
+ this.name = "DecideConfigError";
70
+ }
71
+ }
72
+
73
+ /** Where a brokered capability is declared: the member and its broker. */
74
+ export interface BrokeredCapability {
75
+ member: string;
76
+ broker: string;
77
+ scope: string[];
78
+ }
79
+
80
+ /**
81
+ * The declaration's entry for a brokered key (#2726): the box member that
82
+ * declares the capability, and the broker it names. Read from the workspace
83
+ * declaration nearest above `cwd`, the same declaration `workspace status`
84
+ * reads it from. Throws a {@link DecideConfigError} when no member declares
85
+ * the capability, when more than one does and the key names none, or when the
86
+ * capability names no broker.
87
+ */
88
+ export function brokeredCapability(key: BrokeredKey, cwd: string): BrokeredCapability {
89
+ let members;
90
+ try {
91
+ members = readDeclaration(locateWorkspace(cwd).tree).members;
92
+ } catch (err) {
93
+ throw new DecideConfigError(`the key names the brokered capability ${key.capability}, and the workspace declaration can't be read: ${err instanceof Error ? err.message : String(err)}`);
94
+ }
95
+ const declaring = members.filter((m) => (key.member === undefined || m.name === key.member) && m.box?.capabilities.some((c) => c.name === key.capability));
96
+ if (declaring.length === 0) {
97
+ const what = key.member !== undefined ? `member ${key.member}'s box does not declare the capability ${key.capability}` : `no member's box declares the capability ${key.capability}`;
98
+ throw new DecideConfigError(`${what} (box.capabilities in chant.workspace.json, #2726)`);
99
+ }
100
+ if (declaring.length > 1) {
101
+ throw new DecideConfigError(`the capability ${key.capability} is declared by ${declaring.map((m) => m.name).join(" and ")}: name the member in the key`);
102
+ }
103
+ const member = declaring[0];
104
+ const capability = member.box!.capabilities.find((c) => c.name === key.capability)!;
105
+ if (capability.broker === null) {
106
+ throw new DecideConfigError(`member ${member.name}'s capability ${key.capability} names no broker (WSP122), so nothing holds its key`);
107
+ }
108
+ return { member: member.name, broker: capability.broker, scope: capability.scope };
109
+ }
110
+
111
+ /** Check a key reference without reading any secret: a string is a literal and refused, and a brokered capability must be declared. */
112
+ export function checkKey(key: unknown, where: string, cwd: string): void {
113
+ if (key === undefined) return;
114
+ if (typeof key === "string") {
115
+ throw new DecideConfigError(`${where} is a literal string; a key is { env: "<VARIABLE>" } or a brokered capability, never the key itself (SYS001)`);
116
+ }
117
+ if (key !== null && typeof key === "object" && "capability" in key) brokeredCapability(key as BrokeredKey, cwd);
118
+ }
119
+
120
+ /**
121
+ * The bearer key for one call, or undefined when none is sent: a backend with
122
+ * no key, or a brokered capability whose broker is the endpoint. Throws when a
123
+ * variable the key names is unset, which the chain reads as unreachable.
124
+ */
125
+ export function resolveKey(key: BackendKey | undefined, cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
126
+ if (key === undefined) return undefined;
127
+ if ("capability" in key) {
128
+ const brokered = brokeredCapability(key, cwd);
129
+ if (key.env === undefined) return undefined;
130
+ const value = env[key.env];
131
+ if (!value) throw new Error(`the broker ${brokered.broker} has not set ${key.env} for the capability ${key.capability}`);
132
+ return value;
133
+ }
134
+ const value = env[key.env];
135
+ if (!value) throw new Error(`the environment variable ${key.env} is not set`);
136
+ return value;
137
+ }
138
+
139
+ export interface BackendAskOptions {
140
+ /** Backend name, as a point's model decider names it, to the backend. */
141
+ backends: Record<string, DecideBackend>;
142
+ /** Where the workspace declaration is found, for a brokered key. */
143
+ cwd: string;
144
+ /** The HTTP call. Defaults to the global fetch; a test passes its own. */
145
+ transport?: typeof fetch;
146
+ /** Defaults to process.env. */
147
+ env?: NodeJS.ProcessEnv;
148
+ /** Cancels an in-flight call, such as the Op's timeout. */
149
+ signal?: AbortSignal;
150
+ /** Called with each response, for usage and logs. */
151
+ onResponse?: (backend: string, response: BackendResponse) => void;
152
+ }
153
+
154
+ function short(text: string): string {
155
+ const flat = text.replace(/\s+/g, " ").trim();
156
+ return flat.length > 300 ? `${flat.slice(0, 300)}...` : flat;
157
+ }
158
+
159
+ /** Post one question to one backend and return the model that answered and its answer. */
160
+ export async function postQuestion(backend: DecideBackend, name: string, request: ModelRequest, opts: Omit<BackendAskOptions, "backends">): Promise<{ model: string; answer: WireAnswer; response: BackendResponse }> {
161
+ const key = resolveKey(backend.key, opts.cwd, opts.env);
162
+ const question = questionName(request.point);
163
+ const body: BackendRequest = {
164
+ model: request.model,
165
+ state: request.state,
166
+ questions: { [question]: { type: request.question.type, instructions: request.question.instructions, criteria: request.question.criteria } },
167
+ };
168
+ const url = `${backend.url.replace(/\/+$/, "")}${SYSTEMONE_PATH}`;
169
+ const timeout = AbortSignal.timeout(backend.timeoutMs ?? DEFAULT_TIMEOUT_MS);
170
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
171
+ let res: Response;
172
+ try {
173
+ res = await (opts.transport ?? fetch)(url, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json", accept: "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) },
176
+ body: JSON.stringify(body),
177
+ signal,
178
+ });
179
+ } catch (err) {
180
+ const why = timeout.aborted ? `no answer within ${backend.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms` : err instanceof Error ? err.message : String(err);
181
+ throw new Error(`${name} at ${url}: ${why}`);
182
+ }
183
+ const text = await res.text();
184
+ if (!res.ok) throw new Error(`${name} at ${url} answered ${res.status}${text ? `: ${short(text)}` : ""}`);
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(text);
188
+ } catch {
189
+ throw new Error(`${name} at ${url} answered with something that is not JSON: ${short(text)}`);
190
+ }
191
+ const r = parsed as Partial<BackendResponse> | null;
192
+ if (r === null || typeof r !== "object") throw new Error(`${name} answered with no JSON object`);
193
+ if (typeof r.model !== "string" || r.model === "") throw new Error(`${name}'s response names no model`);
194
+ const answer = r.answers?.[question];
195
+ if (answer === null || typeof answer !== "object") throw new Error(`${name}'s response has no answer to ${question}`);
196
+ return { model: r.model, answer, response: r as BackendResponse };
197
+ }
198
+
199
+ /**
200
+ * The model call for `askPoint`: each request goes to the backend its decider
201
+ * names. A name no backend has throws, and so does anything
202
+ * {@link postQuestion} throws, which the chain reads as unreachable.
203
+ */
204
+ export function backendAsk(opts: BackendAskOptions): ModelAsk {
205
+ return async (request) => {
206
+ const backend = opts.backends[request.backend];
207
+ if (!backend) throw new Error(`no backend named ${request.backend} is configured (decide.backends)`);
208
+ const { model, answer, response } = await postQuestion(backend, request.backend, request, opts);
209
+ opts.onResponse?.(request.backend, response);
210
+ return { model, answer };
211
+ };
212
+ }