@intentius/chant 0.58.0 → 0.59.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.
Files changed (146) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/lint.d.ts +13 -0
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/operator.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts +0 -16
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/plugins.d.ts +22 -0
  15. package/dist/cli/plugins.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/cli-support.d.ts +4 -1
  19. package/dist/components/cli-support.d.ts.map +1 -1
  20. package/dist/components/component.d.ts +19 -4
  21. package/dist/components/component.d.ts.map +1 -1
  22. package/dist/components/driver.d.ts +8 -2
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/verbs/run-agent.d.ts +1 -7
  25. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  26. package/dist/detectLexicon.d.ts +13 -0
  27. package/dist/detectLexicon.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +170 -3
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  31. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  32. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +2 -2
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/reconcile.d.ts +62 -2
  36. package/dist/op/activities/reconcile.d.ts.map +1 -1
  37. package/dist/op/builders.d.ts +2 -2
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/change-signal.d.ts +91 -0
  40. package/dist/op/change-signal.d.ts.map +1 -0
  41. package/dist/op/composites/apply-op.d.ts +7 -2
  42. package/dist/op/composites/apply-op.d.ts.map +1 -1
  43. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +40 -0
  45. package/dist/op/gate-name.d.ts.map +1 -0
  46. package/dist/op/gate-summary.d.ts +43 -0
  47. package/dist/op/gate-summary.d.ts.map +1 -0
  48. package/dist/op/index.d.ts +6 -2
  49. package/dist/op/index.d.ts.map +1 -1
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/op-ir.d.ts +2 -1
  52. package/dist/op/op-ir.d.ts.map +1 -1
  53. package/dist/op/operator.d.ts +63 -0
  54. package/dist/op/operator.d.ts.map +1 -1
  55. package/dist/op/types.d.ts +18 -3
  56. package/dist/op/types.d.ts.map +1 -1
  57. package/dist/terraform/bridge.d.ts +26 -7
  58. package/dist/terraform/bridge.d.ts.map +1 -1
  59. package/dist/terraform/carve-provider.d.ts +28 -2
  60. package/dist/terraform/carve-provider.d.ts.map +1 -1
  61. package/dist/terraform/data-source-shape.d.ts +77 -0
  62. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  63. package/dist/terraform/graph.d.ts.map +1 -1
  64. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  65. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  66. package/dist/terraform/tier-map.d.ts +16 -4
  67. package/dist/terraform/tier-map.d.ts.map +1 -1
  68. package/dist/terraform/types.d.ts +8 -0
  69. package/dist/terraform/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/audit/core.ts +25 -3
  72. package/src/audit/discover.ts +122 -14
  73. package/src/cli/commands/audit.test.ts +5 -3
  74. package/src/cli/commands/audit.ts +5 -1
  75. package/src/cli/commands/build.test.ts +39 -1
  76. package/src/cli/commands/build.ts +14 -0
  77. package/src/cli/commands/carve-bridge.test.ts +237 -14
  78. package/src/cli/commands/carve-bridge.ts +21 -17
  79. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  80. package/src/cli/commands/lint.test.ts +146 -1
  81. package/src/cli/commands/lint.ts +82 -7
  82. package/src/cli/handlers/operator.ts +81 -0
  83. package/src/cli/handlers/run.test.ts +132 -3
  84. package/src/cli/handlers/run.ts +87 -3
  85. package/src/cli/main.test.ts +9 -11
  86. package/src/cli/main.ts +5 -27
  87. package/src/cli/plugins.test.ts +68 -2
  88. package/src/cli/plugins.ts +39 -0
  89. package/src/cli/registry.ts +14 -0
  90. package/src/components/README.md +2 -2
  91. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  92. package/src/components/cli-support.test.ts +18 -7
  93. package/src/components/cli-support.ts +8 -3
  94. package/src/components/component-schema.test.ts +18 -2
  95. package/src/components/component.schema.json +17 -4
  96. package/src/components/component.test.ts +2 -2
  97. package/src/components/component.ts +25 -5
  98. package/src/components/config-defaults.test.ts +2 -2
  99. package/src/components/driver.test.ts +20 -1
  100. package/src/components/driver.ts +12 -5
  101. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  102. package/src/components/verbs/run-agent.test.ts +19 -0
  103. package/src/components/verbs/run-agent.ts +1 -7
  104. package/src/detectLexicon.ts +18 -1
  105. package/src/discovery/fold-import.test.ts +1 -1
  106. package/src/fold/foldable-helpers.ts +1 -1
  107. package/src/graph-ops.test.ts +1 -1
  108. package/src/lexicon.ts +177 -3
  109. package/src/lifecycle/gate-ledger.ts +12 -1
  110. package/src/lint/pipeline-change-gate.test.ts +2 -2
  111. package/src/lint/rules/comp/comp.test.ts +26 -0
  112. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  113. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  114. package/src/op/activities/index.ts +2 -2
  115. package/src/op/activities/reconcile.test.ts +82 -2
  116. package/src/op/activities/reconcile.ts +169 -2
  117. package/src/op/builders.ts +3 -3
  118. package/src/op/change-signal.test.ts +117 -0
  119. package/src/op/change-signal.ts +169 -0
  120. package/src/op/composites/apply-op.ts +14 -4
  121. package/src/op/composites/composites.test.ts +17 -4
  122. package/src/op/composites/reconcile-op.ts +7 -4
  123. package/src/op/effect-step.test.ts +3 -3
  124. package/src/op/gate-name.test.ts +65 -0
  125. package/src/op/gate-name.ts +60 -0
  126. package/src/op/gate-summary.ts +84 -0
  127. package/src/op/index.ts +9 -2
  128. package/src/op/local-executor.test.ts +16 -1
  129. package/src/op/local-executor.ts +4 -3
  130. package/src/op/op-ir.test.ts +12 -1
  131. package/src/op/op-ir.ts +5 -3
  132. package/src/op/op-verb-class.test.ts +2 -2
  133. package/src/op/op.test.ts +2 -2
  134. package/src/op/operator.test.ts +368 -0
  135. package/src/op/operator.ts +141 -5
  136. package/src/op/runtimes/local.test.ts +1 -1
  137. package/src/op/types.ts +23 -3
  138. package/src/terraform/aws-resources.test.ts +13 -4
  139. package/src/terraform/bridge.test.ts +22 -9
  140. package/src/terraform/bridge.ts +89 -28
  141. package/src/terraform/carve-provider.ts +38 -2
  142. package/src/terraform/data-source-shape.ts +95 -0
  143. package/src/terraform/graph.ts +35 -7
  144. package/src/terraform/providers/kubernetes.ts +48 -2
  145. package/src/terraform/tier-map.ts +21 -6
  146. package/src/terraform/types.ts +8 -0
