@intentius/chant 0.106.4 → 0.107.1
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/cli/commands/check-lexicon.d.ts +7 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/activities/apply.d.ts +40 -6
- package/dist/op/activities/apply.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +21 -3
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +10 -2
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-plan-context.d.ts +101 -0
- package/dist/op/gate-plan-context.d.ts.map +1 -0
- package/dist/op/gate-point-run.d.ts +110 -0
- package/dist/op/gate-point-run.d.ts.map +1 -0
- package/dist/op/gate-point.d.ts +70 -0
- package/dist/op/gate-point.d.ts.map +1 -0
- package/dist/op/gate.d.ts +12 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +11 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +11 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +15 -1
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon.test.ts +21 -5
- package/src/cli/commands/check-lexicon.ts +14 -3
- package/src/cli/handlers/operator.ts +9 -0
- package/src/lifecycle/gate-ledger.ts +30 -0
- package/src/op/activities/apply.test.ts +43 -0
- package/src/op/activities/apply.ts +111 -6
- package/src/op/builders.ts +13 -1
- package/src/op/composites/apply-op.ts +24 -4
- package/src/op/gate-approval.ts +18 -2
- package/src/op/gate-plan-context.test.ts +186 -0
- package/src/op/gate-plan-context.ts +229 -0
- package/src/op/gate-point-run.ts +207 -0
- package/src/op/gate-point.e2e.test.ts +166 -0
- package/src/op/gate-point.test.ts +235 -0
- package/src/op/gate-point.ts +119 -0
- package/src/op/gate.ts +26 -1
- package/src/op/index.ts +8 -0
- package/src/op/local-executor.ts +96 -3
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/workspace/status-gates.ts +13 -2
- package/src/workspace/status.schema.json +11 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A gate that asks a declared decision point (#3170), against a stub asker
|
|
3
|
+
* and an in-memory gate ledger: the declaration's checks, the `gate.*`
|
|
4
|
+
* inputs, an open question ending the run `waiting` with a pending fact that
|
|
5
|
+
* cites it, an answer passing the gate with a resolution that cites the
|
|
6
|
+
* record (once), an answer the gate does not pass on failing the step, and a
|
|
7
|
+
* plain gate deciding as before. The end-to-end run through `chant workspace
|
|
8
|
+
* points answer` is in `./gate-point.e2e.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { gate, Op, phase, activity } from "./builders";
|
|
13
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
14
|
+
import { gatePointInputs, gatePointProblems, gateSubject, type GatePointFacts } from "./gate-point";
|
|
15
|
+
import { evaluatePointGate, type GatePointAsker, type GatePointQuestion, type GatePointRequest } from "./gate-point-run";
|
|
16
|
+
import { runOpLocally } from "./local-executor";
|
|
17
|
+
import { stepOutput } from "./step-output-ref";
|
|
18
|
+
import type { ActivityFn } from "./activity-registry";
|
|
19
|
+
import type { OpConfig } from "./types";
|
|
20
|
+
|
|
21
|
+
const PLAN = `sha256:${"a".repeat(64)}`;
|
|
22
|
+
const OTHER = `sha256:${"b".repeat(64)}`;
|
|
23
|
+
const NOW = "2026-10-06T10:00:00.000Z";
|
|
24
|
+
const LATER = "2026-10-06T11:00:00.000Z";
|
|
25
|
+
|
|
26
|
+
/** A stub asker: the question's state comes from `answers`, by plan digest, and every request is kept. */
|
|
27
|
+
function stubAsker(answers: Map<string | null, Partial<GatePointQuestion>>): GatePointAsker & { asked: GatePointRequest[] } {
|
|
28
|
+
const asked: GatePointRequest[] = [];
|
|
29
|
+
return {
|
|
30
|
+
asked,
|
|
31
|
+
async ask(request) {
|
|
32
|
+
asked.push(request);
|
|
33
|
+
const id = `${request.point}-${(request.facts.planDigest ?? "none").slice(7, 19)}`;
|
|
34
|
+
const given = answers.get(request.facts.planDigest) ?? {};
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
point: request.point,
|
|
38
|
+
path: `answers/${id}.md`,
|
|
39
|
+
state: "escalated",
|
|
40
|
+
answer: null,
|
|
41
|
+
decider: "quorum",
|
|
42
|
+
answeredBy: [],
|
|
43
|
+
subject: request.subject,
|
|
44
|
+
steward: null,
|
|
45
|
+
...given,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const facts = (over: Partial<GatePointFacts> = {}): GatePointFacts => ({ component: "prod-apply", name: "apply", env: null, planDigest: PLAN, ...over });
|
|
52
|
+
|
|
53
|
+
describe("a gate's point declaration (#3170)", () => {
|
|
54
|
+
test("gate() takes a point by name or as { name, inputs, pass }", () => {
|
|
55
|
+
expect(gate("apply", { point: "prod-apply" }).point).toBe("prod-apply");
|
|
56
|
+
expect(gate("apply", { point: { name: "prod-apply", pass: ["apply"] } }).point).toEqual({ name: "prod-apply", pass: ["apply"] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("gate() refuses a point beside an approval block, a bad name, and a gate.* input given by hand", () => {
|
|
60
|
+
expect(() => gate("apply", { point: "prod-apply", approval: { quorum: { count: 2 } } })).toThrow(/give `approval` or `point`, not both/);
|
|
61
|
+
expect(() => gate("apply", { point: "Prod Apply" })).toThrow(/decision point's name/);
|
|
62
|
+
expect(() => gate("apply", { point: { name: "prod-apply", inputs: { "gate.planDigest": PLAN } } })).toThrow(/which the gate passes itself/);
|
|
63
|
+
expect(gatePointProblems({ name: "prod-apply", pass: [] })).toEqual(["`point.pass` is a non-empty list of the answers that pass the gate"]);
|
|
64
|
+
expect(gatePointProblems({ name: "prod-apply", quorum: 2 })).toEqual(["`point` takes name, inputs and pass, not quorum"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the gate passes the gate.* inputs the point declares, beside the authored ones", () => {
|
|
68
|
+
expect(gatePointInputs("prod-apply", ["gate.component", "gate.planDigest", "record.size"], facts(), { "record.size": 3 })).toEqual({
|
|
69
|
+
"record.size": 3,
|
|
70
|
+
"gate.component": "prod-apply",
|
|
71
|
+
"gate.planDigest": PLAN,
|
|
72
|
+
});
|
|
73
|
+
// gate.env is null without --env, so it is left out even when declared.
|
|
74
|
+
expect(gatePointInputs("prod-apply", ["gate.env", "gate.planDigest"], facts())).toEqual({ "gate.planDigest": PLAN });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a plan-bound gate refuses a point that does not declare gate.planDigest", () => {
|
|
78
|
+
expect(() => gatePointInputs("prod-apply", ["gate.component"], facts())).toThrow(/does not declare the input gate.planDigest/);
|
|
79
|
+
// A gate that binds no plan needs no such input.
|
|
80
|
+
expect(gatePointInputs("prod-apply", ["gate.component"], facts({ planDigest: null }))).toEqual({ "gate.component": "prod-apply" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the answer record names the gate as gate:<op>/<gate>, with @<env> when the run names one", () => {
|
|
84
|
+
expect(gateSubject("prod-apply", "apply")).toBe("gate:prod-apply/apply");
|
|
85
|
+
expect(gateSubject("prod-apply", "apply", "prod")).toBe("gate:prod-apply/apply@prod");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("evaluatePointGate (#3170)", () => {
|
|
90
|
+
test("an open question records one pending fact that cites it, and a second run leaves it standing", async () => {
|
|
91
|
+
const port = memoryGateLedgerPort();
|
|
92
|
+
const asker = stubAsker(new Map());
|
|
93
|
+
const input = { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: PLAN, now: NOW };
|
|
94
|
+
const first = await evaluatePointGate(port, asker, input);
|
|
95
|
+
if (first.satisfied || !("waiting" in first)) throw new Error("expected the gate to wait");
|
|
96
|
+
expect(first.recorded).toBe(true);
|
|
97
|
+
expect(first.waiting).toMatchObject({ id: "prod-apply-aaaaaaaaaaaa", point: "prod-apply", state: "escalated" });
|
|
98
|
+
expect(first.pending).toMatchObject({ op: "prod-apply", gate: "apply", planDigest: PLAN, answer: { point: "prod-apply", id: "prod-apply-aaaaaaaaaaaa", path: "answers/prod-apply-aaaaaaaaaaaa.md" } });
|
|
99
|
+
expect(asker.asked[0]).toMatchObject({ point: "prod-apply", subject: "gate:prod-apply/apply", facts: { component: "prod-apply", name: "apply", planDigest: PLAN } });
|
|
100
|
+
|
|
101
|
+
const second = await evaluatePointGate(port, asker, { ...input, now: LATER });
|
|
102
|
+
if (second.satisfied || !("waiting" in second)) throw new Error("expected the gate to wait");
|
|
103
|
+
expect(second.recorded).toBe(false);
|
|
104
|
+
expect(port.appended).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("an answer it passes on writes one resolution citing the answer record, and a retry reuses it", async () => {
|
|
108
|
+
const port = memoryGateLedgerPort();
|
|
109
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: true, answeredBy: ["alice"] }]]));
|
|
110
|
+
const input = { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: PLAN, now: NOW };
|
|
111
|
+
const first = await evaluatePointGate(port, asker, input);
|
|
112
|
+
if (!first.satisfied) throw new Error("expected the gate to pass");
|
|
113
|
+
expect(first.recorded).toBe(true);
|
|
114
|
+
expect(first.resolution).toMatchObject({
|
|
115
|
+
op: "prod-apply",
|
|
116
|
+
gate: "apply",
|
|
117
|
+
resolvedBy: "alice",
|
|
118
|
+
planDigest: PLAN,
|
|
119
|
+
approver: { kind: "human" },
|
|
120
|
+
answer: { point: "prod-apply", id: "prod-apply-aaaaaaaaaaaa", path: "answers/prod-apply-aaaaaaaaaaaa.md", answer: true, decider: "quorum", answeredBy: ["alice"] },
|
|
121
|
+
});
|
|
122
|
+
const again = await evaluatePointGate(port, asker, { ...input, now: LATER });
|
|
123
|
+
if (!again.satisfied) throw new Error("expected the gate to pass");
|
|
124
|
+
expect(again.recorded).toBe(false);
|
|
125
|
+
expect(port.resolved).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an answer for one plan does not pass another: the new plan is a new question", async () => {
|
|
129
|
+
const port = memoryGateLedgerPort();
|
|
130
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: true, answeredBy: ["alice"] }]]));
|
|
131
|
+
const passed = await evaluatePointGate(port, asker, { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: PLAN, now: NOW });
|
|
132
|
+
expect(passed.satisfied).toBe(true);
|
|
133
|
+
const moved = await evaluatePointGate(port, asker, { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: OTHER, now: LATER });
|
|
134
|
+
if (moved.satisfied || !("waiting" in moved)) throw new Error("expected the gate to wait on the new plan's question");
|
|
135
|
+
expect(moved.waiting.id).toBe("prod-apply-bbbbbbbbbbbb");
|
|
136
|
+
expect(moved.pending.planDigest).toBe(OTHER);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a table's answer passes the gate, recorded as the decider's", async () => {
|
|
140
|
+
const port = memoryGateLedgerPort();
|
|
141
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: true, decider: "table" }]]));
|
|
142
|
+
const check = await evaluatePointGate(port, asker, { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: PLAN, now: NOW });
|
|
143
|
+
if (!check.satisfied) throw new Error("expected the gate to pass");
|
|
144
|
+
expect(check.resolution).toMatchObject({ resolvedBy: "table", approver: { kind: "agent" }, answer: { decider: "table", answer: true } });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an answer the gate does not pass on is refused, naming the record and how to ask again", async () => {
|
|
148
|
+
const port = memoryGateLedgerPort();
|
|
149
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: false, answeredBy: ["alice"] }]]));
|
|
150
|
+
const check = await evaluatePointGate(port, asker, { op: "prod-apply", gate: "apply", point: { name: "prod-apply" }, planDigest: PLAN, now: NOW });
|
|
151
|
+
if (check.satisfied || !("refused" in check)) throw new Error("expected a refusal");
|
|
152
|
+
expect(check.refused).toContain("prod-apply-aaaaaaaaaaaa");
|
|
153
|
+
expect(check.refused).toContain("chant workspace points retract prod-apply-aaaaaaaaaaaa");
|
|
154
|
+
expect(port.resolved).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("pass names the answers a choice point passes on", async () => {
|
|
158
|
+
const port = memoryGateLedgerPort();
|
|
159
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: "apply", answeredBy: ["alice"] }]]));
|
|
160
|
+
const check = await evaluatePointGate(port, asker, { op: "prod-apply", gate: "apply", point: { name: "prod-route", pass: ["apply"] }, planDigest: PLAN, now: NOW });
|
|
161
|
+
expect(check.satisfied).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("a run reaching a gate that asks a point (#3170)", () => {
|
|
166
|
+
const applied: string[] = [];
|
|
167
|
+
const activities = new Map<string, ActivityFn>([
|
|
168
|
+
["plan", async () => ({ planDigest: PLAN })],
|
|
169
|
+
["apply", async () => {
|
|
170
|
+
applied.push("apply");
|
|
171
|
+
return { ok: true };
|
|
172
|
+
}],
|
|
173
|
+
]);
|
|
174
|
+
const opWith = (gateStep: ReturnType<typeof gate>): OpConfig =>
|
|
175
|
+
(Op({
|
|
176
|
+
name: "prod-apply",
|
|
177
|
+
overview: "Plan, gate and apply.",
|
|
178
|
+
phases: [
|
|
179
|
+
phase("Plan", [activity("plan", {}, { id: "plan" })]),
|
|
180
|
+
phase("Approve", [gateStep]),
|
|
181
|
+
phase("Apply", [activity("apply", {})]),
|
|
182
|
+
],
|
|
183
|
+
}) as unknown as { props: OpConfig }).props;
|
|
184
|
+
|
|
185
|
+
test("an open question ends the run waiting on it, and its answer lets the next run apply", async () => {
|
|
186
|
+
applied.length = 0;
|
|
187
|
+
const port = memoryGateLedgerPort();
|
|
188
|
+
const answers = new Map<string | null, Partial<GatePointQuestion>>();
|
|
189
|
+
const asker = stubAsker(answers);
|
|
190
|
+
const config = opWith(gate("apply", { plan: stepOutput("plan", "planDigest"), point: "prod-apply" }));
|
|
191
|
+
|
|
192
|
+
const first = await runOpLocally(config, activities, {}, undefined, { gates: port, points: asker, now: NOW });
|
|
193
|
+
expect(first.status).toBe("waiting");
|
|
194
|
+
expect(first.point).toMatchObject({ id: "prod-apply-aaaaaaaaaaaa", point: "prod-apply" });
|
|
195
|
+
expect(first.records.find((r) => r.fn === "gate:apply")).toMatchObject({ status: "skipped", point: { id: "prod-apply-aaaaaaaaaaaa" } });
|
|
196
|
+
expect(applied).toEqual([]);
|
|
197
|
+
|
|
198
|
+
answers.set(PLAN, { state: "answered", answer: true, answeredBy: ["alice"] });
|
|
199
|
+
const second = await runOpLocally(config, activities, {}, undefined, { gates: port, points: asker, now: LATER });
|
|
200
|
+
expect(second.status).toBe("ok");
|
|
201
|
+
expect(applied).toEqual(["apply"]);
|
|
202
|
+
const step = second.records.find((r) => r.fn === "gate:apply");
|
|
203
|
+
expect(step?.approval).toMatchObject({ gate: "apply", resolvedBy: "alice", via: "point", answer: { id: "prod-apply-aaaaaaaaaaaa", answer: true } });
|
|
204
|
+
expect(port.resolved[0].answer?.id).toBe("prod-apply-aaaaaaaaaaaa");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a no fails the run at the gate and applies nothing", async () => {
|
|
208
|
+
applied.length = 0;
|
|
209
|
+
const port = memoryGateLedgerPort();
|
|
210
|
+
const asker = stubAsker(new Map([[PLAN, { state: "answered", answer: false, answeredBy: ["alice"] }]]));
|
|
211
|
+
const config = opWith(gate("apply", { plan: stepOutput("plan", "planDigest"), point: "prod-apply" }));
|
|
212
|
+
const failed = await runOpLocally(config, activities, {}, undefined, { gates: port, points: asker, now: NOW }).then(
|
|
213
|
+
() => {
|
|
214
|
+
throw new Error("expected the run to fail");
|
|
215
|
+
},
|
|
216
|
+
(err: { result?: { status: string; records: Array<{ fn: string; status: string; error?: string }> } }) => err.result,
|
|
217
|
+
);
|
|
218
|
+
expect(failed?.status).toBe("fail");
|
|
219
|
+
expect(failed?.records.find((r) => r.fn === "gate:apply")).toMatchObject({ status: "fail" });
|
|
220
|
+
expect(applied).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a plain gate still records a pending fact and ends gated, and never asks a point", async () => {
|
|
224
|
+
applied.length = 0;
|
|
225
|
+
const port = memoryGateLedgerPort();
|
|
226
|
+
const asker = stubAsker(new Map());
|
|
227
|
+
const config = opWith(gate("apply", { plan: stepOutput("plan", "planDigest") }));
|
|
228
|
+
const result = await runOpLocally(config, activities, {}, undefined, { gates: port, points: asker, now: NOW });
|
|
229
|
+
expect(result.status).toBe("gated");
|
|
230
|
+
expect(result.gate).toMatchObject({ gate: "apply", planDigest: PLAN });
|
|
231
|
+
expect(result.gate?.answer).toBeUndefined();
|
|
232
|
+
expect(asker.asked).toHaveLength(0);
|
|
233
|
+
expect(applied).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A gate that asks a declared decision point (#3170): the declaration's
|
|
3
|
+
* shape. What a run does when it reaches one is in `./gate-point-run.ts`.
|
|
4
|
+
*
|
|
5
|
+
* A plain gate is a pair of facts on the gate ledger: the run records a
|
|
6
|
+
* pending fact, and `chant approve` records the resolution. A gate that names
|
|
7
|
+
* a point asks it instead, the way the `decide` activity does (#2740): the
|
|
8
|
+
* point's deciders (a table, a model, people) and its quorum decide, the
|
|
9
|
+
* answer is an answer record in the workspace, and an open question is
|
|
10
|
+
* answered with `chant workspace points answer` or through hud. The run then
|
|
11
|
+
* passes the gate on that answer and writes the resolution itself, citing the
|
|
12
|
+
* record. So the gate ledger and the workspace's decision history both show
|
|
13
|
+
* the approval, and each names the other.
|
|
14
|
+
*
|
|
15
|
+
* This module imports nothing at run time, so the `gate` builder can check a
|
|
16
|
+
* declaration without loading the ledger or the workspace.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The decision point a gate asks (#3170). `gate("apply", { point:
|
|
21
|
+
* "prod-apply" })` is `{ name: "prod-apply" }`.
|
|
22
|
+
*/
|
|
23
|
+
export interface GatePoint {
|
|
24
|
+
/** The point's name, as the workspace's points file declares it. */
|
|
25
|
+
name: string;
|
|
26
|
+
/**
|
|
27
|
+
* Inputs besides the gate's own `gate.*` ones, by the point's input names.
|
|
28
|
+
* A step-output reference resolves when the run reaches the gate. Only
|
|
29
|
+
* inputs the point declares may be given.
|
|
30
|
+
*/
|
|
31
|
+
inputs?: Record<string, unknown>;
|
|
32
|
+
/** The answers that pass the gate. Default `[true]`: a noul point's yes. Any other answer fails the step. */
|
|
33
|
+
pass?: Array<string | boolean>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The answers a gate passes on when it names none: a noul point's yes. */
|
|
37
|
+
export const DEFAULT_GATE_POINT_PASS: ReadonlyArray<string | boolean> = [true];
|
|
38
|
+
|
|
39
|
+
/** A gate's `point` as a {@link GatePoint}, or undefined for a plain gate. */
|
|
40
|
+
export function gatePointOf(point: string | GatePoint | undefined): GatePoint | undefined {
|
|
41
|
+
if (point === undefined) return undefined;
|
|
42
|
+
return typeof point === "string" ? { name: point } : point;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const POINT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
46
|
+
|
|
47
|
+
/** What is wrong with a gate's `point`, empty when nothing is. `withApproval` is whether the gate also declares `approval`. */
|
|
48
|
+
export function gatePointProblems(point: unknown, withApproval = false): string[] {
|
|
49
|
+
const problems: string[] = [];
|
|
50
|
+
if (withApproval) problems.push("a gate that asks a decision point takes its quorum from the point: give `approval` or `point`, not both");
|
|
51
|
+
const p = typeof point === "string" ? { name: point } : point;
|
|
52
|
+
if (p === null || typeof p !== "object" || Array.isArray(p)) return [...problems, "`point` is a decision point's name or { name, inputs?, pass? }"];
|
|
53
|
+
const { name, inputs, pass, ...rest } = p as Record<string, unknown>;
|
|
54
|
+
if (typeof name !== "string" || !POINT_NAME.test(name)) problems.push("`point.name` is a decision point's name: lower case letters, digits and hyphens, starting with a letter");
|
|
55
|
+
if (inputs !== undefined && (inputs === null || typeof inputs !== "object" || Array.isArray(inputs))) problems.push("`point.inputs` is an object of input name to value");
|
|
56
|
+
if (inputs && typeof inputs === "object") {
|
|
57
|
+
const own = Object.keys(inputs).filter((k) => k in GATE_POINT_INPUTS);
|
|
58
|
+
if (own.length > 0) problems.push(`\`point.inputs\` gives ${own.join(", ")}, which the gate passes itself`);
|
|
59
|
+
}
|
|
60
|
+
if (pass !== undefined && (!Array.isArray(pass) || pass.length === 0 || pass.some((a) => typeof a !== "string" && typeof a !== "boolean"))) {
|
|
61
|
+
problems.push("`point.pass` is a non-empty list of the answers that pass the gate");
|
|
62
|
+
}
|
|
63
|
+
const extra = Object.keys(rest);
|
|
64
|
+
if (extra.length > 0) problems.push(`\`point\` takes name, inputs and pass, not ${extra.join(", ")}`);
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the run knows about the gate it reached: the fields the `status` read
|
|
70
|
+
* contract lists a gate with, which a point reads as its `gate.*` inputs.
|
|
71
|
+
*/
|
|
72
|
+
export interface GatePointFacts {
|
|
73
|
+
/** The op the gate is recorded under. */
|
|
74
|
+
component: string;
|
|
75
|
+
/** The gate's name. */
|
|
76
|
+
name: string;
|
|
77
|
+
/** The environment the run was started for, or null. */
|
|
78
|
+
env: string | null;
|
|
79
|
+
/** The plan the gate binds, or null when it binds none. */
|
|
80
|
+
planDigest: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `gate.*` inputs a gate passes to its point, each from a {@link GatePointFacts} field. */
|
|
84
|
+
export const GATE_POINT_INPUTS: Readonly<Record<string, keyof GatePointFacts>> = {
|
|
85
|
+
"gate.component": "component",
|
|
86
|
+
"gate.name": "name",
|
|
87
|
+
"gate.env": "env",
|
|
88
|
+
"gate.planDigest": "planDigest",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The inputs a gate asks its point with: the `gate.*` inputs the point
|
|
93
|
+
* declares, from `facts`, and the authored ones. A gate that binds a plan
|
|
94
|
+
* asks a point that declares `gate.planDigest`, so each plan is its own
|
|
95
|
+
* question and an answer for one plan never passes another: it throws
|
|
96
|
+
* otherwise, naming the input to declare.
|
|
97
|
+
*/
|
|
98
|
+
export function gatePointInputs(point: string, declared: readonly string[], facts: GatePointFacts, authored: Record<string, unknown> = {}): Record<string, unknown> {
|
|
99
|
+
if (facts.planDigest !== null && !declared.includes("gate.planDigest")) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`gate "${facts.name}" binds a plan and asks decision point ${point}, which does not declare the input gate.planDigest: ` +
|
|
102
|
+
`declare it in the points file, so each plan is its own question and an answer for one plan never passes another`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const inputs: Record<string, unknown> = {};
|
|
106
|
+
for (const [input, field] of Object.entries(GATE_POINT_INPUTS)) {
|
|
107
|
+
if (declared.includes(input) && facts[field] !== null) inputs[input] = facts[field];
|
|
108
|
+
}
|
|
109
|
+
return { ...authored, ...inputs };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What an answer record's `constrains` names a gate by: `gate:<op>/<gate>`,
|
|
114
|
+
* with `@<env>` when the run names an environment. It is how the record
|
|
115
|
+
* names the gate ledger entry that cites it.
|
|
116
|
+
*/
|
|
117
|
+
export function gateSubject(op: string, gate: string, env?: string | null): string {
|
|
118
|
+
return `gate:${op}/${gate}${env ? `@${env}` : ""}`;
|
|
119
|
+
}
|
package/src/op/gate.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
|
+
appendGateResolution,
|
|
36
37
|
appendPendingGate,
|
|
37
38
|
isPendingGateExpired,
|
|
38
39
|
latestPendingGate,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
readGateLedger,
|
|
41
42
|
resolveApprovalUrl,
|
|
42
43
|
DEFAULT_GATE_EXPIRY,
|
|
44
|
+
type GateResolutionInput,
|
|
43
45
|
type GateResolutionRecord,
|
|
44
46
|
type PendingGateInput,
|
|
45
47
|
type PendingGateRecord,
|
|
@@ -74,6 +76,12 @@ export interface PendingGatePush {
|
|
|
74
76
|
export interface GateLedgerPort {
|
|
75
77
|
read(op: string): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }>;
|
|
76
78
|
appendPending(input: PendingGateInput): Promise<PendingGatePush>;
|
|
79
|
+
/**
|
|
80
|
+
* Append a resolution the run itself writes (#3170): a gate that asks a
|
|
81
|
+
* decision point records the answer that passed it. Absent on a port that
|
|
82
|
+
* only plain gates use.
|
|
83
|
+
*/
|
|
84
|
+
appendResolution?(input: GateResolutionInput): Promise<{ record: GateResolutionRecord; pushed: boolean; pushWarning?: string }>;
|
|
77
85
|
/**
|
|
78
86
|
* The rule a workspace sets for approvals of `gate` (#3163, ws-080), or
|
|
79
87
|
* null when none applies: the declaration's `identity.gates`, read at base.
|
|
@@ -120,6 +128,15 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
120
128
|
const { gateAdmission } = await import("../workspace/identity");
|
|
121
129
|
return gateAdmission(opts?.cwd ?? process.cwd(), gate);
|
|
122
130
|
},
|
|
131
|
+
async appendResolution(input) {
|
|
132
|
+
const { record } = await appendGateResolution(input, opts);
|
|
133
|
+
try {
|
|
134
|
+
const pushed = await pushLifecycle(opts);
|
|
135
|
+
return pushed ? { record, pushed } : { record, pushed, pushWarning: "no remote is configured for chant/lifecycle — the resolution was recorded locally only" };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { record, pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
|
|
138
|
+
}
|
|
139
|
+
},
|
|
123
140
|
async appendPending(input) {
|
|
124
141
|
const { record } = await appendPendingGate(input, opts);
|
|
125
142
|
try {
|
|
@@ -146,15 +163,23 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
146
163
|
/** A gate ledger held in memory — what a test, or a caller that has already read the ledger, hands {@link evaluateGate}. */
|
|
147
164
|
export function memoryGateLedgerPort(
|
|
148
165
|
seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[] } = {},
|
|
149
|
-
): GateLedgerPort & { appended: PendingGateRecord[] } {
|
|
166
|
+
): GateLedgerPort & { appended: PendingGateRecord[]; resolved: GateResolutionRecord[] } {
|
|
150
167
|
const resolutions = [...(seed.resolutions ?? [])];
|
|
151
168
|
const pending = [...(seed.pending ?? [])];
|
|
152
169
|
const appended: PendingGateRecord[] = [];
|
|
170
|
+
const resolved: GateResolutionRecord[] = [];
|
|
153
171
|
return {
|
|
154
172
|
appended,
|
|
173
|
+
resolved,
|
|
155
174
|
async read() {
|
|
156
175
|
return { resolutions: [...resolutions], pending: [...pending] };
|
|
157
176
|
},
|
|
177
|
+
async appendResolution(input) {
|
|
178
|
+
const record: GateResolutionRecord = { version: 1, ...input };
|
|
179
|
+
resolutions.push(record);
|
|
180
|
+
resolved.push(record);
|
|
181
|
+
return { record, pushed: true };
|
|
182
|
+
},
|
|
158
183
|
async appendPending(input) {
|
|
159
184
|
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
160
185
|
pending.push(record);
|
package/src/op/index.ts
CHANGED
|
@@ -72,11 +72,19 @@ export type {
|
|
|
72
72
|
GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
|
|
73
73
|
GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
|
|
74
74
|
} from "./gate-approval";
|
|
75
|
+
export {
|
|
76
|
+
gatePlanSummary, gatePlanSummaryOfResult, changeSetOfResult, GATE_PLAN_CONTEXT_KEY, GATE_PLAN_ADDRESS_LIMIT,
|
|
77
|
+
} from "./gate-plan-context";
|
|
78
|
+
export type { GatePlanSummary } from "./gate-plan-context";
|
|
75
79
|
export {
|
|
76
80
|
computePlanDigest, isPlanDigest, describePlanDigest, samePlanDigest, PLAN_DIGEST_ALGORITHM, PLAN_DIGEST_PREFIX,
|
|
77
81
|
} from "../lifecycle/plan-digest";
|
|
78
82
|
export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
|
|
79
83
|
export type { GateNamed } from "./gate-name";
|
|
84
|
+
export { gatePointOf, gatePointProblems, gatePointInputs, gateSubject, GATE_POINT_INPUTS, DEFAULT_GATE_POINT_PASS } from "./gate-point";
|
|
85
|
+
export type { GatePoint, GatePointFacts } from "./gate-point";
|
|
86
|
+
export { evaluatePointGate, workspaceGatePointAsker } from "./gate-point-run";
|
|
87
|
+
export type { GatePointAsker, GatePointQuestion, GatePointRequest, PointGateCheck, PointGateInput } from "./gate-point-run";
|
|
80
88
|
export { createLocalOpRuntime } from "./runtimes/local";
|
|
81
89
|
export { runStateOf } from "./runtime";
|
|
82
90
|
export type {
|
package/src/op/local-executor.ts
CHANGED
|
@@ -28,9 +28,12 @@ import { parseDuration } from "./duration";
|
|
|
28
28
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
29
29
|
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
30
30
|
import { gateName } from "./gate-name";
|
|
31
|
+
import { gatePointOf } from "./gate-point";
|
|
32
|
+
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
31
33
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
34
|
+
import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
|
|
32
35
|
import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
|
|
33
|
-
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
36
|
+
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
34
37
|
import { isPointWait, type WaitingPoint } from "./steward-points";
|
|
35
38
|
import { isGateWait } from "./gate-wait";
|
|
36
39
|
import { currentStewardTurn, enterStewardTurn } from "./steward-turn";
|
|
@@ -72,10 +75,12 @@ export interface StepRecord {
|
|
|
72
75
|
resolvedBy: string;
|
|
73
76
|
timestamp: string;
|
|
74
77
|
url?: string;
|
|
75
|
-
/** On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. */
|
|
76
|
-
via?: "quorum" | "policy";
|
|
78
|
+
/** On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. `"point"` on a gate that asks a decision point (#3170). */
|
|
79
|
+
via?: "quorum" | "policy" | "point";
|
|
77
80
|
/** On a gate with a quorum (#2508): every approver who counted toward it. */
|
|
78
81
|
approvers?: string[];
|
|
82
|
+
/** On a gate that asks a decision point (#3170): the answer record that passed it, which the gate ledger's resolution cites too. */
|
|
83
|
+
answer?: GateAnswerRef;
|
|
79
84
|
};
|
|
80
85
|
/**
|
|
81
86
|
* Why a step declined to proceed on something that is not a failure
|
|
@@ -489,6 +494,10 @@ interface GateContext {
|
|
|
489
494
|
passed?: PassedGate[];
|
|
490
495
|
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
491
496
|
work?: RunWorkLease;
|
|
497
|
+
/** How a gate that names a decision point asks it (#3170). */
|
|
498
|
+
points?: GatePointAsker;
|
|
499
|
+
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
500
|
+
env?: string;
|
|
492
501
|
}
|
|
493
502
|
|
|
494
503
|
/**
|
|
@@ -584,6 +593,13 @@ function resolveGateApproval(
|
|
|
584
593
|
if (resolved !== undefined && resolved !== null) context[key] = resolved;
|
|
585
594
|
}
|
|
586
595
|
}
|
|
596
|
+
// #3182: a policy also sees what the plan does. When the gate's `plan`
|
|
597
|
+
// names the step that planned it and that step returned a change set, its
|
|
598
|
+
// summary lands under `context.plan` with no context code in the Op.
|
|
599
|
+
if (authored.policy && isStepOutputRef(step.plan)) {
|
|
600
|
+
const summary = gatePlanSummaryOfResult(resultsById.get(step.plan.step));
|
|
601
|
+
if (summary) context = { ...(context ?? {}), [GATE_PLAN_CONTEXT_KEY]: summary };
|
|
602
|
+
}
|
|
587
603
|
return {
|
|
588
604
|
...(authored.quorum ? { quorum: authored.quorum } : {}),
|
|
589
605
|
...(authored.policy ? { policy: authored.policy } : {}),
|
|
@@ -604,6 +620,7 @@ async function runGateStep(
|
|
|
604
620
|
gates: GateContext,
|
|
605
621
|
resultsById: ReadonlyMap<string, unknown> = new Map(),
|
|
606
622
|
): Promise<{ record: StepRecord; pending?: PendingGateRecord; pushed?: boolean; pushWarning?: string }> {
|
|
623
|
+
if (step.point !== undefined) return runPointGateStep(step, phaseName, gates, resultsById);
|
|
607
624
|
const start = Date.now();
|
|
608
625
|
// #2300: `plan` is authored as a reference into the Plan phase's result
|
|
609
626
|
// (`plan.out.planDigest`), and is resolved here through the same walk an
|
|
@@ -689,6 +706,57 @@ async function runGateStep(
|
|
|
689
706
|
};
|
|
690
707
|
}
|
|
691
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Decide a gate that asks a decision point (#3170, `./gate-point-run.ts`).
|
|
711
|
+
* An answer it passes on lands on the step record as the approval, citing
|
|
712
|
+
* the answer record; an open question puts the question on the record, as an
|
|
713
|
+
* activity's open point does, and the caller ends the run `waiting`; any
|
|
714
|
+
* other answer fails the step.
|
|
715
|
+
*/
|
|
716
|
+
async function runPointGateStep(
|
|
717
|
+
step: GateStep,
|
|
718
|
+
phaseName: string,
|
|
719
|
+
gates: GateContext,
|
|
720
|
+
resultsById: ReadonlyMap<string, unknown>,
|
|
721
|
+
): Promise<{ record: StepRecord; pushed?: boolean; pushWarning?: string }> {
|
|
722
|
+
const start = Date.now();
|
|
723
|
+
const point = gatePointOf(step.point)!;
|
|
724
|
+
const base = { phase: phaseName, fn: gateFn(step), args: {} };
|
|
725
|
+
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
726
|
+
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
727
|
+
const inputs = point.inputs ? (resolveStepOutputRefs(point.inputs, resultsById) as Record<string, unknown>) : undefined;
|
|
728
|
+
try {
|
|
729
|
+
const check = await evaluatePointGate(gates.port, gates.points ?? workspaceGatePointAsker(process.cwd()), {
|
|
730
|
+
op: gates.op,
|
|
731
|
+
gate: gateName(step),
|
|
732
|
+
point,
|
|
733
|
+
...(inputs ? { inputs } : {}),
|
|
734
|
+
...(step.description ? { description: step.description } : {}),
|
|
735
|
+
...(step.timeout ? { timeout: step.timeout } : {}),
|
|
736
|
+
...(gates.runId ? { runId: gates.runId } : {}),
|
|
737
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
738
|
+
...(gates.env !== undefined ? { env: gates.env } : {}),
|
|
739
|
+
...(gates.now ? { now: gates.now } : {}),
|
|
740
|
+
});
|
|
741
|
+
if (check.satisfied) {
|
|
742
|
+
const approval: NonNullable<StepRecord["approval"]> = {
|
|
743
|
+
gate: gateName(step),
|
|
744
|
+
resolvedBy: check.resolution.resolvedBy,
|
|
745
|
+
timestamp: check.resolution.timestamp,
|
|
746
|
+
via: "point",
|
|
747
|
+
...(check.answer.answeredBy ? { approvers: check.answer.answeredBy } : {}),
|
|
748
|
+
answer: check.answer,
|
|
749
|
+
};
|
|
750
|
+
gates.passed?.push({ gate: gateName(step), approval });
|
|
751
|
+
return { record: { ...base, status: "ok", durationMs: Date.now() - start, approval }, pushed: check.pushed, ...(check.pushWarning ? { pushWarning: check.pushWarning } : {}) };
|
|
752
|
+
}
|
|
753
|
+
if ("refused" in check) return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: check.refused } };
|
|
754
|
+
return { record: { ...base, status: "skipped", durationMs: Date.now() - start, point: check.waiting }, pushed: check.pushed, ...(check.pushWarning ? { pushWarning: check.pushWarning } : {}) };
|
|
755
|
+
} catch (err) {
|
|
756
|
+
return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(err) } };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
692
760
|
/**
|
|
693
761
|
* Run one effect step: read-compare-run-write. On a match the nested steps are
|
|
694
762
|
* recorded as skipped ("effect already applied") and nothing is written. On a
|
|
@@ -778,6 +846,11 @@ async function runEffectStep(
|
|
|
778
846
|
skipRest(i + 1);
|
|
779
847
|
return { records, failed: false, pending, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
780
848
|
}
|
|
849
|
+
if (record.point) {
|
|
850
|
+
// A gate's open decision point (#3170): as for an activity's.
|
|
851
|
+
skipRest(i + 1);
|
|
852
|
+
return { records, failed: false, point: record.point };
|
|
853
|
+
}
|
|
781
854
|
continue;
|
|
782
855
|
}
|
|
783
856
|
const ran = await runStep(nested, phaseName, activities, profiles, resultsById, signal);
|
|
@@ -866,6 +939,13 @@ async function runPhase(
|
|
|
866
939
|
}
|
|
867
940
|
throw new GateStop(gateRecords, pending, phase.name, pushed, pushWarning);
|
|
868
941
|
}
|
|
942
|
+
if (record.point) {
|
|
943
|
+
// A gate's open decision point (#3170): nothing fans out.
|
|
944
|
+
for (const skipped of phase.steps.filter(isActivity)) {
|
|
945
|
+
pushRecord(gateRecords, gates, skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
946
|
+
}
|
|
947
|
+
throw new PointStop(gateRecords, record.point, phase.name);
|
|
948
|
+
}
|
|
869
949
|
}
|
|
870
950
|
const steps = phase.steps.filter(isActivity);
|
|
871
951
|
const settled = await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)));
|
|
@@ -921,6 +1001,12 @@ async function runPhase(
|
|
|
921
1001
|
skipRemaining(i + 1);
|
|
922
1002
|
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
923
1003
|
}
|
|
1004
|
+
if (record.point) {
|
|
1005
|
+
// A gate that asks a decision point (#3170) and found it open: the
|
|
1006
|
+
// run waits on the question, as at an activity's open point.
|
|
1007
|
+
skipRemaining(i + 1);
|
|
1008
|
+
throw new PointStop(records, record.point, phase.name);
|
|
1009
|
+
}
|
|
924
1010
|
continue;
|
|
925
1011
|
}
|
|
926
1012
|
if (isEffect(step)) {
|
|
@@ -980,6 +1066,11 @@ export interface RunOpOptions {
|
|
|
980
1066
|
now?: string;
|
|
981
1067
|
/** Identifies this run on any pending fact it records. */
|
|
982
1068
|
runId?: string;
|
|
1069
|
+
/**
|
|
1070
|
+
* How a gate that names a decision point asks it (#3170). Defaults to the
|
|
1071
|
+
* workspace holding `cwd` (`workspaceGatePointAsker`); a test passes a stub.
|
|
1072
|
+
*/
|
|
1073
|
+
points?: GatePointAsker;
|
|
983
1074
|
/**
|
|
984
1075
|
* The environment the run was started for (`--env`, #2522). The executor
|
|
985
1076
|
* only hands it to activities through the run context (`./run-context.ts`).
|
|
@@ -1102,6 +1193,8 @@ async function runOpInTurn(
|
|
|
1102
1193
|
runId,
|
|
1103
1194
|
...(options.onRecord ? { onRecord: options.onRecord } : {}),
|
|
1104
1195
|
passed: run.passedGates,
|
|
1196
|
+
points: options.points ?? workspaceGatePointAsker(options.cwd ?? process.cwd()),
|
|
1197
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
1105
1198
|
};
|
|
1106
1199
|
|
|
1107
1200
|
// Effect steps are ordered (read-compare-run-write): refuse them in a
|
package/src/op/op-ir.ts
CHANGED
|
@@ -149,6 +149,8 @@ export interface OpIRGateStep {
|
|
|
149
149
|
plan?: GateStep["plan"];
|
|
150
150
|
/** Quorum, roles and policy (#2508), carried as authored. Absent on a gate that passes on one approval. */
|
|
151
151
|
approval?: GateStep["approval"];
|
|
152
|
+
/** The decision point the gate asks (#3170), carried as authored. Absent on a gate that `chant approve` passes. */
|
|
153
|
+
point?: GateStep["point"];
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
export interface OpIREffectStep {
|
|
@@ -258,6 +260,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
258
260
|
...(step.description ? { description: step.description } : {}),
|
|
259
261
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
260
262
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
263
|
+
...(step.point !== undefined ? { point: step.point } : {}),
|
|
261
264
|
};
|
|
262
265
|
}
|
|
263
266
|
|
|
@@ -412,6 +415,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
412
415
|
...(step.description ? { description: step.description } : {}),
|
|
413
416
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
414
417
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
418
|
+
...(step.point !== undefined ? { point: step.point } : {}),
|
|
415
419
|
};
|
|
416
420
|
}
|
|
417
421
|
return {
|