@intentius/chant 0.115.0 → 0.117.0

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 (44) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +44 -0
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
  3. package/dist/cli/handlers/operator.d.ts +1 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run-generate.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-wave.d.ts +7 -0
  7. package/dist/cli/handlers/run-wave.d.ts.map +1 -0
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +11 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +22 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  14. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  15. package/dist/op/gate-resume.d.ts +104 -0
  16. package/dist/op/gate-resume.d.ts.map +1 -0
  17. package/dist/op/gate.d.ts.map +1 -1
  18. package/dist/op/generate-pipeline.d.ts +15 -1
  19. package/dist/op/generate-pipeline.d.ts.map +1 -1
  20. package/dist/op/index.d.ts +6 -2
  21. package/dist/op/index.d.ts.map +1 -1
  22. package/dist/op/op-waves-run.d.ts +115 -0
  23. package/dist/op/op-waves-run.d.ts.map +1 -0
  24. package/dist/op/op-waves.d.ts +158 -0
  25. package/dist/op/op-waves.d.ts.map +1 -0
  26. package/package.json +1 -1
  27. package/src/cli/handlers/gate-resume.test.ts +51 -0
  28. package/src/cli/handlers/gate-resume.ts +166 -0
  29. package/src/cli/handlers/operator.ts +8 -2
  30. package/src/cli/handlers/run-generate.ts +51 -1
  31. package/src/cli/handlers/run-wave.ts +43 -0
  32. package/src/cli/main.ts +28 -2
  33. package/src/cli/registry.ts +11 -1
  34. package/src/lexicon.ts +26 -0
  35. package/src/lifecycle/gate-ledger.ts +7 -0
  36. package/src/op/gate-resume.e2e.test.ts +135 -0
  37. package/src/op/gate-resume.test.ts +177 -0
  38. package/src/op/gate-resume.ts +267 -0
  39. package/src/op/gate.ts +4 -0
  40. package/src/op/generate-pipeline.ts +49 -0
  41. package/src/op/index.ts +13 -2
  42. package/src/op/op-waves-run.ts +325 -0
  43. package/src/op/op-waves.test.ts +95 -0
  44. package/src/op/op-waves.ts +250 -0
package/src/cli/main.ts CHANGED
@@ -36,6 +36,7 @@ import { runGraph } from "./handlers/graph";
36
36
  import { runExplain } from "./handlers/explain";
37
37
  import { runSearch } from "./handlers/search";
38
38
  import { runOp, runOpList, runOpStatus, runOpApprove, runOpSignalRenamed, runOpCancel, runOpLog } from "./handlers/run";
39
+ import { runOpWaveCommand } from "./handlers/run-wave";
39
40
  import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
40
41
  import { runEmulator } from "./handlers/emulator";
41
42
  import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
@@ -106,6 +107,7 @@ const BOOLEAN_FLAGS = new Set([
106
107
  "--allow-code",
107
108
  "--root-only",
108
109
  "--generated",
110
+ "--decide",
109
111
  ]);
110
112
 
