@intentius/chant 0.124.14 → 0.124.16

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 (178) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +1 -0
  4. package/dist/change-set.d.ts +24 -2
  5. package/dist/change-set.d.ts.map +1 -1
  6. package/dist/change-set.js +50 -1
  7. package/dist/ci-jobs.d.ts +227 -0
  8. package/dist/ci-jobs.d.ts.map +1 -0
  9. package/dist/ci-jobs.js +448 -0
  10. package/dist/cli/flush.d.ts +16 -0
  11. package/dist/cli/flush.d.ts.map +1 -0
  12. package/dist/cli/flush.js +42 -0
  13. package/dist/cli/handlers/approve.d.ts +98 -0
  14. package/dist/cli/handlers/approve.d.ts.map +1 -0
  15. package/dist/cli/handlers/approve.js +495 -0
  16. package/dist/cli/handlers/operator.d.ts +1 -95
  17. package/dist/cli/handlers/operator.d.ts.map +1 -1
  18. package/dist/cli/handlers/operator.js +4 -488
  19. package/dist/cli/handlers/override-approve.js +1 -1
  20. package/dist/cli/main.d.ts +4 -15
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/main.js +4 -1062
  23. package/dist/cli/parse-args.d.ts +6 -0
  24. package/dist/cli/parse-args.d.ts.map +1 -0
  25. package/dist/cli/parse-args.js +1028 -0
  26. package/dist/components/fan-out-record.d.ts +1 -1
  27. package/dist/components/fan-out-record.d.ts.map +1 -1
  28. package/dist/components/fan-out-record.js +3 -7
  29. package/dist/components/fan-out-run.d.ts +5 -3
  30. package/dist/components/fan-out-run.d.ts.map +1 -1
  31. package/dist/components/fan-out-run.js +7 -5
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +3 -2
  34. package/dist/composite.d.ts +10 -2
  35. package/dist/composite.d.ts.map +1 -1
  36. package/dist/composite.js +8 -0
  37. package/dist/discovery/collect.d.ts +2 -1
  38. package/dist/discovery/collect.d.ts.map +1 -1
  39. package/dist/discovery/collect.js +16 -1
  40. package/dist/discovery/fold-import.d.ts.map +1 -1
  41. package/dist/discovery/fold-import.js +2 -107
  42. package/dist/discovery/host-composite.d.ts +35 -0
  43. package/dist/discovery/host-composite.d.ts.map +1 -0
  44. package/dist/discovery/host-composite.js +93 -0
  45. package/dist/discovery/index.d.ts +9 -0
  46. package/dist/discovery/index.d.ts.map +1 -1
  47. package/dist/discovery/index.js +1 -1
  48. package/dist/gate-decision.d.ts +20 -9
  49. package/dist/gate-decision.d.ts.map +1 -1
  50. package/dist/gate-decision.js +72 -19
  51. package/dist/gated-waves.d.ts +25 -6
  52. package/dist/gated-waves.d.ts.map +1 -1
  53. package/dist/gated-waves.js +21 -6
  54. package/dist/lifecycle/gate-audit.d.ts +69 -2
  55. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  56. package/dist/lifecycle/gate-audit.js +76 -2
  57. package/dist/lifecycle/gate-ledger.d.ts +24 -0
  58. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  59. package/dist/lifecycle/gate-ledger.js +9 -1
  60. package/dist/op/activities/activity-contracts.d.ts +5 -5
  61. package/dist/op/activities/activity-contracts.js +1 -1
  62. package/dist/op/activities/change-set.d.ts +8 -3
  63. package/dist/op/activities/change-set.d.ts.map +1 -1
  64. package/dist/op/activities/change-set.js +5 -2
  65. package/dist/op/activities/reconcile.d.ts +28 -16
  66. package/dist/op/activities/reconcile.d.ts.map +1 -1
  67. package/dist/op/activities/reconcile.js +34 -20
  68. package/dist/op/builders.d.ts +1 -1
  69. package/dist/op/builders.d.ts.map +1 -1
  70. package/dist/op/builders.js +1 -1
  71. package/dist/op/op-waves-run.d.ts +2 -2
  72. package/dist/op/op-waves-run.d.ts.map +1 -1
  73. package/dist/op/op-waves-run.js +8 -9
  74. package/dist/op/op-waves.d.ts.map +1 -1
  75. package/dist/op/op-waves.js +4 -8
  76. package/dist/package-version.d.ts +2 -0
  77. package/dist/package-version.d.ts.map +1 -0
  78. package/dist/package-version.js +10 -0
  79. package/dist/wave-engine.d.ts +62 -0
  80. package/dist/wave-engine.d.ts.map +1 -0
  81. package/dist/wave-engine.js +59 -0
  82. package/dist/wave-layout.d.ts +29 -0
  83. package/dist/wave-layout.d.ts.map +1 -0
  84. package/dist/wave-layout.js +41 -0
  85. package/dist/wave-runtime.d.ts +29 -0
  86. package/dist/wave-runtime.d.ts.map +1 -0
  87. package/dist/wave-runtime.js +103 -0
  88. package/dist/wave-runtime.mjs +331 -0
  89. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  90. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  91. package/dist/workspace/__fixtures__/contract-repo.js +17 -1
  92. package/dist/workspace/__fixtures__/sessions.d.ts +1 -1
  93. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  94. package/dist/workspace/__fixtures__/sessions.js +3 -3
  95. package/dist/workspace/ajv2020.d.ts +5 -0
  96. package/dist/workspace/ajv2020.d.ts.map +1 -0
  97. package/dist/workspace/ajv2020.js +14 -0
  98. package/dist/workspace/change-set.schema.json +5 -3
  99. package/dist/workspace/ci-green-workflow.d.ts +3 -1
  100. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  101. package/dist/workspace/ci-green-workflow.js +5 -2
  102. package/dist/workspace/class-data.d.ts +84 -0
  103. package/dist/workspace/class-data.d.ts.map +1 -0
  104. package/dist/workspace/class-data.js +238 -0
  105. package/dist/workspace/declaration.d.ts.map +1 -1
  106. package/dist/workspace/declaration.js +4 -8
  107. package/dist/workspace/kinds.d.ts +3 -7
  108. package/dist/workspace/kinds.d.ts.map +1 -1
  109. package/dist/workspace/kinds.js +8 -45
  110. package/dist/workspace/points.d.ts.map +1 -1
  111. package/dist/workspace/points.js +2 -5
  112. package/dist/workspace/principal-classes.d.ts +4 -11
  113. package/dist/workspace/principal-classes.d.ts.map +1 -1
  114. package/dist/workspace/principal-classes.js +13 -99
  115. package/package.json +5 -3
  116. package/src/build.ts +8 -0
  117. package/src/change-set.test.ts +48 -0
  118. package/src/change-set.ts +52 -2
  119. package/src/ci-jobs.test.ts +49 -0
  120. package/src/ci-jobs.ts +515 -0
  121. package/src/cli/flush.ts +44 -0
  122. package/src/cli/handlers/approve.ts +618 -0
  123. package/src/cli/handlers/operator.ts +10 -608
  124. package/src/cli/handlers/override-approve.test.ts +1 -1
  125. package/src/cli/handlers/override-approve.ts +1 -1
  126. package/src/cli/main.ts +5 -816
  127. package/src/cli/parse-args.ts +781 -0
  128. package/src/components/fan-out-record.ts +3 -7
  129. package/src/components/fan-out-run.ts +7 -5
  130. package/src/components/pr-pipeline.ts +3 -2
  131. package/src/composite.ts +15 -3
  132. package/src/discovery/collect.ts +15 -1
  133. package/src/discovery/fold-build-parity.test.ts +71 -0
  134. package/src/discovery/fold-import.ts +2 -113
  135. package/src/discovery/fold-project-provenance.test.ts +21 -4
  136. package/src/discovery/host-composite.ts +101 -0
  137. package/src/discovery/index.ts +11 -1
  138. package/src/gate-decision-bundle.test.ts +6 -4
  139. package/src/gate-decision-identity.test.ts +59 -6
  140. package/src/gate-decision.ts +81 -21
  141. package/src/gated-waves.ts +28 -6
  142. package/src/lifecycle/gate-audit.test.ts +55 -1
  143. package/src/lifecycle/gate-audit.ts +116 -3
  144. package/src/lifecycle/gate-ledger.test.ts +20 -1
  145. package/src/lifecycle/gate-ledger.ts +33 -1
  146. package/src/op/activities/activity-contracts.ts +1 -1
  147. package/src/op/activities/change-set.ts +11 -3
  148. package/src/op/activities/lexicon-upgrade.test.ts +2 -0
  149. package/src/op/activities/reconcile.test.ts +62 -2
  150. package/src/op/activities/reconcile.ts +46 -22
  151. package/src/op/builders.ts +1 -1
  152. package/src/op/op-waves-run.ts +9 -9
  153. package/src/op/op-waves.test.ts +2 -0
  154. package/src/op/op-waves.ts +4 -8
  155. package/src/op/work-lease-run.test.ts +12 -4
  156. package/src/package-version.ts +11 -0
  157. package/src/wave-engine.test.ts +107 -0
  158. package/src/wave-engine.ts +98 -0
  159. package/src/wave-layout.ts +44 -0
  160. package/src/wave-runtime-bundle.test.ts +70 -0
  161. package/src/wave-runtime.e2e.test.ts +177 -0
  162. package/src/wave-runtime.ts +104 -0
  163. package/src/workspace/__fixtures__/contract-repo.ts +17 -1
  164. package/src/workspace/__fixtures__/sessions.ts +3 -3
  165. package/src/workspace/ajv2020.ts +17 -0
  166. package/src/workspace/change-set.schema.json +5 -3
  167. package/src/workspace/ci-green-workflow.test.ts +4 -4
  168. package/src/workspace/ci-green-workflow.ts +5 -2
  169. package/src/workspace/class-data.test.ts +64 -0
  170. package/src/workspace/class-data.ts +236 -0
  171. package/src/workspace/declaration.ts +4 -10
  172. package/src/workspace/kinds.ts +8 -40
  173. package/src/workspace/points.ts +2 -5
  174. package/src/workspace/principal-classes.ts +13 -99
  175. package/src/workspace/records-formats.test.ts +7 -2
  176. package/src/workspace/records-sessions-write.test.ts +3 -3
  177. package/src/workspace/status-contract.e2e.test.ts +37 -0
  178. package/src/workspace/status-contract.test.ts +23 -21
