@intentius/chant 0.123.0 → 0.124.1
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/check-lexicon.d.ts.map +1 -1
- package/dist/config-import.d.ts +1 -1
- package/dist/lint/policy-import.d.ts +1 -1
- package/dist/notify-verify.js +76 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- package/dist/op/index.d.ts +4 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +134 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +14 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +30 -3
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +1 -1
- package/package.json +14 -2
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- package/src/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- package/src/lint/policy-import.ts +1 -1
- package/src/notify-plain-node.e2e.test.ts +68 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
- package/src/op/index.ts +18 -2
- package/src/op/op-waves-run.ts +454 -83
- package/src/op/op-waves.test.ts +191 -1
- package/src/op/op-waves.ts +29 -1
- package/src/pr-forge.test.ts +76 -1
- package/src/pr-forge.ts +52 -8
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write.e2e.test.ts +1 -1
- /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
package/src/op/op-waves-run.ts
CHANGED
|
@@ -29,11 +29,19 @@
|
|
|
29
29
|
* approved set (#3762). A share job (`--share i`) re-plans its
|
|
30
30
|
* slice first and refuses, without applying it, a run whose plan digest is
|
|
31
31
|
* not the one the deciding job recorded (exit 4).
|
|
32
|
+
* - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
|
|
33
|
+
* ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
|
|
34
|
+
* and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
|
|
35
|
+
* at base) chant does not run the apply-stage hooks itself: the file hands
|
|
36
|
+
* them to the apply, which runs them under its own lock with
|
|
37
|
+
* {@link runOpWaveApplyHooks}, and their outcomes come back through
|
|
38
|
+
* `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
|
|
39
|
+
* {@link opWaveHooksAtBase}.
|
|
32
40
|
*/
|
|
33
41
|
|
|
34
42
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
35
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
36
|
-
import { dirname, join, posix } from "node:path";
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
37
45
|
import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
|
|
38
46
|
import type { ReviewApproval } from "../gate-decision";
|
|
39
47
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
@@ -47,11 +55,13 @@ import {
|
|
|
47
55
|
GATE_APPROVAL_SOURCES,
|
|
48
56
|
assertOpWaveHooks,
|
|
49
57
|
assertOpWavesSpec,
|
|
58
|
+
opWaveApplyHooksRunBy,
|
|
50
59
|
opWaveHooksAt,
|
|
51
60
|
opWaveRecordPath,
|
|
52
61
|
opWaveShare,
|
|
53
62
|
type GateApprovalSource,
|
|
54
63
|
type OpWave,
|
|
64
|
+
type OpWaveApplyHooksRunner,
|
|
55
65
|
type OpWaveGatePolicy,
|
|
56
66
|
type OpWaveHook,
|
|
57
67
|
type OpWaveHookMoment,
|
|
@@ -134,6 +144,10 @@ export interface OpWaveDecision {
|
|
|
134
144
|
approvedAt?: string;
|
|
135
145
|
/** When the wave recorded that it applies under the approval, which spent it. */
|
|
136
146
|
spentAt?: string;
|
|
147
|
+
/** `apply`: the apply command runs the apply-stage hooks under its own lock (#3804), as read at base. Absent: chant runs them. */
|
|
148
|
+
applyHooksRunBy?: "apply";
|
|
149
|
+
/** Every hook's outcome so far in the job that wrote the record (#3804). */
|
|
150
|
+
outcomes?: OpWaveHookOutcome[];
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
/** One hook's run (#3762). */
|
|
@@ -143,7 +157,80 @@ export interface OpWaveHookOutcome {
|
|
|
143
157
|
/** The run, for a `perRun` hook. */
|
|
144
158
|
target?: string;
|
|
145
159
|
exit: number;
|
|
146
|
-
outcome: "passed" | "failed" | "held";
|
|
160
|
+
outcome: "passed" | "failed" | "held" | "skipped";
|
|
161
|
+
/** Why the moment was skipped, for `skipped` (#3811). */
|
|
162
|
+
reason?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One run as a hook sees it (#3804). */
|
|
166
|
+
export interface OpWaveHookRun {
|
|
167
|
+
target: string;
|
|
168
|
+
op: string;
|
|
169
|
+
/** The run's plan file, once planned. */
|
|
170
|
+
plan?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
|
|
175
|
+
* {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
|
|
176
|
+
* relative to the job's working directory.
|
|
177
|
+
*/
|
|
178
|
+
export interface OpWaveHooksFile {
|
|
179
|
+
version: 1;
|
|
180
|
+
/** The Op waves spec's name. */
|
|
181
|
+
opWaves: string;
|
|
182
|
+
/** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
|
|
183
|
+
op: string;
|
|
184
|
+
wave: string;
|
|
185
|
+
/** 1-based; 0 when the working tree's spec has no such wave. */
|
|
186
|
+
number: number;
|
|
187
|
+
/** The commit the hooks were read at. */
|
|
188
|
+
base?: string;
|
|
189
|
+
/** The wave's set digest, once decided. */
|
|
190
|
+
digest?: string;
|
|
191
|
+
/** An approval of the digest stands, so a held hook does not stop the apply. */
|
|
192
|
+
approved: boolean;
|
|
193
|
+
/** The labels of the hooks that held the wave, approved or not. */
|
|
194
|
+
heldBy?: string[];
|
|
195
|
+
/** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
|
|
196
|
+
runBy: OpWaveApplyHooksRunner;
|
|
197
|
+
/** The runs this apply call applies. */
|
|
198
|
+
runs: OpWaveHookRun[];
|
|
199
|
+
/** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
|
|
200
|
+
runsFile: string;
|
|
201
|
+
/** The hooks the apply runs, by moment, as read at base. */
|
|
202
|
+
hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
203
|
+
/** The hooks that already ran for this wave in this job, and how each came out. */
|
|
204
|
+
outcomes: OpWaveHookOutcome[];
|
|
205
|
+
/** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
|
|
206
|
+
result?: string;
|
|
207
|
+
/** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
|
|
208
|
+
refused?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
|
|
212
|
+
export interface OpWaveHooksResult {
|
|
213
|
+
version: 1;
|
|
214
|
+
/** The moments the apply ran, empty ones included. */
|
|
215
|
+
moments: OpWaveHookMoment[];
|
|
216
|
+
outcomes: OpWaveHookOutcome[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One call of {@link runOpWaveApplyHooks}. */
|
|
220
|
+
export interface OpWaveApplyHooksRun {
|
|
221
|
+
moment: OpWaveHookMoment;
|
|
222
|
+
outcomes: OpWaveHookOutcome[];
|
|
223
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
224
|
+
failed?: string;
|
|
225
|
+
/** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
|
|
226
|
+
held: string[];
|
|
227
|
+
/**
|
|
228
|
+
* Whether the apply may go on: no hook failed, and nothing held it or an
|
|
229
|
+
* approval of the digest stands. When false after a hold, the apply applies
|
|
230
|
+
* nothing and exits non-zero; chant then waits for, or finds, an approval
|
|
231
|
+
* and calls it again.
|
|
232
|
+
*/
|
|
233
|
+
proceed: boolean;
|
|
147
234
|
}
|
|
148
235
|
|
|
149
236
|
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
@@ -204,6 +291,8 @@ export interface OpWaveBaseRules {
|
|
|
204
291
|
hooksRefused?: string;
|
|
205
292
|
/** The spec at base sets `approvals.spend` (#3784). */
|
|
206
293
|
spend: boolean;
|
|
294
|
+
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
295
|
+
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
207
296
|
}
|
|
208
297
|
|
|
209
298
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -224,7 +313,7 @@ export function readOpWavePolicy(
|
|
|
224
313
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
225
314
|
): OpWaveBaseRules {
|
|
226
315
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
227
|
-
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false };
|
|
316
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
|
|
228
317
|
if (text === null) return absent;
|
|
229
318
|
let spec: OpWavesSpec;
|
|
230
319
|
try {
|
|
@@ -239,11 +328,12 @@ export function readOpWavePolicy(
|
|
|
239
328
|
const policy = wave.gate ?? "always";
|
|
240
329
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
241
330
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
242
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend };
|
|
331
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
243
332
|
try {
|
|
244
333
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
245
334
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
246
335
|
rules.hooks = opWaveHooksAt(spec, name);
|
|
336
|
+
rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
|
|
247
337
|
} catch (err) {
|
|
248
338
|
// A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
|
|
249
339
|
rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
|
|
@@ -366,8 +456,8 @@ function writeRunsFile(
|
|
|
366
456
|
runs: readonly OpWaveRun[],
|
|
367
457
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
368
458
|
digest?: string,
|
|
459
|
+
path: string = opWaveRunsPath(at.spec, at.k),
|
|
369
460
|
): string {
|
|
370
|
-
const path = opWaveRunsPath(at.spec, at.k);
|
|
371
461
|
const file: OpWaveRunsFile = {
|
|
372
462
|
wave: at.wave.name,
|
|
373
463
|
number: at.k,
|
|
@@ -436,36 +526,43 @@ interface HooksOutcome {
|
|
|
436
526
|
holds: string[];
|
|
437
527
|
}
|
|
438
528
|
|
|
529
|
+
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
530
|
+
interface HookPlace {
|
|
531
|
+
/** The spec's name and `op`. */
|
|
532
|
+
name: string;
|
|
533
|
+
op: string;
|
|
534
|
+
waveName: string;
|
|
535
|
+
k: number;
|
|
536
|
+
cwd: string;
|
|
537
|
+
exec: OpWaveExec;
|
|
538
|
+
log: (line: string) => void;
|
|
539
|
+
}
|
|
540
|
+
|
|
439
541
|
/**
|
|
440
|
-
* Run one moment's hooks
|
|
441
|
-
*
|
|
542
|
+
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
543
|
+
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
442
544
|
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
443
545
|
*/
|
|
444
|
-
function
|
|
445
|
-
|
|
546
|
+
function runHookList(
|
|
547
|
+
at: HookPlace,
|
|
446
548
|
moment: OpWaveHookMoment,
|
|
447
549
|
hooks: readonly OpWaveHook[] | undefined,
|
|
448
|
-
runs: readonly
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const out: HooksOutcome = { holds: [] };
|
|
550
|
+
runs: readonly OpWaveHookRun[],
|
|
551
|
+
runsPath: string,
|
|
552
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
|
|
553
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
|
|
453
554
|
if (!hooks || hooks.length === 0) return out;
|
|
454
|
-
const {
|
|
455
|
-
const
|
|
456
|
-
const env = { CHANT_OP_WAVES: spec.name, CHANT_WAVE: wave.name, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
|
|
555
|
+
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
556
|
+
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
|
|
457
557
|
for (const hook of hooks) {
|
|
458
558
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
459
559
|
const calls = hook.perRun
|
|
460
|
-
? runs.map((run) => {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
};
|
|
467
|
-
})
|
|
468
|
-
: [{ target: undefined, argv: opWaveArgv(hook.command, { op: spec.op, wave: wave.name, runs: runsPath }), env: env as Record<string, string> }];
|
|
560
|
+
? runs.map((run) => ({
|
|
561
|
+
target: run.target as string | undefined,
|
|
562
|
+
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
563
|
+
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
564
|
+
}))
|
|
565
|
+
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
469
566
|
for (const call of calls) {
|
|
470
567
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
471
568
|
let code: number;
|
|
@@ -477,16 +574,16 @@ function runHooks(
|
|
|
477
574
|
}
|
|
478
575
|
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
479
576
|
if (code === 0) {
|
|
480
|
-
|
|
577
|
+
out.outcomes.push({ ...base, outcome: "passed" });
|
|
481
578
|
continue;
|
|
482
579
|
}
|
|
483
580
|
if (hook.onFailure === "approve") {
|
|
484
|
-
|
|
581
|
+
out.outcomes.push({ ...base, outcome: "held" });
|
|
485
582
|
out.holds.push(label);
|
|
486
|
-
log(`${label} exited ${code}, so wave ${k} (${
|
|
583
|
+
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
487
584
|
continue;
|
|
488
585
|
}
|
|
489
|
-
|
|
586
|
+
out.outcomes.push({ ...base, outcome: "failed" });
|
|
490
587
|
out.failed = `${label} exited ${code}`;
|
|
491
588
|
log(out.failed);
|
|
492
589
|
return out;
|
|
@@ -495,6 +592,150 @@ function runHooks(
|
|
|
495
592
|
return out;
|
|
496
593
|
}
|
|
497
594
|
|
|
595
|
+
const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
|
|
596
|
+
|
|
597
|
+
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
598
|
+
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
599
|
+
|
|
600
|
+
/** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
|
|
601
|
+
function runHooks(
|
|
602
|
+
ctx: WaveContext,
|
|
603
|
+
moment: OpWaveHookMoment,
|
|
604
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
605
|
+
runs: readonly OpWaveRun[],
|
|
606
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
607
|
+
digest?: string,
|
|
608
|
+
): HooksOutcome {
|
|
609
|
+
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
610
|
+
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
611
|
+
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
|
|
612
|
+
ctx.result.hooks.push(...out.outcomes);
|
|
613
|
+
return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Read the hooks file chant hands an apply command (#3804): the one at
|
|
618
|
+
* `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
|
|
619
|
+
* that a wave job did not start.
|
|
620
|
+
*/
|
|
621
|
+
export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
|
|
622
|
+
if (!path) return null;
|
|
623
|
+
const at = isAbsolute(path) ? path : join(cwd, path);
|
|
624
|
+
let parsed: OpWaveHooksFile;
|
|
625
|
+
try {
|
|
626
|
+
parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
629
|
+
}
|
|
630
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.runs) || typeof parsed.hooks !== "object") throw new Error(`${path} is not an Op waves hooks file (version 1)`);
|
|
631
|
+
return parsed;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Run one moment's hooks from a hooks file (#3804): what an apply command
|
|
636
|
+
* calls under its own lock, `before-apply` right before it changes anything
|
|
637
|
+
* and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
|
|
638
|
+
* is `apply`. Also what a product's apply outside the waves calls, with a
|
|
639
|
+
* file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
|
|
640
|
+
* runs nothing and proceeds, so an apply can call it whoever runs the hooks.
|
|
641
|
+
*
|
|
642
|
+
* Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
|
|
643
|
+
* which chant reads back after the apply exits: the outcomes join the wave's
|
|
644
|
+
* result and decision record, and a hold makes the wave wait for, or find,
|
|
645
|
+
* an approval of its digest. `skip: "<reason>"` runs nothing and records the
|
|
646
|
+
* moment as skipped with the reason (#3811), for an apply that changes nothing:
|
|
647
|
+
* chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
|
|
648
|
+
* in process or to keep its output.
|
|
649
|
+
*/
|
|
650
|
+
export function runOpWaveApplyHooks(
|
|
651
|
+
file: OpWaveHooksFile,
|
|
652
|
+
moment: OpWaveHookMoment,
|
|
653
|
+
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
|
|
654
|
+
): OpWaveApplyHooksRun {
|
|
655
|
+
const cwd = options.cwd ?? process.cwd();
|
|
656
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
657
|
+
let run: OpWaveApplyHooksRun;
|
|
658
|
+
if (options.skip !== undefined) {
|
|
659
|
+
const reason = options.skip.trim();
|
|
660
|
+
if (!reason) throw new Error(`skipping the ${moment} hooks needs a reason (skip: "nothing to apply")`);
|
|
661
|
+
log(`${moment} hooks skipped: ${reason}`);
|
|
662
|
+
run = { moment, outcomes: [{ moment, name: "skip", exit: 0, outcome: "skipped", reason }], held: [], proceed: true };
|
|
663
|
+
} else if (file.refused) {
|
|
664
|
+
log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
|
|
665
|
+
run = { moment, outcomes: [], held: [file.refused], proceed: false };
|
|
666
|
+
} else {
|
|
667
|
+
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
668
|
+
const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
|
|
669
|
+
run = {
|
|
670
|
+
moment,
|
|
671
|
+
outcomes: out.outcomes,
|
|
672
|
+
...(out.failed ? { failed: out.failed } : {}),
|
|
673
|
+
held: out.holds,
|
|
674
|
+
proceed: !out.failed && (out.holds.length === 0 || file.approved),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (file.result) {
|
|
678
|
+
const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
|
|
679
|
+
let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
|
|
680
|
+
if (existsSync(at)) {
|
|
681
|
+
try {
|
|
682
|
+
prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
|
|
683
|
+
} catch {
|
|
684
|
+
// A result file that cannot be read is started again.
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
|
|
688
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
689
|
+
writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
|
|
690
|
+
}
|
|
691
|
+
return run;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The hooks of wave `wave` read at base, for an apply outside the waves
|
|
696
|
+
* (#3804): a product's own `apply <env>` that runs the same Op as the wave
|
|
697
|
+
* does, and the same hooks. All four moments are in the file; the apply runs
|
|
698
|
+
* the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
|
|
699
|
+
* approved: a held hook stops the apply. The wave or the file missing at
|
|
700
|
+
* base has no hooks, as in a wave job.
|
|
701
|
+
*/
|
|
702
|
+
export function opWaveHooksAtBase(options: {
|
|
703
|
+
specFile: string;
|
|
704
|
+
wave: string;
|
|
705
|
+
/** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
|
|
706
|
+
runs?: ReadonlyArray<{ target: string; op?: string }>;
|
|
707
|
+
/** Default: the spec's `base`, else `HEAD^1`. */
|
|
708
|
+
base?: string;
|
|
709
|
+
cwd?: string;
|
|
710
|
+
show?: OpWaveShowAtBase;
|
|
711
|
+
}): OpWaveHooksFile {
|
|
712
|
+
const cwd = options.cwd ?? process.cwd();
|
|
713
|
+
const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
|
|
714
|
+
const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
|
|
715
|
+
const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
716
|
+
const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
|
|
717
|
+
const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
|
|
718
|
+
const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
|
|
719
|
+
mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
|
|
720
|
+
writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
|
|
721
|
+
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
722
|
+
return {
|
|
723
|
+
version: 1,
|
|
724
|
+
opWaves: spec.name,
|
|
725
|
+
op: spec.op,
|
|
726
|
+
wave: options.wave,
|
|
727
|
+
number: k,
|
|
728
|
+
base: rules.base,
|
|
729
|
+
approved: false,
|
|
730
|
+
runBy: "apply",
|
|
731
|
+
runs,
|
|
732
|
+
runsFile,
|
|
733
|
+
hooks,
|
|
734
|
+
outcomes: [],
|
|
735
|
+
...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
498
739
|
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
499
740
|
export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
|
|
500
741
|
if (policy === "never") return false;
|
|
@@ -504,9 +745,10 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
|
|
|
504
745
|
}
|
|
505
746
|
|
|
506
747
|
/** Log when the change's hooks are not the ones at base, which are the ones that run. */
|
|
507
|
-
function noteHooksAtBase(ctx: WaveContext,
|
|
748
|
+
function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
|
|
749
|
+
const { hooks: atBase, base } = rules;
|
|
508
750
|
const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
|
|
509
|
-
if (JSON.stringify(own) !== JSON.stringify(atBase)) {
|
|
751
|
+
if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
|
|
510
752
|
ctx.log(`wave ${ctx.k} (${ctx.wave.name}): its hooks are read at ${base.slice(0, 12)}, and this change's differ; the ones at base run`);
|
|
511
753
|
}
|
|
512
754
|
}
|
|
@@ -570,7 +812,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
570
812
|
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
571
813
|
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
572
814
|
const { policy, approval, source, base } = rules;
|
|
573
|
-
noteHooksAtBase(ctx, rules
|
|
815
|
+
noteHooksAtBase(ctx, rules);
|
|
574
816
|
const holds: string[] = [];
|
|
575
817
|
if (rules.hooksRefused) {
|
|
576
818
|
holds.push(rules.hooksRefused);
|
|
@@ -601,12 +843,13 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
601
843
|
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
602
844
|
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
603
845
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
846
|
+
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
604
847
|
};
|
|
605
848
|
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
606
849
|
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
607
850
|
if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
|
|
608
851
|
mkdirSync(dirname(recordPath), { recursive: true });
|
|
609
|
-
|
|
852
|
+
writeRecord(recordPath, decision, result);
|
|
610
853
|
result.decision = decision;
|
|
611
854
|
log(
|
|
612
855
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
@@ -695,11 +938,135 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
695
938
|
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
696
939
|
}
|
|
697
940
|
|
|
941
|
+
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
942
|
+
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
943
|
+
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
944
|
+
writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* A hold waits only when nobody approved the decided digest: settle the gate
|
|
949
|
+
* once for `holds`. False when the wave now waits (exit 3).
|
|
950
|
+
*/
|
|
951
|
+
async function settleHolds(
|
|
952
|
+
options: RunOpWaveOptions,
|
|
953
|
+
ctx: WaveContext,
|
|
954
|
+
decision: OpWaveDecision,
|
|
955
|
+
holds: readonly string[],
|
|
956
|
+
recordPath?: string,
|
|
957
|
+
): Promise<boolean> {
|
|
958
|
+
const { k, wave, log, result } = ctx;
|
|
959
|
+
if (holds.length === 0 || decision.status === "approved") return true;
|
|
960
|
+
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
961
|
+
await settleGate(options, ctx, decision);
|
|
962
|
+
if (recordPath) writeRecord(recordPath, decision, result);
|
|
963
|
+
if (decision.status === "waiting") {
|
|
964
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
965
|
+
result.exitCode = 3;
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
|
|
973
|
+
type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
|
|
977
|
+
* When the apply runs the apply-stage hooks, read their outcomes back: a hold
|
|
978
|
+
* with no approval standing settles the gate and, once approved, calls the
|
|
979
|
+
* apply again; an `after-apply` failure means the runs applied and the wave
|
|
980
|
+
* fails; an apply that exited 0 without running its hooks fails the wave.
|
|
981
|
+
*/
|
|
982
|
+
async function callApply(
|
|
983
|
+
options: RunOpWaveOptions,
|
|
984
|
+
ctx: WaveContext,
|
|
985
|
+
decision: OpWaveDecision,
|
|
986
|
+
runs: readonly OpWaveRun[],
|
|
987
|
+
planned: ReadonlyMap<string, OpWaveMember>,
|
|
988
|
+
argv: string[],
|
|
989
|
+
call: { label: string; key: string; runsPath: string },
|
|
990
|
+
recordPath?: string,
|
|
991
|
+
): Promise<ApplyCall> {
|
|
992
|
+
const { spec, k, wave, cwd, exec, log, result } = ctx;
|
|
993
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
994
|
+
const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
|
|
995
|
+
const filePath = `${dir}/${call.key}.json`;
|
|
996
|
+
const resultPath = `${dir}/${call.key}-result.json`;
|
|
997
|
+
const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
|
|
998
|
+
if (byApply) {
|
|
999
|
+
for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
|
|
1000
|
+
}
|
|
1001
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
1002
|
+
const approved = decision.status === "approved";
|
|
1003
|
+
const file: OpWaveHooksFile = {
|
|
1004
|
+
version: 1,
|
|
1005
|
+
opWaves: spec.name,
|
|
1006
|
+
op: spec.op,
|
|
1007
|
+
wave: wave.name,
|
|
1008
|
+
number: k,
|
|
1009
|
+
base: decision.base,
|
|
1010
|
+
digest: decision.digest,
|
|
1011
|
+
approved,
|
|
1012
|
+
...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
|
|
1013
|
+
runBy: byApply ? "apply" : "chant",
|
|
1014
|
+
runs: hookRuns(spec, k, runs, planned),
|
|
1015
|
+
runsFile: call.runsPath,
|
|
1016
|
+
hooks: handed,
|
|
1017
|
+
outcomes: [...result.hooks],
|
|
1018
|
+
...(byApply ? { result: resultPath } : {}),
|
|
1019
|
+
};
|
|
1020
|
+
mkdirSync(join(cwd, dir), { recursive: true });
|
|
1021
|
+
writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
|
|
1022
|
+
rmSync(join(cwd, resultPath), { force: true });
|
|
1023
|
+
let code: number;
|
|
1024
|
+
try {
|
|
1025
|
+
code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
1028
|
+
code = 127;
|
|
1029
|
+
}
|
|
1030
|
+
if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
|
|
1031
|
+
let back: OpWaveHooksResult | null = null;
|
|
1032
|
+
try {
|
|
1033
|
+
back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
|
|
1034
|
+
} catch {
|
|
1035
|
+
back = null;
|
|
1036
|
+
}
|
|
1037
|
+
const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
|
|
1038
|
+
const moments = Array.isArray(back?.moments) ? back!.moments : [];
|
|
1039
|
+
result.hooks.push(...outcomes);
|
|
1040
|
+
const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
|
|
1041
|
+
if (holds.length > 0 && !approved) {
|
|
1042
|
+
if (code === 0) {
|
|
1043
|
+
log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
|
|
1044
|
+
return { outcome: "failed" };
|
|
1045
|
+
}
|
|
1046
|
+
if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
|
|
1047
|
+
if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
|
|
1048
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
|
|
1052
|
+
if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
|
|
1053
|
+
if (code !== 0) return { outcome: "failed" };
|
|
1054
|
+
const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
|
|
1055
|
+
if (skipped.length > 0) {
|
|
1056
|
+
return { outcome: "applied", problem: `${call.label}: the apply exited 0 without running its ${skipped.join(" and ")} hooks (runOpWaveApplyHooks with CHANT_HOOKS), which the spec at base hands it` };
|
|
1057
|
+
}
|
|
1058
|
+
return { outcome: "applied" };
|
|
1059
|
+
}
|
|
1060
|
+
return { outcome: "failed" };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
698
1063
|
/**
|
|
699
1064
|
* Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
|
|
700
1065
|
* per run, or one `applyWave` call for the set), then the after-apply hooks.
|
|
701
1066
|
* A hook that holds the wave here, or held a share job's plan, makes it wait
|
|
702
|
-
* for an approval of the decided digest unless one already stands.
|
|
1067
|
+
* for an approval of the decided digest unless one already stands. Under
|
|
1068
|
+
* `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
|
|
1069
|
+
* itself, under its lock (#3804).
|
|
703
1070
|
*/
|
|
704
1071
|
async function applyStage(
|
|
705
1072
|
options: RunOpWaveOptions,
|
|
@@ -710,70 +1077,74 @@ async function applyStage(
|
|
|
710
1077
|
earlierHolds: readonly string[],
|
|
711
1078
|
recordPath?: string,
|
|
712
1079
|
): Promise<void> {
|
|
713
|
-
const { spec, k, wave,
|
|
1080
|
+
const { spec, k, wave, log, result } = ctx;
|
|
714
1081
|
if (runs.length === 0) return;
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (holds.length > 0 && decision.status !== "approved") {
|
|
722
|
-
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
723
|
-
await settleGate(options, ctx, decision);
|
|
724
|
-
if (recordPath) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
725
|
-
if (decision.status === "waiting") {
|
|
726
|
-
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
727
|
-
result.exitCode = 3;
|
|
1082
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
1083
|
+
const holds = [...earlierHolds];
|
|
1084
|
+
if (!byApply) {
|
|
1085
|
+
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1086
|
+
if (before.failed) {
|
|
1087
|
+
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
728
1088
|
return;
|
|
729
1089
|
}
|
|
730
|
-
|
|
1090
|
+
holds.push(...before.holds);
|
|
731
1091
|
}
|
|
1092
|
+
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
732
1093
|
if (!(await spendApproval(options, ctx, decision))) return;
|
|
733
|
-
if (recordPath && decision.spentAt)
|
|
1094
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1095
|
+
const problems: string[] = [];
|
|
1096
|
+
let waiting = false;
|
|
734
1097
|
if (spec.applyWave) {
|
|
735
1098
|
const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
if (code === 0) result.applied.push(...runs.map((r) => r.target));
|
|
1099
|
+
const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
|
|
1100
|
+
const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
|
|
1101
|
+
if (got.outcome === "applied") {
|
|
1102
|
+
result.applied.push(...runs.map((r) => r.target));
|
|
1103
|
+
if (got.problem) problems.push(got.problem);
|
|
1104
|
+
} else if (got.outcome === "waiting") waiting = true;
|
|
744
1105
|
else {
|
|
745
|
-
log(`wave ${k} (${wave.name}): apply
|
|
1106
|
+
log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
|
|
746
1107
|
result.failed.push(...runs.map((r) => r.target));
|
|
747
1108
|
}
|
|
748
1109
|
} else {
|
|
749
|
-
for (const run of runs)
|
|
1110
|
+
for (const run of runs) {
|
|
1111
|
+
const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1112
|
+
// Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
|
|
1113
|
+
const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
|
|
1114
|
+
const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
|
|
1115
|
+
const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
|
|
1116
|
+
if (got.outcome === "waiting") {
|
|
1117
|
+
waiting = true;
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
if (got.outcome === "applied") {
|
|
1121
|
+
result.applied.push(run.target);
|
|
1122
|
+
if (got.problem) problems.push(got.problem);
|
|
1123
|
+
} else {
|
|
1124
|
+
log(`${run.target}: apply failed`);
|
|
1125
|
+
result.failed.push(run.target);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (recordPath && byApply) writeRecord(recordPath, decision, result);
|
|
1130
|
+
if (waiting) {
|
|
1131
|
+
result.exitCode = 3;
|
|
1132
|
+
return;
|
|
750
1133
|
}
|
|
751
1134
|
if (result.failed.length > 0) {
|
|
752
1135
|
result.exitCode = 1;
|
|
753
1136
|
return;
|
|
754
1137
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
run: OpWaveRun,
|
|
764
|
-
cwd: string,
|
|
765
|
-
exec: OpWaveExec,
|
|
766
|
-
result: RunOpWaveResult,
|
|
767
|
-
log: (line: string) => void,
|
|
768
|
-
): void {
|
|
769
|
-
const op = run.op ?? spec.op;
|
|
770
|
-
const plan = opWavePlanPath(spec, k, run.target);
|
|
771
|
-
const code = exec(opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op, target: run.target, wave: waveName, plan }), cwd);
|
|
772
|
-
if (code === 0) result.applied.push(run.target);
|
|
773
|
-
else {
|
|
774
|
-
log(`${run.target}: apply exited ${code}`);
|
|
775
|
-
result.failed.push(run.target);
|
|
1138
|
+
if (problems.length > 0) {
|
|
1139
|
+
for (const p of problems) log(p);
|
|
1140
|
+
result.exitCode = 1;
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (!byApply) {
|
|
1144
|
+
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1145
|
+
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
776
1146
|
}
|
|
1147
|
+
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|
|
777
1148
|
}
|
|
778
1149
|
|
|
779
1150
|
/**
|