@intentius/chant 0.122.0 → 0.123.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 (156) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/override-approve.d.ts +17 -0
  7. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  8. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/gate-decision.d.ts +157 -0
  13. package/dist/gate-decision.d.ts.map +1 -0
  14. package/dist/lifecycle/gate-applied.d.ts +75 -0
  15. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-audit.d.ts +145 -0
  17. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  19. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/override-ledger.d.ts +34 -0
  23. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  24. package/dist/lifecycle/rollback.d.ts.map +1 -1
  25. package/dist/lifecycle/work-item-id.d.ts +22 -0
  26. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  27. package/dist/lifecycle/work-lease.d.ts +2 -2
  28. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  29. package/dist/lint/post-synth.d.ts +20 -1
  30. package/dist/lint/post-synth.d.ts.map +1 -1
  31. package/dist/notify-verify.d.ts +62 -0
  32. package/dist/notify-verify.d.ts.map +1 -0
  33. package/dist/notify.d.ts +202 -0
  34. package/dist/notify.d.ts.map +1 -0
  35. package/dist/op/activities/activity-contracts.d.ts +4 -4
  36. package/dist/op/activities/gh-host.d.ts +37 -0
  37. package/dist/op/activities/gh-host.d.ts.map +1 -0
  38. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  39. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  40. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  41. package/dist/op/activities/reconcile.d.ts +74 -76
  42. package/dist/op/activities/reconcile.d.ts.map +1 -1
  43. package/dist/op/gate-approval.d.ts +17 -1
  44. package/dist/op/gate-approval.d.ts.map +1 -1
  45. package/dist/op/gate-name.d.ts +8 -1
  46. package/dist/op/gate-name.d.ts.map +1 -1
  47. package/dist/op/gate-resume.d.ts +4 -2
  48. package/dist/op/gate-resume.d.ts.map +1 -1
  49. package/dist/op/gate-review.d.ts +2 -1
  50. package/dist/op/gate-review.d.ts.map +1 -1
  51. package/dist/op/gate.d.ts +43 -6
  52. package/dist/op/gate.d.ts.map +1 -1
  53. package/dist/op/index.d.ts +8 -6
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/op-waves-notify.d.ts +31 -0
  56. package/dist/op/op-waves-notify.d.ts.map +1 -0
  57. package/dist/op/op-waves-run.d.ts +91 -16
  58. package/dist/op/op-waves-run.d.ts.map +1 -1
  59. package/dist/op/op-waves.d.ts +99 -2
  60. package/dist/op/op-waves.d.ts.map +1 -1
  61. package/dist/op/operator.d.ts +15 -0
  62. package/dist/op/operator.d.ts.map +1 -1
  63. package/dist/op/work-lease-decl.d.ts.map +1 -1
  64. package/dist/op/work-lease-run.d.ts.map +1 -1
  65. package/dist/override-gate.d.ts +224 -0
  66. package/dist/override-gate.d.ts.map +1 -0
  67. package/dist/pr-forge.d.ts +198 -19
  68. package/dist/pr-forge.d.ts.map +1 -1
  69. package/dist/workspace/status-gates.d.ts +1 -0
  70. package/dist/workspace/status-gates.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  72. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  73. package/dist/workspace/trust/policy.d.ts +2 -41
  74. package/dist/workspace/trust/policy.d.ts.map +1 -1
  75. package/dist/workspace/trust/seal.d.ts +2 -27
  76. package/dist/workspace/trust/seal.d.ts.map +1 -1
  77. package/dist/workspace/trust/trust-files.d.ts +57 -0
  78. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  79. package/dist/workspace/work-cli.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/cli/commands/build.ts +1 -1
  82. package/src/cli/commands/lint.ts +5 -2
  83. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  84. package/src/cli/handlers/gate-resume.ts +3 -2
  85. package/src/cli/handlers/operator.test.ts +12 -1
  86. package/src/cli/handlers/operator.ts +25 -13
  87. package/src/cli/handlers/override-approve.test.ts +87 -0
  88. package/src/cli/handlers/override-approve.ts +103 -0
  89. package/src/cli/handlers/run-wave.ts +10 -2
  90. package/src/cli/main.test.ts +8 -0
  91. package/src/cli/main.ts +15 -0
  92. package/src/cli/registry.ts +4 -0
  93. package/src/gate-decision-bundle.test.ts +56 -0
  94. package/src/gate-decision.test.ts +237 -0
  95. package/src/gate-decision.ts +321 -0
  96. package/src/lifecycle/gate-applied.ts +115 -0
  97. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  98. package/src/lifecycle/gate-audit.test.ts +134 -0
  99. package/src/lifecycle/gate-audit.ts +438 -0
  100. package/src/lifecycle/gate-ledger-select.ts +113 -0
  101. package/src/lifecycle/gate-ledger.test.ts +22 -0
  102. package/src/lifecycle/gate-ledger.ts +9 -104
  103. package/src/lifecycle/override-ledger.test.ts +63 -0
  104. package/src/lifecycle/override-ledger.ts +69 -0
  105. package/src/lifecycle/rollback.ts +5 -1
  106. package/src/lifecycle/work-item-id.test.ts +71 -0
  107. package/src/lifecycle/work-item-id.ts +35 -0
  108. package/src/lifecycle/work-lease.ts +4 -5
  109. package/src/lint/post-synth.test.ts +29 -0
  110. package/src/lint/post-synth.ts +21 -2
  111. package/src/notify-bundle.test.ts +50 -0
  112. package/src/notify-verify.test.ts +59 -0
  113. package/src/notify-verify.ts +93 -0
  114. package/src/notify.schema.json +72 -0
  115. package/src/notify.test.ts +279 -0
  116. package/src/notify.ts +398 -0
  117. package/src/op/activities/gh-host.test.ts +130 -0
  118. package/src/op/activities/gh-host.ts +79 -0
  119. package/src/op/activities/lexicon-upgrade.ts +22 -1
  120. package/src/op/activities/propose-upgrade.ts +5 -1
  121. package/src/op/activities/reconcile.test.ts +284 -280
  122. package/src/op/activities/reconcile.ts +128 -131
  123. package/src/op/gate-approval.test.ts +175 -7
  124. package/src/op/gate-approval.ts +27 -1
  125. package/src/op/gate-name.ts +10 -2
  126. package/src/op/gate-resume.ts +6 -3
  127. package/src/op/gate-review.ts +4 -7
  128. package/src/op/gate.ts +117 -16
  129. package/src/op/index.ts +20 -5
  130. package/src/op/op-waves-notify.test.ts +127 -0
  131. package/src/op/op-waves-notify.ts +101 -0
  132. package/src/op/op-waves-run.ts +527 -104
  133. package/src/op/op-waves.test.ts +383 -3
  134. package/src/op/op-waves.ts +187 -6
  135. package/src/op/operator.test.ts +29 -0
  136. package/src/op/operator.ts +27 -2
  137. package/src/op/run-context.test.ts +2 -2
  138. package/src/op/work-lease-decl.ts +4 -4
  139. package/src/op/work-lease-run.test.ts +5 -0
  140. package/src/op/work-lease-run.ts +4 -3
  141. package/src/override-gate-bundle.test.ts +47 -0
  142. package/src/override-gate.test.ts +239 -0
  143. package/src/override-gate.ts +395 -0
  144. package/src/pr-forge-bundle.test.ts +40 -0
  145. package/src/pr-forge.test.ts +303 -0
  146. package/src/pr-forge.ts +502 -47
  147. package/src/workspace/records-write-contract.test.ts +3 -2
  148. package/src/workspace/status-gates.ts +7 -4
  149. package/src/workspace/trust/gate-seal.test.ts +156 -0
  150. package/src/workspace/trust/gate-seal.ts +254 -0
  151. package/src/workspace/trust/policy.ts +10 -165
  152. package/src/workspace/trust/seal.ts +19 -34
  153. package/src/workspace/trust/trust-files.test.ts +50 -0
  154. package/src/workspace/trust/trust-files.ts +245 -0
  155. package/src/workspace/work-cli.ts +5 -3
  156. package/src/workspace/work-history.test.ts +7 -0
