@intentius/chant 0.124.2 → 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/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 +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 +3 -1
- package/dist/workspace/decide.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/registry.ts +2 -0
- package/src/lifecycle/gate-ledger.ts +3 -53
- 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 +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 +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 +7 -2
- package/src/workspace/points-cli.ts +1 -1
|
@@ -48,6 +48,9 @@ import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../
|
|
|
48
48
|
import type { GateRunLocator } from "../op/gate-resume";
|
|
49
49
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
50
50
|
|
|
51
|
+
// The facts a pending record is written with live apart, so a bundle can import them without git (#3816).
|
|
52
|
+
export { DEFAULT_GATE_EXPIRY, isApprovalUrl, resolveApprovalUrl } from "./gate-pending";
|
|
53
|
+
|
|
51
54
|
// The selection rules a gate decides by live apart, so a bundle can import them without git (#3763).
|
|
52
55
|
export {
|
|
53
56
|
isPendingGateExpired,
|
|
@@ -63,57 +66,6 @@ const DIR = "_gates";
|
|
|
63
66
|
export const GATES_DIR = DIR;
|
|
64
67
|
const APPEND_RETRY_ATTEMPTS = 5;
|
|
65
68
|
|
|
66
|
-
/**
|
|
67
|
-
* The address of the approval surface for a gate, resolved from the CI
|
|
68
|
-
* environment (#2028).
|
|
69
|
-
*
|
|
70
|
-
* #1485's argument for gate-as-fact was that approval gets an address:
|
|
71
|
-
* "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
|
|
72
|
-
* authorization model." What shipped recorded the gate and not the address,
|
|
73
|
-
* so a pending-approval card had nothing to link to and gate-as-PR stayed a
|
|
74
|
-
* convention. This is the narrow, honest half of that: when a tick (or a
|
|
75
|
-
* `chant approve`) runs inside the PR/MR job that carries the change, the
|
|
76
|
-
* loop genuinely knows where approval happens, and says so. Anywhere else it
|
|
77
|
-
* returns `undefined` and the field is simply absent — never a guess, never a
|
|
78
|
-
* synthesized link.
|
|
79
|
-
*
|
|
80
|
-
* The env-var fallback chain is the same one `--actor` and `--run-id` already
|
|
81
|
-
* use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
|
|
82
|
-
*
|
|
83
|
-
* - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
|
|
84
|
-
* `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
|
|
85
|
-
* `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
|
|
86
|
-
* - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
|
|
87
|
-
* `CI_MERGE_REQUEST_IID`.
|
|
88
|
-
*/
|
|
89
|
-
export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
90
|
-
const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
|
|
91
|
-
if (prNumber && env.GITHUB_REPOSITORY) {
|
|
92
|
-
const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
|
|
93
|
-
return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
|
|
97
|
-
return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Whether `raw` is an address worth recording as one: an absolute `http`/
|
|
105
|
-
* `https` URL. A gate's address is a link a reader is expected to follow, so
|
|
106
|
-
* a relative path or a `file:`/`javascript:` scheme is refused at the CLI
|
|
107
|
-
* boundary rather than written into an immutable record.
|
|
108
|
-
*/
|
|
109
|
-
export function isApprovalUrl(raw: string): boolean {
|
|
110
|
-
try {
|
|
111
|
-
const parsed = new URL(raw);
|
|
112
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
113
|
-
} catch {
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
69
|
|
|
118
70
|
/**
|
|
119
71
|
* The answer record a gate that asks a decision point cites (#3170): the
|
|
@@ -342,8 +294,6 @@ export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
|
|
|
342
294
|
/** Either kind of line in `_gates/<op>.jsonl`. */
|
|
343
295
|
export type GateLedgerRecord = GateResolutionRecord | PendingGateRecord;
|
|
344
296
|
|
|
345
|
-
/** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
|
|
346
|
-
export const DEFAULT_GATE_EXPIRY = "48h";
|
|
347
297
|
|
|
348
298
|
function filename(op: string): string {
|
|
349
299
|
return `${op}.jsonl`;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a pending gate fact is written with, apart from the ledger's git code
|
|
3
|
+
* (#3816): its default expiry and the address of its approval surface. A
|
|
4
|
+
* bundle that records a pending fact through its own port, such as
|
|
5
|
+
* `@intentius/chant/point-decision`, imports these without git.
|
|
6
|
+
* `./gate-ledger.ts` re-exports them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
|
|
10
|
+
export const DEFAULT_GATE_EXPIRY = "48h";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The address of the approval surface for a gate, resolved from the CI
|
|
14
|
+
* environment (#2028).
|
|
15
|
+
*
|
|
16
|
+
* #1485's argument for gate-as-fact was that approval gets an address:
|
|
17
|
+
* "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
|
|
18
|
+
* authorization model." What shipped recorded the gate and not the address,
|
|
19
|
+
* so a pending-approval card had nothing to link to and gate-as-PR stayed a
|
|
20
|
+
* convention. This is the narrow, honest half of that: when a tick (or a
|
|
21
|
+
* `chant approve`) runs inside the PR/MR job that carries the change, the
|
|
22
|
+
* loop genuinely knows where approval happens, and says so. Anywhere else it
|
|
23
|
+
* returns `undefined` and the field is simply absent — never a guess, never a
|
|
24
|
+
* synthesized link.
|
|
25
|
+
*
|
|
26
|
+
* The env-var fallback chain is the same one `--actor` and `--run-id` already
|
|
27
|
+
* use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
|
|
28
|
+
*
|
|
29
|
+
* - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
|
|
30
|
+
* `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
|
|
31
|
+
* `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
|
|
32
|
+
* - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
|
|
33
|
+
* `CI_MERGE_REQUEST_IID`.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
36
|
+
const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
|
|
37
|
+
if (prNumber && env.GITHUB_REPOSITORY) {
|
|
38
|
+
const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
|
|
39
|
+
return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
|
|
43
|
+
return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `raw` is an address worth recording as one: an absolute `http`/
|
|
51
|
+
* `https` URL. A gate's address is a link a reader is expected to follow, so
|
|
52
|
+
* a relative path or a `file:`/`javascript:` scheme is refused at the CLI
|
|
53
|
+
* boundary rather than written into an immutable record.
|
|
54
|
+
*/
|
|
55
|
+
export function isApprovalUrl(raw: string): boolean {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = new URL(raw);
|
|
58
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -320,6 +320,7 @@ export const decideContract = activityContract(
|
|
|
320
320
|
cwd: z.string().optional(),
|
|
321
321
|
backends: z.record(z.string(), backendSchema).optional(),
|
|
322
322
|
dryRun: z.boolean().optional(),
|
|
323
|
+
store: z.enum(["tree", "ledger"]).optional(),
|
|
323
324
|
}),
|
|
324
325
|
z.strictObject({
|
|
325
326
|
id: z.string(),
|
|
@@ -65,6 +65,8 @@ export interface DecideArgs {
|
|
|
65
65
|
backends?: Record<string, DecideBackend>;
|
|
66
66
|
/** Ask, but write nothing, and return the question whatever its state: an open one does not stop the run. */
|
|
67
67
|
dryRun?: boolean;
|
|
68
|
+
/** Where a new question goes (#3815): `"ledger"` holds it on chant/lifecycle, for a run whose checkout does not outlive it. Default: the ledger in a steward's turn, the tree otherwise. */
|
|
69
|
+
store?: "tree" | "ledger";
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export interface DecideResult {
|
|
@@ -234,6 +236,7 @@ export async function runDecide(args: DecideArgs, deps: DecideDeps = {}, signal?
|
|
|
234
236
|
inputs,
|
|
235
237
|
...(args.subject !== undefined ? { subject: args.subject } : {}),
|
|
236
238
|
...(args.kind !== undefined ? { kind: args.kind } : {}),
|
|
239
|
+
...(args.store !== undefined ? { store: args.store } : {}),
|
|
237
240
|
...(deps.on ? { on: deps.on } : {}),
|
|
238
241
|
};
|
|
239
242
|
|
|
@@ -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,
|