@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.
- package/dist/cli/handlers/gate-resume.d.ts +85 -10
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/gate-resume-run.d.ts +87 -0
- package/dist/gate-resume-run.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +44 -1
- package/dist/lifecycle/gate-applied.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.d.ts +6 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-resume-plan.d.ts +113 -0
- package/dist/op/gate-resume-plan.d.ts.map +1 -0
- package/dist/op/gate-resume.d.ts +8 -64
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/point-decision.d.ts +11 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +140 -2
- package/src/cli/handlers/gate-resume.ts +174 -65
- package/src/cli/main.ts +8 -3
- package/src/codegen/pinned-upgrade.test.ts +8 -1
- package/src/codegen/publish-order.test.ts +20 -4
- package/src/gate-decision-bundle.test.ts +1 -0
- package/src/gate-resume-run-bundle.test.ts +49 -0
- package/src/gate-resume-run.ts +171 -0
- package/src/lifecycle/gate-applied.ts +63 -1
- package/src/lifecycle/gate-audit.test.ts +6 -0
- package/src/lifecycle/gate-audit.ts +8 -2
- package/src/lifecycle/gate-ledger.test.ts +7 -1
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/op/gate-resume-plan-bundle.test.ts +49 -0
- package/src/op/gate-resume-plan.test.ts +107 -0
- package/src/op/gate-resume-plan.ts +228 -0
- package/src/op/gate-resume.test.ts +18 -0
- package/src/op/gate-resume.ts +16 -150
- package/src/op/gate-run-locator.ts +1 -1
- package/src/op/gate.ts +1 -1
- package/src/op/index.ts +2 -0
- package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
- package/src/op/op-waves-point.test.ts +60 -2
- package/src/op/op-waves-run.ts +6 -2
- package/src/op/op-waves.test.ts +5 -1
- 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: "
|
|
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
|
});
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/op/op-waves.test.ts
CHANGED
|
@@ -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();
|
package/src/point-decision.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|