@@ -33,9 +33,28 @@
33
33
  * against the signers file at base) or `pr-review` (also the merged pull
34
34
  * request's review of its head, by a writer other than its author, when the
35
35
  * head planned the same digest). See `./gate-review.ts`.
36
+ *
37
+ * ## Spent approvals (#3784)
38
+ *
39
+ * With `approvals: { spend: true }` (read at base, like the policy), a wave
40
+ * records each approval it applies under in `_gates/<name>/applied.jsonl` on
41
+ * `chant/lifecycle`, as terragucci's wave gate does, and that approval is
42
+ * spent: the same digest needs a new approval to apply again.
43
+ *
44
+ * ## Wave-level commands and hooks (#3762)
45
+ *
46
+ * A product whose tool plans a whole wave in one call (Terragrunt's `run
47
+ * --all`, an apply by rows) gives `planWave` and `applyWave` in place of the
48
+ * per-run `plan` and `apply`: one call plans every run and lists each run's
49
+ * plan, and the wave's digest and gate come from that list as from per-run
50
+ * plans. Stage hooks (`hooks`) run commands before and after the plan and the
51
+ * apply; like the policy they are read from the spec at the base commit, and
52
+ * one whose `onFailure` is `approve` holds the wave for an approval whatever
53
+ * its policy says.
36
54
  */
37
55
 
38
56
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
57
+ import { notifyConfigProblem, type NotifyConfig } from "../notify";
39
58
 
40
59
  /** When a wave waits for an approval. */
41
60
  export type OpWaveGatePolicy =
@@ -59,6 +78,41 @@ export type GateApprovalSource =
59
78
 
60
79
  export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "pr-review", "sealed"];
61
80
 
81
+ /** What a failing hook does: fail the wave, or hold it for an approval of its set digest. */
82
+ export type OpWaveHookFailure = "fail" | "approve";
83
+
84
+ export const OP_WAVE_HOOK_FAILURES: readonly OpWaveHookFailure[] = ["fail", "approve"];
85
+
86
+ /** One stage hook (#3762): a command run before or after a wave's plan or apply. */
87
+ export interface OpWaveHook {
88
+ /** The hook's name in the log and the result. Default: its command. */
89
+ name?: string;
90
+ /**
91
+ * Argv template. `{op}`, `{wave}` and `{runs}` (the runs file, see
92
+ * `opWaveRunsPath`) are filled in; a `perRun` hook also gets `{target}`
93
+ * and, once the run is planned, `{plan}`.
94
+ */
95
+ command: string[];
96
+ /** Default `"fail"`. `"approve"` holds the wave for an approval of its set digest whatever its policy says. Not allowed after apply. */
97
+ onFailure?: OpWaveHookFailure;
98
+ /** Run once per run of the wave, rather than once for the wave. */
99
+ perRun?: boolean;
100
+ }
101
+
102
+ /** The stages a hook runs around. */
103
+ export type OpWaveHookStage = "plan" | "apply";
104
+
105
+ /** When a hook runs. */
106
+ export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "after-apply";
107
+
108
+ export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "before-apply", "after-apply"];
109
+
110
+ /** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
111
+ export interface OpWaveHooks {
112
+ before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
113
+ after?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
114
+ }
115
+
62
116
  /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
63
117
  export interface OpWaveRun {
64
118
  /** What the run applies to. Unique within its wave; it names the run in the set digest. */
@@ -94,6 +148,8 @@ export interface OpWave {
94
148
  variables?: Record<string, string>;
95
149
  /** Steps this wave's jobs run after the checkout, as `ScheduledOpSpec.setup`. */
96
150
  setup?: OpSetupStep[];
151
+ /** This wave's stage hooks (#3762), run after the spec's own. Read at the base commit. */
152
+ hooks?: OpWaveHooks;
97
153
  }
98
154
 
99
155
  /**
@@ -119,11 +175,29 @@ export interface OpWavesSpec {
119
175
  * Plan one run and write its plan file to `{plan}`: JSON `{ "planDigest":
120
176
  * string, "destructive"?: boolean, "empty"?: boolean }`. `destructive` is
121
177
  * what `on-destructive` waits on; a wave whose runs are all `empty` never
122
- * waits.
178
+ * waits. Give this or `planWave`.
123
179
  */
124
- plan: string[];
180
+ plan?: string[];
125
181
  /** Apply one run whose plan is at `{plan}`. Default `chant run {op} --env {target}`. */
126
182
  apply?: string[];
183
+ /**
184
+ * Plan every run of a wave in one call (#3762), in place of `plan`. `{runs}`
185
+ * is a file chant writes, `{ "wave", "number", "runs": [{ "target", "op" }] }`;
186
+ * the command writes `{plans}`: `{ "runs": [{ "target", "planDigest",
187
+ * "destructive"?, "empty"? }] }` (or the array alone), one entry per run.
188
+ */
189
+ planWave?: string[];
190
+ /**
191
+ * Apply a wave's approved set in one call (#3762), in place of `apply`.
192
+ * `{runs}` lists the runs to apply with the plan digest each was approved
193
+ * at; `{plans}` is the wave plan's file. A non-zero exit fails every run in
194
+ * the set.
195
+ */
196
+ applyWave?: string[];
197
+ /** Stage hooks for every wave (#3762), run before each wave's own. Read at the base commit. */
198
+ hooks?: OpWaveHooks;
199
+ /** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
200
+ approvals?: OpWavesApprovals;
127
201
  waves: OpWave[];
128
202
  /**
129
203
  * A scheduled job that starts again a wave job whose approval has arrived
@@ -133,6 +207,27 @@ export interface OpWavesSpec {
133
207
  * followed by `chant approve --resume` or a re-run by hand.
134
208
  */
135
209
  resume?: OpWavesResume;
210
+ /**
211
+ * Tell people when a wave waits for an approval, a share job refuses a run
212
+ * whose plan moved, or a wave fails (#3761): the environment variables
213
+ * holding a Slack or Teams incoming webhook's URL, or a generic webhook's
214
+ * URL and the key its `chant.notify/v1` events are signed with. A failed
215
+ * delivery is logged and never changes the job's exit code. See
216
+ * `../notify.ts`.
217
+ */
218
+ notify?: NotifyConfig;
219
+ }
220
+
221
+ /** What happens to an approval once a wave applies under it (#3784). */
222
+ export interface OpWavesApprovals {
223
+ /**
224
+ * Spend it. Before a wave applies under an approval it records that in
225
+ * `_gates/<name>/applied.jsonl` on `chant/lifecycle`, and from then on the
226
+ * approval counts for nothing: applying the same digest again, by a re-run
227
+ * or a revert and re-merge, waits for a new approval. Default false: an
228
+ * approval of a digest passes that digest as often as it is planned.
229
+ */
230
+ spend?: boolean;
136
231
  }
137
232
 
138
233
  /** The scheduled resume job of an Op waves pipeline (#3683). */
@@ -184,16 +279,50 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
184
279
  const where = `Op waves "${spec.name}"`;
185
280
  if (!NAME.test(spec.name)) throw new Error(`${where}: the name must be lowercase letters, digits, "-" and "_"`);
186
281
  if (!spec.op) throw new Error(`${where} names no \`op\`. Name the Op a run runs when it names none.`);
