@intentius/chant 0.124.8 → 0.124.10
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/lifecycle/gate-audit.d.ts +6 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/op/gate.d.ts +16 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +2 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +57 -7
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +42 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/workspace/identity.d.ts +3 -2
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +3 -2
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen/publish-order.test.ts +20 -4
- package/src/components/pr-run.ts +1 -1
- package/src/lifecycle/converge-ledger.ts +1 -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 +37 -0
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/lifecycle/git.ts +1 -1
- package/src/lifecycle/override-ledger.ts +1 -1
- package/src/lifecycle/run-ledger.ts +1 -1
- package/src/lifecycle/work-lease.ts +1 -1
- package/src/op/gate-review.test.ts +53 -1
- package/src/op/gate.ts +31 -9
- package/src/op/op-waves-notify.ts +13 -1
- package/src/op/op-waves-point.test.ts +254 -0
- package/src/op/op-waves-run.ts +234 -42
- package/src/op/op-waves.test.ts +100 -3
- package/src/op/op-waves.ts +68 -0
- package/src/workspace/identity.test.ts +4 -1
- package/src/workspace/identity.ts +4 -3
- package/src/workspace/write-scope.ts +4 -3
|
@@ -441,3 +441,40 @@ describe("applied approvals on chant/lifecycle (#3784)", () => {
|
|
|
441
441
|
});
|
|
442
442
|
});
|
|
443
443
|
});
|
|
444
|
+
|
|
445
|
+
describe("gate ledger newline termination (#3849)", () => {
|
|
446
|
+
test("every append ends the file in a newline, with no blank or merged lines", async () => {
|
|
447
|
+
await withTestDir(async (dir) => {
|
|
448
|
+
await initRepo(dir);
|
|
449
|
+
await appendPendingGate({ op: "nl-op", gate: "g1", planDigest: "sha256:" + "a".repeat(64) } as never, { cwd: dir });
|
|
450
|
+
const one = await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir });
|
|
451
|
+
expect(one!.endsWith("\n")).toBe(true);
|
|
452
|
+
await appendGateResolution(
|
|
453
|
+
{ op: "nl-op", gate: "g1", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
454
|
+
{ cwd: dir },
|
|
455
|
+
);
|
|
456
|
+
const raw = (await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir }))!;
|
|
457
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
458
|
+
expect(raw.endsWith("\n\n")).toBe(false);
|
|
459
|
+
const lines = raw.slice(0, -1).split("\n");
|
|
460
|
+
expect(lines).toHaveLength(2);
|
|
461
|
+
for (const l of lines) expect(() => JSON.parse(l)).not.toThrow();
|
|
462
|
+
const { malformed } = await readGateLedger("nl-op", { cwd: dir });
|
|
463
|
+
expect(malformed).toBe(0);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("a legacy ledger whose last line has no newline still reads and appends cleanly", async () => {
|
|
468
|
+
await withTestDir(async (dir) => {
|
|
469
|
+
await initRepo(dir);
|
|
470
|
+
await writeBlobToPath("_gates", "legacy.jsonl", JSON.stringify({ kind: "x" }), "seed", { cwd: dir });
|
|
471
|
+
await appendGateResolution(
|
|
472
|
+
{ op: "legacy", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
473
|
+
{ cwd: dir },
|
|
474
|
+
);
|
|
475
|
+
const raw = (await readBlobFromPath("_gates", "legacy.jsonl", { cwd: dir }))!;
|
|
476
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
477
|
+
expect(raw.split("\n").filter(Boolean)).toHaveLength(2);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -367,7 +367,7 @@ async function appendGateLine(
|
|
|
367
367
|
try {
|
|
368
368
|
const priorSha = await readPathSha(DIR, filename(record.op), opts);
|
|
369
369
|
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
370
|
-
const content = existing ? `${existing.replace(/\n$/, "")}\n
|
|
370
|
+
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
|
|
371
371
|
return await writeBlobToPath(DIR, filename(record.op), content, message, {
|
|
372
372
|
...opts,
|
|
373
373
|
expectPriorPathSha: priorSha,
|
package/src/lifecycle/git.ts
CHANGED
|
@@ -485,7 +485,7 @@ export async function appendReleaseRecordLine(
|
|
|
485
485
|
try {
|
|
486
486
|
const priorSha = await readPathSha(environment, filename, opts);
|
|
487
487
|
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
488
|
-
const content = existing ? `${existing.replace(/\n$/, "")}\n
|
|
488
|
+
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${recordJson}\n`;
|
|
489
489
|
return await writeBlobToPath(environment, filename, content, "Release record", {
|
|
490
490
|
...opts,
|
|
491
491
|
expectPriorPathSha: priorSha,
|
|
@@ -55,7 +55,7 @@ export async function recordOverridesApplied(records: readonly AppliedOverrideRe
|
|
|
55
55
|
try {
|
|
56
56
|
const priorSha = await readPathSha(APPLIED_DIR, APPLIED_FILE, opts);
|
|
57
57
|
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
58
|
-
const content = existing ? `${existing.replace(/\n$/, "")}\n
|
|
58
|
+
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${lines}\n`;
|
|
59
59
|
return await writeBlobToPath(APPLIED_DIR, APPLIED_FILE, content, `Applied under override: ${records.map((r) => r.gate).join(", ")}`, {
|
|
60
60
|
...opts,
|
|
61
61
|
expectPriorPathSha: priorSha,
|
|
@@ -160,7 +160,7 @@ export async function appendRunRecord(
|
|
|
160
160
|
try {
|
|
161
161
|
const priorSha = await readPathSha(record.env, filename, opts);
|
|
162
162
|
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
163
|
-
const content = existing ? `${existing.replace(/\n$/, "")}\n
|
|
163
|
+
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
|
|
164
164
|
const commit = await writeBlobToPath(record.env, filename, content, `Op run record: ${record.op}`, {
|
|
165
165
|
...opts,
|
|
166
166
|
expectPriorPathSha: priorSha,
|
|
@@ -408,7 +408,7 @@ async function appendLeaseHistory(record: LeaseHistoryRecord, opts: { cwd: strin
|
|
|
408
408
|
try {
|
|
409
409
|
const priorSha = await readPathSha(LEASES_DIR, file, opts);
|
|
410
410
|
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
411
|
-
const content = existing ? `${existing.replace(/\n$/, "")}\n
|
|
411
|
+
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
|
|
412
412
|
commit = await writeBlobToPath(LEASES_DIR, file, content, `Work lease ${record.event}: ${record.item} (${record.holder})`, {
|
|
413
413
|
...opts,
|
|
414
414
|
expectPriorPathSha: priorSha,
|
|
@@ -7,7 +7,7 @@ import { waveSetDigest } from "../gated-waves";
|
|
|
7
7
|
import { sealGateApproval } from "../workspace/trust/seal";
|
|
8
8
|
import { TestRepo, hasSshKeygen } from "../workspace/trust/test-repo";
|
|
9
9
|
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
10
|
-
import { memoryGateLedgerPort } from "./gate";
|
|
10
|
+
import { gitGateLedgerPort, memoryGateLedgerPort } from "./gate";
|
|
11
11
|
import { githubReviews, forgejoReviews, gitlabReviews, type MergedReview, type ReviewSource } from "./gate-review";
|
|
12
12
|
import type { OpWavesSpec } from "./op-waves";
|
|
13
13
|
import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
@@ -223,3 +223,55 @@ describe.skipIf(!hasSshKeygen)("a sealed wave (#3684)", () => {
|
|
|
223
223
|
expect(await run([approval("alice", alice.file)])).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
describe.skipIf(!hasSshKeygen)("identity.gates is read at the wave's --base (#3841)", () => {
|
|
228
|
+
const repo = new TestRepo("3841-base");
|
|
229
|
+
const alice = repo.key("alice");
|
|
230
|
+
afterAll(() => rmSync(join(repo.dir, ".."), { recursive: true, force: true }));
|
|
231
|
+
const GATE = "migrations-wave-1";
|
|
232
|
+
const waves = (approval: "ledger" | "sealed"): OpWavesSpec => ({ ...spec("sealed"), waves: [{ name: "prod", runs: [{ target: "prod" }], approval }] });
|
|
233
|
+
const workspace = (gates?: Record<string, object>) => JSON.stringify({ name: "acme", schema: 1, members: [], ...(gates ? { identity: { gates } } : {}) });
|
|
234
|
+
|
|
235
|
+
// Two commits on main: --base names the first, and main (the base a bare read falls back to) is the second.
|
|
236
|
+
function commits(atBase: string, atMain: string, approval: "ledger" | "sealed"): string {
|
|
237
|
+
repo.write("waves.json", JSON.stringify(waves(approval)));
|
|
238
|
+
repo.write(".chant/allowed_signers", `alice ${alice.pub}\n`);
|
|
239
|
+
repo.write("chant.workspace.json", atBase);
|
|
240
|
+
const base = repo.commit(`base (${approval})`);
|
|
241
|
+
repo.write("chant.workspace.json", atMain);
|
|
242
|
+
repo.commit(`main (${approval})`);
|
|
243
|
+
return base;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const plan = digest("a");
|
|
247
|
+
const set = waveSetDigest([{ member: "prod", planDigest: plan }]);
|
|
248
|
+
const approval = (by: string, keyFile?: string): GateResolutionRecord => {
|
|
249
|
+
const a = { op: "migrations", gate: GATE, resolvedBy: by, timestamp: "2026-10-10T00:00:00Z", planDigest: set };
|
|
250
|
+
return { version: 1, ...a, ...(keyFile ? { seal: sealGateApproval(keyFile, a) } : {}) };
|
|
251
|
+
};
|
|
252
|
+
const run = async (approvalMode: "ledger" | "sealed", base: string, resolutions: GateResolutionRecord[]) => {
|
|
253
|
+
const h = harness(plan, repo.dir);
|
|
254
|
+
const lines: string[] = [];
|
|
255
|
+
// The memory ledger, with the real port's identity.gates rule.
|
|
256
|
+
const gates = { ...memoryGateLedgerPort({ resolutions }), approvalRule: gitGateLedgerPort({ cwd: repo.dir }).approvalRule };
|
|
257
|
+
const result = await runOpWave({ spec: waves(approvalMode), specFile: "waves.json", wave: 1, base, cwd: repo.dir, exec: h.exec, gates, now: "2026-10-10T01:00:00Z", log: (l) => lines.push(l) });
|
|
258
|
+
return { result, lines };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
test("a gate --base seals holds the wave, though main does not seal it, and the log says why", async () => {
|
|
262
|
+
const base = commits(workspace({ [GATE]: {} }), workspace(), "ledger");
|
|
263
|
+
expect(repo.head()).not.toBe(base);
|
|
264
|
+
const { result, lines } = await run("ledger", base, [approval("alice")]);
|
|
265
|
+
expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", base } });
|
|
266
|
+
expect(lines.join("\n")).toMatch(/the approval by alice at 2026-10-10T00:00:00Z does not count: .*not signed/);
|
|
267
|
+
// A seal by a signer at base passes both.
|
|
268
|
+
expect((await run("ledger", base, [approval("alice", alice.file)])).result).toMatchObject({ exitCode: 0, applied: ["prod"] });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a rule main adds does not judge a wave whose --base has none", async () => {
|
|
272
|
+
// At main the gate needs a class no pinned package supplies, which nothing passes.
|
|
273
|
+
const base = commits(workspace(), workspace({ [GATE]: { class: "operator" } }), "sealed");
|
|
274
|
+
const { result } = await run("sealed", base, [approval("alice", alice.file)]);
|
|
275
|
+
expect(result).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
|
|
276
|
+
});
|
|
277
|
+
});
|
package/src/op/gate.ts
CHANGED
|
@@ -90,9 +90,11 @@ export interface GateLedgerPort {
|
|
|
90
90
|
/**
|
|
91
91
|
* The rule a workspace sets for approvals of `gate` (#3163, ws-080), or
|
|
92
92
|
* null when none applies: the declaration's `identity.gates`, read at base.
|
|
93
|
+
* `at.base` names that commit, as an op wave reads every rule at its
|
|
94
|
+
* `--base` (#3841); absent, origin/HEAD, else `main`, else `master`.
|
|
93
95
|
* Absent, every approval is judged as before.
|
|
94
96
|
*/
|
|
95
|
-
approvalRule?(gate: string): Promise<GateApprovalRule | null>;
|
|
97
|
+
approvalRule?(gate: string, at?: { base?: string }): Promise<GateApprovalRule | null>;
|
|
96
98
|
/** The approvals a wave applied under (#3784), from `_gates/<op>/applied.jsonl`. Absent on a port that never spends approvals. */
|
|
97
99
|
readApplied?(op: string): Promise<AppliedGateRecord[]>;
|
|
98
100
|
/** Record that a wave is about to apply under an approval (#3784), which spends it. */
|
|
@@ -132,10 +134,10 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
132
134
|
const { resolutions, pending } = await readGateLedger(op, opts);
|
|
133
135
|
return { resolutions, pending };
|
|
134
136
|
},
|
|
135
|
-
async approvalRule(gate) {
|
|
137
|
+
async approvalRule(gate, at) {
|
|
136
138
|
// Loaded on demand: the rule lives in the workspace modules, which import the Op modules.
|
|
137
139
|
const { gateAdmission } = await import("../workspace/identity");
|
|
138
|
-
return gateAdmission(opts?.cwd ?? process.cwd(), gate);
|
|
140
|
+
return gateAdmission(opts?.cwd ?? process.cwd(), gate, at?.base);
|
|
139
141
|
},
|
|
140
142
|
async appendResolution(input) {
|
|
141
143
|
const { record } = await appendGateResolution(input, opts);
|
|
@@ -355,8 +357,20 @@ export type GateCheck =
|
|
|
355
357
|
mismatch?: GateDigestMismatch;
|
|
356
358
|
/** Present on a gate with a quorum (#2508): who has approved this plan so far. */
|
|
357
359
|
quorum?: GateQuorumProgress;
|
|
360
|
+
/**
|
|
361
|
+
* Approvals of this gate and plan, newer than the standing pending fact,
|
|
362
|
+
* that the port's approval rule did not count, and why (#3841), oldest
|
|
363
|
+
* first. Absent when there are none.
|
|
364
|
+
*/
|
|
365
|
+
refused?: GateRefusedApproval[];
|
|
358
366
|
};
|
|
359
367
|
|
|
368
|
+
/** An approval the port's {@link GateLedgerPort.approvalRule} did not count, and why. */
|
|
369
|
+
export interface GateRefusedApproval {
|
|
370
|
+
resolution: GateResolutionRecord;
|
|
371
|
+
reason: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
360
374
|
/** How a recorded approval reads: its own `approver`, or for a record written before #2508, a human unless a model-authored channel wrote it. */
|
|
361
375
|
export function approverOf(record: GateResolutionRecord): GateApprover {
|
|
362
376
|
if (record.approver) return record.approver;
|
|
@@ -527,16 +541,24 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
527
541
|
const pending = env === undefined ? ledger.pending : ledger.pending.filter((p) => p.environment === env);
|
|
528
542
|
// #3163: a gate the workspace's identity.gates names counts only signed approvals from its class.
|
|
529
543
|
const rule = (await port.approvalRule?.(input.gate)) ?? null;
|
|
544
|
+
const standing = latestPendingGate(pending, input.gate);
|
|
545
|
+
const since = standing?.timestamp ?? EPOCH;
|
|
546
|
+
// #3841: an approval of this plan the rule refuses is named, so a wait says why.
|
|
547
|
+
const refused: GateRefusedApproval[] = [];
|
|
530
548
|
const resolutions = (
|
|
531
549
|
env === undefined
|
|
532
550
|
? ledger.resolutions
|
|
533
551
|
: ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
|
|
534
552
|
)
|
|
535
|
-
.filter((r) =>
|
|
536
|
-
.filter((r) =>
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
553
|
+
.filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r))
|
|
554
|
+
.filter((r) => {
|
|
555
|
+
if (rule === null || r.gate !== input.gate) return true;
|
|
556
|
+
const reason = rule.refuses(r);
|
|
557
|
+
if (reason === null) return true;
|
|
558
|
+
if (samePlanDigest(r.planDigest, input.planDigest) && new Date(r.timestamp).getTime() >= new Date(since).getTime()) refused.push({ resolution: r, reason });
|
|
559
|
+
return false;
|
|
560
|
+
});
|
|
561
|
+
refused.sort((a, b) => new Date(a.resolution.timestamp).getTime() - new Date(b.resolution.timestamp).getTime());
|
|
540
562
|
|
|
541
563
|
let mismatched: GateResolutionRecord | undefined;
|
|
542
564
|
let quorum: GateQuorumProgress | undefined;
|
|
@@ -579,7 +601,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
579
601
|
...(env !== undefined ? { environment: env } : {}),
|
|
580
602
|
}
|
|
581
603
|
: undefined;
|
|
582
|
-
const asMismatch = { ...(mismatch ? { mismatch } : {}), ...(quorum ? { quorum } : {}) };
|
|
604
|
+
const asMismatch = { ...(mismatch ? { mismatch } : {}), ...(quorum ? { quorum } : {}), ...(refused.length > 0 ? { refused } : {}) };
|
|
583
605
|
|
|
584
606
|
// A standing fact only stands for the plan it was recorded against. When
|
|
585
607
|
// the plan has moved, re-recording is what gives `chant approve` (which
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* `refused`. A wave whose plan, apply or stage hook failed (exit 1) sends
|
|
8
8
|
* `failed`. A wave the product's policy refused (exit 7, #3821) sends
|
|
9
9
|
* `refused` with the runs it denied, and one whose apply was held by another
|
|
10
|
-
* run (exit 5) sends `refused` with status `held
|
|
10
|
+
* run (exit 5) sends `refused` with status `held`, and one whose decision
|
|
11
|
+
* point refused it (exit 8, #3836) sends `refused` with status `point`; one that stood down for a
|
|
11
12
|
* newer run (exit 6) sends nothing. A share job of a waiting wave sends nothing, since its deciding
|
|
12
13
|
* 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
|
|
13
14
|
* is what the wave decided.
|
|
@@ -58,6 +59,17 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
|
|
|
58
59
|
: {}),
|
|
59
60
|
};
|
|
60
61
|
}
|
|
62
|
+
if (result.exitCode === 8 && decision?.refused?.reason === "point") {
|
|
63
|
+
// #3836: the gate's decision point was answered with an answer it does not pass on.
|
|
64
|
+
return {
|
|
65
|
+
...base,
|
|
66
|
+
event: "refused",
|
|
67
|
+
status: "point",
|
|
68
|
+
summary: `${label} refused: decision point ${decision.point?.name ?? ""} answered ${JSON.stringify(decision.point?.answer?.answer ?? null)}`,
|
|
69
|
+
targets: decision.refused.runs,
|
|
70
|
+
...(decision.refused.message ? { reason: decision.refused.message } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
61
73
|
if (result.exitCode === 7 && decision?.refused) {
|
|
62
74
|
// #3821: the product's policy denied a run, and no override counts for it.
|
|
63
75
|
return {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wave gate that asks a workspace decision point (#3836), against a stub
|
|
3
|
+
* asker and an in-memory gate ledger: the point is read at base, an open
|
|
4
|
+
* question waits (exit 3) with a pending fact citing it and the `points
|
|
5
|
+
* answer` line as `approve`, a passing answer for the set digest lets the
|
|
6
|
+
* wave apply under a resolution that cites it, any other answer refuses the
|
|
7
|
+
* wave (exit 8), and the waiting pending fact is what `chant run resume`
|
|
8
|
+
* resumes once the question is answered (#3835).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
|
|
16
|
+
import { runOpWave, type OpWaveDecision, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
17
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
18
|
+
import { waveSetDigest } from "../gated-waves";
|
|
19
|
+
import { pointGateResumable, type GatePointAsker, type GatePointQuestion, type GatePointRequest } from "../point-decision";
|
|
20
|
+
import { resumeStanding } from "../cli/handlers/gate-resume";
|
|
21
|
+
import { opWaveNotifyFields } from "./op-waves-notify";
|
|
22
|
+
|
|
23
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
24
|
+
const NOW = "2026-10-11T10:00:00.000Z";
|
|
25
|
+
|
|
26
|
+
const spec: OpWavesSpec = {
|
|
27
|
+
name: "tf-apply",
|
|
28
|
+
op: "apply",
|
|
29
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
30
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
31
|
+
point: { name: "prod-apply", timeout: "48h" },
|
|
32
|
+
waves: [
|
|
33
|
+
{ name: "prod", runs: [{ target: "db" }, { target: "net" }], gate: "always" },
|
|
34
|
+
{ name: "wide", runs: [{ target: "a" }, { target: "b" }], gate: "always", shares: 2 },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A stub asker: each question's state comes from `answers`, by the plan digest it was asked about. */
|
|
39
|
+
function stubAsker(answers: Map<string, Partial<GatePointQuestion>> = new Map()): GatePointAsker & { asked: GatePointRequest[] } {
|
|
40
|
+
const asked: GatePointRequest[] = [];
|
|
41
|
+
return {
|
|
42
|
+
asked,
|
|
43
|
+
async ask(request) {
|
|
44
|
+
asked.push(request);
|
|
45
|
+
const id = `${request.point}-${(request.facts.planDigest ?? "none").slice(7, 19)}`;
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
point: request.point,
|
|
49
|
+
path: `.chant/answers/${id}.md`,
|
|
50
|
+
state: "escalated",
|
|
51
|
+
answer: null,
|
|
52
|
+
decider: "quorum",
|
|
53
|
+
answeredBy: [],
|
|
54
|
+
subject: request.subject,
|
|
55
|
+
steward: null,
|
|
56
|
+
...(answers.get(request.facts.planDigest ?? "") ?? {}),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function harness(atBase: OpWavesSpec | null = spec) {
|
|
63
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-point-"));
|
|
64
|
+
const calls: string[] = [];
|
|
65
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
66
|
+
calls.push(argv.join(" "));
|
|
67
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2]!.slice(0, 1)) }));
|
|
68
|
+
return 0;
|
|
69
|
+
};
|
|
70
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
|
|
71
|
+
return { cwd, calls, exec, show, applied: () => calls.filter((c) => c.startsWith("tool apply")), done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const prodSet = waveSetDigest([
|
|
75
|
+
{ member: "db", planDigest: digest("d") },
|
|
76
|
+
{ member: "net", planDigest: digest("n") },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
80
|
+
|
|
81
|
+
describe("a wave gate that asks a decision point (#3836)", () => {
|
|
82
|
+
test("the spec takes a point on the spec or a wave, and refuses a bad one", () => {
|
|
83
|
+
expect(() => assertOpWavesSpec(spec)).not.toThrow();
|
|
84
|
+
expect(() => assertOpWavesSpec({ ...spec, point: "prod-apply" })).not.toThrow();
|
|
85
|
+
expect(() => assertOpWavesSpec({ ...spec, point: { name: "Prod Apply" } })).toThrow(/decision point's name/);
|
|
86
|
+
expect(() => assertOpWavesSpec({ ...spec, point: { name: "prod-apply", timeout: "soon" } })).toThrow(/point.timeout/);
|
|
87
|
+
expect(() => assertOpWavesSpec({ ...spec, waves: [{ ...spec.waves[0]!, point: { name: "prod-apply", inputs: { "gate.planDigest": "x" } } }] })).toThrow(/wave "prod".*passes itself/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a wave waits on the point's question, it is answered yes, and the wave applies under a resolution that cites the answer", async () => {
|
|
91
|
+
const h = harness();
|
|
92
|
+
const gates = memoryGateLedgerPort();
|
|
93
|
+
const answers = new Map<string, Partial<GatePointQuestion>>();
|
|
94
|
+
const asker = stubAsker(answers);
|
|
95
|
+
|
|
96
|
+
const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
|
|
97
|
+
expect(waiting.exitCode).toBe(3);
|
|
98
|
+
expect(asker.asked[0]).toMatchObject({ point: "prod-apply", facts: { component: "tf-apply", name: "tf-apply-wave-1", planDigest: prodSet }, subject: "gate:tf-apply/tf-apply-wave-1" });
|
|
99
|
+
const id = asker.asked[0]!.point + "-" + prodSet.slice(7, 19);
|
|
100
|
+
expect(waiting.decision).toMatchObject({
|
|
101
|
+
status: "waiting",
|
|
102
|
+
digest: prodSet,
|
|
103
|
+
point: { name: "prod-apply", question: { id, state: "escalated" } },
|
|
104
|
+
approve: `chant workspace points answer ${id} --answer <answer> --by <your name>`,
|
|
105
|
+
pendingFact: { gate: "tf-apply-wave-1", planDigest: prodSet, answer: { point: "prod-apply", id } },
|
|
106
|
+
});
|
|
107
|
+
// The pending fact stands for 48h, as the point's timeout says.
|
|
108
|
+
expect(gates.appended[0]!.expiresAt).toBe("2026-10-13T10:00:00.000Z");
|
|
109
|
+
expect(h.applied()).toEqual([]);
|
|
110
|
+
expect(opWaveNotifyFields(spec, 1, { result: waiting })).toMatchObject({ event: "waiting", approve: { command: expect.stringContaining("points answer") } });
|
|
111
|
+
|
|
112
|
+
// A `chant approve` of the digest does not pass a gate that asks a point.
|
|
113
|
+
const approvedByHand = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-1", resolvedBy: "mallory", timestamp: NOW, planDigest: prodSet }] });
|
|
114
|
+
expect((await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: approvedByHand, points: stubAsker(), now: NOW, log: () => {} })).exitCode).toBe(3);
|
|
115
|
+
|
|
116
|
+
answers.set(prodSet, { state: "answered", answer: true, answeredBy: ["alice"] });
|
|
117
|
+
const passed = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
|
|
118
|
+
expect(passed).toMatchObject({ exitCode: 0, applied: ["db", "net"], decision: { status: "approved", via: "point", approvedBy: "alice", point: { answer: { id, answer: true } } } });
|
|
119
|
+
expect(gates.resolved).toHaveLength(1);
|
|
120
|
+
expect(gates.resolved[0]).toMatchObject({ gate: "tf-apply-wave-1", planDigest: prodSet, answer: { point: "prod-apply", id, answer: true } });
|
|
121
|
+
|
|
122
|
+
// Asked again for the same plan, the resolution already stands: none is written twice.
|
|
123
|
+
await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
|
|
124
|
+
expect(gates.resolved).toHaveLength(1);
|
|
125
|
+
h.done();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("each plan is its own question: a yes for one set digest does not pass another", async () => {
|
|
129
|
+
const h = harness();
|
|
130
|
+
const other = digest("z");
|
|
131
|
+
const asker = stubAsker(new Map([[other, { state: "answered", answer: true, answeredBy: ["alice"] }]]));
|
|
132
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: () => {} });
|
|
133
|
+
expect(result.exitCode).toBe(3);
|
|
134
|
+
h.done();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an answer the gate does not pass on refuses the wave with exit 8, and nothing applies", async () => {
|
|
138
|
+
const h = harness();
|
|
139
|
+
const asker = stubAsker(new Map([[prodSet, { state: "answered", answer: false, answeredBy: ["bob"] }]]));
|
|
140
|
+
const logs: string[] = [];
|
|
141
|
+
const refused = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: (l) => logs.push(l) });
|
|
142
|
+
expect(refused).toMatchObject({
|
|
143
|
+
exitCode: 8,
|
|
144
|
+
applied: [],
|
|
145
|
+
decision: { status: "refused", refused: { reason: "point", runs: ["db", "net"] }, point: { answer: { answer: false } } },
|
|
146
|
+
});
|
|
147
|
+
expect(refused.decision!.refused!.message).toMatch(/answered false .* passes only on true/);
|
|
148
|
+
expect(h.applied()).toEqual([]);
|
|
149
|
+
expect(opWaveNotifyFields(spec, 1, { result: refused })).toMatchObject({ event: "refused", status: "point", targets: ["db", "net"] });
|
|
150
|
+
h.done();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a wide wave's shares refuse with exit 8 when the deciding job's point said no", async () => {
|
|
154
|
+
const h = harness();
|
|
155
|
+
const set = waveSetDigest([
|
|
156
|
+
{ member: "a", planDigest: digest("a") },
|
|
157
|
+
{ member: "b", planDigest: digest("b") },
|
|
158
|
+
]);
|
|
159
|
+
const asker = stubAsker(new Map([[set, { state: "answered", answer: false, answeredBy: ["bob"] }]]));
|
|
160
|
+
const decided = await runOpWave({ spec, specFile: "waves.json", wave: 2, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: () => {} });
|
|
161
|
+
expect(decided.exitCode).toBe(8);
|
|
162
|
+
const record = JSON.parse(readFileSync(join(h.cwd, ".chant/op-waves/tf-apply/wave-2.json"), "utf-8")) as OpWaveDecision;
|
|
163
|
+
expect(record.refused?.reason).toBe("point");
|
|
164
|
+
const share = await runOpWave({ spec, specFile: "waves.json", wave: 2, share: 1, cwd: h.cwd, exec: h.exec, show: h.show, log: () => {} });
|
|
165
|
+
expect(share.exitCode).toBe(8);
|
|
166
|
+
expect(h.applied()).toEqual([]);
|
|
167
|
+
h.done();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("the point is read at base: a change can neither add one nor drop one for its own gate", async () => {
|
|
171
|
+
const bare: OpWavesSpec = { ...spec, point: undefined };
|
|
172
|
+
// The change adds a point, the base has none: the ledger decides.
|
|
173
|
+
const added = harness(bare);
|
|
174
|
+
const ledgerApproved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-1", resolvedBy: "alice", timestamp: NOW, planDigest: prodSet }] });
|
|
175
|
+
const asker = stubAsker();
|
|
176
|
+
const logs: string[] = [];
|
|
177
|
+
const viaLedger = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: added.cwd, exec: added.exec, show: added.show, gates: ledgerApproved, points: asker, now: NOW, log: (l) => logs.push(l) });
|
|
178
|
+
expect(viaLedger).toMatchObject({ exitCode: 0, decision: { status: "approved", via: "ledger" } });
|
|
179
|
+
expect(asker.asked).toHaveLength(0);
|
|
180
|
+
expect(logs.some((l) => l.includes("the one at base holds"))).toBe(true);
|
|
181
|
+
added.done();
|
|
182
|
+
|
|
183
|
+
// The change drops the point the base has: the point still decides.
|
|
184
|
+
const dropped = harness(spec);
|
|
185
|
+
const still = await runOpWave({ spec: bare, specFile: "waves.json", wave: 1, cwd: dropped.cwd, exec: dropped.exec, show: dropped.show, gates: ledgerApproved, points: asker, now: NOW, log: () => {} });
|
|
186
|
+
expect(still.exitCode).toBe(3);
|
|
187
|
+
expect(asker.asked).toHaveLength(1);
|
|
188
|
+
dropped.done();
|
|
189
|
+
|
|
190
|
+
// A wave's own point wins over the spec's.
|
|
191
|
+
const own: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, point: { name: "db-apply", pass: ["apply"] } }, spec.waves[1]!] };
|
|
192
|
+
const mine = harness(own);
|
|
193
|
+
const ownAsker = stubAsker(new Map([[prodSet, { state: "answered", answer: "apply", answeredBy: ["carol"] }]]));
|
|
194
|
+
expect(await runOpWave({ spec: own, specFile: "waves.json", wave: 1, cwd: mine.cwd, exec: mine.exec, show: mine.show, gates: memoryGateLedgerPort(), points: ownAsker, now: NOW, log: () => {} })).toMatchObject({
|
|
195
|
+
exitCode: 0,
|
|
196
|
+
decision: { via: "point", approvedBy: "carol" },
|
|
197
|
+
});
|
|
198
|
+
expect(ownAsker.asked[0]!.point).toBe("db-apply");
|
|
199
|
+
mine.done();
|
|
200
|
+
|
|
201
|
+
// A point at base no gate could ask fails the wave rather than fall back to the ledger.
|
|
202
|
+
const broken = harness({ ...spec, point: { name: "Not A Point" } });
|
|
203
|
+
const failed = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: broken.cwd, exec: broken.exec, show: broken.show, gates: ledgerApproved, points: asker, now: NOW, log: () => {} });
|
|
204
|
+
expect(failed.exitCode).toBe(1);
|
|
205
|
+
expect(broken.calls).toEqual([]);
|
|
206
|
+
broken.done();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a policy that lets the wave through asks nothing", async () => {
|
|
210
|
+
const free: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, gate: "never" }, spec.waves[1]!] };
|
|
211
|
+
const h = harness(free);
|
|
212
|
+
const asker = stubAsker();
|
|
213
|
+
expect(await runOpWave({ spec: free, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, log: () => {} })).toMatchObject({
|
|
214
|
+
exitCode: 0,
|
|
215
|
+
decision: { status: "not-required" },
|
|
216
|
+
});
|
|
217
|
+
expect(asker.asked).toHaveLength(0);
|
|
218
|
+
h.done();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("resume picks up the waiting wave once its question is answered (#3835)", async () => {
|
|
222
|
+
vi.stubEnv("GITLAB_CI", "");
|
|
223
|
+
vi.stubEnv("GITHUB_ACTIONS", "true");
|
|
224
|
+
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
225
|
+
vi.stubEnv("GITHUB_RUN_ID", "4242");
|
|
226
|
+
vi.stubEnv("GITHUB_RUN_ATTEMPT", "1");
|
|
227
|
+
vi.stubEnv("FORGEJO_ACTIONS", "");
|
|
228
|
+
vi.stubEnv("GITEA_ACTIONS", "");
|
|
229
|
+
const h = harness();
|
|
230
|
+
const gates = memoryGateLedgerPort();
|
|
231
|
+
const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: stubAsker(), now: NOW, log: () => {} });
|
|
232
|
+
expect(waiting.exitCode).toBe(3);
|
|
233
|
+
const fact = gates.appended[0]!;
|
|
234
|
+
expect(fact.resume).toMatchObject({ forge: "github", repo: "acme/infra", run: "4242" });
|
|
235
|
+
const id = fact.answer!.id;
|
|
236
|
+
|
|
237
|
+
const open = { id, state: "escalated" as const, answer: null, answeredBy: [], answeredOn: null };
|
|
238
|
+
expect(pointGateResumable(fact, open)).toMatchObject({ resumable: false, why: "open" });
|
|
239
|
+
expect(await resumeStanding(fact, [], { questions: new Map([[id, open]]), dryRun: true })).toMatchObject({ status: "not-approved" });
|
|
240
|
+
|
|
241
|
+
const no = { ...open, state: "answered" as const, answer: false, answeredBy: ["bob"], answeredOn: NOW };
|
|
242
|
+
expect(pointGateResumable(fact, no)).toMatchObject({ resumable: false, why: "refused" });
|
|
243
|
+
|
|
244
|
+
const yes = { ...open, state: "answered" as const, answer: true, answeredBy: ["alice"], answeredOn: NOW };
|
|
245
|
+
expect(await resumeStanding(fact, [], { questions: new Map([[id, yes]]), dryRun: true })).toMatchObject({
|
|
246
|
+
op: "tf-apply",
|
|
247
|
+
gate: "tf-apply-wave-1",
|
|
248
|
+
status: "skipped",
|
|
249
|
+
approvedBy: "alice",
|
|
250
|
+
message: expect.stringContaining("would resume github run 4242"),
|
|
251
|
+
});
|
|
252
|
+
h.done();
|
|
253
|
+
});
|
|
254
|
+
});
|