@intentius/chant 0.123.0 → 0.124.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/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 +128 -1
- 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 +444 -82
- package/src/op/op-waves.test.ts +161 -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). */
|
|
@@ -146,6 +160,77 @@ export interface OpWaveHookOutcome {
|
|
|
146
160
|
outcome: "passed" | "failed" | "held";
|
|
147
161
|
}
|
|
148
162
|
|
|
163
|
+
/** One run as a hook sees it (#3804). */
|
|
164
|
+
export interface OpWaveHookRun {
|
|
165
|
+
target: string;
|
|
166
|
+
op: string;
|
|
167
|
+
/** The run's plan file, once planned. */
|
|
168
|
+
plan?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
|
|
173
|
+
* {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
|
|
174
|
+
* relative to the job's working directory.
|
|
175
|
+
*/
|
|
176
|
+
export interface OpWaveHooksFile {
|
|
177
|
+
version: 1;
|
|
178
|
+
/** The Op waves spec's name. */
|
|
179
|
+
opWaves: string;
|
|
180
|
+
/** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
|
|
181
|
+
op: string;
|
|
182
|
+
wave: string;
|
|
183
|
+
/** 1-based; 0 when the working tree's spec has no such wave. */
|
|
184
|
+
number: number;
|
|
185
|
+
/** The commit the hooks were read at. */
|
|
186
|
+
base?: string;
|
|
187
|
+
/** The wave's set digest, once decided. */
|
|
188
|
+
digest?: string;
|
|
189
|
+
/** An approval of the digest stands, so a held hook does not stop the apply. */
|
|
190
|
+
approved: boolean;
|
|
191
|
+
/** The labels of the hooks that held the wave, approved or not. */
|
|
192
|
+
heldBy?: string[];
|
|
193
|
+
/** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
|
|
194
|
+
runBy: OpWaveApplyHooksRunner;
|
|
195
|
+
/** The runs this apply call applies. */
|
|
196
|
+
runs: OpWaveHookRun[];
|
|
197
|
+
/** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
|
|
198
|
+
runsFile: string;
|
|
199
|
+
/** The hooks the apply runs, by moment, as read at base. */
|
|
200
|
+
hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
201
|
+
/** The hooks that already ran for this wave in this job, and how each came out. */
|
|
202
|
+
outcomes: OpWaveHookOutcome[];
|
|
203
|
+
/** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
|
|
204
|
+
result?: string;
|
|
205
|
+
/** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
|
|
206
|
+
refused?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
|
|
210
|
+
export interface OpWaveHooksResult {
|
|
211
|
+
version: 1;
|
|
212
|
+
/** The moments the apply ran, empty ones included. */
|
|
213
|
+
moments: OpWaveHookMoment[];
|
|
214
|
+
outcomes: OpWaveHookOutcome[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** One call of {@link runOpWaveApplyHooks}. */
|
|
218
|
+
export interface OpWaveApplyHooksRun {
|
|
219
|
+
moment: OpWaveHookMoment;
|
|
220
|
+
outcomes: OpWaveHookOutcome[];
|
|
221
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
222
|
+
failed?: string;
|
|
223
|
+
/** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
|
|
224
|
+
held: string[];
|
|
225
|
+
/**
|
|
226
|
+
* Whether the apply may go on: no hook failed, and nothing held it or an
|
|
227
|
+
* approval of the digest stands. When false after a hold, the apply applies
|
|
228
|
+
* nothing and exits non-zero; chant then waits for, or finds, an approval
|
|
229
|
+
* and calls it again.
|
|
230
|
+
*/
|
|
231
|
+
proceed: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
149
234
|
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
150
235
|
export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
|
|
151
236
|
|
|
@@ -204,6 +289,8 @@ export interface OpWaveBaseRules {
|
|
|
204
289
|
hooksRefused?: string;
|
|
205
290
|
/** The spec at base sets `approvals.spend` (#3784). */
|
|
206
291
|
spend: boolean;
|
|
292
|
+
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
293
|
+
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
207
294
|
}
|
|
208
295
|
|
|
209
296
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -224,7 +311,7 @@ export function readOpWavePolicy(
|
|
|
224
311
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
225
312
|
): OpWaveBaseRules {
|
|
226
313
|
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 };
|
|
314
|
+
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
315
|
if (text === null) return absent;
|
|
229
316
|
let spec: OpWavesSpec;
|
|
230
317
|
try {
|
|
@@ -239,11 +326,12 @@ export function readOpWavePolicy(
|
|
|
239
326
|
const policy = wave.gate ?? "always";
|
|
240
327
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
241
328
|
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 };
|
|
329
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
243
330
|
try {
|
|
244
331
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
245
332
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
246
333
|
rules.hooks = opWaveHooksAt(spec, name);
|
|
334
|
+
rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
|
|
247
335
|
} catch (err) {
|
|
248
336
|
// A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
|
|
249
337
|
rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
|
|
@@ -366,8 +454,8 @@ function writeRunsFile(
|
|
|
366
454
|
runs: readonly OpWaveRun[],
|
|
367
455
|
members?: ReadonlyMap<string, OpWaveMember>,
|
|
368
456
|
digest?: string,
|
|
457
|
+
path: string = opWaveRunsPath(at.spec, at.k),
|
|
369
458
|
): string {
|
|
370
|
-
const path = opWaveRunsPath(at.spec, at.k);
|
|
371
459
|
const file: OpWaveRunsFile = {
|
|
372
460
|
wave: at.wave.name,
|
|
373
461
|
number: at.k,
|
|
@@ -436,36 +524,43 @@ interface HooksOutcome {
|
|
|
436
524
|
holds: string[];
|
|
437
525
|
}
|
|
438
526
|
|
|
527
|
+
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
528
|
+
interface HookPlace {
|
|
529
|
+
/** The spec's name and `op`. */
|
|
530
|
+
name: string;
|
|
531
|
+
op: string;
|
|
532
|
+
waveName: string;
|
|
533
|
+
k: number;
|
|
534
|
+
cwd: string;
|
|
535
|
+
exec: OpWaveExec;
|
|
536
|
+
log: (line: string) => void;
|
|
537
|
+
}
|
|
538
|
+
|
|
439
539
|
/**
|
|
440
|
-
* Run one moment's hooks
|
|
441
|
-
*
|
|
540
|
+
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
541
|
+
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
442
542
|
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
443
543
|
*/
|
|
444
|
-
function
|
|
445
|
-
|
|
544
|
+
function runHookList(
|
|
545
|
+
at: HookPlace,
|
|
446
546
|
moment: OpWaveHookMoment,
|
|
447
547
|
hooks: readonly OpWaveHook[] | undefined,
|
|
448
|
-
runs: readonly
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const out: HooksOutcome = { holds: [] };
|
|
548
|
+
runs: readonly OpWaveHookRun[],
|
|
549
|
+
runsPath: string,
|
|
550
|
+
): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
|
|
551
|
+
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
|
|
453
552
|
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 };
|
|
553
|
+
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
554
|
+
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
|
|
457
555
|
for (const hook of hooks) {
|
|
458
556
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
459
557
|
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> }];
|
|
558
|
+
? runs.map((run) => ({
|
|
559
|
+
target: run.target as string | undefined,
|
|
560
|
+
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
561
|
+
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
562
|
+
}))
|
|
563
|
+
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
469
564
|
for (const call of calls) {
|
|
470
565
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
471
566
|
let code: number;
|
|
@@ -477,16 +572,16 @@ function runHooks(
|
|
|
477
572
|
}
|
|
478
573
|
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
479
574
|
if (code === 0) {
|
|
480
|
-
|
|
575
|
+
out.outcomes.push({ ...base, outcome: "passed" });
|
|
481
576
|
continue;
|
|
482
577
|
}
|
|
483
578
|
if (hook.onFailure === "approve") {
|
|
484
|
-
|
|
579
|
+
out.outcomes.push({ ...base, outcome: "held" });
|
|
485
580
|
out.holds.push(label);
|
|
486
|
-
log(`${label} exited ${code}, so wave ${k} (${
|
|
581
|
+
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
487
582
|
continue;
|
|
488
583
|
}
|
|
489
|
-
|
|
584
|
+
out.outcomes.push({ ...base, outcome: "failed" });
|
|
490
585
|
out.failed = `${label} exited ${code}`;
|
|
491
586
|
log(out.failed);
|
|
492
587
|
return out;
|
|
@@ -495,6 +590,143 @@ function runHooks(
|
|
|
495
590
|
return out;
|
|
496
591
|
}
|
|
497
592
|
|
|
593
|
+
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 });
|
|
594
|
+
|
|
595
|
+
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
596
|
+
runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
|
|
597
|
+
|
|
598
|
+
/** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
|
|
599
|
+
function runHooks(
|
|
600
|
+
ctx: WaveContext,
|
|
601
|
+
moment: OpWaveHookMoment,
|
|
602
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
603
|
+
runs: readonly OpWaveRun[],
|
|
604
|
+
members?: ReadonlyMap<string, OpWaveMember>,
|
|
605
|
+
digest?: string,
|
|
606
|
+
): HooksOutcome {
|
|
607
|
+
if (!hooks || hooks.length === 0) return { holds: [] };
|
|
608
|
+
const runsPath = writeRunsFile(ctx, runs, members, digest);
|
|
609
|
+
const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
|
|
610
|
+
ctx.result.hooks.push(...out.outcomes);
|
|
611
|
+
return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Read the hooks file chant hands an apply command (#3804): the one at
|
|
616
|
+
* `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
|
|
617
|
+
* that a wave job did not start.
|
|
618
|
+
*/
|
|
619
|
+
export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
|
|
620
|
+
if (!path) return null;
|
|
621
|
+
const at = isAbsolute(path) ? path : join(cwd, path);
|
|
622
|
+
let parsed: OpWaveHooksFile;
|
|
623
|
+
try {
|
|
624
|
+
parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
627
|
+
}
|
|
628
|
+
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)`);
|
|
629
|
+
return parsed;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Run one moment's hooks from a hooks file (#3804): what an apply command
|
|
634
|
+
* calls under its own lock, `before-apply` right before it changes anything
|
|
635
|
+
* and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
|
|
636
|
+
* is `apply`. Also what a product's apply outside the waves calls, with a
|
|
637
|
+
* file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
|
|
638
|
+
* runs nothing and proceeds, so an apply can call it whoever runs the hooks.
|
|
639
|
+
*
|
|
640
|
+
* Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
|
|
641
|
+
* which chant reads back after the apply exits: the outcomes join the wave's
|
|
642
|
+
* result and decision record, and a hold makes the wave wait for, or find,
|
|
643
|
+
* an approval of its digest. `exec` runs one hook; inject it to run a hook
|
|
644
|
+
* in process or to keep its output.
|
|
645
|
+
*/
|
|
646
|
+
export function runOpWaveApplyHooks(
|
|
647
|
+
file: OpWaveHooksFile,
|
|
648
|
+
moment: OpWaveHookMoment,
|
|
649
|
+
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void } = {},
|
|
650
|
+
): OpWaveApplyHooksRun {
|
|
651
|
+
const cwd = options.cwd ?? process.cwd();
|
|
652
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
653
|
+
let run: OpWaveApplyHooksRun;
|
|
654
|
+
if (file.refused) {
|
|
655
|
+
log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
|
|
656
|
+
run = { moment, outcomes: [], held: [file.refused], proceed: false };
|
|
657
|
+
} else {
|
|
658
|
+
const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
|
|
659
|
+
const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
|
|
660
|
+
run = {
|
|
661
|
+
moment,
|
|
662
|
+
outcomes: out.outcomes,
|
|
663
|
+
...(out.failed ? { failed: out.failed } : {}),
|
|
664
|
+
held: out.holds,
|
|
665
|
+
proceed: !out.failed && (out.holds.length === 0 || file.approved),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (file.result) {
|
|
669
|
+
const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
|
|
670
|
+
let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
|
|
671
|
+
if (existsSync(at)) {
|
|
672
|
+
try {
|
|
673
|
+
prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
|
|
674
|
+
} catch {
|
|
675
|
+
// A result file that cannot be read is started again.
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
|
|
679
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
680
|
+
writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
|
|
681
|
+
}
|
|
682
|
+
return run;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The hooks of wave `wave` read at base, for an apply outside the waves
|
|
687
|
+
* (#3804): a product's own `apply <env>` that runs the same Op as the wave
|
|
688
|
+
* does, and the same hooks. All four moments are in the file; the apply runs
|
|
689
|
+
* the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
|
|
690
|
+
* approved: a held hook stops the apply. The wave or the file missing at
|
|
691
|
+
* base has no hooks, as in a wave job.
|
|
692
|
+
*/
|
|
693
|
+
export function opWaveHooksAtBase(options: {
|
|
694
|
+
specFile: string;
|
|
695
|
+
wave: string;
|
|
696
|
+
/** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
|
|
697
|
+
runs?: ReadonlyArray<{ target: string; op?: string }>;
|
|
698
|
+
/** Default: the spec's `base`, else `HEAD^1`. */
|
|
699
|
+
base?: string;
|
|
700
|
+
cwd?: string;
|
|
701
|
+
show?: OpWaveShowAtBase;
|
|
702
|
+
}): OpWaveHooksFile {
|
|
703
|
+
const cwd = options.cwd ?? process.cwd();
|
|
704
|
+
const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
|
|
705
|
+
const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
|
|
706
|
+
const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
707
|
+
const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
|
|
708
|
+
const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
|
|
709
|
+
const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
|
|
710
|
+
mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
|
|
711
|
+
writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
|
|
712
|
+
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
713
|
+
return {
|
|
714
|
+
version: 1,
|
|
715
|
+
opWaves: spec.name,
|
|
716
|
+
op: spec.op,
|
|
717
|
+
wave: options.wave,
|
|
718
|
+
number: k,
|
|
719
|
+
base: rules.base,
|
|
720
|
+
approved: false,
|
|
721
|
+
runBy: "apply",
|
|
722
|
+
runs,
|
|
723
|
+
runsFile,
|
|
724
|
+
hooks,
|
|
725
|
+
outcomes: [],
|
|
726
|
+
...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
498
730
|
/** Whether `policy` lets a wave with these members through without an approval. */
|
|
499
731
|
export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
|
|
500
732
|
if (policy === "never") return false;
|
|
@@ -504,9 +736,10 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
|
|
|
504
736
|
}
|
|
505
737
|
|
|
506
738
|
/** Log when the change's hooks are not the ones at base, which are the ones that run. */
|
|
507
|
-
function noteHooksAtBase(ctx: WaveContext,
|
|
739
|
+
function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
|
|
740
|
+
const { hooks: atBase, base } = rules;
|
|
508
741
|
const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
|
|
509
|
-
if (JSON.stringify(own) !== JSON.stringify(atBase)) {
|
|
742
|
+
if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
|
|
510
743
|
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
744
|
}
|
|
512
745
|
}
|
|
@@ -570,7 +803,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
570
803
|
// The policy, the approval mode and the hooks, read at base before anything runs.
|
|
571
804
|
const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
572
805
|
const { policy, approval, source, base } = rules;
|
|
573
|
-
noteHooksAtBase(ctx, rules
|
|
806
|
+
noteHooksAtBase(ctx, rules);
|
|
574
807
|
const holds: string[] = [];
|
|
575
808
|
if (rules.hooksRefused) {
|
|
576
809
|
holds.push(rules.hooksRefused);
|
|
@@ -601,12 +834,13 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
601
834
|
...(Object.keys(hooks).length > 0 ? { hooks } : {}),
|
|
602
835
|
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
603
836
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
837
|
+
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
604
838
|
};
|
|
605
839
|
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
606
840
|
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
607
841
|
if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
|
|
608
842
|
mkdirSync(dirname(recordPath), { recursive: true });
|
|
609
|
-
|
|
843
|
+
writeRecord(recordPath, decision, result);
|
|
610
844
|
result.decision = decision;
|
|
611
845
|
log(
|
|
612
846
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
@@ -695,11 +929,135 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
695
929
|
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
696
930
|
}
|
|
697
931
|
|
|
932
|
+
/** Write the decision record, with every hook's outcome so far (#3804). */
|
|
933
|
+
function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
|
|
934
|
+
if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
|
|
935
|
+
writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* A hold waits only when nobody approved the decided digest: settle the gate
|
|
940
|
+
* once for `holds`. False when the wave now waits (exit 3).
|
|
941
|
+
*/
|
|
942
|
+
async function settleHolds(
|
|
943
|
+
options: RunOpWaveOptions,
|
|
944
|
+
ctx: WaveContext,
|
|
945
|
+
decision: OpWaveDecision,
|
|
946
|
+
holds: readonly string[],
|
|
947
|
+
recordPath?: string,
|
|
948
|
+
): Promise<boolean> {
|
|
949
|
+
const { k, wave, log, result } = ctx;
|
|
950
|
+
if (holds.length === 0 || decision.status === "approved") return true;
|
|
951
|
+
decision.heldBy = [...(decision.heldBy ?? []), ...holds];
|
|
952
|
+
await settleGate(options, ctx, decision);
|
|
953
|
+
if (recordPath) writeRecord(recordPath, decision, result);
|
|
954
|
+
if (decision.status === "waiting") {
|
|
955
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
|
|
956
|
+
result.exitCode = 3;
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
|
|
964
|
+
type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
|
|
968
|
+
* When the apply runs the apply-stage hooks, read their outcomes back: a hold
|
|
969
|
+
* with no approval standing settles the gate and, once approved, calls the
|
|
970
|
+
* apply again; an `after-apply` failure means the runs applied and the wave
|
|
971
|
+
* fails; an apply that exited 0 without running its hooks fails the wave.
|
|
972
|
+
*/
|
|
973
|
+
async function callApply(
|
|
974
|
+
options: RunOpWaveOptions,
|
|
975
|
+
ctx: WaveContext,
|
|
976
|
+
decision: OpWaveDecision,
|
|
977
|
+
runs: readonly OpWaveRun[],
|
|
978
|
+
planned: ReadonlyMap<string, OpWaveMember>,
|
|
979
|
+
argv: string[],
|
|
980
|
+
call: { label: string; key: string; runsPath: string },
|
|
981
|
+
recordPath?: string,
|
|
982
|
+
): Promise<ApplyCall> {
|
|
983
|
+
const { spec, k, wave, cwd, exec, log, result } = ctx;
|
|
984
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
985
|
+
const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
|
|
986
|
+
const filePath = `${dir}/${call.key}.json`;
|
|
987
|
+
const resultPath = `${dir}/${call.key}-result.json`;
|
|
988
|
+
const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
|
|
989
|
+
if (byApply) {
|
|
990
|
+
for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
|
|
991
|
+
}
|
|
992
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
993
|
+
const approved = decision.status === "approved";
|
|
994
|
+
const file: OpWaveHooksFile = {
|
|
995
|
+
version: 1,
|
|
996
|
+
opWaves: spec.name,
|
|
997
|
+
op: spec.op,
|
|
998
|
+
wave: wave.name,
|
|
999
|
+
number: k,
|
|
1000
|
+
base: decision.base,
|
|
1001
|
+
digest: decision.digest,
|
|
1002
|
+
approved,
|
|
1003
|
+
...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
|
|
1004
|
+
runBy: byApply ? "apply" : "chant",
|
|
1005
|
+
runs: hookRuns(spec, k, runs, planned),
|
|
1006
|
+
runsFile: call.runsPath,
|
|
1007
|
+
hooks: handed,
|
|
1008
|
+
outcomes: [...result.hooks],
|
|
1009
|
+
...(byApply ? { result: resultPath } : {}),
|
|
1010
|
+
};
|
|
1011
|
+
mkdirSync(join(cwd, dir), { recursive: true });
|
|
1012
|
+
writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
|
|
1013
|
+
rmSync(join(cwd, resultPath), { force: true });
|
|
1014
|
+
let code: number;
|
|
1015
|
+
try {
|
|
1016
|
+
code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
1019
|
+
code = 127;
|
|
1020
|
+
}
|
|
1021
|
+
if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
|
|
1022
|
+
let back: OpWaveHooksResult | null = null;
|
|
1023
|
+
try {
|
|
1024
|
+
back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
|
|
1025
|
+
} catch {
|
|
1026
|
+
back = null;
|
|
1027
|
+
}
|
|
1028
|
+
const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
|
|
1029
|
+
const moments = Array.isArray(back?.moments) ? back!.moments : [];
|
|
1030
|
+
result.hooks.push(...outcomes);
|
|
1031
|
+
const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
|
|
1032
|
+
if (holds.length > 0 && !approved) {
|
|
1033
|
+
if (code === 0) {
|
|
1034
|
+
log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
|
|
1035
|
+
return { outcome: "failed" };
|
|
1036
|
+
}
|
|
1037
|
+
if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
|
|
1038
|
+
if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
|
|
1039
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
|
|
1043
|
+
if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
|
|
1044
|
+
if (code !== 0) return { outcome: "failed" };
|
|
1045
|
+
const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
|
|
1046
|
+
if (skipped.length > 0) {
|
|
1047
|
+
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` };
|
|
1048
|
+
}
|
|
1049
|
+
return { outcome: "applied" };
|
|
1050
|
+
}
|
|
1051
|
+
return { outcome: "failed" };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
698
1054
|
/**
|
|
699
1055
|
* Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
|
|
700
1056
|
* per run, or one `applyWave` call for the set), then the after-apply hooks.
|
|
701
1057
|
* 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.
|
|
1058
|
+
* for an approval of the decided digest unless one already stands. Under
|
|
1059
|
+
* `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
|
|
1060
|
+
* itself, under its lock (#3804).
|
|
703
1061
|
*/
|
|
704
1062
|
async function applyStage(
|
|
705
1063
|
options: RunOpWaveOptions,
|
|
@@ -710,70 +1068,74 @@ async function applyStage(
|
|
|
710
1068
|
earlierHolds: readonly string[],
|
|
711
1069
|
recordPath?: string,
|
|
712
1070
|
): Promise<void> {
|
|
713
|
-
const { spec, k, wave,
|
|
1071
|
+
const { spec, k, wave, log, result } = ctx;
|
|
714
1072
|
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;
|
|
1073
|
+
const byApply = decision.applyHooksRunBy === "apply";
|
|
1074
|
+
const holds = [...earlierHolds];
|
|
1075
|
+
if (!byApply) {
|
|
1076
|
+
const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
|
|
1077
|
+
if (before.failed) {
|
|
1078
|
+
failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
|
|
728
1079
|
return;
|
|
729
1080
|
}
|
|
730
|
-
|
|
1081
|
+
holds.push(...before.holds);
|
|
731
1082
|
}
|
|
1083
|
+
if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
|
|
732
1084
|
if (!(await spendApproval(options, ctx, decision))) return;
|
|
733
|
-
if (recordPath && decision.spentAt)
|
|
1085
|
+
if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
|
|
1086
|
+
const problems: string[] = [];
|
|
1087
|
+
let waiting = false;
|
|
734
1088
|
if (spec.applyWave) {
|
|
735
1089
|
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));
|
|
1090
|
+
const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
|
|
1091
|
+
const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
|
|
1092
|
+
if (got.outcome === "applied") {
|
|
1093
|
+
result.applied.push(...runs.map((r) => r.target));
|
|
1094
|
+
if (got.problem) problems.push(got.problem);
|
|
1095
|
+
} else if (got.outcome === "waiting") waiting = true;
|
|
744
1096
|
else {
|
|
745
|
-
log(`wave ${k} (${wave.name}): apply
|
|
1097
|
+
log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
|
|
746
1098
|
result.failed.push(...runs.map((r) => r.target));
|
|
747
1099
|
}
|
|
748
1100
|
} else {
|
|
749
|
-
for (const run of runs)
|
|
1101
|
+
for (const run of runs) {
|
|
1102
|
+
const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1103
|
+
// Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
|
|
1104
|
+
const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
|
|
1105
|
+
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) });
|
|
1106
|
+
const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
|
|
1107
|
+
if (got.outcome === "waiting") {
|
|
1108
|
+
waiting = true;
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
if (got.outcome === "applied") {
|
|
1112
|
+
result.applied.push(run.target);
|
|
1113
|
+
if (got.problem) problems.push(got.problem);
|
|
1114
|
+
} else {
|
|
1115
|
+
log(`${run.target}: apply failed`);
|
|
1116
|
+
result.failed.push(run.target);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (recordPath && byApply) writeRecord(recordPath, decision, result);
|
|
1121
|
+
if (waiting) {
|
|
1122
|
+
result.exitCode = 3;
|
|
1123
|
+
return;
|
|
750
1124
|
}
|
|
751
1125
|
if (result.failed.length > 0) {
|
|
752
1126
|
result.exitCode = 1;
|
|
753
1127
|
return;
|
|
754
1128
|
}
|
|
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);
|
|
1129
|
+
if (problems.length > 0) {
|
|
1130
|
+
for (const p of problems) log(p);
|
|
1131
|
+
result.exitCode = 1;
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (!byApply) {
|
|
1135
|
+
const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
|
|
1136
|
+
if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
|
|
776
1137
|
}
|
|
1138
|
+
if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
|
|
777
1139
|
}
|
|
778
1140
|
|
|
779
1141
|
/**
|