111
113
  /**
@@ -355,6 +357,13 @@ export function parseArgs(args: string[]): ParsedArgs {
355
357
  const wave = Number(raw);
356
358
  if (!Number.isInteger(wave) || wave < 1) throw new Error(`--wave needs a wave number from 1: got "${raw ?? ""}"`);
357
359
  result.wave = wave;
360
+ } else if (arg === "--decide") {
361
+ result.decide = true;
362
+ } else if (arg === "--share") {
363
+ const raw = args[++i];
364
+ const share = Number(raw);
365
+ if (!Number.isInteger(share) || share < 1) throw new Error(`--share needs a share number from 1: got "${raw ?? ""}"`);
366
+ result.share = share;
358
367
  } else if (arg === "--canary") {
359
368
  const raw = args[++i];
360
369
  if (!raw || raw.startsWith("-")) throw new Error("--canary needs a component name: --canary <name>[,<name>...]");
@@ -372,7 +381,11 @@ export function parseArgs(args: string[]): ParsedArgs {
372
381
  } else if (arg === "--pr-loop") {
373
382
  result.prLoop = true;
374
383
  } else if (arg === "--resume") {
375
- result.resume = args[++i];
384
+ // `chant approve <op> <gate> --resume` (#3683) takes no value; the
385
+ // fan-out and pull-request apply take the attempt file they resume.
386
+ const next = args[i + 1];
387
+ if (next === undefined || next.startsWith("-")) result.resumeRun = true;
388
+ else result.resume = args[++i];
376
389
  } else if (arg === "--local") {
377
390
  result.local = true;
378
391
  } else if (arg === "--json") {
@@ -909,6 +922,13 @@ Ops:
909
922
  run approve <op> <gate> Record a gate's resolution and wake the runtime
910
923
  run cancel <name> Cancel the active run (requires --force)
911
924
  run log <name> Show run history for an Op
925
+ run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
926
+ waves spec (#3679): the job a generated waves
927
+ pipeline runs. --decide plans and decides only;
928
+ --share <i> applies one share of a decided wave
929
+ run resume [--op <name>] Start again each CI job that waits at a gate an
930
+ approval now answers, through the forge's API
931
+ (#3683); approves nothing. --dry-run lists them
912
932
  run --generate <provider> Write one CI pipeline file per scheduled Op for
913
933
  github, gitlab or forgejo (#2533), from every Op that
914
934
  declares a schedule or from --spec <file.json>.
@@ -963,7 +983,10 @@ Ops:
963
983
  counts only an approval sealed by a key the signers
964
984
  file at base lists for --actor. --relayed-by
965
985
  <principal> records who carried the approval to chant
966
- for --actor (a follower, a bot); --sign covers it
986
+ for --actor (a follower, a bot); --sign covers it.
987
+ --resume then starts the CI job that waits at the
988
+ gate again (GitHub re-run, GitLab retry, Forgejo
989
+ dispatch) with CHANT_FORGE_TOKEN
967
990
 
968
991
  graph Show Op dependency graph (--stacks for cross-stack order,
969
992
  --format ir|mermaid|dot|layout for the lint-gated graph IR,
@@ -1815,6 +1838,9 @@ export const commandRegistry: CommandDef[] = [
1815
1838
  { name: "run signal", handler: runOpSignalRenamed },
1816
1839
  { name: "run cancel", handler: runOpCancel },
1817
1840
  { name: "run log", handler: runOpLog },
1841
+ { name: "run wave", handler: runOpWaveCommand },
1842
+ // #3683 — start the CI jobs that wait at gates whose approval arrived. Imported on first use.
1843
+ { name: "run resume", handler: async (ctx) => (await import("./handlers/gate-resume")).runResumeCommand(ctx) },
1818
1844
  { name: "run", handler: runOp },
1819
1845
 
1820
1846
  { name: "operator status", handler: runOperatorStatus },
@@ -248,8 +248,12 @@ export interface ParsedArgs {
248
248
  * <name>`, which generates one CI job per wave.
249
249
  */
250
250
  waveGate?: string;
251
- /** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. */
251
+ /** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. Also `chant run wave --wave <k>` (#3679). */
252
252
  wave?: number;
253
+ /** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
254
+ decide?: boolean;
255
+ /** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
256
+ share?: number;
253
257
  /** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
254
258
  canary?: string[];
255
259
  /**
@@ -287,6 +291,12 @@ export interface ParsedArgs {
287
291
  * partway under that approval.
288
292
  */
289
293
  resume?: string;
294
+ /**
295
+ * `chant approve <op> <gate> --resume` (#3683): after recording the
296
+ * approval, start the CI job that waits at the gate again through the
297
+ * forge's API. Set when `--resume` is given no value.
298
+ */
299
+ resumeRun?: boolean;
290
300
  /** `chant audit --tier merge-worthy|all` */
291
301
  tier?: string;
