@intentius/chant 0.124.9 → 0.124.11

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 (48) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +85 -10
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/gate-resume-run.d.ts +87 -0
  5. package/dist/gate-resume-run.d.ts.map +1 -0
  6. package/dist/lifecycle/gate-applied.d.ts +44 -1
  7. package/dist/lifecycle/gate-applied.d.ts.map +1 -1
  8. package/dist/lifecycle/gate-audit.d.ts +6 -0
  9. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  10. package/dist/lifecycle/gate-ledger.d.ts +1 -1
  11. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  12. package/dist/op/gate-resume-plan.d.ts +113 -0
  13. package/dist/op/gate-resume-plan.d.ts.map +1 -0
  14. package/dist/op/gate-resume.d.ts +8 -64
  15. package/dist/op/gate-resume.d.ts.map +1 -1
  16. package/dist/op/gate-run-locator.d.ts +1 -1
  17. package/dist/op/index.d.ts +2 -0
  18. package/dist/op/index.d.ts.map +1 -1
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/point-decision.d.ts +11 -0
  21. package/dist/point-decision.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/handlers/gate-resume.test.ts +140 -2
  24. package/src/cli/handlers/gate-resume.ts +174 -65
  25. package/src/cli/main.ts +8 -3
  26. package/src/codegen/pinned-upgrade.test.ts +8 -1
  27. package/src/codegen/publish-order.test.ts +20 -4
  28. package/src/gate-decision-bundle.test.ts +1 -0
  29. package/src/gate-resume-run-bundle.test.ts +49 -0
  30. package/src/gate-resume-run.ts +171 -0
  31. package/src/lifecycle/gate-applied.ts +63 -1
  32. package/src/lifecycle/gate-audit.test.ts +6 -0
  33. package/src/lifecycle/gate-audit.ts +8 -2
  34. package/src/lifecycle/gate-ledger.test.ts +7 -1
  35. package/src/lifecycle/gate-ledger.ts +1 -1
  36. package/src/op/gate-resume-plan-bundle.test.ts +49 -0
  37. package/src/op/gate-resume-plan.test.ts +107 -0
  38. package/src/op/gate-resume-plan.ts +228 -0
  39. package/src/op/gate-resume.test.ts +18 -0
  40. package/src/op/gate-resume.ts +16 -150
  41. package/src/op/gate-run-locator.ts +1 -1
  42. package/src/op/gate.ts +1 -1
  43. package/src/op/index.ts +2 -0
  44. package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
  45. package/src/op/op-waves-point.test.ts +60 -2
  46. package/src/op/op-waves-run.ts +6 -2
  47. package/src/op/op-waves.test.ts +5 -1
  48. package/src/point-decision.ts +19 -2
@@ -13,7 +13,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { tmpdir } from "node:os";
15
15
  import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
