@intentius/chant 0.124.10 → 0.124.12

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.
Files changed (67) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +85 -10
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/gate-resume-run.d.ts +87 -0
  5. package/dist/gate-resume-run.d.ts.map +1 -0
  6. package/dist/lifecycle/gate-applied.d.ts +44 -1
  7. package/dist/lifecycle/gate-applied.d.ts.map +1 -1
  8. package/dist/lifecycle/gate-ledger.d.ts +1 -1
  9. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  10. package/dist/lifecycle/git.d.ts +30 -14
  11. package/dist/lifecycle/git.d.ts.map +1 -1
  12. package/dist/lifecycle/ledger-port.d.ts +18 -0
  13. package/dist/lifecycle/ledger-port.d.ts.map +1 -0
  14. package/dist/lifecycle/release-ledger.d.ts +10 -9
  15. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  16. package/dist/lifecycle/release-read.d.ts +23 -0
  17. package/dist/lifecycle/release-read.d.ts.map +1 -0
  18. package/dist/lifecycle/wave-plans.d.ts +51 -0
  19. package/dist/lifecycle/wave-plans.d.ts.map +1 -0
  20. package/dist/lint/typescript.d.ts +18 -0
  21. package/dist/lint/typescript.d.ts.map +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts +4 -4
  23. package/dist/op/gate-resume-plan.d.ts +113 -0
  24. package/dist/op/gate-resume-plan.d.ts.map +1 -0
  25. package/dist/op/gate-resume.d.ts +8 -64
  26. package/dist/op/gate-resume.d.ts.map +1 -1
  27. package/dist/op/gate-review.d.ts +1 -20
  28. package/dist/op/gate-review.d.ts.map +1 -1
  29. package/dist/op/gate-run-locator.d.ts +1 -1
  30. package/dist/op/index.d.ts +2 -0
  31. package/dist/op/index.d.ts.map +1 -1
  32. package/dist/op/op-waves-run.d.ts.map +1 -1
  33. package/dist/point-decision.d.ts +11 -0
  34. package/dist/point-decision.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/gate-resume.test.ts +140 -2
  37. package/src/cli/handlers/gate-resume.ts +174 -65
  38. package/src/cli/main.ts +8 -3
  39. package/src/codegen/pinned-upgrade.test.ts +8 -1
  40. package/src/gate-decision-bundle.test.ts +1 -0
  41. package/src/gate-resume-run-bundle.test.ts +49 -0
  42. package/src/gate-resume-run.ts +171 -0
  43. package/src/lifecycle/gate-applied.ts +63 -1
  44. package/src/lifecycle/gate-ledger.test.ts +7 -1
  45. package/src/lifecycle/gate-ledger.ts +1 -1
  46. package/src/lifecycle/git.ts +61 -18
  47. package/src/lifecycle/ledger-port.ts +17 -0
  48. package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
  49. package/src/lifecycle/ledger-readers.test.ts +139 -0
  50. package/src/lifecycle/release-ledger.ts +15 -42
  51. package/src/lifecycle/release-read.ts +69 -0
  52. package/src/lifecycle/wave-plans.ts +93 -0
  53. package/src/lint/typescript.ts +18 -0
  54. package/src/meta/lexicon-typescript-import.test.ts +42 -0
  55. package/src/op/gate-resume-plan-bundle.test.ts +49 -0
  56. package/src/op/gate-resume-plan.test.ts +107 -0
  57. package/src/op/gate-resume-plan.ts +228 -0
  58. package/src/op/gate-resume.test.ts +18 -0
  59. package/src/op/gate-resume.ts +16 -150
  60. package/src/op/gate-review.ts +2 -16
  61. package/src/op/gate-run-locator.ts +1 -1
  62. package/src/op/gate.ts +1 -1
  63. package/src/op/index.ts +2 -0
  64. package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
  65. package/src/op/op-waves-point.test.ts +60 -2
  66. package/src/op/op-waves-run.ts +6 -10
  67. package/src/point-decision.ts +19 -2