@@ -1,10 +1,19 @@
1
1
  import { exec } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
 
4
5
  const execAsync = promisify(exec);
5
6
 
6
- /** What the reconcile activity does with the regenerated source. */
7
- export type ReconcileMode = "pull-request" | "issue" | "report";
7
+ /**
8
+ * What the reconcile activity does with the regenerated source.
9
+ *
10
+ * `comment` is the one mode that posts nothing new: it writes the body onto
11
+ * the pull request that triggered the run, updating the same comment on every
12
+ * re-run (chant #2231). It therefore needs a pull-request trigger, and
13
+ * {@link resolvePullRequestContext} fails the step by name when the run has
14
+ * none.
15
+ */
16
+ export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
8
17
 
9
18
  /** A change-set entry that triggered reconciliation. */
10
19
  export interface ReconcileEntry {
@@ -35,6 +44,14 @@ export interface ReconcilePrArgs {
35
44
  owned?: boolean;
36
45
  /** PR / issue title. Default derived from env. */
37
46
  title?: string;
47
+ /**
48
+ * Hidden marker identifying this Op's comment on the pull request (comment
49
+ * mode). The activity writes it as the comment's first line and finds the
50
+ * comment again by it on the next run, so a re-run edits one comment instead
51
+ * of stacking a new one. Default: {@link commentMarker} keyed on `env`, so
52
+ * two Ops over two roots get two comments and each updates in place.
53
+ */
54
+ marker?: string;
38
55
  /**
39
56
  * A finding body built by the caller, used verbatim as the issue/PR body in
40
57
  * place of {@link reconcileSummary} (chant #2087).
@@ -61,6 +78,10 @@ export interface ReconcileResult {
61
78
  prUrl?: string;
62
79
  /** Opened issue URL (issue mode). */
63
80
  issueUrl?: string;
81
+ /** The posted or updated PR comment's URL (comment mode). */
82
+ commentUrl?: string;
83
+ /** The pull request the comment landed on, `owner/repo#number` (comment mode). */
84
+ pullRequest?: string;
64
85
  /** The markdown summary used as the PR/issue body. */
65
86
  summary: string;
66
87
  /** The entries that triggered the reconcile. */
@@ -101,6 +122,136 @@ function shellQuote(s: string): string {
101
122
  return `'${s.replace(/'/g, "'\\''")}'`;
102
123
  }
103
124
 
125
+ /** The pull request a `comment`-mode run posts onto. */
126
+ export interface PullRequestContext {
127
+ /** `owner/repo`, from `GITHUB_REPOSITORY`. */
128
+ repo: string;
129
+ /** The pull request number. */
130
+ number: number;
131
+ }
132
+
133
+ /**
134
+ * The hidden marker that makes a `comment`-mode finding findable across
135
+ * re-runs: written as the comment's first line, matched with `startswith` on
136
+ * the next run. Keyed on `env` (slugified the same way {@link
137
+ * reconcileBranchName} slugifies it, which also keeps the value free of the
138
+ * quotes and backslashes it is interpolated next to), so two Ops over two
139
+ * environments own two comments and each updates in place.
140
+ */
141
+ export function commentMarker(env: string): string {
142
+ return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
143
+ }
144
+
145
+ /** What a `comment`-mode step says when the run it is in has no pull request. */
146
+ export function noPullRequestContextMessage(): string {
147
+ return (
148
+ 'reconcilePr mode "comment" posts the finding on the pull request that triggered the run, and this run ' +
149
+ "has none. It needs GITHUB_REPOSITORY plus a pull request number, read from the event payload at " +
150
+ "GITHUB_EVENT_PATH (`.number` / `.pull_request.number`) or from GITHUB_REF (`refs/pull/<n>/merge`). " +
151
+ "GitHub Actions sets those on a pull_request event and on nothing else. Trigger this Op from a " +
152
+ 'pull_request workflow, or give it findingMode "issue" or "report".'
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Read the pull request number out of a parsed webhook event payload. Pure.
158
+ * A `pull_request` event carries it top-level as `number` and again under
159
+ * `pull_request.number`; both are accepted, neither is invented.
160
+ */
161
+ function prNumberFromPayload(payload: unknown): number | undefined {
162
+ if (typeof payload !== "object" || payload === null) return undefined;
163
+ const p = payload as { number?: unknown; pull_request?: { number?: unknown } };
164
+ if (typeof p.number === "number") return p.number;
165
+ if (typeof p.pull_request?.number === "number") return p.pull_request.number;
166
+ return undefined;
167
+ }
168
+
169
+ /**
170
+ * Derive the triggering pull request from CI environment variables plus the
171
+ * already-parsed event payload. Pure — exported for testing; the IO (reading
172
+ * `GITHUB_EVENT_PATH`) is {@link resolvePullRequestContext}'s.
173
+ *
174
+ * Returns undefined rather than throwing, so the caller owns the message.
175
+ */
176
+ export function pullRequestContextFrom(
177
+ env: Record<string, string | undefined>,
178
+ eventPayload?: unknown,
179
+ ): PullRequestContext | undefined {
180
+ const repo = env.GITHUB_REPOSITORY;
181
+ if (!repo) return undefined;
182
+ const fromRef = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF ?? "")?.[1];
183
+ const number = prNumberFromPayload(eventPayload) ?? (fromRef ? Number(fromRef) : undefined);
184
+ if (number === undefined || !Number.isInteger(number) || number <= 0) return undefined;
185
+ return { repo, number };
186
+ }
187
+
188
+ /**
189
+ * Resolve the triggering pull request, reading and parsing the event payload
190
+ * `GITHUB_EVENT_PATH` names. Throws {@link noPullRequestContextMessage} when
191
+ * the run has no pull request, which is the whole point: a `comment` mode that
192
+ * quietly fell back to an issue would post the finding somewhere nobody asked
193
+ * for it.
194
+ */
195
+ export async function resolvePullRequestContext(
196
+ env: Record<string, string | undefined> = process.env,
197
+ ): Promise<PullRequestContext> {
198
+ let payload: unknown;
199
+ const eventPath = env.GITHUB_EVENT_PATH;
200
+ if (eventPath) {
201
+ try {
202
+ payload = JSON.parse(await readFile(eventPath, "utf8"));
203
+ } catch {
204
+ // An unreadable or malformed payload is not fatal on its own: GITHUB_REF
205
+ // may still name the pull request. If it does not, the error below says so.
206
+ payload = undefined;
207
+ }
208
+ }
209
+ const ctx = pullRequestContextFrom(env, payload);
210
+ if (!ctx) throw new Error(noPullRequestContextMessage());
211
+ return ctx;
212
+ }
213
+
214
+ /**
215
+ * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
216
+ * Op already owns there. The sticky-comment recipe the github lexicon's
217
+ * `PrPlanReport` uses, run from the activity instead of from generated YAML:
218
+ * find the comment whose body starts with `marker`, PATCH it when there is
219
+ * one, POST otherwise. `gh` ships on GitHub's hosted runners and is already
220
+ * this activity's dependency for the issue and pull-request modes, so the
221
+ * mode needs nothing new on the runner.
222
+ */
223
+ async function postOrUpdateComment(
224
+ ctx: PullRequestContext,
225
+ marker: string,
226
+ body: string,
227
+ signal?: AbortSignal,
228
+ ): Promise<string> {
229
+ const listPath = `repos/${ctx.repo}/issues/${ctx.number}/comments`;
230
+ const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
231
+ const { stdout: found } = await execAsync(
232
+ `gh api ${shellQuote(listPath)} --paginate --jq ${shellQuote(jq)}`,
233
+ { signal },
234
+ );
235
+ // `--paginate` prints one `--jq` result per page, so take the first line
236
+ // that is an id and ignore the empty ones the other pages produce.
237
+ const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
238
+ const field = `body=${marker}\n\n${body}`;
239
+
240
+ if (existing) {
241
+ const { stdout } = await execAsync(
242
+ `gh api --method PATCH ${shellQuote(`repos/${ctx.repo}/issues/comments/${existing}`)} ` +
243
+ `-f ${shellQuote(field)} --jq .html_url`,
244
+ { signal },
245
+ );
246
+ return stdout.trim();
247
+ }
248
+ const { stdout } = await execAsync(
249
+ `gh api --method POST ${shellQuote(listPath)} -f ${shellQuote(field)} --jq .html_url`,
250
+ { signal },
251
+ );
252
+ return stdout.trim();
253
+ }
254
+
104
255
  /**
105
256
  * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
106
257
  * `noop` entries (nothing to reconcile). Pure — exported for testing.
@@ -133,6 +284,12 @@ async function derivePlanEntries(
133
284
  *
134
285
  * - `report` — return the summary only; no git, no network.
135
286
  * - `issue` — open a GitHub issue describing the drift (no code change).
287
+ * - `comment` — post the body as one comment on the pull request that
288
+ * triggered the run, editing that same comment on every re-run rather than
289
+ * stacking a new one (#2231). Needs a pull-request-triggered run; fails by
290
+ * name when there is none. No code change, and the `pull-requests: write`
291
+ * the generated workflow already grants on that trigger is the whole scope
292
+ * it spends.
136
293
  * - `pull-request` — create a branch, regenerate source via
137
294
  * `chant import --from <env>`, commit, push, and open a PR whose diff is the
138
295
  * regenerated TypeScript. Never commits to the main branch.
@@ -164,6 +321,16 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
164
321
  return { mode, summary, entries, issueUrl: stdout.trim() };
165
322
  }
166
323
 
324
+ if (mode === "comment") {
325
+ // The trigger context is read here rather than passed in: a step's args
326
+ // are serialized at build time, and the pull request is not known until
327
+ // the run. Missing context is fatal — see `noPullRequestContextMessage`.
328
+ const ctx = await resolvePullRequestContext();
329
+ const marker = args.marker ?? commentMarker(args.env);
330
+ const commentUrl = await postOrUpdateComment(ctx, marker, summary, signal);
331
+ return { mode, summary, entries, commentUrl, pullRequest: `${ctx.repo}#${ctx.number}` };
332
+ }
333
+
167
334
  // pull-request
168
335
  const branch = args.branch ?? reconcileBranchName(args.env);
169
336
  const output = args.output ?? "./infra";
@@ -105,16 +105,16 @@ export function activity(
105
105
  /**
106
106
  * Insert a human gate. A gate is a fact on the gate ledger, not a wait: a run
107
107
  * that reaches this step with no resolution newer than its pending fact
108
- * records the pending fact and ends `gated`. `chant approve <op> <signalName>`
108
+ * records the pending fact and ends `gated`. `chant approve <op> <gate>`
109
109
  * writes the resolution, and the next run walks through carrying the approver.
110
110
  */
111
111
  export function gate(
112
- signalName: string,
112
+ name: string,
113
113
  opts?: { timeout?: string; description?: string },
114
114
  ): GateStep {
115
115
  return {
116
116
  kind: "gate",
117
- signalName,
117
+ gate: name,
118
118
  ...(opts?.timeout ? { timeout: opts.timeout } : {}),
119
119
  ...(opts?.description ? { description: opts.description } : {}),
120
120
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The wake gate (#1981): coalescing, the floor, and abort.
3
+ *
4
+ * Real timers with small floors rather than fake ones: the gate is nothing but
5
+ * two `setTimeout`s racing, and a fake-timer test of that asserts the mock
6
+ * rather than the behaviour.
7
+ */
8
+ import { describe, test, expect } from "vitest";
9
+ import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS } from "./change-signal";
10
+
11
+ describe("createChangeSignalGate", () => {
12
+ test("no signal: the wait runs the full interval and reports the timer", async () => {
13
+ const gate = createChangeSignalGate({ floorMs: 10 });
14
+ gate.roundStarted();
15
+ const started = Date.now();
16
+ expect(await gate.wait(60)).toBe("timer");
17
+ expect(Date.now() - started).toBeGreaterThanOrEqual(45);
18
+ expect(gate.wakeCount).toBe(0);
19
+ });
20
+
21
+ test("a signal past the floor wakes the sleep early", async () => {
22
+ const gate = createChangeSignalGate({ floorMs: 0 });
23
+ gate.roundStarted();
24
+ const started = Date.now();
25
+ const waiting = gate.wait(5_000);
26
+ setTimeout(() => gate.signal(), 10);
27
+ expect(await waiting).toBe("signal");
28
+ expect(Date.now() - started).toBeLessThan(2_000);
29
+ expect(gate.wakeCount).toBe(1);
30
+ });
31
+
32
+ test("a signal inside the floor waits the floor out rather than waking at once", async () => {
33
+ const gate = createChangeSignalGate({ floorMs: 120 });
34
+ gate.roundStarted();
35
+ const started = Date.now();
36
+ const waiting = gate.wait(5_000);
37
+ gate.signal();
38
+ expect(await waiting).toBe("signal");
39
+ // Woken by the signal, but not before the floor had passed.
40
+ expect(Date.now() - started).toBeGreaterThanOrEqual(100);
41
+ });
42
+
43
+ test("a storm of signals inside one floor window costs exactly one wake", async () => {
44
+ const gate = createChangeSignalGate({ floorMs: 60 });
45
+ gate.roundStarted();
46
+ const waiting = gate.wait(5_000);
47
+ for (let i = 0; i < 500; i++) gate.signal();
48
+ expect(await waiting).toBe("signal");
49
+ expect(gate.signalCount).toBe(500);
50
+ expect(gate.wakeCount).toBe(1);
51
+ });
52
+
53
+ test("the floor is measured from the round, so a steady stream cannot starve the tick", async () => {
54
+ const gate = createChangeSignalGate({ floorMs: 80 });
55
+ gate.roundStarted();
56
+ const started = Date.now();
57
+ const waiting = gate.wait(5_000);
58
+ // A signal every 10ms. A debounce would push the deadline out forever.
59
+ const drip = setInterval(() => gate.signal(), 10);
60
+ const reason = await waiting;
61
+ clearInterval(drip);
62
+ expect(reason).toBe("signal");
63
+ expect(Date.now() - started).toBeLessThan(1_000);
64
+ });
65
+
66
+ test("a signal arriving between rounds is honoured by the next wait, not lost", async () => {
67
+ const gate = createChangeSignalGate({ floorMs: 0 });
68
+ gate.roundStarted();
69
+ gate.signal(); // nothing is sleeping yet
70
+ expect(await gate.wait(5_000)).toBe("signal");
71
+ });
72
+
73
+ test("roundStarted clears the pending flag, so one signal never wakes two rounds", async () => {
74
+ const gate = createChangeSignalGate({ floorMs: 0 });
75
+ gate.roundStarted();
76
+ gate.signal();
77
+ expect(await gate.wait(5_000)).toBe("signal");
78
+ gate.roundStarted();
79
+ expect(await gate.wait(40)).toBe("timer");
80
+ expect(gate.wakeCount).toBe(1);
81
+ });
82
+
83
+ test("abort resolves rather than throwing, before and during a wait", async () => {
84
+ const gate = createChangeSignalGate({ floorMs: 0 });
85
+ const already = new AbortController();
86
+ already.abort();
87
+ expect(await gate.wait(5_000, already.signal)).toBe("aborted");
88
+
89
+ const controller = new AbortController();
90
+ const waiting = gate.wait(5_000, controller.signal);
91
+ setTimeout(() => controller.abort(), 10);
92
+ expect(await waiting).toBe("aborted");
93
+ });
94
+
95
+ test("a signal after abort does nothing", async () => {
96
+ const gate = createChangeSignalGate({ floorMs: 0 });
97
+ const controller = new AbortController();
98
+ const waiting = gate.wait(5_000, controller.signal);
99
+ controller.abort();
100
+ expect(await waiting).toBe("aborted");
101
+ gate.signal();
102
+ expect(gate.wakeCount).toBe(0);
103
+ });
104
+
105
+ test("signal() carries no payload: the type has no argument and the call ignores one", () => {
106
+ const gate = createChangeSignalGate();
107
+ expect(gate.signal.length).toBe(0);
108
+ // A caller that fabricates an event has nowhere to put it: the extra
109
+ // argument is dropped, and the gate's only state is a boolean.
110
+ (gate.signal as (...args: unknown[]) => void)({ kind: "Deployment", name: "fabricated" });
111
+ expect(gate.signalCount).toBe(1);
112
+ });
113
+
114
+ test("the documented floor is a real, positive default", () => {
115
+ expect(DEFAULT_SIGNAL_FLOOR_MS).toBe(5_000);
116
+ });
117
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The wake gate between a substrate's change signal and `chant operator`'s
3
+ * sleep (#1981, epic #1487).
4
+ *
5
+ * A lexicon that implements `subscribeChanges` (`../lexicon.ts`) can say
6
+ * "something moved". This module is everything the loop does with that: it
7
+ * shortens the current sleep, at most once per floor. It is deliberately the
8
+ * only place a signal touches, so the rule that a signal is a trigger and
9
+ * never a fact is a property of the code rather than a convention. Nothing
10
+ * here has a parameter, a return value or a field that could carry what
11
+ * changed.
12
+ *
13
+ * ## Backpressure: the floor
14
+ *
15
+ * A watch on a busy namespace is not a trickle. A rollout, a Job sweep or a
16
+ * controller resyncing its whole world produces hundreds of events in a
17
+ * second, and each one is a truthful "something moved". Waking a tick per
18
+ * event would turn a convergence loop into a load generator pointed at the
19
+ * cluster it is meant to be watching.
20
+ *
21
+ * So the gate coalesces. Signals are collapsed into a single pending flag, and
22
+ * a pending flag wakes the sleep no earlier than {@link
23
+ * ChangeSignalGateOptions.floorMs} after the last round *started*. A storm of
24
+ * a thousand signals inside one floor window costs exactly one early wake, and
25
+ * a substrate that never stops sending settles into ticking on the floor
26
+ * rather than on the interval, which is the fastest the loop is ever allowed
27
+ * to run.
28
+ *
29
+ * The floor is measured from the start of the last round, not from the last
30
+ * signal, so a steady stream can never push the wake further out (that would
31
+ * be a debounce, and a debounce starves: the busier the cluster, the later the
32
+ * tick). Measuring from the round start makes the guarantee a rate limit,
33
+ * never more than one signal-driven tick per floor, which is the property the
34
+ * loop actually needs.
35
+ */
36
+
37
+ /**
38
+ * The shortest gap between two rounds a change signal may produce. Signals
39
+ * arriving inside this window of the last round's start are coalesced into one
40
+ * wake at the end of it.
41
+ *
42
+ * Five seconds: long enough that a rollout's event storm costs one tick rather
43
+ * than dozens, short enough that "wakes within a second or so of a change"
44
+ * holds for the quiet case a signal exists for. The timer
45
+ * (`DEFAULT_OPERATOR_INTERVAL_MS`, 60s) is unchanged and remains the ceiling.
46
+ */
47
+ export const DEFAULT_SIGNAL_FLOOR_MS = 5_000;
48
+
49
+ /** Why {@link ChangeSignalGate.wait} returned. */
50
+ export type WakeReason =
51
+ /** The full interval elapsed. An ordinary timer-driven round. */
52
+ | "timer"
53
+ /** A substrate signalled and the floor had passed. An early round. */
54
+ | "signal"
55
+ /** The operator is stopping. */
56
+ | "aborted";
57
+
58
+ export interface ChangeSignalGateOptions {
59
+ /** @default DEFAULT_SIGNAL_FLOOR_MS */
60
+ floorMs?: number;
61
+ /** Injectable clock, for tests. @default Date.now */
62
+ now?: () => number;
63
+ }
64
+
65
+ /**
66
+ * One operator loop's wake gate. Not reusable across loops: it carries the
67
+ * time the current loop last started a round, which is what the floor is
68
+ * measured from.
69
+ */
70
+ export interface ChangeSignalGate {
71
+ /**
72
+ * A substrate said something moved. Takes nothing and returns nothing, and
73
+ * that is the enforcement of "a trigger, never a fact": there is no
74
+ * argument for a watch event to ride in on.
75
+ *
76
+ * Safe to call at any time, including while no `wait` is in flight (the flag
77
+ * is remembered and honoured by the next one) and after the loop has
78
+ * stopped (it does nothing).
79
+ */
80
+ signal(): void;
81
+ /** The loop is starting a round now. Resets the pending flag and the floor. */
82
+ roundStarted(): void;
83
+ /**
84
+ * Sleep up to `intervalMs`, resolving early when a signal has passed the
85
+ * floor. Resolves rather than throws on abort, like the timer-only sleep it
86
+ * replaces, so Ctrl-C never surfaces as a loop failure.
87
+ */
88
+ wait(intervalMs: number, abortSignal?: AbortSignal): Promise<WakeReason>;
89
+ /** How many signals have arrived, coalesced or not. Diagnostics and tests. */
90
+ readonly signalCount: number;
91
+ /** How many of those actually shortened a sleep. Diagnostics and tests. */
92
+ readonly wakeCount: number;
93
+ }
94
+
95
+ export function createChangeSignalGate(options: ChangeSignalGateOptions = {}): ChangeSignalGate {
96
+ const floorMs = options.floorMs ?? DEFAULT_SIGNAL_FLOOR_MS;
97
+ const now = options.now ?? (() => Date.now());
98
+
99
+ let pending = false;
100
+ let lastRoundAt = now();
101
+ let signalCount = 0;
102
+ let wakeCount = 0;
103
+ /** Set only while a `wait` is in flight. */
104
+ let arm: (() => void) | undefined;
105
+
106
+ return {
107
+ get signalCount() {
108
+ return signalCount;
109
+ },
110
+ get wakeCount() {
111
+ return wakeCount;
112
+ },
113
+
114
+ signal() {
115
+ signalCount++;
116
+ pending = true;
117
+ arm?.();
118
+ },
119
+
120
+ roundStarted() {
121
+ lastRoundAt = now();
122
+ pending = false;
123
+ },
124
+
125
+ wait(intervalMs: number, abortSignal?: AbortSignal): Promise<WakeReason> {
126
+ return new Promise<WakeReason>((resolve) => {
127
+ if (abortSignal?.aborted) return resolve("aborted");
128
+
129
+ let settled = false;
130
+ let floorTimer: ReturnType<typeof setTimeout> | undefined;
131
+
132
+ const finish = (reason: WakeReason) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ clearTimeout(intervalTimer);
136
+ if (floorTimer) clearTimeout(floorTimer);
137
+ abortSignal?.removeEventListener("abort", onAbort);
138
+ arm = undefined;
139
+ if (reason === "signal") wakeCount++;
140
+ resolve(reason);
141
+ };
142
+
143
+ const intervalTimer = setTimeout(() => finish("timer"), intervalMs);
144
+ const onAbort = () => finish("aborted");
145
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
146
+
147
+ // A pending signal wakes the sleep the moment the floor has passed,
148
+ // and not one moment earlier. `remaining` is measured from the last
149
+ // round's start, so repeated signals inside the window collapse onto
150
+ // the same deadline instead of pushing it out.
151
+ const consider = () => {
152
+ if (settled || !pending) return;
153
+ const remaining = lastRoundAt + floorMs - now();
154
+ if (remaining <= 0) return finish("signal");
155
+ if (floorTimer) return; // already waiting out this window
156
+ floorTimer = setTimeout(() => {
157
+ floorTimer = undefined;
158
+ consider();
159
+ }, remaining);
160
+ };
161
+
162
+ arm = consider;
163
+ // A signal that arrived between rounds, while nothing was sleeping,
164
+ // is honoured here rather than lost.
165
+ consider();
166
+ });
167
+ },
168
+ };
169
+ }
@@ -30,7 +30,7 @@
30
30
  * env: "prod",
31
31
  * target: "kubectl",
32
32
  * delete: "gated",
33
- * gate: { signalName: "approve-apply", description: "Approve prod apply with deletes" },
33
+ * gate: { gate: "approve-apply", description: "Approve prod apply with deletes" },
34
34
  * });
35
35
  * ```
36
36
  *
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import { Op, phase, activity, gate } from "../builders";
41
+ import { gateName } from "../gate-name";
41
42
  import type { OpResource } from "../resource";
42
43
  import { defaultOutput, hasNativeRollback, type ApplyTarget, type DeleteMode } from "../activities/apply";
43
44
 
@@ -69,11 +70,20 @@ export interface ApplyOpConfig {
69
70
  effects?: "gated";
70
71
  /**
71
72
  * Approval gate before the apply. Implied when `delete: "gated"`; may also be
72
- * set explicitly. Omit `signalName` to default to `approve-<name>`. The gate
73
+ * set explicitly. Omit `gate` to default to `approve-<name>`. The gate
73
74
  * is resolved by `chant approve` on the ledger (#2119), which the next run
74
75
  * reads; nothing waits in the meantime.
76
+ *
77
+ * `signalName` is the key `gate` carried through 0.58.0 (#2202): still read,
78
+ * removed in 0.60.0.
75
79
  */
76
- gate?: { signalName?: string; timeout?: string; description?: string };
80
+ gate?: {
81
+ gate?: string;
82
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
83
+ signalName?: string;
84
+ timeout?: string;
85
+ description?: string;
86
+ };
77
87
  /**
78
88
  * Saga-style rollback on partial apply failure, run as an `onFailure` phase.
79
89
  *
@@ -129,7 +139,7 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
129
139
  if (gated) {
130
140
  phases.push(
131
141
  phase("Approve", [
132
- gate(config.gate?.signalName ?? `approve-${config.name}`, {
142
+ gate(gateName(config.gate ?? {}) || `approve-${config.name}`, {
133
143
  ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
134
144
  description:
135
145
  config.gate?.description ??
@@ -165,21 +165,34 @@ describe("ApplyOp: gating + deletes", () => {
165
165
  expect(phases.map((p) => p.name)).toEqual(["Build", "Plan", "Approve", "Apply"]);
166
166
  const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
167
167
  expect(gateStep.kind).toBe("gate");
168
- expect(gateStep.signalName).toBe("approve-p");
168
+ expect(gateStep.gate).toBe("approve-p");
169
169
  });
170
170
 
171
171
  test("explicit gate config is honored", () => {
172
172
  const { op } = ApplyOp({
173
173
  name: "p",
174
174
  env: "prod",
175
- gate: { signalName: "go", description: "ship it" },
175
+ gate: { gate: "go", description: "ship it" },
176
176
  });
177
177
  const phases = getProps(op).phases as Array<Record<string, unknown>>;
178
178
  const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
179
- expect(gateStep.signalName).toBe("go");
179
+ expect(gateStep.gate).toBe("go");
180
180
  expect(gateStep.description).toBe("ship it");
181
181
  });
182
182
 
183
+ // #2202: the gate config's key is `gate`; `signalName` is read through 0.59.0.
184
+ test("the deprecated `signalName` key on the gate config still names the gate", () => {
185
+ const { op } = ApplyOp({
186
+ name: "p",
187
+ env: "prod",
188
+ gate: { signalName: "go", description: "ship it" },
189
+ });
190
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
191
+ const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
192
+ expect(gateStep.gate).toBe("go");
193
+ expect(gateStep.signalName).toBeUndefined();
194
+ });
195
+
183
196
  test("deleteMode flows into the nativeApply step", () => {
184
197
  const { op } = ApplyOp({ name: "p", env: "prod", delete: "owned-only" });
185
198
  const phases = getProps(op).phases as Array<Record<string, unknown>>;
@@ -301,7 +314,7 @@ describe("ApplyOp: effects gated (#1834, #1703 decision 6)", () => {
301
314
  expect(phases.map((p) => p.name)).toEqual(["Build", "Plan", "Approve", "Apply"]);
302
315
  const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
303
316
  expect(gateStep.kind).toBe("gate");
304
- expect(gateStep.signalName).toBe("approve-p");
317
+ expect(gateStep.gate).toBe("approve-p");
305
318
  expect(gateStep.description).toBe("Approve apply to prod (delete mode: never, effects: gated)");
306
319
  });
307
320
 
@@ -60,15 +60,18 @@ export function ReconcileOp(config: ReconcileOpConfig): ReconcileOpResources {
60
60
  const onDrift = config.onDrift ?? "pull-request";
61
61
  const owned = config.scope?.owned ?? false;
62
62
 
63
- // The reconcile's headline output is the opened PR (or issue) URL. Expose it as
64
- // an outcome attribute so it prints in `chant run` and lands on the ledger.
65
- // `report` mode opens nothing, so it has no URL outcome.
63
+ // The reconcile's headline output is the opened PR (or issue, or the comment
64
+ // it posted on the triggering pull request) URL. Expose it as an outcome
65
+ // attribute so it prints in `chant run` and lands on the ledger. `report`
66
+ // mode opens nothing, so it has no URL outcome.
66
67
  const reconcileOutcome =
67
68
  onDrift === "pull-request"
68
69
  ? { name: "PR", from: "prUrl" }
69
70
  : onDrift === "issue"
70
71
  ? { name: "Issue", from: "issueUrl" }
71
- : undefined;
72
+ : onDrift === "comment"
73
+ ? { name: "Comment", from: "commentUrl" }
74
+ : undefined;
72
75
 
73
76
  const op = Op({
74
77
  name: config.name,
@@ -58,7 +58,7 @@ describe("effect() builder", () => {
58
58
  inputs: { file: "seed.sql", version: 3 },
59
59
  });
60
60
  expect(s.expectation).toBe(receiptExpectation(seeded));
61
- expect(s.steps.map((n) => (n.kind === "activity" ? n.fn : n.signalName))).toEqual(["shellCmd"]);
61
+ expect(s.steps.map((n) => (n.kind === "activity" ? n.fn : n.gate))).toEqual(["shellCmd"]);
62
62
  });
63
63
 
64
64
  test("existence receipts always get the constant expectation, reference inputs or not", () => {
@@ -83,7 +83,7 @@ describe("effect() builder", () => {
83
83
 
84
84
  test("preserves authored order and nested gates", () => {
85
85
  const s = effect(seeded, [
86
- { kind: "gate", signalName: "approve-seed" },
86
+ { kind: "gate", gate: "approve-seed" },
87
87
  step("shellCmd", { cmd: "seed" }),
88
88
  ]);
89
89
  expect(s.steps.map((n) => n.kind)).toEqual(["gate", "activity"]);
@@ -288,7 +288,7 @@ describe("runOpLocally — effect steps", () => {
288
288
  name: "gated",
289
289
  overview: "",
290
290
  phases: [
291
- phase("Seed", [effect(seeded, [{ kind: "gate", signalName: "approve-seed" }, step("runSeed")])]),
291
+ phase("Seed", [effect(seeded, [{ kind: "gate", gate: "approve-seed" }, step("runSeed")])]),
292
292
  ],
293
293
  };
294
294
  const { store } = memStore();