@intentius/chant 0.121.1 → 0.123.0
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/commands/build.d.ts +12 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +16 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +37 -18
- package/src/cli/commands/carve-emit-state.test.ts +8 -2
- package/src/cli/commands/lint.test.ts +109 -1
- package/src/cli/commands/lint.ts +165 -1
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
package/src/op/op-waves.ts
CHANGED
|
@@ -33,9 +33,28 @@
|
|
|
33
33
|
* against the signers file at base) or `pr-review` (also the merged pull
|
|
34
34
|
* request's review of its head, by a writer other than its author, when the
|
|
35
35
|
* head planned the same digest). See `./gate-review.ts`.
|
|
36
|
+
*
|
|
37
|
+
* ## Spent approvals (#3784)
|
|
38
|
+
*
|
|
39
|
+
* With `approvals: { spend: true }` (read at base, like the policy), a wave
|
|
40
|
+
* records each approval it applies under in `_gates/<name>/applied.jsonl` on
|
|
41
|
+
* `chant/lifecycle`, as terragucci's wave gate does, and that approval is
|
|
42
|
+
* spent: the same digest needs a new approval to apply again.
|
|
43
|
+
*
|
|
44
|
+
* ## Wave-level commands and hooks (#3762)
|
|
45
|
+
*
|
|
46
|
+
* A product whose tool plans a whole wave in one call (Terragrunt's `run
|
|
47
|
+
* --all`, an apply by rows) gives `planWave` and `applyWave` in place of the
|
|
48
|
+
* per-run `plan` and `apply`: one call plans every run and lists each run's
|
|
49
|
+
* plan, and the wave's digest and gate come from that list as from per-run
|
|
50
|
+
* plans. Stage hooks (`hooks`) run commands before and after the plan and the
|
|
51
|
+
* apply; like the policy they are read from the spec at the base commit, and
|
|
52
|
+
* one whose `onFailure` is `approve` holds the wave for an approval whatever
|
|
53
|
+
* its policy says.
|
|
36
54
|
*/
|
|
37
55
|
|
|
38
56
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
57
|
+
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
39
58
|
|
|
40
59
|
/** When a wave waits for an approval. */
|
|
41
60
|
export type OpWaveGatePolicy =
|
|
@@ -59,6 +78,41 @@ export type GateApprovalSource =
|
|
|
59
78
|
|
|
60
79
|
export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "pr-review", "sealed"];
|
|
61
80
|
|
|
81
|
+
/** What a failing hook does: fail the wave, or hold it for an approval of its set digest. */
|
|
82
|
+
export type OpWaveHookFailure = "fail" | "approve";
|
|
83
|
+
|
|
84
|
+
export const OP_WAVE_HOOK_FAILURES: readonly OpWaveHookFailure[] = ["fail", "approve"];
|
|
85
|
+
|
|
86
|
+
/** One stage hook (#3762): a command run before or after a wave's plan or apply. */
|
|
87
|
+
export interface OpWaveHook {
|
|
88
|
+
/** The hook's name in the log and the result. Default: its command. */
|
|
89
|
+
name?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Argv template. `{op}`, `{wave}` and `{runs}` (the runs file, see
|
|
92
|
+
* `opWaveRunsPath`) are filled in; a `perRun` hook also gets `{target}`
|
|
93
|
+
* and, once the run is planned, `{plan}`.
|
|
94
|
+
*/
|
|
95
|
+
command: string[];
|
|
96
|
+
/** Default `"fail"`. `"approve"` holds the wave for an approval of its set digest whatever its policy says. Not allowed after apply. */
|
|
97
|
+
onFailure?: OpWaveHookFailure;
|
|
98
|
+
/** Run once per run of the wave, rather than once for the wave. */
|
|
99
|
+
perRun?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The stages a hook runs around. */
|
|
103
|
+
export type OpWaveHookStage = "plan" | "apply";
|
|
104
|
+
|
|
105
|
+
/** When a hook runs. */
|
|
106
|
+
export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "after-apply";
|
|
107
|
+
|
|
108
|
+
export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "before-apply", "after-apply"];
|
|
109
|
+
|
|
110
|
+
/** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
|
|
111
|
+
export interface OpWaveHooks {
|
|
112
|
+
before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
113
|
+
after?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
114
|
+
}
|
|
115
|
+
|
|
62
116
|
/** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
|
|
63
117
|
export interface OpWaveRun {
|
|
64
118
|
/** What the run applies to. Unique within its wave; it names the run in the set digest. */
|
|
@@ -94,6 +148,8 @@ export interface OpWave {
|
|
|
94
148
|
variables?: Record<string, string>;
|
|
95
149
|
/** Steps this wave's jobs run after the checkout, as `ScheduledOpSpec.setup`. */
|
|
96
150
|
setup?: OpSetupStep[];
|
|
151
|
+
/** This wave's stage hooks (#3762), run after the spec's own. Read at the base commit. */
|
|
152
|
+
hooks?: OpWaveHooks;
|
|
97
153
|
}
|
|
98
154
|
|
|
99
155
|
/**
|
|
@@ -119,11 +175,29 @@ export interface OpWavesSpec {
|
|
|
119
175
|
* Plan one run and write its plan file to `{plan}`: JSON `{ "planDigest":
|
|
120
176
|
* string, "destructive"?: boolean, "empty"?: boolean }`. `destructive` is
|
|
121
177
|
* what `on-destructive` waits on; a wave whose runs are all `empty` never
|
|
122
|
-
* waits.
|
|
178
|
+
* waits. Give this or `planWave`.
|
|
123
179
|
*/
|
|
124
|
-
plan
|
|
180
|
+
plan?: string[];
|
|
125
181
|
/** Apply one run whose plan is at `{plan}`. Default `chant run {op} --env {target}`. */
|
|
126
182
|
apply?: string[];
|
|
183
|
+
/**
|
|
184
|
+
* Plan every run of a wave in one call (#3762), in place of `plan`. `{runs}`
|
|
185
|
+
* is a file chant writes, `{ "wave", "number", "runs": [{ "target", "op" }] }`;
|
|
186
|
+
* the command writes `{plans}`: `{ "runs": [{ "target", "planDigest",
|
|
187
|
+
* "destructive"?, "empty"? }] }` (or the array alone), one entry per run.
|
|
188
|
+
*/
|
|
189
|
+
planWave?: string[];
|
|
190
|
+
/**
|
|
191
|
+
* Apply a wave's approved set in one call (#3762), in place of `apply`.
|
|
192
|
+
* `{runs}` lists the runs to apply with the plan digest each was approved
|
|
193
|
+
* at; `{plans}` is the wave plan's file. A non-zero exit fails every run in
|
|
194
|
+
* the set.
|
|
195
|
+
*/
|
|
196
|
+
applyWave?: string[];
|
|
197
|
+
/** Stage hooks for every wave (#3762), run before each wave's own. Read at the base commit. */
|
|
198
|
+
hooks?: OpWaveHooks;
|
|
199
|
+
/** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
|
|
200
|
+
approvals?: OpWavesApprovals;
|
|
127
201
|
waves: OpWave[];
|
|
128
202
|
/**
|
|
129
203
|
* A scheduled job that starts again a wave job whose approval has arrived
|
|
@@ -133,6 +207,27 @@ export interface OpWavesSpec {
|
|
|
133
207
|
* followed by `chant approve --resume` or a re-run by hand.
|
|
134
208
|
*/
|
|
135
209
|
resume?: OpWavesResume;
|
|
210
|
+
/**
|
|
211
|
+
* Tell people when a wave waits for an approval, a share job refuses a run
|
|
212
|
+
* whose plan moved, or a wave fails (#3761): the environment variables
|
|
213
|
+
* holding a Slack or Teams incoming webhook's URL, or a generic webhook's
|
|
214
|
+
* URL and the key its `chant.notify/v1` events are signed with. A failed
|
|
215
|
+
* delivery is logged and never changes the job's exit code. See
|
|
216
|
+
* `../notify.ts`.
|
|
217
|
+
*/
|
|
218
|
+
notify?: NotifyConfig;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What happens to an approval once a wave applies under it (#3784). */
|
|
222
|
+
export interface OpWavesApprovals {
|
|
223
|
+
/**
|
|
224
|
+
* Spend it. Before a wave applies under an approval it records that in
|
|
225
|
+
* `_gates/<name>/applied.jsonl` on `chant/lifecycle`, and from then on the
|
|
226
|
+
* approval counts for nothing: applying the same digest again, by a re-run
|
|
227
|
+
* or a revert and re-merge, waits for a new approval. Default false: an
|
|
228
|
+
* approval of a digest passes that digest as often as it is planned.
|
|
229
|
+
*/
|
|
230
|
+
spend?: boolean;
|
|
136
231
|
}
|
|
137
232
|
|
|
138
233
|
/** The scheduled resume job of an Op waves pipeline (#3683). */
|
|
@@ -184,16 +279,50 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
184
279
|
const where = `Op waves "${spec.name}"`;
|
|
185
280
|
if (!NAME.test(spec.name)) throw new Error(`${where}: the name must be lowercase letters, digits, "-" and "_"`);
|
|
186
281
|
if (!spec.op) throw new Error(`${where} names no \`op\`. Name the Op a run runs when it names none.`);
|
|
187
|
-
if (
|
|
188
|
-
throw new Error(`${where} has
|
|
282
|
+
if (spec.plan !== undefined && spec.planWave !== undefined) {
|
|
283
|
+
throw new Error(`${where} has both \`plan\` and \`planWave\`. A wave is planned one run at a time or all at once; give one.`);
|
|
189
284
|
}
|
|
190
|
-
if (
|
|
191
|
-
throw new Error(`${where}
|
|
285
|
+
if (spec.apply !== undefined && spec.applyWave !== undefined) {
|
|
286
|
+
throw new Error(`${where} has both \`apply\` and \`applyWave\`. A wave is applied one run at a time or all at once; give one.`);
|
|
192
287
|
}
|
|
288
|
+
if (spec.planWave !== undefined) {
|
|
289
|
+
assertArgv(where, "planWave", spec.planWave);
|
|
290
|
+
if (!spec.planWave.some((part) => part.includes("{plans}"))) {
|
|
291
|
+
throw new Error(`${where}: the \`planWave\` command never names {plans}, the file it writes each run's plan digest to.`);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
|
|
295
|
+
throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests; give \`plan\` or \`planWave\`.`);
|
|
296
|
+
}
|
|
297
|
+
assertArgv(where, "plan", spec.plan);
|
|
298
|
+
if (!spec.plan.some((part) => part.includes("{plan}"))) {
|
|
299
|
+
throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (spec.apply !== undefined) assertArgv(where, "apply", spec.apply);
|
|
303
|
+
if (spec.applyWave !== undefined) {
|
|
304
|
+
assertArgv(where, "applyWave", spec.applyWave);
|
|
305
|
+
if (!spec.applyWave.some((part) => part.includes("{runs}"))) {
|
|
306
|
+
throw new Error(`${where}: the \`applyWave\` command never names {runs}, the file listing the approved runs it applies.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
assertOpWaveHooks(where, spec.hooks);
|
|
193
310
|
if (spec.waves.length === 0) throw new Error(`${where} has no waves.`);
|
|
194
311
|
if (spec.resume !== undefined && (typeof spec.resume.schedule !== "string" || spec.resume.schedule.trim().split(/\s+/).length !== 5)) {
|
|
195
312
|
throw new Error(`${where}: resume.schedule must be a five-field cron expression, such as "*/10 * * * *".`);
|
|
196
313
|
}
|
|
314
|
+
if (spec.approvals !== undefined) {
|
|
315
|
+
const a = spec.approvals as unknown;
|
|
316
|
+
if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error(`${where}: \`approvals\` must be { "spend"?: true | false }.`);
|
|
317
|
+
for (const [key, value] of Object.entries(a as Record<string, unknown>)) {
|
|
318
|
+
if (key !== "spend") throw new Error(`${where}: approvals.${key} is not a setting; the one there is approvals.spend.`);
|
|
319
|
+
if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (spec.notify !== undefined) {
|
|
323
|
+
const problem = notifyConfigProblem(spec.notify, "notify");
|
|
324
|
+
if (problem) throw new Error(`${where}: ${problem}`);
|
|
325
|
+
}
|
|
197
326
|
const names = new Set<string>();
|
|
198
327
|
for (const wave of spec.waves) {
|
|
199
328
|
if (!NAME.test(wave.name)) {
|
|
@@ -207,6 +336,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
207
336
|
if (wave.approval !== undefined && !GATE_APPROVAL_SOURCES.includes(wave.approval)) {
|
|
208
337
|
throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
|
|
209
338
|
}
|
|
339
|
+
assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
|
|
210
340
|
if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
|
|
211
341
|
const targets = new Set<string>();
|
|
212
342
|
for (const run of wave.runs) {
|
|
@@ -224,6 +354,57 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
224
354
|
}
|
|
225
355
|
}
|
|
226
356
|
|
|
357
|
+
function assertArgv(where: string, field: string, argv: unknown): void {
|
|
358
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((part) => typeof part === "string") || argv[0] === "") {
|
|
359
|
+
throw new Error(`${where}: \`${field}\` must be a command as argv, a non-empty array of strings.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Refuse hooks no runner could run, naming what is wrong (#3762). */
|
|
364
|
+
export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined): void {
|
|
365
|
+
if (hooks === undefined) return;
|
|
366
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
367
|
+
throw new Error(`${where}: \`hooks\` must be { "before"?: { "plan"?, "apply"? }, "after"?: { "plan"?, "apply"? } }.`);
|
|
368
|
+
}
|
|
369
|
+
for (const [side, stages] of Object.entries(hooks as Record<string, unknown>)) {
|
|
370
|
+
if (side !== "before" && side !== "after") throw new Error(`${where}: hooks.${side} is not a moment; use hooks.before or hooks.after.`);
|
|
371
|
+
if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
|
|
372
|
+
for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
|
|
373
|
+
const at = `hooks.${side}.${stage}`;
|
|
374
|
+
if (stage !== "plan" && stage !== "apply") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply.`);
|
|
375
|
+
if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
|
|
376
|
+
list.forEach((hook: OpWaveHook, i: number) => {
|
|
377
|
+
const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
|
|
378
|
+
if (!hook || typeof hook !== "object") throw new Error(`${where}: ${which} must be { "command": [...], "onFailure"?, "perRun"? }.`);
|
|
379
|
+
assertArgv(where, `${which}.command`, hook.command);
|
|
380
|
+
if (hook.onFailure !== undefined && !OP_WAVE_HOOK_FAILURES.includes(hook.onFailure)) {
|
|
381
|
+
throw new Error(`${where}: ${which} has onFailure "${hook.onFailure}"; use ${OP_WAVE_HOOK_FAILURES.join(" or ")}.`);
|
|
382
|
+
}
|
|
383
|
+
if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
|
|
384
|
+
throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
|
|
385
|
+
}
|
|
386
|
+
if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
|
|
387
|
+
if (!hook.perRun) {
|
|
388
|
+
const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
|
|
389
|
+
if (named) throw new Error(`${where}: ${which} runs once for the wave, so it has no {target} or {plan} ("${named}"). Set "perRun": true, or read the runs from {runs}.`);
|
|
390
|
+
} else if (side === "before" && stage === "plan" && hook.command.some((part) => part.includes("{plan}"))) {
|
|
391
|
+
throw new Error(`${where}: ${which} runs before the plan, so it has no {plan} yet.`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The hooks of each moment for wave `name`: the spec's, then the wave's own. */
|
|
399
|
+
export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): Record<OpWaveHookMoment, OpWaveHook[]> {
|
|
400
|
+
const wave = spec.waves.find((w) => w.name === name);
|
|
401
|
+
const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
|
|
402
|
+
const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
|
|
403
|
+
return hooks?.[side]?.[stage] ?? [];
|
|
404
|
+
};
|
|
405
|
+
return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
406
|
+
}
|
|
407
|
+
|
|
227
408
|
/**
|
|
228
409
|
* The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
|
|
229
410
|
* by target, cut into contiguous slices whose sizes differ by at most one.
|
package/src/op/operator.test.ts
CHANGED
|
@@ -429,6 +429,35 @@ describe("runOperatorForever — interval/timer behavior", () => {
|
|
|
429
429
|
});
|
|
430
430
|
}, 10_000);
|
|
431
431
|
|
|
432
|
+
test("releases the Ops' leases when it stops, so a restarted operator ticks at once (#2879)", async () => {
|
|
433
|
+
await withTestDir(async (dir) => {
|
|
434
|
+
await initRepo(dir);
|
|
435
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
436
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
437
|
+
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const rounds: unknown[] = [];
|
|
440
|
+
const loop = runOperatorForever({
|
|
441
|
+
cwd: dir,
|
|
442
|
+
holder: "op-a",
|
|
443
|
+
intervalMs: 40,
|
|
444
|
+
activities,
|
|
445
|
+
profiles: PROFILES,
|
|
446
|
+
signal: controller.signal,
|
|
447
|
+
onRound: () => rounds.push(1),
|
|
448
|
+
});
|
|
449
|
+
await waitForRoundCount(rounds, 1);
|
|
450
|
+
expect((await readLease("staging-converge", { cwd: dir })).record?.holder).toBe("op-a");
|
|
451
|
+
|
|
452
|
+
controller.abort();
|
|
453
|
+
await loop;
|
|
454
|
+
expect((await readLease("staging-converge", { cwd: dir })).record).toBeUndefined();
|
|
455
|
+
|
|
456
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op-b", activities, profiles: PROFILES });
|
|
457
|
+
expect(events.map((e) => e.kind)).toEqual(["ticked"]);
|
|
458
|
+
});
|
|
459
|
+
}, 10_000);
|
|
460
|
+
|
|
432
461
|
test("default interval is used when none is given", () => {
|
|
433
462
|
expect(DEFAULT_OPERATOR_INTERVAL_MS).toBeGreaterThan(0);
|
|
434
463
|
});
|
package/src/op/operator.ts
CHANGED
|
@@ -196,6 +196,17 @@ export function createBesideState(): BesideState {
|
|
|
196
196
|
return { running: new Map(), started: new Map(), ended: [] };
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Release the per-Op leases a stopped operator still holds (#2879), best
|
|
201
|
+
* effort: a lease that is gone or taken over since is left alone, and one that
|
|
202
|
+
* can't be released expires on its TTL as before.
|
|
203
|
+
*/
|
|
204
|
+
export async function releaseHeldLeases(held: Map<string, string>, holder: string, opts: { cwd?: string } = {}): Promise<void> {
|
|
205
|
+
const entries = [...held];
|
|
206
|
+
held.clear();
|
|
207
|
+
await Promise.allSettled(entries.map(([op, token]) => releaseLease(op, holder, token, opts)));
|
|
208
|
+
}
|
|
209
|
+
|
|
199
210
|
/** Wait for every run in flight beside the turns to end. `chant operator --steward --once` does before it exits. */
|
|
200
211
|
export async function waitForBesideRuns(state: BesideState): Promise<void> {
|
|
201
212
|
await Promise.all([...state.running.values()].map((r) => r.handle.done));
|
|
@@ -263,6 +274,13 @@ export interface OperatorRoundOptions {
|
|
|
263
274
|
* it returns.
|
|
264
275
|
*/
|
|
265
276
|
besideState?: BesideState;
|
|
277
|
+
/**
|
|
278
|
+
* The per-Op leases this process holds after its ticks, Op name to the
|
|
279
|
+
* token of the newest acquire (#2879). A round records each lease it takes
|
|
280
|
+
* or renews here. `runOperatorForever` owns one and releases what is left when it stops, so
|
|
281
|
+
* a restarted operator does not skip its Ops until the old leases expire.
|
|
282
|
+
*/
|
|
283
|
+
heldLeases?: Map<string, string>;
|
|
266
284
|
/** How a run beside the turns is started. Default: `chant run <op>` in a process of its own ({@link spawnBesideRun}). */
|
|
267
285
|
launchBeside?: BesideLauncher;
|
|
268
286
|
/** The environment the steward operator was started for (`--env`), passed on to a run it starts beside its turns. */
|
|
@@ -540,6 +558,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
540
558
|
}
|
|
541
559
|
|
|
542
560
|
const lease = acquired.lease as LeaseRecord;
|
|
561
|
+
opts.heldLeases?.set(config.name, lease.token);
|
|
543
562
|
|
|
544
563
|
// A local steward is one turn at a time (#2750): a hand run (`chant run
|
|
545
564
|
// <op>`) holds this same lease for as long as it runs, so a round never
|
|
@@ -556,12 +575,14 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
556
575
|
// catch above: report it and stop, rather than let it propagate and
|
|
557
576
|
// leak the per-op lease this round just took.
|
|
558
577
|
await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
|
|
578
|
+
opts.heldLeases?.delete(config.name);
|
|
559
579
|
const message = err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
|
|
560
580
|
events.push({ kind: "lease-error", op: stewardTurnLeaseName(steward.name), env, error: message });
|
|
561
581
|
break;
|
|
562
582
|
}
|
|
563
583
|
if (!turn.acquired) {
|
|
564
584
|
await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
|
|
585
|
+
opts.heldLeases?.delete(config.name);
|
|
565
586
|
events.push({ kind: "turn-busy", op: config.name, env, steward: steward.name, heldBy: turn.heldBy?.holder });
|
|
566
587
|
break;
|
|
567
588
|
}
|
|
@@ -898,6 +919,8 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
898
919
|
// The runs a steward starts beside its turns (#2861), for the life of the
|
|
899
920
|
// loop. When the loop stops, so do they.
|
|
900
921
|
const besideState = opts.besideState ?? createBesideState();
|
|
922
|
+
// The per-Op leases the rounds leave held (#2879), released when the loop stops.
|
|
923
|
+
const heldLeases = opts.heldLeases ?? new Map<string, string>();
|
|
901
924
|
const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
|
|
902
925
|
const subscribers = opts.subscribers ?? [];
|
|
903
926
|
|
|
@@ -906,13 +929,14 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
906
929
|
if (subscribers.length === 0) {
|
|
907
930
|
try {
|
|
908
931
|
while (!opts.signal?.aborted) {
|
|
909
|
-
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
|
|
932
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
|
|
910
933
|
opts.onRound?.(events);
|
|
911
934
|
if (opts.signal?.aborted) break;
|
|
912
935
|
await sleepAbortable(intervalMs, opts.signal);
|
|
913
936
|
}
|
|
914
937
|
} finally {
|
|
915
938
|
await stopBesideRuns(besideState);
|
|
939
|
+
await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
|
|
916
940
|
}
|
|
917
941
|
return;
|
|
918
942
|
}
|
|
@@ -965,7 +989,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
965
989
|
while (!opts.signal?.aborted) {
|
|
966
990
|
gate.roundStarted();
|
|
967
991
|
const startedAt = Date.now();
|
|
968
|
-
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
|
|
992
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
|
|
969
993
|
opts.onRound?.(events);
|
|
970
994
|
if (opts.signal?.aborted) break;
|
|
971
995
|
|
|
@@ -980,6 +1004,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
980
1004
|
} finally {
|
|
981
1005
|
await closeAll();
|
|
982
1006
|
await stopBesideRuns(besideState);
|
|
1007
|
+
await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
|
|
983
1008
|
}
|
|
984
1009
|
}
|
|
985
1010
|
|
|
@@ -8,7 +8,7 @@ import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
|
8
8
|
import { runOpLocally } from "./local-executor";
|
|
9
9
|
import { memoryGateLedgerPort } from "./gate";
|
|
10
10
|
import { currentOpRun, type OpRunContext } from "./run-context";
|
|
11
|
-
import { gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
|
|
11
|
+
import { gateContextDigest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
|
|
12
12
|
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
13
13
|
|
|
14
14
|
const PROFILES: Record<string, ActivityProfile> = {
|
|
@@ -81,7 +81,7 @@ describe("the Op run context (#2522)", () => {
|
|
|
81
81
|
const permit = (mode: "log-only" | "enforce"): GateResolutionRecord => ({
|
|
82
82
|
version: 1, op: "release", gate: "ship", resolvedBy: "release-bot", timestamp: "2026-09-05T11:00:00.000Z",
|
|
83
83
|
approver: { kind: "agent" },
|
|
84
|
-
policyDecision: { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] },
|
|
84
|
+
policyDecision: { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(undefined), decision: "allow", determining: ["ship-0"], errors: [] },
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
async function run(mode: "log-only" | "enforce") {
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import { isStepOutputRef } from "./step-output-ref";
|
|
9
9
|
import { parseDuration } from "./duration";
|
|
10
|
+
import { WORK_ITEM_ID_PATTERN, workItemIdProblem } from "../lifecycle/work-item-id";
|
|
10
11
|
import { WORK_LEASE_STEP_ID, type OpConfig, type PhaseDefinition } from "./types";
|
|
11
12
|
|
|
12
13
|
/** A work item id a lease can be keyed by: the same pattern as `WORK_ITEM_ID_PATTERN` in `../lifecycle/work-lease.ts`. */
|
|
13
|
-
export const WORK_LEASE_ITEM_PATTERN =
|
|
14
|
+
export const WORK_LEASE_ITEM_PATTERN = WORK_ITEM_ID_PATTERN;
|
|
14
15
|
|
|
15
16
|
/** The ids of the activity steps in an Op's main phases, in order. */
|
|
16
17
|
function mainStepIds(phases: readonly PhaseDefinition[]): string[] {
|
|
@@ -42,9 +43,8 @@ export function workLeaseProblems(config: Pick<OpConfig, "name" | "phases" | "wo
|
|
|
42
43
|
problems.push(`Op "${name}": step id "${WORK_LEASE_STEP_ID}" is reserved for the run's work lease; rename the step`);
|
|
43
44
|
}
|
|
44
45
|
const checkId = (id: unknown) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
46
|
+
const problem = workItemIdProblem(id);
|
|
47
|
+
if (problem) problems.push(`Op "${name}": workLease.item ${problem}`);
|
|
48
48
|
};
|
|
49
49
|
const item = spec.item;
|
|
50
50
|
if (isStepOutputRef(item)) {
|
|
@@ -80,6 +80,11 @@ describe("the declaration", () => {
|
|
|
80
80
|
).toThrow(/scheduled, but its workLease names no item/);
|
|
81
81
|
expect(declareSteward({ name: "box", ops: [dispatchOp({ workLease: {} })] }).ops).toHaveLength(1);
|
|
82
82
|
expect(workLeaseProblems(dispatchOp({ workLease: { item: "../x" } }))[0]).toMatch(/can't key a work lease/);
|
|
83
|
+
for (const item of ["upgrade-.", "x.lock", "x.LOCK", "a..b"]) {
|
|
84
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item } }))[0], item).toMatch(/can't key a work lease/);
|
|
85
|
+
}
|
|
86
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item: ["W-1", "upgrade-."] } }))).toHaveLength(1);
|
|
87
|
+
expect(() => Op(dispatchOp({ workLease: { item: "upgrade-." } }))).toThrow(/can't key a work lease/);
|
|
83
88
|
expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1", ttl: "1s" } }))[0]).toMatch(/shorter than 3s/);
|
|
84
89
|
expect(workLeaseProblems(dispatchOp({ workLease: { item: stepOutput("nope", "id") } }))[0]).toMatch(/references step "nope"/);
|
|
85
90
|
expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1" } }, [step("x", {}, { id: WORK_LEASE_STEP_ID })]))[0]).toMatch(/reserved/);
|
package/src/op/work-lease-run.ts
CHANGED
|
@@ -64,7 +64,8 @@ import {
|
|
|
64
64
|
releaseWorkLease,
|
|
65
65
|
renewWorkLease,
|
|
66
66
|
workLeaseRef,
|
|
67
|
-
|
|
67
|
+
isWorkItemId,
|
|
68
|
+
workItemIdProblem,
|
|
68
69
|
type WorkLease,
|
|
69
70
|
} from "../lifecycle/work-lease";
|
|
70
71
|
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
@@ -216,8 +217,8 @@ export class RunWorkLease {
|
|
|
216
217
|
if (value === undefined || value === null || value === "") return [];
|
|
217
218
|
const list = Array.isArray(value) ? value : [value];
|
|
218
219
|
return list.filter((v) => v !== undefined && v !== null && v !== "").map((v) => {
|
|
219
|
-
if (
|
|
220
|
-
throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease`);
|
|
220
|
+
if (!isWorkItemId(v)) {
|
|
221
|
+
throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease${typeof v === "string" ? ` (${workItemIdProblem(v)})` : ""}`);
|
|
221
222
|
}
|
|
222
223
|
return v;
|
|
223
224
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The override gate bundles small (#3760, ruling #3421), as the gate decision
|
|
3
|
+
* does: terragucci and SQL Yodeler decide overrides from one bundled file
|
|
4
|
+
* with no TypeScript toolchain. This bundles `@intentius/chant/override-gate`
|
|
5
|
+
* the way they would and reads esbuild's own list of what went in.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { build } from "esbuild";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
12
|
+
|
|
13
|
+
const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
|
|
14
|
+
|
|
15
|
+
describe("the override-gate bundle", () => {
|
|
16
|
+
test("holds the decision, the seal check and the digest, and no package or git ledger code", async () => {
|
|
17
|
+
const result = await build({
|
|
18
|
+
entryPoints: [join(REPO, "packages", "core", "src", "override-gate.ts")],
|
|
19
|
+
bundle: true,
|
|
20
|
+
platform: "node",
|
|
21
|
+
format: "esm",
|
|
22
|
+
write: false,
|
|
23
|
+
metafile: true,
|
|
24
|
+
outdir: "out",
|
|
25
|
+
logLevel: "silent",
|
|
26
|
+
});
|
|
27
|
+
const inputs = Object.keys(result.metafile.inputs);
|
|
28
|
+
for (const pattern of FORBIDDEN) {
|
|
29
|
+
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
30
|
+
}
|
|
31
|
+
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
|
|
32
|
+
"packages/core/src/content-digest.ts",
|
|
33
|
+
"packages/core/src/declarable.ts",
|
|
34
|
+
"packages/core/src/effect-receipt.ts",
|
|
35
|
+
"packages/core/src/intrinsic.ts",
|
|
36
|
+
"packages/core/src/lifecycle/gate-ledger-select.ts",
|
|
37
|
+
"packages/core/src/lifecycle/plan-digest.ts",
|
|
38
|
+
"packages/core/src/op/gate-name.ts",
|
|
39
|
+
"packages/core/src/override-gate.ts",
|
|
40
|
+
"packages/core/src/workspace/trust/gate-seal.ts",
|
|
41
|
+
"packages/core/src/workspace/trust/trust-files.ts",
|
|
42
|
+
]);
|
|
43
|
+
// About 16 KB today.
|
|
44
|
+
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
45
|
+
expect(bytes).toBeLessThan(40 * 1024);
|
|
46
|
+
});
|
|
47
|
+
});
|