@intentius/chant 0.124.9 → 0.124.11

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 (48) 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-audit.d.ts +6 -0
  9. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  10. package/dist/lifecycle/gate-ledger.d.ts +1 -1
  11. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  12. package/dist/op/gate-resume-plan.d.ts +113 -0
  13. package/dist/op/gate-resume-plan.d.ts.map +1 -0
  14. package/dist/op/gate-resume.d.ts +8 -64
  15. package/dist/op/gate-resume.d.ts.map +1 -1
  16. package/dist/op/gate-run-locator.d.ts +1 -1
  17. package/dist/op/index.d.ts +2 -0
  18. package/dist/op/index.d.ts.map +1 -1
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/point-decision.d.ts +11 -0
  21. package/dist/point-decision.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/handlers/gate-resume.test.ts +140 -2
  24. package/src/cli/handlers/gate-resume.ts +174 -65
  25. package/src/cli/main.ts +8 -3
  26. package/src/codegen/pinned-upgrade.test.ts +8 -1
  27. package/src/codegen/publish-order.test.ts +20 -4
  28. package/src/gate-decision-bundle.test.ts +1 -0
  29. package/src/gate-resume-run-bundle.test.ts +49 -0
  30. package/src/gate-resume-run.ts +171 -0
  31. package/src/lifecycle/gate-applied.ts +63 -1
  32. package/src/lifecycle/gate-audit.test.ts +6 -0
  33. package/src/lifecycle/gate-audit.ts +8 -2
  34. package/src/lifecycle/gate-ledger.test.ts +7 -1
  35. package/src/lifecycle/gate-ledger.ts +1 -1
  36. package/src/op/gate-resume-plan-bundle.test.ts +49 -0
  37. package/src/op/gate-resume-plan.test.ts +107 -0
  38. package/src/op/gate-resume-plan.ts +228 -0
  39. package/src/op/gate-resume.test.ts +18 -0
  40. package/src/op/gate-resume.ts +16 -150
  41. package/src/op/gate-run-locator.ts +1 -1
  42. package/src/op/gate.ts +1 -1
  43. package/src/op/index.ts +2 -0
  44. package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
  45. package/src/op/op-waves-point.test.ts +60 -2
  46. package/src/op/op-waves-run.ts +6 -2
  47. package/src/op/op-waves.test.ts +5 -1
  48. package/src/point-decision.ts +19 -2
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 waits at a gate an
956
- approval now answers, through the forge's API
957
- (#3683); approves nothing. --dry-run lists them
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 {
@@ -11,9 +11,10 @@
11
11
  * import existed. Half a release shipped and the failure only surfaced from
12
12
  * npm, after the tag.
13
13
  *
14
- * publish-packages.sh now derives the order instead of assuming one. This
15
- * asserts the derivation is actually topological, so the next lexicon that
16
- * depends on an alphabetically earlier one fails here rather than mid-release.
14
+ * publish-packages.sh now derives the order instead of assuming one, as
15
+ * dependency layers that pack and publish in parallel (#3857). This asserts
16
+ * the derivation is actually topological, so the next lexicon that depends on
17
+ * an alphabetically earlier one fails here rather than mid-release.
17
18
  */
18
19
 
19
20
  import { describe, expect, it } from "vitest";
@@ -34,7 +35,9 @@ function ask(fn: string): string[] {
34
35
  .filter(Boolean);
35
36
  }
36
37
 
37
- const publishOrder = () => ask("publishable_dirs");
38
+ /** `layer dir name version deps` rows, in publish order. */
39
+ const publishLayers = () => ask("publish_layers").map((line) => line.split("\t"));
40
+ const publishOrder = () => publishLayers().map((row) => row[1]);
38
41
 
39
42
  function manifest(dir: string) {
40
43
  return JSON.parse(readFileSync(join(REPO, dir, "package.json"), "utf8"));
@@ -81,6 +84,19 @@ describe("publish order", () => {
81
84
  }
82
85
  });
83
86
 
87
+ it("puts every package in a later layer than its dependencies", () => {
88
+ // #3857: a layer publishes in parallel, so a dependency in the same layer
89
+ // would race its dependent rather than precede it.
90
+ const rows = publishLayers();
91
+ const layer = new Map(rows.map(([l, d]) => [d, Number(l)]));
92
+ for (const [l, dir, name, , deps] of rows) {
93
+ for (const dep of deps ? deps.split(",") : []) {
94
+ expect(layer.get(dep), `${name} (${dir}) is in layer ${l}, not after its dependency ${dep}`)
95
+ .toBeLessThan(Number(l));
96
+ }
97
+ }
98
+ });
99
+
84
100
  it("puts the two packages that stranded chant-v0.34.0 after what they import", () => {
85
101
  // The specific regression, named, so the failure says what broke rather
86
102
  // than only that some invariant did.
@@ -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
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Resume the job that waits at a gate (#3683), bundle-safe (#3854).
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.
33
+ *
34
+ * This module imports no workspace, ledger, schema or git code, so a product
35
+ * that ships one bundled file (terragucci) can import it as
36
+ * `@intentius/chant/gate-resume-run` and resume a job in process. Which
37
+ * approval answers a pending fact is `./op/gate-resume.ts`'s
38
+ * `resolutionAnswering`, which reads the gate's quorum rules and does pull the
39
+ * workspace in; that module re-exports everything here.
40
+ */
41
+
42
+ import { ForgeApiError, type ForgeFetch, type ForgeKind } from "./pr-forge";
43
+ import type { GateRunLocator } from "./op/gate-run-locator";
44
+
45
+ export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
46
+
47
+ /** What {@link resumeGateRun} did. */
48
+ export type GateResumeOutcome =
49
+ | { status: "resumed"; forge: ForgeKind; how: string; url?: string }
50
+ | { status: "skipped"; forge: ForgeKind; reason: string; url?: string }
51
+ /** Under `dryRun` (#3858): the run is gone and would be started again; nothing was started. */
52
+ | { status: "would-resume"; forge: ForgeKind; how: string; url?: string };
53
+
54
+ export interface ResumeGateRunOptions {
55
+ token: string;
56
+ /** The approval's time. A Forgejo run of the workflow created after it means the run was already resumed. */
57
+ since?: string;
58
+ fetch?: ForgeFetch;
59
+ /**
60
+ * Read the run as a resume would, and start nothing (#3858): say
61
+ * `would-resume` where it would start it. The calls are reads, so a token
62
+ * with read access to the forge's runs is enough.
63
+ */
64
+ dryRun?: boolean;
65
+ }
66
+
67
+ const trimSlash = (s: string): string => s.replace(/\/+$/, "");
68
+
69
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
70
+
71
+ function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
72
+ return async (method: string, path: string, body?: unknown): Promise<unknown> => {
73
+ const url = `${trimSlash(base)}${path}`;
74
+ const res = await fetchImpl(url, {
75
+ method,
76
+ headers: { ...headers, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
77
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
78
+ });
79
+ const text = await res.text();
80
+ if (!res.ok) throw new ForgeApiError(method, url, res.status, text);
81
+ if (!text) return null;
82
+ try {
83
+ return JSON.parse(text);
84
+ } catch {
85
+ return text;
86
+ }
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Start the job a pending fact's locator names again, or say why not. Throws
92
+ * {@link ForgeApiError} when the forge refuses a call, such as a token
93
+ * without the access the table in the module doc lists.
94
+ */
95
+ export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOptions): Promise<GateResumeOutcome> {
96
+ const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
97
+ const url = loc.url ? { url: loc.url } : {};
98
+ const skip = (reason: string): GateResumeOutcome => ({ status: "skipped", forge: loc.forge, reason, ...url });
99
+ const wouldResume = (how: string): GateResumeOutcome => ({ status: "would-resume", forge: loc.forge, how: `would ${how}`, ...url });
100
+
101
+ if (loc.forge === "github") {
102
+ const call = client(loc.api, { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }, fetchImpl);
103
+ const path = `/repos/${loc.repo}/actions/runs/${loc.run}`;
104
+ const run = await call("GET", path);
105
+ if (!isObject(run)) throw new Error(`GET ${path} returned no run`);
106
+ if (run.status !== "completed") return skip(`run ${loc.run} is ${String(run.status)}, not finished`);
107
+ const attempt = typeof run.run_attempt === "number" ? run.run_attempt : undefined;
108
+ if (loc.attempt !== undefined && attempt !== undefined && attempt > loc.attempt) {
109
+ return skip(`run ${loc.run} was already re-run (attempt ${attempt}; attempt ${loc.attempt} waited)`);
110
+ }
111
+ if (run.conclusion === "success") return skip(`run ${loc.run} succeeded`);
112
+ if (opts.dryRun) return wouldResume(`re-run the failed jobs of run ${loc.run}`);
113
+ await call("POST", `${path}/rerun-failed-jobs`, {});
114
+ return { status: "resumed", forge: "github", how: `re-ran the failed jobs of run ${loc.run}`, ...url };
115
+ }
116
+
117
+ if (loc.forge === "gitlab") {
118
+ const call = client(loc.api, { "PRIVATE-TOKEN": opts.token }, fetchImpl);
119
+ const project = `/projects/${encodeURIComponent(decodeURIComponent(loc.repo))}`;
120
+ const job = await call("GET", `${project}/jobs/${loc.job}`);
121
+ if (!isObject(job)) throw new Error(`GET ${project}/jobs/${loc.job} returned no job`);
122
+ const status = String(job.status);
123
+ if (status === "success") return skip(`job ${loc.job} succeeded`);
124
+ if (!["failed", "canceled", "skipped"].includes(status)) return skip(`job ${loc.job} is ${status}, not finished`);
125
+ const name = loc.jobName ?? (typeof job.name === "string" ? job.name : undefined);
126
+ if (name) {
127
+ // The pipeline's jobs list leaves retried jobs out, so the job of this name is its newest try.
128
+ const jobs = await call("GET", `${project}/pipelines/${loc.run}/jobs?per_page=100`);
129
+ const newest = Array.isArray(jobs) ? jobs.find((j) => isObject(j) && j.name === name) : undefined;
130
+ if (isObject(newest) && String(newest.id) !== String(loc.job)) {
131
+ return skip(`job ${loc.job} (${name}) was already retried as job ${String(newest.id)}`);
132
+ }
133
+ }
134
+ if (opts.dryRun) return wouldResume(`retry job ${loc.job}`);
135
+ const retried = await call("POST", `${project}/jobs/${loc.job}/retry`);
136
+ const id = isObject(retried) && retried.id !== undefined ? ` as job ${String(retried.id)}` : "";
137
+ return { status: "resumed", forge: "gitlab", how: `retried job ${loc.job}${id}`, ...url };
138
+ }
139
+
140
+ const call = client(loc.api, { Authorization: `token ${opts.token}`, Accept: "application/json" }, fetchImpl);
141
+ const repo = `/repos/${loc.repo}`;
142
+ const run = await call("GET", `${repo}/actions/runs/${loc.run}`);
143
+ if (!isObject(run)) throw new Error(`GET ${repo}/actions/runs/${loc.run} returned no run`);
144
+ const status = String(run.status);
145
+ if (status === "success") return skip(`run ${loc.run} succeeded`);
146
+ if (["waiting", "running", "blocked", "unknown"].includes(status)) return skip(`run ${loc.run} is ${status}, not finished`);
147
+ const listed = await call("GET", `${repo}/actions/runs?workflow_id=${encodeURIComponent(loc.workflow!)}&limit=50`);
148
+ const runs = isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : Array.isArray(listed) ? listed : [];
149
+ // Forgejo reports `created` in whole seconds.
150
+ const since = opts.since ? Math.floor(new Date(opts.since).getTime() / 1000) * 1000 : undefined;
151
+ const later = runs.find(
152
+ (r) =>
153
+ isObject(r) && Number(r.id) > Number(loc.run) &&
154
+ (r.prettyref === undefined || r.prettyref === loc.ref) &&
155
+ (since === undefined || (typeof r.created === "string" && new Date(r.created).getTime() >= since)),
156
+ );
157
+ if (isObject(later)) return skip(`run ${String(later.id)} of ${loc.workflow} started after the approval`);
158
+ if (opts.dryRun) return wouldResume(`dispatch ${loc.workflow} on ${loc.ref}`);
159
+ await call("POST", `${repo}/actions/workflows/${encodeURIComponent(loc.workflow!)}/dispatches`, { ref: loc.ref });
160
+ return { status: "resumed", forge: "forgejo", how: `dispatched ${loc.workflow} on ${loc.ref}`, ...url };
161
+ }
162
+
163
+ /**
164
+ * The token a resume uses: `CHANT_FORGE_TOKEN`, else the forge's own
165
+ * (`GITHUB_TOKEN` or `GH_TOKEN` on GitHub and Forgejo, `GITLAB_TOKEN` on
166
+ * GitLab), the same order the pull-request loop reads (`../pr-forge.ts`).
167
+ */
168
+ export function resumeTokenFromEnv(forge: ForgeKind, env: Record<string, string | undefined> = process.env): string | undefined {
169
+ if (env.CHANT_FORGE_TOKEN) return env.CHANT_FORGE_TOKEN;
170
+ return forge === "gitlab" ? env.GITLAB_TOKEN : env.GITHUB_TOKEN ?? env.GH_TOKEN;
171
+ }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { samePlanDigest } from "./plan-digest";
21
21
  import type { GateAuditApply } from "./gate-audit";
22
+ import type { GateRunLocator } from "../op/gate-run-locator";
22
23
 
23
24
  /** One approval a wave applied under: the approval of `planDigest` made at `approvedAt` was used. */
24
25
  export interface AppliedGateRecord {
@@ -38,6 +39,37 @@ export interface AppliedGateRecord {
38
39
  review?: { pr?: number; head?: string };
39
40
  runId?: string;
40
41
  commit?: string;
42
+ /**
43
+ * Where the run applying runs (#3858), as a pending fact's `resume` names
44
+ * it. A line that carries one and has no {@link FinishedGateRecord} is an
45
+ * apply that may have stopped: `chant run resume` starts that run again
46
+ * once it is gone, and the wave applies the rest under the same approval.
47
+ */
48
+ resume?: GateRunLocator;
49
+ }
50
+
51
+ /**
52
+ * A line of `applied.jsonl` that a wave applying under an approval writes
53
+ * (#3858): chant's own `applied` line, or a product's `changes` line beside
54
+ * it (terragucci's, which names the changes the approved plans make). Both
55
+ * name the approval the apply ran under.
56
+ */
57
+ export type AppliedGateLine = Omit<AppliedGateRecord, "kind"> & { kind: "applied" | "changes" };
58
+
59
+ /**
60
+ * How the apply an applied line names ended, in the same file (#3858). A
61
+ * product writes it when the apply finishes; an apply that was killed writes
62
+ * none. `applied` is that line's `timestamp`.
63
+ */
64
+ export interface FinishedGateRecord {
65
+ version: 1;
66
+ kind: "finished";
67
+ op: string;
68
+ gate: string;
69
+ planDigest: string;
70
+ applied: string;
71
+ result: "applied" | "failed";
72
+ timestamp: string;
41
73
  }
42
74
 
43
75
  /** Where the applied lines of `op` live on `chant/lifecycle`: the directory and the file, as `writeBlobToPath` takes them. */
@@ -67,6 +99,36 @@ export function parseAppliedGateLines(text: string): AppliedGateRecord[] {
67
99
 
68
100
  const time = (iso: string): number => new Date(iso).getTime();
69
101
 
102
+ /**
103
+ * Every line of an `applied.jsonl` file a resume reads (#3858): the
104
+ * `applied` and `changes` lines, and the `finished` lines that say how an
105
+ * apply ended. Oldest first; a line that is none of them is skipped.
106
+ */
107
+ export function parseAppliedGateFile(text: string): { applied: AppliedGateLine[]; finished: FinishedGateRecord[] } {
108
+ const applied: AppliedGateLine[] = [];
109
+ const finished: FinishedGateRecord[] = [];
110
+ for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
111
+ let r: Record<string, unknown>;
112
+ try {
113
+ r = JSON.parse(line) as Record<string, unknown>;
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (r?.version !== 1 || typeof r.gate !== "string" || typeof r.planDigest !== "string" || typeof r.timestamp !== "string") continue;
118
+ if ((r.kind === "applied" || r.kind === "changes") && typeof r.approvedAt === "string") {
119
+ applied.push({ op: "", approvedBy: "", ...r } as unknown as AppliedGateLine);
120
+ } else if (r.kind === "finished" && typeof r.applied === "string" && (r.result === "applied" || r.result === "failed")) {
121
+ finished.push({ op: "", ...r } as unknown as FinishedGateRecord);
122
+ }
123
+ }
124
+ return { applied, finished };
125
+ }
126
+
127
+ /** The finished line for the apply `line` names, or undefined while it has none. */
128
+ export function finishedApply(finished: readonly FinishedGateRecord[], line: Pick<AppliedGateLine, "gate" | "planDigest" | "timestamp">): FinishedGateRecord | undefined {
129
+ return finished.find((f) => f.gate === line.gate && samePlanDigest(f.planDigest, line.planDigest) && time(f.applied) === time(line.timestamp));
130
+ }
131
+
70
132
  /**
71
133
  * Whether an approval is spent: a wave applied under an approval of the same
72
134
  * gate and digest made at or after it. One that names no digest is never
@@ -76,7 +138,7 @@ const time = (iso: string): number => new Date(iso).getTime();
76
138
  * review not yet recorded has no `timestamp`.
77
139
  */
78
140
  export function isApprovalSpent(
79
- applied: readonly AppliedGateRecord[],
141
+ applied: readonly Pick<AppliedGateRecord, "gate" | "planDigest" | "approvedAt" | "review">[],
80
142
  approval: { gate: string; planDigest?: string; timestamp?: string; review?: { pr?: number; head?: string } },
81
143
  ): boolean {
82
144
  const digest = approval.planDigest;
@@ -12,6 +12,12 @@ const resolution = (o: Record<string, unknown> = {}) =>
12
12
  const ledger = (op: string, ...texts: string[]) => ({ op, lines: texts.map((text) => ({ text })) });
13
13
 
14
14
  describe("lifecycle/gate-audit", () => {
15
+ test("a pending fact's wave is surfaced (#3839)", () => {
16
+ const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ wave: { number: 2, name: "prod", op: "tg" } }))] });
17
+ expect(entries[0]).toMatchObject({ wave: { number: 2, name: "prod", op: "tg" } });
18
+ expect(entries[0]!.detail).not.toHaveProperty("wave");
19
+ });
20
+
15
21
  test("a request, then an approval with its seal and approver", () => {
16
22
  const seal = { signer: "alex", key: "SHA256:k", signature: "-----BEGIN" };
17
23
  const { entries, gaps } = deriveGateAudit({
@@ -191,6 +191,8 @@ export interface GateAuditEntry {
191
191
  runId?: string;
192
192
  expiresAt?: string;
193
193
  description?: string;
194
+ /** Pending facts an op wave wrote for a policy denial or a hold (#3839): the wave they belong to. */
195
+ wave?: { number: number; name: string; op?: string };
194
196
  /** Overrides: the rules the denial named (from the denial line when there is one). */
195
197
  rules?: string[];
196
198
  /** A revoked entry: what was revoked. */
@@ -239,9 +241,13 @@ type Row = Record<string, unknown>;
239
241
 
240
242
  const KNOWN = new Set([
241
243
  "version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
242
- "approver", "seal", "review", "runId", "expiresAt", "description", "rules", "pr", "head", "reviewers",
244
+ "approver", "seal", "review", "runId", "expiresAt", "description", "wave", "rules", "pr", "head", "reviewers",
243
245
  ]);
244
246
 
247
+ const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
248
+ const w = v as { number?: unknown; name?: unknown; op?: unknown } | null;
249
+ return w && typeof w === "object" && typeof w.number === "number" && typeof w.name === "string" ? { number: w.number, name: w.name, ...(typeof w.op === "string" ? { op: w.op } : {}) } : undefined;
250
+ };
245
251
  const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
246
252
  const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : undefined);
247
253
  const time = (iso: string): number => Date.parse(iso) || 0;
@@ -353,7 +359,7 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
353
359
  ...base,
354
360
  kind: override ? "override-requested" : "approval-requested",
355
361
  at: String(r.timestamp),
356
- ...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules) }),
362
+ ...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules), wave: waveOf(r.wave) }),
357
363
  detail: extra,
358
364
  }));
