@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
package/src/op/op-waves-run.ts
CHANGED
|
@@ -37,6 +37,15 @@
|
|
|
37
37
|
* {@link runOpWaveApplyHooks}, and their outcomes come back through
|
|
38
38
|
* `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
|
|
39
39
|
* {@link opWaveHooksAtBase}.
|
|
40
|
+
* - Point (#3836): with `point` at base (the wave's, else the spec's), a wave
|
|
41
|
+
* that needs an approval asks that decision point through
|
|
42
|
+
* `evaluatePointGate` (`../point-decision.ts`) with `gate.planDigest` set to
|
|
43
|
+
* its set digest: an open question waits (exit 3, the pending fact cites
|
|
44
|
+
* it, `approve` is the `points answer` line), an answer the gate passes on
|
|
45
|
+
* records the resolution that cites it and the wave goes on, and any other
|
|
46
|
+
* answer refuses the wave (`refused.reason: "point"`, exit 8). Once the
|
|
47
|
+
* question is answered, `chant run resume --op <name>` starts the job again
|
|
48
|
+
* (`pointGateResumable`, #3835).
|
|
40
49
|
*/
|
|
41
50
|
|
|
42
51
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -44,8 +53,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
44
53
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
54
|
import { waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
55
|
import type { ReviewApproval } from "../gate-decision";
|
|
47
|
-
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
48
|
-
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
56
|
+
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
57
|
+
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
58
|
+
import type { GatePointAsker } from "../point-decision";
|
|
59
|
+
import type { WaitingPoint } from "./steward-points";
|
|
49
60
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
50
61
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
51
62
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
@@ -60,6 +71,8 @@ import {
|
|
|
60
71
|
opWaveApplyHooksRunBy,
|
|
61
72
|
opWaveGateName,
|
|
62
73
|
opWaveHooksAt,
|
|
74
|
+
opWavePointAt,
|
|
75
|
+
opWavePointProblems,
|
|
63
76
|
opWaveRecordPath,
|
|
64
77
|
opWaveShare,
|
|
65
78
|
type GateApprovalSource,
|
|
@@ -68,6 +81,7 @@ import {
|
|
|
68
81
|
type OpWaveGatePolicy,
|
|
69
82
|
type OpWaveHook,
|
|
70
83
|
type OpWaveHookMoment,
|
|
84
|
+
type OpWavePoint,
|
|
71
85
|
type OpWaveRun,
|
|
72
86
|
type OpWavesSpec,
|
|
73
87
|
} from "./op-waves";
|
|
@@ -169,8 +183,14 @@ export interface OpWaveDecision {
|
|
|
169
183
|
*/
|
|
170
184
|
status: "approved" | "not-required" | "waiting" | "refused";
|
|
171
185
|
approvedBy?: string;
|
|
172
|
-
/** How the approval arrived: a resolution on the ledger,
|
|
173
|
-
via?: "ledger" | "pr-review";
|
|
186
|
+
/** How the approval arrived: a resolution on the ledger, the merged pull request's review (#3684), or a decision point's answer (#3836). */
|
|
187
|
+
via?: "ledger" | "pr-review" | "point";
|
|
188
|
+
/**
|
|
189
|
+
* The decision point the gate asks, as read at base (#3836), and what it
|
|
190
|
+
* said: the open `question` while the wave waits, the `answer` once it is
|
|
191
|
+
* answered (the one that passed, or the one that refused the wave).
|
|
192
|
+
*/
|
|
193
|
+
point?: OpWavePoint & { question?: WaitingPoint; answer?: GateAnswerRef };
|
|
174
194
|
/** The review that approved the wave, or why the review did not, under `pr-review`. */
|
|
175
195
|
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
176
196
|
/** The command that approves this digest, when waiting. */
|
|
@@ -208,8 +228,19 @@ export interface OpWaveDecision {
|
|
|
208
228
|
waitingSince?: string;
|
|
209
229
|
/** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
|
|
210
230
|
mismatch?: GateDigestMismatch;
|
|
211
|
-
/**
|
|
212
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Why a `refused` wave applies nothing: the runs the policy denied, and
|
|
233
|
+
* whether an override stands for an earlier plan or other rules (#3821,
|
|
234
|
+
* exit 7); or `point`, the gate's decision point answered with an answer
|
|
235
|
+
* it does not pass on (#3836, exit 8), with every run and the `message`.
|
|
236
|
+
*/
|
|
237
|
+
refused?: {
|
|
238
|
+
reason: "policy" | "override-moved" | "point";
|
|
239
|
+
runs: string[];
|
|
240
|
+
message?: string;
|
|
241
|
+
/** Where the plans file said its overriders come from (#3848), when it did. */
|
|
242
|
+
overridersSource?: string;
|
|
243
|
+
};
|
|
213
244
|
/**
|
|
214
245
|
* A hook held the wave on another gate (#3821), such as a state migration
|
|
215
246
|
* behind its own: that gate, its digest, and how it came out. While it
|
|
@@ -267,6 +298,20 @@ export interface OpWaveHeldOn {
|
|
|
267
298
|
waitingSince?: string;
|
|
268
299
|
recorded?: boolean;
|
|
269
300
|
mismatch?: GateDigestMismatch;
|
|
301
|
+
/**
|
|
302
|
+
* The gates the same hook call held the wave on before this one, each
|
|
303
|
+
* approved, oldest first (#3847): its approved re-runs asked for this one.
|
|
304
|
+
*/
|
|
305
|
+
after?: OpWaveHeldOnApproval[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** An approved hold on another gate (#3847), as `CHANT_HOLD_APPROVED` and `CHANT_HOLDS_APPROVED` carry it. */
|
|
309
|
+
export interface OpWaveHeldOnApproval {
|
|
310
|
+
op: string;
|
|
311
|
+
gate: string;
|
|
312
|
+
digest: string;
|
|
313
|
+
approvedBy: string;
|
|
314
|
+
approvedAt: string;
|
|
270
315
|
}
|
|
271
316
|
|
|
272
317
|
/** One hook's run (#3762). */
|
|
@@ -426,6 +471,10 @@ export interface OpWaveBaseRules {
|
|
|
426
471
|
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
427
472
|
/** The spec at base sets `neverOverMcp` (#3821). */
|
|
428
473
|
neverOverMcp: boolean;
|
|
474
|
+
/** The decision point the wave's gate asks, as read at base (#3836): the wave's, else the spec's. */
|
|
475
|
+
point?: OpWavePoint;
|
|
476
|
+
/** Set when the point at base cannot be asked: the wave fails rather than fall back to the ledger (#3836). */
|
|
477
|
+
pointRefused?: string;
|
|
429
478
|
}
|
|
430
479
|
|
|
431
480
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -457,12 +506,14 @@ export function readOpWavePolicy(
|
|
|
457
506
|
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
458
507
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
459
508
|
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
509
|
+
// #3836: the gate's decision point, the spec's for a wave the base does not have yet.
|
|
510
|
+
const asks = pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`);
|
|
460
511
|
const wave = spec.waves.find((w) => w.name === name);
|
|
461
|
-
if (!wave) return { ...absent, spend, neverOverMcp };
|
|
512
|
+
if (!wave) return { ...absent, spend, neverOverMcp, ...asks };
|
|
462
513
|
const policy = wave.gate ?? "always";
|
|
463
514
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
464
515
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
465
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
|
|
516
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp, ...asks };
|
|
466
517
|
try {
|
|
467
518
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
468
519
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
@@ -475,6 +526,15 @@ export function readOpWavePolicy(
|
|
|
475
526
|
return rules;
|
|
476
527
|
}
|
|
477
528
|
|
|
529
|
+
/** The point wave `name` asks at base (#3836), or why it cannot be asked. */
|
|
530
|
+
function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWaveBaseRules, "point" | "pointRefused"> {
|
|
531
|
+
const raw = spec.waves.find((w) => w.name === name)?.point ?? spec.point;
|
|
532
|
+
if (raw === undefined) return {};
|
|
533
|
+
const problems = opWavePointProblems(raw);
|
|
534
|
+
if (problems.length > 0) return { pointRefused: `the decision point at base cannot be asked (${where}: ${problems.join("; ")})` };
|
|
535
|
+
return { point: opWavePointAt(spec, name)! };
|
|
536
|
+
}
|
|
537
|
+
|
|
478
538
|
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
479
539
|
export function opWaveArgv(
|
|
480
540
|
template: readonly string[],
|
|
@@ -533,6 +593,8 @@ export interface RunOpWaveOptions {
|
|
|
533
593
|
trust?: (base: string) => TrustPolicy;
|
|
534
594
|
/** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
|
|
535
595
|
signers?: (base: string) => GateSigners;
|
|
596
|
+
/** How a gate with a `point` asks it (#3836). Default: `workspaceGatePointAsker` over `cwd`. A tool that bundles chant passes `chantGatePointAsker`. */
|
|
597
|
+
points?: GatePointAsker;
|
|
536
598
|
}
|
|
537
599
|
|
|
538
600
|
export interface RunOpWaveResult {
|
|
@@ -541,7 +603,8 @@ export interface RunOpWaveResult {
|
|
|
541
603
|
* approval, 4 a plan moved since the decision, 5 the apply applied nothing
|
|
542
604
|
* because another run holds what it changes, 6 the apply stood down for a
|
|
543
605
|
* newer run, 7 the product's policy denied a run and no override counts
|
|
544
|
-
* (5, 6 and 7: #3821)
|
|
606
|
+
* (5, 6 and 7: #3821), 8 the gate's decision point was answered with an
|
|
607
|
+
* answer it does not pass on (#3836).
|
|
545
608
|
*/
|
|
546
609
|
exitCode: number;
|
|
547
610
|
/** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
|
|
@@ -690,6 +753,10 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
690
753
|
return m;
|
|
691
754
|
});
|
|
692
755
|
const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
|
|
756
|
+
const overridersSource = Array.isArray(parsed) ? undefined : (parsed as { overridersSource?: unknown }).overridersSource;
|
|
757
|
+
if (overridersSource !== undefined && (typeof overridersSource !== "string" || overridersSource.trim() === "")) {
|
|
758
|
+
throw new Error(`the plans file of wave ${k} (${plans}): \`overridersSource\` must be a label for where the overriders come from, such as "policy.override in terragucci.yml"`);
|
|
759
|
+
}
|
|
693
760
|
if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
|
|
694
761
|
throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
|
|
695
762
|
}
|
|
@@ -697,6 +764,7 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
697
764
|
members,
|
|
698
765
|
...(pending ? { pending } : {}),
|
|
699
766
|
...(overriders ? { overriders: overriders as string[] } : {}),
|
|
767
|
+
...(overriders && overridersSource ? { overridersSource: overridersSource as string } : {}),
|
|
700
768
|
...(extras ? { extras } : {}),
|
|
701
769
|
...(count !== undefined ? { waves: count as number } : {}),
|
|
702
770
|
};
|
|
@@ -708,6 +776,8 @@ interface PlannedRuns {
|
|
|
708
776
|
pending?: Record<string, unknown>;
|
|
709
777
|
/** The plans file's overriders (#3821). */
|
|
710
778
|
overriders?: string[];
|
|
779
|
+
/** A label for where those overriders come from, used in refusals (#3848). */
|
|
780
|
+
overridersSource?: string;
|
|
711
781
|
/** Digest members that are not runs, such as the wave's cost (#3821). */
|
|
712
782
|
extras?: WaveMember[];
|
|
713
783
|
/** How many waves the product found (#3821), for a `rest` wave. */
|
|
@@ -870,7 +940,9 @@ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], memb
|
|
|
870
940
|
* Run one moment's hooks in the wave job (#3762), recording each outcome on
|
|
871
941
|
* the result. A hook that held the wave on another gate (#3821) has that
|
|
872
942
|
* gate settled here: approved, the hook runs again with
|
|
873
|
-
* `CHANT_HOLD_APPROVED
|
|
943
|
+
* `CHANT_HOLD_APPROVED`; otherwise the wave waits on it. The re-run passes,
|
|
944
|
+
* or holds the wave on a further gate (#3847), settled the same way, up to
|
|
945
|
+
* {@link OP_WAVE_MAX_CHAINED_HOLDS} gates per hook call.
|
|
874
946
|
*/
|
|
875
947
|
async function runHooks(
|
|
876
948
|
ctx: WaveContext,
|
|
@@ -887,35 +959,74 @@ async function runHooks(
|
|
|
887
959
|
ctx.result.hooks.push(...out.outcomes);
|
|
888
960
|
const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
889
961
|
if (out.failed) return back;
|
|
890
|
-
for (const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
962
|
+
for (const first of out.otherGates) {
|
|
963
|
+
// #3847: an approved re-run may hold the wave on a further gate (a second state migration that plans against the first one's states), settled in turn.
|
|
964
|
+
let hold = first;
|
|
965
|
+
const approved: OpWaveHeldOnApproval[] = [];
|
|
966
|
+
for (;;) {
|
|
967
|
+
const held = await settleOtherGate(ctx, hold);
|
|
968
|
+
if (held.status === "waiting") {
|
|
969
|
+
if (approved.length > 0) held.after = approved;
|
|
970
|
+
back.waitingOn ??= held;
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
const now: OpWaveHeldOnApproval = { op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy!, approvedAt: held.approvedAt! };
|
|
974
|
+
approved.push(now);
|
|
975
|
+
const approvedEnv = {
|
|
976
|
+
...hold.env,
|
|
977
|
+
CHANT_HOLD_APPROVED: JSON.stringify(now),
|
|
978
|
+
// Every hold this hook call was approved through so far, oldest first (#3847).
|
|
979
|
+
CHANT_HOLDS_APPROVED: JSON.stringify(approved),
|
|
980
|
+
};
|
|
981
|
+
const holdPath = join(ctx.cwd, hold.env.CHANT_HOLD!);
|
|
982
|
+
rmSync(holdPath, { force: true });
|
|
983
|
+
let code: number;
|
|
984
|
+
try {
|
|
985
|
+
code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
|
|
986
|
+
} catch (err) {
|
|
987
|
+
ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
988
|
+
code = 127;
|
|
989
|
+
}
|
|
990
|
+
const target = hold.target ? { target: hold.target } : {};
|
|
991
|
+
if (code === 0) {
|
|
992
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "passed" });
|
|
993
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
const next = readHoldRequest(holdPath, ctx.log, hold.label);
|
|
997
|
+
const problem = !next
|
|
998
|
+
? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`
|
|
999
|
+
: approved.some((a) => a.op === (next.op ?? ctx.spec.name) && a.gate === next.gate && a.digest === next.digest)
|
|
1000
|
+
? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}, and holds the wave again on ${next.op ?? ctx.spec.name} / ${next.gate} at ${next.digest}, which is already approved`
|
|
1001
|
+
: approved.length >= OP_WAVE_MAX_CHAINED_HOLDS
|
|
1002
|
+
? `${hold.label} held the wave on ${approved.length} gates in turn and asks for one more; a hook call holds on at most ${OP_WAVE_MAX_CHAINED_HOLDS}`
|
|
1003
|
+
: undefined;
|
|
1004
|
+
if (problem) {
|
|
1005
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "failed" });
|
|
1006
|
+
back.failed = problem;
|
|
1007
|
+
ctx.log(back.failed);
|
|
1008
|
+
return back;
|
|
1009
|
+
}
|
|
1010
|
+
ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "held" });
|
|
1011
|
+
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate}; the hook ran again, exited ${code} and holds wave ${ctx.k} (${ctx.wave.name}) on gate ${next!.op ?? ctx.spec.name} / ${next!.gate}, digest ${next!.digest}`);
|
|
1012
|
+
hold = { ...hold, request: next! };
|
|
909
1013
|
}
|
|
910
|
-
ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
|
|
911
1014
|
}
|
|
912
1015
|
return back;
|
|
913
1016
|
}
|
|
914
1017
|
|
|
915
|
-
/**
|
|
1018
|
+
/** How many gates one hook call holds a wave on in turn (#3847): the first hold and the holds its approved re-runs ask for. */
|
|
1019
|
+
export const OP_WAVE_MAX_CHAINED_HOLDS = 10;
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The gate ledger a wave decides by: the job's port, its `identity.gates`
|
|
1023
|
+
* rule read at the wave's base (#3841), and the signers at base as a second
|
|
1024
|
+
* rule when `approval` is `sealed`.
|
|
1025
|
+
*/
|
|
916
1026
|
async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
|
|
917
1027
|
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
918
|
-
|
|
1028
|
+
const atBase = (g: string) => ledger.approvalRule?.(g, { base }) ?? Promise.resolve(null);
|
|
1029
|
+
if (approval !== "sealed") return { ledger, port: ledger.approvalRule ? { ...ledger, approvalRule: atBase } : ledger };
|
|
919
1030
|
// #3684: only an approval sealed by a signer listed at base counts.
|
|
920
1031
|
const review = await import("./gate-review");
|
|
921
1032
|
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
@@ -924,13 +1035,19 @@ async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, ap
|
|
|
924
1035
|
port: {
|
|
925
1036
|
...ledger,
|
|
926
1037
|
async approvalRule(g) {
|
|
927
|
-
const own =
|
|
1038
|
+
const own = await atBase(g);
|
|
928
1039
|
return g === gate ? review.bothRules(own, sealed) : own;
|
|
929
1040
|
},
|
|
930
1041
|
},
|
|
931
1042
|
};
|
|
932
1043
|
}
|
|
933
1044
|
|
|
1045
|
+
/** Log each approval of the digest a rule did not count (#3841), so a wait says why. */
|
|
1046
|
+
function logRefused(log: (line: string) => void, label: string, check: GateCheck): void {
|
|
1047
|
+
if (check.satisfied) return;
|
|
1048
|
+
for (const { resolution: r, reason } of check.refused ?? []) log(`${label}: the approval by ${r.resolvedBy} at ${r.timestamp} does not count: ${reason}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
934
1051
|
/**
|
|
935
1052
|
* Settle a hold on another gate (#3821): an approval of that gate and digest
|
|
936
1053
|
* under the wave's approval mode (`pr-review` counts the ledger alone, since
|
|
@@ -950,10 +1067,12 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
|
|
|
950
1067
|
planDigest: hold.request.digest,
|
|
951
1068
|
description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
|
|
952
1069
|
now,
|
|
953
|
-
|
|
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 } },
|
|
954
1072
|
...(neverOverMcp ? { neverOverMcp: true } : {}),
|
|
955
1073
|
});
|
|
956
1074
|
const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
|
|
1075
|
+
logRefused(log, `${op} / ${hold.request.gate}`, check);
|
|
957
1076
|
if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
|
|
958
1077
|
const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
|
|
959
1078
|
log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
|
|
@@ -1171,7 +1290,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1171
1290
|
ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
|
|
1172
1291
|
if (decision.status === "waiting" || decision.status === "refused") {
|
|
1173
1292
|
log(`wave ${k} (${wave.name}) is ${decision.status === "refused" ? "refused by its policy" : `waiting on ${decision.heldOn?.gate ?? decision.gate}`}; nothing applied. ${decision.approve ?? ""}`.trim());
|
|
1174
|
-
return { ...result, exitCode: decision.status === "refused" ?
|
|
1293
|
+
return { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 };
|
|
1175
1294
|
}
|
|
1176
1295
|
// The share runs the hooks its deciding job read at base, around its own slice.
|
|
1177
1296
|
if (wave.discover) {
|
|
@@ -1213,6 +1332,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1213
1332
|
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
1214
1333
|
ctx.gateRule = { approval, base, neverOverMcp };
|
|
1215
1334
|
noteHooksAtBase(ctx, rules);
|
|
1335
|
+
// #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
|
|
1336
|
+
if (rules.pointRefused) return failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`);
|
|
1337
|
+
const own = opWavePointAt(spec, wave.name);
|
|
1338
|
+
if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
|
|
1339
|
+
log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
|
|
1340
|
+
}
|
|
1216
1341
|
const holds: string[] = [];
|
|
1217
1342
|
if (rules.hooksRefused) {
|
|
1218
1343
|
holds.push(rules.hooksRefused);
|
|
@@ -1237,11 +1362,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1237
1362
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
1238
1363
|
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
1239
1364
|
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
1365
|
+
...(rules.point ? { point: { ...rules.point } } : {}),
|
|
1240
1366
|
};
|
|
1241
1367
|
const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
1242
1368
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
1243
1369
|
if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
|
|
1244
|
-
const { members, pending, overriders, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
|
|
1370
|
+
const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
|
|
1245
1371
|
if (wave.discover) {
|
|
1246
1372
|
wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
|
|
1247
1373
|
ctx.wave = wave;
|
|
@@ -1267,7 +1393,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
|
|
|
1267
1393
|
for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
|
|
1268
1394
|
if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
|
|
1269
1395
|
}
|
|
1270
|
-
if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
|
|
1396
|
+
if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders, overridersSource);
|
|
1271
1397
|
if (kept && prior.status === "approved" && decision.status !== "refused") {
|
|
1272
1398
|
// #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
|
|
1273
1399
|
for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (prior[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = prior[key];
|
|
@@ -1314,6 +1440,10 @@ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: s
|
|
|
1314
1440
|
log(`Nothing in wave ${k} or after it ran. ${decision.heldOn ? `It waits on ${decision.heldOn.op} / ${decision.heldOn.gate}.` : "Read the plans, then"} approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
|
|
1315
1441
|
return { ...result, exitCode: 3 };
|
|
1316
1442
|
}
|
|
1443
|
+
if (decision.status === "refused" && decision.refused?.reason === "point") {
|
|
1444
|
+
log(`Nothing in wave ${k} (${wave.name}) or after it ran: ${decision.refused.message}`);
|
|
1445
|
+
return { ...result, exitCode: 8 };
|
|
1446
|
+
}
|
|
1317
1447
|
if (decision.status === "refused") {
|
|
1318
1448
|
log(`Nothing in wave ${k} (${wave.name}) or after it ran: the policy denied ${decision.refused?.runs.join(", ")}, and no override counts for ${decision.refused?.runs.length === 1 ? "it" : "them"}.`);
|
|
1319
1449
|
return { ...result, exitCode: 7 };
|
|
@@ -1381,7 +1511,7 @@ function relativeTo(cwd: string, path: string): string {
|
|
|
1381
1511
|
* decision is `refused`, and each such run gets a pending fact on the
|
|
1382
1512
|
* override ledger unless one stands.
|
|
1383
1513
|
*/
|
|
1384
|
-
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
|
|
1514
|
+
async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined, source?: string): Promise<void> {
|
|
1385
1515
|
const { spec, k, wave, cwd, log, options } = ctx;
|
|
1386
1516
|
const og = await import("../override-gate");
|
|
1387
1517
|
const port = options.gates ?? gitGateLedgerPort({ cwd });
|
|
@@ -1403,7 +1533,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1403
1533
|
planDigest: m.planDigest,
|
|
1404
1534
|
rules: m.denied.rules,
|
|
1405
1535
|
overriders,
|
|
1406
|
-
overridersPath: listed ? "the overriders the plans file names" : path,
|
|
1536
|
+
overridersPath: listed ? (source ?? "the overriders the plans file names") : path,
|
|
1407
1537
|
mode: sealed ? "sealed" : "ledger",
|
|
1408
1538
|
...(signers ? { signers } : {}),
|
|
1409
1539
|
now,
|
|
@@ -1430,7 +1560,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1430
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(", ")}`,
|
|
1431
1561
|
});
|
|
1432
1562
|
try {
|
|
1433
|
-
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);
|
|
1434
1564
|
} catch (err) {
|
|
1435
1565
|
log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
|
|
1436
1566
|
}
|
|
@@ -1439,7 +1569,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1439
1569
|
}
|
|
1440
1570
|
if (still.length > 0) {
|
|
1441
1571
|
decision.status = "refused";
|
|
1442
|
-
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
|
|
1572
|
+
decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort(), ...(listed && source ? { overridersSource: source } : {}) };
|
|
1443
1573
|
}
|
|
1444
1574
|
}
|
|
1445
1575
|
|
|
@@ -1471,6 +1601,11 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
|
|
|
1471
1601
|
return true;
|
|
1472
1602
|
}
|
|
1473
1603
|
|
|
1604
|
+
/** The exit code of a refused wave: 8 when its decision point refused it (#3836), 7 when the product's policy did (#3821). */
|
|
1605
|
+
function refusedExit(decision: OpWaveDecision): number {
|
|
1606
|
+
return decision.refused?.reason === "point" ? 8 : 7;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1474
1609
|
function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
1475
1610
|
ctx.log(line);
|
|
1476
1611
|
ctx.result.exitCode = 1;
|
|
@@ -1483,6 +1618,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
1483
1618
|
* asks for one, or when a hook holds the wave (#3762).
|
|
1484
1619
|
*/
|
|
1485
1620
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
1621
|
+
if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
|
|
1486
1622
|
const { spec, k, wave, cwd } = ctx;
|
|
1487
1623
|
const { gate, digest, approval, base, policy, members } = decision;
|
|
1488
1624
|
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
@@ -1507,6 +1643,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1507
1643
|
decision.approvedAt = check.resolution.timestamp;
|
|
1508
1644
|
return;
|
|
1509
1645
|
}
|
|
1646
|
+
logRefused(ctx.log, `wave ${k} (${wave.name})`, check);
|
|
1510
1647
|
// #3821: what the wait is, for the after-gate hooks and the record.
|
|
1511
1648
|
decision.pendingFact = check.pending;
|
|
1512
1649
|
decision.recorded = check.recorded;
|
|
@@ -1535,6 +1672,54 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1535
1672
|
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
1536
1673
|
}
|
|
1537
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Decide a gate that asks a decision point (#3836), as an Op gate with a
|
|
1677
|
+
* `point` is decided (#3170): ask it with `gate.planDigest` set to the set
|
|
1678
|
+
* digest, then wait on an open question, pass on an answer the gate passes
|
|
1679
|
+
* on, or refuse the wave on any other. The wave's `approval` mode and spent
|
|
1680
|
+
* approvals do not apply: the point's deciders and quorum decide, and a
|
|
1681
|
+
* `chant approve` of the digest does not pass it.
|
|
1682
|
+
*/
|
|
1683
|
+
async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, point: NonNullable<OpWaveDecision["point"]>): Promise<void> {
|
|
1684
|
+
const { spec, k, wave, cwd, log } = ctx;
|
|
1685
|
+
const { gate, digest, policy, members } = decision;
|
|
1686
|
+
if (decision.approval !== "ledger") log(`wave ${k} (${wave.name}): its gate asks decision point ${point.name}, so its approval mode (${decision.approval}) does not apply`);
|
|
1687
|
+
const { evaluatePointGate } = await import("../point-decision");
|
|
1688
|
+
const asker = options.points ?? (await import("./gate-point-run")).workspaceGatePointAsker(cwd);
|
|
1689
|
+
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
1690
|
+
const check = await evaluatePointGate(options.gates ?? gitGateLedgerPort({ cwd }), asker, {
|
|
1691
|
+
op: spec.name,
|
|
1692
|
+
gate,
|
|
1693
|
+
point: { name: point.name, ...(point.pass ? { pass: point.pass } : {}), ...(point.store ? { store: point.store } : {}) },
|
|
1694
|
+
...(point.inputs ? { inputs: point.inputs } : {}),
|
|
1695
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, decision point ${point.name}${held}`,
|
|
1696
|
+
...(point.timeout ? { timeout: point.timeout } : {}),
|
|
1697
|
+
planDigest: digest,
|
|
1698
|
+
...(options.now ? { now: options.now } : {}),
|
|
1699
|
+
});
|
|
1700
|
+
if (check.satisfied) {
|
|
1701
|
+
decision.status = "approved";
|
|
1702
|
+
decision.via = "point";
|
|
1703
|
+
decision.approvedBy = check.resolution.resolvedBy;
|
|
1704
|
+
decision.approvedAt = check.resolution.timestamp;
|
|
1705
|
+
point.answer = check.answer;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if ("refused" in check) {
|
|
1709
|
+
decision.status = "refused";
|
|
1710
|
+
decision.refused = { reason: "point", runs: members.map((m) => m.member), message: check.refused };
|
|
1711
|
+
point.answer = check.answer;
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
decision.status = "waiting";
|
|
1715
|
+
decision.pendingFact = check.pending;
|
|
1716
|
+
decision.recorded = check.recorded;
|
|
1717
|
+
decision.waitingSince = check.pending.timestamp;
|
|
1718
|
+
point.question = check.waiting;
|
|
1719
|
+
decision.approve = `chant workspace points answer ${check.waiting.id} --answer <answer> --by <your name>`;
|
|
1720
|
+
log(`wave ${k} (${wave.name}) asks decision point ${point.name} about ${digest}: question ${check.waiting.id} is ${check.waiting.state}`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1538
1723
|
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
1539
1724
|
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
1540
1725
|
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
@@ -1557,6 +1742,13 @@ async function settleHolds(
|
|
|
1557
1742
|
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
1558
1743
|
await settleGate(options, ctx, decision);
|
|
1559
1744
|
if (recordPath) writeRecord(recordPath, decision, result);
|
|
1745
|
+
if (decision.status === "refused") {
|
|
1746
|
+
// #3836: the gate's decision point refused the held wave.
|
|
1747
|
+
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
1748
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}; nothing applied: ${decision.refused?.message ?? "refused"}`);
|
|
1749
|
+
result.exitCode = refusedExit(decision);
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1560
1752
|
if (decision.status === "waiting") {
|
|
1561
1753
|
// #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
|
|
1562
1754
|
if (recordPath) await runGateHooks(ctx, decision, recordPath);
|
|
@@ -1786,7 +1978,7 @@ async function applyStage(
|
|
|
1786
1978
|
return;
|
|
1787
1979
|
}
|
|
1788
1980
|
if (waiting) {
|
|
1789
|
-
result.exitCode = 3;
|
|
1981
|
+
result.exitCode = decision.status === "refused" ? refusedExit(decision) : 3;
|
|
1790
1982
|
return;
|
|
1791
1983
|
}
|
|
1792
1984
|
if (result.failed.length > 0) {
|