@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
@@ -26,7 +26,9 @@
26
26
  * on the resolution with the policy's version, and a run counts a recorded
27
27
  * permit only while the gate still declares that same version. Editing the
28
28
  * policy invalidates every permit recorded under the old one, the way a new
29
- * plan invalidates every approval recorded for the old plan (#2300).
29
+ * plan invalidates every approval recorded for the old plan (#2300). The
30
+ * decision also records the digest of the context it was asked about, and a
31
+ * permit counts only while the run resolves that same context (#3774).
30
32
  *
31
33
  * The trust boundary is unchanged from `../lifecycle/gate-ledger.ts`: a
32
34
  * resolution is a local fact, not a signed one, and roles on it are what the
@@ -134,6 +136,14 @@ export interface GatePolicyDecision extends GatePolicyAnswer {
134
136
  * an allow recorded under `log-only` never binds after a switch.
135
137
  */
136
138
  mode: GateApprovalMode;
139
+ /**
140
+ * {@link gateContextDigest} of the resolved context the policy was asked
141
+ * about (#3774). A permit passes a gate only while the run's resolved
142
+ * context has this same digest, so a permit given for `risk: "low"` does
143
+ * not pass a run that now resolves `risk: "high"`. A decision recorded
144
+ * before #3774 has none and does not pass an enforced gate.
145
+ */
146
+ contextDigest?: string;
137
147
  }
138
148
 
139
149
  /** What a lexicon's `gate-policy` module exports. */
@@ -146,6 +156,22 @@ export function gatePolicyVersion(text: string): string {
146
156
  return `sha256:${createHash("sha256").update(text).digest("hex")}`;
147
157
  }
148
158
 
159
+ /**
160
+ * The digest of a gate's resolved approval context (#3774): `sha256:` and the
161
+ * hex digest of its JSON with keys sorted at every level. No context and an
162
+ * empty one digest the same.
163
+ */
164
+ export function gateContextDigest(context: Record<string, unknown> | undefined): string {
165
+ return `sha256:${createHash("sha256").update(JSON.stringify(context ?? {}, sortedKeys)).digest("hex")}`;
166
+ }
167
+
168
+ function sortedKeys(_key: string, value: unknown): unknown {
169
+ if (value && typeof value === "object" && !Array.isArray(value)) {
170
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
171
+ }
172
+ return value;
173
+ }
174
+
149
175
  export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
150
176
  if (typeof value !== "object" || value === null) return false;
151
177
  const v = value as Record<string, unknown>;
@@ -37,6 +37,13 @@ export const FAN_OUT_GATE_OP = "fan-out";
37
37
  */
38
38
  export const WORKSPACE_UPGRADE_GATE_OP = "workspace-upgrade";
39
39
 
40
+ /**
41
+ * The op name the override gate records denials and overrides under (#3760):
42
+ * `_gates/policy-override.jsonl`, gate the subject a policy denied. Like a
43
+ * fan-out it has no `*.op.ts`, and an override is a person's, never a model's.
44
+ */
45
+ export const OVERRIDE_GATE_OP = "policy-override";
46
+
40
47
  /**
41
48
  * The pull-request loop records its gate under `pr-<number>`, or
42
49
  * `pr-<number>-<member>` in a workspace member (`prOp` in `pr-loop.ts`), and
@@ -47,11 +54,12 @@ const PR_GATE_OP = /^pr-\d+(?:-[a-z0-9][a-z0-9-]*)?$/;
47
54
  /**
48
55
  * Whether gates recorded under this op name are never resolved over MCP or ACP
49
56
  * (chant#3485, chant#3513). These are the gate-only names MCP cannot discover:
50
- * `fan-out`, `workspace-upgrade` and the pull-request gate `pr-<number>`. With
57
+ * `fan-out`, `workspace-upgrade`, `policy-override` (#3760) and the
58
+ * pull-request gate `pr-<number>`. With
51
59
  * no Op config to read `neverOverMcp` from, the pending record carries it.
52
60
  */
53
61
  export function isNeverOverMcpGateOp(op: string): boolean {
54
- return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || PR_GATE_OP.test(op);
62
+ return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || op === OVERRIDE_GATE_OP || PR_GATE_OP.test(op);
55
63
  }
56
64
 
57
65
  /** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
@@ -35,7 +35,7 @@
35
35
  import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
36
36
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
37
37
  import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
38
- import { tallyGateApprovals } from "./gate";
38
+ import { humanQuorumSince, tallyGateApprovals } from "./gate";
39
39
 
40
40
  /** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
41
41
  export interface GateRunLocator {
@@ -237,18 +237,21 @@ export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOpti
237
237
 
238
238
  /**
239
239
  * The resolution that answers `standing`, read the way a run decides the gate
240
- * (the plan, the environment, the quorum), or undefined. A workspace's
240
+ * (the plan, the environment, the quorum), or undefined. `pending` is the
241
+ * gate ledger's pending facts, which place the human quorum's anchor (#3206);
242
+ * without them it is `standing` itself. A workspace's
241
243
  * `identity.gates` rule is not applied here: the resumed job applies it, and
242
244
  * waits again when the approval does not count.
243
245
  */
244
246
  export function resolutionAnswering(
245
247
  resolutions: readonly GateResolutionRecord[],
246
248
  standing: PendingGateRecord,
249
+ pending: readonly PendingGateRecord[] = [standing],
247
250
  ): GateResolutionRecord | undefined {
248
251
  const env = standing.environment;
249
252
  const mine = resolutions.filter((r) => env === undefined || r.environment === env);
250
253
  if (standing.approval) {
251
- const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval);
254
+ const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval, humanQuorumSince(pending, standing));
252
255
  if (tally.permit) return tally.permit;
253
256
  return tally.counted.length >= tally.need ? tally.counted[tally.counted.length - 1] : undefined;
254
257
  }
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { readFileSync } from "node:fs";
33
- import { checkGateSeal } from "../workspace/trust/seal";
33
+ import { gateSealRefusal } from "../workspace/trust/gate-seal";
34
34
  import { readTrustPolicy, type TrustPolicy } from "../workspace/trust/policy";
35
35
  import { gitRevisionSource } from "../workspace/record-source";
36
36
  import { forgePrincipalOf, ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
@@ -48,18 +48,15 @@ export function trustPolicyAt(cwd: string, base: string): TrustPolicy {
48
48
 
49
49
  /**
50
50
  * The rule a `sealed` gate judges approvals by: a seal that verifies for the
51
- * approver against `policy`, the signers at base. With no signers file at
51
+ * approver against `policy`, the signers at base, checked with node:crypto
52
+ * (#3785), so an apply job needs no ssh-keygen. With no signers file at
52
53
  * base nothing verifies, so nothing passes the gate.
53
54
  */
54
55
  export function sealedApprovalRule(gate: string, policy: TrustPolicy): GateApprovalRule {
55
56
  return {
56
57
  requirement: { gate, class: null },
57
58
  refuses(approval: GateResolutionRecord): string | null {
58
- if (!policy.active) {
59
- return `the gate is sealed, and there is no signers file (${policy.signersPath}) at base ${policy.base?.slice(0, 12) ?? ""}`.trim();
60
- }
61
- const check = checkGateSeal(policy, approval);
62
- return check.attested === true ? null : check.message;
59
+ return gateSealRefusal(policy, approval);
63
60
  },
64
61
  };
65
62
  }
package/src/op/gate.ts CHANGED
@@ -48,9 +48,10 @@ import {
48
48
  } from "../lifecycle/gate-ledger";
49
49
  import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
50
50
  import { isModelAuthored } from "../lifecycle/gate-origin";
51
+ import { appliedGatePath, isApprovalSpent, parseAppliedGateLines, type AppliedGateRecord } from "../lifecycle/gate-applied";
51
52
  import { sortedJsonReplacer } from "../utils";
52
- import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
53
- import { pushLifecycleReport, requireLifecycleLedger } from "../lifecycle/git";
53
+ import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from "./gate-approval";
54
+ import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
54
55
  import { parseDuration } from "./duration";
55
56
  import { resolveGateRunLocator } from "./gate-resume";
56
57
 
@@ -92,6 +93,10 @@ export interface GateLedgerPort {
92
93
  * Absent, every approval is judged as before.
93
94
  */
94
95
  approvalRule?(gate: string): Promise<GateApprovalRule | null>;
96
+ /** The approvals a wave applied under (#3784), from `_gates/<op>/applied.jsonl`. Absent on a port that never spends approvals. */
97
+ readApplied?(op: string): Promise<AppliedGateRecord[]>;
98
+ /** Record that a wave is about to apply under an approval (#3784), which spends it. */
99
+ appendApplied?(record: AppliedGateRecord): Promise<{ pushed: boolean; pushWarning?: string }>;
95
100
  }
96
101
 
97
102
  /** A workspace's rule for which approvals of a gate count (#3163): a signed approval, from a class when it names one. */
@@ -140,20 +145,56 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
140
145
  const { record } = await appendPendingGate(input, opts);
141
146
  return { record, ...(await pushLifecycleReport(opts)) };
142
147
  },
148
+ async readApplied(op) {
149
+ await requireLifecycleLedger(opts);
150
+ const { dir, file } = appliedGatePath(op);
151
+ const text = await readBlobFromPath(dir, file, opts);
152
+ return text ? parseAppliedGateLines(text) : [];
153
+ },
154
+ async appendApplied(record) {
155
+ const { dir, file } = appliedGatePath(record.op);
156
+ const line = JSON.stringify(record, sortedJsonReplacer);
157
+ let lastErr: unknown;
158
+ // The same append-and-retry as a ledger line: another writer to the branch is the ordinary case.
159
+ for (let attempt = 1; attempt <= 5; attempt++) {
160
+ try {
161
+ const prior = await readPathSha(dir, file, opts);
162
+ const existing = prior ? await readBlobBySha(prior, opts) : null;
163
+ await writeBlobToPath(dir, file, existing ? `${existing.replace(/\n$/, "")}\n${line}\n` : `${line}\n`, `Applied under ${record.gate}`, {
164
+ ...opts,
165
+ expectPriorPathSha: prior,
166
+ });
167
+ return pushLifecycleReport(opts);
168
+ } catch (err) {
169
+ if (!(err instanceof RefCASConflictError)) throw err;
170
+ lastErr = err;
171
+ }
172
+ }
173
+ throw lastErr;
174
+ },
143
175
  };
144
176
  }
145
177
 
146
178
  /** A gate ledger held in memory — what a test, or a caller that has already read the ledger, hands {@link evaluateGate}. */
147
179
  export function memoryGateLedgerPort(
148
- seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[] } = {},
149
- ): GateLedgerPort & { appended: PendingGateRecord[]; resolved: GateResolutionRecord[] } {
180
+ seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[]; applied?: AppliedGateRecord[] } = {},
181
+ ): GateLedgerPort & { appended: PendingGateRecord[]; resolved: GateResolutionRecord[]; applied: AppliedGateRecord[] } {
150
182
  const resolutions = [...(seed.resolutions ?? [])];
151
183
  const pending = [...(seed.pending ?? [])];
152
184
  const appended: PendingGateRecord[] = [];
153
185
  const resolved: GateResolutionRecord[] = [];
186
+ const applied: AppliedGateRecord[] = [...(seed.applied ?? [])];
154
187
  return {
155
188
  appended,
156
189
  resolved,
190
+ applied,
191
+ async readApplied(op) {
192
+ return applied.filter((a) => a.op === op);
193
+ },
194
+ async appendApplied(record) {
195
+ applied.push(record);
196
+ return { pushed: true };
197
+ },
157
198
  async read() {
158
199
  return { resolutions: [...resolutions], pending: [...pending] };
159
200
  },
@@ -213,6 +254,12 @@ export interface GateCheckInput {
213
254
  approval?: ResolvedGateApproval;
214
255
  /** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
215
256
  now?: string;
257
+ /**
258
+ * The approvals a wave already applied under (#3784). Given, an approval
259
+ * one of them spent counts for nothing: it neither satisfies the gate nor
260
+ * stands as a mismatch. Absent, nothing is spent.
261
+ */
262
+ spent?: readonly AppliedGateRecord[];
216
263
  }
217
264
 
218
265
  /**
@@ -314,7 +361,7 @@ export interface GateTally {
314
361
  counted: GateResolutionRecord[];
315
362
  /** The quorum's count, 1 when the gate declares none. */
316
363
  need: number;
317
- /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, only for a permit recorded under `enforce`, and only under the gate's current policy version. */
364
+ /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, only for a permit recorded under `enforce`, and only under the gate's current policy version and resolved context. */
318
365
  permit?: GateResolutionRecord;
319
366
  /** The newest approval of this gate for a different plan, when the gate binds one. */
320
367
  mismatched?: GateResolutionRecord;
@@ -326,13 +373,17 @@ export interface GateTally {
326
373
  * An approval counts only if it is newer than `sinceIso` (the standing pending
327
374
  * fact) and, on a plan-bound gate, recorded for `planDigest`. So a changed
328
375
  * plan invalidates every approval collected for the old one, the rule #2300
329
- * set for a single approval.
376
+ * set for a single approval. A human's approval may be older than the
377
+ * standing fact, back to `humansSinceIso` ({@link humanQuorumSince}), so a
378
+ * pending fact re-recorded for a changed mode or context keeps a partly
379
+ * filled quorum (#3206). It defaults to `sinceIso`.
330
380
  *
331
381
  * Of those, only a human's counts toward the quorum, once per `resolvedBy`,
332
382
  * and only with one of the quorum's roles when it names any. An agent counts
333
383
  * only through a recorded `allow` in `enforce` mode, evaluated under the
334
- * policy version the gate declares now and recorded while the gate was in
335
- * `enforce` (#2512). A `log-only` decision never changes the outcome, even
384
+ * policy version the gate declares now, recorded while the gate was in
385
+ * `enforce` (#2512), and evaluated under the context the gate resolves now
386
+ * (#3774). A `log-only` decision never changes the outcome, even
336
387
  * after the gate switches to `enforce`, and a `deny` never removes a human's approval: a policy can add
337
388
  * a way through the gate but cannot take one away.
338
389
  */
@@ -342,29 +393,38 @@ export function tallyGateApprovals(
342
393
  sinceIso: string,
343
394
  planDigest: string | undefined,
344
395
  approval: ResolvedGateApproval,
396
+ humansSinceIso: string = sinceIso,
345
397
  ): GateTally {
346
398
  const since = new Date(sinceIso).getTime();
399
+ const humansSince = Math.min(since, new Date(humansSinceIso).getTime());
347
400
  const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
348
401
  const roles = approval.quorum?.roles;
402
+ const context = gateContextDigest(approval.context);
349
403
 
350
404
  const byActor = new Map<string, GateResolutionRecord>();
351
405
  let permit: GateResolutionRecord | undefined;
352
406
  let mismatched: GateResolutionRecord | undefined;
353
407
  for (const r of records) {
354
- if (r.gate !== gate || at(r) < since) continue;
408
+ if (r.gate !== gate || at(r) < humansSince) continue;
355
409
  if (planDigest !== undefined && !samePlanDigest(r.planDigest, planDigest)) {
356
- if (!mismatched || at(r) >= at(mismatched)) mismatched = r;
410
+ if (at(r) >= since && (!mismatched || at(r) >= at(mismatched))) mismatched = r;
357
411
  continue;
358
412
  }
359
413
 
360
414
  const decision = r.policyDecision;
415
+ // #3206: an approval from before the standing fact can only be a human's
416
+ // toward the quorum. A permit is anchored on the standing fact, the one
417
+ // `chant approve` evaluated it under.
418
+ const sinceStanding = at(r) >= since;
361
419
  // #2512: the decision must also have been recorded under `enforce`. An
362
420
  // allow recorded while the gate was log-only was never binding, and a
363
- // later switch to enforce does not make it binding.
421
+ // later switch to enforce does not make it binding. #3774: and under
422
+ // the context this run resolved, since the run checks approvals before it
423
+ // re-records the pending fact for a changed context.
364
424
  if (
365
- approval.mode === "enforce" && approval.policy && decision?.decision === "allow" &&
366
- decision.mode === "enforce" &&
367
- decision.version === approval.policy.version && (!permit || at(r) >= at(permit))
425
+ sinceStanding && approval.mode === "enforce" && approval.policy && decision?.decision === "allow" &&
426
+ decision.mode === "enforce" && decision.version === approval.policy.version &&
427
+ decision.contextDigest === context && (!permit || at(r) >= at(permit))
368
428
  ) {
369
429
  permit = r;
370
430
  }
@@ -385,6 +445,38 @@ export function tallyGateApprovals(
385
445
  };
386
446
  }
387
447
 
448
+ /**
449
+ * Where a gate's human quorum starts counting (#3206): the oldest pending fact
450
+ * in the unbroken run of facts, newest first from `standing`, recorded for
451
+ * `standing`'s plan and policy version.
452
+ *
453
+ * A pending fact is re-recorded when anything in the approval block changes,
454
+ * the mode and the context included, so `chant approve` evaluates the policy
455
+ * under what the gate now declares. A human's approval counts for the plan
456
+ * it names whatever the mode or context was, so re-recording for one of
457
+ * those must not drop it. The run stops at a fact for another plan or policy
458
+ * version, which needs fresh approvals, and at one that had expired by the
459
+ * time the next was recorded, which expiry already closed.
460
+ *
461
+ * `pending` may hold every pending fact in the ledger; only `standing`'s gate
462
+ * and environment are read.
463
+ */
464
+ export function humanQuorumSince(pending: readonly PendingGateRecord[], standing: PendingGateRecord): string {
465
+ const at = (p: PendingGateRecord) => new Date(p.timestamp).getTime();
466
+ const version = (p: PendingGateRecord) => p.approval?.policy?.version;
467
+ const sameKey = (p: PendingGateRecord) =>
468
+ samePlanDigest(p.planDigest, standing.planDigest) && version(p) === version(standing);
469
+ const older = pending
470
+ .filter((p) => p !== standing && p.gate === standing.gate && p.environment === standing.environment && at(p) <= at(standing))
471
+ .sort((a, b) => at(b) - at(a));
472
+ let anchor = standing;
473
+ for (const p of older) {
474
+ if (!sameKey(p) || isPendingGateExpired(p, anchor.timestamp)) break;
475
+ anchor = p;
476
+ }
477
+ return anchor.timestamp;
478
+ }
479
+
388
480
  /** Whether two resolved approval blocks are the same, so a standing pending fact still describes this gate. */
389
481
  function sameApproval(a: ResolvedGateApproval | undefined, b: ResolvedGateApproval | undefined): boolean {
390
482
  return JSON.stringify(a ?? null, sortedJsonReplacer) === JSON.stringify(b ?? null, sortedJsonReplacer);
@@ -431,7 +523,9 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
431
523
  env === undefined
432
524
  ? ledger.resolutions
433
525
  : ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
434
- ).filter((r) => rule === null || r.gate !== input.gate || rule.refuses(r) === null);
526
+ )
527
+ .filter((r) => rule === null || r.gate !== input.gate || rule.refuses(r) === null)
528
+ .filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r));
435
529
 
436
530
  const standing = latestPendingGate(pending, input.gate);
437
531
  const since = standing?.timestamp ?? EPOCH;
@@ -439,7 +533,14 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
439
533
  let mismatched: GateResolutionRecord | undefined;
440
534
  let quorum: GateQuorumProgress | undefined;
441
535
  if (input.approval) {
442
- const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval);
536
+ // #3206: the humans' anchor reaches back past facts re-recorded for this
537
+ // plan and policy version, so a mode or context change keeps the quorum.
538
+ const humansSince =
539
+ standing && samePlanDigest(standing.planDigest, input.planDigest) &&
540
+ standing.approval?.policy?.version === input.approval.policy?.version
541
+ ? humanQuorumSince(pending, standing)
542
+ : since;
543
+ const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval, humansSince);
443
544
  if (tally.permit) {
444
545
  return { satisfied: true, resolution: tally.permit, via: "policy", approvals: tally.counted };
445
546
  }
package/src/op/index.ts CHANGED
@@ -54,14 +54,29 @@ export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from ".
54
54
  export {
55
55
  OP_WAVE_DEFAULT_APPLY,
56
56
  OP_WAVE_GATE_POLICIES,
57
+ OP_WAVE_HOOK_MOMENTS,
58
+ assertOpWaveHooks,
57
59
  assertOpWavesSpec,
60
+ opWaveHooksAt,
58
61
  opWaveJobs,
59
62
  opWaveRecordPath,
60
63
  opWaveShare,
61
64
  } from "./op-waves";
62
- export type { OpWave, OpWaveGatePolicy, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
63
- export { opWaveNeedsApproval, parseOpWavesSpec, readOpWavePolicy, runOpWave } from "./op-waves-run";
64
- export type { OpWaveDecision, OpWaveMember, OpWavePlanFile, RunOpWaveOptions, RunOpWaveResult } from "./op-waves-run";
65
+ export type { OpWave, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
66
+ export { opWaveNeedsApproval, opWavePlansPath, opWaveRunsPath, parseOpWavesSpec, readOpWavePolicy, runOpWave } from "./op-waves-run";
67
+ export type {
68
+ OpWaveBaseRules,
69
+ OpWaveDecision,
70
+ OpWaveHookOutcome,
71
+ OpWaveMember,
72
+ OpWavePlanFile,
73
+ OpWavePlansFile,
74
+ OpWaveRunsFile,
75
+ RunOpWaveOptions,
76
+ RunOpWaveResult,
77
+ } from "./op-waves-run";
78
+ export { notifyOpWave, opWaveNotifyFields } from "./op-waves-notify";
79
+ export type { OpWaveOutcome } from "./op-waves-notify";
65
80
  export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
66
81
  export type { ActivityFn } from "./activity-registry";
67
82
  export { loadActivityContracts, mergeActivityContracts } from "./activity-contract-registry";
@@ -75,9 +90,9 @@ export { currentOpRun, withOpRunContext } from "./run-context";
75
90
  export type { OpRunContext, PassedGate } from "./run-context";
76
91
  export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch, gateIsSealed } from "./gate";
77
92
  export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
78
- export { tallyGateApprovals, approverOf } from "./gate";
93
+ export { tallyGateApprovals, humanQuorumSince, approverOf } from "./gate";
79
94
  export {
80
- gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
95
+ gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
81
96
  } from "./gate-approval";
82
97
  export type {
83
98
  GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
@@ -0,0 +1,127 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, test } from "vitest";
5
+ import { verifyWebhook, type NotifyEvent, type NotifyFetch } from "../notify";
6
+ import { memoryGateLedgerPort } from "./gate";
7
+ import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
8
+ import { notifyOpWave, opWaveNotifyFields } from "./op-waves-notify";
9
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
10
+
11
+ const spec: OpWavesSpec = {
12
+ name: "migrations",
13
+ op: "migrate",
14
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
15
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
16
+ waves: [
17
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
18
+ { name: "prod", runs: [{ target: "t2" }, { target: "t1" }], shares: 2 },
19
+ ],
20
+ notify: { webhook: "CHANT_WEBHOOK", key: "CHANT_WEBHOOK_KEY", slack: "CHANT_SLACK_WEBHOOK" },
21
+ };
22
+ const env = { CHANT_WEBHOOK: "https://hooks.example/chant", CHANT_WEBHOOK_KEY: "k", CHANT_SLACK_WEBHOOK: "https://hooks.slack.com/services/x", GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "acme/db", GITHUB_RUN_ID: "7" };
23
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
24
+
25
+ function harness(plans: Record<string, { planDigest: string }>, failApply = false) {
26
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-notify-"));
27
+ const exec: OpWaveExec = (argv, dir) => {
28
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plans[argv[2]!]));
29
+ return argv[1] === "apply" && failApply ? 2 : 0;
30
+ };
31
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
32
+ return { cwd, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
33
+ }
34
+
35
+ function recorder(status = 200) {
36
+ const calls: Array<{ url: string; headers: Record<string, string>; body: string }> = [];
37
+ const fetch: NotifyFetch = async (url, init) => {
38
+ calls.push({ url, headers: init.headers, body: init.body });
39
+ return { ok: status < 300, status };
40
+ };
41
+ return { calls, fetch };
42
+ }
43
+
44
+ describe("an Op waves job's notify", () => {
45
+ test("a waiting wave sends the approve command, the digest, the targets and the run, signed", async () => {
46
+ const h = harness({ t1: { planDigest: digest("1") }, t2: { planDigest: digest("2") } });
47
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 2, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
48
+ h.done();
49
+ expect(result.exitCode).toBe(3);
50
+ const { calls, fetch } = recorder();
51
+ const out = await notifyOpWave(spec, 2, { result }, {}, { env, fetch, sleep: async () => {}, log: () => {} });
52
+ expect(out.map((d) => d.ok)).toEqual([true, true]);
53
+ const event = JSON.parse(calls[0]!.body) as NotifyEvent;
54
+ expect(event).toMatchObject({
55
+ schema: "chant.notify/v1",
56
+ source: "chant",
57
+ event: "waiting",
58
+ project: "acme/db",
59
+ summary: "wave 2 (prod) of migrations waits for an approval",
60
+ wave: { number: 2, name: "prod", gate: "migrations-wave-2" },
61
+ targets: ["t2", "t1"],
62
+ digest: result.decision!.digest,
63
+ approve: { command: result.decision!.approve },
64
+ exitCode: 3,
65
+ run: { url: "https://github.com/acme/db/actions/runs/7" },
66
+ });
67
+ expect(event.approve!.command).toMatch(/^chant approve migrations migrations-wave-2 --plan (jcs1-)?sha256:/);
68
+ expect(verifyWebhook({ body: calls[0]!.body, headers: calls[0]!.headers, key: "k" }).ok).toBe(true);
69
+ expect(JSON.parse(calls[1]!.body).text).toContain("Approve: `chant approve migrations migrations-wave-2");
70
+ });
71
+
72
+ test("a share job of a waiting wave sends nothing; its deciding job did", () => {
73
+ const decision = { wave: 2, name: "prod", op: "migrations", gate: "migrations-wave-2", policy: "always" as const, approval: "ledger" as const, policySource: "base" as const, base: "b", digest: digest("d"), members: [], status: "waiting" as const };
74
+ expect(opWaveNotifyFields(spec, 2, { result: { exitCode: 3, decision, applied: [], failed: [], moved: [], hooks: [] } }, { share: 1 })).toBeUndefined();
75
+ expect(opWaveNotifyFields(spec, 2, { result: { exitCode: 0, applied: ["t1"], failed: [], moved: [], hooks: [] } })).toBeUndefined();
76
+ });
77
+
78
+ test("a share job held by its own hook sends waiting, and a failed hook is named (#3762)", () => {
79
+ const decision = { wave: 2, name: "prod", op: "migrations", gate: "migrations-wave-2", policy: "always" as const, approval: "ledger" as const, policySource: "base" as const, base: "b", digest: digest("d"), members: [], status: "waiting" as const, heldBy: ["smoke"], approve: "chant approve migrations migrations-wave-2 --plan x" };
80
+ const held = { moment: "before-apply" as const, name: "smoke", exit: 2, outcome: "held" as const };
81
+ expect(opWaveNotifyFields(spec, 2, { result: { exitCode: 3, decision, applied: [], failed: [], moved: [], hooks: [held] } }, { share: 1 })).toMatchObject({ event: "waiting", reason: "held by smoke" });
82
+ const broke = { moment: "after-apply" as const, name: "verify", exit: 1, outcome: "failed" as const };
83
+ expect(opWaveNotifyFields(spec, 1, { result: { exitCode: 1, applied: ["dev"], failed: [], moved: [], hooks: [broke] } })).toMatchObject({
84
+ event: "failed",
85
+ summary: "wave 1 (dev) of migrations failed: after-apply hook verify failed",
86
+ });
87
+ });
88
+
89
+ test("a share that refused a moved plan sends refused, and a failed apply sends failed", () => {
90
+ expect(opWaveNotifyFields(spec, 2, { result: { exitCode: 4, applied: ["t1"], failed: [], moved: ["t2"], hooks: [] } }, { share: 1 })).toMatchObject({
91
+ event: "refused",
92
+ summary: "wave 2 (prod) of migrations, share 1, refused 1 run whose plan moved since the wave was decided",
93
+ targets: ["t2"],
94
+ exitCode: 4,
95
+ });
96
+ expect(opWaveNotifyFields(spec, 1, { result: { exitCode: 1, applied: [], failed: ["dev"], moved: [], hooks: [] } })).toMatchObject({
97
+ event: "failed",
98
+ summary: "wave 1 (dev) of migrations failed: dev did not apply",
99
+ targets: ["dev"],
100
+ });
101
+ expect(opWaveNotifyFields(spec, 1, { error: "planning dev exited 2" })).toMatchObject({ event: "failed", reason: "planning dev exited 2", exitCode: 1 });
102
+ });
103
+
104
+ test("a failed apply's exit code stays 1 when every delivery fails", async () => {
105
+ const h = harness({ dev: { planDigest: digest("1") } }, true);
106
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
107
+ h.done();
108
+ const { fetch } = recorder(500);
109
+ const lines: string[] = [];
110
+ const out = await notifyOpWave(spec, 1, { result }, {}, { env, fetch, sleep: async () => {}, log: (l) => lines.push(l) });
111
+ expect(result.exitCode).toBe(1);
112
+ expect(out.every((d) => !d.ok)).toBe(true);
113
+ expect(lines).toEqual([
114
+ "notify: the failed event was not delivered to the webhook: HTTP 500",
115
+ "notify: the failed event was not delivered to Slack: HTTP 500",
116
+ ]);
117
+ });
118
+
119
+ test("the spec refuses a URL where a variable name goes, and such a spec sends nothing", async () => {
120
+ const bad = { ...spec, notify: { slack: "https://hooks.slack.com/services/x" } };
121
+ expect(() => assertOpWavesSpec(bad)).toThrow(/Op waves "migrations": notify\.slack: the name of the environment variable/);
122
+ const { calls, fetch } = recorder();
123
+ expect(await notifyOpWave(bad, 1, { error: "x" }, {}, { env, fetch })).toEqual([]);
124
+ expect(calls).toEqual([]);
125
+ expect(await notifyOpWave({ ...spec, notify: undefined }, 1, { error: "x" }, {}, { env, fetch })).toEqual([]);
126
+ });
127
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What an Op waves job tells people (#3761), when the spec has `notify`.
3
+ *
4
+ * A wave that waits for an approval (exit 3) sends `waiting` with the
5
+ * approval command, the digest and, under `pr-review`, the pull request to
6
+ * review. A share job that refused a run whose plan moved (exit 4) sends
7
+ * `refused`. A wave whose plan, apply or stage hook failed (exit 1) sends
8
+ * `failed`. A share job of a waiting wave sends nothing, since its deciding
9
+ * job already did, unless a hook in the share job itself held it (#3762). Delivery is `../notify.ts`: it never throws, and the job's exit code
10
+ * is what the wave decided.
11
+ */
12
+
13
+ import { makeNotifyEvent, notifyConfigProblem, sendNotify, type NotifyDelivery, type NotifyFields, type SendNotifyOptions } from "../notify";
14
+ import type { OpWavesSpec } from "./op-waves";
15
+ import type { RunOpWaveResult } from "./op-waves-run";
16
+
17
+ /** How the job ended: the wave's result, or the error that stopped it. */
18
+ export type OpWaveOutcome = { result: RunOpWaveResult; error?: undefined } | { result?: undefined; error: string };
19
+
20
+ /** The event a wave job sends, or undefined when it sends none. */
21
+ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWaveOutcome, job: { share?: number } = {}): NotifyFields | undefined {
22
+ const wave = spec.waves[k - 1];
23
+ const name = wave?.name;
24
+ const label = `wave ${k}${name ? ` (${name})` : ""} of ${spec.name}`;
25
+ if (outcome.error !== undefined) {
26
+ return { source: "chant", event: "failed", summary: `${label} failed`, wave: { number: k, ...(name ? { name } : {}) }, targets: [], reason: outcome.error, exitCode: 1 };
27
+ }
28
+ const { result } = outcome;
29
+ const decision = result.decision;
30
+ const base = {
31
+ source: "chant",
32
+ wave: { number: k, ...(name ? { name } : {}), ...(decision?.gate ? { gate: decision.gate } : {}) },
33
+ ...(decision?.digest ? { digest: decision.digest } : {}),
34
+ exitCode: result.exitCode,
35
+ };
36
+ if (result.exitCode === 3) {
37
+ if (!decision) return undefined;
38
+ // A share job's wait was announced by its deciding job, unless a hook in this job held it (#3762).
39
+ const heldHere = result.hooks.filter((h) => h.outcome === "held");
40
+ if (job.share !== undefined && heldHere.length === 0) return undefined;
41
+ const review = decision.review?.pr !== undefined && decision.review.url ? { review: { pr: decision.review.pr, url: decision.review.url } } : {};
42
+ return {
43
+ ...base,
44
+ event: "waiting",
45
+ status: "waiting",
46
+ summary: `${label} waits for an approval`,
47
+ targets: decision.members.map((m) => m.member),
48
+ approve: { command: decision.approve ?? `chant approve ${decision.op} ${decision.gate} --plan ${decision.digest}`, ...review },
49
+ ...(decision.heldBy?.length
50
+ ? { reason: `held by ${decision.heldBy.join(", ")}` }
51
+ : decision.review?.reason
52
+ ? { reason: decision.review.reason }
53
+ : {}),
54
+ };
55
+ }
56
+ if (result.exitCode === 4) {
57
+ return {
58
+ ...base,
59
+ event: "refused",
60
+ status: "moved",
61
+ summary: `${label}${job.share !== undefined ? `, share ${job.share},` : ""} refused ${result.moved.length} run${result.moved.length === 1 ? "" : "s"} whose plan moved since the wave was decided`,
62
+ targets: result.moved,
63
+ reason: "their plans moved since the wave was decided, so they were not applied",
64
+ };
65
+ }
66
+ if (result.exitCode !== 0) {
67
+ const hooks = result.hooks.filter((h) => h.outcome === "failed").map((h) => `${h.moment} hook ${h.name}${h.target ? ` (${h.target})` : ""}`);
68
+ if (result.failed.length === 0 && hooks.length > 0) {
69
+ return { ...base, event: "failed", status: "failed", summary: `${label} failed: ${hooks.join(", ")} failed`, targets: [], reason: `${hooks.join(", ")} exited non-zero` };
70
+ }
71
+ return {
72
+ ...base,
73
+ event: "failed",
74
+ status: "failed",
75
+ summary: `${label} failed: ${result.failed.length > 0 ? `${result.failed.join(", ")} did not apply` : `exit ${result.exitCode}`}`,
76
+ targets: result.failed,
77
+ ...(result.failed.length > 0 ? { reason: `the apply command failed for ${result.failed.join(", ")}` } : {}),
78
+ };
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** Send what the job's outcome calls for to what `spec.notify` names. Never throws. */
84
+ export async function notifyOpWave(
85
+ spec: OpWavesSpec,
86
+ k: number,
87
+ outcome: OpWaveOutcome,
88
+ job: { share?: number } = {},
89
+ options: SendNotifyOptions = {},
90
+ ): Promise<NotifyDelivery[]> {
91
+ // A spec whose notify the run refused (it may name a URL in place of a variable) sends nothing.
92
+ if (!spec.notify || notifyConfigProblem(spec.notify) !== undefined) return [];
93
+ try {
94
+ const fields = opWaveNotifyFields(spec, k, outcome, job);
95
+ if (!fields) return [];
96
+ return await sendNotify(spec.notify, makeNotifyEvent(fields, { ...(options.env ? { env: options.env } : {}) }), options);
97
+ } catch (e) {
98
+ (options.log ?? ((line: string) => process.stderr.write(line + "\n")))(`notify: nothing was sent: ${e instanceof Error ? e.message : String(e)}`);
99
+ return [];
100
+ }
101
+ }