@intentius/chant 0.124.1 → 0.124.3
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/handlers/override-approve.d.ts +3 -1
- package/dist/cli/handlers/override-approve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +5 -1
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.d.ts +37 -13
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -33
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-origin.d.ts +14 -2
- package/dist/lifecycle/gate-origin.d.ts.map +1 -1
- package/dist/lifecycle/gate-pending.d.ts +41 -0
- package/dist/lifecycle/gate-pending.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +2 -0
- package/dist/op/activities/decide.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +5 -77
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +18 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate.d.ts +8 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +37 -6
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +38 -4
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +7 -0
- package/dist/op/steward-points.d.ts.map +1 -1
- package/dist/override-gate.d.ts +8 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/point-decision.d.ts +134 -0
- package/dist/point-decision.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +37 -2
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +14 -2
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/override-approve.test.ts +16 -0
- package/src/cli/handlers/override-approve.ts +6 -2
- package/src/cli/main.test.ts +3 -0
- package/src/cli/main.ts +9 -2
- package/src/cli/mcp/workspace-tools.ts +7 -3
- package/src/cli/registry.ts +2 -0
- package/src/lifecycle/gate-audit.test.ts +42 -0
- package/src/lifecycle/gate-audit.ts +102 -34
- package/src/lifecycle/gate-ledger.ts +3 -53
- package/src/lifecycle/gate-origin.ts +20 -3
- package/src/lifecycle/gate-pending.ts +62 -0
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/decide.ts +3 -0
- package/src/op/gate-point-ci.e2e.test.ts +171 -0
- package/src/op/gate-point-mcp.e2e.test.ts +163 -0
- package/src/op/gate-point-run.ts +18 -149
- package/src/op/gate-point.e2e.test.ts +2 -0
- package/src/op/gate-point.test.ts +1 -1
- package/src/op/gate-point.ts +27 -3
- package/src/op/gate.ts +10 -0
- package/src/op/index.ts +10 -1
- package/src/op/op-waves-run.ts +84 -19
- package/src/op/op-waves.test.ts +96 -0
- package/src/op/op-waves.ts +60 -4
- package/src/op/steward-points.ts +8 -0
- package/src/override-gate.test.ts +5 -0
- package/src/override-gate.ts +8 -2
- package/src/point-decision-bundle.test.ts +62 -0
- package/src/point-decision.e2e.test.ts +95 -0
- package/src/point-decision.ts +280 -0
- package/src/pr-forge.test.ts +62 -0
- package/src/pr-forge.ts +60 -9
- package/src/workspace/decide.ts +92 -8
- package/src/workspace/points-cli.ts +1 -1
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/reason-codes.ts +2 -1
package/src/op/gate-point-run.ts
CHANGED
|
@@ -25,51 +25,28 @@
|
|
|
25
25
|
* and, when the point declares them, in its `gate.*` inputs.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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";
|
|
28
|
+
import { defaultGatePointStore, gatePointInputs } from "./gate-point";
|
|
29
|
+
import { isPointWait } from "./steward-points";
|
|
30
|
+
import type { GatePointAsker } from "../point-decision";
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
32
|
+
// The decision itself bundles without the workspace (#3816): `@intentius/chant/point-decision`.
|
|
33
|
+
export {
|
|
34
|
+
evaluatePointGate,
|
|
35
|
+
type GatePointAsker,
|
|
36
|
+
type GatePointQuestion,
|
|
37
|
+
type GatePointRequest,
|
|
38
|
+
type PointGateCheck,
|
|
39
|
+
type PointGateInput,
|
|
40
|
+
type PointGateLedgerPort,
|
|
41
|
+
} from "../point-decision";
|
|
67
42
|
|
|
68
43
|
/**
|
|
69
44
|
* The real asker: the workspace that holds `cwd`. It reads the point's
|
|
70
45
|
* declared inputs from `points`, adds the `gate.*` ones the point declares,
|
|
71
46
|
* 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`.
|
|
47
|
+
* asked with no backends, so the run needs no `decide.backends`. A question
|
|
48
|
+
* goes where the gate's `point.store` says, and to the ledger in a CI job
|
|
49
|
+
* when it says nothing (`defaultGatePointStore`, #3815).
|
|
73
50
|
*/
|
|
74
51
|
export function workspaceGatePointAsker(cwd: string): GatePointAsker {
|
|
75
52
|
return {
|
|
@@ -86,7 +63,8 @@ export function workspaceGatePointAsker(cwd: string): GatePointAsker {
|
|
|
86
63
|
const { runDecide } = await import("./activities/decide");
|
|
87
64
|
const asksModel = point.deciders.some((d) => d.kind === "model");
|
|
88
65
|
try {
|
|
89
|
-
const
|
|
66
|
+
const store = request.store ?? defaultGatePointStore();
|
|
67
|
+
const r = await runDecide({ point: request.point, inputs, subject: request.subject, cwd, ...(store ? { store } : {}), ...(asksModel ? {} : { backends: {} }) });
|
|
90
68
|
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
69
|
} catch (err) {
|
|
92
70
|
if (!isPointWait(err)) throw err;
|
|
@@ -96,112 +74,3 @@ export function workspaceGatePointAsker(cwd: string): GatePointAsker {
|
|
|
96
74
|
},
|
|
97
75
|
};
|
|
98
76
|
}
|
|
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
|
-
}
|
|
@@ -75,6 +75,8 @@ async function pointsAnswer(id: string, answer: string, by: string): Promise<Rec
|
|
|
75
75
|
|
|
76
76
|
beforeAll(() => {
|
|
77
77
|
root = workspace(POINTS);
|
|
78
|
+
// The questions go to the working tree, as outside CI: a CI job asks into the ledger by default (#3815).
|
|
79
|
+
vi.stubEnv("CI", "");
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
afterAll(cleanScratch);
|
|
@@ -61,7 +61,7 @@ describe("a gate's point declaration (#3170)", () => {
|
|
|
61
61
|
expect(() => gate("apply", { point: "Prod Apply" })).toThrow(/decision point's name/);
|
|
62
62
|
expect(() => gate("apply", { point: { name: "prod-apply", inputs: { "gate.planDigest": PLAN } } })).toThrow(/which the gate passes itself/);
|
|
63
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
|
|
64
|
+
expect(gatePointProblems({ name: "prod-apply", quorum: 2 })).toEqual(["`point` takes name, inputs, pass and store, not quorum"]);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test("the gate passes the gate.* inputs the point declares, beside the authored ones", () => {
|
package/src/op/gate-point.ts
CHANGED
|
@@ -31,6 +31,29 @@ export interface GatePoint {
|
|
|
31
31
|
inputs?: Record<string, unknown>;
|
|
32
32
|
/** The answers that pass the gate. Default `[true]`: a noul point's yes. Any other answer fails the step. */
|
|
33
33
|
pass?: Array<string | boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* Where the question goes when the run asks it (#3815): `"tree"`, the
|
|
36
|
+
* answer kind's directory in the working tree, or `"ledger"`,
|
|
37
|
+
* `chant/lifecycle`, pushed, so a person can answer it after a checkout
|
|
38
|
+
* that does not outlive the run is gone. Default: the ledger in a steward's
|
|
39
|
+
* turn or a CI job (`CI` set), and the tree otherwise.
|
|
40
|
+
*/
|
|
41
|
+
store?: GatePointStore;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a gate's question is held: the working tree or `chant/lifecycle`. */
|
|
45
|
+
export type GatePointStore = "tree" | "ledger";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a gate's question goes when its `point` names no `store` (#3815): the
|
|
49
|
+
* ledger in a CI job, whose checkout is thrown away when the job ends, so the
|
|
50
|
+
* question outlives it; otherwise undefined, and `points ask`'s own rule
|
|
51
|
+
* holds (the ledger in a steward's turn, the tree otherwise). A CI job is one
|
|
52
|
+
* whose environment sets `CI`, as GitHub Actions and GitLab CI do.
|
|
53
|
+
*/
|
|
54
|
+
export function defaultGatePointStore(env: Readonly<Record<string, string | undefined>> = process.env): GatePointStore | undefined {
|
|
55
|
+
const ci = env.CI?.trim().toLowerCase();
|
|
56
|
+
return ci && ci !== "false" && ci !== "0" ? "ledger" : undefined;
|
|
34
57
|
}
|
|
35
58
|
|
|
36
59
|
/** The answers a gate passes on when it names none: a noul point's yes. */
|
|
@@ -49,8 +72,8 @@ export function gatePointProblems(point: unknown, withApproval = false): string[
|
|
|
49
72
|
const problems: string[] = [];
|
|
50
73
|
if (withApproval) problems.push("a gate that asks a decision point takes its quorum from the point: give `approval` or `point`, not both");
|
|
51
74
|
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>;
|
|
75
|
+
if (p === null || typeof p !== "object" || Array.isArray(p)) return [...problems, "`point` is a decision point's name or { name, inputs?, pass?, store? }"];
|
|
76
|
+
const { name, inputs, pass, store, ...rest } = p as Record<string, unknown>;
|
|
54
77
|
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
78
|
if (inputs !== undefined && (inputs === null || typeof inputs !== "object" || Array.isArray(inputs))) problems.push("`point.inputs` is an object of input name to value");
|
|
56
79
|
if (inputs && typeof inputs === "object") {
|
|
@@ -60,8 +83,9 @@ export function gatePointProblems(point: unknown, withApproval = false): string[
|
|
|
60
83
|
if (pass !== undefined && (!Array.isArray(pass) || pass.length === 0 || pass.some((a) => typeof a !== "string" && typeof a !== "boolean"))) {
|
|
61
84
|
problems.push("`point.pass` is a non-empty list of the answers that pass the gate");
|
|
62
85
|
}
|
|
86
|
+
if (store !== undefined && store !== "tree" && store !== "ledger") problems.push("`point.store` is tree or ledger: where the question is held");
|
|
63
87
|
const extra = Object.keys(rest);
|
|
64
|
-
if (extra.length > 0) problems.push(`\`point\` takes name, inputs and
|
|
88
|
+
if (extra.length > 0) problems.push(`\`point\` takes name, inputs, pass and store, not ${extra.join(", ")}`);
|
|
65
89
|
return problems;
|
|
66
90
|
}
|
|
67
91
|
|
package/src/op/gate.ts
CHANGED
|
@@ -260,6 +260,14 @@ export interface GateCheckInput {
|
|
|
260
260
|
* stands as a mismatch. Absent, nothing is spent.
|
|
261
261
|
*/
|
|
262
262
|
spent?: readonly AppliedGateRecord[];
|
|
263
|
+
/**
|
|
264
|
+
* Fields a caller adds to the pending fact it records (#3821), such as an
|
|
265
|
+
* Op waves product's commit and members. Every field this function writes
|
|
266
|
+
* itself wins over one of the same name.
|
|
267
|
+
*/
|
|
268
|
+
fields?: Record<string, unknown>;
|
|
269
|
+
/** The pending fact it records carries `neverOverMcp` (#3821, chant#3485). */
|
|
270
|
+
neverOverMcp?: boolean;
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
/**
|
|
@@ -592,6 +600,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
592
600
|
// #3683: in CI, where this job runs, so an approval can start it again.
|
|
593
601
|
const resume = resolveGateRunLocator();
|
|
594
602
|
const { record, pushed, pushWarning } = await port.appendPending({
|
|
603
|
+
...(input.fields as Partial<PendingGateInput> | undefined),
|
|
595
604
|
op: input.op,
|
|
596
605
|
gate: input.gate,
|
|
597
606
|
timestamp: now,
|
|
@@ -605,6 +614,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
605
614
|
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
606
615
|
...(env !== undefined ? { environment: env } : {}),
|
|
607
616
|
...(input.approval ? { approval: input.approval } : {}),
|
|
617
|
+
...(input.neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
608
618
|
});
|
|
609
619
|
return {
|
|
610
620
|
satisfied: false,
|
package/src/op/index.ts
CHANGED
|
@@ -54,23 +54,27 @@ export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from ".
|
|
|
54
54
|
export {
|
|
55
55
|
OP_WAVE_APPLY_HOOKS_RUNNERS,
|
|
56
56
|
OP_WAVE_DEFAULT_APPLY,
|
|
57
|
+
OP_WAVE_DEFAULT_GATE_NAME,
|
|
57
58
|
OP_WAVE_GATE_POLICIES,
|
|
58
59
|
OP_WAVE_HOOK_MOMENTS,
|
|
59
60
|
assertOpWaveHooks,
|
|
60
61
|
assertOpWavesSpec,
|
|
61
62
|
opWaveApplyHooksRunBy,
|
|
63
|
+
opWaveGateName,
|
|
62
64
|
opWaveHooksAt,
|
|
63
65
|
opWaveJobs,
|
|
64
66
|
opWaveRecordPath,
|
|
65
67
|
opWaveShare,
|
|
66
68
|
} from "./op-waves";
|
|
67
|
-
export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
69
|
+
export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesDigest, OpWavesSpec } from "./op-waves";
|
|
68
70
|
export {
|
|
69
71
|
opWaveHooksAtBase,
|
|
70
72
|
opWaveNeedsApproval,
|
|
71
73
|
opWavePlansPath,
|
|
72
74
|
opWaveRunsPath,
|
|
75
|
+
opWaveSetDigest,
|
|
73
76
|
parseOpWavesSpec,
|
|
77
|
+
recordOpWaveHeadPlans,
|
|
74
78
|
readOpWaveHooksFile,
|
|
75
79
|
readOpWavePolicy,
|
|
76
80
|
runOpWave,
|
|
@@ -80,6 +84,7 @@ export type {
|
|
|
80
84
|
OpWaveBaseRules,
|
|
81
85
|
OpWaveDecision,
|
|
82
86
|
OpWaveApplyHooksRun,
|
|
87
|
+
OpWaveExec,
|
|
83
88
|
OpWaveHookOutcome,
|
|
84
89
|
OpWaveHookRun,
|
|
85
90
|
OpWaveHooksFile,
|
|
@@ -87,7 +92,11 @@ export type {
|
|
|
87
92
|
OpWaveMember,
|
|
88
93
|
OpWavePlanFile,
|
|
89
94
|
OpWavePlansFile,
|
|
95
|
+
OpWavePolicySource,
|
|
90
96
|
OpWaveRunsFile,
|
|
97
|
+
OpWaveShowAtBase,
|
|
98
|
+
RecordHeadPlansOptions,
|
|
99
|
+
RunOpWaveApplyHooksOptions,
|
|
91
100
|
RunOpWaveOptions,
|
|
92
101
|
RunOpWaveResult,
|
|
93
102
|
} from "./op-waves-run";
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
43
43
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
44
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
|
-
import {
|
|
45
|
+
import { waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
46
|
import type { ReviewApproval } from "../gate-decision";
|
|
47
47
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
48
48
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
assertOpWaveHooks,
|
|
57
57
|
assertOpWavesSpec,
|
|
58
58
|
opWaveApplyHooksRunBy,
|
|
59
|
+
opWaveGateName,
|
|
59
60
|
opWaveHooksAt,
|
|
60
61
|
opWaveRecordPath,
|
|
61
62
|
opWaveShare,
|
|
@@ -81,8 +82,21 @@ export interface OpWavePlanFile {
|
|
|
81
82
|
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
82
83
|
export interface OpWavePlansFile {
|
|
83
84
|
runs: Array<OpWavePlanFile & { target: string }>;
|
|
85
|
+
/**
|
|
86
|
+
* Fields the product adds to the wave's pending fact (#3821), such as the
|
|
87
|
+
* commit it planned or the members its own readers expect. chant's own
|
|
88
|
+
* fields win: a key chant writes (`op`, `gate`, `planDigest`, `timestamp`,
|
|
89
|
+
* ...) is refused, except `description`.
|
|
90
|
+
*/
|
|
91
|
+
pending?: Record<string, unknown>;
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
95
|
+
export const OP_WAVE_PENDING_RESERVED = [
|
|
96
|
+
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
97
|
+
"environment", "origin", "neverOverMcp", "approval", "answer",
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
86
100
|
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
87
101
|
export interface OpWaveRunsFile {
|
|
88
102
|
/** The wave's name. */
|
|
@@ -148,6 +162,12 @@ export interface OpWaveDecision {
|
|
|
148
162
|
applyHooksRunBy?: "apply";
|
|
149
163
|
/** Every hook's outcome so far in the job that wrote the record (#3804). */
|
|
150
164
|
outcomes?: OpWaveHookOutcome[];
|
|
165
|
+
/** `changing`: the digest covers only the runs whose plan is not empty, as the spec's `digest.over` says (#3821). Absent: every run. */
|
|
166
|
+
digestOver?: "changing";
|
|
167
|
+
/** The fields the plans file added to the pending fact (#3821). */
|
|
168
|
+
pending?: Record<string, unknown>;
|
|
169
|
+
/** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
|
|
170
|
+
neverOverMcp?: true;
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
/** One hook's run (#3762). */
|
|
@@ -216,6 +236,18 @@ export interface OpWaveHooksResult {
|
|
|
216
236
|
outcomes: OpWaveHookOutcome[];
|
|
217
237
|
}
|
|
218
238
|
|
|
239
|
+
/** The options of {@link runOpWaveApplyHooks} (#3825). */
|
|
240
|
+
export interface RunOpWaveApplyHooksOptions {
|
|
241
|
+
/** Where the hooks run. Default `process.cwd()`. */
|
|
242
|
+
cwd?: string;
|
|
243
|
+
/** Runs one hook. Default: spawn it with the job's environment. */
|
|
244
|
+
exec?: OpWaveExec;
|
|
245
|
+
/** Default: stderr. */
|
|
246
|
+
log?: (line: string) => void;
|
|
247
|
+
/** Run nothing and record the moment as skipped, with this reason (#3811). */
|
|
248
|
+
skip?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
219
251
|
/** One call of {@link runOpWaveApplyHooks}. */
|
|
220
252
|
export interface OpWaveApplyHooksRun {
|
|
221
253
|
moment: OpWaveHookMoment;
|
|
@@ -293,6 +325,8 @@ export interface OpWaveBaseRules {
|
|
|
293
325
|
spend: boolean;
|
|
294
326
|
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
295
327
|
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
328
|
+
/** The spec at base sets `neverOverMcp` (#3821). */
|
|
329
|
+
neverOverMcp: boolean;
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -313,7 +347,7 @@ export function readOpWavePolicy(
|
|
|
313
347
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
314
348
|
): OpWaveBaseRules {
|
|
315
349
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
316
|
-
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
|
|
350
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const, neverOverMcp: false };
|
|
317
351
|
if (text === null) return absent;
|
|
318
352
|
let spec: OpWavesSpec;
|
|
319
353
|
try {
|
|
@@ -323,12 +357,13 @@ export function readOpWavePolicy(
|
|
|
323
357
|
}
|
|
324
358
|
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
325
359
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
360
|
+
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
326
361
|
const wave = spec.waves.find((w) => w.name === name);
|
|
327
|
-
if (!wave) return { ...absent, spend };
|
|
362
|
+
if (!wave) return { ...absent, spend, neverOverMcp };
|
|
328
363
|
const policy = wave.gate ?? "always";
|
|
329
364
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
330
365
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
331
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
366
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
|
|
332
367
|
try {
|
|
333
368
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
334
369
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
@@ -481,9 +516,9 @@ function writeRunsFile(
|
|
|
481
516
|
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
482
517
|
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
483
518
|
*/
|
|
484
|
-
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]):
|
|
519
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
|
|
485
520
|
const { spec, k, wave, cwd, exec } = at;
|
|
486
|
-
if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
|
|
521
|
+
if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
|
|
487
522
|
const runsPath = writeRunsFile(at, runs);
|
|
488
523
|
const plans = opWavePlansPath(spec, k);
|
|
489
524
|
rmSync(join(cwd, plans), { force: true });
|
|
@@ -510,12 +545,40 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
510
545
|
}
|
|
511
546
|
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
512
547
|
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
513
|
-
|
|
548
|
+
const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
|
|
549
|
+
const members = runs.map((run) => {
|
|
514
550
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
515
551
|
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
516
552
|
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
|
|
517
553
|
return m;
|
|
518
554
|
});
|
|
555
|
+
return { members, ...(pending ? { pending } : {}) };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
|
|
559
|
+
interface PlannedRuns {
|
|
560
|
+
members: OpWaveMember[];
|
|
561
|
+
pending?: Record<string, unknown>;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
565
|
+
function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
|
|
566
|
+
if (value === undefined) return undefined;
|
|
567
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
|
|
568
|
+
const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
|
|
569
|
+
if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
|
|
570
|
+
if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
|
|
571
|
+
return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
|
|
576
|
+
* or under `digest.over: "changing"` over the runs whose plan is not empty,
|
|
577
|
+
* falling back to every run when all of them are empty.
|
|
578
|
+
*/
|
|
579
|
+
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
|
|
580
|
+
const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
|
|
581
|
+
return waveSetDigest(changing.length > 0 ? changing : members);
|
|
519
582
|
}
|
|
520
583
|
|
|
521
584
|
/** What one moment's hooks came to. */
|
|
@@ -647,11 +710,7 @@ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT
|
|
|
647
710
|
* chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
|
|
648
711
|
* in process or to keep its output.
|
|
649
712
|
*/
|
|
650
|
-
export function runOpWaveApplyHooks(
|
|
651
|
-
file: OpWaveHooksFile,
|
|
652
|
-
moment: OpWaveHookMoment,
|
|
653
|
-
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
|
|
654
|
-
): OpWaveApplyHooksRun {
|
|
713
|
+
export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMoment, options: RunOpWaveApplyHooksOptions = {}): OpWaveApplyHooksRun {
|
|
655
714
|
const cwd = options.cwd ?? process.cwd();
|
|
656
715
|
const log = options.log ?? ((line: string) => console.error(line));
|
|
657
716
|
let run: OpWaveApplyHooksRun;
|
|
@@ -786,7 +845,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
786
845
|
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
787
846
|
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
788
847
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
|
|
789
|
-
const members = planRuns(ctx, slice);
|
|
848
|
+
const { members } = planRuns(ctx, slice);
|
|
790
849
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
791
850
|
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
792
851
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
@@ -820,13 +879,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
820
879
|
}
|
|
821
880
|
const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
822
881
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
823
|
-
const members = planRuns(ctx, wave.runs);
|
|
882
|
+
const { members, pending } = planRuns(ctx, wave.runs);
|
|
824
883
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
825
|
-
const digest =
|
|
884
|
+
const digest = opWaveSetDigest(spec, members);
|
|
885
|
+
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
826
886
|
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
827
887
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
828
888
|
holds.push(...before.holds, ...after.holds);
|
|
829
|
-
const gate =
|
|
889
|
+
const gate = opWaveGateName(spec, k);
|
|
830
890
|
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
831
891
|
const decision: OpWaveDecision = {
|
|
832
892
|
wave: k,
|
|
@@ -844,6 +904,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
844
904
|
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
845
905
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
846
906
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
907
|
+
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
908
|
+
...(pending ? { pending } : {}),
|
|
909
|
+
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
847
910
|
};
|
|
848
911
|
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
849
912
|
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
@@ -905,9 +968,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
905
968
|
op: spec.name,
|
|
906
969
|
gate,
|
|
907
970
|
planDigest: digest,
|
|
908
|
-
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
971
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
909
972
|
now,
|
|
910
973
|
...(spent ? { spent } : {}),
|
|
974
|
+
...(decision.pending ? { fields: decision.pending } : {}),
|
|
975
|
+
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
911
976
|
});
|
|
912
977
|
if (check.satisfied) {
|
|
913
978
|
decision.status = "approved";
|
|
@@ -1279,8 +1344,8 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
1279
1344
|
head,
|
|
1280
1345
|
timestamp: options.now ?? new Date().toISOString(),
|
|
1281
1346
|
waves: spec.waves.map((wave, i) => {
|
|
1282
|
-
const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1283
|
-
return { wave: i + 1, name: wave.name, digest:
|
|
1347
|
+
const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1348
|
+
return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
|
|
1284
1349
|
}),
|
|
1285
1350
|
};
|
|
1286
1351
|
await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
|