@intentius/chant 0.124.2 → 0.124.4
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/operator.d.ts.map +1 -1
- 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/registry.d.ts +2 -0
- package/dist/cli/registry.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-pending.d.ts +41 -0
- package/dist/lifecycle/gate-pending.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -4
- 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-notify.d.ts +2 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +123 -10
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +71 -6
- 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 +3 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/discovery-skip.e2e.test.ts +4 -1
- package/src/cli/handlers/operator.ts +25 -11
- 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/registry.ts +2 -0
- package/src/lifecycle/gate-ledger.ts +3 -53
- package/src/lifecycle/gate-pending.ts +62 -0
- package/src/notify-bundle.test.ts +20 -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 +2 -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 +12 -1
- package/src/op/op-waves-notify.ts +19 -2
- package/src/op/op-waves-run.ts +541 -76
- package/src/op/op-waves.test.ts +229 -0
- package/src/op/op-waves.ts +110 -10
- 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 +7 -2
- package/src/workspace/points-cli.ts +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3815: a gate's decision-point question survives a CI job. Outside a
|
|
3
|
+
* steward's turn a question used to go to the working tree, which a CI
|
|
4
|
+
* checkout throws away, so `points answer` elsewhere found nothing and the
|
|
5
|
+
* next run asked again. Asked into the ledger (`--store ledger`, a gate's
|
|
6
|
+
* `point.store`, or by default when `CI` is set), it lands on
|
|
7
|
+
* `chant/lifecycle`, is pushed, and is found and answered from another clone;
|
|
8
|
+
* the job's next run, in a fresh clone, passes on that answer.
|
|
9
|
+
*
|
|
10
|
+
* Three clones of one bare remote stand for the CI job's checkout, the
|
|
11
|
+
* person's clone the answer comes from, and the next job's checkout. The gate
|
|
12
|
+
* ledger the runs write is the in-memory port, so only the question and its
|
|
13
|
+
* answer travel through the remote.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
21
|
+
import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
|
|
22
|
+
import { runWorkspacePoints } from "../workspace/points-cli";
|
|
23
|
+
import type { CommandContext } from "../cli/registry";
|
|
24
|
+
import { ApplyOp } from "./composites/apply-op";
|
|
25
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
26
|
+
import { defaultGatePointStore, type GatePoint } from "./gate-point";
|
|
27
|
+
import { runOpLocally } from "./local-executor";
|
|
28
|
+
import type { ActivityFn } from "./activity-registry";
|
|
29
|
+
import type { OpConfig } from "./types";
|
|
30
|
+
|
|
31
|
+
const PLAN = `jcs1-sha256:${"4".repeat(64)}`;
|
|
32
|
+
|
|
33
|
+
const POINTS = {
|
|
34
|
+
points: {
|
|
35
|
+
"prod-apply": {
|
|
36
|
+
title: "Apply this plan to prod",
|
|
37
|
+
question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
|
|
38
|
+
inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
|
|
39
|
+
deciders: [{ kind: "quorum", count: 1 }],
|
|
40
|
+
},
|
|
41
|
+
"ship-it": {
|
|
42
|
+
title: "Ship it",
|
|
43
|
+
question: { type: "noul", instructions: "Ship it?", criteria: { true: "Ship.", false: "Hold." } },
|
|
44
|
+
inputs: { "release.version": "the version" },
|
|
45
|
+
deciders: [{ kind: "quorum", count: 1 }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const applied: string[] = [];
|
|
51
|
+
const activities = new Map<string, ActivityFn>([
|
|
52
|
+
["chantBuild", async () => ({ ok: true })],
|
|
53
|
+
["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
|
|
54
|
+
["nativeApply", async () => {
|
|
55
|
+
applied.push(PLAN);
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}],
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const config = (name: string, point: string | GatePoint): OpConfig =>
|
|
61
|
+
(ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point } }).op as unknown as { props: OpConfig }).props;
|
|
62
|
+
|
|
63
|
+
let home: string;
|
|
64
|
+
let remote: string;
|
|
65
|
+
|
|
66
|
+
/** A fresh clone of the remote: a CI job's checkout, or a person's. */
|
|
67
|
+
function clone(name: string): string {
|
|
68
|
+
const dir = join(home, name);
|
|
69
|
+
execFileSync("git", ["clone", "-q", remote, dir], { stdio: "ignore" });
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `chant workspace points <argv>` through the command's handler, in `cwd`. Returns the document it prints. */
|
|
74
|
+
async function points(cwd: string, args: Record<string, unknown>): Promise<Record<string, unknown> & { error?: { code: string } }> {
|
|
75
|
+
const printed: string[] = [];
|
|
76
|
+
const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
|
|
77
|
+
printed.push(String(line));
|
|
78
|
+
});
|
|
79
|
+
const was = process.cwd();
|
|
80
|
+
process.chdir(cwd);
|
|
81
|
+
try {
|
|
82
|
+
await runWorkspacePoints({ args, plugins: [], serializers: [] } as unknown as CommandContext);
|
|
83
|
+
} finally {
|
|
84
|
+
process.chdir(was);
|
|
85
|
+
log.mockRestore();
|
|
86
|
+
}
|
|
87
|
+
return JSON.parse(printed.join("\n"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const answersIn = (dir: string): string[] => (existsSync(join(dir, "answers")) ? readdirSync(join(dir, "answers")).filter((f) => f.endsWith(".md")) : []);
|
|
91
|
+
const run = (op: OpConfig, cwd: string) => runOpLocally(op, activities, {}, undefined, { gates: memoryGateLedgerPort(), cwd, now: "2026-10-11T12:00:00.000Z" });
|
|
92
|
+
|
|
93
|
+
beforeAll(() => {
|
|
94
|
+
home = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-ci-")));
|
|
95
|
+
const root = workspace(POINTS);
|
|
96
|
+
remote = join(home, "remote.git");
|
|
97
|
+
execFileSync("git", ["clone", "-q", "--bare", root, remote], { stdio: "ignore" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
vi.unstubAllEnvs();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
cleanScratch();
|
|
106
|
+
rmSync(home, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("a gate's question outlives the CI job that asked it (#3815)", () => {
|
|
110
|
+
test("a gate with point.store ledger: asked in one clone, answered from another, and the next job's clone passes on it", async () => {
|
|
111
|
+
vi.stubEnv("CI", "");
|
|
112
|
+
const op = config("prod-apply", { name: "prod-apply", store: "ledger" });
|
|
113
|
+
|
|
114
|
+
const job = clone("job-1");
|
|
115
|
+
const first = await run(op, job);
|
|
116
|
+
expect(first.status).toBe("waiting");
|
|
117
|
+
const id = first.point!.id;
|
|
118
|
+
// Nothing was written to the job's working tree, which goes away with the job.
|
|
119
|
+
expect(answersIn(job)).toEqual([]);
|
|
120
|
+
expect(git(job, "status", "--porcelain")).toBe("");
|
|
121
|
+
|
|
122
|
+
// A person's clone, which never ran the job, finds the question and answers it.
|
|
123
|
+
const person = clone("person");
|
|
124
|
+
const answered = await points(person, { extraPositional: "answer", extraPositional2: id, answer: "yes", bys: ["alice"] });
|
|
125
|
+
expect(answered).toMatchObject({ verb: "answer", id, written: true, question: { state: "answered", answer: true, ledger: expect.stringContaining("chant/lifecycle:") } });
|
|
126
|
+
|
|
127
|
+
// The next job, in a fresh checkout, passes on that answer.
|
|
128
|
+
const next = await run(op, clone("job-2"));
|
|
129
|
+
expect(next.status).toBe("ok");
|
|
130
|
+
expect(applied).toEqual([PLAN]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("with CI set, a gate that names no store asks into the ledger", async () => {
|
|
134
|
+
expect(defaultGatePointStore({ CI: "true" })).toBe("ledger");
|
|
135
|
+
expect(defaultGatePointStore({ CI: "false" })).toBeUndefined();
|
|
136
|
+
expect(defaultGatePointStore({})).toBeUndefined();
|
|
137
|
+
|
|
138
|
+
vi.stubEnv("CI", "true");
|
|
139
|
+
const op = config("ci-apply", "prod-apply");
|
|
140
|
+
const job = clone("job-3");
|
|
141
|
+
const first = await run(op, job);
|
|
142
|
+
expect(first.status).toBe("waiting");
|
|
143
|
+
expect(answersIn(job)).toEqual([]);
|
|
144
|
+
|
|
145
|
+
const person = clone("person-2");
|
|
146
|
+
expect(await points(person, { extraPositional: "answer", extraPositional2: first.point!.id, answer: "yes", bys: ["alice"] })).toMatchObject({ written: true });
|
|
147
|
+
expect((await run(op, clone("job-4"))).status).toBe("ok");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("chant workspace points ask --store ledger holds the question on chant/lifecycle, and another clone answers it", async () => {
|
|
151
|
+
vi.stubEnv("CI", "");
|
|
152
|
+
const job = clone("ask-job");
|
|
153
|
+
const asked = await points(job, { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "1.2.3" }), store: "ledger" });
|
|
154
|
+
expect(asked).toMatchObject({ verb: "ask", written: true, question: { state: "escalated", ledger: expect.stringContaining("chant/lifecycle:") } });
|
|
155
|
+
expect(answersIn(job)).toEqual([]);
|
|
156
|
+
|
|
157
|
+
const id = asked.id as string;
|
|
158
|
+
const person = clone("ask-person");
|
|
159
|
+
expect(await points(person, { extraPositional: "answer", extraPositional2: id, answer: "no", bys: ["bob"] })).toMatchObject({ written: true, question: { state: "answered", answer: false } });
|
|
160
|
+
|
|
161
|
+
// Asked again from a fresh checkout, the question is the answered one.
|
|
162
|
+
const again = await points(clone("ask-job-2"), { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "1.2.3" }), store: "ledger" });
|
|
163
|
+
expect(again).toMatchObject({ reused: true, question: { state: "answered", answer: false } });
|
|
164
|
+
|
|
165
|
+
// Without --store ledger the same ask in a clone with no ledger history goes to the tree, as before.
|
|
166
|
+
const tree = clone("ask-tree");
|
|
167
|
+
const local = await points(tree, { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "9.9.9" }) });
|
|
168
|
+
expect(local).toMatchObject({ written: true, question: { ledger: null } });
|
|
169
|
+
expect(answersIn(tree)).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -85,6 +85,8 @@ async function shell(verb: "answer" | "retract", id: string, by: string[], answe
|
|
|
85
85
|
|
|
86
86
|
beforeAll(() => {
|
|
87
87
|
root = workspace(POINTS);
|
|
88
|
+
// The questions go to the working tree, as outside CI: a CI job asks into the ledger by default (#3815).
|
|
89
|
+
vi.stubEnv("CI", "");
|
|
88
90
|
mkdirSync(join(root, ".chant"), { recursive: true });
|
|
89
91
|
writeFileSync(join(root, ".chant", "trust.json"), JSON.stringify({ schema: 1, roles: { agent: ["bot"], lead: ["alice", "bob"] } }));
|
|
90
92
|
git(root, "add", "-A");
|
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, OpWavesOverride, 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,9 @@ export type {
|
|
|
80
84
|
OpWaveBaseRules,
|
|
81
85
|
OpWaveDecision,
|
|
82
86
|
OpWaveApplyHooksRun,
|
|
87
|
+
OpWaveExec,
|
|
88
|
+
OpWaveHeldOn,
|
|
89
|
+
OpWaveHoldRequest,
|
|
83
90
|
OpWaveHookOutcome,
|
|
84
91
|
OpWaveHookRun,
|
|
85
92
|
OpWaveHooksFile,
|
|
@@ -87,7 +94,11 @@ export type {
|
|
|
87
94
|
OpWaveMember,
|
|
88
95
|
OpWavePlanFile,
|
|
89
96
|
OpWavePlansFile,
|
|
97
|
+
OpWavePolicySource,
|
|
90
98
|
OpWaveRunsFile,
|
|
99
|
+
OpWaveShowAtBase,
|
|
100
|
+
RecordHeadPlansOptions,
|
|
101
|
+
RunOpWaveApplyHooksOptions,
|
|
91
102
|
RunOpWaveOptions,
|
|
92
103
|
RunOpWaveResult,
|
|
93
104
|
} from "./op-waves-run";
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* approval command, the digest and, under `pr-review`, the pull request to
|
|
6
6
|
* review. A share job that refused a run whose plan moved (exit 4) sends
|
|
7
7
|
* `refused`. A wave whose plan, apply or stage hook failed (exit 1) sends
|
|
8
|
-
* `failed`. A
|
|
8
|
+
* `failed`. A wave the product's policy refused (exit 7, #3821) sends
|
|
9
|
+
* `refused` with the runs it denied. A share job of a waiting wave sends nothing, since its deciding
|
|
9
10
|
* job already did, unless a hook in the share job itself held it (#3762). Delivery is `../notify.ts`: it never throws, and the job's exit code
|
|
10
11
|
* is what the wave decided.
|
|
11
12
|
*/
|
|
@@ -46,13 +47,29 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
|
|
|
46
47
|
summary: `${label} waits for an approval`,
|
|
47
48
|
targets: decision.members.map((m) => m.member),
|
|
48
49
|
approve: { command: decision.approve ?? `chant approve ${decision.op} ${decision.gate} --plan ${decision.digest}`, ...review },
|
|
49
|
-
...(decision.
|
|
50
|
+
...(decision.heldOn
|
|
51
|
+
? { reason: `held on ${decision.heldOn.op} / ${decision.heldOn.gate} by ${decision.heldOn.hook}` }
|
|
52
|
+
: decision.heldBy?.length
|
|
50
53
|
? { reason: `held by ${decision.heldBy.join(", ")}` }
|
|
51
54
|
: decision.review?.reason
|
|
52
55
|
? { reason: decision.review.reason }
|
|
53
56
|
: {}),
|
|
54
57
|
};
|
|
55
58
|
}
|
|
59
|
+
if (result.exitCode === 7 && decision?.refused) {
|
|
60
|
+
// #3821: the product's policy denied a run, and no override counts for it.
|
|
61
|
+
return {
|
|
62
|
+
...base,
|
|
63
|
+
event: "refused",
|
|
64
|
+
status: decision.refused.reason === "override-moved" ? "override-moved" : "denied",
|
|
65
|
+
summary: `${label} refused: the policy denied ${decision.refused.runs.join(", ")}`,
|
|
66
|
+
targets: decision.refused.runs,
|
|
67
|
+
reason:
|
|
68
|
+
decision.refused.reason === "override-moved"
|
|
69
|
+
? "an override stands for an earlier plan or other rules, so it counts for nothing"
|
|
70
|
+
: "the policy denied these runs, and no override counts for them",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
56
73
|
if (result.exitCode === 4) {
|
|
57
74
|
return {
|
|
58
75
|
...base,
|