@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.
- package/dist/audit/core.d.ts +17 -1
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +15 -4
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +13 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts +0 -16
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +22 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +19 -4
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts +8 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +1 -7
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +13 -0
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lexicon.d.ts +170 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +9 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +62 -2
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/change-signal.d.ts +91 -0
- package/dist/op/change-signal.d.ts.map +1 -0
- package/dist/op/composites/apply-op.d.ts +7 -2
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +40 -0
- package/dist/op/gate-name.d.ts.map +1 -0
- package/dist/op/gate-summary.d.ts +43 -0
- package/dist/op/gate-summary.d.ts.map +1 -0
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +63 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/types.d.ts +18 -3
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +26 -7
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +28 -2
- package/dist/terraform/carve-provider.d.ts.map +1 -1
- package/dist/terraform/data-source-shape.d.ts +77 -0
- package/dist/terraform/data-source-shape.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/providers/kubernetes.d.ts +5 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +16 -4
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +8 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +25 -3
- package/src/audit/discover.ts +122 -14
- package/src/cli/commands/audit.test.ts +5 -3
- package/src/cli/commands/audit.ts +5 -1
- package/src/cli/commands/build.test.ts +39 -1
- package/src/cli/commands/build.ts +14 -0
- package/src/cli/commands/carve-bridge.test.ts +237 -14
- package/src/cli/commands/carve-bridge.ts +21 -17
- package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
- package/src/cli/commands/lint.test.ts +146 -1
- package/src/cli/commands/lint.ts +82 -7
- package/src/cli/handlers/operator.ts +81 -0
- package/src/cli/handlers/run.test.ts +132 -3
- package/src/cli/handlers/run.ts +87 -3
- package/src/cli/main.test.ts +9 -11
- package/src/cli/main.ts +5 -27
- package/src/cli/plugins.test.ts +68 -2
- package/src/cli/plugins.ts +39 -0
- package/src/cli/registry.ts +14 -0
- package/src/components/README.md +2 -2
- package/src/components/__fixtures__/neo4j-fanout.json +1 -1
- package/src/components/cli-support.test.ts +18 -7
- package/src/components/cli-support.ts +8 -3
- package/src/components/component-schema.test.ts +18 -2
- package/src/components/component.schema.json +17 -4
- package/src/components/component.test.ts +2 -2
- package/src/components/component.ts +25 -5
- package/src/components/config-defaults.test.ts +2 -2
- package/src/components/driver.test.ts +20 -1
- package/src/components/driver.ts +12 -5
- package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
- package/src/components/verbs/run-agent.test.ts +19 -0
- package/src/components/verbs/run-agent.ts +1 -7
- package/src/detectLexicon.ts +18 -1
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/fold/foldable-helpers.ts +1 -1
- package/src/graph-ops.test.ts +1 -1
- package/src/lexicon.ts +177 -3
- package/src/lifecycle/gate-ledger.ts +12 -1
- package/src/lint/pipeline-change-gate.test.ts +2 -2
- package/src/lint/rules/comp/comp.test.ts +26 -0
- package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
- package/src/op/activities/index.ts +2 -2
- package/src/op/activities/reconcile.test.ts +82 -2
- package/src/op/activities/reconcile.ts +169 -2
- package/src/op/builders.ts +3 -3
- package/src/op/change-signal.test.ts +117 -0
- package/src/op/change-signal.ts +169 -0
- package/src/op/composites/apply-op.ts +14 -4
- package/src/op/composites/composites.test.ts +17 -4
- package/src/op/composites/reconcile-op.ts +7 -4
- package/src/op/effect-step.test.ts +3 -3
- package/src/op/gate-name.test.ts +65 -0
- package/src/op/gate-name.ts +60 -0
- package/src/op/gate-summary.ts +84 -0
- package/src/op/index.ts +9 -2
- package/src/op/local-executor.test.ts +16 -1
- package/src/op/local-executor.ts +4 -3
- package/src/op/op-ir.test.ts +12 -1
- package/src/op/op-ir.ts +5 -3
- package/src/op/op-verb-class.test.ts +2 -2
- package/src/op/op.test.ts +2 -2
- package/src/op/operator.test.ts +368 -0
- package/src/op/operator.ts +141 -5
- package/src/op/runtimes/local.test.ts +1 -1
- package/src/op/types.ts +23 -3
- package/src/terraform/aws-resources.test.ts +13 -4
- package/src/terraform/bridge.test.ts +22 -9
- package/src/terraform/bridge.ts +89 -28
- package/src/terraform/carve-provider.ts +38 -2
- package/src/terraform/data-source-shape.ts +95 -0
- package/src/terraform/graph.ts +35 -7
- package/src/terraform/providers/kubernetes.ts +48 -2
- package/src/terraform/tier-map.ts +21 -6
- 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
|
-
/**
|
|
7
|
-
|
|
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";
|
package/src/op/builders.ts
CHANGED
|
@@ -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> <
|
|
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
|
-
|
|
112
|
+
name: string,
|
|
113
113
|
opts?: { timeout?: string; description?: string },
|
|
114
114
|
): GateStep {
|
|
115
115
|
return {
|
|
116
116
|
kind: "gate",
|
|
117
|
-
|
|
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: {
|
|
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 `
|
|
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?: {
|
|
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
|
|
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.
|
|
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: {
|
|
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.
|
|
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.
|
|
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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
-
:
|
|
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.
|
|
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",
|
|
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",
|
|
291
|
+
phase("Seed", [effect(seeded, [{ kind: "gate", gate: "approve-seed" }, step("runSeed")])]),
|
|
292
292
|
],
|
|
293
293
|
};
|
|
294
294
|
const { store } = memStore();
|