187
- if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
188
- throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests.`);
282
+ if (spec.plan !== undefined && spec.planWave !== undefined) {
283
+ throw new Error(`${where} has both \`plan\` and \`planWave\`. A wave is planned one run at a time or all at once; give one.`);
189
284
  }
190
- if (!spec.plan.some((part) => part.includes("{plan}"))) {
191
- throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
285
+ if (spec.apply !== undefined && spec.applyWave !== undefined) {
286
+ throw new Error(`${where} has both \`apply\` and \`applyWave\`. A wave is applied one run at a time or all at once; give one.`);
192
287
  }
288
+ if (spec.planWave !== undefined) {
289
+ assertArgv(where, "planWave", spec.planWave);
290
+ if (!spec.planWave.some((part) => part.includes("{plans}"))) {
291
+ throw new Error(`${where}: the \`planWave\` command never names {plans}, the file it writes each run's plan digest to.`);
292
+ }
293
+ } else {
294
+ if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
295
+ throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests; give \`plan\` or \`planWave\`.`);
296
+ }
297
+ assertArgv(where, "plan", spec.plan);
298
+ if (!spec.plan.some((part) => part.includes("{plan}"))) {
299
+ throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
300
+ }
301
+ }
302
+ if (spec.apply !== undefined) assertArgv(where, "apply", spec.apply);
303
+ if (spec.applyWave !== undefined) {
304
+ assertArgv(where, "applyWave", spec.applyWave);
305
+ if (!spec.applyWave.some((part) => part.includes("{runs}"))) {
306
+ throw new Error(`${where}: the \`applyWave\` command never names {runs}, the file listing the approved runs it applies.`);
307
+ }
308
+ }
309
+ assertOpWaveHooks(where, spec.hooks);
193
310
  if (spec.waves.length === 0) throw new Error(`${where} has no waves.`);
