@intentius/chant 0.124.41 → 0.124.43

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.
Files changed (127) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +3 -0
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -3
  5. package/dist/cli/handlers/pr.d.ts +3 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +113 -2
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.js +11 -0
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +8 -2
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +15 -0
  14. package/dist/cli/registry.d.ts +19 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/capability.d.ts +47 -0
  17. package/dist/components/capability.d.ts.map +1 -1
  18. package/dist/components/capability.js +44 -0
  19. package/dist/components/cli-support.d.ts +6 -0
  20. package/dist/components/cli-support.d.ts.map +1 -1
  21. package/dist/components/cli-support.js +8 -1
  22. package/dist/components/driver.d.ts +10 -0
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/driver.js +52 -2
  25. package/dist/components/fan-out-run.d.ts +9 -0
  26. package/dist/components/fan-out-run.d.ts.map +1 -1
  27. package/dist/components/fan-out-run.js +17 -1
  28. package/dist/components/gate-plan.d.ts +2 -0
  29. package/dist/components/gate-plan.d.ts.map +1 -1
  30. package/dist/components/gate-plan.js +1 -0
  31. package/dist/components/pr-pipeline.d.ts +20 -0
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +54 -16
  34. package/dist/components/pr-run.d.ts +6 -0
  35. package/dist/components/pr-run.d.ts.map +1 -1
  36. package/dist/components/pr-run.js +3 -0
  37. package/dist/components/wave-plan.d.ts +14 -1
  38. package/dist/components/wave-plan.d.ts.map +1 -1
  39. package/dist/components/wave-plan.js +34 -2
  40. package/dist/op/activities/pr-refs.d.ts +10 -0
  41. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  42. package/dist/op/activities/pr-refs.js +26 -0
  43. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  44. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  45. package/dist/op/activities/predict-behaviour.js +2 -21
  46. package/dist/op/activities/review-agent.js +1 -1
  47. package/dist/op/index.d.ts +2 -2
  48. package/dist/op/index.d.ts.map +1 -1
  49. package/dist/op/index.js +1 -1
  50. package/dist/op/op-waves-run.d.ts +3 -79
  51. package/dist/op/op-waves-run.d.ts.map +1 -1
  52. package/dist/op/op-waves-run.js +4 -133
  53. package/dist/op/stage-hooks.d.ts +152 -0
  54. package/dist/op/stage-hooks.d.ts.map +1 -0
  55. package/dist/op/stage-hooks.js +199 -0
  56. package/dist/pr-forge.d.ts +56 -0
  57. package/dist/pr-forge.d.ts.map +1 -1
  58. package/dist/pr-forge.js +320 -45
  59. package/dist/pr-loop.d.ts +18 -1
  60. package/dist/pr-loop.d.ts.map +1 -1
  61. package/dist/pr-loop.js +7 -0
  62. package/dist/pr-requires.d.ts +47 -0
  63. package/dist/pr-requires.d.ts.map +1 -0
  64. package/dist/pr-requires.js +53 -0
  65. package/dist/wave-runtime.mjs +213 -213
  66. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  67. package/dist/workspace/checks/ci-workflow.js +19 -4
  68. package/dist/workspace/ci-init.d.ts.map +1 -1
  69. package/dist/workspace/ci-init.js +5 -4
  70. package/dist/workspace/ci-run.d.ts.map +1 -1
  71. package/dist/workspace/ci-run.js +2 -1
  72. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  73. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  74. package/dist/workspace/ci-workflow-at.js +17 -0
  75. package/dist/workspace/ci-workflow.d.ts +11 -3
  76. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  77. package/dist/workspace/ci-workflow.js +10 -2
  78. package/dist/workspace/declaration.d.ts +6 -3
  79. package/dist/workspace/declaration.d.ts.map +1 -1
  80. package/dist/workspace/declaration.schema.json +2 -2
  81. package/dist/workspace/pr-report.schema.json +23 -3
  82. package/dist/workspace/reason-codes.d.ts +1 -1
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/reason-codes.js +1 -1
  85. package/package.json +1 -1
  86. package/src/cli/handlers/build.ts +3 -0
  87. package/src/cli/handlers/fan-out.ts +27 -3
  88. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  89. package/src/cli/handlers/pr.ts +110 -2
  90. package/src/cli/handlers/run.ts +10 -0
  91. package/src/cli/main.ts +8 -2
  92. package/src/cli/parse-args.ts +11 -0
  93. package/src/cli/registry.ts +19 -0
  94. package/src/components/capability-targets.test.ts +162 -0
  95. package/src/components/capability.ts +71 -0
  96. package/src/components/cli-support.ts +14 -1
  97. package/src/components/driver.ts +54 -3
  98. package/src/components/fan-out-run.ts +19 -1
  99. package/src/components/gate-plan.ts +3 -0
  100. package/src/components/pr-pipeline.ts +64 -16
  101. package/src/components/pr-run.ts +9 -0
  102. package/src/components/wave-plan.ts +38 -3
  103. package/src/op/activities/pr-refs.ts +28 -0
  104. package/src/op/activities/predict-behaviour.ts +3 -23
  105. package/src/op/activities/review-agent.ts +1 -1
  106. package/src/op/index.ts +4 -0
  107. package/src/op/op-waves-run.ts +24 -228
  108. package/src/op/stage-hooks-async.test.ts +79 -0
  109. package/src/op/stage-hooks.ts +346 -0
  110. package/src/pr-forge-bundle.test.ts +2 -2
  111. package/src/pr-forge.ts +334 -36
  112. package/src/pr-loop-bundle.test.ts +2 -2
  113. package/src/pr-loop.ts +25 -1
  114. package/src/pr-requires.test.ts +248 -0
  115. package/src/pr-requires.ts +93 -0
  116. package/src/review-agent-bundle.test.ts +35 -0
  117. package/src/stage-hooks-bundle.test.ts +38 -0
  118. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  119. package/src/workspace/checks/ci-workflow.ts +18 -4
  120. package/src/workspace/ci-init.ts +5 -4
  121. package/src/workspace/ci-run.ts +2 -1
  122. package/src/workspace/ci-workflow-at.ts +23 -0
  123. package/src/workspace/ci-workflow.ts +13 -2
  124. package/src/workspace/declaration.schema.json +2 -2
  125. package/src/workspace/declaration.ts +6 -3
  126. package/src/workspace/pr-report.schema.json +23 -3
  127. package/src/workspace/reason-codes.ts +1 -1
