@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.
- package/dist/cli/handlers/gate-resume.d.ts +85 -10
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/gate-resume-run.d.ts +87 -0
- package/dist/gate-resume-run.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +44 -1
- package/dist/lifecycle/gate-applied.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +30 -14
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/ledger-port.d.ts +18 -0
- package/dist/lifecycle/ledger-port.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +10 -9
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-read.d.ts +23 -0
- package/dist/lifecycle/release-read.d.ts.map +1 -0
- package/dist/lifecycle/wave-plans.d.ts +51 -0
- package/dist/lifecycle/wave-plans.d.ts.map +1 -0
- package/dist/lint/typescript.d.ts +18 -0
- package/dist/lint/typescript.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-resume-plan.d.ts +113 -0
- package/dist/op/gate-resume-plan.d.ts.map +1 -0
- package/dist/op/gate-resume.d.ts +8 -64
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +1 -20
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/point-decision.d.ts +11 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +140 -2
- package/src/cli/handlers/gate-resume.ts +174 -65
- package/src/cli/main.ts +8 -3
- package/src/codegen/pinned-upgrade.test.ts +8 -1
- package/src/gate-decision-bundle.test.ts +1 -0
- package/src/gate-resume-run-bundle.test.ts +49 -0
- package/src/gate-resume-run.ts +171 -0
- package/src/lifecycle/gate-applied.ts +63 -1
- package/src/lifecycle/gate-ledger.test.ts +7 -1
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/lifecycle/git.ts +61 -18
- package/src/lifecycle/ledger-port.ts +17 -0
- package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
- package/src/lifecycle/ledger-readers.test.ts +139 -0
- package/src/lifecycle/release-ledger.ts +15 -42
- package/src/lifecycle/release-read.ts +69 -0
- package/src/lifecycle/wave-plans.ts +93 -0
- package/src/lint/typescript.ts +18 -0
- package/src/meta/lexicon-typescript-import.test.ts +42 -0
- package/src/op/gate-resume-plan-bundle.test.ts +49 -0
- package/src/op/gate-resume-plan.test.ts +107 -0
- package/src/op/gate-resume-plan.ts +228 -0
- package/src/op/gate-resume.test.ts +18 -0
- package/src/op/gate-resume.ts +16 -150
- package/src/op/gate-review.ts +2 -16
- package/src/op/gate-run-locator.ts +1 -1
- package/src/op/gate.ts +1 -1
- package/src/op/index.ts +2 -0
- package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
- package/src/op/op-waves-point.test.ts +60 -2
- package/src/op/op-waves-run.ts +6 -10
- package/src/point-decision.ts +19 -2
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
2
|
import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
3
3
|
import type { ForgeFetch } from "../../pr-forge";
|
|
4
4
|
import { parseArgs } from "../main";
|
|
5
|
-
import {
|
|
5
|
+
import type { GateResumeCandidate } from "../../op/gate-resume-plan";
|
|
6
|
+
import { resumeCandidate, resumeStanding, resumeWaitingGates } from "./gate-resume";
|
|
7
|
+
|
|
8
|
+
// The ledgers `resumeWaitingGates` reads, without a `chant/lifecycle` branch.
|
|
9
|
+
const ledgers = vi.hoisted(() => new Map<string, { resolutions: unknown[]; pending: unknown[]; malformed: number }>());
|
|
10
|
+
vi.mock("../../lifecycle/gate-ledger", async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<typeof import("../../lifecycle/gate-ledger")>()),
|
|
12
|
+
readGateLedger: async (op: string) => ledgers.get(op) ?? { resolutions: [], pending: [], malformed: 0 },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("../../lifecycle/git", async (importOriginal) => ({
|
|
15
|
+
...(await importOriginal<typeof import("../../lifecycle/git")>()),
|
|
16
|
+
listFilesInDir: async () => [...ledgers.keys()].map((op) => `${op}.jsonl`),
|
|
17
|
+
// No `_gates/<op>/applied.jsonl` (#3858).
|
|
18
|
+
readBlobFromPath: async () => null,
|
|
19
|
+
}));
|
|
6
20
|
|
|
7
21
|
const DIGEST = `sha256:${"a".repeat(64)}`;
|
|
8
22
|
const standing: PendingGateRecord = {
|
|
@@ -48,4 +62,128 @@ describe("resumeStanding (#3683)", () => {
|
|
|
48
62
|
expect(parseArgs(["approve", "migrations", "g", "--resume", "--sign"])).toMatchObject({ resumeRun: true, sign: true });
|
|
49
63
|
expect(parseArgs(["components", "pr-apply", "--resume", "attempt.json"]).resume).toBe("attempt.json");
|
|
50
64
|
});
|
|
65
|
+
|
|
66
|
+
test("dry run: a waiting job would resume without a forge call", async () => {
|
|
67
|
+
const fetch: ForgeFetch = async () => {
|
|
68
|
+
throw new Error("no call expected");
|
|
69
|
+
};
|
|
70
|
+
expect(await resumeStanding(standing, [approval], { env: {}, fetch, dryRun: true })).toMatchObject({
|
|
71
|
+
status: "would-resume",
|
|
72
|
+
resumable: true,
|
|
73
|
+
reason: "approved",
|
|
74
|
+
source: "pending",
|
|
75
|
+
planDigest: DIGEST,
|
|
76
|
+
run: { run: "42" },
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an approval a wave already applied under is spent: no call, in a dry run or not (#3858)", async () => {
|
|
81
|
+
const fetch: ForgeFetch = async () => {
|
|
82
|
+
throw new Error("no call expected");
|
|
83
|
+
};
|
|
84
|
+
const applied = [{ version: 1 as const, kind: "applied" as const, op: "migrations", gate: "migrations-wave-2", planDigest: DIGEST, approvedAt: approval.timestamp, approvedBy: "alice", timestamp: "2026-10-01T02:00:00Z" }];
|
|
85
|
+
for (const dryRun of [true, false]) {
|
|
86
|
+
expect(await resumeStanding(standing, [approval], { env: { GITHUB_TOKEN: "t" }, fetch, dryRun, applied })).toMatchObject({
|
|
87
|
+
status: "spent",
|
|
88
|
+
resumable: false,
|
|
89
|
+
reason: "spent",
|
|
90
|
+
spent: { appliedAt: "2026-10-01T02:00:00Z" },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("a stopped apply (#3858)", () => {
|
|
97
|
+
const stopped: GateResumeCandidate = {
|
|
98
|
+
source: "stopped",
|
|
99
|
+
op: "tf-apply",
|
|
100
|
+
gate: "wave-2",
|
|
101
|
+
planDigest: DIGEST,
|
|
102
|
+
resumable: true,
|
|
103
|
+
reason: "stopped",
|
|
104
|
+
message: "the apply stopped",
|
|
105
|
+
approvedBy: "alice",
|
|
106
|
+
approvedAt: "2026-10-01T01:00:00Z",
|
|
107
|
+
run: { forge: "github", api: "https://api.github.com", repo: "acme/infra", run: "57", attempt: 1 },
|
|
108
|
+
stopped: { appliedAt: "2026-10-01T02:00:00Z", approvedAt: "2026-10-01T01:00:00Z", kind: "changes" },
|
|
109
|
+
};
|
|
110
|
+
const forge = (run: Record<string, unknown>) => {
|
|
111
|
+
const calls: string[] = [];
|
|
112
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
113
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
114
|
+
return { ok: true, status: 200, text: async () => (init.method === "GET" ? JSON.stringify(run) : "") };
|
|
115
|
+
};
|
|
116
|
+
return { calls, fetch };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
test("its run is gone: a dry run says it would resume and starts nothing; a real run re-runs it", async () => {
|
|
120
|
+
const gone = forge({ status: "completed", conclusion: "cancelled", run_attempt: 1 });
|
|
121
|
+
expect(await resumeCandidate(stopped, { env: { GITHUB_TOKEN: "t" }, fetch: gone.fetch, dryRun: true })).toMatchObject({
|
|
122
|
+
status: "would-resume",
|
|
123
|
+
resumable: true,
|
|
124
|
+
stopped: { runGone: true },
|
|
125
|
+
});
|
|
126
|
+
expect(gone.calls).toEqual(["GET /repos/acme/infra/actions/runs/57"]);
|
|
127
|
+
expect(await resumeCandidate(stopped, { env: { GITHUB_TOKEN: "t" }, fetch: gone.fetch })).toMatchObject({ status: "resumed", resumable: true });
|
|
128
|
+
expect(gone.calls.at(-1)).toBe("POST /repos/acme/infra/actions/runs/57/rerun-failed-jobs");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("its run still runs: left alone, not resumable", async () => {
|
|
132
|
+
const live = forge({ status: "in_progress", run_attempt: 1 });
|
|
133
|
+
expect(await resumeCandidate(stopped, { env: { GITHUB_TOKEN: "t" }, fetch: live.fetch, dryRun: true })).toMatchObject({
|
|
134
|
+
status: "skipped",
|
|
135
|
+
resumable: false,
|
|
136
|
+
stopped: { runGone: false },
|
|
137
|
+
});
|
|
138
|
+
expect(live.calls).toEqual(["GET /repos/acme/infra/actions/runs/57"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("no token: a dry run cannot tell whether the run is gone, and says so", async () => {
|
|
142
|
+
const report = await resumeCandidate(stopped, { env: {}, dryRun: true });
|
|
143
|
+
expect(report).toMatchObject({ status: "no-token", resumable: false, reason: "stopped" });
|
|
144
|
+
expect(report.stopped?.runGone).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("resume one gate's job (#3854)", () => {
|
|
149
|
+
const wave = (k: number, run: string, op = "tf-apply"): PendingGateRecord => ({
|
|
150
|
+
...standing,
|
|
151
|
+
op,
|
|
152
|
+
gate: `${op}-wave-${k}`,
|
|
153
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
154
|
+
resume: { forge: "github", api: "https://api.github.com", repo: "acme/infra", run, attempt: 1 },
|
|
155
|
+
});
|
|
156
|
+
const approve = (p: PendingGateRecord): GateResolutionRecord => ({ ...approval, op: p.op, gate: p.gate });
|
|
157
|
+
ledgers.set("tf-apply", {
|
|
158
|
+
pending: [wave(2, "20"), wave(3, "30")],
|
|
159
|
+
resolutions: [approve(wave(2, "20")), approve(wave(3, "30"))],
|
|
160
|
+
malformed: 0,
|
|
161
|
+
});
|
|
162
|
+
ledgers.set("other", { pending: [wave(1, "10", "other")], resolutions: [approve(wave(1, "10", "other"))], malformed: 0 });
|
|
163
|
+
|
|
164
|
+
test("without a gate, every approved job of the op is resumed", async () => {
|
|
165
|
+
const reports = await resumeWaitingGates({ op: "tf-apply", dryRun: true });
|
|
166
|
+
expect(reports.map((r) => r.gate).sort()).toEqual(["tf-apply-wave-2", "tf-apply-wave-3"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("with a gate, only that gate's job is touched", async () => {
|
|
170
|
+
const calls: string[] = [];
|
|
171
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
172
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
173
|
+
const body = init.method === "GET" ? { status: "completed", conclusion: "failure", run_attempt: 1 } : null;
|
|
174
|
+
return { ok: true, status: 200, text: async () => (body ? JSON.stringify(body) : "") };
|
|
175
|
+
};
|
|
176
|
+
const reports = await resumeWaitingGates({ op: "tf-apply", gate: "tf-apply-wave-3", env: { GITHUB_TOKEN: "t" }, fetch });
|
|
177
|
+
expect(reports).toEqual([expect.objectContaining({ gate: "tf-apply-wave-3", status: "resumed" })]);
|
|
178
|
+
expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/30", "POST /repos/acme/infra/actions/runs/30/rerun-failed-jobs"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a gate without an op looks in every ledger; an unknown gate resumes nothing", async () => {
|
|
182
|
+
expect((await resumeWaitingGates({ gate: "other-wave-1", dryRun: true })).map((r) => `${r.op}/${r.gate}`)).toEqual(["other/other-wave-1"]);
|
|
183
|
+
expect(await resumeWaitingGates({ op: "tf-apply", gate: "tf-apply-wave-9", dryRun: true })).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("`chant run resume` takes --op and --gate", () => {
|
|
187
|
+
expect(parseArgs(["run", "resume", "--op", "tf-apply", "--gate", "tf-apply-wave-3"])).toMatchObject({ op: "tf-apply", gate: "tf-apply-wave-3" });
|
|
188
|
+
});
|
|
51
189
|
});
|
|
@@ -10,51 +10,71 @@
|
|
|
10
10
|
* question has an answer that passes the gate (`pointGateResumable`, #3835),
|
|
11
11
|
* and `chant workspace points answer --resume` resumes the job right after
|
|
12
12
|
* the answer.
|
|
13
|
+
*
|
|
14
|
+
* What to resume is decided from the ledger by `planGateResumes`
|
|
15
|
+
* (`../../op/gate-resume-plan.ts`, #3858): an approval a wave already applied
|
|
16
|
+
* under is spent and resumes nothing, and an apply under an approval that
|
|
17
|
+
* stopped (an applied line with a run locator and no finished line) resumes
|
|
18
|
+
* once the forge says its run is gone.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import {
|
|
16
22
|
GATES_DIR,
|
|
17
|
-
isPendingGateExpired,
|
|
18
23
|
readGateLedger,
|
|
19
24
|
type GateResolutionRecord,
|
|
20
25
|
type PendingGateRecord,
|
|
21
26
|
} from "../../lifecycle/gate-ledger";
|
|
22
|
-
import {
|
|
27
|
+
import { appliedGatePath, parseAppliedGateFile, type AppliedGateLine, type FinishedGateRecord } from "../../lifecycle/gate-applied";
|
|
28
|
+
import { listFilesInDir, pushLifecycleStatus, readBlobFromPath, requireLifecycleLedger } from "../../lifecycle/git";
|
|
23
29
|
import { parseGateSubject } from "../../op/gate-point";
|
|
24
30
|
import {
|
|
25
|
-
isGateRunLocator,
|
|
26
31
|
resolutionAnswering,
|
|
27
32
|
resumeGateRun,
|
|
28
33
|
resumeTokenFromEnv,
|
|
29
34
|
type GateResumeOutcome,
|
|
30
35
|
} from "../../op/gate-resume";
|
|
31
|
-
import {
|
|
36
|
+
import { planGateResumes, type GateResumeCandidate, type GateResumeReason } from "../../op/gate-resume-plan";
|
|
37
|
+
import type { GateRunLocator } from "../../op/gate-run-locator";
|
|
38
|
+
import type { PointQuestionState } from "../../point-decision";
|
|
32
39
|
import type { ForgeFetch } from "../../pr-forge";
|
|
33
40
|
import { formatError, formatInfo, formatSuccess, formatWarning } from "../format";
|
|
34
41
|
import type { CommandContext } from "../registry";
|
|
35
42
|
|
|
36
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* One gate a resume looked at and what it did. With `--json`, `chant run
|
|
45
|
+
* resume` prints these as `{ reports }`; under `--dry-run` it starts nothing,
|
|
46
|
+
* so a product that resumes in its own job reads `resumable` and `reason`
|
|
47
|
+
* (#3858).
|
|
48
|
+
*/
|
|
37
49
|
export interface GateResumeReport {
|
|
38
50
|
op: string;
|
|
39
51
|
gate: string;
|
|
40
52
|
environment?: string;
|
|
41
|
-
/**
|
|
42
|
-
|
|
53
|
+
/** A standing pending fact, or an apply under an approval that stopped (#3858). */
|
|
54
|
+
source: GateResumeCandidate["source"];
|
|
55
|
+
/**
|
|
56
|
+
* What happened. `resumed`, `skipped` and `would-resume` (`--dry-run`) come
|
|
57
|
+
* from the forge; the rest are decided before any call.
|
|
58
|
+
*/
|
|
59
|
+
status: GateResumeOutcome["status"] | "not-approved" | "spent" | "no-job" | "no-token" | "failed";
|
|
60
|
+
/** Why, from the ledger: `approved`, `answered`, `not-approved`, `spent` or `stopped`. */
|
|
61
|
+
reason: GateResumeReason;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the gate's job should start again: an approval or a passing
|
|
64
|
+
* answer stands and is not spent, or an apply stopped and the run that
|
|
65
|
+
* applied is gone. A product that applies in its own job acts on these.
|
|
66
|
+
*/
|
|
67
|
+
resumable: boolean;
|
|
43
68
|
message: string;
|
|
69
|
+
planDigest?: string;
|
|
44
70
|
approvedBy?: string;
|
|
71
|
+
approvedAt?: string;
|
|
45
72
|
url?: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const p of pending) {
|
|
52
|
-
if (gate !== undefined && p.gate !== gate) continue;
|
|
53
|
-
const key = `${p.gate}\u0000${p.environment ?? ""}`;
|
|
54
|
-
const prior = newest.get(key);
|
|
55
|
-
if (!prior || new Date(p.timestamp).getTime() >= new Date(prior.timestamp).getTime()) newest.set(key, p);
|
|
56
|
-
}
|
|
57
|
-
return [...newest.values()];
|
|
73
|
+
/** The job a resume starts again. */
|
|
74
|
+
run?: GateRunLocator;
|
|
75
|
+
spent?: GateResumeCandidate["spent"];
|
|
76
|
+
/** `runGone` is what the forge said about the run that applied; absent when it was not asked. */
|
|
77
|
+
stopped?: GateResumeCandidate["stopped"] & { runGone?: boolean };
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
export interface ResumeGateOptions {
|
|
@@ -68,6 +88,9 @@ export interface ResumeGateOptions {
|
|
|
68
88
|
* when a fact needs them and none are given.
|
|
69
89
|
*/
|
|
70
90
|
questions?: ReadonlyMap<string, PointQuestionState>;
|
|
91
|
+
/** The lines of `_gates/<op>/applied.jsonl` (#3858). Read from `chant/lifecycle` by {@link resumeOpGates} when not given. */
|
|
92
|
+
applied?: readonly AppliedGateLine[];
|
|
93
|
+
finished?: readonly FinishedGateRecord[];
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
/** Every question the workspace at `cwd` lists, by id, the lifecycle ledger's included. Empty when the workspace can't be read. */
|
|
@@ -78,64 +101,117 @@ export async function workspaceQuestions(cwd: string): Promise<Map<string, Point
|
|
|
78
101
|
return new Map(doc.questions.map((q) => [q.id, { id: q.id, state: q.state, answer: q.answer, answeredBy: q.answeredBy, answeredOn: q.answeredOn }]));
|
|
79
102
|
}
|
|
80
103
|
|
|
81
|
-
/** Resume the job behind one standing pending fact, when an approval answers it. */
|
|
104
|
+
/** Resume the job behind one standing pending fact, when an approval answers it and is not spent. */
|
|
82
105
|
export async function resumeStanding(
|
|
83
106
|
standing: PendingGateRecord,
|
|
84
107
|
resolutions: readonly GateResolutionRecord[],
|
|
85
108
|
opts: ResumeGateOptions = {},
|
|
86
109
|
pending: readonly PendingGateRecord[] = [standing],
|
|
87
110
|
): Promise<GateResumeReport> {
|
|
88
|
-
const
|
|
111
|
+
const [candidate] = planGateResumes({
|
|
89
112
|
op: standing.op,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
pending: [standing],
|
|
114
|
+
resolutions,
|
|
115
|
+
applied: opts.applied ?? [],
|
|
116
|
+
now: standing.timestamp,
|
|
117
|
+
...(opts.questions ? { questions: opts.questions } : {}),
|
|
118
|
+
answering: (r, s) => resolutionAnswering(r, s, pending),
|
|
119
|
+
});
|
|
120
|
+
return resumeCandidate(candidate!, opts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Act on one candidate {@link planGateResumes} decided: start its job again
|
|
125
|
+
* through the forge, or under `dryRun` ask the forge whether it would. A
|
|
126
|
+
* stopped apply is resumed only once the forge says its run is gone.
|
|
127
|
+
*/
|
|
128
|
+
export async function resumeCandidate(c: GateResumeCandidate, opts: ResumeGateOptions = {}): Promise<GateResumeReport> {
|
|
129
|
+
const base: GateResumeReport = {
|
|
130
|
+
op: c.op,
|
|
131
|
+
gate: c.gate,
|
|
132
|
+
...(c.environment !== undefined ? { environment: c.environment } : {}),
|
|
133
|
+
source: c.source,
|
|
134
|
+
status: "not-approved",
|
|
135
|
+
reason: c.reason,
|
|
136
|
+
resumable: c.resumable,
|
|
137
|
+
message: c.message,
|
|
138
|
+
...(c.planDigest !== undefined ? { planDigest: c.planDigest } : {}),
|
|
139
|
+
...(c.approvedBy !== undefined ? { approvedBy: c.approvedBy } : {}),
|
|
140
|
+
...(c.approvedAt !== undefined ? { approvedAt: c.approvedAt } : {}),
|
|
141
|
+
...(c.run?.url ? { url: c.run.url } : {}),
|
|
142
|
+
...(c.run ? { run: c.run } : {}),
|
|
143
|
+
...(c.spent ? { spent: c.spent } : {}),
|
|
144
|
+
...(c.stopped ? { stopped: { ...c.stopped } } : {}),
|
|
93
145
|
};
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
const check = pointGateResumable(standing, opts.questions?.get(standing.answer.id));
|
|
99
|
-
if (!check.resumable) return { ...base, status: "not-approved", message: check.message };
|
|
100
|
-
approvedBy = { approvedBy: check.answeredBy };
|
|
101
|
-
since = check.answeredOn ?? undefined;
|
|
102
|
-
} else {
|
|
103
|
-
const answer = resolutionAnswering(resolutions, standing, pending);
|
|
104
|
-
if (!answer) return { ...base, status: "not-approved", message: "no approval answers it yet" };
|
|
105
|
-
approvedBy = { approvedBy: answer.resolvedBy };
|
|
106
|
-
since = answer.timestamp;
|
|
107
|
-
}
|
|
108
|
-
if (!isGateRunLocator(standing.resume)) {
|
|
109
|
-
return { ...base, ...approvedBy, status: "no-job", message: "its pending fact names no CI job (it was recorded outside CI); run it again by hand" };
|
|
146
|
+
if (c.reason === "spent") return { ...base, status: "spent" };
|
|
147
|
+
if (!c.resumable) return base;
|
|
148
|
+
if (!c.run) {
|
|
149
|
+
return { ...base, status: "no-job", message: "its pending fact names no CI job (it was recorded outside CI); run it again by hand" };
|
|
110
150
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
151
|
+
const stopped = c.source === "stopped";
|
|
152
|
+
const token = resumeTokenFromEnv(c.run.forge, opts.env ?? process.env);
|
|
153
|
+
// A waiting job needs no forge call to say it would resume; a stopped apply needs one to know its run is gone.
|
|
154
|
+
if (opts.dryRun && !stopped) return { ...base, status: "would-resume", message: `would resume ${c.run.forge} run ${c.run.run}; ${c.message}` };
|
|
114
155
|
if (!token) {
|
|
156
|
+
const need = `${c.run.forge === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_TOKEN"}`;
|
|
115
157
|
return {
|
|
116
158
|
...base,
|
|
117
|
-
...
|
|
159
|
+
...(stopped ? { resumable: false } : {}),
|
|
118
160
|
status: "no-token",
|
|
119
|
-
message:
|
|
161
|
+
message: stopped
|
|
162
|
+
? `${c.message}; whether run ${c.run.run} is gone needs a token to read ${c.run.forge} with: set CHANT_FORGE_TOKEN (or ${need})`
|
|
163
|
+
: `no token to call ${c.run.forge} with: set CHANT_FORGE_TOKEN (or ${need})`,
|
|
120
164
|
};
|
|
121
165
|
}
|
|
122
166
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
167
|
+
const since = stopped ? c.stopped!.appliedAt : c.approvedAt;
|
|
168
|
+
const outcome = await resumeGateRun(c.run, { token, ...(since ? { since } : {}), ...(opts.fetch ? { fetch: opts.fetch } : {}), ...(opts.dryRun ? { dryRun: true } : {}) });
|
|
169
|
+
const message = outcome.status === "skipped" ? outcome.reason : outcome.how;
|
|
170
|
+
if (!stopped) return { ...base, status: outcome.status, message };
|
|
171
|
+
const gone = outcome.status !== "skipped";
|
|
172
|
+
return {
|
|
173
|
+
...base,
|
|
174
|
+
status: outcome.status,
|
|
175
|
+
resumable: gone,
|
|
176
|
+
message: gone ? `the apply that began at ${c.stopped!.appliedAt} stopped and its run is gone: ${message}` : `the apply that began at ${c.stopped!.appliedAt} is left alone: ${message}`,
|
|
177
|
+
stopped: { ...c.stopped!, runGone: gone },
|
|
178
|
+
};
|
|
125
179
|
} catch (err) {
|
|
126
|
-
return { ...base,
|
|
180
|
+
return { ...base, status: "failed", message: err instanceof Error ? err.message : String(err) };
|
|
127
181
|
}
|
|
128
182
|
}
|
|
129
183
|
|
|
130
|
-
/**
|
|
184
|
+
/** The `applied` and `finished` lines of `_gates/<op>/applied.jsonl` on `chant/lifecycle`. */
|
|
185
|
+
export async function readAppliedGateFile(op: string): Promise<{ applied: AppliedGateLine[]; finished: FinishedGateRecord[] }> {
|
|
186
|
+
const { dir, file } = appliedGatePath(op);
|
|
187
|
+
const text = await readBlobFromPath(dir, file);
|
|
188
|
+
return text ? parseAppliedGateFile(text) : { applied: [], finished: [] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resume every job waiting on `op`'s gates (or one gate) whose approval
|
|
193
|
+
* arrived and is not spent, and every apply under an approval that stopped
|
|
194
|
+
* once its run is gone (#3858).
|
|
195
|
+
*/
|
|
131
196
|
export async function resumeOpGates(op: string, gate: string | undefined, opts: ResumeGateOptions = {}): Promise<GateResumeReport[]> {
|
|
132
197
|
const ledger = await readGateLedger(op);
|
|
198
|
+
const lines = opts.applied ? { applied: opts.applied, finished: opts.finished ?? [] } : await readAppliedGateFile(op);
|
|
133
199
|
const now = opts.now ?? new Date().toISOString();
|
|
134
|
-
const reports: GateResumeReport[] = [];
|
|
135
|
-
const standing = standingFacts(ledger.pending, gate).filter((p) => !isPendingGateExpired(p, now));
|
|
136
200
|
// Read the workspace's questions once, and only when a fact waits on one (#3835).
|
|
137
|
-
const questions = opts.questions ?? (
|
|
138
|
-
|
|
201
|
+
const questions = opts.questions ?? (ledger.pending.some((p) => p.answer) ? await workspaceQuestions(process.cwd()) : undefined);
|
|
202
|
+
const candidates = planGateResumes({
|
|
203
|
+
op,
|
|
204
|
+
pending: ledger.pending,
|
|
205
|
+
resolutions: ledger.resolutions,
|
|
206
|
+
applied: lines.applied,
|
|
207
|
+
finished: lines.finished,
|
|
208
|
+
now,
|
|
209
|
+
...(gate !== undefined ? { gate } : {}),
|
|
210
|
+
...(questions ? { questions } : {}),
|
|
211
|
+
answering: resolutionAnswering,
|
|
212
|
+
});
|
|
213
|
+
const reports: GateResumeReport[] = [];
|
|
214
|
+
for (const c of candidates) reports.push(await resumeCandidate(c, opts));
|
|
139
215
|
return reports;
|
|
140
216
|
}
|
|
141
217
|
|
|
@@ -144,6 +220,7 @@ function printReport(r: GateResumeReport): void {
|
|
|
144
220
|
const by = r.approvedBy ? `, approved by ${r.approvedBy}` : "";
|
|
145
221
|
const link = r.url ? ` ${r.url}` : "";
|
|
146
222
|
if (r.status === "resumed") console.error(formatSuccess(`${where}${by}: ${r.message}${link}`));
|
|
223
|
+
else if (r.status === "would-resume") console.error(formatInfo(`${where}${by}: ${r.message}${link}`));
|
|
147
224
|
else if (r.status === "failed" || r.status === "no-token") {
|
|
148
225
|
console.error(formatError({
|
|
149
226
|
message: `${where}${by}: not resumed: ${r.message}`,
|
|
@@ -212,11 +289,40 @@ export async function resumeAfterAnswer(question: { id: string; subject: string
|
|
|
212
289
|
return reports.some((r) => r.status === "failed" || r.status === "no-token") ? 1 : 0;
|
|
213
290
|
}
|
|
214
291
|
|
|
292
|
+
/** What {@link resumeWaitingGates} reads. */
|
|
293
|
+
export interface ResumeWaitingOptions extends ResumeGateOptions {
|
|
294
|
+
/** One op's gate ledger; absent, every gate ledger on `chant/lifecycle`. */
|
|
295
|
+
op?: string;
|
|
296
|
+
/** Only this gate's waiting job (#3854); absent, every gate's. */
|
|
297
|
+
gate?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
215
300
|
/**
|
|
216
|
-
* `chant run resume
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
301
|
+
* What `chant run resume` does, callable in process: read the gate ledgers
|
|
302
|
+
* (`op`'s alone, when given) and start again each CI job that waits at a
|
|
303
|
+
* gate an approval now answers. With `gate`, only that gate's job is touched,
|
|
304
|
+
* so a caller that just approved one wave resumes that wave's job and no
|
|
305
|
+
* other (#3854). The lifecycle ledger has to be readable
|
|
306
|
+
* (`requireLifecycleLedger`).
|
|
307
|
+
*/
|
|
308
|
+
export async function resumeWaitingGates(opts: ResumeWaitingOptions = {}): Promise<GateResumeReport[]> {
|
|
309
|
+
const { op, gate, ...rest } = opts;
|
|
310
|
+
const ops = op ? [op] : (await listFilesInDir(GATES_DIR)).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length));
|
|
311
|
+
const reports: GateResumeReport[] = [];
|
|
312
|
+
for (const o of ops) reports.push(...(await resumeOpGates(o, gate, rest)));
|
|
313
|
+
return reports;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `chant run resume [--op <op>] [--gate <gate>] [--dry-run] [--json]`: the
|
|
318
|
+
* scheduled job. Reads every gate ledger on `chant/lifecycle` (or `--op`'s),
|
|
319
|
+
* and starts again each CI job that waits at a gate (or at `--gate` alone)
|
|
320
|
+
* an approval now answers, or at a gate whose decision point now has an
|
|
321
|
+
* answer that passes it (#3835), unless a wave already applied under that
|
|
322
|
+
* approval (#3858). It also starts again the run of an apply under an
|
|
323
|
+
* approval that stopped, once that run is gone. `--dry-run --json` starts
|
|
324
|
+
* nothing and says, per gate, whether it would resume and why, for a product
|
|
325
|
+
* that resumes in its own job.
|
|
220
326
|
*/
|
|
221
327
|
export async function runResumeCommand(ctx: CommandContext): Promise<number> {
|
|
222
328
|
try {
|
|
@@ -225,16 +331,19 @@ export async function runResumeCommand(ctx: CommandContext): Promise<number> {
|
|
|
225
331
|
console.error(formatError({ message: `Cannot read the gate ledgers: ${err instanceof Error ? err.message : String(err)}` }));
|
|
226
332
|
return 1;
|
|
227
333
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
334
|
+
const dryRun = ctx.args.dryRun === true;
|
|
335
|
+
const reports = await resumeWaitingGates({
|
|
336
|
+
...(ctx.args.op ? { op: ctx.args.op } : {}),
|
|
337
|
+
...(ctx.args.gate ? { gate: ctx.args.gate } : {}),
|
|
338
|
+
dryRun,
|
|
339
|
+
});
|
|
340
|
+
if (ctx.args.json) console.log(JSON.stringify({ dryRun, reports }, null, 2));
|
|
234
341
|
else {
|
|
235
342
|
for (const r of reports.filter((r) => r.status !== "not-approved")) printReport(r);
|
|
236
343
|
const waiting = reports.filter((r) => r.status === "not-approved").length;
|
|
237
|
-
|
|
344
|
+
const done = dryRun ? `${reports.filter((r) => r.resumable).length} would resume` : `${reports.filter((r) => r.status === "resumed").length} resumed`;
|
|
345
|
+
console.error(formatInfo(`${done}, ${waiting} still waiting for an approval or a passing answer.`));
|
|
238
346
|
}
|
|
239
|
-
|
|
347
|
+
// A dry run reads; a missing token only leaves a stopped apply unchecked, which its report says.
|
|
348
|
+
return reports.some((r) => r.status === "failed" || (!dryRun && r.status === "no-token")) ? 1 : 0;
|
|
240
349
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -952,9 +952,14 @@ Ops:
|
|
|
952
952
|
--record-plans (no --wave), in a pull request's
|
|
953
953
|
pipeline: plan every wave at its head and record the
|
|
954
954
|
digests a pr-review approval mode compares
|
|
955
|
-
run resume [--op <name>] Start again each CI job that
|
|
956
|
-
approval now answers, through the
|
|
957
|
-
(#3683); approves nothing. --
|
|
955
|
+
run resume [--op <name>] [--gate <name>] Start again each CI job that
|
|
956
|
+
waits at a gate an approval now answers, through the
|
|
957
|
+
forge's API (#3683); approves nothing. --gate resumes
|
|
958
|
+
that gate's job alone (#3854). Skips an approval a
|
|
959
|
+
wave already applied under; restarts an apply that
|
|
960
|
+
stopped once its run is gone (#3858). --dry-run
|
|
961
|
+
starts nothing; with --json it says per gate whether
|
|
962
|
+
it would resume and why
|
|
958
963
|
run --generate <provider> Write one CI pipeline file per scheduled Op for
|
|
959
964
|
github, gitlab or forgejo (#2533), from every Op that
|
|
960
965
|
declares a schedule or from --spec <file.json>.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeAll } from "vitest";
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
@@ -185,6 +185,13 @@ function makeWorkingLexicon(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
describe("checkPinnedUpgrade — no upgrade paths", () => {
|
|
188
|
+
// The first checkPinnedUpgrade loads the k8s lexicon plugin from source, which
|
|
189
|
+
// takes 1.6s on a quiet machine and over the 5s unit budget on a loaded CI
|
|
190
|
+
// shard. beforeAll is outside the per-test budget, so pay it there once.
|
|
191
|
+
beforeAll(async () => {
|
|
192
|
+
await loadUpstreamPin("k8s");
|
|
193
|
+
}, 60_000);
|
|
194
|
+
|
|
188
195
|
test("reports no upgrade when upstream equals pin", async () => {
|
|
189
196
|
const root = mkdtempSync(join(tmpdir(), "chant-up-"));
|
|
190
197
|
try {
|
|
@@ -44,6 +44,7 @@ describe("the gate-decision bundle", () => {
|
|
|
44
44
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
45
45
|
"packages/core/src/notify-verify.ts",
|
|
46
46
|
"packages/core/src/notify.ts",
|
|
47
|
+
"packages/core/src/op/gate-point.ts",
|
|
47
48
|
"packages/core/src/op/op-waves.ts",
|
|
48
49
|
"packages/core/src/workspace/jsonc.ts",
|
|
49
50
|
"packages/core/src/workspace/trust/gate-seal.ts",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate resume bundles small (#3854).
|
|
3
|
+
*
|
|
4
|
+
* terragucci ships one bundled file, and importing `resumeGateRun` from
|
|
5
|
+
* `@intentius/chant/op/gate-resume` brought Ajv along, through `./op/gate`
|
|
6
|
+
* and the workspace's identity rules. `@intentius/chant/gate-resume-run` holds
|
|
7
|
+
* the forge calls, the token lookup and the run locator alone. This bundles it
|
|
8
|
+
* the way terragucci would and reads esbuild's own list of what went in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { build } from "esbuild";
|
|
13
|
+
import { describe, expect, test } from "vitest";
|
|
14
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
15
|
+
|
|
16
|
+
const FORBIDDEN = [/node_modules\//, /\/src\/workspace\//, /\/src\/lifecycle\//, /\/src\/cli\//, /\/src\/op\/gate\.ts$/];
|
|
17
|
+
|
|
18
|
+
describe("the gate-resume-run bundle", () => {
|
|
19
|
+
test("holds the forge calls and the locator, and no package, workspace, ledger or git code", async () => {
|
|
20
|
+
const result = await build({
|
|
21
|
+
entryPoints: [join(REPO, "packages", "core", "src", "gate-resume-run.ts")],
|
|
22
|
+
bundle: true,
|
|
23
|
+
platform: "node",
|
|
24
|
+
format: "esm",
|
|
25
|
+
write: false,
|
|
26
|
+
metafile: true,
|
|
27
|
+
outdir: "out",
|
|
28
|
+
logLevel: "silent",
|
|
29
|
+
});
|
|
30
|
+
const inputs = Object.keys(result.metafile.inputs);
|
|
31
|
+
for (const pattern of FORBIDDEN) {
|
|
32
|
+
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
33
|
+
}
|
|
34
|
+
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
|
|
35
|
+
"packages/core/src/gate-resume-run.ts",
|
|
36
|
+
"packages/core/src/op/gate-run-locator.ts",
|
|
37
|
+
"packages/core/src/pr-forge.ts",
|
|
38
|
+
]);
|
|
39
|
+
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
40
|
+
expect(bytes).toBeLessThan(40 * 1024);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("@intentius/chant/op/gate-resume still exports what it did", async () => {
|
|
44
|
+
const mod = await import("./op/gate-resume");
|
|
45
|
+
for (const name of ["resumeGateRun", "resumeTokenFromEnv", "resolutionAnswering", "isGateRunLocator", "resolveGateRunLocator"]) {
|
|
46
|
+
expect(typeof (mod as Record<string, unknown>)[name], name).toBe("function");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|