194
311
  if (spec.resume !== undefined && (typeof spec.resume.schedule !== "string" || spec.resume.schedule.trim().split(/\s+/).length !== 5)) {
195
312
  throw new Error(`${where}: resume.schedule must be a five-field cron expression, such as "*/10 * * * *".`);
196
313
  }
314
+ if (spec.approvals !== undefined) {
315
+ const a = spec.approvals as unknown;
316
+ if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error(`${where}: \`approvals\` must be { "spend"?: true | false }.`);
317
+ for (const [key, value] of Object.entries(a as Record<string, unknown>)) {
318
+ if (key !== "spend") throw new Error(`${where}: approvals.${key} is not a setting; the one there is approvals.spend.`);
319
+ if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
320
+ }
321
+ }
322
+ if (spec.notify !== undefined) {
323
+ const problem = notifyConfigProblem(spec.notify, "notify");
324
+ if (problem) throw new Error(`${where}: ${problem}`);
325
+ }
197
326
  const names = new Set<string>();
198
327
  for (const wave of spec.waves) {
199
328
  if (!NAME.test(wave.name)) {
@@ -207,6 +336,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
207
336
  if (wave.approval !== undefined && !GATE_APPROVAL_SOURCES.includes(wave.approval)) {
208
337
  throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
209
338
  }
339
+ assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
210
340
  if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
211
341
  const targets = new Set<string>();
212
342
  for (const run of wave.runs) {
@@ -224,6 +354,57 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
224
354
  }
225
355
  }
226
356
 
357
+ function assertArgv(where: string, field: string, argv: unknown): void {
358
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((part) => typeof part === "string") || argv[0] === "") {
359
+ throw new Error(`${where}: \`${field}\` must be a command as argv, a non-empty array of strings.`);
360
+ }
361
+ }
362
+
363
+ /** Refuse hooks no runner could run, naming what is wrong (#3762). */
364
+ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined): void {
365
+ if (hooks === undefined) return;
366
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
367
+ throw new Error(`${where}: \`hooks\` must be { "before"?: { "plan"?, "apply"? }, "after"?: { "plan"?, "apply"? } }.`);
368
+ }
369
+ for (const [side, stages] of Object.entries(hooks as Record<string, unknown>)) {
370
+ if (side !== "before" && side !== "after") throw new Error(`${where}: hooks.${side} is not a moment; use hooks.before or hooks.after.`);
371
+ if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
372
+ for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
373
+ const at = `hooks.${side}.${stage}`;
374
+ if (stage !== "plan" && stage !== "apply") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply.`);
375
+ if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
376
+ list.forEach((hook: OpWaveHook, i: number) => {
377
+ const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
378
+ if (!hook || typeof hook !== "object") throw new Error(`${where}: ${which} must be { "command": [...], "onFailure"?, "perRun"? }.`);
379
+ assertArgv(where, `${which}.command`, hook.command);
380
+ if (hook.onFailure !== undefined && !OP_WAVE_HOOK_FAILURES.includes(hook.onFailure)) {
381
+ throw new Error(`${where}: ${which} has onFailure "${hook.onFailure}"; use ${OP_WAVE_HOOK_FAILURES.join(" or ")}.`);
382
+ }
383
+ if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
384
+ throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
385
+ }
386
+ if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
387
+ if (!hook.perRun) {
388
+ const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
389
+ if (named) throw new Error(`${where}: ${which} runs once for the wave, so it has no {target} or {plan} ("${named}"). Set "perRun": true, or read the runs from {runs}.`);
390
+ } else if (side === "before" && stage === "plan" && hook.command.some((part) => part.includes("{plan}"))) {
391
+ throw new Error(`${where}: ${which} runs before the plan, so it has no {plan} yet.`);
392
+ }
393
+ });
394
+ }
395
+ }
396
+ }
397
+
398
+ /** The hooks of each moment for wave `name`: the spec's, then the wave's own. */
399
+ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): Record<OpWaveHookMoment, OpWaveHook[]> {
400
+ const wave = spec.waves.find((w) => w.name === name);
401
+ const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
402
+ const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
403
+ return hooks?.[side]?.[stage] ?? [];
404
+ };
405
+ return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
406
+ }
407
+
227
408
  /**
228
409
  * The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
229
410
  * by target, cut into contiguous slices whose sizes differ by at most one.
@@ -429,6 +429,35 @@ describe("runOperatorForever — interval/timer behavior", () => {
429
429
  });
430
430
  }, 10_000);
431
431
 
432
+ test("releases the Ops' leases when it stops, so a restarted operator ticks at once (#2879)", async () => {
433
+ await withTestDir(async (dir) => {
434
+ await initRepo(dir);
435
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
436
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
437
+
438
+ const controller = new AbortController();
439
+ const rounds: unknown[] = [];
440
+ const loop = runOperatorForever({
441
+ cwd: dir,
442
+ holder: "op-a",
443
+ intervalMs: 40,
444
+ activities,
445
+ profiles: PROFILES,
446
+ signal: controller.signal,
447
+ onRound: () => rounds.push(1),
448
+ });
449
+ await waitForRoundCount(rounds, 1);
450
+ expect((await readLease("staging-converge", { cwd: dir })).record?.holder).toBe("op-a");
451
+
452
+ controller.abort();
453
+ await loop;
454
+ expect((await readLease("staging-converge", { cwd: dir })).record).toBeUndefined();
455
+
456
+ const events = await runOperatorRound({ cwd: dir, holder: "op-b", activities, profiles: PROFILES });
457
+ expect(events.map((e) => e.kind)).toEqual(["ticked"]);
458
+ });
459
+ }, 10_000);
460
+
432
461
  test("default interval is used when none is given", () => {
433
462
  expect(DEFAULT_OPERATOR_INTERVAL_MS).toBeGreaterThan(0);
434
463
  });
@@ -196,6 +196,17 @@ export function createBesideState(): BesideState {
196
196
  return { running: new Map(), started: new Map(), ended: [] };
197
197
  }
198
198
 
199
+ /**
200
+ * Release the per-Op leases a stopped operator still holds (#2879), best
201
+ * effort: a lease that is gone or taken over since is left alone, and one that
202
+ * can't be released expires on its TTL as before.
203
+ */
204
+ export async function releaseHeldLeases(held: Map<string, string>, holder: string, opts: { cwd?: string } = {}): Promise<void> {
205
+ const entries = [...held];
206
+ held.clear();
207
+ await Promise.allSettled(entries.map(([op, token]) => releaseLease(op, holder, token, opts)));
208
+ }
209
+
199
210
  /** Wait for every run in flight beside the turns to end. `chant operator --steward --once` does before it exits. */
200
211
  export async function waitForBesideRuns(state: BesideState): Promise<void> {
201
212
  await Promise.all([...state.running.values()].map((r) => r.handle.done));
@@ -263,6 +274,13 @@ export interface OperatorRoundOptions {
263
274
  * it returns.
264
275
  */
265
276
  besideState?: BesideState;
277
+ /**
278
+ * The per-Op leases this process holds after its ticks, Op name to the
279
+ * token of the newest acquire (#2879). A round records each lease it takes
280
+ * or renews here. `runOperatorForever` owns one and releases what is left when it stops, so
281
+ * a restarted operator does not skip its Ops until the old leases expire.
282
+ */
283
+ heldLeases?: Map<string, string>;
266
284
  /** How a run beside the turns is started. Default: `chant run <op>` in a process of its own ({@link spawnBesideRun}). */
267
285
  launchBeside?: BesideLauncher;
268
286
  /** The environment the steward operator was started for (`--env`), passed on to a run it starts beside its turns. */
@@ -540,6 +558,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
540
558
  }
541
559
 
542
560
  const lease = acquired.lease as LeaseRecord;
561
+ opts.heldLeases?.set(config.name, lease.token);
543
562
 
544
563
  // A local steward is one turn at a time (#2750): a hand run (`chant run
545
564
  // <op>`) holds this same lease for as long as it runs, so a round never
@@ -556,12 +575,14 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
556
575
  // catch above: report it and stop, rather than let it propagate and
557
576
  // leak the per-op lease this round just took.
558
577
  await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
578
+ opts.heldLeases?.delete(config.name);
559
579
  const message = err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
560
580
  events.push({ kind: "lease-error", op: stewardTurnLeaseName(steward.name), env, error: message });
561
581
  break;
562
582
  }