16
- import { runOpWave, type OpWaveDecision, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
16
+ import { runOpWave, type OpWaveDecision, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
17
17
  import { memoryGateLedgerPort } from "./gate";
18
18
  import { waveSetDigest } from "../gated-waves";
19
19
  import { pointGateResumable, type GatePointAsker, type GatePointQuestion, type GatePointRequest } from "../point-decision";
@@ -218,6 +218,62 @@ describe("a wave gate that asks a decision point (#3836)", () => {
218
218
  h.done();
219
219
  });
220
220
 
221
+ test("the plans file's pending fields and the spec's neverOverMcp land on the point gate's pending fact (#3863)", async () => {
222
+ const tg: OpWavesSpec = {
223
+ name: "tf-apply",
224
+ op: "tf-apply",
225
+ gateName: "wave-{k}",
226
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
227
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
228
+ point: { name: "prod-apply" },
229
+ waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "nat" }] }],
230
+ };
231
+ const members = [{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("2") }];
232
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-point-3863-"));
233
+ const exec: OpWaveExec = (argv, dir) => {
234
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
235
+ if (argv[1] === "plan") {
236
+ writeFileSync(join(dir, argv[4]!), JSON.stringify({
237
+ runs: runs.runs.map((r) => ({ target: r.target, planDigest: members.find((m) => m.member === r.target)!.planDigest })),
238
+ pending: { commit: "c".repeat(40), members, description: "wave 1 of 1: vpc, nat" },
239
+ }));
240
+ }
241
+ return 0;
242
+ };
243
+ // neverOverMcp at base counts even when the change drops it.
244
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify({ ...tg, neverOverMcp: true }) });
245
+ const gates = memoryGateLedgerPort();
246
+ const asker = stubAsker();
247
+ const waiting = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd, exec, show, gates, points: asker, now: NOW, log: () => {} });
248
+ expect(waiting.exitCode).toBe(3);
249
+ expect(gates.appended).toHaveLength(1);
250
+ expect(gates.appended[0]).toMatchObject({
251
+ op: "tf-apply",
252
+ gate: "wave-1",
253
+ commit: "c".repeat(40),
254
+ members,
255
+ description: "wave 1 of 1: vpc, nat",
256
+ neverOverMcp: true,
257
+ answer: { point: "prod-apply" },
258
+ });
259
+ expect(waiting.decision?.pendingFact).toMatchObject({ commit: "c".repeat(40), neverOverMcp: true });
260
+
261
+ // A standing fact for the same question that does not carry neverOverMcp does not stand: the run records one that does.
262
+ const old = { ...gates.appended[0]! };
263
+ delete (old as { neverOverMcp?: true }).neverOverMcp;
264
+ const stale = memoryGateLedgerPort({ pending: [old] });
265
+ await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd, exec, show, gates: stale, points: asker, now: NOW, log: () => {} });
266
+ expect(stale.appended).toHaveLength(1);
267
+ expect(stale.appended[0]).toMatchObject({ neverOverMcp: true });
268
+
269
+ // Without neverOverMcp at base, the fact carries the fields and no neverOverMcp.
270
+ const plain = memoryGateLedgerPort();
271
+ await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd, exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify(tg) }), gates: plain, points: asker, now: NOW, log: () => {} });
272
+ expect(plain.appended[0]).toMatchObject({ commit: "c".repeat(40), members });
273
+ expect(plain.appended[0]).not.toHaveProperty("neverOverMcp");
274
+ rmSync(cwd, { recursive: true, force: true });
275
+ });
276
+
221
277
  test("resume picks up the waiting wave once its question is answered (#3835)", async () => {
222
278
  vi.stubEnv("GITLAB_CI", "");
223
279
  vi.stubEnv("GITHUB_ACTIONS", "true");
@@ -245,7 +301,9 @@ describe("a wave gate that asks a decision point (#3836)", () => {
245
301
  expect(await resumeStanding(fact, [], { questions: new Map([[id, yes]]), dryRun: true })).toMatchObject({
246
302
  op: "tf-apply",
247
303
  gate: "tf-apply-wave-1",
248
- status: "skipped",
304
+ status: "would-resume",
305
+ resumable: true,
306
+ reason: "answered",
249
307
  approvedBy: "alice",
250
308
  message: expect.stringContaining("would resume github run 4242"),
251
309
  });
@@ -1067,7 +1067,8 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
1067
1067
  planDigest: hold.request.digest,
1068
1068
  description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
1069
1069
  now,
1070
- ...(hold.request.pending ? { fields: hold.request.pending } : {}),
1070
+ // #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
1071
+ fields: { ...hold.request.pending, wave: { number: k, name: wave.name, op: spec.name } },
1071
1072
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
1072
1073
  });
1073
1074
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
@@ -1559,7 +1560,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1559
1560
  description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
1560
1561
  });
1561
1562
  try {
1562
- await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
1563
+ await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }], wave: { number: k, name: wave.name, op: spec.name } } as typeof fact);
1563
1564
  } catch (err) {
1564
1565
  log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
1565
1566
  }
@@ -1695,6 +1696,9 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
1695
1696
  ...(point.timeout ? { timeout: point.timeout } : {}),
1696
1697
  planDigest: digest,
1697
1698
  ...(options.now ? { now: options.now } : {}),
1699
+ // #3863: the plans file's pending fields and the spec's neverOverMcp, as settleGate passes them.
1700
+ ...(decision.pending ? { fields: decision.pending } : {}),
1701
+ ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
1698
1702
  });
1699
1703
  if (check.satisfied) {
1700
1704
  decision.status = "approved";
@@ -836,6 +836,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
836
836
  expect(result).toMatchObject({ exitCode: 7, decision: { status: "refused", refused: { reason: "policy", runs: ["vpc"] } } });
837
837
  expect(gates.appended).toHaveLength(1);
838
838
  expect(gates.appended[0]).toMatchObject({ op: "policy-override", gate: "vpc", plan: digest("1"), rules: ["no-public-s3"], members: [{ member: "vpc", planDigest: digest("1") }] });
839
+ // #3839: the denial names its wave as a field.
840
+ expect(gates.appended[0]).toMatchObject({ wave: { number: 1, name: expect.any(String), op: tg.name } });
839
841
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "refused" });
840
842
  expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