@@ -27,11 +27,11 @@
27
27
  * `@<component>.publish.*`) resolves.
28
28
  */
29
29
 
30
- import { resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
30
+ import { assertTargetsAccepted, resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
31
31
  import { componentPlanDigest } from "./gate-plan";
32
32
  import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
33
33
  import { computePlanDigest } from "../lifecycle/plan-digest";
34
- import type { CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
34
+ import type { CapabilityPlan, CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
35
35
  import type { WaveMember } from "../gated-waves";
36
36
  import type { ChangeSetPart } from "../change-set";
37
37
 
@@ -101,12 +101,13 @@ export async function planWaveComponent(
101
101
  const plans: Record<string, unknown> = {};
102
102
  const parts: ChangeSetPart[] = [];
103
103
  let outputs: PlannedOutputs | undefined;
104
+ assertTargetsAccepted(component, registry, ctx);
104
105
  for (const step of capabilitySteps(component.deploy)) {
105
106
  const capability = registry.resolve(step.kind);
106
107
  if (!capability.plan) continue;
107
108
  const { kind: _kind, ...rest } = step;
108
109
  const input = resolveStepInput(rest, {}, componentOutputs);
109
- const planned = await capability.plan(planContext(ctx, rest, componentOutputs), input as never);
110
+ const planned = bindTargets(step.kind, ctx, await capability.plan(planContext(ctx, rest, componentOutputs), input as never));
110
111
  const member = { member: planned.member, planDigest: planned.planDigest };
111
112
  members.push(member);
112
113
  parts.push(planned.changeSet ?? emptyPart(member));
@@ -124,6 +125,7 @@ export async function planWaveComponent(
124
125
  component,
125
126
  ...(ctx.vars ? { vars: ctx.vars } : {}),
126
127
  inputs: read.inputs,
128
+ ...(ctx.targets ? { targets: ctx.targets } : {}),
127
129
  }),
128
130
  };
129
131
  members.push(member);
@@ -145,6 +147,39 @@ export async function planWaveComponent(
145
147
  }
146
148
  }
147
149
 
150
+ /**
151
+ * A member's digest with its targets in it (#4300), the way terragucci's
152
+ * cone plan digest binds a cone: the digest over the plan's own digest and
153
+ * the sorted targets, so an approval of one narrowing covers no other.
154
+ */
155
+ export function targetedPlanDigest(planDigest: string, targets: readonly string[]): string {
156
+ return computePlanDigest("capability-targets", { plan: planDigest, targets: [...new Set(targets)].sort() });
157
+ }
158
+
159
+ /**
160
+ * A step's plan made with `ctx.targets` (#4300), its digest bound to them.
161
+ * The plan must give back the targets it was asked for, which says it planned
162
+ * those alone; one that gives back others, or none, refuses. Without
163
+ * `ctx.targets`, the plan as it came.
164
+ */
165
+ export function bindTargets(kind: string, ctx: DeployContext, planned: CapabilityPlan): CapabilityPlan {
166
+ const asked = ctx.targets;
167
+ if (!asked || asked.length === 0) return planned;
168
+ const want = [...new Set(asked)].sort();
169
+ const got = [...new Set(planned.targets ?? [])].sort();
170
+ if (got.length !== want.length || got.some((t, i) => t !== want[i])) {
171
+ throw new Error(
172
+ `component "${ctx.component}": ${kind} was asked to plan ${want.join(", ")} but its plan of ${planned.member} ` +
173
+ (got.length === 0 ? "names no targets" : `names ${got.join(", ")}`),
174
+ );
175
+ }
176
+ const planDigest = targetedPlanDigest(planned.planDigest, want);
177
+ const changeSet = planned.changeSet
178
+ ? { ...planned.changeSet, member: { ...planned.changeSet.member, planDigest } }
179
+ : undefined;
180
+ return { ...planned, planDigest, targets: want, ...(changeSet ? { changeSet } : {}) };
181
+ }
182
+
148
183
  /** `next` over `prior`, as a later step's outputs win over an earlier one's in a run. */
149
184
  function mergePlannedOutputs(prior: PlannedOutputs | undefined, next: PlannedOutputs): PlannedOutputs {
150
185
  const known = { ...(prior?.known ?? {}), ...next.known };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The pull request's base and head branch names, read off the run's own
3
+ * event (#4299). Its own module so the review agent bundles without
4
+ * predict-behaviour's estate code.
5
+ */
6
+
7
+ /** Where the base branch name comes from, most specific first. Pure — exported for testing. */
8
+ export function baseRefFrom(
9
+ env: Record<string, string | undefined>,
10
+ explicit?: string,
11
+ ): string | undefined {
12
+ const fromArgs = explicit?.trim();
13
+ if (fromArgs) return fromArgs;
14
+ for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
15
+ const value = env[source]?.trim();
16
+ if (value) return value;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
22
+ export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
23
+ for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
24
+ const value = env[source]?.trim();
25
+ if (value) return value;
26
+ }
27
+ return shortSha?.trim() || "head";
28
+ }
@@ -85,6 +85,9 @@ import type { BehaviourKinds } from "../../behaviour-kinds";
85
85
  import { createBehaviourPredict } from "../../behaviour-predict";
86
86
  import type { SerializerResult } from "../../serializer";
87
87
  import { markerSlug, reconcilePr, suppliedMarker, type ReconcileResult } from "./reconcile";
88
+ import { baseRefFrom, headRefFrom } from "./pr-refs";
89
+
90
+ export { baseRefFrom, headRefFrom };
88
91
 
89
92
  const execAsync = promisify(exec);
90
93
 
@@ -294,29 +297,6 @@ export function noBaseRefMessage(): string {
294
297
  );
295
298
  }
296
299
 
297
- /** Where the base branch name comes from, most specific first. Pure — exported for testing. */
298
- export function baseRefFrom(
299
- env: Record<string, string | undefined>,
300
- explicit?: string,
301
- ): string | undefined {
302
- const fromArgs = explicit?.trim();
303
- if (fromArgs) return fromArgs;
304
- for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
305
- const value = env[source]?.trim();
306
- if (value) return value;
307
- }
308
- return undefined;
309
- }
310
-
311
- /** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
312
- export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
313
- for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
314
- const value = env[source]?.trim();
315
- if (value) return value;
316
- }
317
- return shortSha?.trim() || "head";
318
- }
319
-
320
300
  /** A checked-out base branch: where its project root is, and how to remove it. */
321
301
  export interface BaseCheckout {
322
302
  /** The base branch's counterpart of the working directory. */
@@ -27,7 +27,7 @@ import { spawn, execFile } from "node:child_process";
27
27
  import { promisify } from "node:util";
28
28
  import { readFile } from "node:fs/promises";
29
29
  import type { ChangeSetDocument, ChangeSetEntry } from "../../change-set";
30
- import { baseRefFrom } from "./predict-behaviour";
30
+ import { baseRefFrom } from "./pr-refs";
31
31
  import { markerSlug, reconcilePr, suppliedMarker } from "./reconcile";
32
32
 
33
33
  const execFileAsync = promisify(execFile);
package/src/op/index.ts CHANGED
@@ -75,6 +75,8 @@ export {
75
75
  OP_WAVE_MAX_REPLANS,
76
76
  opWaveHooksAtBase,
77
77
  runStageHooks,
78
+ runStageHooksAsync,
79
+ spawnExecAsync,
78
80
  opWaveNeedsApproval,
79
81
  opWavePlansPath,
80
82
  opWaveRunsPath,
@@ -91,6 +93,7 @@ export type {
91
93
  OpWaveDecision,
92
94
  OpWaveApplyHooksRun,
93
95
  OpWaveApplyOutcome,
96
+ OpWaveAsyncExec,
94
97
  OpWaveExec,
95
98
  OpWaveHeldOn,
96
99
  OpWaveHoldRequest,
@@ -106,6 +109,7 @@ export type {
106
109
  OpWaveShowAtBase,
107
110
  RecordHeadPlansOptions,
108
111
  RunOpWaveApplyHooksOptions,
112
+ RunStageHooksAsyncOptions,
109
113
  RunStageHooksOptions,
110
114
  StageHooksRun,
111
115
  RunOpWaveOptions,
@@ -105,6 +105,30 @@ import {
105
105
  type OpWaveRun,
106
106
  type OpWavesSpec,
107
107
  } from "./op-waves";
108
+ import {
109
+ OP_WAVE_PENDING_RESERVED,
110
+ opWaveArgv,
111
+ pendingFields,
112
+ readHoldRequest,
113
+ runHookList,
114
+ runStageHooks,
115
+ runStageHooksAsync,
116
+ spawnExec,
117
+ spawnExecAsync,
118
+ type HookPlace,
119
+ type OpWaveAsyncExec,
120
+ type OpWaveExec,
121
+ type OpWaveHoldRequest,
122
+ type OpWaveHookOutcome,
123
+ type OpWaveHookRun,
124
+ type OtherGateHold,
125
+ type RunStageHooksAsyncOptions,
126
+ type RunStageHooksOptions,
127
+ type StageHooksRun,
128
+ } from "./stage-hooks";
129
+
130
+ export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks, runStageHooksAsync, spawnExecAsync };
131
+ export type { OpWaveAsyncExec, OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksAsyncOptions, RunStageHooksOptions, StageHooksRun };
108
132
 
109
133
  /** What a run's plan command writes to `{plan}`. */
110
134
  export interface OpWavePlanFile {
@@ -154,12 +178,6 @@ export interface OpWavePlansFile {
154
178
  waves?: number;
155
179
  }
156
180
 
157
- /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
158
- export const OP_WAVE_PENDING_RESERVED = [
159
- "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
160
- "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
161
- ] as const;
162
-
163
181
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
164
182
  export interface OpWaveRunsFile {
165
183
  /** The wave's name. */
@@ -305,18 +323,6 @@ export interface OpWaveApplyOutcome {
305
323
 
306
324
  export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
307
325
 
308
- /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
309
- export interface OpWaveHoldRequest {
310
- /** The op the gate is recorded under. Default: the spec's name. */
311
- op?: string;
312
- gate: string;
313
- /** The digest an approval binds. */
314
- digest: string;
315
- description?: string;
316
- /** Fields for the pending fact, as a plans file's `pending`. */
317
- pending?: Record<string, unknown>;
318
- }
319
-
320
326
  /** A hold on another gate, as the decision records it (#3821). */
321
327
  export interface OpWaveHeldOn {
322
328
  op: string;
@@ -347,26 +353,6 @@ export interface OpWaveHeldOnApproval {
347
353
  approvedAt: string;
348
354
  }
349
355
 
350
- /** One hook's run (#3762). */
351
- export interface OpWaveHookOutcome {
352
- moment: OpWaveHookMoment;
353
- name: string;
354
- /** The run, for a `perRun` hook. */
355
- target?: string;
356
- exit: number;
357
- outcome: "passed" | "failed" | "held" | "skipped";
358
- /** Why the moment was skipped, for `skipped` (#3811). */
359
- reason?: string;
360
- }
361
-
362
- /** One run as a hook sees it (#3804). */
363
- export interface OpWaveHookRun {
364
- target: string;
365
- op: string;
366
- /** The run's plan file, once planned. */
367
- plan?: string;
368
- }
369
-
370
356
  /**
371
357
  * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
372
358
  * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
@@ -442,15 +428,6 @@ export interface OpWaveApplyHooksRun {
442
428
  proceed: boolean;
443
429
  }
444
430
 
445
- /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
446
- export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
447
-
448
- const spawnExec: OpWaveExec = (argv, cwd, env) => {
449
- const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
450
- if (result.error) throw result.error;
451
- return result.status ?? 1;
452
- };
453
-
454
431
  /** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
455
432
  export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
456
433
 
@@ -581,22 +558,6 @@ function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWav
581
558
  return { point: opWavePointAt(spec, name)! };
582
559
  }
583
560
 
584
- /** Fill one argv template. A placeholder whose value is not given is left as it is. */
585
- export function opWaveArgv(
586
- template: readonly string[],
587
- values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
588
- ): string[] {
589
- const pairs: Array<[string, string | undefined]> = [
590
- ["{op}", values.op],
591
- ["{target}", values.target],
592
- ["{wave}", values.wave],
593
- ["{plans}", values.plans],
594
- ["{plan}", values.plan],
595
- ["{runs}", values.runs],
596
- ];
597
- return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
598
- }
599
-
600
561
  /** Where a run's plan file goes. */
601
562
  export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
602
563
  return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
@@ -853,16 +814,6 @@ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: strin
853
814
  return out.length > 0 ? out : undefined;
854
815
  }
855
816
 
856
- /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
857
- function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
858
- if (value === undefined) return undefined;
859
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
860
- const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
861
- if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
862
- if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
863
- return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
864
- }
865
-
866
817
  /**
867
818
  * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
868
819
  * or under `digest.over: "changing"` over the runs whose plan is not empty,
@@ -883,115 +834,6 @@ interface HooksOutcome {
883
834
  waitingOn?: OpWaveHeldOn;
884
835
  }
885
836
 
886
- /** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
887
- interface OtherGateHold {
888
- label: string;
889
- name: string;
890
- moment: OpWaveHookMoment;
891
- target?: string;
892
- argv: string[];
893
- env: Record<string, string>;
894
- request: OpWaveHoldRequest;
895
- }
896
-
897
- /** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
898
- function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
899
- if (!existsSync(path)) return undefined;
900
- try {
901
- const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
902
- if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
903
- if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
904
- return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
905
- } catch (err) {
906
- log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
907
- return undefined;
908
- }
909
- }
910
-
911
- /** Where hooks run: the wave they belong to and the job's tools. */
912
- interface HookPlace {
913
- /** The spec's name and `op`. */
914
- name: string;
915
- op: string;
916
- waveName: string;
917
- k: number;
918
- cwd: string;
919
- exec: OpWaveExec;
920
- log: (line: string) => void;
921
- /** Where a hook writes a hold on another gate (`CHANT_HOLD`). Default: under the wave's directory. */
922
- holdPath?: string;
923
- }
924
-
925
- /**
926
- * Run one moment's hooks, in order: each once for the wave, or once per run
927
- * for a `perRun` hook. A hook that fails stops the rest, unless its
928
- * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
929
- */
930
- function runHookList(
931
- at: HookPlace,
932
- moment: OpWaveHookMoment,
933
- hooks: readonly OpWaveHook[] | undefined,
934
- runs: readonly OpWaveHookRun[],
935
- runsPath: string,
936
- extraEnv: Record<string, string> = {},
937
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
938
- const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
939
- if (!hooks || hooks.length === 0) return out;
940
- const { name: specName, op, waveName, k, cwd, exec, log } = at;
941
- // #3821: a hook that holds the wave on another gate names it in this file.
942
- const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
943
- const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
944
- for (const hook of hooks) {
945
- const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
946
- // #4288: a hook with `targets` is for the runs they match. Once for the wave, it runs when one matches or no run is known yet.
947
- const mine = opWaveHookRuns(hook, runs);
948
- if (hook.targets && (hook.perRun ? mine.length === 0 : mine.length === 0 && runs.length > 0)) {
949
- log(`${moment} hook ${name}: no run matches its targets (${hook.targets.join(", ")}), so it did not run`);
950
- continue;
951
- }
952
- const calls = hook.perRun
953
- ? mine.map((run) => ({
954
- target: run.target as string | undefined,
955
- argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
956
- env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
957
- }))
958
- : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
959
- for (const call of calls) {
960
- const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
961
- let code: number;
962
- rmSync(join(cwd, holdPath), { force: true });
963
- try {
964
- code = exec(call.argv, cwd, call.env);
965
- } catch (err) {
966
- log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
967
- code = 127;
968
- }
969
- const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
970
- if (code === 0) {
971
- out.outcomes.push({ ...base, outcome: "passed" });
972
- continue;
973
- }
974
- if (hook.onFailure === "approve") {
975
- out.outcomes.push({ ...base, outcome: "held" });
976
- const request = readHoldRequest(join(cwd, holdPath), log, label);
977
- if (request) {
978
- out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
979
- log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
980
- continue;
981
- }
982
- out.holds.push(label);
983
- log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
984
- continue;
985
- }
986
- out.outcomes.push({ ...base, outcome: "failed" });
987
- out.failed = `${label} exited ${code}`;
988
- log(out.failed);
989
- return out;
990
- }
991
- }
992
- return out;
993
- }
994
-
995
837
  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 });
996
838
 
997
839
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
@@ -1076,52 +918,6 @@ async function runHooks(
1076
918
  return back;
1077
919
  }
1078
920
 
1079
- /** The options of {@link runStageHooks} (#4288). */
1080
- export interface RunStageHooksOptions {
1081
- /** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
1082
- name: string;
1083
- op: string;
1084
- /** `{wave}` and `CHANT_WAVE`. */
1085
- wave: string;
1086
- /** `CHANT_WAVE_NUMBER`. Default 0. */
1087
- k?: number;
1088
- /** The runs a `perRun` hook runs for and `targets` match against. */
1089
- runs: readonly OpWaveHookRun[];
1090
- /** `{runs}` and `CHANT_RUNS`, relative to `cwd`; the caller writes it. */
1091
- runsFile: string;
1092
- /** `CHANT_HOLD`, relative to `cwd`. */
1093
- holdFile: string;
1094
- /** Added to every hook's environment. */
1095
- env?: Record<string, string>;
1096
- cwd?: string;
1097
- exec?: OpWaveExec;
1098
- log?: (line: string) => void;
1099
- }
1100
-
1101
- /** What {@link runStageHooks} gave. */
1102
- export interface StageHooksRun {
1103
- outcomes: OpWaveHookOutcome[];
1104
- /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
1105
- failed?: string;
1106
- /** The `onFailure: approve` hooks that failed. A hold on another gate counts as one here. */
1107
- holds: string[];
1108
- }
1109
-
1110
- /**
1111
- * Run one moment's hooks outside an Op waves job (#4288): the pull-request
1112
- * jobs, or a product's own stage, with the same `targets`, `perRun`,
1113
- * `onFailure` and argv filling a wave job uses. A hook that holds on another
1114
- * gate (`CHANT_HOLD`) is counted as a plain hold; settling that gate is the
1115
- * caller's.
1116
- */
1117
- export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
1118
- const cwd = options.cwd ?? process.cwd();
1119
- const log = options.log ?? ((line: string) => console.error(line));
1120
- const place: HookPlace = { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd, exec: options.exec ?? spawnExec, log, holdPath: options.holdFile };
1121
- const out = runHookList(place, moment, hooks, options.runs, options.runsFile, options.env);
1122
- return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? options.name} / ${g.request.gate})`)] };
1123
- }
1124
-
1125
921
  /**
1126
922
  * The hooks that run before a job plans (#4288): `before-observe`, then
1127
923
  * `before-plan`. A failure or a hold on another gate in the first ends it
@@ -0,0 +1,79 @@
1
+ /**
2
+ * runStageHooksAsync (#4334): the async stage runner gives what the sync one
3
+ * gives for the same hooks, and two calls run side by side.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import type { OpWaveHook } from "./op-waves";
10
+ import { runStageHooks, runStageHooksAsync, spawnExecAsync, type OpWaveAsyncExec, type OpWaveExec } from "./stage-hooks";
11
+
12
+ const hooks: OpWaveHook[] = [
13
+ { name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
14
+ { name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
15
+ { name: "other", command: ["hook", "other"], onFailure: "approve" },
16
+ { name: "lost", command: ["hook", "lost"] },
17
+ { name: "stop", command: ["hook", "stop"] },
18
+ { name: "after", command: ["hook", "after"] },
19
+ ];
20
+ const runs = [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }];
21
+
22
+ /** Exit codes by the hook's second word; `other` writes a hold on another gate and `lost` cannot start. */
23
+ function codeOf(argv: string[], cwd: string, env?: Record<string, string>): number {
24
+ if (argv[1] === "other") writeFileSync(join(cwd, env!.CHANT_HOLD!), JSON.stringify({ gate: "dba", digest: "sha256:abc" }));
25
+ if (argv[1] === "lost") throw new Error("spawn hook ENOENT");
26
+ return argv[1] === "gate" || argv[1] === "other" || argv[1] === "stop" ? 1 : 0;
27
+ }
28
+
29
+ describe("runStageHooksAsync (#4334)", () => {
30
+ test("gives the outcomes, holds and failure the sync runner gives", async () => {
31
+ const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
32
+ const options = { name: "pr-7", op: "pr-7", wave: "prod", runs, runsFile: "runs.json", holdFile: "hold.json", cwd };
33
+ const syncCalls: string[] = [];
34
+ const syncLog: string[] = [];
35
+ const exec: OpWaveExec = (argv, at, env) => (syncCalls.push(argv.join(" ")), codeOf(argv, at, env));
36
+ const sync = runStageHooks("before-plan", hooks, { ...options, exec, log: (l) => syncLog.push(l) });
37
+ const asyncCalls: string[] = [];
38
+ const asyncLog: string[] = [];
39
+ const asyncExec: OpWaveAsyncExec = async (argv, at, env) => (asyncCalls.push(argv.join(" ")), codeOf(argv, at, env));
40
+ const ran = await runStageHooksAsync("before-plan", hooks, { ...options, exec: asyncExec, log: (l) => asyncLog.push(l) });
41
+ expect(ran).toEqual(sync);
42
+ expect(asyncCalls).toEqual(syncCalls);
43
+ expect(asyncLog).toEqual(syncLog);
44
+ expect(asyncCalls).toEqual(["hook each api pr-7", "hook gate prod", "hook other", "hook lost"]);
45
+ expect(ran.failed).toBe("before-plan hook lost exited 127");
46
+ expect(ran.holds).toEqual(["before-plan hook gate", "before-plan hook other (on pr-7 / dba)"]);
47
+ expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held", "held", "failed"]);
48
+ expect(asyncLog).toContain("before-plan hook lost did not run: spawn hook ENOENT");
49
+ rmSync(cwd, { recursive: true, force: true });
50
+ });
51
+
52
+ test("two calls run side by side", async () => {
53
+ const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
54
+ const started: string[] = [];
55
+ let release!: () => void;
56
+ const both = new Promise<void>((resolve) => (release = resolve));
57
+ const exec: OpWaveAsyncExec = async (argv) => {
58
+ started.push(argv[1]!);
59
+ if (started.length === 2) release();
60
+ await both;
61
+ return 0;
62
+ };
63
+ const one = (name: string) =>
64
+ runStageHooksAsync("before-plan", [{ command: ["hook", name] }], { name, op: name, wave: "prod", runs: [], runsFile: `${name}/runs.json`, holdFile: `${name}/hold.json`, cwd, exec, log: () => {} });
65
+ const [a, b] = await Promise.all([one("a"), one("b")]);
66
+ expect(started.sort()).toEqual(["a", "b"]);
67
+ expect([a.outcomes[0]!.outcome, b.outcomes[0]!.outcome]).toEqual(["passed", "passed"]);
68
+ rmSync(cwd, { recursive: true, force: true });
69
+ });
70
+
71
+ test("the default exec spawns the hook, gives its exit code and rejects a command that cannot start", async () => {
72
+ const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
73
+ expect(await spawnExecAsync([process.execPath, "-e", "process.exit(process.env.CODE === '3' ? 3 : 0)"], cwd, { CODE: "3" })).toBe(3);
74
+ await expect(spawnExecAsync(["./no-such-hook"], cwd)).rejects.toThrow(/ENOENT/);
75
+ const ran = await runStageHooksAsync("after-apply", [{ name: "ok", command: [process.execPath, "-e", "process.exit(0)"] }], { name: "s", op: "s", wave: "w", runs: [], runsFile: "runs.json", holdFile: "hold.json", cwd, log: () => {} });
76
+ expect(ran).toEqual({ outcomes: [{ moment: "after-apply", name: "ok", exit: 0, outcome: "passed" }], holds: [] });
77
+ rmSync(cwd, { recursive: true, force: true });
78
+ });
79
+ });