563
583
  if (!turn.acquired) {
564
584
  await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
585
+ opts.heldLeases?.delete(config.name);
565
586
  events.push({ kind: "turn-busy", op: config.name, env, steward: steward.name, heldBy: turn.heldBy?.holder });
566
587
  break;
567
588
  }
@@ -898,6 +919,8 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
898
919
  // The runs a steward starts beside its turns (#2861), for the life of the
899
920
  // loop. When the loop stops, so do they.
900
921
  const besideState = opts.besideState ?? createBesideState();
922
+ // The per-Op leases the rounds leave held (#2879), released when the loop stops.
923
+ const heldLeases = opts.heldLeases ?? new Map<string, string>();
901
924
  const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
902
925
  const subscribers = opts.subscribers ?? [];
903
926
 
@@ -906,13 +929,14 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
906
929
  if (subscribers.length === 0) {
907
930
  try {
908
931
  while (!opts.signal?.aborted) {
909
- const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
932
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
910
933
  opts.onRound?.(events);
911
934
  if (opts.signal?.aborted) break;
912
935
  await sleepAbortable(intervalMs, opts.signal);
913
936
  }
914
937
  } finally {
915
938
  await stopBesideRuns(besideState);
939
+ await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
916
940
  }
917
941
  return;
918
942
  }
