@intentius/chant 0.58.0 → 0.60.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/build-params.d.ts +2 -2
- 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/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/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/components/verbs/run-agent.d.ts +1 -7
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/detectLexicon.d.ts +13 -0
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lexicon.d.ts +213 -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/lifecycle/observe.d.ts +4 -4
- 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 +134 -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 +55 -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/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- 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/build-params.ts +2 -2
- 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 +22 -8
- 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 +297 -1
- package/src/cli/commands/lint.ts +119 -11
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- 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/SPRAWL-VALIDATION.md +5 -5
- 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/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- 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/config.ts +4 -4
- package/src/detectLexicon.ts +18 -1
- package/src/discovery/fold-import.test.ts +2 -2
- package/src/discovery/fold-import.ts +3 -3
- package/src/fold/foldable-helpers.ts +1 -1
- package/src/graph-ops.test.ts +1 -1
- package/src/lexicon.ts +221 -3
- package/src/lifecycle/gate-ledger.ts +12 -1
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +8 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- 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 +9 -2
- package/src/op/activities/reconcile.test.ts +320 -2
- package/src/op/activities/reconcile.ts +423 -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.test.ts +62 -0
- package/src/op/gate-summary.ts +96 -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/params.ts +1 -1
- package/src/project-root.ts +2 -2
- 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
|
@@ -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();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `signalName` -> `gate` rename (#2202) and the one-version bridge that
|
|
3
|
+
* keeps the old key readable through 0.59.0.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { gateName, usesDeprecatedGateKey, opUsesDeprecatedGateKey } from "./gate-name";
|
|
7
|
+
import type { OpConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
describe("gateName", () => {
|
|
10
|
+
it("reads the new key", () => {
|
|
11
|
+
expect(gateName({ gate: "approve-prod" })).toBe("approve-prod");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("falls back to the deprecated key", () => {
|
|
15
|
+
expect(gateName({ signalName: "approve-prod" })).toBe("approve-prod");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("prefers the new key when a step somehow carries both", () => {
|
|
19
|
+
expect(gateName({ gate: "new", signalName: "old" })).toBe("new");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("usesDeprecatedGateKey", () => {
|
|
24
|
+
it("is true only when the new key is absent", () => {
|
|
25
|
+
expect(usesDeprecatedGateKey({ signalName: "old" })).toBe(true);
|
|
26
|
+
expect(usesDeprecatedGateKey({ gate: "new" })).toBe(false);
|
|
27
|
+
expect(usesDeprecatedGateKey({ gate: "new", signalName: "old" })).toBe(false);
|
|
28
|
+
expect(usesDeprecatedGateKey({})).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("opUsesDeprecatedGateKey", () => {
|
|
33
|
+
const op = (phases: OpConfig["phases"], onFailure?: OpConfig["phases"]): OpConfig =>
|
|
34
|
+
({ name: "x", overview: "x", phases, ...(onFailure ? { onFailure } : {}) }) as OpConfig;
|
|
35
|
+
|
|
36
|
+
it("finds a gate on the old key in a plain phase", () => {
|
|
37
|
+
expect(opUsesDeprecatedGateKey(op([{ name: "Approve", steps: [{ kind: "gate", signalName: "go" }] }]))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("finds one nested inside an effect step", () => {
|
|
41
|
+
const config = op([
|
|
42
|
+
{
|
|
43
|
+
name: "Seed",
|
|
44
|
+
steps: [
|
|
45
|
+
{
|
|
46
|
+
kind: "effect",
|
|
47
|
+
receipt: { name: "seeded", effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql" } },
|
|
48
|
+
steps: [{ kind: "gate", signalName: "go" }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
expect(opUsesDeprecatedGateKey(config)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("finds one in an onFailure phase", () => {
|
|
57
|
+
expect(
|
|
58
|
+
opUsesDeprecatedGateKey(op([{ name: "Apply", steps: [] }], [{ name: "Undo", steps: [{ kind: "gate", signalName: "go" }] }])),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is false for an Op on the new key", () => {
|
|
63
|
+
expect(opUsesDeprecatedGateKey(op([{ name: "Approve", steps: [{ kind: "gate", gate: "go" }] }]))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate step's name field, and the one-version bridge for the key it used
|
|
3
|
+
* to carry (chant #2202).
|
|
4
|
+
*
|
|
5
|
+
* A gate is a fact on the gate ledger, not a channel: since #2119 nothing is
|
|
6
|
+
* running and nothing is signalled, so the field that names a gate is called
|
|
7
|
+
* `gate` — the same word `chant approve <op> <gate>` takes on the command
|
|
8
|
+
* line. It was called `signalName` through 0.58.0.
|
|
9
|
+
*
|
|
10
|
+
* `signalName` is still read, on `GateStep`, on the component `Gate` step and
|
|
11
|
+
* on the composites' gate options, and a build that sees only the old key
|
|
12
|
+
* warns once. It goes away in 0.60.0: delete this module's
|
|
13
|
+
* {@link DEPRECATED_GATE_KEY_WARNING}, {@link usesDeprecatedGateKey}, the
|
|
14
|
+
* `signalName` branch of {@link gateName}, the optional `signalName` fields
|
|
15
|
+
* that call it, and the schema's `oneOf` once that release is out, leaving
|
|
16
|
+
* `gate` as the only key.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { OpConfig, PhaseDefinition, StepDefinition } from "./types";
|
|
20
|
+
|
|
21
|
+
/** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
|
|
22
|
+
export interface GateNamed {
|
|
23
|
+
gate?: string;
|
|
24
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
25
|
+
signalName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The gate's name, from whichever key carries it. One place reads the old key,
|
|
30
|
+
* so removing the bridge is a one-file edit rather than a sweep.
|
|
31
|
+
*/
|
|
32
|
+
export function gateName(step: GateNamed): string {
|
|
33
|
+
return step.gate ?? step.signalName ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when a step names its gate only with the deprecated key. */
|
|
37
|
+
export function usesDeprecatedGateKey(step: GateNamed): boolean {
|
|
38
|
+
return step.gate === undefined && typeof step.signalName === "string";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a build prints once when it finds a gate step still using the old key. */
|
|
42
|
+
export const DEPRECATED_GATE_KEY_WARNING =
|
|
43
|
+
'Gate steps still using "signalName": the key is now "gate" (chant #2202). ' +
|
|
44
|
+
'"signalName" is accepted through 0.59.0 and removed in 0.60.0.';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when any gate step in an Op's phases (main or `onFailure`, at any
|
|
48
|
+
* nesting depth inside an `effect()`) names itself only with the deprecated
|
|
49
|
+
* key. `chant build` calls this to warn once per build.
|
|
50
|
+
*/
|
|
51
|
+
export function opUsesDeprecatedGateKey(config: OpConfig): boolean {
|
|
52
|
+
const inStep = (step: StepDefinition): boolean => {
|
|
53
|
+
if (step.kind === "gate") return usesDeprecatedGateKey(step);
|
|
54
|
+
if (step.kind === "effect") return step.steps.some(inStep);
|
|
55
|
+
return false;
|
|
56
|
+
};
|
|
57
|
+
const inPhases = (phases: PhaseDefinition[] | undefined): boolean =>
|
|
58
|
+
(phases ?? []).some((p) => p.steps.some(inStep));
|
|
59
|
+
return inPhases(config.phases) || inPhases(config.onFailure);
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-gate block a CI run leaves where people actually look (#2243),
|
|
3
|
+
* and the second surface GitLab needs for it (#2256).
|
|
4
|
+
*
|
|
5
|
+
* GitHub Actions, Forgejo Actions and Gitea all hand a step a markdown
|
|
6
|
+
* scratchpad through `GITHUB_STEP_SUMMARY`. GitLab CI has no equivalent at
|
|
7
|
+
* all — a job's surfaces are its log and its artifacts — so `CHANT_GATE_SUMMARY`
|
|
8
|
+
* names a path the generated GitLab job also declares under `artifacts:`. One
|
|
9
|
+
* more environment variable, the same block, still no API call and no token.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { describe, test, expect } from "vitest";
|
|
16
|
+
import { gatedRunSummaryMarkdown, writeGatedRunSummary } from "./gate-summary";
|
|
17
|
+
|
|
18
|
+
const summary = { op: "app-apply", gate: "approve-app-apply", expiresAt: "2026-09-08T00:00:00Z" };
|
|
19
|
+
|
|
20
|
+
function tempPath(name: string): string {
|
|
21
|
+
return join(mkdtempSync(join(tmpdir(), "chant-gate-")), name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("gatedRunSummaryMarkdown (#2243)", () => {
|
|
25
|
+
test("names the gate, the exact approve command and the ledger path", () => {
|
|
26
|
+
const md = gatedRunSummaryMarkdown(summary);
|
|
27
|
+
expect(md).toContain("Waiting on gate `approve-app-apply`");
|
|
28
|
+
expect(md).toContain("chant approve app-apply approve-app-apply --approver <you>");
|
|
29
|
+
expect(md).toContain("_gates/app-apply.jsonl");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
|
|
34
|
+
test("writes to GITHUB_STEP_SUMMARY where the forge sets one", () => {
|
|
35
|
+
const path = tempPath("step-summary.md");
|
|
36
|
+
expect(writeGatedRunSummary(summary, { GITHUB_STEP_SUMMARY: path })).toBe(path);
|
|
37
|
+
expect(readFileSync(path, "utf8")).toContain("approve-app-apply");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("writes to CHANT_GATE_SUMMARY where the forge sets none — GitLab's artifact path", () => {
|
|
41
|
+
const path = tempPath("chant-gate-app-apply.md");
|
|
42
|
+
expect(writeGatedRunSummary(summary, { CHANT_GATE_SUMMARY: path })).toBe(path);
|
|
43
|
+
expect(readFileSync(path, "utf8")).toContain("chant approve app-apply approve-app-apply");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the forge's own variable wins when both are set", () => {
|
|
47
|
+
const forge = tempPath("step-summary.md");
|
|
48
|
+
const artifact = tempPath("chant-gate-app-apply.md");
|
|
49
|
+
expect(
|
|
50
|
+
writeGatedRunSummary(summary, { GITHUB_STEP_SUMMARY: forge, CHANT_GATE_SUMMARY: artifact }),
|
|
51
|
+
).toBe(forge);
|
|
52
|
+
expect(existsSync(artifact)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a run with neither variable writes nothing and reports nothing", () => {
|
|
56
|
+
expect(writeGatedRunSummary(summary, {})).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an unwritable path is swallowed: a decided run does not change outcome over a scratchpad", () => {
|
|
60
|
+
expect(writeGatedRunSummary(summary, { CHANT_GATE_SUMMARY: "/no/such/dir/gate.md" })).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-gate block a CI run leaves where people actually look (#2243).
|
|
3
|
+
*
|
|
4
|
+
* A gated run already prints the gate, the approve command and the expiry to
|
|
5
|
+
* stderr (`./local-output.ts`'s `renderHuman`). In CI that lands in a log
|
|
6
|
+
* nobody opens unless the run is red, which is exactly the run this stops
|
|
7
|
+
* being red. GitHub Actions, Forgejo Actions and Gitea all give a step a
|
|
8
|
+
* markdown scratchpad through `GITHUB_STEP_SUMMARY`, rendered at the top of
|
|
9
|
+
* the run page; anything else that sets the variable gets the same block.
|
|
10
|
+
*
|
|
11
|
+
* GitLab CI sets no such variable and has no step summary at all: a job's
|
|
12
|
+
* surfaces are its log and its artifacts (#2256). So `CHANT_GATE_SUMMARY`
|
|
13
|
+
* names a second path, which the generated GitLab job sets to a file it also
|
|
14
|
+
* declares under `artifacts:`. Same block, same rule, one more variable.
|
|
15
|
+
*
|
|
16
|
+
* That is the whole forge coupling: two environment variables, appended to
|
|
17
|
+
* whichever is set, and nothing at all when neither is. No API call, no
|
|
18
|
+
* token, no flag to turn it on.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { appendFileSync } from "node:fs";
|
|
22
|
+
import { gateLedgerPath } from "../lifecycle/gate-ledger";
|
|
23
|
+
import { approveCommand } from "./gate";
|
|
24
|
+
|
|
25
|
+
/** What a gated run knows about the gate it stopped on. */
|
|
26
|
+
export interface GatedRunSummary {
|
|
27
|
+
/** The Op (or component) the gate belongs to. */
|
|
28
|
+
op: string;
|
|
29
|
+
/** The gate's name — the second argument to `chant approve`. */
|
|
30
|
+
gate: string;
|
|
31
|
+
/** The gate's authored description, when it has one. */
|
|
32
|
+
description?: string;
|
|
33
|
+
/** ISO-8601 instant the pending fact ages out at. */
|
|
34
|
+
expiresAt?: string;
|
|
35
|
+
/** The approval surface the run resolved, when it knew one. */
|
|
36
|
+
url?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The markdown block itself. Pure, so a test reads what CI would render
|
|
41
|
+
* without touching a file: op, gate, the exact `chant approve` line, and the
|
|
42
|
+
* ledger path the pending fact was appended to.
|
|
43
|
+
*/
|
|
44
|
+
export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
|
|
45
|
+
const lines = [
|
|
46
|
+
`### Waiting on gate \`${summary.gate}\``,
|
|
47
|
+
"",
|
|
48
|
+
`Op \`${summary.op}\` reached a gate nobody has approved, recorded the pending fact, and stopped. Nothing failed.`,
|
|
49
|
+
"",
|
|
50
|
+
];
|
|
51
|
+
if (summary.description) {
|
|
52
|
+
lines.push(summary.description, "");
|
|
53
|
+
}
|
|
54
|
+
lines.push(
|
|
55
|
+
"Approve it, then re-run this workflow:",
|
|
56
|
+
"",
|
|
57
|
+
"```",
|
|
58
|
+
`${approveCommand(summary.op, summary.gate)} --approver <you>`,
|
|
59
|
+
"```",
|
|
60
|
+
"",
|
|
61
|
+
`Ledger: \`${gateLedgerPath(summary.op)}\` on the \`chant/lifecycle\` branch.`,
|
|
62
|
+
);
|
|
63
|
+
if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
|
|
64
|
+
if (summary.url) lines.push(`Approve at: ${summary.url}`);
|
|
65
|
+
lines.push("");
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Append {@link gatedRunSummaryMarkdown} to the file `GITHUB_STEP_SUMMARY`
|
|
71
|
+
* names, or, where the forge sets no such variable, the one
|
|
72
|
+
* `CHANT_GATE_SUMMARY` names (#2256) — when it is set and the file can be
|
|
73
|
+
* written.
|
|
74
|
+
*
|
|
75
|
+
* The forge's own variable wins where both are set: a step summary is
|
|
76
|
+
* rendered on the run page, an artifact has to be downloaded, and writing
|
|
77
|
+
* both would put the same block in two places on a forge that already shows
|
|
78
|
+
* one of them.
|
|
79
|
+
*
|
|
80
|
+
* Returns the path written, or `undefined` when there was nothing to write to.
|
|
81
|
+
* A write that fails is swallowed: a run that already decided its outcome must
|
|
82
|
+
* not turn into a different one because a CI scratchpad was read-only.
|
|
83
|
+
*/
|
|
84
|
+
export function writeGatedRunSummary(
|
|
85
|
+
summary: GatedRunSummary,
|
|
86
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
87
|
+
): string | undefined {
|
|
88
|
+
const path = env.GITHUB_STEP_SUMMARY || env.CHANT_GATE_SUMMARY;
|
|
89
|
+
if (!path) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
appendFileSync(path, gatedRunSummaryMarkdown(summary));
|
|
92
|
+
return path;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -52,6 +52,8 @@ export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
|
|
|
52
52
|
export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
|
|
53
53
|
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand } from "./gate";
|
|
54
54
|
export type { GateLedgerPort, GateCheck, GateCheckInput } from "./gate";
|
|
55
|
+
export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
|
|
56
|
+
export type { GateNamed } from "./gate-name";
|
|
55
57
|
export { createLocalOpRuntime } from "./runtimes/local";
|
|
56
58
|
export { runStateOf } from "./runtime";
|
|
57
59
|
export type {
|
|
@@ -85,9 +87,14 @@ export type {
|
|
|
85
87
|
} from "./converge-rule";
|
|
86
88
|
export {
|
|
87
89
|
discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
|
|
88
|
-
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
90
|
+
formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS,
|
|
89
91
|
} from "./operator";
|
|
90
|
-
export type {
|
|
92
|
+
export type {
|
|
93
|
+
OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions,
|
|
94
|
+
ChangeSubscriber, OperatorSignalEvent,
|
|
95
|
+
} from "./operator";
|
|
96
|
+
export { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS } from "./change-signal";
|
|
97
|
+
export type { ChangeSignalGate, ChangeSignalGateOptions, WakeReason } from "./change-signal";
|
|
91
98
|
export { classifyOpVerbClass, isGated } from "./op-verb-class";
|
|
92
99
|
export type { OpVerbClass } from "./op-verb-class";
|
|
93
100
|
export { takeProfileAndId } from "./builders";
|
|
@@ -284,7 +284,7 @@ describe("runOpLocally — gate as fact (#2119)", () => {
|
|
|
284
284
|
phases: [
|
|
285
285
|
{ name: "P1", steps: [
|
|
286
286
|
{ kind: "activity", fn: "before" },
|
|
287
|
-
{ kind: "gate",
|
|
287
|
+
{ kind: "gate", gate: "approve-prod", description: "release manager signs off", timeout: "24h" },
|
|
288
288
|
{ kind: "activity", fn: "after" },
|
|
289
289
|
] },
|
|
290
290
|
{ name: "P2", steps: [{ kind: "activity", fn: "later" }] },
|
|
@@ -324,6 +324,21 @@ describe("runOpLocally — gate as fact (#2119)", () => {
|
|
|
324
324
|
]);
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
+
// #2202: the gate step's name key is `gate`; `signalName` is read through
|
|
328
|
+
// 0.59.0, so a step still spelling it that way reaches the same ledger entry.
|
|
329
|
+
test("a gate step still using the deprecated `signalName` key names the same gate", async () => {
|
|
330
|
+
const { activities } = tracked();
|
|
331
|
+
const legacy = op({
|
|
332
|
+
phases: [{ name: "P1", steps: [{ kind: "gate", signalName: "approve-prod" }] }],
|
|
333
|
+
});
|
|
334
|
+
const port = memoryGateLedgerPort();
|
|
335
|
+
const result = await runOpLocally(legacy, activities, PROFILES, undefined, { gates: port, now: NOW });
|
|
336
|
+
|
|
337
|
+
expect(result.status).toBe("gated");
|
|
338
|
+
expect(result.gate).toMatchObject({ op: "test-op", gate: "approve-prod" });
|
|
339
|
+
expect(result.records.map((r) => r.fn)).toEqual(["gate:approve-prod"]);
|
|
340
|
+
});
|
|
341
|
+
|
|
327
342
|
test("onFailure phases do not run on a gated run", async () => {
|
|
328
343
|
const { calls, activities } = tracked();
|
|
329
344
|
await runOpLocally(gatedOp(), activities, PROFILES, undefined, { gates: memoryGateLedgerPort(), now: NOW });
|