@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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan summary a gate's policy reads (#3182).
|
|
3
|
+
*
|
|
4
|
+
* A gate's Cedar request used to carry `planDigest` and whatever the Op put
|
|
5
|
+
* in `approval.context` by hand, so a rule like "deny an agent a plan that
|
|
6
|
+
* deletes a database" needed every Op to compute and pass the plan's deletes
|
|
7
|
+
* itself. A gate whose `plan` names the step that planned the change now gets
|
|
8
|
+
* `context.plan` from that step's change-set document (#3181), with no code in
|
|
9
|
+
* the Op:
|
|
10
|
+
*
|
|
11
|
+
* - `terraformPlan` returns `changeSet`, one member's part;
|
|
12
|
+
* - `lifecyclePlanChangeSet` and `readChangeSetPart` return `part`;
|
|
13
|
+
* - `composeChangeSet` returns `document`, the whole run's change set.
|
|
14
|
+
*
|
|
15
|
+
* {@link gatePlanSummary} is the summary, shaped for Cedar: flat counts
|
|
16
|
+
* (`no-op` is not a Cedar identifier), sets of names, and no nulls or floats.
|
|
17
|
+
* The cedar lexicon's `GATE_CEDAR_SCHEMA` declares it as `Chant::PlanSummary`,
|
|
18
|
+
* and its policy pack is written against it.
|
|
19
|
+
*
|
|
20
|
+
* The summary rides on the pending fact with the rest of the resolved context,
|
|
21
|
+
* so `chant approve` evaluates the policy against the plan the run produced.
|
|
22
|
+
* It comes from the same step result as the digest the gate binds, and a new
|
|
23
|
+
* plan is a new digest, so an approval never stands on a summary of another
|
|
24
|
+
* plan.
|
|
25
|
+
*
|
|
26
|
+
* Imports types only, so the module bundles without TypeScript (#3421).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ChangeSetEntry, ChangeSetMember, ChangeSetPart, ChangeSetDocument } from "../change-set";
|
|
30
|
+
|
|
31
|
+
/** The `approval.context` key the summary lands under. An authored context may not use it. */
|
|
32
|
+
export const GATE_PLAN_CONTEXT_KEY = "plan";
|
|
33
|
+
|
|
34
|
+
/** How many addresses each address set carries before it is cut and {@link GatePlanSummary.truncated} is set. */
|
|
35
|
+
export const GATE_PLAN_ADDRESS_LIMIT = 500;
|
|
36
|
+
|
|
37
|
+
/** The attributes that hold a resource's tags or labels, read in this order. */
|
|
38
|
+
const TAG_ATTRIBUTES = ["tags", "tags_all", "labels"] as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `context.plan` on a gate's Cedar request. Counts are over entries; type,
|
|
42
|
+
* address, region, scope and member sets are over the changes only (create,
|
|
43
|
+
* update, replace and delete), so a resource the plan only reads or leaves
|
|
44
|
+
* alone does not count as touched.
|
|
45
|
+
*/
|
|
46
|
+
export interface GatePlanSummary {
|
|
47
|
+
/** Every member the change set names. */
|
|
48
|
+
members: string[];
|
|
49
|
+
/** Members with at least one change. */
|
|
50
|
+
membersChanged: string[];
|
|
51
|
+
/** Members that failed to plan. A plan with a failed member says nothing about it. */
|
|
52
|
+
failedMembers: string[];
|
|
53
|
+
/** How many members failed to plan. */
|
|
54
|
+
failed: number;
|
|
55
|
+
/** Resources a planner could not read. */
|
|
56
|
+
holes: number;
|
|
57
|
+
entries: number;
|
|
58
|
+
/** creates + updates + replaces + deletes. */
|
|
59
|
+
changes: number;
|
|
60
|
+
creates: number;
|
|
61
|
+
updates: number;
|
|
62
|
+
replaces: number;
|
|
63
|
+
deletes: number;
|
|
64
|
+
reads: number;
|
|
65
|
+
noOps: number;
|
|
66
|
+
forgets: number;
|
|
67
|
+
/** Types with a change. */
|
|
68
|
+
types: string[];
|
|
69
|
+
createdTypes: string[];
|
|
70
|
+
updatedTypes: string[];
|
|
71
|
+
replacedTypes: string[];
|
|
72
|
+
deletedTypes: string[];
|
|
73
|
+
/** Addresses deleted, as each planner writes them. */
|
|
74
|
+
deleted: string[];
|
|
75
|
+
/** Addresses replaced. */
|
|
76
|
+
replaced: string[];
|
|
77
|
+
/** Regions a change lands in, where the planner reports one. */
|
|
78
|
+
regions: string[];
|
|
79
|
+
/** Estates, environments or orgs a change lands in. */
|
|
80
|
+
scopes: string[];
|
|
81
|
+
lexicons: string[];
|
|
82
|
+
/** Creates that carry tags or labels. */
|
|
83
|
+
taggedCreates: number;
|
|
84
|
+
/** Creates that carry no tags or labels attribute at all: a type with no tags, or tags left unset. */
|
|
85
|
+
untaggedCreates: number;
|
|
86
|
+
/**
|
|
87
|
+
* The tag keys every tagged create carries: the intersection over
|
|
88
|
+
* {@link taggedCreates}. A required-tags rule checks this contains its keys.
|
|
89
|
+
*/
|
|
90
|
+
createTagKeys: string[];
|
|
91
|
+
/** At least one change, and every change an update of tags or labels only. */
|
|
92
|
+
tagOnly: boolean;
|
|
93
|
+
/** An address set was cut at {@link GATE_PLAN_ADDRESS_LIMIT}. The counts are whole. */
|
|
94
|
+
truncated: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type Json = Record<string, unknown>;
|
|
98
|
+
const isObject = (v: unknown): v is Json => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
99
|
+
|
|
100
|
+
function isMember(v: unknown): v is ChangeSetMember {
|
|
101
|
+
return isObject(v) && typeof v.member === "string";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isPart(v: unknown): v is ChangeSetPart {
|
|
105
|
+
return isObject(v) && isMember(v.member) && Array.isArray(v.entries);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isDocument(v: unknown): v is ChangeSetDocument {
|
|
109
|
+
return isObject(v) && Array.isArray(v.members) && v.members.every(isMember) && Array.isArray(v.entries);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The members and entries a step result carries, when it carries a change set. */
|
|
113
|
+
export function changeSetOfResult(result: unknown): { members: ChangeSetMember[]; entries: ChangeSetEntry[] } | undefined {
|
|
114
|
+
if (isDocument(result)) return result;
|
|
115
|
+
if (isPart(result)) return { members: [result.member], entries: result.entries };
|
|
116
|
+
if (!isObject(result)) return undefined;
|
|
117
|
+
if (isDocument(result.document)) return result.document;
|
|
118
|
+
for (const key of ["changeSet", "part"] as const) {
|
|
119
|
+
const part = result[key];
|
|
120
|
+
if (isPart(part)) return { members: [part.member], entries: part.entries };
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const CHANGES = new Set(["create", "update", "replace", "delete"]);
|
|
126
|
+
|
|
127
|
+
function tagKeysOf(entry: ChangeSetEntry): string[] | undefined {
|
|
128
|
+
let keys: Set<string> | undefined;
|
|
129
|
+
for (const name of TAG_ATTRIBUTES) {
|
|
130
|
+
const attr = entry.attributes.find((a) => a.path === name);
|
|
131
|
+
if (!attr) continue;
|
|
132
|
+
keys ??= new Set();
|
|
133
|
+
if (isObject(attr.after)) for (const k of Object.keys(attr.after)) keys.add(k);
|
|
134
|
+
}
|
|
135
|
+
return keys ? [...keys] : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isTagOnlyUpdate(entry: ChangeSetEntry): boolean {
|
|
139
|
+
return (
|
|
140
|
+
entry.action === "update" &&
|
|
141
|
+
entry.attributes.length > 0 &&
|
|
142
|
+
entry.attributes.every((a) => (TAG_ATTRIBUTES as readonly string[]).includes(a.path))
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sorted = (s: Set<string>): string[] => [...s].sort();
|
|
147
|
+
|
|
148
|
+
/** The plan summary of a change set's members and entries. */
|
|
149
|
+
export function gatePlanSummary(changeSet: { members: ReadonlyArray<ChangeSetMember>; entries: ReadonlyArray<ChangeSetEntry> }): GatePlanSummary {
|
|
150
|
+
const count = { create: 0, update: 0, replace: 0, delete: 0, read: 0, "no-op": 0, forget: 0 };
|
|
151
|
+
const typesBy = { create: new Set<string>(), update: new Set<string>(), replace: new Set<string>(), delete: new Set<string>() };
|
|
152
|
+
const types = new Set<string>();
|
|
153
|
+
const membersChanged = new Set<string>();
|
|
154
|
+
const regions = new Set<string>();
|
|
155
|
+
const scopes = new Set<string>();
|
|
156
|
+
const lexicons = new Set<string>();
|
|
157
|
+
const deleted: string[] = [];
|
|
158
|
+
const replaced: string[] = [];
|
|
159
|
+
let taggedCreates = 0;
|
|
160
|
+
let untaggedCreates = 0;
|
|
161
|
+
let createTagKeys: Set<string> | undefined;
|
|
162
|
+
let tagOnly = true;
|
|
163
|
+
|
|
164
|
+
for (const e of changeSet.entries) {
|
|
165
|
+
if (e.action in count) count[e.action]++;
|
|
166
|
+
if (!CHANGES.has(e.action)) continue;
|
|
167
|
+
const action = e.action as keyof typeof typesBy;
|
|
168
|
+
typesBy[action].add(e.type);
|
|
169
|
+
types.add(e.type);
|
|
170
|
+
membersChanged.add(e.member);
|
|
171
|
+
lexicons.add(e.lexicon);
|
|
172
|
+
if (e.region) regions.add(e.region);
|
|
173
|
+
if (e.scope) scopes.add(e.scope);
|
|
174
|
+
if (action === "delete") deleted.push(e.address);
|
|
175
|
+
if (action === "replace") replaced.push(e.address);
|
|
176
|
+
if (!isTagOnlyUpdate(e)) tagOnly = false;
|
|
177
|
+
if (action === "create") {
|
|
178
|
+
const keys = tagKeysOf(e);
|
|
179
|
+
if (keys === undefined) {
|
|
180
|
+
untaggedCreates++;
|
|
181
|
+
} else {
|
|
182
|
+
taggedCreates++;
|
|
183
|
+
createTagKeys = createTagKeys === undefined ? new Set(keys) : new Set(keys.filter((k) => createTagKeys!.has(k)));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const failedMembers = new Set(changeSet.members.filter((m) => m.status === "failed").map((m) => m.member));
|
|
189
|
+
const changes = count.create + count.update + count.replace + count.delete;
|
|
190
|
+
const cap = (list: string[]) => [...new Set(list)].sort().slice(0, GATE_PLAN_ADDRESS_LIMIT);
|
|
191
|
+
const truncated = new Set(deleted).size > GATE_PLAN_ADDRESS_LIMIT || new Set(replaced).size > GATE_PLAN_ADDRESS_LIMIT;
|
|
192
|
+
return {
|
|
193
|
+
members: sorted(new Set(changeSet.members.map((m) => m.member))),
|
|
194
|
+
membersChanged: sorted(membersChanged),
|
|
195
|
+
failedMembers: sorted(failedMembers),
|
|
196
|
+
failed: failedMembers.size,
|
|
197
|
+
holes: changeSet.members.reduce((n, m) => n + (Array.isArray(m.holes) ? m.holes.length : 0), 0),
|
|
198
|
+
entries: changeSet.entries.length,
|
|
199
|
+
changes,
|
|
200
|
+
creates: count.create,
|
|
201
|
+
updates: count.update,
|
|
202
|
+
replaces: count.replace,
|
|
203
|
+
deletes: count.delete,
|
|
204
|
+
reads: count.read,
|
|
205
|
+
noOps: count["no-op"],
|
|
206
|
+
forgets: count.forget,
|
|
207
|
+
types: sorted(types),
|
|
208
|
+
createdTypes: sorted(typesBy.create),
|
|
209
|
+
updatedTypes: sorted(typesBy.update),
|
|
210
|
+
replacedTypes: sorted(typesBy.replace),
|
|
211
|
+
deletedTypes: sorted(typesBy.delete),
|
|
212
|
+
deleted: cap(deleted),
|
|
213
|
+
replaced: cap(replaced),
|
|
214
|
+
regions: sorted(regions),
|
|
215
|
+
scopes: sorted(scopes),
|
|
216
|
+
lexicons: sorted(lexicons),
|
|
217
|
+
taggedCreates,
|
|
218
|
+
untaggedCreates,
|
|
219
|
+
createTagKeys: createTagKeys ? sorted(createTagKeys) : [],
|
|
220
|
+
tagOnly: changes > 0 && tagOnly,
|
|
221
|
+
truncated,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The summary of a step result, when the result carries a change set. */
|
|
226
|
+
export function gatePlanSummaryOfResult(result: unknown): GatePlanSummary | undefined {
|
|
227
|
+
const changeSet = changeSetOfResult(result);
|
|
228
|
+
return changeSet ? gatePlanSummary(changeSet) : undefined;
|
|
229
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching a gate that asks a declared decision point (#3170). The shape of
|
|
3
|
+
* the declaration is in `./gate-point.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The run asks the point with the gate's own `gate.*` inputs (those the point
|
|
6
|
+
* declares) and the authored ones, through the `decide` activity's path
|
|
7
|
+
* (`./activities/decide.ts`), so the chain, the model's threshold, the reuse
|
|
8
|
+
* of an answer the same inputs already have and the escalation to people are
|
|
9
|
+
* the decide activity's. Then:
|
|
10
|
+
*
|
|
11
|
+
* - The question is open (escalated to people, or a model's proposal waiting
|
|
12
|
+
* for a person): the run records a pending fact on the gate ledger that
|
|
13
|
+
* cites the question, as a plain gate records one, and ends `waiting` on
|
|
14
|
+
* the question. A person answers it with `chant workspace points answer
|
|
15
|
+
* <id>` (or through hud), and the next run, or the local operator's next
|
|
16
|
+
* round, asks again and finds the answer.
|
|
17
|
+
* - It is answered with an answer the gate passes on (`pass`, default yes):
|
|
18
|
+
* the run appends a resolution to the gate ledger that cites the answer
|
|
19
|
+
* record, once per answer and plan, and walks through.
|
|
20
|
+
* - It is answered otherwise: the gate step fails, naming the record. `points
|
|
21
|
+
* retract` takes the answer back and asks people again.
|
|
22
|
+
*
|
|
23
|
+
* `chant approve` does not pass such a gate: only the point's answer does.
|
|
24
|
+
* The answer record names the gate in its `constrains` (`gate:<op>/<gate>`)
|
|
25
|
+
* and, when the point declares them, in its `gate.*` inputs.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { isPendingGateExpired, latestPendingGate, DEFAULT_GATE_EXPIRY, resolveApprovalUrl, type GateAnswerRef, type GateResolutionRecord, type PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
29
|
+
import { samePlanDigest } from "../lifecycle/plan-digest";
|
|
30
|
+
import { parseDuration } from "./duration";
|
|
31
|
+
import type { GateLedgerPort } from "./gate";
|
|
32
|
+
import { DEFAULT_GATE_POINT_PASS, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts } from "./gate-point";
|
|
33
|
+
import { isPointWait, type WaitingPoint } from "./steward-points";
|
|
34
|
+
|
|
35
|
+
/** The question a gate's point was asked, as the gate reads it. */
|
|
36
|
+
export interface GatePointQuestion {
|
|
37
|
+
/** The answer record's id: what `points answer` takes. */
|
|
38
|
+
id: string;
|
|
39
|
+
point: string;
|
|
40
|
+
/** The record, from the repository root. */
|
|
41
|
+
path: string;
|
|
42
|
+
state: "escalated" | "proposed" | "answered";
|
|
43
|
+
/** The answer, or null while the question is open. */
|
|
44
|
+
answer: string | boolean | null;
|
|
45
|
+
/** The decider that gave the recorded state: table, model or quorum. */
|
|
46
|
+
decider: string;
|
|
47
|
+
answeredBy: string[];
|
|
48
|
+
subject: string | null;
|
|
49
|
+
/** The steward whose turn asked it, or null. */
|
|
50
|
+
steward: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a gate asks its point with. */
|
|
54
|
+
export interface GatePointRequest {
|
|
55
|
+
point: string;
|
|
56
|
+
/** The authored inputs, references resolved. */
|
|
57
|
+
inputs: Record<string, unknown>;
|
|
58
|
+
facts: GatePointFacts;
|
|
59
|
+
/** What the answer record's `constrains` names: {@link gateSubject}. */
|
|
60
|
+
subject: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How a gate asks its point. A test passes a stub; {@link workspaceGatePointAsker} is the real one. */
|
|
64
|
+
export interface GatePointAsker {
|
|
65
|
+
ask(request: GatePointRequest): Promise<GatePointQuestion>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The real asker: the workspace that holds `cwd`. It reads the point's
|
|
70
|
+
* declared inputs from `points`, adds the `gate.*` ones the point declares,
|
|
71
|
+
* and asks through the decide activity. A point with no model decider is
|
|
72
|
+
* asked with no backends, so the run needs no `decide.backends`.
|
|
73
|
+
*/
|
|
74
|
+
export function workspaceGatePointAsker(cwd: string): GatePointAsker {
|
|
75
|
+
return {
|
|
76
|
+
async ask(request) {
|
|
77
|
+
const { workspacePoints } = await import("../workspace/points-cli");
|
|
78
|
+
const doc = await workspacePoints({ cwd });
|
|
79
|
+
if ("error" in doc) throw new Error(`decision point ${request.point}: ${doc.error.code}: ${doc.error.message}`);
|
|
80
|
+
const point = doc.points.find((p) => p.name === request.point);
|
|
81
|
+
if (!point) {
|
|
82
|
+
const bad = doc.sources.find((s) => s.reason);
|
|
83
|
+
throw new Error(`gate "${request.facts.name}" asks decision point ${request.point}, and no points file declares it${bad ? ` (${bad.kind}: ${bad.reason!.message})` : ""}`);
|
|
84
|
+
}
|
|
85
|
+
const inputs = gatePointInputs(request.point, point.inputs.map((i) => i.name), request.facts, request.inputs);
|
|
86
|
+
const { runDecide } = await import("./activities/decide");
|
|
87
|
+
const asksModel = point.deciders.some((d) => d.kind === "model");
|
|
88
|
+
try {
|
|
89
|
+
const r = await runDecide({ point: request.point, inputs, subject: request.subject, cwd, ...(asksModel ? {} : { backends: {} }) });
|
|
90
|
+
return { id: r.id, point: request.point, path: r.path, state: r.state, answer: r.answer, decider: r.decider, answeredBy: r.answeredBy, subject: request.subject, steward: null };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (!isPointWait(err)) throw err;
|
|
93
|
+
const q = err.question;
|
|
94
|
+
return { id: q.id, point: q.point, path: q.path, state: q.state, answer: null, decider: "quorum", answeredBy: [], subject: q.subject, steward: q.steward };
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the run needs to decide a gate that asks a point. */
|
|
101
|
+
export interface PointGateInput {
|
|
102
|
+
op: string;
|
|
103
|
+
gate: string;
|
|
104
|
+
point: GatePoint;
|
|
105
|
+
/** The authored inputs, references resolved. */
|
|
106
|
+
inputs?: Record<string, unknown>;
|
|
107
|
+
description?: string;
|
|
108
|
+
timeout?: string;
|
|
109
|
+
runId?: string;
|
|
110
|
+
planDigest?: string;
|
|
111
|
+
/** The environment the run was started for: the `gate.env` input. Op gate facts on the ledger record none. */
|
|
112
|
+
env?: string;
|
|
113
|
+
now?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What reaching a gate that asks a point decided. */
|
|
117
|
+
export type PointGateCheck =
|
|
118
|
+
| {
|
|
119
|
+
satisfied: true;
|
|
120
|
+
/** The resolution that cites the answer: this run's, or one an earlier run wrote for the same answer and plan. */
|
|
121
|
+
resolution: GateResolutionRecord;
|
|
122
|
+
answer: GateAnswerRef;
|
|
123
|
+
/** Whether this run appended the resolution. */
|
|
124
|
+
recorded: boolean;
|
|
125
|
+
pushed?: boolean;
|
|
126
|
+
pushWarning?: string;
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
satisfied: false;
|
|
130
|
+
/** The question is answered, with an answer the gate does not pass on. */
|
|
131
|
+
refused: string;
|
|
132
|
+
answer: GateAnswerRef;
|
|
133
|
+
}
|
|
134
|
+
| {
|
|
135
|
+
satisfied: false;
|
|
136
|
+
/** The open question the run waits on. */
|
|
137
|
+
waiting: WaitingPoint;
|
|
138
|
+
/** The pending fact that cites it. */
|
|
139
|
+
pending: PendingGateRecord;
|
|
140
|
+
recorded: boolean;
|
|
141
|
+
pushed?: boolean;
|
|
142
|
+
pushWarning?: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Who a resolution written for an answer is by: the people who answered, or the decider that did. */
|
|
146
|
+
function resolvedByOf(q: GatePointQuestion): string {
|
|
147
|
+
return q.answeredBy.length > 0 ? q.answeredBy.join(", ") : q.decider;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
|
|
151
|
+
export async function evaluatePointGate(port: GateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
|
|
152
|
+
const now = input.now ?? new Date().toISOString();
|
|
153
|
+
const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
|
|
154
|
+
const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env) });
|
|
155
|
+
const ref: GateAnswerRef = { point: q.point, id: q.id, path: q.path };
|
|
156
|
+
|
|
157
|
+
if (q.state === "answered" && q.answer !== null) {
|
|
158
|
+
const answer: GateAnswerRef = { ...ref, answer: q.answer, decider: q.decider, ...(q.answeredBy.length > 0 ? { answeredBy: q.answeredBy } : {}) };
|
|
159
|
+
const pass = input.point.pass ?? DEFAULT_GATE_POINT_PASS;
|
|
160
|
+
if (!pass.includes(q.answer)) {
|
|
161
|
+
return {
|
|
162
|
+
satisfied: false,
|
|
163
|
+
answer,
|
|
164
|
+
refused:
|
|
165
|
+
`decision point ${q.point} answered ${JSON.stringify(q.answer)} (${q.id}, by ${resolvedByOf(q)}), and gate "${input.gate}" passes only on ${pass.map((a) => JSON.stringify(a)).join(" or ")}. ` +
|
|
166
|
+
`To ask again, take the answer back: chant workspace points retract ${q.id} --by <name>`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const ledger = await port.read(input.op);
|
|
170
|
+
const standing = ledger.resolutions.find(
|
|
171
|
+
(r) => r.gate === input.gate && r.answer?.id === q.id && r.environment === undefined && samePlanDigest(r.planDigest, input.planDigest),
|
|
172
|
+
);
|
|
173
|
+
if (standing) return { satisfied: true, resolution: standing, answer, recorded: false };
|
|
174
|
+
if (!port.appendResolution) throw new Error(`gate "${input.gate}" asks decision point ${q.point}, and this gate ledger cannot record the resolution that cites its answer`);
|
|
175
|
+
const { record, pushed, pushWarning } = await port.appendResolution({
|
|
176
|
+
op: input.op,
|
|
177
|
+
gate: input.gate,
|
|
178
|
+
resolvedBy: resolvedByOf(q),
|
|
179
|
+
timestamp: now,
|
|
180
|
+
note: `answered through decision point ${q.point} (${q.id})`,
|
|
181
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
182
|
+
approver: { kind: q.decider === "quorum" || q.answeredBy.length > 0 ? "human" : "agent" },
|
|
183
|
+
answer,
|
|
184
|
+
});
|
|
185
|
+
return { satisfied: true, resolution: record, answer, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const waiting: WaitingPoint = { id: q.id, point: q.point, state: q.state === "proposed" ? "proposed" : "escalated", path: q.path, subject: q.subject, steward: q.steward };
|
|
189
|
+
const ledger = await port.read(input.op);
|
|
190
|
+
const standing = latestPendingGate(ledger.pending.filter((p) => p.environment === undefined), input.gate);
|
|
191
|
+
if (standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id) {
|
|
192
|
+
return { satisfied: false, waiting, pending: standing, recorded: false };
|
|
193
|
+
}
|
|
194
|
+
const url = resolveApprovalUrl();
|
|
195
|
+
const { record, pushed, pushWarning } = await port.appendPending({
|
|
196
|
+
op: input.op,
|
|
197
|
+
gate: input.gate,
|
|
198
|
+
timestamp: now,
|
|
199
|
+
expiresAt: new Date(new Date(now).getTime() + parseDuration(input.timeout ?? DEFAULT_GATE_EXPIRY)).toISOString(),
|
|
200
|
+
...(input.description ? { description: input.description } : {}),
|
|
201
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
202
|
+
...(url ? { url } : {}),
|
|
203
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
204
|
+
answer: ref,
|
|
205
|
+
});
|
|
206
|
+
return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
207
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3170's Done when, end to end: an infra apply Op's gate (`ApplyOp` with
|
|
3
|
+
* `gate.point`) is answered through `chant workspace points answer`, the run
|
|
4
|
+
* proceeds on that answer, and the answer record and the gate ledger entry
|
|
5
|
+
* reference each other.
|
|
6
|
+
*
|
|
7
|
+
* The workspace is real (a git repository with the reference workspace's
|
|
8
|
+
* answer kind and a points file), the point is asked through the decide
|
|
9
|
+
* activity's path, and the answer goes through the command's own handler,
|
|
10
|
+
* `runWorkspacePoints`. The gate ledger is the in-memory port, and the build,
|
|
11
|
+
* plan and apply activities are stubs: the plan's digest is what the gate
|
|
12
|
+
* binds.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
18
|
+
import { cleanScratch, workspace } from "../workspace/__fixtures__/decide-workspace";
|
|
19
|
+
import { runWorkspacePoints } from "../workspace/points-cli";
|
|
20
|
+
import { workspacePoints } from "../workspace/points-cli";
|
|
21
|
+
import type { CommandContext } from "../cli/registry";
|
|
22
|
+
import { ApplyOp } from "./composites/apply-op";
|
|
23
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
24
|
+
import { runOpLocally } from "./local-executor";
|
|
25
|
+
import type { ActivityFn } from "./activity-registry";
|
|
26
|
+
import type { OpConfig } from "./types";
|
|
27
|
+
|
|
28
|
+
const PLAN_A = `jcs1-sha256:${"1".repeat(64)}`;
|
|
29
|
+
const PLAN_B = `jcs1-sha256:${"2".repeat(64)}`;
|
|
30
|
+
|
|
31
|
+
const POINTS = {
|
|
32
|
+
points: {
|
|
33
|
+
"prod-apply": {
|
|
34
|
+
title: "Apply this plan to prod",
|
|
35
|
+
question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
|
|
36
|
+
inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
|
|
37
|
+
deciders: [{ kind: "quorum", count: 1 }],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let root: string;
|
|
43
|
+
let plan = PLAN_A;
|
|
44
|
+
const applied: string[] = [];
|
|
45
|
+
const activities = new Map<string, ActivityFn>([
|
|
46
|
+
["chantBuild", async () => ({ ok: true })],
|
|
47
|
+
["lifecycleDiff", async () => ({ planDigest: plan, drifted: true })],
|
|
48
|
+
["nativeApply", async () => {
|
|
49
|
+
applied.push(plan);
|
|
50
|
+
return { ok: true };
|
|
51
|
+
}],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const config = (): OpConfig =>
|
|
55
|
+
(ApplyOp({ name: "prod-apply", env: "prod", target: "kubectl", gate: { gate: "approve-apply", point: "prod-apply" } }).op as unknown as { props: OpConfig }).props;
|
|
56
|
+
|
|
57
|
+
/** `chant workspace points answer <id> --answer <value> --by <name>`, through the command's handler, in the workspace. Returns the document it prints. */
|
|
58
|
+
async function pointsAnswer(id: string, answer: string, by: string): Promise<Record<string, unknown>> {
|
|
59
|
+
const printed: string[] = [];
|
|
60
|
+
const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
|
|
61
|
+
printed.push(String(line));
|
|
62
|
+
});
|
|
63
|
+
const was = process.cwd();
|
|
64
|
+
process.chdir(root);
|
|
65
|
+
try {
|
|
66
|
+
const ctx = { args: { extraPositional: "answer", extraPositional2: id, answer, bys: [by] }, plugins: [], serializers: [] } as unknown as CommandContext;
|
|
67
|
+
const code = await runWorkspacePoints(ctx);
|
|
68
|
+
expect(code).toBe(0);
|
|
69
|
+
} finally {
|
|
70
|
+
process.chdir(was);
|
|
71
|
+
log.mockRestore();
|
|
72
|
+
}
|
|
73
|
+
return JSON.parse(printed.join("\n")) as Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeAll(() => {
|
|
77
|
+
root = workspace(POINTS);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(cleanScratch);
|
|
81
|
+
|
|
82
|
+
describe("an infra apply Op's gate answered through chant workspace points answer (#3170)", () => {
|
|
83
|
+
test("the run waits on the point, proceeds on its answer, and the answer record and the ledger entry cite each other", async () => {
|
|
84
|
+
const port = memoryGateLedgerPort();
|
|
85
|
+
const op = config();
|
|
86
|
+
|
|
87
|
+
// 1. The run reaches the gate: the point is asked, the question is open
|
|
88
|
+
// for people, the run ends waiting on it, and nothing is applied.
|
|
89
|
+
const first = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-06T10:00:00.000Z" });
|
|
90
|
+
expect(first.status).toBe("waiting");
|
|
91
|
+
expect(applied).toEqual([]);
|
|
92
|
+
const question = first.point!;
|
|
93
|
+
expect(question).toMatchObject({ point: "prod-apply", state: "escalated", subject: "gate:prod-apply/approve-apply" });
|
|
94
|
+
|
|
95
|
+
// The pending fact on the gate ledger names the question.
|
|
96
|
+
expect(port.appended).toHaveLength(1);
|
|
97
|
+
expect(port.appended[0]).toMatchObject({ op: "prod-apply", gate: "approve-apply", planDigest: PLAN_A, answer: { point: "prod-apply", id: question.id, path: question.path } });
|
|
98
|
+
|
|
99
|
+
// The question is open in the workspace, asked with the gate's facts.
|
|
100
|
+
const open = await workspacePoints({ cwd: root, open: true });
|
|
101
|
+
if ("error" in open) throw new Error(open.error.message);
|
|
102
|
+
expect(open.questions.find((q) => q.id === question.id)).toMatchObject({
|
|
103
|
+
state: "escalated",
|
|
104
|
+
subject: "gate:prod-apply/approve-apply",
|
|
105
|
+
inputs: { "gate.component": "prod-apply", "gate.planDigest": PLAN_A },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 2. A person answers it through the command.
|
|
109
|
+
const doc = await pointsAnswer(question.id, "yes", "alice");
|
|
110
|
+
expect(doc).toMatchObject({ verb: "answer", id: question.id, written: true, question: { state: "answered", answer: true, answeredBy: ["alice"] } });
|
|
111
|
+
|
|
112
|
+
// 3. The next run proceeds on the answer and applies the plan.
|
|
113
|
+
const second = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-06T10:05:00.000Z" });
|
|
114
|
+
expect(second.status).toBe("ok");
|
|
115
|
+
expect(applied).toEqual([PLAN_A]);
|
|
116
|
+
const gateStep = second.records.find((r) => r.fn === "gate:approve-apply");
|
|
117
|
+
expect(gateStep?.approval).toMatchObject({ gate: "approve-apply", resolvedBy: "alice", via: "point", answer: { id: question.id, path: question.path, answer: true } });
|
|
118
|
+
|
|
119
|
+
// 4. The ledger entry cites the answer record...
|
|
120
|
+
expect(port.resolved).toHaveLength(1);
|
|
121
|
+
const resolution = port.resolved[0];
|
|
122
|
+
expect(resolution).toMatchObject({
|
|
123
|
+
op: "prod-apply",
|
|
124
|
+
gate: "approve-apply",
|
|
125
|
+
resolvedBy: "alice",
|
|
126
|
+
planDigest: PLAN_A,
|
|
127
|
+
answer: { point: "prod-apply", id: question.id, path: question.path, answer: true, decider: "quorum", answeredBy: ["alice"] },
|
|
128
|
+
});
|
|
129
|
+
// ...and the answer record names the gate and the plan the entry is for.
|
|
130
|
+
const text = readFileSync(join(root, resolution.answer!.path), "utf-8");
|
|
131
|
+
expect(text).toContain(`id: "${question.id}"`);
|
|
132
|
+
expect(text).toContain("gate:prod-apply/approve-apply");
|
|
133
|
+
expect(text).toContain(PLAN_A);
|
|
134
|
+
const answered = await workspacePoints({ cwd: root });
|
|
135
|
+
if ("error" in answered) throw new Error(answered.error.message);
|
|
136
|
+
expect(answered.questions.find((q) => q.id === resolution.answer!.id)).toMatchObject({
|
|
137
|
+
state: "answered",
|
|
138
|
+
subject: `gate:${resolution.op}/${resolution.gate}`,
|
|
139
|
+
inputs: { "gate.component": resolution.op, "gate.planDigest": resolution.planDigest },
|
|
140
|
+
answeredBy: ["alice"],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a retry of the answered plan writes no second resolution, and a new plan is a new question", async () => {
|
|
145
|
+
const port = memoryGateLedgerPort();
|
|
146
|
+
const op = config();
|
|
147
|
+
plan = PLAN_A;
|
|
148
|
+
applied.length = 0;
|
|
149
|
+
|
|
150
|
+
// The answer from the first test stands for PLAN_A: this ledger has no
|
|
151
|
+
// pending fact yet, and the run passes at once, citing the same record.
|
|
152
|
+
const retry = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-06T11:00:00.000Z" });
|
|
153
|
+
expect(retry.status).toBe("ok");
|
|
154
|
+
expect(port.resolved).toHaveLength(1);
|
|
155
|
+
const again = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-06T11:01:00.000Z" });
|
|
156
|
+
expect(again.status).toBe("ok");
|
|
157
|
+
expect(port.resolved).toHaveLength(1);
|
|
158
|
+
|
|
159
|
+
// Another plan asks another question, and waits on it.
|
|
160
|
+
plan = PLAN_B;
|
|
161
|
+
const moved = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-06T11:02:00.000Z" });
|
|
162
|
+
expect(moved.status).toBe("waiting");
|
|
163
|
+
expect(moved.point?.id).not.toBe(port.resolved[0].answer?.id);
|
|
164
|
+
expect(applied).toEqual([PLAN_A, PLAN_A]);
|
|
165
|
+
});
|
|
166
|
+
});
|