@intentius/chant 0.122.0 → 0.124.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.
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config-import.d.ts +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify-verify.js +76 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +218 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +113 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +225 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +15 -2
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/policy-import.ts +1 -1
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-plain-node.e2e.test.ts +68 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +36 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +908 -123
- package/src/op/op-waves.test.ts +543 -3
- package/src/op/op-waves.ts +215 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +378 -0
- package/src/pr-forge.ts +546 -47
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/records-write.e2e.test.ts +1 -1
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
- /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
package/src/op/gate-approval.ts
CHANGED
|
@@ -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>;
|
package/src/op/gate-name.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/op/gate-resume.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/op/gate-review.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { readFileSync } from "node:fs";
|
|
33
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
335
|
-
* `enforce` (#2512)
|
|
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) <
|
|
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.
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
@@ -52,16 +52,47 @@ export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
|
|
52
52
|
export { generateOpsPipeline, generateOpWavesPipeline, withOpSchedules } from "./generate-pipeline";
|
|
53
53
|
export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from "./generate-pipeline";
|
|
54
54
|
export {
|
|
55
|
+
OP_WAVE_APPLY_HOOKS_RUNNERS,
|
|
55
56
|
OP_WAVE_DEFAULT_APPLY,
|
|
56
57
|
OP_WAVE_GATE_POLICIES,
|
|
58
|
+
OP_WAVE_HOOK_MOMENTS,
|
|
59
|
+
assertOpWaveHooks,
|
|
57
60
|
assertOpWavesSpec,
|
|
61
|
+
opWaveApplyHooksRunBy,
|
|
62
|
+
opWaveHooksAt,
|
|
58
63
|
opWaveJobs,
|
|
59
64
|
opWaveRecordPath,
|
|
60
65
|
opWaveShare,
|
|
61
66
|
} from "./op-waves";
|
|
62
|
-
export type { OpWave, OpWaveGatePolicy, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
63
|
-
export {
|
|
64
|
-
|
|
67
|
+
export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
68
|
+
export {
|
|
69
|
+
opWaveHooksAtBase,
|
|
70
|
+
opWaveNeedsApproval,
|
|
71
|
+
opWavePlansPath,
|
|
72
|
+
opWaveRunsPath,
|
|
73
|
+
parseOpWavesSpec,
|
|
74
|
+
readOpWaveHooksFile,
|
|
75
|
+
readOpWavePolicy,
|
|
76
|
+
runOpWave,
|
|
77
|
+
runOpWaveApplyHooks,
|
|
78
|
+
} from "./op-waves-run";
|
|
79
|
+
export type {
|
|
80
|
+
OpWaveBaseRules,
|
|
81
|
+
OpWaveDecision,
|
|
82
|
+
OpWaveApplyHooksRun,
|
|
83
|
+
OpWaveHookOutcome,
|
|
84
|
+
OpWaveHookRun,
|
|
85
|
+
OpWaveHooksFile,
|
|
86
|
+
OpWaveHooksResult,
|
|
87
|
+
OpWaveMember,
|
|
88
|
+
OpWavePlanFile,
|
|
89
|
+
OpWavePlansFile,
|
|
90
|
+
OpWaveRunsFile,
|
|
91
|
+
RunOpWaveOptions,
|
|
92
|
+
RunOpWaveResult,
|
|
93
|
+
} from "./op-waves-run";
|
|
94
|
+
export { notifyOpWave, opWaveNotifyFields } from "./op-waves-notify";
|
|
95
|
+
export type { OpWaveOutcome } from "./op-waves-notify";
|
|
65
96
|
export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
|
|
66
97
|
export type { ActivityFn } from "./activity-registry";
|
|
67
98
|
export { loadActivityContracts, mergeActivityContracts } from "./activity-contract-registry";
|
|
@@ -75,9 +106,9 @@ export { currentOpRun, withOpRunContext } from "./run-context";
|
|
|
75
106
|
export type { OpRunContext, PassedGate } from "./run-context";
|
|
76
107
|
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch, gateIsSealed } from "./gate";
|
|
77
108
|
export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
|
|
78
|
-
export { tallyGateApprovals, approverOf } from "./gate";
|
|
109
|
+
export { tallyGateApprovals, humanQuorumSince, approverOf } from "./gate";
|
|
79
110
|
export {
|
|
80
|
-
gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
|
|
111
|
+
gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
|
|
81
112
|
} from "./gate-approval";
|
|
82
113
|
export type {
|
|
83
114
|
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
|
+
});
|