@intentius/chant 0.124.10 → 0.124.12

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 (67) 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-ledger.d.ts +1 -1
  9. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  10. package/dist/lifecycle/git.d.ts +30 -14
  11. package/dist/lifecycle/git.d.ts.map +1 -1
  12. package/dist/lifecycle/ledger-port.d.ts +18 -0
  13. package/dist/lifecycle/ledger-port.d.ts.map +1 -0
  14. package/dist/lifecycle/release-ledger.d.ts +10 -9
  15. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  16. package/dist/lifecycle/release-read.d.ts +23 -0
  17. package/dist/lifecycle/release-read.d.ts.map +1 -0
  18. package/dist/lifecycle/wave-plans.d.ts +51 -0
  19. package/dist/lifecycle/wave-plans.d.ts.map +1 -0
  20. package/dist/lint/typescript.d.ts +18 -0
  21. package/dist/lint/typescript.d.ts.map +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts +4 -4
  23. package/dist/op/gate-resume-plan.d.ts +113 -0
  24. package/dist/op/gate-resume-plan.d.ts.map +1 -0
  25. package/dist/op/gate-resume.d.ts +8 -64
  26. package/dist/op/gate-resume.d.ts.map +1 -1
  27. package/dist/op/gate-review.d.ts +1 -20
  28. package/dist/op/gate-review.d.ts.map +1 -1
  29. package/dist/op/gate-run-locator.d.ts +1 -1
  30. package/dist/op/index.d.ts +2 -0
  31. package/dist/op/index.d.ts.map +1 -1
  32. package/dist/op/op-waves-run.d.ts.map +1 -1
  33. package/dist/point-decision.d.ts +11 -0
  34. package/dist/point-decision.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/gate-resume.test.ts +140 -2
  37. package/src/cli/handlers/gate-resume.ts +174 -65
  38. package/src/cli/main.ts +8 -3
  39. package/src/codegen/pinned-upgrade.test.ts +8 -1
  40. package/src/gate-decision-bundle.test.ts +1 -0
  41. package/src/gate-resume-run-bundle.test.ts +49 -0
  42. package/src/gate-resume-run.ts +171 -0
  43. package/src/lifecycle/gate-applied.ts +63 -1
  44. package/src/lifecycle/gate-ledger.test.ts +7 -1
  45. package/src/lifecycle/gate-ledger.ts +1 -1
  46. package/src/lifecycle/git.ts +61 -18
  47. package/src/lifecycle/ledger-port.ts +17 -0
  48. package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
  49. package/src/lifecycle/ledger-readers.test.ts +139 -0
  50. package/src/lifecycle/release-ledger.ts +15 -42
  51. package/src/lifecycle/release-read.ts +69 -0
  52. package/src/lifecycle/wave-plans.ts +93 -0
  53. package/src/lint/typescript.ts +18 -0
  54. package/src/meta/lexicon-typescript-import.test.ts +42 -0
  55. package/src/op/gate-resume-plan-bundle.test.ts +49 -0
  56. package/src/op/gate-resume-plan.test.ts +107 -0
  57. package/src/op/gate-resume-plan.ts +228 -0
  58. package/src/op/gate-resume.test.ts +18 -0
  59. package/src/op/gate-resume.ts +16 -150
  60. package/src/op/gate-review.ts +2 -16
  61. package/src/op/gate-run-locator.ts +1 -1
  62. package/src/op/gate.ts +1 -1
  63. package/src/op/index.ts +2 -0
  64. package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
  65. package/src/op/op-waves-point.test.ts +60 -2
  66. package/src/op/op-waves-run.ts +6 -10
  67. 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
  });
@@ -1696,6 +1696,9 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
1696
1696
  ...(point.timeout ? { timeout: point.timeout } : {}),
1697
1697
  planDigest: digest,
1698
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 } : {}),
1699
1702
  });
1700
1703
  if (check.satisfied) {
1701
1704
  decision.status = "approved";
@@ -2085,17 +2088,10 @@ async function approveByReview(
2085
2088
  }
2086
2089
 
2087
2090
  async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
2088
- const { readBlobFromPath, requireLifecycleLedger } = await import("../lifecycle/git");
2089
- const { headPlansPath } = await import("./gate-review");
2091
+ const { gitLedgerReadPort, requireLifecycleLedger } = await import("../lifecycle/git");
2092
+ const { readHeadPlans: read } = await import("../lifecycle/wave-plans");
2090
2093
  await requireLifecycleLedger({ cwd });
2091
- const { dir, file } = headPlansPath(name, head);
2092
- const text = await readBlobFromPath(dir, file, { cwd });
2093
- if (!text) return null;
2094
- try {
2095
- return JSON.parse(text) as HeadPlans;
2096
- } catch {
2097
- return null;
2098
- }
2094
+ return read(gitLedgerReadPort({ cwd }), name, head);
2099
2095
  }
2100
2096
 
2101
2097
  export interface RecordHeadPlansOptions {
@@ -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
  }