@@ -0,0 +1,228 @@
1
+ /**
2
+ * What a resume would start again, decided from the ledger alone (#3858).
3
+ *
4
+ * `chant run resume` starts again the CI job behind a gate an approval now
5
+ * answers (`./gate-resume.ts`). A product that resumes in its own job, such
6
+ * as terragucci's GitHub and Forgejo resume job, which applies the waves
7
+ * itself from wave 1 with a read-only token, needs the same decision without
8
+ * the forge call. {@link planGateResumes} makes it, per gate:
9
+ *
10
+ * - A standing pending fact (the newest per gate and environment, not
11
+ * expired) is resumable when an approval answers it, or when the decision
12
+ * point it cites has an answer the gate passes on. One whose answering
13
+ * approval a wave already applied under (`approvals.spend`,
14
+ * `_gates/<op>/applied.jsonl`, #3784) is `spent`: that apply ran, so there
15
+ * is nothing to resume, and a resumed run would only wait again.
16
+ * - An apply that stopped: the newest applied line of a gate carries a run
17
+ * locator (`resume`), no `finished` line says how it ended, and no pending
18
+ * fact of the gate is newer. It is resumable once the run that applied is
19
+ * gone, which only the forge can say: the caller checks that
20
+ * (`resumeGateRun` does, and `chant run resume --dry-run` asks it without
21
+ * starting anything). The resumed wave applies the rest under the same
22
+ * approval.
23
+ *
24
+ * Pure: no git, no forge, no clock, no Ajv. It imports the run locator's
25
+ * check, the ledger's selection rules and `pointGateResumable`, all of which
26
+ * bundle on their own, so a product can bundle
27
+ * `@intentius/chant/op/gate-resume-plan` (`gate-resume-plan-bundle.test.ts`).
28
+ */
29
+
30
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
31
+ import { isPendingGateExpired, latestResolutionForPlan } from "../lifecycle/gate-ledger-select";
32
+ import { finishedApply, isApprovalSpent, type AppliedGateLine, type FinishedGateRecord } from "../lifecycle/gate-applied";
33
+ import { samePlanDigest } from "../lifecycle/plan-digest";
34
+ import { pointGateResumable, type PointQuestionState } from "../point-decision";
35
+ import { isGateRunLocator, type GateRunLocator } from "./gate-run-locator";
36
+
37
+ /**
38
+ * Why a gate would or would not resume:
39
+ *
40
+ * - `approved`: an approval answers its standing pending fact, and no wave applied under it yet.
41
+ * - `answered`: the decision point it waits on has an answer the gate passes on.
42
+ * - `not-approved`: nothing answers it yet.
43
+ * - `spent`: the approval that answers it is spent; a wave already applied under it.
44
+ * - `stopped`: an apply under an approval stopped before it said how it ended.
45
+ */
46
+ export type GateResumeReason = "approved" | "answered" | "not-approved" | "spent" | "stopped";
47
+
48
+ /** One gate a resume looks at, and what the ledger says about it. */
49
+ export interface GateResumeCandidate {
50
+ /** A standing pending fact, or an apply under an approval that stopped. */
51
+ source: "pending" | "stopped";
52
+ op: string;
53
+ gate: string;
54
+ environment?: string;
55
+ planDigest?: string;
56
+ /**
57
+ * The ledger says to resume it. For `stopped`, only once the run that
58
+ * applied is gone, which the caller checks with the forge.
59
+ */
60
+ resumable: boolean;
61
+ reason: GateResumeReason;
62
+ message: string;
63
+ approvedBy?: string;
64
+ /** The answering approval's timestamp, or the answer's date. A Forgejo run created after it was already resumed. */
65
+ approvedAt?: string;
66
+ /** The job to start again: the pending fact's `resume`, or the stopped apply's. */
67
+ run?: GateRunLocator;
68
+ /** The wave applied under the answering approval at `appliedAt`. */
69
+ spent?: { appliedAt: string; approvedAt: string; wave?: { number: number; name: string } };
70
+ /** The apply that stopped: when it began, the approval it ran under, and its wave. */
71
+ stopped?: { appliedAt: string; approvedAt: string; kind: AppliedGateLine["kind"]; wave?: { number: number; name: string } };
72
+ /** The standing pending fact, for a product that reads its own fields (a commit, a description). */
73
+ pending?: PendingGateRecord;
74
+ }
75
+
76
+ /** The resolution that answers `standing` under its approval block (`resolutionAnswering` in `./gate-resume.ts`). */
77
+ export type GateAnswering = (
78
+ resolutions: readonly GateResolutionRecord[],
79
+ standing: PendingGateRecord,
80
+ pending: readonly PendingGateRecord[],
81
+ ) => GateResolutionRecord | undefined;
82
+
83
+ export interface PlanGateResumesInput {
84
+ /** The op whose ledger these are. */
85
+ op: string;
86
+ pending: readonly PendingGateRecord[];
87
+ resolutions: readonly GateResolutionRecord[];
88
+ /** The `applied` and `changes` lines of `_gates/<op>/applied.jsonl` (`parseAppliedGateFile`). */
89
+ applied?: readonly AppliedGateLine[];
90
+ /** Its `finished` lines. */
91
+ finished?: readonly FinishedGateRecord[];
92
+ now: string;
93
+ /** Only this gate. */
94
+ gate?: string;
95
+ /** The workspace's questions by id, for a fact that cites a decision point. */
96
+ questions?: ReadonlyMap<string, PointQuestionState>;
97
+ /**
98
+ * Which resolution answers a fact. Default: the newest approval of its plan
99
+ * since the fact. A fact with an `approval` block (a quorum, a policy)
100
+ * needs chant's tally: pass `resolutionAnswering` from
101
+ * `@intentius/chant/op/gate-resume`, which `chant run resume` uses.
102
+ */
103
+ answering?: GateAnswering;
104
+ }
105
+
106
+ const time = (iso: string): number => new Date(iso).getTime();
107
+
108
+ /** The newest pending fact per gate and environment: the facts a run still waits on. */
109
+ export function standingGateFacts(pending: readonly PendingGateRecord[], gate?: string): PendingGateRecord[] {
110
+ const newest = new Map<string, PendingGateRecord>();
111
+ for (const p of pending) {
112
+ if (gate !== undefined && p.gate !== gate) continue;
113
+ const key = `${p.gate}\u0000${p.environment ?? ""}`;
114
+ const prior = newest.get(key);
115
+ if (!prior || time(p.timestamp) >= time(prior.timestamp)) newest.set(key, p);
116
+ }
117
+ return [...newest.values()];
118
+ }
119
+
120
+ /** The newest approval of the fact's plan since the fact, when it has no approval block. */
121
+ const defaultAnswering: GateAnswering = (resolutions, standing) => {
122
+ if (standing.approval) return undefined;
123
+ const mine = resolutions.filter((r) => standing.environment === undefined || r.environment === standing.environment);
124
+ return latestResolutionForPlan(mine, standing.gate, standing.timestamp, standing.planDigest).resolution;
125
+ };
126
+
127
+ /** The applied line that spent an approval of `gate` and `digest` made at `approvedAt`, if one did. */
128
+ function spentBy(applied: readonly AppliedGateLine[], gate: string, digest: string | undefined, approval: GateResolutionRecord): AppliedGateLine | undefined {
129
+ if (digest === undefined) return undefined;
130
+ const probe = { gate, planDigest: digest, timestamp: approval.timestamp, ...(approval.review ? { review: { pr: approval.review.pr, head: approval.review.head } } : {}) };
131
+ if (!isApprovalSpent(applied, probe)) return undefined;
132
+ return applied.find((a) => isApprovalSpent([a], probe));
133
+ }
134
+
135
+ /** Decide, for each standing pending fact and each stopped apply of an op, whether a resume would start its job again and why. */
136
+ export function planGateResumes(input: PlanGateResumesInput): GateResumeCandidate[] {
137
+ const applied = input.applied ?? [];
138
+ const finished = input.finished ?? [];
139
+ const answering = input.answering ?? defaultAnswering;
140
+ const out: GateResumeCandidate[] = [];
141
+
142
+ const standing = standingGateFacts(input.pending, input.gate).filter((p) => !isPendingGateExpired(p, input.now));
143
+ for (const p of standing) {
144
+ const base = {
145
+ source: "pending" as const,
146
+ op: p.op || input.op,
147
+ gate: p.gate,
148
+ ...(p.environment !== undefined ? { environment: p.environment } : {}),
149
+ ...(p.planDigest !== undefined ? { planDigest: p.planDigest } : {}),
150
+ ...(isGateRunLocator(p.resume) ? { run: p.resume } : {}),
151
+ pending: p,
152
+ };
153
+ if (p.answer) {
154
+ const check = pointGateResumable(p, input.questions?.get(p.answer.id));
155
+ if (!check.resumable) {
156
+ out.push({ ...base, resumable: false, reason: "not-approved", message: check.message });
157
+ continue;
158
+ }
159
+ // The resolution a wave recorded when it went through on this answer, if one did, is what an apply spends.
160
+ const cited = input.resolutions.filter((r) => r.gate === p.gate && r.answer?.id === p.answer!.id && samePlanDigest(r.planDigest, p.planDigest));
161
+ const used = cited.map((r) => ({ r, by: spentBy(applied, p.gate, p.planDigest, r) })).find((x) => x.by);
162
+ if (used?.by) {
163
+ out.push({ ...base, resumable: false, reason: "spent", approvedBy: check.answeredBy, approvedAt: used.r.timestamp, message: spentMessage(used.by), spent: spentOf(used.by) });
164
+ continue;
165
+ }
166
+ out.push({ ...base, resumable: true, reason: "answered", approvedBy: check.answeredBy, ...(check.answeredOn ? { approvedAt: check.answeredOn } : {}), message: `question ${p.answer.id} has an answer the gate passes on` });
167
+ continue;
168
+ }
169
+ const answer = answering(input.resolutions, p, input.pending);
170
+ if (!answer) {
171
+ out.push({ ...base, resumable: false, reason: "not-approved", message: p.approval && !input.answering ? "its gate has an approval block, which needs chant's tally (pass answering)" : "no approval answers it yet" });
172
+ continue;
173
+ }
174
+ const by = spentBy(applied, p.gate, p.planDigest, answer);
175
+ if (by) {
176
+ out.push({ ...base, resumable: false, reason: "spent", approvedBy: answer.resolvedBy, approvedAt: answer.timestamp, message: spentMessage(by), spent: spentOf(by) });
177
+ continue;
178
+ }
179
+ out.push({ ...base, resumable: true, reason: "approved", approvedBy: answer.resolvedBy, approvedAt: answer.timestamp, message: `approved by ${answer.resolvedBy} at ${answer.timestamp}` });
180
+ }
181
+
182
+ for (const line of stoppedApplies(applied, finished, input.pending, input.gate)) {
183
+ out.push({
184
+ source: "stopped",
185
+ op: line.op || input.op,
186
+ gate: line.gate,
187
+ planDigest: line.planDigest,
188
+ resumable: true,
189
+ reason: "stopped",
190
+ approvedBy: line.approvedBy,
191
+ approvedAt: line.approvedAt,
192
+ run: line.resume!,
193
+ stopped: { appliedAt: line.timestamp, approvedAt: line.approvedAt, kind: line.kind, ...(line.wave ? { wave: line.wave } : {}) },
194
+ message: `the apply of ${line.planDigest} under the approval by ${line.approvedBy || "?"} began at ${line.timestamp} and never recorded how it ended; it resumes once run ${line.resume!.run} is gone`,
195
+ });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ const spentOf = (a: AppliedGateLine): NonNullable<GateResumeCandidate["spent"]> => ({ appliedAt: a.timestamp, approvedAt: a.approvedAt, ...(a.wave ? { wave: a.wave } : {}) });
201
+ const spentMessage = (a: AppliedGateLine): string =>
202
+ `the approval that answers it is spent: ${a.wave ? `wave ${a.wave.number} (${a.wave.name})` : "a wave"} applied under it at ${a.timestamp}`;
203
+
204
+ /**
205
+ * Per gate, the apply that may have stopped: the gate's newest applied line,
206
+ * or the line that names the same approval and carries a run locator, with
207
+ * no finished line for it and no pending fact of the gate since.
208
+ */
209
+ function stoppedApplies(applied: readonly AppliedGateLine[], finished: readonly FinishedGateRecord[], pending: readonly PendingGateRecord[], gate?: string): AppliedGateLine[] {
210
+ const newest = new Map<string, AppliedGateLine>();
211
+ for (const a of applied) {
212
+ if (gate !== undefined && a.gate !== gate) continue;
213
+ const prior = newest.get(a.gate);
214
+ if (!prior || time(a.timestamp) >= time(prior.timestamp)) newest.set(a.gate, a);
215
+ }
216
+ const out: AppliedGateLine[] = [];
217
+ for (const [g, last] of newest) {
218
+ const sameApply = applied.filter(
219
+ (a) => a.gate === g && samePlanDigest(a.planDigest, last.planDigest) && time(a.approvedAt) === time(last.approvedAt) && isGateRunLocator(a.resume),
220
+ );
221
+ const line = sameApply.reduce<AppliedGateLine | undefined>((n, a) => (!n || time(a.timestamp) >= time(n.timestamp) ? a : n), undefined);
222
+ if (!line) continue;
223
+ if (sameApply.some((a) => finishedApply(finished, a))) continue;
224
+ if (pending.some((p) => p.gate === g && time(p.timestamp) > time(line.timestamp))) continue;
225
+ out.push(line);
226
+ }
227
+ return out;
228
+ }
@@ -138,6 +138,24 @@ describe("resumeGateRun", () => {
138
138
  expect(await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: again.fetch })).toMatchObject({ status: "skipped" });
139
139
  });