292
302
  /**
package/src/lexicon.ts CHANGED
@@ -10,6 +10,7 @@ import type { AgentConfigImporter } from "./agents/importer";
10
10
  import type { ArtifactIntegrity } from "./lexicon-integrity";
11
11
  import type { GatedWavePipelineOptions } from "./components/gated-wave-pipeline";
12
12
  import type { PrLoopPipelineOptions } from "./components/pr-pipeline";
13
+ import type { OpWaveJob, OpWavesSpec } from "./op/op-waves";
13
14
  import type { OkfFile } from "./okf";
14
15
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
15
16
  import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
@@ -900,6 +901,23 @@ export interface OpPipelineResult {
900
901
  jobs: OpPipelineJob[];
901
902
  }
902
903
 
904
+ /**
905
+ * Options for an Op waves pipeline (#3679): the generic knobs of
906
+ * {@link ComponentPipelineOptions} that apply to every wave job, and the path
907
+ * of the spec file the jobs read.
908
+ */
909
+ export interface OpWavesPipelineOptions
910
+ extends Pick<ComponentPipelineOptions, "image" | "beforeScript" | "extraScript" | "variables" | "opsStage" | "opsFileName"> {
911
+ /** The spec's path from the repository root. Every job runs `chant run wave --spec <specFile>`. */
912
+ specFile: string;
913
+ }
914
+
915
+ /** An Op waves pipeline: one file, and its jobs in order. */
916
+ export interface OpWavesPipelineResult {
917
+ files: OpPipelineFile[];
918
+ jobs: OpWaveJob[];
919
+ }
920
+
903
921
  /**
904
922
  * Live status of a single deploy unit (a CloudFormation stack, a K8s release, …)
905
923
  * addressed by its deployed name — the per-component presence signal
@@ -1325,6 +1343,14 @@ export interface LexiconPlugin {
1325
1343
  options?: ComponentPipelineOptions,
1326
1344
  ): OpPipelineResult;
1327
1345
 
1346
+ /**
1347
+ * Render ordered waves of Op runs, each behind one gate (#3679,
1348
+ * `./op/op-waves.ts`): one job per wave, or a deciding job and share jobs
1349
+ * for a wide one, each wave needing the one before. Only CI-provider
1350
+ * lexicons implement this.
1351
+ */
1352
+ generateOpWavesPipeline?(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
1353
+
1328
1354
  /**
1329
1355
  * Host Op runs — the seam `chant run <op> --on <this lexicon>` dispatches to
1330
1356
  * (#2121, epic #2114). Core ships one provider of its own, `local`, and
@@ -46,6 +46,7 @@ import { samePlanDigest } from "./plan-digest";
46
46
  import { sortedJsonReplacer } from "../utils";
47
47
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
48
48
  import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
49
+ import type { GateRunLocator } from "../op/gate-resume";
49
50
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
50
51
 
51
52
  const DIR = "_gates";
@@ -270,6 +271,12 @@ export interface PendingGateRecord {
270
271
  expiresAt: string;
271
272
  /** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
272
273
  url?: string;
274
+ /**
275
+ * The CI job that reached this gate (#3683), when it ran in one: what
276
+ * `chant approve --resume` and `chant run resume` hand the forge to start
277
+ * that job again (`../op/gate-resume.ts`). Absent outside CI.
278
+ */
279
+ resume?: GateRunLocator;
273
280
  /**
274
281
  * The plan the run reached this gate with (#2300) — `computePlanDigest`'s
275
282
  * output (`./plan-digest.ts`). This is what `chant approve` copies onto the
@@ -0,0 +1,135 @@
1
+ /**
2
+ * #3683 end to end on a real Forgejo with a runner: a job waits at a gate
3
+ * (exit 3), an approval lands, and {@link resumeGateRun} starts the job again
4
+ * without a push, from the locator {@link resolveGateRunLocator} reads off the
5
+ * job's own environment. The resume call uses a token with the
6
+ * `write:repository` scope alone, the least Forgejo's dispatch accepts.
7
+ *
8
+ * The job stands in for `chant run wave`: it prints its environment and exits
9
+ * 3 until a file named `approved` exists on the `lifecycle` branch. What a
10
+ * chant gate records and decides is covered by the unit tests; this proves
11
+ * the forge's half.
12
+ *
13
+ * Runs only with CHANT_E2E_FORGEJO_URL and CHANT_E2E_FORGEJO_TOKEN (a token
14
+ * with every scope, which creates and deletes the repository), against a
15
+ * Forgejo whose runner takes `docker` jobs. CHANT_E2E_FORGEJO_RESUME_TOKEN is
16
+ * the token the resume calls with; mint it with the `write:repository` scope
17
+ * alone (Forgejo mints tokens only over basic auth). Without it the resume
18
+ * uses the first token.
19
+ */
20
+
21
+ import { afterAll, describe, expect, test } from "vitest";
22
+ import { resolveGateRunLocator, resumeGateRun } from "./gate-resume";
23
+
24
+ const URL_ = process.env.CHANT_E2E_FORGEJO_URL?.replace(/\/+$/, "");
25
+ const TOKEN = process.env.CHANT_E2E_FORGEJO_TOKEN;
26
+ const RESUME_TOKEN = process.env.CHANT_E2E_FORGEJO_RESUME_TOKEN ?? TOKEN;
27
+ const api = `${URL_}/api/v1`;
28
+
29
+ const WORKFLOW = `name: gate
30
+ on:
31
+ push:
32
+ branches: [main]
33
+ workflow_dispatch: {}
34
+ jobs:
35
+ wait:
36
+ runs-on: docker
37
+ steps:
38
+ - run: |
39
+ env | grep -E '^(GITHUB_|FORGEJO_|GITEA_)' | grep -v TOKEN | sort | sed 's/^/CHANT_E2E_ENV /'
40
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token \${{ secrets.GITHUB_TOKEN }}" "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/contents/approved?ref=lifecycle")
41
+ echo "approval lookup answered $code"
42
+ [ "$code" = 200 ] || exit 3
43
+ `;
44
+
45
+ async function call(method: string, path: string, body?: unknown, token = TOKEN!): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any -- untyped forge JSON
46
+ const res = await fetch(`${api}${path}`, {
47
+ method,
48
+ headers: { Authorization: `token ${token}`, "Content-Type": "application/json" },
49
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
50
+ });
51
+ const text = await res.text();
52
+ if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text.slice(0, 300)}`);
53
+ try {
54
+ return text ? JSON.parse(text) : null;
55
+ } catch {
56
+ return text;
57
+ }
58
+ }
59
+
60
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
61
+
62
+ interface Run {
63
+ id: number;
64
+ status: string;
65
+ event: string;
66
+ trigger_event?: string;
67
+ }
68
+
69
+ async function runs(repo: string): Promise<Run[]> {
70
+ const listed = await call("GET", `/repos/${repo}/actions/runs?limit=50`);
71
+ return (listed.workflow_runs ?? listed) as Run[];
72
+ }
73
+
74
+ async function finished(repo: string, pick: (r: Run) => boolean, timeoutMs = 240_000): Promise<Run> {
75
+ const end = Date.now() + timeoutMs;
76
+ while (Date.now() < end) {
77
+ const run = (await runs(repo)).find(pick);
78
+ if (run && !["waiting", "running", "blocked", "unknown"].includes(run.status)) return run;
79
+ await sleep(3000);
80
+ }
81
+ throw new Error(`no finished run in ${repo} within ${timeoutMs}ms`);
82
+ }
83
+
84
+ describe.skipIf(!URL_ || !TOKEN)("resume a waiting job on Forgejo (#3683)", () => {
85
+ const name = `chant-e2e-resume-${Date.now().toString(36)}`;
86
+ let owner = "";
87
+
88
+ afterAll(async () => {
89
+ if (owner) await call("DELETE", `/repos/${owner}/${name}`).catch(() => undefined);
90
+ });
91
+
92
+ test("the job waits, the approval lands, and a dispatch resumes it without a push", async () => {
93
+ owner = (await call("GET", "/user")).login;
94
+ await call("POST", "/user/repos", { name, auto_init: true, default_branch: "main", private: false });
95
+ const repo = `${owner}/${name}`;
96
+ await call("POST", `/repos/${repo}/branches`, { new_branch_name: "lifecycle", old_branch_name: "main" });
97
+ await call("POST", `/repos/${repo}/contents/.forgejo/workflows/gate.yml`, {
98
+ content: Buffer.from(WORKFLOW).toString("base64"),
99
+ message: "gate workflow",
100
+ branch: "main",
101
+ });
102
+
103
+ // The push runs the job, which waits at the gate.
104
+ const waited = await finished(repo, () => true);
105
+ expect(waited.status).toBe("failure");
106
+
107
+ // Its environment, as the job printed it, is what a pending fact records.
108
+ const jobs = await call("GET", `/repos/${repo}/actions/runs/${waited.id}/jobs`);
109
+ const jobId = (jobs.jobs ?? jobs)[0].id;
110
+ const log = String(await call("GET", `/repos/${repo}/actions/jobs/${jobId}/logs`));
111
+ const env: Record<string, string> = {};
112
+ for (const m of log.matchAll(/CHANT_E2E_ENV ([A-Z_]+)=(.*)/g)) env[m[1]!] = m[2]!.trim();
113
+ const loc = resolveGateRunLocator(env);
114
+ expect(loc).toMatchObject({ forge: "forgejo", repo, run: String(waited.id), workflow: "gate.yml", ref: "main" });
115
+
116
+ const token = RESUME_TOKEN!;
117
+ // The job reaches Forgejo by the address its runner uses, which on a
118
+ // local stack is a container name; this test reaches it by its own.
119
+ const reachable = { ...loc!, api };
120
+
121
+ // Approve (here: the file the job looks for), then resume.
122
+ const approvedAt = new Date().toISOString();
123
+ await call("POST", `/repos/${repo}/contents/approved`, { content: Buffer.from("yes").toString("base64"), message: "approve", branch: "lifecycle" });
124
+ const outcome = await resumeGateRun(reachable, { token, since: approvedAt });
125
+ expect(outcome).toMatchObject({ status: "resumed", how: "dispatched gate.yml on main" });
126
+
127
+ const resumed = await finished(repo, (r) => r.id > waited.id);
128
+ expect(resumed.status).toBe("success");
129
+ expect(resumed.trigger_event ?? resumed.event).toBe("workflow_dispatch");
130
+
131
+ // A second resume finds the run that already started and starts nothing.
132
+ expect(await resumeGateRun(reachable, { token, since: approvedAt })).toMatchObject({ status: "skipped" });
133
+ expect((await runs(repo)).filter((r) => r.id > waited.id)).toHaveLength(1);
134
+ }, 600_000);
135
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { ForgeFetch } from "../pr-forge";
3
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
4
+ import { evaluateGate, memoryGateLedgerPort } from "./gate";
5
+ import {
6
+ isGateRunLocator,
7
+ resolutionAnswering,
8
+ resolveGateRunLocator,
9
+ resumeGateRun,
10
+ resumeTokenFromEnv,
11
+ type GateRunLocator,
12
+ } from "./gate-resume";
13
+
14
+ /** A fake forge: answers by "METHOD path" and records every call. */
15
+ function fakeForge(routes: Record<string, unknown>) {
16
+ const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: string }> = [];
17
+ const fetch: ForgeFetch = async (url, init) => {
18
+ calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: init.body } : {}) });
19
+ const key = `${init.method} ${new URL(url).pathname}${new URL(url).search}`;
20
+ const hit = Object.keys(routes).find((k) => key === k || key.startsWith(`${k}?`) || key.startsWith(k));
21
+ if (!hit) return { ok: false, status: 404, text: async () => `no route for ${key}` };
22
+ const body = routes[hit];
23
+ return { ok: true, status: 200, text: async () => (body === null ? "" : JSON.stringify(body)) };
24
+ };
25
+ return { fetch, calls };
26
+ }
27
+
28
+ const GITHUB_ENV = {
29
+ GITHUB_ACTIONS: "true",
30
+ GITHUB_REPOSITORY: "acme/db",
31
+ GITHUB_RUN_ID: "42",
32
+ GITHUB_RUN_ATTEMPT: "1",
33
+ GITHUB_SERVER_URL: "https://github.com",
34
+ GITHUB_API_URL: "https://api.github.com",
35
+ };
36
+
37
+ describe("resolveGateRunLocator", () => {
38
+ test("GitHub Actions: the run and the attempt that waited", () => {
39
+ expect(resolveGateRunLocator(GITHUB_ENV)).toEqual({
40
+ forge: "github",
41
+ api: "https://api.github.com",
42
+ repo: "acme/db",
43
+ run: "42",
44
+ attempt: 1,
45
+ url: "https://github.com/acme/db/actions/runs/42",
46
+ });
47
+ });
48
+
49
+ test("Forgejo Actions: the workflow file and branch a dispatch names", () => {
50
+ const loc = resolveGateRunLocator({
51
+ ...GITHUB_ENV,
52
+ FORGEJO_ACTIONS: "true",
53
+ GITHUB_SERVER_URL: "https://code.example.org",
54
+ GITHUB_API_URL: "https://code.example.org/api/v1",
55
+ GITHUB_WORKFLOW: "migrations.yml",
56
+ GITHUB_REF_NAME: "main",
57
+ });
58
+ expect(loc).toMatchObject({ forge: "forgejo", api: "https://code.example.org/api/v1", workflow: "migrations.yml", ref: "main", run: "42" });
59
+ expect(isGateRunLocator(loc)).toBe(true);
60
+ });
61
+
62
+ test("GitLab CI: the pipeline, the job and its name", () => {
63
+ expect(
64
+ resolveGateRunLocator({
65
+ GITLAB_CI: "true",
66
+ CI_API_V4_URL: "https://gitlab.example.com/api/v4/",
67
+ CI_PROJECT_ID: "7",
68
+ CI_PIPELINE_ID: "100",
69
+ CI_JOB_ID: "555",
70
+ CI_JOB_NAME: "wave-2-staging",
71
+ }),
72
+ ).toEqual({ forge: "gitlab", api: "https://gitlab.example.com/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" });
73
+ });
74
+
75
+ test("outside CI, or missing a part a resume needs, there is none", () => {
76
+ expect(resolveGateRunLocator({})).toBeUndefined();
77
+ expect(resolveGateRunLocator({ GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "a/b" })).toBeUndefined();
78
+ expect(resolveGateRunLocator({ ...GITHUB_ENV, GITEA_ACTIONS: "true", GITHUB_WORKFLOW: "Deploy" })).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe("resumeGateRun", () => {
83
+ const github: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/db", run: "42", attempt: 1 };
84
+
85
+ test("GitHub: re-runs the failed jobs of a finished run, with a bearer token", async () => {
86
+ const forge = fakeForge({
87
+ "GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 1 },
88
+ "POST /repos/acme/db/actions/runs/42/rerun-failed-jobs": null,
89
+ });
90
+ const out = await resumeGateRun(github, { token: "t", fetch: forge.fetch });
91
+ expect(out).toMatchObject({ status: "resumed", how: "re-ran the failed jobs of run 42" });
92
+ expect(forge.calls.map((c) => c.method)).toEqual(["GET", "POST"]);
93
+ expect(forge.calls[1]!.headers.Authorization).toBe("Bearer t");
94
+ });
95
+
96
+ test("GitHub: a run still going, or already re-run, is left alone", async () => {
97
+ const running = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "in_progress", run_attempt: 1 } });
98
+ expect(await resumeGateRun(github, { token: "t", fetch: running.fetch })).toMatchObject({ status: "skipped" });
99
+ const rerun = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 2 } });
100
+ const out = await resumeGateRun(github, { token: "t", fetch: rerun.fetch });
101
+ expect(out).toMatchObject({ status: "skipped", reason: expect.stringContaining("already re-run") });
102
+ expect(rerun.calls.some((c) => c.method === "POST")).toBe(false);
103
+ });
104
+
105
+ test("GitLab: retries the waiting job, unless a newer job of its name exists", async () => {
106
+ const gitlab: GateRunLocator = { forge: "gitlab", api: "https://gl/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" };
107
+ const forge = fakeForge({
108
+ "GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
109
+ "GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 555, name: "wave-2-staging" }],
110
+ "POST /api/v4/projects/7/jobs/555/retry": { id: 556 },
111
+ });
112
+ expect(await resumeGateRun(gitlab, { token: "t", fetch: forge.fetch })).toMatchObject({ status: "resumed", how: "retried job 555 as job 556" });
113
+ expect(forge.calls[2]!.headers["PRIVATE-TOKEN"]).toBe("t");
114
+
115
+ const retried = fakeForge({
116
+ "GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
117
+ "GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 556, name: "wave-2-staging" }],
118
+ });
119
+ expect(await resumeGateRun(gitlab, { token: "t", fetch: retried.fetch })).toMatchObject({ status: "skipped", reason: expect.stringContaining("job 556") });
120
+ });
121
+
122
+ test("Forgejo: dispatches the workflow on its branch, unless a run of it started after the approval", async () => {
123
+ const forgejo: GateRunLocator = { forge: "forgejo", api: "https://fj/api/v1", repo: "acme/db", run: "9", workflow: "migrations.yml", ref: "main" };
124
+ const forge = fakeForge({
125
+ "GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
126
+ "GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 9, prettyref: "main", created: "2026-10-01T00:00:00Z" }] },
127
+ "POST /api/v1/repos/acme/db/actions/workflows/migrations.yml/dispatches": null,
128
+ });
129
+ const out = await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: forge.fetch });
130
+ expect(out).toMatchObject({ status: "resumed", how: "dispatched migrations.yml on main" });
131
+ expect(JSON.parse(forge.calls[2]!.body!)).toEqual({ ref: "main" });
132
+ expect(forge.calls[2]!.headers.Authorization).toBe("token t");
133
+
134
+ const again = fakeForge({
135
+ "GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
136
+ "GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 10, prettyref: "main", created: "2026-10-02T00:01:00Z" }] },
137
+ });
138
+ expect(await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: again.fetch })).toMatchObject({ status: "skipped" });
139
+ });
140
+
141
+ test("a refusal from the forge is thrown with its status", async () => {
142
+ const fetch: ForgeFetch = async () => ({ ok: false, status: 403, text: async () => "Resource not accessible by integration" });
143
+ await expect(resumeGateRun(github, { token: "t", fetch })).rejects.toThrow(/403/);
144
+ });
145
+ });
146
+
147
+ describe("the pending fact and its answer", () => {
148
+ test("a gate reached in CI records where its job runs", async () => {
149
+ const saved = { ...process.env };
150
+ try {
151
+ Object.assign(process.env, GITHUB_ENV);
152
+ const port = memoryGateLedgerPort();
153
+ await evaluateGate(port, { op: "migrations", gate: "migrations-wave-2", planDigest: `sha256:${"a".repeat(64)}`, now: "2026-10-01T00:00:00Z" });
154
+ expect(port.appended[0]!.resume).toMatchObject({ forge: "github", run: "42", attempt: 1 });
155
+ } finally {
156
+ for (const key of Object.keys(GITHUB_ENV)) if (!(key in saved)) delete process.env[key];
157
+ Object.assign(process.env, saved);
158
+ }
159
+ });
160
+
161
+ test("an approval answers the standing fact only for its plan", () => {
162
+ const digest = `sha256:${"a".repeat(64)}`;
163
+ const standing: PendingGateRecord = {
164
+ version: 1, kind: "pending", op: "m", gate: "g", timestamp: "2026-10-01T00:00:00Z", expiresAt: "2026-10-03T00:00:00Z", planDigest: digest,
165
+ };
166
+ const approve = (planDigest: string, timestamp = "2026-10-01T01:00:00Z"): GateResolutionRecord => ({ version: 1, op: "m", gate: "g", resolvedBy: "alice", timestamp, planDigest });
167
+ expect(resolutionAnswering([approve(`sha256:${"b".repeat(64)}`)], standing)).toBeUndefined();
168
+ expect(resolutionAnswering([approve(digest, "2026-09-30T00:00:00Z")], standing)).toBeUndefined();
169
+ expect(resolutionAnswering([approve(digest)], standing)?.resolvedBy).toBe("alice");
170
+ });
171
+
172
+ test("the token: CHANT_FORGE_TOKEN first, then the forge's own", () => {
173
+ expect(resumeTokenFromEnv("github", { GITHUB_TOKEN: "g", CHANT_FORGE_TOKEN: "c" })).toBe("c");
174
+ expect(resumeTokenFromEnv("forgejo", { GITHUB_TOKEN: "g" })).toBe("g");
175
+ expect(resumeTokenFromEnv("gitlab", { GITHUB_TOKEN: "g" })).toBeUndefined();
176
+ });
177
+ });