359
365
  continue;
@@ -446,7 +446,13 @@ describe("gate ledger newline termination (#3849)", () => {
446
446
  test("every append ends the file in a newline, with no blank or merged lines", async () => {
447
447
  await withTestDir(async (dir) => {
448
448
  await initRepo(dir);
449
- await appendPendingGate({ op: "nl-op", gate: "g1", planDigest: "sha256:" + "a".repeat(64) } as never, { cwd: dir });
449
+ await appendPendingGate(
450
+ {
451
+ op: "nl-op", gate: "g1", planDigest: "sha256:" + "a".repeat(64),
452
+ timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-08T00:00:00.000Z",
453
+ },
454
+ { cwd: dir },
455
+ );
450
456
  const one = await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir });
451
457
  expect(one!.endsWith("\n")).toBe(true);
452
458
  await appendGateResolution(
@@ -45,7 +45,7 @@ import { isNeverOverMcpGateOp } from "../op/gate-name";
45
45
  import { sortedJsonReplacer } from "../utils";
46
46
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
47
47
  import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
48
- import type { GateRunLocator } from "../op/gate-resume";
48
+ import type { GateRunLocator } from "../op/gate-run-locator";
49
49
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
50
50
 
51
51
  // The facts a pending record is written with live apart, so a bundle can import them without git (#3816).
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The resume plan bundles small (#3858).
3
+ *
4
+ * A product that resumes in its own job, such as terragucci's, decides what
5
+ * to resume with `@intentius/chant/op/gate-resume-plan` and ships as one
6
+ * bundled file. This bundles it the way such a product would and reads
7
+ * esbuild's own list of what went in: no git ledger, no gate evaluation, no
8
+ * workspace, CLI or Ajv code.
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 = [
17
+ /node_modules\//,
18
+ /\/src\/cli\//,
19
+ /\/src\/workspace\//,
20
+ /\/src\/fold\//,
21
+ /\/src\/lint\//,
22
+ /\/src\/codegen\//,
23
+ /\/src\/op\/activities\//,
24
+ /\/src\/op\/gate\.ts$/,
25
+ /\/src\/op\/gate-resume\.ts$/,
26
+ /\/src\/lifecycle\/git\.ts$/,
27
+ /\/src\/lifecycle\/gate-ledger\.ts$/,
28
+ ];
29
+
30
+ describe("the gate-resume-plan bundle", () => {
31
+ test("holds the resume decision and its pure helpers only", async () => {
32
+ const result = await build({
33
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "gate-resume-plan.ts")],
34
+ bundle: true,
35
+ platform: "node",
36
+ format: "esm",
37
+ write: false,
38
+ metafile: true,
39
+ outdir: "out",
40
+ logLevel: "silent",
41
+ });
42
+ const inputs = Object.keys(result.metafile.inputs);
43
+ for (const pattern of FORBIDDEN) {
44
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
45
+ }
46
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
47
+ expect(bytes).toBeLessThan(32 * 1024);
48
+ });
49
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
3
+ import { parseAppliedGateFile } from "../lifecycle/gate-applied";
4
+ import { planGateResumes } from "./gate-resume-plan";
5
+ import type { GateRunLocator } from "./gate-run-locator";
6
+
7
+ const DIGEST = `sha256:${"a".repeat(64)}`;
8
+ const OTHER = `sha256:${"b".repeat(64)}`;
9
+ const NOW = "2026-10-01T06:00:00Z";
10
+ const waited: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/infra", run: "42", attempt: 1 };
11
+ const applying: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/infra", run: "57", attempt: 1 };
12
+
13
+ const pending = (gate: string, timestamp: string, extra: Partial<PendingGateRecord> = {}): PendingGateRecord => ({
14
+ version: 1,
15
+ kind: "pending",
16
+ op: "tf-apply",
17
+ gate,
18
+ timestamp,
19
+ expiresAt: "2026-10-03T00:00:00Z",
20
+ planDigest: DIGEST,
21
+ resume: waited,
22
+ ...extra,
23
+ });
24
+ const approval = (gate: string, timestamp: string, by = "alice"): GateResolutionRecord => ({ version: 1, op: "tf-apply", gate, resolvedBy: by, timestamp, planDigest: DIGEST });
25
+
26
+ const file = (...lines: object[]) => parseAppliedGateFile(lines.map((l) => JSON.stringify(l)).join("\n"));
27
+ const appliedLine = (gate: string, approvedAt: string, timestamp: string, extra: Record<string, unknown> = {}) => ({
28
+ version: 1, kind: "applied", op: "tf-apply", gate, planDigest: DIGEST, approvedAt, approvedBy: "alice", timestamp, ...extra,
29
+ });
30
+
31
+ describe("planGateResumes (#3858)", () => {
32
+ test("an approval that answers a standing fact and is not spent: resumable, approved", () => {
33
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], now: NOW });
34
+ expect(c).toMatchObject({ source: "pending", gate: "wave-1", resumable: true, reason: "approved", approvedBy: "alice", approvedAt: "2026-10-01T01:00:00Z", run: waited });
35
+ });
36
+
37
+ test("nothing answers it: not resumable", () => {
38
+ expect(planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [], now: NOW })).toMatchObject([
39
+ { resumable: false, reason: "not-approved" },
40
+ ]);
41
+ });
42
+
43
+ test("an approval a wave already applied under is spent: not resumable, and the report names the apply", () => {
44
+ const lines = file(appliedLine("wave-1", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z", { wave: { number: 1, name: "network" } }));
45
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
46
+ expect(c).toMatchObject({ resumable: false, reason: "spent", spent: { appliedAt: "2026-10-01T02:00:00Z", approvedAt: "2026-10-01T01:00:00Z", wave: { number: 1 } } });
47
+ expect(c!.message).toContain("spent");
48
+ });
49
+
50
+ test("a spent approval of another digest, or an older one, spends nothing here", () => {
51
+ const lines = file(appliedLine("wave-1", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z", { planDigest: OTHER }), appliedLine("wave-1", "2026-09-30T00:00:00Z", "2026-09-30T01:00:00Z"));
52
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
53
+ expect(c).toMatchObject({ resumable: true, reason: "approved" });
54
+ });
55
+
56
+ test("a decision point's answer the gate passes on: answered, unless the resolution citing it is spent", () => {
57
+ const answer = { point: "prod-apply", id: "q-1", path: "points/q-1.json" };
58
+ const fact = pending("wave-1", "2026-10-01T00:00:00Z", { answer });
59
+ const questions = new Map([["q-1", { id: "q-1", state: "answered" as const, answer: true, answeredBy: ["alice"], answeredOn: "2026-10-01T01:00:00Z" }]]);
60
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [], questions, now: NOW })).toMatchObject([{ resumable: true, reason: "answered", approvedBy: "alice" }]);
61
+
62
+ const cited: GateResolutionRecord = { ...approval("wave-1", "2026-10-01T03:00:00Z", "alice"), answer };
63
+ const lines = file(appliedLine("wave-1", "2026-10-01T03:00:00Z", "2026-10-01T03:00:01Z"));
64
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [cited], ...lines, questions, now: NOW })).toMatchObject([{ resumable: false, reason: "spent" }]);
65
+ });
66
+
67
+ test("an apply that recorded its run and never finished is a stopped apply", () => {
68
+ // terragucci's shape: chant's `applied` line, then the product's `changes` line with the run.
69
+ const lines = file(
70
+ appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z"),
71
+ appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:01Z", { kind: "changes", resume: applying, changes: [{ address: "aws_s3_bucket.a" }] }),
72
+ );
73
+ const out = planGateResumes({ op: "tf-apply", pending: [pending("wave-2", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-2", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
74
+ expect(out).toMatchObject([
75
+ { source: "pending", reason: "spent", resumable: false },
76
+ { source: "stopped", gate: "wave-2", reason: "stopped", resumable: true, run: applying, approvedBy: "alice", stopped: { appliedAt: "2026-10-01T02:00:01Z", approvedAt: "2026-10-01T01:00:00Z", kind: "changes" } },
77
+ ]);
78
+ });
79
+
80
+ test("a finished line, a newer pending fact, or no run locator: not a stopped apply", () => {
81
+ const changes = appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:01Z", { kind: "changes", resume: applying });
82
+ const finished = { version: 1, kind: "finished", op: "tf-apply", gate: "wave-2", planDigest: DIGEST, applied: "2026-10-01T02:00:01Z", result: "failed", timestamp: "2026-10-01T02:30:00Z" };
83
+ const stopped = (lines: ReturnType<typeof file>, p: PendingGateRecord[] = []) =>
84
+ planGateResumes({ op: "tf-apply", pending: p, resolutions: [], ...lines, now: NOW }).filter((c) => c.source === "stopped");
85
+ expect(stopped(file(changes))).toHaveLength(1);
86
+ expect(stopped(file(changes, finished))).toEqual([]);
87
+ expect(stopped(file(changes), [pending("wave-2", "2026-10-01T03:00:00Z")])).toEqual([]);
88
+ expect(stopped(file(appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z")))).toEqual([]);
89
+ // A newer apply under another approval, with no run, supersedes the stopped one.
90
+ expect(stopped(file(changes, appliedLine("wave-2", "2026-10-01T04:00:00Z", "2026-10-01T05:00:00Z")))).toEqual([]);
91
+ });
92
+
93
+ test("a gate filter, and an expired fact, leave facts out", () => {
94
+ const facts = [pending("wave-1", "2026-10-01T00:00:00Z"), pending("wave-2", "2026-10-01T00:00:00Z", { expiresAt: "2026-10-01T05:00:00Z" })];
95
+ expect(planGateResumes({ op: "tf-apply", pending: facts, resolutions: [], now: NOW }).map((c) => c.gate)).toEqual(["wave-1"]);
96
+ expect(planGateResumes({ op: "tf-apply", pending: facts, resolutions: [], now: NOW, gate: "wave-2" })).toEqual([]);
97
+ });
98
+
99
+ test("a fact with an approval block needs the tally to be answered", () => {
100
+ const fact = pending("wave-1", "2026-10-01T00:00:00Z", { approval: { mode: "off" } as unknown as PendingGateRecord["approval"] });
101
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], now: NOW })).toMatchObject([
102
+ { resumable: false, reason: "not-approved", message: expect.stringContaining("tally") },
103
+ ]);
104
+ const answering = () => approval("wave-1", "2026-10-01T01:00:00Z");
105
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [], now: NOW, answering })).toMatchObject([{ resumable: true, reason: "approved" }]);
106
+ });
107
+ });