140
140
 
141
+ test("dryRun reads each forge as a resume would and starts nothing (#3858)", async () => {
142
+ const gh = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 1 } });
143
+ expect(await resumeGateRun(github, { token: "t", fetch: gh.fetch, dryRun: true })).toMatchObject({ status: "would-resume", how: "would re-run the failed jobs of run 42" });
144
+ const gitlab: GateRunLocator = { forge: "gitlab", api: "https://gl/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" };
145
+ const gl = fakeForge({
146
+ "GET /api/v4/projects/7/jobs/555": { status: "canceled", name: "wave-2-staging" },
147
+ "GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 555, name: "wave-2-staging" }],
148
+ });
149
+ expect(await resumeGateRun(gitlab, { token: "t", fetch: gl.fetch, dryRun: true })).toMatchObject({ status: "would-resume", how: "would retry job 555" });
150
+ const forgejo: GateRunLocator = { forge: "forgejo", api: "https://fj/api/v1", repo: "acme/db", run: "9", workflow: "migrations.yml", ref: "main" };
151
+ const fj = fakeForge({
152
+ "GET /api/v1/repos/acme/db/actions/runs/9": { status: "cancelled" },
153
+ "GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [] },
154
+ });
155
+ expect(await resumeGateRun(forgejo, { token: "t", fetch: fj.fetch, dryRun: true })).toMatchObject({ status: "would-resume", how: "would dispatch migrations.yml on main" });
156
+ expect([...gh.calls, ...gl.calls, ...fj.calls].every((c) => c.method === "GET")).toBe(true);
157
+ });
158
+
141
159
  test("a refusal from the forge is thrown with its status", async () => {
142
160
  const fetch: ForgeFetch = async () => ({ ok: false, status: 403, text: async () => "Resource not accessible by integration" });
143
161
  await expect(resumeGateRun(github, { token: "t", fetch })).rejects.toThrow(/403/);
@@ -1,149 +1,26 @@
1
1
  /**
2
- * Resume the job that waits at a gate (#3683).
3
- *
4
- * A CI job that reaches a gate nobody approved records a pending fact on
5
- * `chant/lifecycle` and exits 3. Nothing re-runs it when the approval lands,
6
- * because a push to the ledger branch starts no pipeline. This module closes
7
- * that loop through the forge's API.
8
- *
9
- * When the job runs in CI, the pending fact it records carries a
10
- * {@link GateRunLocator}, read from the job's environment by
11
- * {@link resolveGateRunLocator}: which forge, the API base, the repository,
12
- * the run, and what the forge needs to start it again. After an approval,
13
- * {@link resumeGateRun} starts it again:
14
- *
15
- * | Forge | Call | Token |
16
- * |---|---|---|
17
- * | GitHub | `POST /repos/{repo}/actions/runs/{run}/rerun-failed-jobs` | `actions: write` (the job's `GITHUB_TOKEN` with that permission, or a fine-grained token with Actions read and write) |
18
- * | GitLab | `POST /projects/{id}/jobs/{job}/retry` | a project or personal access token with the `api` scope and the Developer role; `CI_JOB_TOKEN` cannot retry jobs |
19
- * | Forgejo | `POST /repos/{repo}/actions/workflows/{file}/dispatches` on the run's branch | a token with the `write:repository` scope; Forgejo has no API to re-run a run, so it starts a new run of the workflow |
20
- *
21
- * GitHub re-runs the failed job and the jobs that need it, at the same
22
- * commit. GitLab retries the job, and the jobs it skipped run after it. A
23
- * Forgejo dispatch runs the whole workflow again at the branch's head, so the
24
- * jobs before the gate run again too; they must be safe to repeat (a wave
25
- * whose runs already applied plans nothing and applies nothing new).
26
- *
27
- * Resuming approves nothing. The resumed job decides the gate again from the
28
- * ledger, so resuming a run whose approval does not count only makes it wait
29
- * again. {@link resumeGateRun} skips a run that is still going, that ended
30
- * well, or that was already resumed (a later GitHub attempt, a newer GitLab
31
- * job of the same name, a newer Forgejo run of the workflow), so a scheduled
32
- * resume job can call it every few minutes without starting a run twice.
2
+ * Resume the job that waits at a gate (#3683). The forge calls,
3
+ * {@link resumeGateRun} and {@link resumeTokenFromEnv}, live in
4
+ * `../gate-resume-run.ts`, which bundles without the workspace code (#3854),
5
+ * and are re-exported here so `@intentius/chant/op/gate-resume` keeps working.
6
+ * This module adds {@link resolutionAnswering}, which decides whether an
7
+ * approval answers a pending fact the way a run decides the gate, and so reads
8
+ * `./gate`'s quorum rules.
33
9
  */
34
10
 
35
- import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
36
11
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
37
12
  import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
38
13
  import { humanQuorumSince, tallyGateApprovals } from "./gate";
39
- import type { GateRunLocator } from "./gate-run-locator";
40
14
 
41
- // The locator is read where a pending fact is recorded, which `@intentius/chant/point-decision` does too, so it lives on its own (#3835).
42
- export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./gate-run-locator";
43
-
44
- /** What {@link resumeGateRun} did. */
45
- export type GateResumeOutcome =
46
- | { status: "resumed"; forge: ForgeKind; how: string; url?: string }
47
- | { status: "skipped"; forge: ForgeKind; reason: string; url?: string };
48
-
49
- export interface ResumeGateRunOptions {
50
- token: string;
51
- /** The approval's time. A Forgejo run of the workflow created after it means the run was already resumed. */
52
- since?: string;
53
- fetch?: ForgeFetch;
54
- }
55
-
56
- const trimSlash = (s: string): string => s.replace(/\/+$/, "");
57
-
58
- const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
59
-
60
- function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
61
- return async (method: string, path: string, body?: unknown): Promise<unknown> => {
62
- const url = `${trimSlash(base)}${path}`;
63
- const res = await fetchImpl(url, {
64
- method,
65
- headers: { ...headers, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
66
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
67
- });
68
- const text = await res.text();
69
- if (!res.ok) throw new ForgeApiError(method, url, res.status, text);
70
- if (!text) return null;
71
- try {
72
- return JSON.parse(text);
73
- } catch {
74
- return text;
75
- }
76
- };
77
- }
78
-
79
- /**
80
- * Start the job a pending fact's locator names again, or say why not. Throws
81
- * {@link ForgeApiError} when the forge refuses a call, such as a token
82
- * without the access the table in the module doc lists.
83
- */
84
- export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOptions): Promise<GateResumeOutcome> {
85
- const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
86
- const url = loc.url ? { url: loc.url } : {};
87
- const skip = (reason: string): GateResumeOutcome => ({ status: "skipped", forge: loc.forge, reason, ...url });
88
-
89
- if (loc.forge === "github") {
90
- const call = client(loc.api, { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }, fetchImpl);
91
- const path = `/repos/${loc.repo}/actions/runs/${loc.run}`;
92
- const run = await call("GET", path);
93
- if (!isObject(run)) throw new Error(`GET ${path} returned no run`);
94
- if (run.status !== "completed") return skip(`run ${loc.run} is ${String(run.status)}, not finished`);
95
- const attempt = typeof run.run_attempt === "number" ? run.run_attempt : undefined;
96
- if (loc.attempt !== undefined && attempt !== undefined && attempt > loc.attempt) {
97
- return skip(`run ${loc.run} was already re-run (attempt ${attempt}; attempt ${loc.attempt} waited)`);
98
- }
99
- if (run.conclusion === "success") return skip(`run ${loc.run} succeeded`);
100
- await call("POST", `${path}/rerun-failed-jobs`, {});
101
- return { status: "resumed", forge: "github", how: `re-ran the failed jobs of run ${loc.run}`, ...url };
102
- }
103
-
104
- if (loc.forge === "gitlab") {
105
- const call = client(loc.api, { "PRIVATE-TOKEN": opts.token }, fetchImpl);
106
- const project = `/projects/${encodeURIComponent(decodeURIComponent(loc.repo))}`;
107
- const job = await call("GET", `${project}/jobs/${loc.job}`);
108
- if (!isObject(job)) throw new Error(`GET ${project}/jobs/${loc.job} returned no job`);
109
- const status = String(job.status);
110
- if (status === "success") return skip(`job ${loc.job} succeeded`);
111
- if (!["failed", "canceled", "skipped"].includes(status)) return skip(`job ${loc.job} is ${status}, not finished`);
112
- const name = loc.jobName ?? (typeof job.name === "string" ? job.name : undefined);
113
- if (name) {
114
- // The pipeline's jobs list leaves retried jobs out, so the job of this name is its newest try.
115
- const jobs = await call("GET", `${project}/pipelines/${loc.run}/jobs?per_page=100`);
116
- const newest = Array.isArray(jobs) ? jobs.find((j) => isObject(j) && j.name === name) : undefined;
117
- if (isObject(newest) && String(newest.id) !== String(loc.job)) {
118
- return skip(`job ${loc.job} (${name}) was already retried as job ${String(newest.id)}`);
119
- }
120
- }
121
- const retried = await call("POST", `${project}/jobs/${loc.job}/retry`);
122
- const id = isObject(retried) && retried.id !== undefined ? ` as job ${String(retried.id)}` : "";
123
- return { status: "resumed", forge: "gitlab", how: `retried job ${loc.job}${id}`, ...url };
124
- }
125
-
126
- const call = client(loc.api, { Authorization: `token ${opts.token}`, Accept: "application/json" }, fetchImpl);
127
- const repo = `/repos/${loc.repo}`;
128
- const run = await call("GET", `${repo}/actions/runs/${loc.run}`);
129
- if (!isObject(run)) throw new Error(`GET ${repo}/actions/runs/${loc.run} returned no run`);
130
- const status = String(run.status);
131
- if (status === "success") return skip(`run ${loc.run} succeeded`);
132
- if (["waiting", "running", "blocked", "unknown"].includes(status)) return skip(`run ${loc.run} is ${status}, not finished`);
133
- const listed = await call("GET", `${repo}/actions/runs?workflow_id=${encodeURIComponent(loc.workflow!)}&limit=50`);
134
- const runs = isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : Array.isArray(listed) ? listed : [];
135
- // Forgejo reports `created` in whole seconds.
136
- const since = opts.since ? Math.floor(new Date(opts.since).getTime() / 1000) * 1000 : undefined;
137
- const later = runs.find(
138
- (r) =>
139
- isObject(r) && Number(r.id) > Number(loc.run) &&
140
- (r.prettyref === undefined || r.prettyref === loc.ref) &&
141
- (since === undefined || (typeof r.created === "string" && new Date(r.created).getTime() >= since)),
142
- );
143
- if (isObject(later)) return skip(`run ${String(later.id)} of ${loc.workflow} started after the approval`);
144
- await call("POST", `${repo}/actions/workflows/${encodeURIComponent(loc.workflow!)}/dispatches`, { ref: loc.ref });
145
- return { status: "resumed", forge: "forgejo", how: `dispatched ${loc.workflow} on ${loc.ref}`, ...url };
146
- }
15
+ export {
16
+ isGateRunLocator,
17
+ resolveGateRunLocator,
18
+ resumeGateRun,
19
+ resumeTokenFromEnv,
20
+ type GateResumeOutcome,
21
+ type GateRunLocator,
22
+ type ResumeGateRunOptions,
23
+ } from "../gate-resume-run";
147
24
 
148
25
  /**
149
26
  * The resolution that answers `standing`, read the way a run decides the gate
@@ -167,14 +44,3 @@ export function resolutionAnswering(
167
44
  }
168
45
  return latestResolutionForPlan(mine, standing.gate, standing.timestamp, standing.planDigest).resolution;
169
46
  }
170
-
171
- /**
172
- * The token a resume uses: `CHANT_FORGE_TOKEN`, else the forge's own
173
- * (`GITHUB_TOKEN` or `GH_TOKEN` on GitHub and Forgejo, `GITLAB_TOKEN` on
174
- * GitLab), the same order the pull-request loop reads (`../pr-forge.ts`).
175
- */
176
- export function resumeTokenFromEnv(forge: ForgeKind, env: Record<string, string | undefined> = process.env): string | undefined {
177
- if (env.CHANT_FORGE_TOKEN) return env.CHANT_FORGE_TOKEN;
178
- return forge === "gitlab" ? env.GITLAB_TOKEN : env.GITHUB_TOKEN ?? env.GH_TOKEN;
179
- }
180
-
@@ -70,23 +70,9 @@ export function bothRules(a: GateApprovalRule | null, b: GateApprovalRule | null
70
70
 
71
71
  // ── pr-review ─────────────────────────────────────────────────────────────
72
72
 
73
- /** The digests each wave planned at one commit, recorded for a review of it to approve. */
74
- export interface HeadPlans {
75
- version: 1;
76
- /** The Op waves spec's name. */
77
- name: string;
78
- /** The commit the waves were planned at: a pull request's head. */
79
- head: string;
80
- timestamp: string;
81
- waves: Array<{ wave: number; name: string; digest: string }>;
82
- }
73
+ // The record and its reader live apart, so a bundle can import them without git (#3865).
74
+ export { HEAD_PLANS_DIR, headPlansPath, type HeadPlans } from "../lifecycle/wave-plans";
83
75
 
84
- /** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
85
- export const HEAD_PLANS_DIR = "_wave-plans";
86
-
87
- export function headPlansPath(name: string, head: string): { dir: string; file: string } {
88
- return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
89
- }
90
76
 
91
77
  /** The merged pull request's review, as a `pr-review` gate reads it. */
92
78
  export interface MergedReview {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Where a CI job that waits at a gate runs, and what its forge needs to start
3
3
  * it again (#3683). Read from the job's environment when a pending fact is
4
- * recorded; `./gate-resume.ts` uses it to resume the job. It imports nothing
4
+ * recorded; `../gate-resume-run.ts` uses it to resume the job. It imports nothing
5
5
  * at run time, so `@intentius/chant/point-decision` records it on the pending
6
6
  * fact of a gate that asks a decision point too (#3835).
7
7
  */
package/src/op/gate.ts CHANGED
@@ -53,7 +53,7 @@ import { sortedJsonReplacer } from "../utils";
53
53
  import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from "./gate-approval";
54
54
  import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
55
55
  import { parseDuration } from "./duration";
56
- import { resolveGateRunLocator } from "./gate-resume";
56
+ import { resolveGateRunLocator } from "./gate-run-locator";
57
57
 
58
58
  /**
59
59
  * What appending a pending fact learned about reaching the remote.
package/src/op/index.ts CHANGED
@@ -121,6 +121,8 @@ export type { OpRunContext, PassedGate } from "./run-context";
121
121
  export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch, gateIsSealed } from "./gate";
122
122
  export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
123
123
  export { tallyGateApprovals, humanQuorumSince, approverOf } from "./gate";
124
+ export { planGateResumes, standingGateFacts } from "./gate-resume-plan";
125
+ export type { GateResumeCandidate, GateResumeReason, GateAnswering, PlanGateResumesInput } from "./gate-resume-plan";
124
126
  export {
125
127
  gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, isGatePolicyRef, loadGatePolicyEvaluator, GATE_APPROVAL_MODES,
126
128
  } from "./gate-approval";
@@ -0,0 +1,148 @@
1
+ /**
2
+ * #3863: a wave gate that asks a decision point (#3836) under a spec that is
3
+ * `neverOverMcp` records that on its pending fact, so an answer to its
4
+ * question over MCP is refused (`answer-never-over-mcp`, #3817) and the same
5
+ * answer from a shell passes the wave.
6
+ *
7
+ * The workspace is real (a git repository with the reference workspace's
8
+ * answer kind, a points file and a trust policy at base), and so is the
9
+ * asker. The run's pending facts go to the workspace's own `chant/lifecycle`,
10
+ * where `points answer` reads them; the run reads them back from memory, as a
11
+ * repository without a remote cannot fetch the ledger branch.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
17
+ import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
18
+ import { runWorkspacePoints } from "../workspace/points-cli";
19
+ import type { CommandContext } from "../cli/registry";
20
+ import { createWorkspaceTools } from "../cli/mcp/workspace-tools";
21
+ import { resetGateOrigin, setGateOrigin } from "../lifecycle/gate-origin";
22
+ import { appendPendingGate } from "../lifecycle/gate-ledger";
23
+ import { memoryGateLedgerPort, type GateLedgerPort } from "./gate";
24
+ import type { OpWavesSpec } from "./op-waves";
25
+ import { runOpWave, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
26
+ import { workspaceGatePointAsker } from "./gate-point-run";
27
+
28
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
29
+
30
+ const POINTS = {
31
+ points: {
32
+ "prod-apply": {
33
+ title: "Apply this plan to prod",
34
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
35
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
36
+ deciders: [{ kind: "quorum", count: 2, roles: ["lead"] }],
37
+ },
38
+ },
39
+ };
40
+
41
+ const spec: OpWavesSpec = {
42
+ name: "tf-apply",
43
+ op: "tf-apply",
44
+ gateName: "wave-{k}",
45
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
46
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
47
+ point: { name: "prod-apply" },
48
+ neverOverMcp: true,
49
+ waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "nat" }] }],
50
+ };
51
+
52
+ let root: string;
53
+ const applied: string[] = [];
54
+
55
+ const exec: OpWaveExec = (argv, dir) => {
56
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
57
+ if (argv[1] === "plan") {
58
+ writeFileSync(join(dir, argv[4]!), JSON.stringify({
59
+ runs: runs.runs.map((r, i) => ({ target: r.target, planDigest: digest(String(i + 1)) })),
60
+ pending: { commit: "c".repeat(40) },
61
+ }));
62
+ }
63
+ if (argv[1] === "apply") applied.push(...runs.runs.map((r) => r.target));
64
+ return 0;
65
+ };
66
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
67
+
68
+ /** The in-memory port, with each pending fact also appended to the workspace's chant/lifecycle. */
69
+ function ledgerPort(): GateLedgerPort & { appended: ReturnType<typeof memoryGateLedgerPort>["appended"] } {
70
+ const memory = memoryGateLedgerPort();
71
+ return {
72
+ ...memory,
73
+ async appendPending(input) {
74
+ await appendPendingGate(input, { cwd: root });
75
+ return memory.appendPending(input);
76
+ },
77
+ };
78
+ }
79
+
80
+ type Doc = Record<string, unknown> & { error?: { code: string; message: string } };
81
+
82
+ /** The `points-answer` tool, as `chant serve mcp` serves it in the workspace. */
83
+ async function overMcp(params: Record<string, unknown>): Promise<Doc> {
84
+ const tool = createWorkspaceTools({ cwd: root }).find((t) => t.definition.name === "points-answer")!;
85
+ return (await tool.handler(params, {} as never)) as Doc;
86
+ }
87
+
88
+ /** `chant workspace points answer <id> ...` through the command's handler, in the workspace. */
89
+ async function shell(id: string, by: string[], answer: string): Promise<Doc> {
90
+ const printed: string[] = [];
91
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
92
+ printed.push(String(line));
93
+ });
94
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
95
+ const was = process.cwd();
96
+ process.chdir(root);
97
+ try {
98
+ const ctx = { args: { extraPositional: "answer", extraPositional2: id, answer, bys: by, json: true }, plugins: [], serializers: [] } as unknown as CommandContext;
99
+ await runWorkspacePoints(ctx);
100
+ } finally {
101
+ process.chdir(was);
102
+ log.mockRestore();
103
+ err.mockRestore();
104
+ }
105
+ return JSON.parse(printed.join("\n")) as Doc;
106
+ }
107
+
108
+ beforeAll(() => {
109
+ root = workspace(POINTS);
110
+ vi.stubEnv("CI", "");
111
+ mkdirSync(join(root, ".chant"), { recursive: true });
112
+ writeFileSync(join(root, ".chant", "trust.json"), JSON.stringify({ schema: 1, roles: { agent: ["bot"], lead: ["alice", "bob"] } }));
113
+ git(root, "add", "-A");
114
+ git(root, "commit", "-q", "-m", "trust");
115
+ });
116
+
117
+ afterEach(() => resetGateOrigin());
118
+ afterAll(cleanScratch);
119
+
120
+ describe("a neverOverMcp wave's point gate is not answered over MCP (#3863)", () => {
121
+ test("the pending fact carries neverOverMcp and the plans file's fields; an MCP answer is refused; a shell answer passes the wave", async () => {
122
+ const gates = ledgerPort();
123
+ const asker = workspaceGatePointAsker(root);
124
+ const run = (now: string) => runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: root, exec, show, gates, points: asker, now, log: () => {} });
125
+
126
+ const waiting = await run("2026-10-11T10:00:00.000Z");
127
+ expect(waiting.exitCode).toBe(3);
128
+ const id = waiting.decision!.point!.question!.id;
129
+ expect(gates.appended[0]).toMatchObject({ op: "tf-apply", gate: "wave-1", commit: "c".repeat(40), neverOverMcp: true, answer: { point: "prod-apply", id } });
130
+
131
+ setGateOrigin("mcp");
132
+ const refused = await overMcp({ id, answer: true, by: ["alice", "bob"] });
133
+ expect(refused.error?.code).toBe("answer-never-over-mcp");
134
+ expect(refused.error?.message).toContain('gate "wave-1" on "tf-apply"');
135
+ setGateOrigin("acp");
136
+ expect((await overMcp({ id, answer: true, by: ["alice", "bob"] })).error?.code).toBe("answer-never-over-mcp");
137
+ resetGateOrigin();
138
+
139
+ expect((await run("2026-10-11T10:05:00.000Z")).exitCode).toBe(3);
140
+ expect(applied).toEqual([]);
141
+
142
+ const answered = await shell(id, ["alice", "bob"], "yes");
143
+ expect(answered).toMatchObject({ verb: "answer", id, written: true, question: { state: "answered", answer: true } });
144
+ const passed = await run("2026-10-11T10:10:00.000Z");
145
+ expect(passed).toMatchObject({ exitCode: 0, decision: { status: "approved", via: "point" } });
146
+ expect(applied).toEqual(["vpc", "nat"]);
147
+ });
148
+ });