@@ -965,7 +989,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
965
989
  while (!opts.signal?.aborted) {
966
990
  gate.roundStarted();
967
991
  const startedAt = Date.now();
968
- const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
992
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
969
993
  opts.onRound?.(events);
970
994
  if (opts.signal?.aborted) break;
971
995
 
@@ -980,6 +1004,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
980
1004
  } finally {
981
1005
  await closeAll();
982
1006
  await stopBesideRuns(besideState);
1007
+ await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
983
1008
  }
984
1009
  }
985
1010
 
@@ -8,7 +8,7 @@ import type { ActivityFn, ActivityProfile } from "./activity-registry";
8
8
  import { runOpLocally } from "./local-executor";
9
9
  import { memoryGateLedgerPort } from "./gate";
10
10
  import { currentOpRun, type OpRunContext } from "./run-context";
11
- import { gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
11
+ import { gateContextDigest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
12
12
  import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
13
13
 
14
14
  const PROFILES: Record<string, ActivityProfile> = {
@@ -81,7 +81,7 @@ describe("the Op run context (#2522)", () => {
81
81
  const permit = (mode: "log-only" | "enforce"): GateResolutionRecord => ({
82
82
  version: 1, op: "release", gate: "ship", resolvedBy: "release-bot", timestamp: "2026-09-05T11:00:00.000Z",
83
83
  approver: { kind: "agent" },
84
- policyDecision: { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] },
84
+ policyDecision: { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(undefined), decision: "allow", determining: ["ship-0"], errors: [] },
85
85
  });
86
86
 
87
87
  async function run(mode: "log-only" | "enforce") {
@@ -7,10 +7,11 @@
7
7
 
8
8
  import { isStepOutputRef } from "./step-output-ref";
9
9
  import { parseDuration } from "./duration";
10
+ import { WORK_ITEM_ID_PATTERN, workItemIdProblem } from "../lifecycle/work-item-id";
10
11
  import { WORK_LEASE_STEP_ID, type OpConfig, type PhaseDefinition } from "./types";
11
12
 
12
13
  /** A work item id a lease can be keyed by: the same pattern as `WORK_ITEM_ID_PATTERN` in `../lifecycle/work-lease.ts`. */
13
- export const WORK_LEASE_ITEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
14
+ export const WORK_LEASE_ITEM_PATTERN = WORK_ITEM_ID_PATTERN;
14
15
 
15
16
  /** The ids of the activity steps in an Op's main phases, in order. */
16
17
  function mainStepIds(phases: readonly PhaseDefinition[]): string[] {
@@ -42,9 +43,8 @@ export function workLeaseProblems(config: Pick<OpConfig, "name" | "phases" | "wo
42
43
  problems.push(`Op "${name}": step id "${WORK_LEASE_STEP_ID}" is reserved for the run's work lease; rename the step`);
43
44
  }
44
45
  const checkId = (id: unknown) => {
45
- if (typeof id !== "string" || !WORK_LEASE_ITEM_PATTERN.test(id) || id.includes("..")) {
46
- problems.push(`Op "${name}": workLease.item ${JSON.stringify(id)} can't key a work lease (letters, digits, ".", "_" and "-")`);
47
- }
46
+ const problem = workItemIdProblem(id);
47
+ if (problem) problems.push(`Op "${name}": workLease.item ${problem}`);
48
48
  };
49
49
  const item = spec.item;
50
50
  if (isStepOutputRef(item)) {
@@ -80,6 +80,11 @@ describe("the declaration", () => {
80
80
  ).toThrow(/scheduled, but its workLease names no item/);
81
81
  expect(declareSteward({ name: "box", ops: [dispatchOp({ workLease: {} })] }).ops).toHaveLength(1);
82
82
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "../x" } }))[0]).toMatch(/can't key a work lease/);
83
+ for (const item of ["upgrade-.", "x.lock", "x.LOCK", "a..b"]) {
84
+ expect(workLeaseProblems(dispatchOp({ workLease: { item } }))[0], item).toMatch(/can't key a work lease/);
85
+ }
86
+ expect(workLeaseProblems(dispatchOp({ workLease: { item: ["W-1", "upgrade-."] } }))).toHaveLength(1);
87
+ expect(() => Op(dispatchOp({ workLease: { item: "upgrade-." } }))).toThrow(/can't key a work lease/);
83
88
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1", ttl: "1s" } }))[0]).toMatch(/shorter than 3s/);
84
89
  expect(workLeaseProblems(dispatchOp({ workLease: { item: stepOutput("nope", "id") } }))[0]).toMatch(/references step "nope"/);
85
90
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1" } }, [step("x", {}, { id: WORK_LEASE_STEP_ID })]))[0]).toMatch(/reserved/);
@@ -64,7 +64,8 @@ import {
64
64
  releaseWorkLease,
65
65
  renewWorkLease,
66
66
  workLeaseRef,
67
- WORK_ITEM_ID_PATTERN,
67
+ isWorkItemId,
68
+ workItemIdProblem,
68
69
  type WorkLease,
69
70
  } from "../lifecycle/work-lease";
70
71
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
@@ -216,8 +217,8 @@ export class RunWorkLease {
216
217
  if (value === undefined || value === null || value === "") return [];
217
218
  const list = Array.isArray(value) ? value : [value];
218
219
  return list.filter((v) => v !== undefined && v !== null && v !== "").map((v) => {
219
- if (typeof v !== "string" || !WORK_ITEM_ID_PATTERN.test(v) || v.includes("..")) {
220
- throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease`);
220
+ if (!isWorkItemId(v)) {
221
+ throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease${typeof v === "string" ? ` (${workItemIdProblem(v)})` : ""}`);
221
222
  }
222
223
  return v;
223
224
  });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The override gate bundles small (#3760, ruling #3421), as the gate decision
3
+ * does: terragucci and SQL Yodeler decide overrides from one bundled file
4
+ * with no TypeScript toolchain. This bundles `@intentius/chant/override-gate`
5
+ * the way they would and reads esbuild's own list of what went in.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { build } from "esbuild";
10
+ import { describe, expect, test } from "vitest";
11
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
12
+
13
+ const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
14
+
15
+ describe("the override-gate bundle", () => {
16
+ test("holds the decision, the seal check and the digest, and no package or git ledger code", async () => {
17
+ const result = await build({
18
+ entryPoints: [join(REPO, "packages", "core", "src", "override-gate.ts")],
19
+ bundle: true,
20
+ platform: "node",
21
+ format: "esm",
22
+ write: false,
23
+ metafile: true,
24
+ outdir: "out",
25
+ logLevel: "silent",
26
+ });
27
+ const inputs = Object.keys(result.metafile.inputs);
28
+ for (const pattern of FORBIDDEN) {
29
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
30
+ }
31
+ expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
32
+ "packages/core/src/content-digest.ts",
33
+ "packages/core/src/declarable.ts",
34
+ "packages/core/src/effect-receipt.ts",
35
+ "packages/core/src/intrinsic.ts",
36
+ "packages/core/src/lifecycle/gate-ledger-select.ts",
37
+ "packages/core/src/lifecycle/plan-digest.ts",
38
+ "packages/core/src/op/gate-name.ts",
39
+ "packages/core/src/override-gate.ts",
40
+ "packages/core/src/workspace/trust/gate-seal.ts",
41
+ "packages/core/src/workspace/trust/trust-files.ts",
42
+ ]);
43
+ // About 16 KB today.
44
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
45
+ expect(bytes).toBeLessThan(40 * 1024);
46
+ });
47
+ });