@intentius/chant 0.49.0 → 0.51.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 (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -0,0 +1,346 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { withTestDir } from "@intentius/chant-test-utils";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
7
+ import {
8
+ discoverConvergeOps,
9
+ runOperatorRound,
10
+ runOperatorForever,
11
+ formatRoundLine,
12
+ DEFAULT_OPERATOR_INTERVAL_MS,
13
+ } from "./operator";
14
+ import { readLease } from "../lifecycle/lease";
15
+ import { appendConvergeRecord, readConvergeLedger } from "../lifecycle/converge-ledger";
16
+
17
+ const PROFILES: Record<string, ActivityProfile> = {};
18
+
19
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
20
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
21
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
22
+ }
23
+
24
+ async function initRepo(dir: string): Promise<void> {
25
+ git(["init", "-q", "-b", "main"], dir);
26
+ git(["config", "user.email", "test@chant.dev"], dir);
27
+ git(["config", "user.name", "Test"], dir);
28
+ writeFileSync(join(dir, "README.md"), "fixture\n");
29
+ git(["add", "README.md"], dir);
30
+ git(["commit", "-q", "-m", "init"], dir);
31
+ }
32
+
33
+ /**
34
+ * Write a minimal fixture ConvergeOp — the same `{ props: OpConfig }` shape
35
+ * `discoverOps` reads from a real `*.op.ts` file's default export
36
+ * (`../op/discover.ts`), hand-built here rather than going through the real
37
+ * `ConvergeOp()` composite (lexicons/temporal) so this suite tests the
38
+ * operator's own discovery/lease/tick-loop logic without depending on that
39
+ * lexicon at all. One "Converge" phase calling a single fake activity —
40
+ * `runOpLocally` doesn't care what the phases are named or how many there
41
+ * are, only that they run through the local executor.
42
+ */
43
+ function writeFixtureConvergeOp(dir: string, opName: string, env: string): void {
44
+ const opsDir = join(dir, "ops");
45
+ mkdirSync(opsDir, { recursive: true });
46
+ writeFileSync(
47
+ join(opsDir, `${opName}.op.ts`),
48
+ `export default {
49
+ props: {
50
+ name: ${JSON.stringify(opName)},
51
+ overview: "fixture converge op",
52
+ phases: [{ name: "Converge", steps: [{ kind: "activity", fn: "fakeTick", args: {} }] }],
53
+ searchAttributes: { Converge: "true", Env: ${JSON.stringify(env)}, Dial: "observe" },
54
+ },
55
+ };
56
+ `,
57
+ );
58
+ }
59
+
60
+ function writeFixtureNonConvergeOp(dir: string, opName: string): void {
61
+ const opsDir = join(dir, "ops");
62
+ mkdirSync(opsDir, { recursive: true });
63
+ writeFileSync(
64
+ join(opsDir, `${opName}.op.ts`),
65
+ `export default {
66
+ props: {
67
+ name: ${JSON.stringify(opName)},
68
+ overview: "fixture ordinary op",
69
+ phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "fakeTick", args: {} }] }],
70
+ },
71
+ };
72
+ `,
73
+ );
74
+ }
75
+
76
+ /** A fake `convergeTick`-shaped activity that appends a real converge-ledger record — so a round's ledger effect is observable through the public reader, the same as the real activity's I/O shape. */
77
+ function fakeTickActivities(dir: string, env: string, opName: string, opts?: { throws?: boolean }): Map<string, ActivityFn> {
78
+ return new Map<string, ActivityFn>([
79
+ [
80
+ "fakeTick",
81
+ async () => {
82
+ if (opts?.throws) throw new Error("boom: simulated tick failure");
83
+ await appendConvergeRecord(
84
+ {
85
+ op: opName,
86
+ env,
87
+ timestamp: new Date().toISOString(),
88
+ firedRuleIds: [],
89
+ outcomes: [],
90
+ summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
91
+ log: `converge(${env}): drifted=0 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 unobserved=0 adopted=0`,
92
+ },
93
+ { cwd: dir },
94
+ );
95
+ return { ok: true };
96
+ },
97
+ ],
98
+ ]);
99
+ }
100
+
101
+ describe("discoverConvergeOps", () => {
102
+ test("finds only ops with searchAttributes.Converge === 'true'", async () => {
103
+ await withTestDir(async (dir) => {
104
+ await initRepo(dir);
105
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
106
+ writeFixtureNonConvergeOp(dir, "fountain-apply");
107
+
108
+ const { ops, errors } = await discoverConvergeOps({ cwd: dir });
109
+ expect(errors).toEqual([]);
110
+ expect(ops.map((d) => d.config.name)).toEqual(["staging-converge"]);
111
+ });
112
+ });
113
+
114
+ test("--env filters to ConvergeOps declaring that env", async () => {
115
+ await withTestDir(async (dir) => {
116
+ await initRepo(dir);
117
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
118
+ writeFixtureConvergeOp(dir, "prod-converge", "prod");
119
+
120
+ const { ops } = await discoverConvergeOps({ cwd: dir, env: "prod" });
121
+ expect(ops.map((d) => d.config.name)).toEqual(["prod-converge"]);
122
+ });
123
+ });
124
+
125
+ test("no env filter discovers every ConvergeOp, sorted by name", async () => {
126
+ await withTestDir(async (dir) => {
127
+ await initRepo(dir);
128
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
129
+ writeFixtureConvergeOp(dir, "prod-converge", "prod");
130
+
131
+ const { ops } = await discoverConvergeOps({ cwd: dir });
132
+ expect(ops.map((d) => d.config.name)).toEqual(["prod-converge", "staging-converge"]);
133
+ });
134
+ });
135
+ });
136
+
137
+ describe("runOperatorRound — lease + tick execution over a fixture ConvergeOp (offline)", () => {
138
+ test("acquires the lease, ticks, and records the tick as a ledger fact", async () => {
139
+ await withTestDir(async (dir) => {
140
+ await initRepo(dir);
141
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
142
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
143
+
144
+ const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
145
+ expect(events).toEqual([{ kind: "ticked", op: "staging-converge", env: "staging", result: expect.objectContaining({ ok: true }) }]);
146
+
147
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
148
+ expect(records).toHaveLength(1);
149
+ expect(records[0].op).toBe("staging-converge");
150
+ });
151
+ });
152
+
153
+ test("a second operator process (different holder) sees the lease held and skips — never queues", async () => {
154
+ await withTestDir(async (dir) => {
155
+ await initRepo(dir);
156
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
157
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
158
+
159
+ // holder-a acquires and is (simulated as) still ticking — hold the
160
+ // lease without releasing, by not letting a full round complete for it.
161
+ const { acquireLease } = await import("../lifecycle/lease");
162
+ await acquireLease("staging-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
163
+
164
+ const events = await runOperatorRound({ cwd: dir, holder: "holder-b", activities, profiles: PROFILES });
165
+ expect(events).toEqual([{ kind: "skipped-lease-held", op: "staging-converge", env: "staging", heldBy: "holder-a" }]);
166
+
167
+ // Nothing ticked — no ledger record.
168
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
169
+ expect(records).toHaveLength(0);
170
+ });
171
+ });
172
+
173
+ test("a stale lease .lock file reports lease-error for that op — never a thrown exception out of the round, never misread as lease-held (#1959 finding 2)", async () => {
174
+ await withTestDir(async (dir) => {
175
+ await initRepo(dir);
176
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
177
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
178
+
179
+ // A previous `chant operator` acquired the lease, then was killed
180
+ // mid-renewal — its `.lock` file is still sitting there, blocking
181
+ // every future write to this op's lease ref.
182
+ const { acquireLease } = await import("../lifecycle/lease");
183
+ await acquireLease("staging-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
184
+ mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
185
+ writeFileSync(join(dir, ".git", "refs", "chant", "lease", "staging-converge.lock"), "");
186
+
187
+ // holder-a itself tries to renew next round — it owns the lease, so
188
+ // acquireLease attempts the write and hits the lock file.
189
+ const events = await runOperatorRound({ cwd: dir, holder: "holder-a", activities, profiles: PROFILES });
190
+ expect(events).toHaveLength(1);
191
+ expect(events[0]).toMatchObject({ kind: "lease-error", op: "staging-converge", env: "staging" });
192
+ expect((events[0] as { error: string }).error).toContain("staging-converge.lock");
193
+
194
+ // Nothing ticked.
195
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
196
+ expect(records).toHaveLength(0);
197
+ });
198
+ });
199
+
200
+ test("a failing tick reports tick-failed, not a thrown exception out of the round", async () => {
201
+ await withTestDir(async (dir) => {
202
+ await initRepo(dir);
203
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
204
+ const activities = fakeTickActivities(dir, "staging", "staging-converge", { throws: true });
205
+
206
+ const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
207
+ expect(events).toHaveLength(1);
208
+ expect(events[0].kind).toBe("tick-failed");
209
+ });
210
+ });
211
+
212
+ test("ticks every discovered ConvergeOp across environments in one round", async () => {
213
+ await withTestDir(async (dir) => {
214
+ await initRepo(dir);
215
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
216
+ writeFixtureConvergeOp(dir, "prod-converge", "prod");
217
+ const activities = new Map<string, ActivityFn>([
218
+ ...fakeTickActivities(dir, "staging", "staging-converge"),
219
+ ]);
220
+ // Both fixtures call the same "fakeTick" fn name; one shared fake that
221
+ // reads env/op from the running Op's own config isn't available to an
222
+ // ActivityFn (it only sees step args), so this test only asserts round
223
+ // shape/coverage, not each op's own ledger content — that's covered by
224
+ // the single-op test above.
225
+ activities.set("fakeTick", async () => ({ ok: true }));
226
+
227
+ const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
228
+ expect(events.map((e) => e.op).sort()).toEqual(["prod-converge", "staging-converge"]);
229
+ expect(events.every((e) => e.kind === "ticked")).toBe(true);
230
+ });
231
+ });
232
+ });
233
+
234
+ describe("crash recovery — re-tick after a lease expires with no manual repair (#1485)", () => {
235
+ test("holder A ticks, 'crashes' (never renews); after the TTL passes, holder B re-acquires and re-ticks — the ledger shows continuity, not a gap", async () => {
236
+ await withTestDir(async (dir) => {
237
+ await initRepo(dir);
238
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
239
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
240
+
241
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
242
+ const round1 = await runOperatorRound({
243
+ cwd: dir, holder: "operator-a", leaseTtlMs: 1_000, activities, profiles: PROFILES, now: () => t0,
244
+ });
245
+ expect(round1[0].kind).toBe("ticked");
246
+
247
+ // operator-a crashes: no release, no renewal. A restarted process (a
248
+ // new holder id, matching `currentHolderId()`'s per-process identity)
249
+ // tries again after the lease's TTL has passed.
250
+ const tAfterCrash = new Date("2026-01-01T00:00:05.000Z");
251
+ const round2 = await runOperatorRound({
252
+ cwd: dir, holder: "operator-a-restarted", leaseTtlMs: 1_000, activities, profiles: PROFILES, now: () => tAfterCrash,
253
+ });
254
+ expect(round2[0].kind).toBe("ticked");
255
+
256
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
257
+ expect(records).toHaveLength(2);
258
+
259
+ const { record: lease } = await readLease("staging-converge", { cwd: dir });
260
+ expect(lease?.holder).toBe("operator-a-restarted");
261
+ });
262
+ });
263
+
264
+ test("re-acquiring before the TTL passes still fails — a live operator isn't preempted by a phantom crash", async () => {
265
+ await withTestDir(async (dir) => {
266
+ await initRepo(dir);
267
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
268
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
269
+
270
+ const t0 = new Date("2026-01-01T00:00:00.000Z");
271
+ await runOperatorRound({ cwd: dir, holder: "operator-a", leaseTtlMs: 60_000, activities, profiles: PROFILES, now: () => t0 });
272
+
273
+ const tSoonAfter = new Date("2026-01-01T00:00:05.000Z");
274
+ const events = await runOperatorRound({
275
+ cwd: dir, holder: "operator-b", leaseTtlMs: 60_000, activities, profiles: PROFILES, now: () => tSoonAfter,
276
+ });
277
+ expect(events[0].kind).toBe("skipped-lease-held");
278
+ });
279
+ });
280
+ });
281
+
282
+ describe("runOperatorForever — interval/timer behavior", () => {
283
+ // `sleepAbortable` (operator.ts) is driven by real `setTimeout`, but each
284
+ // round underneath it makes several *real* git subprocess calls (lease
285
+ // read/acquire, ledger append) — genuine process I/O that fake timers
286
+ // (`vi.useFakeTimers`) can't fast-forward through, only JS-scheduled
287
+ // callbacks. So this exercises the actual interval/abort behavior over
288
+ // real (small) wall-clock time instead: a short `intervalMs`, polling for
289
+ // each expected round count with a generous bound, never a bare sleep.
290
+ async function waitForRoundCount(rounds: unknown[], n: number, maxWaitMs = 5_000): Promise<void> {
291
+ const deadline = Date.now() + maxWaitMs;
292
+ while (rounds.length < n && Date.now() < deadline) {
293
+ await new Promise((r) => setTimeout(r, 10));
294
+ }
295
+ expect(rounds.length).toBeGreaterThanOrEqual(n);
296
+ }
297
+
298
+ test("runs a round immediately, then again every intervalMs, stopping on abort", async () => {
299
+ await withTestDir(async (dir) => {
300
+ await initRepo(dir);
301
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
302
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
303
+
304
+ const controller = new AbortController();
305
+ const rounds: number[] = [];
306
+ const loop = runOperatorForever({
307
+ cwd: dir,
308
+ holder: "op-a",
309
+ intervalMs: 40,
310
+ activities,
311
+ profiles: PROFILES,
312
+ signal: controller.signal,
313
+ onRound: () => rounds.push(Date.now()),
314
+ });
315
+
316
+ await waitForRoundCount(rounds, 1); // the immediate first round
317
+ await waitForRoundCount(rounds, 3); // at least two more, ~40ms apart
318
+
319
+ controller.abort();
320
+ const countAtAbort = rounds.length;
321
+ await new Promise((r) => setTimeout(r, 120)); // well past a couple more intervals, if any fired
322
+ expect(rounds.length).toBe(countAtAbort); // no further round after abort
323
+
324
+ await loop;
325
+ });
326
+ }, 10_000);
327
+
328
+ test("default interval is used when none is given", () => {
329
+ expect(DEFAULT_OPERATOR_INTERVAL_MS).toBeGreaterThan(0);
330
+ });
331
+ });
332
+
333
+ describe("formatRoundLine", () => {
334
+ test("renders each event kind as one line", () => {
335
+ expect(formatRoundLine({ kind: "ticked", op: "x", env: "staging", result: { op: "x", records: [], totalMs: 1, ok: true } }))
336
+ .toContain("ticked=1 ok=true");
337
+ expect(formatRoundLine({ kind: "skipped-lease-held", op: "x", env: "staging", heldBy: "holder-a" }))
338
+ .toContain("lease-held:holder-a");
339
+ expect(formatRoundLine({ kind: "tick-failed", op: "x", env: "staging", error: "boom" }))
340
+ .toContain('error="boom"');
341
+ expect(formatRoundLine({ kind: "fenced", op: "x", env: "staging" }))
342
+ .toContain("fenced=1");
343
+ expect(formatRoundLine({ kind: "lease-error", op: "x", env: "staging", error: "stale lock at .../x.lock" }))
344
+ .toContain("stale lock at .../x.lock");
345
+ });
346
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `chant operator` (#1485, epic #1487) — native durable ticks without
3
+ * Temporal. A deliberately small daemon: a timer, a lease
4
+ * (../lifecycle/lease.ts), and the existing local executor
5
+ * (./local-executor.ts) in a loop. No new service, no new state store — the
6
+ * lease lives on a git ref, tick state lives on the `chant/lifecycle`
7
+ * orphan branch via the same `convergeTick` activity `chant run <op>`
8
+ * already calls for a single one-shot tick.
9
+ *
10
+ * One round: discover every `ConvergeOp` (searchAttributes `Converge:
11
+ * "true"`, optionally filtered to one env), and for each one, try to
12
+ * acquire (or renew) its lease. Win it — run one tick via `runOpLocally` on
13
+ * the Op's own Observe → Converge phases, the identical path `chant run
14
+ * <name>` takes for a single tick (issue: "chant run fountain-converge —
15
+ * one tick, by hand — unchanged, and also the test story"). Lose it — skip
16
+ * and report, never queue (issue: "a tick that finds the lease held skips
17
+ * and reports ... never queues").
18
+ *
19
+ * Crash recovery needs no special code: a killed operator simply stops
20
+ * renewing its lease, which expires on its own TTL; the next round (from
21
+ * this process restarted, a different machine's operator, or a bare `chant
22
+ * run <name>` invoked by cron) re-acquires and re-ticks. A converge tick
23
+ * re-observes and re-derives everything every time (ConvergeOp's own
24
+ * design, #1484) — re-ticking after a crash and ticking on a normal
25
+ * schedule are the same act, not two different code paths.
26
+ *
27
+ * One crash *does* need a distinct report, though (#1959 finding 2): if the
28
+ * killed operator's own `git update-ref` was interrupted mid-write, it can
29
+ * leave a stale `.lock` file behind that blocks every future acquire
30
+ * attempt for that op's lease until someone removes it — TTL expiry doesn't
31
+ * help, since the ref update itself can't land. `acquireLease` surfaces
32
+ * that case as a `StaleLockError` rather than ordinary "lease held by
33
+ * someone else" contention, and a round reports it per-op as
34
+ * `{ kind: "lease-error" }` (never aborting the whole round) so `chant
35
+ * operator`'s log names the fix instead of quietly skipping that op
36
+ * forever.
37
+ */
38
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
39
+ import { discoverOps, type DiscoveredOp } from "./discover";
40
+ import { runOpLocally, OpRunFailure, LocalGateUnsupportedError, type OpRunResult } from "./local-executor";
41
+ import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
+ import { StaleLockError } from "../lifecycle/git";
43
+
44
+ /** Poll interval between rounds — the operator's own cadence, distinct from a `ConvergeOp`'s Temporal `schedule` cron (that field drives the durable path's `TemporalSchedule`, not anything the local executor can read back at discovery time; see this module's doc). Chosen short enough to converge promptly, long enough not to hammer `chant lifecycle plan --live` every few seconds. */
45
+ export const DEFAULT_OPERATOR_INTERVAL_MS = 60_000;
46
+
47
+ /** One ConvergeOp's own `searchAttributes.Env` (`../../lexicons/temporal/src/composites/converge-op.ts` always sets it). `undefined` for a non-ConvergeOp or a hand-built one missing it — filtered out by `isConvergeOp` before this is trusted. */
48
+ function envOf(config: DiscoveredOp["config"]): string | undefined {
49
+ return config.searchAttributes?.Env;
50
+ }
51
+
52
+ function isConvergeOp(config: DiscoveredOp["config"]): boolean {
53
+ return config.searchAttributes?.Converge === "true";
54
+ }
55
+
56
+ /** Discover every `ConvergeOp` in the project (`searchAttributes.Converge === "true"`), optionally filtered to one environment. Ops that don't declare an `Env` search attribute are excluded from an `--env`-filtered discovery (there's nothing to match), but included when no `env` filter is given. */
57
+ export async function discoverConvergeOps(
58
+ opts?: { cwd?: string; env?: string },
59
+ ): Promise<{ ops: DiscoveredOp[]; errors: string[] }> {
60
+ const { ops, errors } = await discoverOps({ cwd: opts?.cwd });
61
+ const converge = [...ops.values()].filter((d) => isConvergeOp(d.config));
62
+ const filtered = opts?.env ? converge.filter((d) => envOf(d.config) === opts.env) : converge;
63
+ return { ops: filtered.sort((a, b) => a.config.name.localeCompare(b.config.name)), errors };
64
+ }
65
+
66
+ /** One discovered ConvergeOp's outcome for one round — what `chant operator`'s one-line-per-tick log (and its tests) key off of. */
67
+ export type OperatorTickEvent =
68
+ | { kind: "ticked"; op: string; env: string; result: OpRunResult }
69
+ | { kind: "skipped-lease-held"; op: string; env: string; heldBy?: string }
70
+ | { kind: "tick-failed"; op: string; env: string; error: string }
71
+ /** The lease was lost between acquiring it and the tick finishing (e.g. this process stalled past its TTL and another operator reclaimed it) — the tick's own ledger record (written inside `convergeTick`) still landed, since a converge tick is idempotent by design; this event exists purely so `chant operator`'s log and the ledger-independent test surface can see the fencing violation happened. Never a hard failure. */
72
+ | { kind: "fenced"; op: string; env: string }
73
+ /**
74
+ * `acquireLease` itself threw — a `StaleLockError` (#1959 finding 2: a
75
+ * previous `chant operator` was killed mid-write and left a `.lock` file
76
+ * behind, so this op's lease can no longer be acquired or renewed by
77
+ * anyone until the file is removed) or any other unexpected failure. A
78
+ * distinct outcome from `skipped-lease-held` on purpose — this is not
79
+ * ordinary contention with a live holder, it's wreckage that needs a
80
+ * human, and reading it as "someone else has it" would make the operator
81
+ * back off silently forever instead of surfacing the fix. One op's lease
82
+ * error never aborts the round for every other op.
83
+ */
84
+ | { kind: "lease-error"; op: string; env: string; error: string };
85
+
86
+ export interface OperatorRoundOptions {
87
+ cwd?: string;
88
+ /** Restrict ticking to ConvergeOps for this environment. Omit to tick every discovered ConvergeOp. */
89
+ env?: string;
90
+ /** @default DEFAULT_LEASE_TTL_MS */
91
+ leaseTtlMs?: number;
92
+ /** This process's lease identity. @default currentHolderId() — a fresh identity per process, deliberately: two ticks from what could plausibly be two different processes should behave like two different holders even in a test that reuses one process, unless the caller wants to simulate "same process, later round" by passing a stable value. */
93
+ holder?: string;
94
+ /** Injected activity implementations — the CLI loads the real ones the same way `chant run` does (`loadActivities`); tests inject fakes so a round never shells out. */
95
+ activities: Map<string, ActivityFn>;
96
+ profiles: Record<string, ActivityProfile>;
97
+ now?: () => Date;
98
+ signal?: AbortSignal;
99
+ }
100
+
101
+ /**
102
+ * Run exactly one round: for every discovered ConvergeOp (filtered per
103
+ * `opts.env`), try its lease; tick the ones this holder wins. Returns one
104
+ * event per discovered op, in discovery order — deterministic, so tests can
105
+ * assert on it directly without depending on log output.
106
+ */
107
+ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<OperatorTickEvent[]> {
108
+ const holder = opts.holder ?? currentHolderId();
109
+ const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
110
+ const events: OperatorTickEvent[] = [];
111
+
112
+ for (const { config } of ops) {
113
+ const env = envOf(config) ?? "unknown";
114
+
115
+ let acquired: AcquireLeaseResult;
116
+ try {
117
+ acquired = await acquireLease(config.name, holder, {
118
+ cwd: opts.cwd,
119
+ ttlMs: opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
120
+ now: opts.now,
121
+ });
122
+ } catch (err) {
123
+ // Not lease contention — the acquire attempt itself failed (most
124
+ // likely a StaleLockError, ./lifecycle/git.ts's diagnosable
125
+ // stand-in for "a previous operator was killed mid-write"). Report it
126
+ // for this op and move on to the next; never abort the whole round.
127
+ const message =
128
+ err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
129
+ events.push({ kind: "lease-error", op: config.name, env, error: message });
130
+ continue;
131
+ }
132
+
133
+ if (!acquired.acquired) {
134
+ events.push({ kind: "skipped-lease-held", op: config.name, env, heldBy: acquired.heldBy?.holder });
135
+ continue;
136
+ }
137
+
138
+ const lease = acquired.lease as LeaseRecord;
139
+ try {
140
+ const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal);
141
+ const held = await stillHoldsLease(config.name, holder, lease.token, { cwd: opts.cwd });
142
+ events.push(held ? { kind: "ticked", op: config.name, env, result } : { kind: "fenced", op: config.name, env });
143
+ } catch (err) {
144
+ const message =
145
+ err instanceof OpRunFailure
146
+ ? `Op "${config.name}" failed — see its ledger record for step-level detail`
147
+ : err instanceof LocalGateUnsupportedError
148
+ ? err.message
149
+ : err instanceof Error
150
+ ? err.message
151
+ : String(err);
152
+ events.push({ kind: "tick-failed", op: config.name, env, error: message });
153
+ }
154
+ }
155
+
156
+ return events;
157
+ }
158
+
159
+ /** Abortable sleep — resolves early (without throwing) if `signal` fires mid-wait, so the operator loop can stop promptly on Ctrl-C rather than finishing out a long interval. */
160
+ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
161
+ return new Promise((resolve) => {
162
+ if (signal?.aborted) return resolve();
163
+ const timer = setTimeout(() => {
164
+ signal?.removeEventListener("abort", onAbort);
165
+ resolve();
166
+ }, ms);
167
+ const onAbort = () => {
168
+ clearTimeout(timer);
169
+ resolve();
170
+ };
171
+ signal?.addEventListener("abort", onAbort, { once: true });
172
+ });
173
+ }
174
+
175
+ export interface OperatorLoopOptions extends OperatorRoundOptions {
176
+ /** @default DEFAULT_OPERATOR_INTERVAL_MS */
177
+ intervalMs?: number;
178
+ /** Called after every round completes — the CLI's one-line-per-tick log lives here, not inside the loop itself, so a test can drive rounds without capturing stdout. */
179
+ onRound?: (events: OperatorTickEvent[]) => void;
180
+ }
181
+
182
+ /**
183
+ * Run rounds forever, sleeping `intervalMs` between them, until
184
+ * `opts.signal` aborts. The daemon is a convenience, never a requirement
185
+ * (issue: "if it dies, nothing breaks and nothing is lost") — this function
186
+ * is exactly `while (!aborted) { round(); sleep(); }`, nothing durable lives
187
+ * in its own memory that a restart would need to recover.
188
+ */
189
+ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<void> {
190
+ const holder = opts.holder ?? currentHolderId();
191
+ while (!opts.signal?.aborted) {
192
+ const events = await runOperatorRound({ ...opts, holder });
193
+ opts.onRound?.(events);
194
+ if (opts.signal?.aborted) break;
195
+ await sleepAbortable(opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS, opts.signal);
196
+ }
197
+ }
198
+
199
+ /** Render one round's events as `chant operator`'s log line shape — one line per ticked/skipped/failed op, reusing each ticked op's own `convergeTick` log line (`OpRunResult`'s outcome attribute doesn't carry it, so this reads the tick's step record's `Remediated`... actually the human-readable summary comes from the ledger, not the run result — see `../cli/handlers/operator.ts` for where `chant operator status` reads it back). This formats what's available from the round itself: which op, which env, and whether it ticked, skipped, or failed. */
200
+ export function formatRoundLine(event: OperatorTickEvent): string {
201
+ switch (event.kind) {
202
+ case "ticked":
203
+ return `operator: ${event.op}@${event.env} ticked=1 ok=${event.result.ok}`;
204
+ case "skipped-lease-held":
205
+ return `operator: ${event.op}@${event.env} skipped=1(lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
206
+ case "tick-failed":
207
+ return `operator: ${event.op}@${event.env} failed=1 error="${event.error}"`;
208
+ case "fenced":
209
+ return `operator: ${event.op}@${event.env} fenced=1(lease lost mid-tick — ledger record still written)`;
210
+ case "lease-error":
211
+ return `operator: ${event.op}@${event.env} error=1(lease acquire failed — ${event.error})`;
212
+ }
213
+ }