@@ -228,6 +228,21 @@ export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">
228
228
  * ledger by a line a minute. Once `expiresAt` passes, the fact is stale and
229
229
  * the next run records a fresh one.
230
230
  */
231
+ export interface PendingGateWave {
232
+ number: number;
233
+ name: string;
234
+ /** The Op waves spec's name. */
235
+ op?: string;
236
+ }
237
+
238
+ /** The hook that holds a wave on a gate (#3886): which hook asked, at which moment, and for which run. */
239
+ export interface PendingGateHeldBy {
240
+ hook: string;
241
+ moment: string;
242
+ /** The run, for a `perRun` hook. */
243
+ target?: string;
244
+ }
245
+
231
246
  export interface PendingGateRecord {
232
247
  /** Schema version, so an incompatible future shape is detected before being misread. */
233
248
  version: 1;
@@ -296,6 +311,17 @@ export interface PendingGateRecord {
296
311
  * than inferring it from config. Not the quorum block in `approval`.
297
312
  */
298
313
  approvalMode?: GateApprovalSource;
314
+ /**
315
+ * The Op wave this gate belongs to (#3839): a hold on another gate or a
316
+ * policy denial. Absent on a gate that is not a wave's.
317
+ */
318
+ wave?: PendingGateWave;
319
+ /**
320
+ * The hook that holds the wave on this gate (#3886), on a hold only: its
321
+ * name, the moment it ran at and its run. A reader names the holder from it
322
+ * rather than from `description`, which the hook writes.
323
+ */
324
+ heldBy?: PendingGateHeldBy;
299
325
  /**
300
326
  * The open question this gate waits on, on a gate that asks a decision
301
327
  * point (#3170). It is answered with `chant workspace points answer <id>`,
@@ -449,7 +475,13 @@ export function parseGateLedger(
449
475
  malformed++;
450
476
  continue;
451
477
  }
452
- pending.push(parsed as PendingGateRecord);
478
+ // #3887: a `wave` or `heldBy` of the wrong shape is dropped, so a reader can trust the type.
479
+ const rec = parsed as PendingGateRecord;
480
+ const w = (parsed as { wave?: unknown }).wave as Partial<PendingGateWave> | null | undefined;
481
+ if (w !== undefined && !(w && typeof w === "object" && typeof w.number === "number" && typeof w.name === "string")) delete rec.wave;
482
+ const h = (parsed as { heldBy?: unknown }).heldBy as Partial<PendingGateHeldBy> | null | undefined;
483
+ if (h !== undefined && !(h && typeof h === "object" && typeof h.hook === "string" && typeof h.moment === "string")) delete rec.heldBy;
484
+ pending.push(rec);
453
485
  continue;
454
486
  }
455
487
  if (typeof parsed.resolvedBy !== "string") {
@@ -271,7 +271,7 @@ export const lifecyclePlanChangeSetContract = activityContract(
271
271
 
272
272
  export const readChangeSetPartContract = activityContract(
273
273
  "readChangeSetPart",
274
- z.strictObject({ member: z.string(), planner: z.enum(["chant", "warden"]), file: z.string(), lexicon: z.string().optional() }),
274
+ z.strictObject({ member: z.string(), planner: z.enum(["chant", "warden", "sql"]), file: z.string(), lexicon: z.string().optional() }),
275
275
  z.object({ part: changeSetPart }),
276
276
  );
277
277
 
@@ -25,6 +25,7 @@ import { promisify } from "node:util";
25
25
  import {
26
26
  composeChangeSet as compose,
27
27
  lifecyclePlanPart,
28
+ readChangeSetPartFile,
28
29
  reconcilePlanPart,
29
30
  type ChangeSetDocument,
30
31
  type ChangeSetPart,
@@ -87,8 +88,12 @@ export async function lifecyclePlanChangeSet(args: LifecyclePlanChangeSetArgs, s
87
88
 
88
89
  export interface ReadChangeSetPartArgs {
89
90
  member: string;
90
- /** Which planner wrote the file: `chant` (`lifecycle plan --json`) or `warden` (its reconcile change sets). */
91
- planner: "chant" | "warden";
91
+ /**
92
+ * Which planner wrote the file: `chant` (`lifecycle plan --json`), `warden`
93
+ * (its reconcile change sets), or `sql` (a part the sql lexicon's
94
+ * `change-set` adapters produced, written as JSON).
95
+ */
96
+ planner: "chant" | "warden" | "sql";
92
97
  /** The plan file, relative to the current directory. */
93
98
  file: string;
94
99
  /** The lexicon the entries belong to, when the plan does not say. */
@@ -98,7 +103,8 @@ export interface ReadChangeSetPartArgs {
98
103
  /**
99
104
  * A plan another step or job wrote to a file, as a member's part. A warden
100
105
  * plan is the reconcile `ChangeSet` (`@intentius/chant/reconcile`), or an
101
- * array of them, one per cycle and org.
106
+ * array of them, one per cycle and org. A sql plan is already a part
107
+ * (`@intentius/chant-lexicon-sql/change-set`), renamed to `member` here.
102
108
  */
103
109
  export async function readChangeSetPart(args: ReadChangeSetPartArgs): Promise<ChangeSetPartResult> {
104
110
  const plan: unknown = JSON.parse(readFileSync(resolve(args.file), "utf-8"));
@@ -108,6 +114,8 @@ export async function readChangeSetPart(args: ReadChangeSetPartArgs): Promise<Ch
108
114
  return { part: lifecyclePlanPart({ member: args.member, plan: plan as LifecycleChangeSet, ...lexicon }) };
109
115
  case "warden":
110
116
  return { part: reconcilePlanPart({ member: args.member, plan: plan as ReconcileChangeSet | ReconcileChangeSet[], ...lexicon }) };
117
+ case "sql":
118
+ return { part: readChangeSetPartFile(plan, "sql", args.member, args.lexicon) };
111
119
  default:
112
120
  throw new Error(`readChangeSetPart: no adapter for planner ${String(args.planner)}; terraform-family plans come from terraformPlan's changeSet`);
113
121
  }
@@ -317,6 +317,8 @@ describe("lexiconUpgrade issue mode", () => {
317
317
 
318
318
  vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
319
319
  vi.stubEnv("GITHUB_API_URL", "");
320
+ // A Forgejo run, since CHANT_FORGEJO_TOKEN is only read there (#3807).
321
+ vi.stubEnv("FORGEJO_ACTIONS", "true");
320
322
  // The cross-instance case (#2320): a Forgejo token that must outrank the
321
323
  // job's own `github.token`, on the path that used to forward neither.
322
324
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
@@ -21,6 +21,7 @@ import {
21
21
  ghCredentialEnv,
22
22
  githubShapedForgeFrom,
23
23
  } from "./reconcile";
24
+ import { FORGE_TOKEN_ORDER } from "../../pr-forge";
24
25
 
25
26
  // ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
26
27
  //
@@ -338,12 +339,46 @@ describe("commentTokenFrom (#2291)", () => {
338
339
  expect(commentTokenFrom({ GITHUB_TOKEN: "ghs_y" })).toEqual({ value: "ghs_y", source: "GITHUB_TOKEN" });
339
340
  });
340
341
 
341
- test("CHANT_FORGEJO_TOKEN wins over both, for the cross-instance case", () => {
342
+ test("on Forgejo CHANT_FORGEJO_TOKEN wins, then FORGEJO_TOKEN, then GH_TOKEN, then GITHUB_TOKEN", () => {
343
+ const base = { FORGEJO_ACTIONS: "true" };
344
+ const all = { CHANT_FORGEJO_TOKEN: "a", FORGEJO_TOKEN: "b", GH_TOKEN: "c", GITHUB_TOKEN: "d" };
345
+ expect(commentTokenFrom({ ...base, ...all })?.source).toBe("CHANT_FORGEJO_TOKEN");
346
+ expect(commentTokenFrom({ ...base, FORGEJO_TOKEN: "b", GH_TOKEN: "c", GITHUB_TOKEN: "d" })?.source).toBe("FORGEJO_TOKEN");
347
+ expect(commentTokenFrom({ ...base, GH_TOKEN: "c", GITHUB_TOKEN: "d" })?.source).toBe("GH_TOKEN");
348
+ });
349
+
350
+ test("a /api/v1 API base is Forgejo even without FORGEJO_ACTIONS", () => {
342
351
  expect(
343
- commentTokenFrom({ CHANT_FORGEJO_TOKEN: "a", GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source,
352
+ commentTokenFrom({ GITHUB_API_URL: "https://f.example/api/v1", CHANT_FORGEJO_TOKEN: "a", GH_TOKEN: "b" })?.source,
344
353
  ).toBe("CHANT_FORGEJO_TOKEN");
345
354
  });
346
355
 
356
+ test("on GitHub CHANT_GITHUB_TOKEN wins, then GH_TOKEN, then GITHUB_TOKEN (#3807)", () => {
357
+ const base = { GITHUB_ACTIONS: "true" };
358
+ expect(commentTokenFrom({ ...base, CHANT_GITHUB_TOKEN: "a", GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source).toBe("CHANT_GITHUB_TOKEN");
359
+ expect(commentTokenFrom({ ...base, GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source).toBe("GH_TOKEN");
360
+ });
361
+
362
+ test("on GitHub CHANT_FORGEJO_TOKEN and FORGEJO_TOKEN are not read (#3807)", () => {
363
+ expect(
364
+ commentTokenFrom({ GITHUB_ACTIONS: "true", CHANT_FORGEJO_TOKEN: "a", FORGEJO_TOKEN: "b", GH_TOKEN: "c" }),
365
+ ).toEqual({ value: "c", source: "GH_TOKEN" });
366
+ expect(commentTokenFrom({ GITHUB_ACTIONS: "true", CHANT_FORGEJO_TOKEN: "a" })).toBeUndefined();
367
+ });
368
+
369
+ test("agrees with FORGE_TOKEN_ORDER for each forge", () => {
370
+ for (const [kind, env] of [
371
+ ["github", { GITHUB_ACTIONS: "true" }],
372
+ ["forgejo", { FORGEJO_ACTIONS: "true" }],
373
+ ] as const) {
374
+ for (const name of FORGE_TOKEN_ORDER[kind]) {
375
+ const rest = FORGE_TOKEN_ORDER[kind].slice(FORGE_TOKEN_ORDER[kind].indexOf(name));
376
+ const vars = Object.fromEntries(rest.map((n) => [n, `v-${n}`]));
377
+ expect(commentTokenFrom({ ...env, ...vars })?.source).toBe(name);
378
+ }
379
+ }
380
+ });
381
+
347
382
  test("no token at all is undefined rather than an empty value", () => {
348
383
  expect(commentTokenFrom({})).toBeUndefined();
349
384
  expect(commentTokenFrom({ GH_TOKEN: "", GITHUB_TOKEN: "" })).toBeUndefined();
@@ -469,6 +504,31 @@ describe("reconcilePr comment mode posts through the forge client (#2291, #3757)
469
504
  }
470
505
  });
471
506
 
507
+ test("on a GitHub run CHANT_FORGEJO_TOKEN is ignored and GH_TOKEN is sent (#3807)", async () => {
508
+ stubPrEnv("https://api.github.com");
509
+ vi.stubEnv("GITHUB_ACTIONS", "true");
510
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-token-set-on-github");
511
+ try {
512
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
513
+ expect(forgeCalls.length).toBeGreaterThan(0);
514
+ for (const call of forgeCalls) expect(call.authorization).toBe("Bearer forgejo-actions-token");
515
+ } finally {
516
+ vi.unstubAllEnvs();
517
+ }
518
+ });
519
+
520
+ test("on a GitHub run CHANT_GITHUB_TOKEN outranks GH_TOKEN (#3807)", async () => {
521
+ stubPrEnv("https://api.github.com");
522
+ vi.stubEnv("GITHUB_ACTIONS", "true");
523
+ vi.stubEnv("CHANT_GITHUB_TOKEN", "other-github-token");
524
+ try {
525
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
526
+ for (const call of forgeCalls) expect(call.authorization).toBe("Bearer other-github-token");
527
+ } finally {
528
+ vi.unstubAllEnvs();
529
+ }
530
+ });
531
+
472
532
  test("FORGEJO_ACTIONS marks a Forgejo run whatever its API base looks like", async () => {
473
533
  stubPrEnv("https://code.example/api/forgejo");
474
534
  vi.stubEnv("FORGEJO_ACTIONS", "true");
@@ -1,7 +1,15 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
- import { ForgeApiError, forgejoForge, githubForge, type ForgeFetch, type GithubShapedForge } from "../../pr-forge";
4
+ import {
5
+ detectForge,
6
+ FORGE_TOKEN_ORDER,
7
+ ForgeApiError,
8
+ forgejoForge,
9
+ githubForge,
10
+ type ForgeFetch,
11
+ type GithubShapedForge,
12
+ } from "../../pr-forge";
5
13
  import { ghHostEnv } from "./gh-host";
6
14
  import { restPullRequestForge } from "./forge-pull-request";
7
15
 
@@ -49,9 +57,11 @@ const execAsync = promisify(exec);
49
57
  * of opening a second one.
50
58
  *
51
59
  * Both modes resolve the GitHub/Forgejo credential the same way, through
52
- * {@link commentTokenFrom}: `CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then
53
- * `GITHUB_TOKEN` (chant #2320), refusing by name before any call when none
54
- * is set ({@link noCommentTokenMessage}, {@link noIssueTokenMessage}).
60
+ * {@link commentTokenFrom}, in pr-forge's `FORGE_TOKEN_ORDER` for the forge the
61
+ * run is on (chant #2320, #3807): `CHANT_GITHUB_TOKEN`, `GH_TOKEN`,
62
+ * `GITHUB_TOKEN` on GitHub; `CHANT_FORGEJO_TOKEN`, `FORGEJO_TOKEN`,
63
+ * `GH_TOKEN`, `GITHUB_TOKEN` on Forgejo. It refuses by name before any call
64
+ * when none is set ({@link noCommentTokenMessage}, {@link noIssueTokenMessage}).
55
65
  *
56
66
  * Until #3757 these two modes shelled to `gh api`. That reached Forgejo only
57
67
  * after two fixes: a full URL, because `gh` resolves a relative path against
@@ -451,24 +461,38 @@ export interface CommentToken {
451
461
  source: string;
452
462
  }
453
463
 
464
+ /**
465
+ * Which GitHub-shaped forge a run is on: Forgejo when `detectForge` says so
466
+ * (`FORGEJO_ACTIONS`/`GITEA_ACTIONS`, or a `GITHUB_API_URL` ending in
467
+ * `/api/v1`), or when the API base ends in `/api/v1`, which is how a step
468
+ * posting to some other Forgejo instance names it. GitHub serves `/api/v3`
469
+ * or `api.github.com`, never `/api/v1`. Anything else is GitHub.
470
+ */
471
+ export function githubShapedKindFrom(env: Record<string, string | undefined>): "github" | "forgejo" {
472
+ if (detectForge(env) === "forgejo") return "forgejo";
473
+ return /\/api\/v1$/.test(githubApiBaseFrom(env)) ? "forgejo" : "github";
474
+ }
475
+
454
476
  /**
455
477
  * Resolve the token the `comment` and `issue` modes post with on GitHub and
456
- * Forgejo, most specific first (chant #2291). The generated workflow sets
457
- * `GH_TOKEN` and `GITHUB_TOKEN` to `${{ github.token }}` for every
458
- * non-`report` finding mode — on
459
- * Forgejo the same way as on GitHub, and the real-instance read confirmed
460
- * `github.token` is populated there and good for a 200 read and a 201 write,
461
- * authored as `forgejo-actions`. So the common case needs nothing set beyond
462
- * what the generator already emits.
478
+ * Forgejo (chant #2291), in pr-forge's `FORGE_TOKEN_ORDER` for the forge the
479
+ * run is on (#3807), so this and `forgeTargetFromEnv` cannot drift apart.
480
+ * GitHub reads `CHANT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`; Forgejo reads
481
+ * `CHANT_FORGEJO_TOKEN`, `FORGEJO_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`. The
482
+ * generated workflow sets `GH_TOKEN` and `GITHUB_TOKEN` to
483
+ * `${{ github.token }}` for every non-`report` finding mode, on Forgejo the
484
+ * same way as on GitHub, so the common case needs nothing set beyond what the
485
+ * generator already emits.
463
486
  *
464
- * `CHANT_FORGEJO_TOKEN` is checked first for the case that ambient token
465
- * cannot cover: a run posting to a Forgejo instance other than the one the
466
- * job executes on, where `github.token`'s scope stops at its own instance.
467
- * A run with none of the three is refused by name before any call, in place
468
- * of the forge's own opaque 401.
487
+ * The chant-specific variable comes first for the case that ambient token
488
+ * cannot cover: a run posting to an instance other than the one the job
489
+ * executes on, where `github.token`'s scope stops at its own instance.
490
+ * `CHANT_FORGEJO_TOKEN` is no longer read on a GitHub run. A run with none of
491
+ * the names is refused by name before any call, in place of the forge's own
492
+ * opaque 401.
469
493
  */
470
494
  export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
471
- for (const source of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
495
+ for (const source of FORGE_TOKEN_ORDER[githubShapedKindFrom(env)]) {
472
496
  const value = env[source]?.trim();
473
497
  if (value) return { value, source };
474
498
  }
@@ -543,8 +567,8 @@ export function noCommentTokenMessage(repo: string, number: number): string {
543
567
  return (
544
568
  `reconcilePr mode "comment" has pull request ${repo}#${number} to post its finding on and no token to ` +
545
569
  "post it with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions " +
546
- "or Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
547
- "job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
570
+ "or Forgejo Actions run — set CHANT_GITHUB_TOKEN (GitHub) or CHANT_FORGEJO_TOKEN (Forgejo) to post " +
571
+ "against a different instance than the one the job runs on. It is read first where the two must differ."
548
572
  );
549
573
  }
550
574
 
@@ -553,8 +577,8 @@ export function noIssueTokenMessage(repo: string): string {
553
577
  return (
554
578
  `reconcilePr mode "issue" has repository ${repo} to open or edit its finding in and no token to do it ` +
555
579
  "with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions or " +
556
- "Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
557
- "job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
580
+ "Forgejo Actions run — set CHANT_GITHUB_TOKEN (GitHub) or CHANT_FORGEJO_TOKEN (Forgejo) to post " +
581
+ "against a different instance than the one the job runs on. It is read first where the two must differ."
558
582
  );
559
583
  }
560
584
 
@@ -582,7 +606,7 @@ export function githubShapedForgeFrom(
582
606
  signal?: AbortSignal,
583
607
  ): GithubShapedForge {
584
608
  const apiBase = githubApiBaseFrom(env);
585
- const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" || /\/api\/v1$/.test(apiBase);
609
+ const forgejo = githubShapedKindFrom(env) === "forgejo";
586
610
  // Read at call time, so a test that stubs the global sees its stub.
587
611
  const fetchImpl: ForgeFetch = (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) });
588
612
  const opts = { apiBase, repo, token: token.value, fetch: fetchImpl };
@@ -428,7 +428,7 @@ export const lifecyclePlanChangeSet = (args: WithStepRefs<LifecyclePlanChangeSet
428
428
  return activity("lifecyclePlanChangeSet", rest, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
429
429
  };
430
430
 
431
- /** A member's change-set part from a plan file a chant or warden run wrote (#3181). Its `part` feeds {@link composeChangeSet}. */
431
+ /** A member's change-set part from a plan file a chant, warden or sql run wrote (#3181, #3767). Its `part` feeds {@link composeChangeSet}. */
432
432
  export const readChangeSetPart = (args: WithStepRefs<ReadChangeSetPartArgs> & StepOpts): NamedActivityStep => {
433
433
  const { args: rest, profile, id } = takeProfileAndId(args as Record<string, unknown>);
434
434
  return activity("readChangeSetPart", rest, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
@@ -51,7 +51,7 @@
51
51
  import { execFileSync, spawnSync } from "node:child_process";
52
52
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
53
53
  import { dirname, isAbsolute, join, posix } from "node:path";
54
- import { waveSetDigest, type WaveMember } from "../gated-waves";
54
+ import { decideWaveGate, waveDigestMembers, waveSetDigest, writeWaveRecordFile, type WaveMember } from "../wave-engine";
55
55
  import type { ReviewApproval } from "../gate-decision";
56
56
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
57
57
  import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -130,7 +130,7 @@ export interface OpWavePlansFile {
130
130
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
131
131
  export const OP_WAVE_PENDING_RESERVED = [
132
132
  "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
133
- "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer",
133
+ "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
134
134
  ] as const;
135
135
 
136
136
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
@@ -817,8 +817,7 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
817
817
  * file's members that are no run (a cost), are always in it.
818
818
  */
819
819
  export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
820
- const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
821
- return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
820
+ return waveSetDigest(waveDigestMembers(members, { ...(spec.digest?.over ? { over: spec.digest.over } : {}), extras }));
822
821
  }
823
822
 
824
823
  /** What one moment's hooks came to. */
@@ -835,6 +834,7 @@ interface HooksOutcome {
835
834
  interface OtherGateHold {
836
835
  label: string;
837
836
  name: string;
837
+ moment: OpWaveHookMoment;
838
838
  target?: string;
839
839
  argv: string[];
840
840
  env: Record<string, string>;
@@ -914,7 +914,7 @@ function runHookList(
914
914
  out.outcomes.push({ ...base, outcome: "held" });
915
915
  const request = readHoldRequest(join(cwd, holdPath), log, label);
916
916
  if (request) {
917
- out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
917
+ out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
918
918
  log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
919
919
  continue;
920
920
  }
@@ -1068,7 +1068,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
1068
1068
  description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
1069
1069
  now,
1070
1070
  // #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
1071
- fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name } },
1071
+ fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name }, heldBy: { hook: hold.name, moment: hold.moment, ...(hold.target ? { target: hold.target } : {}) } },
1072
1072
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
1073
1073
  });
1074
1074
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
@@ -1626,10 +1626,10 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1626
1626
  // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
1627
1627
  const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
1628
1628
  const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
1629
- const check = await evaluateGate(port, {
1629
+ const check = await decideWaveGate(port, {
1630
1630
  op: spec.name,
1631
1631
  gate,
1632
- planDigest: digest,
1632
+ digest,
1633
1633
  description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
1634
1634
  now,
1635
1635
  ...(spent ? { spent } : {}),
@@ -1727,7 +1727,7 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
1727
1727
  /** Write the decision record, with every hook's outcome so far (#3804). */
1728
1728
  function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
1729
1729
  if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
1730
- writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
1730
+ writeWaveRecordFile(recordPath, decision);
1731
1731
  }
1732
1732
 
1733
1733
  /**
@@ -910,6 +910,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
910
910
  expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
911
911
  // #3839: the hold names its wave as a field, even when the hook wrote the description.
912
912
  expect(gates.appended[0]).toMatchObject({ description: "move the queues", wave: { number: 1, name: expect.any(String), op: tg.name } });
913
+ // #3886: and the hook that holds it, as a field.
914
+ expect(gates.appended[0]).toMatchObject({ heldBy: { hook: "migrate", moment: expect.any(String) } });
913
915
  expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
914
916
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
915
917
  h.done();
@@ -69,6 +69,7 @@
69
69
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
70
70
  import { gatePointProblems, type GatePoint } from "./gate-point";
71
71
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
72
+ import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
72
73
 
73
74
  /**
74
75
  * The variables a wave job reads its notify secrets from, for the lexicons
@@ -361,11 +362,11 @@ export interface OpWavesDigest {
361
362
  export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
362
363
 
363
364
  /** The default gate name pattern. */
364
- export const OP_WAVE_DEFAULT_GATE_NAME = "{gate}-wave-{k}";
365
+ export const OP_WAVE_DEFAULT_GATE_NAME = WAVE_GATE_NAME_PATTERN;
365
366
 
366
367
  /** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
367
368
  export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
368
- return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
369
+ return waveGateNameFrom(spec.gateName, spec.gate ?? spec.name, k);
369
370
  }
370
371
 
371
372
  /** Where a wave reads who may override a policy denial (#3821). */
@@ -638,12 +639,7 @@ export function opWaveApplyHooksRunBy(spec: Pick<OpWavesSpec, "hooks" | "waves">
638
639
  * by target, cut into contiguous slices whose sizes differ by at most one.
639
640
  */
640
641
  export function opWaveShare<T extends { target: string }>(runs: readonly T[], share: number, shares: number): T[] {
641
- const sorted = [...runs].sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
642
- const base = Math.floor(sorted.length / shares);
643
- const extra = sorted.length % shares;
644
- const start = (share - 1) * base + Math.min(share - 1, extra);
645
- const size = base + (share <= extra ? 1 : 0);
646
- return sorted.slice(start, start + size);
642
+ return waveShare(runs, share, shares, (run) => run.target);
647
643
  }
648
644
 
649
645
  /**
@@ -4,7 +4,7 @@
4
4
  * when it ends, and a lost lease stopping the run. An Op that changes the
5
5
  * checkout runs its leased steps in a worktree of its own.
6
6
  */
7
- import { describe, test, expect } from "vitest";
7
+ import { describe, test, expect, vi } from "vitest";
8
8
  import { withTestDir } from "@intentius/chant-test-utils";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -230,6 +230,11 @@ describe("a run under a work lease", () => {
230
230
  }, 30_000);
231
231
 
232
232
  test("a lease lost mid-step stops the step and the run, which fails lease-lost and releases nothing", async () => {
233
+ // The heartbeat is the only timer faked: the test fires its beat itself
234
+ // once the thief holds the lease, instead of waiting a wall-clock second
235
+ // for a renewal that a loaded machine can delay past any fixed bound.
236
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
237
+ try {
233
238
  await withTestDir(async (dir) => {
234
239
  await initRepo(dir);
235
240
  const log: string[] = [];
@@ -241,9 +246,9 @@ describe("a run under a work lease", () => {
241
246
  // The lease runs out from under this run and another worker takes it.
242
247
  await releaseWorkLease(lease.item, lease.holder, { cwd: dir, token: lease.token });
243
248
  expect((await claimWorkLease(lease.item, "thief", { cwd: dir, ttlMs: 60_000 })).ok).toBe(true);
244
- await new Promise<void>((resolve, reject) => {
245
- const t = setTimeout(() => reject(new Error("never stopped")), 20_000);
246
- signal?.addEventListener("abort", () => { clearTimeout(t); log.push("stopped"); reject(new Error("aborted")); }, { once: true });
249
+ await new Promise<void>((_resolve, reject) => {
250
+ signal?.addEventListener("abort", () => { log.push("stopped"); reject(new Error("aborted")); }, { once: true });
251
+ void vi.advanceTimersByTimeAsync(1_000);
247
252
  });
248
253
  return {};
249
254
  },
@@ -270,6 +275,9 @@ describe("a run under a work lease", () => {
270
275
  expect((await listWorkLeases({ cwd: dir, memberPrefix: "" }))[0]).toMatchObject({ holder: "thief", state: "active" });
271
276
  expect((await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)?.status).toBe("fail");
272
277
  });
278
+ } finally {
279
+ vi.useRealTimers();
280
+ }
273
281
  }, 30_000);
274
282
 
275
283
  test("a run whose lease was lost before it is recorded is never recorded as done", async () => {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@intentius/chant`'s own version, read from its package.json as a file. The
3
+ * path is the same from `src/` and `dist/`. The slim wave-job runtime (#3896)
4
+ * has no package.json beside it, and its build writes the version in here.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ export function readChantPackageVersion(): string {
10
+ return (JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8")) as { version: string }).version;
11
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * One wave engine under both runners (#3766): the same runs, planned to the
3
+ * same digests, are the same set digest and the same gate name whether
4
+ * `chant run wave` (an Op waves spec) or `chant components fan-out
5
+ * --wave-gate` decides the wave. An approval recorded through one matches a
6
+ * wave the other plans.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { describe, expect, test } from "vitest";
13
+ import { memoryGateLedgerPort } from "./op/gate";
14
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op/op-waves-run";
15
+ import { opWaveGateName, opWaveShare, type OpWavesSpec } from "./op/op-waves";
16
+ import { planFanOut } from "./components/fan-out";
17
+ import { runFanOut } from "./components/fan-out-run";
18
+ import { CapabilityRegistry } from "./components/capability";
19
+ import type { DriverComponent } from "./components/driver";
20
+ import { waveGateName, waveGateNameFrom, waveSetDigest, waveShare, WAVE_GATE_NAME_PATTERN } from "./wave-engine";
21
+
22
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
23
+ /** The runs both runners plan: a member name and its plan digest. */
24
+ const RUNS: Record<string, string> = { "tenant-b": digest("b"), "tenant-a": digest("a"), "tenant-c": digest("c") };
25
+ const NOW = "2026-10-11T00:00:00Z";
26
+
27
+ async function throughOpWaves(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
28
+ const spec: OpWavesSpec = {
29
+ name: "rollout",
30
+ op: "migrate",
31
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
32
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
33
+ waves: [{ name: "tenants", runs: Object.keys(RUNS).map((target) => ({ target })) }],
34
+ };
35
+ const cwd = mkdtempSync(join(tmpdir(), "wave-engine-"));
36
+ try {
37
+ const exec: OpWaveExec = (argv, dir) => {
38
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: RUNS[argv[2]!] }));
39
+ return 0;
40
+ };
41
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
42
+ const gates = memoryGateLedgerPort();
43
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd, exec, show, gates, now: NOW });
44
+ expect(result.exitCode).toBe(3);
45
+ return { digest: result.decision!.digest, gate: result.decision!.gate, pending: gates.appended[0]?.planDigest };
46
+ } finally {
47
+ rmSync(cwd, { recursive: true, force: true });
48
+ }
49
+ }
50
+
51
+ async function throughFanOut(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
52
+ const components: DriverComponent[] = Object.keys(RUNS).map((name) => ({ name, deploy: [{ phase: "Apply", steps: [] }] }));
53
+ const plan = planFanOut({ components, changed: Object.keys(RUNS) });
54
+ const gates = memoryGateLedgerPort();
55
+ const result = await runFanOut(plan, components, new CapabilityRegistry(), {
56
+ env: "prod",
57
+ gates,
58
+ now: NOW,
59
+ waveGate: {
60
+ op: "rollout",
61
+ gate: "rollout",
62
+ planComponent: async (component) => ({
63
+ component: component.name,
64
+ members: [{ member: component.name, planDigest: RUNS[component.name]! }],
65
+ plans: {},
66
+ parts: [],
67
+ }),
68
+ },
69
+ });
70
+ expect(result.status).toBe("gated");
71
+ const wave = result.waves![0]!;
72
+ return { digest: wave.digest, gate: wave.gate, pending: gates.appended[0]?.planDigest };
73
+ }
74
+
75
+ describe("one wave engine (#3766)", () => {
76
+ test("the same runs bind the same set digest and gate through both runners", async () => {
77
+ const expected = waveSetDigest(Object.entries(RUNS).map(([member, planDigest]) => ({ member, planDigest })));
78
+ const op = await throughOpWaves();
79
+ const fan = await throughFanOut();
80
+ expect(op.digest).toBe(expected);
81
+ expect(fan.digest).toBe(expected);
82
+ expect(op.pending).toBe(expected);
83
+ expect(fan.pending).toBe(expected);
84
+ expect(op.gate).toBe("rollout-wave-1");
85
+ expect(fan.gate).toBe(op.gate);
86
+ });
87
+
88
+ test("gate names: the default pattern is waveGateName's name, and a spec's pattern still wins", () => {
89
+ for (const k of [1, 2, 12]) {
90
+ expect(waveGateNameFrom(undefined, "deploy", k)).toBe(waveGateName("deploy", k));
91
+ expect(waveGateNameFrom(WAVE_GATE_NAME_PATTERN, "deploy", k)).toBe(`deploy-wave-${k}`);
92
+ expect(opWaveGateName({ name: "deploy" }, k)).toBe(waveGateName("deploy", k));
93
+ }
94
+ expect(opWaveGateName({ name: "x", gate: "tg", gateName: "wave-{k}" }, 3)).toBe("wave-3");
95
+ expect(opWaveGateName({ name: "x", gateName: "{gate}/{k}" }, 3)).toBe("x/3");
96
+ });
97
+
98
+ test("the share split is the Op waves split by target", () => {
99
+ const runs = ["t5", "t1", "t4", "t2", "t3"].map((target) => ({ target }));
100
+ for (const shares of [1, 2, 3, 5]) {
101
+ for (let share = 1; share <= shares; share++) {
102
+ expect(waveShare(runs, share, shares, (r) => r.target)).toEqual(opWaveShare(runs, share, shares));
103
+ }
104
+ }
105
+ expect(opWaveShare(runs, 1, 2).map((r) => r.target)).toEqual(["t1", "t2", "t3"]);
106
+ });
107
+ });