841
843
  const { opWaveNotifyFields } = await import("./op-waves-notify");
@@ -883,7 +885,7 @@ describe("gates and holds a product's wave needs (#3821)", () => {
883
885
  const hook = (argv: string[], env: Record<string, string>, dir: string) => {
884
886
  if (argv[0] !== "migrate") return 0;
885
887
  if (env.CHANT_HOLD_APPROVED) return 0;
886
- writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), pending: { members: [] } }));
888
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), description: "move the queues", pending: { members: [] } }));
887
889
  return 3;
888
890
  };
889
891
  const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook, atBase: spec });
@@ -892,6 +894,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
892
894
  expect(waiting).toMatchObject({ exitCode: 3, decision: { status: "waiting", heldOn: { op: "tf-migrate", gate: "split-queues", digest: digest("m"), status: "waiting" } } });
893
895
  expect(waiting.decision!.approve).toBe(`chant approve tf-migrate split-queues --plan ${digest("m")}`);
894
896
  expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
897
+ // #3839: the hold names its wave as a field, even when the hook wrote the description.
898
+ expect(gates.appended[0]).toMatchObject({ description: "move the queues", wave: { number: 1, name: expect.any(String), op: tg.name } });
895
899
  expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
896
900
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
897
901
  h.done();
@@ -31,7 +31,7 @@ import { execFile } from "node:child_process";
31
31
  import { isPendingGateExpired, latestPendingGate } from "./lifecycle/gate-ledger-select";
32
32
  import { DEFAULT_GATE_EXPIRY, resolveApprovalUrl } from "./lifecycle/gate-pending";
33
33
  import { samePlanDigest } from "./lifecycle/plan-digest";
34
- import type { GateAnswerRef, GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
34
+ import type { GateAnswerRef, GateResolutionRecord, PendingGateInput, PendingGateRecord } from "./lifecycle/gate-ledger";
35
35
  import { parseDuration } from "./op/duration";
36
36
  import type { GateLedgerPort } from "./op/gate";
37
37
  import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
@@ -121,6 +121,17 @@ export interface PointGateInput {
121
121
  * ({@link resolveGateRunLocator}); false records none.
122
122
  */
123
123
  resume?: GateRunLocator | false;
124
+ /**
125
+ * Fields a caller adds to the pending fact it records (#3863), such as an
126
+ * Op waves product's commit and members, as `evaluateGate` takes them.
127
+ * Every field this function writes itself wins over one of the same name.
128
+ */
129
+ fields?: Record<string, unknown>;
130
+ /**
131
+ * The pending fact it records carries `neverOverMcp` (#3863): an answer to
132
+ * the question over MCP or ACP is refused (`answer-never-over-mcp`, #3817).
133
+ */
134
+ neverOverMcp?: boolean;
124
135
  }
125
136
 
126
137
  /** What reaching a gate that asks a point decided. */
@@ -198,12 +209,17 @@ export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePo
198
209
  const waiting: WaitingPoint = { id: q.id, point: q.point, state: q.state === "proposed" ? "proposed" : "escalated", path: q.path, subject: q.subject, steward: q.steward };
199
210
  const ledger = await port.read(input.op);
200
211
  const standing = latestPendingGate(ledger.pending.filter((p) => p.environment === undefined), input.gate);
201
- if (standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id) {
212
+ // #3863: a standing fact that does not carry the gate's neverOverMcp does not stand, so the answer path reads the rule.
213
+ if (
214
+ standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id &&
215
+ (!input.neverOverMcp || standing.neverOverMcp === true)
216
+ ) {
202
217
  return { satisfied: false, waiting, pending: standing, recorded: false };
203
218
  }
204
219
  const url = resolveApprovalUrl();
205
220
  const resume = input.resume === false ? undefined : (input.resume ?? resolveGateRunLocator());
206
221
  const { record, pushed, pushWarning } = await port.appendPending({
222
+ ...(input.fields as Partial<PendingGateInput> | undefined),
207
223
  op: input.op,
208
224
  gate: input.gate,
209
225
  timestamp: now,
@@ -214,6 +230,7 @@ export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePo
214
230
  ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
215
231
  ...(resume ? { resume } : {}),
216
232
  answer: { ...ref, ...(input.point.pass ? { pass: input.point.pass } : {}) },
233
+ ...(input.neverOverMcp ? { neverOverMcp: true as const } : {}),
217
234
  });
218
235
  return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
219
236
  }