@intentius/chant 0.62.0 → 0.63.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.
- package/dist/cli/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -28,6 +28,39 @@ describe("gatedRunSummaryMarkdown (#2243)", () => {
|
|
|
28
28
|
expect(md).toContain("chant approve app-apply approve-app-apply --approver <you>");
|
|
29
29
|
expect(md).toContain("_gates/app-apply.jsonl");
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
// #2310: `recordGateApproval`'s push was already reported (#2309); the
|
|
33
|
+
// gate's own `appendPending` push was not. The GitHub/Forgejo/Gitea step
|
|
34
|
+
// summary is exactly the surface an operator working from a different
|
|
35
|
+
// checkout would open, so it is where the gap mattered most.
|
|
36
|
+
test("says so when this run's own append never reached the remote", () => {
|
|
37
|
+
const md = gatedRunSummaryMarkdown({
|
|
38
|
+
...summary,
|
|
39
|
+
pushed: false,
|
|
40
|
+
pushWarning: "chant/lifecycle remote branch has moved since this run started",
|
|
41
|
+
});
|
|
42
|
+
expect(md).toContain("was not pushed to the remote");
|
|
43
|
+
expect(md).toContain("chant/lifecycle remote branch has moved since this run started");
|
|
44
|
+
expect(md).toContain("cannot see it to approve it");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("says nothing extra when the push landed or nothing was pushed this run", () => {
|
|
48
|
+
const md = gatedRunSummaryMarkdown(summary);
|
|
49
|
+
expect(md).not.toContain("was not pushed");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// #2300 — the block is where a CI approver reads what they are approving,
|
|
53
|
+
// so it names the plan and says the approval does not carry to the next one.
|
|
54
|
+
test("names the plan the approval binds to, when the gate binds one", () => {
|
|
55
|
+
const digest = `sha256:${"d".repeat(64)}`;
|
|
56
|
+
const md = gatedRunSummaryMarkdown({ ...summary, planDigest: digest });
|
|
57
|
+
expect(md).toContain(digest);
|
|
58
|
+
expect(md).toContain("and not the next run");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a gate that binds no plan says nothing about one", () => {
|
|
62
|
+
expect(gatedRunSummaryMarkdown(summary)).not.toContain("and not the next run");
|
|
63
|
+
});
|
|
31
64
|
});
|
|
32
65
|
|
|
33
66
|
describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
|
package/src/op/gate-summary.ts
CHANGED
|
@@ -34,6 +34,22 @@ export interface GatedRunSummary {
|
|
|
34
34
|
expiresAt?: string;
|
|
35
35
|
/** The approval surface the run resolved, when it knew one. */
|
|
36
36
|
url?: string;
|
|
37
|
+
/**
|
|
38
|
+
* False when this run's own append reached only the local chant/lifecycle
|
|
39
|
+
* branch, not the remote (#2310) — the reason lives in `pushWarning`.
|
|
40
|
+
* Absent when this run left an already-standing pending fact alone, or when
|
|
41
|
+
* the push landed.
|
|
42
|
+
*/
|
|
43
|
+
pushed?: boolean;
|
|
44
|
+
/** Set when `pushed` is false. */
|
|
45
|
+
pushWarning?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The plan the pending fact was recorded against (#2300), when the gate
|
|
48
|
+
* binds one. Shown because it is what the `chant approve` line below
|
|
49
|
+
* approves — and because a reader who comes back to a stale summary needs
|
|
50
|
+
* to see that the digest has moved on.
|
|
51
|
+
*/
|
|
52
|
+
planDigest?: string;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
/**
|
|
@@ -51,6 +67,13 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
|
|
|
51
67
|
if (summary.description) {
|
|
52
68
|
lines.push(summary.description, "");
|
|
53
69
|
}
|
|
70
|
+
if (summary.planDigest) {
|
|
71
|
+
lines.push(
|
|
72
|
+
`This approves one plan, \`${summary.planDigest}\`, and not the next run (#2300). ` +
|
|
73
|
+
"Change the configuration after approving and the next run refuses rather than applying.",
|
|
74
|
+
"",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
54
77
|
lines.push(
|
|
55
78
|
"Approve it, then re-run this workflow:",
|
|
56
79
|
"",
|
|
@@ -62,6 +85,14 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
|
|
|
62
85
|
);
|
|
63
86
|
if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
|
|
64
87
|
if (summary.url) lines.push(`Approve at: ${summary.url}`);
|
|
88
|
+
if (summary.pushed === false) {
|
|
89
|
+
lines.push(
|
|
90
|
+
"",
|
|
91
|
+
`**This pending fact was not pushed to the remote** (${summary.pushWarning ?? "no reason given"}). ` +
|
|
92
|
+
"It exists only in this job's checkout. An operator working from a clone of the remote cannot " +
|
|
93
|
+
"see it to approve it until it reaches \`chant/lifecycle\` there.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
65
96
|
lines.push("");
|
|
66
97
|
return lines.join("\n");
|
|
67
98
|
}
|
package/src/op/gate.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { writeFileSync } from "node:fs";
|
|
|
16
16
|
import { mkdir, rm } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
|
-
import { evaluateGate, gitGateLedgerPort } from "./gate";
|
|
19
|
+
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
20
20
|
import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
|
|
21
21
|
import {
|
|
22
22
|
requireLifecycleLedger,
|
|
@@ -500,5 +500,115 @@ describe("op/gate — a CI checkout that never fetched the ledger (#2303)", () =
|
|
|
500
500
|
});
|
|
501
501
|
expect(check.satisfied).toBe(false);
|
|
502
502
|
await expect(requireLifecycleLedger({ cwd: dir })).resolves.toBeUndefined();
|
|
503
|
+
// #2310: no remote to have failed against, but still worth saying —
|
|
504
|
+
// nothing this run just recorded left the checkout.
|
|
505
|
+
if (check.satisfied) throw new Error("unreachable");
|
|
506
|
+
expect(check.recorded).toBe(true);
|
|
507
|
+
expect(check.pushed).toBe(false);
|
|
508
|
+
expect(check.pushWarning).toMatch(/no remote/i);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* #2310 — `recordGateApproval`'s push (`../cli/handlers/operator.ts`) was
|
|
514
|
+
* already reported by #2309's review; `evaluateGate`'s own `appendPending`
|
|
515
|
+
* push, right below it in the same file, still swallowed the rejection into
|
|
516
|
+
* `undefined` and let a gated run print as if the pending fact had reached
|
|
517
|
+
* everyone. It had not: an operator working from a clone of the remote
|
|
518
|
+
* cannot see a pending fact that never got there, and the run gave no hint
|
|
519
|
+
* that this was why their `chant approve` found nothing to resolve.
|
|
520
|
+
*
|
|
521
|
+
* Before this fix, `gitGateLedgerPort({ cwd }).appendPending(...)`'s
|
|
522
|
+
* resolved value was the bare `PendingGateRecord` — there was no `pushed`
|
|
523
|
+
* field to assert on at all, so the rejection above was genuinely invisible
|
|
524
|
+
* to any caller. That is the "red" state: this suite fails to typecheck
|
|
525
|
+
* against the old `Promise<PendingGateRecord>` return type, and a rejected
|
|
526
|
+
* push at `gate.ts:71` produced no failure text anywhere.
|
|
527
|
+
*/
|
|
528
|
+
describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)", () => {
|
|
529
|
+
test("gitGateLedgerPort's appendPending reports pushed:false and the rejection reason", async () => {
|
|
530
|
+
const { remote, author } = await clonePair();
|
|
531
|
+
const other = tmp("other");
|
|
532
|
+
git(["clone", "-q", remote, other], tmpdir());
|
|
533
|
+
git(["config", "user.email", "other@chant.dev"], other);
|
|
534
|
+
git(["config", "user.name", "Other"], other);
|
|
535
|
+
|
|
536
|
+
const port = gitGateLedgerPort({ cwd: author });
|
|
537
|
+
|
|
538
|
+
// `author` records and pushes the branch's first pending fact — this
|
|
539
|
+
// both creates `chant/lifecycle` on the remote and pins `author`'s own
|
|
540
|
+
// remote-tracking ref to the commit it just pushed.
|
|
541
|
+
const first = await port.appendPending({
|
|
542
|
+
op: "live-apply",
|
|
543
|
+
gate: "approve-live-apply",
|
|
544
|
+
timestamp: "2026-09-09T05:00:00.000Z",
|
|
545
|
+
expiresAt: "2026-09-10T05:00:00.000Z",
|
|
546
|
+
});
|
|
547
|
+
expect(first.pushed).toBe(true);
|
|
548
|
+
expect(first.pushWarning).toBeUndefined();
|
|
549
|
+
|
|
550
|
+
// `other` fetches that commit, appends a second pending fact on top of
|
|
551
|
+
// it, and pushes — moving the remote on behind `author`'s back. `author`
|
|
552
|
+
// never re-fetches, so its remote-tracking ref still names the first
|
|
553
|
+
// commit alone.
|
|
554
|
+
git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], other);
|
|
555
|
+
const otherPort = gitGateLedgerPort({ cwd: other });
|
|
556
|
+
const second = await otherPort.appendPending({
|
|
557
|
+
op: "live-apply",
|
|
558
|
+
gate: "approve-other-gate",
|
|
559
|
+
timestamp: "2026-09-09T05:05:00.000Z",
|
|
560
|
+
expiresAt: "2026-09-10T05:05:00.000Z",
|
|
561
|
+
});
|
|
562
|
+
expect(second.pushed).toBe(true);
|
|
563
|
+
|
|
564
|
+
// `author` appends its own third fact on the tip it still knows (the
|
|
565
|
+
// first commit) and pushes against a lease the remote has since moved
|
|
566
|
+
// past — the same `StaleLifecycleBranchError` `chant approve` could hit
|
|
567
|
+
// (#2310's issue).
|
|
568
|
+
const third = await port.appendPending({
|
|
569
|
+
op: "live-apply",
|
|
570
|
+
gate: "approve-third-gate",
|
|
571
|
+
timestamp: "2026-09-09T05:10:00.000Z",
|
|
572
|
+
expiresAt: "2026-09-10T05:10:00.000Z",
|
|
573
|
+
});
|
|
574
|
+
expect(third.pushed).toBe(false);
|
|
575
|
+
expect(third.pushWarning).toBeTruthy();
|
|
576
|
+
expect(third.pushWarning).toMatch(/chant\/lifecycle remote branch has moved|stale|rejected|non-fast-forward/i);
|
|
577
|
+
// The append itself still landed locally — a correct local answer, per
|
|
578
|
+
// the #2309 author's reasoning; only the report was missing.
|
|
579
|
+
expect(third.record.gate).toBe("approve-third-gate");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("evaluateGate carries the rejection onto the gate check it returns, recorded but not pushed", async () => {
|
|
583
|
+
// A minimal port whose read side is inert and whose write side reports
|
|
584
|
+
// the exact rejection a stale `chant/lifecycle` lease produces — the
|
|
585
|
+
// shape `evaluateGate` has to propagate regardless of which port
|
|
586
|
+
// implementation is behind it.
|
|
587
|
+
const rejectingPort = {
|
|
588
|
+
async read() {
|
|
589
|
+
return { resolutions: [], pending: [] };
|
|
590
|
+
},
|
|
591
|
+
async appendPending(input: Parameters<GateLedgerPort["appendPending"]>[0]) {
|
|
592
|
+
return {
|
|
593
|
+
record: { version: 1 as const, kind: "pending" as const, ...input },
|
|
594
|
+
pushed: false,
|
|
595
|
+
pushWarning:
|
|
596
|
+
"chant/lifecycle remote branch has moved since this run started — another snapshot was pushed concurrently.",
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const check = await evaluateGate(rejectingPort, {
|
|
602
|
+
op: "live-apply",
|
|
603
|
+
gate: "approve-live-apply",
|
|
604
|
+
now: "2026-09-09T05:00:00.000Z",
|
|
605
|
+
});
|
|
606
|
+
if (check.satisfied) throw new Error("unreachable — nothing resolved this gate");
|
|
607
|
+
expect(check.recorded).toBe(true);
|
|
608
|
+
expect(check.pushed).toBe(false);
|
|
609
|
+
expect(check.pushWarning).toContain("another snapshot was pushed concurrently");
|
|
610
|
+
// The pending fact is still the one that was recorded — gating is still
|
|
611
|
+
// correct, only silent about reaching the remote.
|
|
612
|
+
expect(check.pending.gate).toBe("approve-live-apply");
|
|
503
613
|
});
|
|
504
614
|
});
|
package/src/op/gate.ts
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* pre-authorize a future run's gate that has since been recorded pending; a
|
|
20
20
|
* resolution from last month does not answer the fact this run just wrote.
|
|
21
21
|
*
|
|
22
|
+
* Since #2300 a gate can also bind a plan. `GateCheckInput.planDigest` is
|
|
23
|
+
* what this run's Plan phase produced (`../lifecycle/plan-digest.ts`), and a
|
|
24
|
+
* resolution counts only when it was recorded for that same plan — recency
|
|
25
|
+
* demoted from the criterion to the tiebreak. Approve, edit the root, re-run,
|
|
26
|
+
* and the gate refuses by name instead of applying something no approver saw,
|
|
27
|
+
* which is what INTENTIUS/choudoufu#1026 measured it doing. A gate with no
|
|
28
|
+
* `planDigest` decides exactly as it did before.
|
|
29
|
+
*
|
|
22
30
|
* Ledger access goes through {@link GateLedgerPort} rather than straight to
|
|
23
31
|
* git, so a test (and the operator's own in-memory paths) can drive the
|
|
24
32
|
* decision without an orphan branch on disk.
|
|
@@ -28,7 +36,7 @@ import {
|
|
|
28
36
|
appendPendingGate,
|
|
29
37
|
isPendingGateExpired,
|
|
30
38
|
latestPendingGate,
|
|
31
|
-
|
|
39
|
+
latestResolutionForPlan,
|
|
32
40
|
readGateLedger,
|
|
33
41
|
resolveApprovalUrl,
|
|
34
42
|
DEFAULT_GATE_EXPIRY,
|
|
@@ -36,21 +44,46 @@ import {
|
|
|
36
44
|
type PendingGateInput,
|
|
37
45
|
type PendingGateRecord,
|
|
38
46
|
} from "../lifecycle/gate-ledger";
|
|
47
|
+
import { describePlanDigest } from "../lifecycle/plan-digest";
|
|
39
48
|
import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
|
|
40
49
|
import { parseDuration } from "./duration";
|
|
41
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What appending a pending fact learned about reaching the remote.
|
|
53
|
+
*
|
|
54
|
+
* The append to the local `chant/lifecycle` branch always lands — that half
|
|
55
|
+
* was fixed by #2309's fetch-before-append. This is the other half: whether
|
|
56
|
+
* the push that follows it did, and why not when it didn't, in one line a
|
|
57
|
+
* renderer can show directly (#2310).
|
|
58
|
+
*/
|
|
59
|
+
export interface PendingGatePush {
|
|
60
|
+
record: PendingGateRecord;
|
|
61
|
+
/**
|
|
62
|
+
* True when the push reached the remote. False when there was no remote
|
|
63
|
+
* configured, or the push was rejected — `pushWarning` says which.
|
|
64
|
+
*/
|
|
65
|
+
pushed: boolean;
|
|
66
|
+
/** Set when `pushed` is false. */
|
|
67
|
+
pushWarning?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/** The gate ledger, as the two executors need it: read both kinds of line, append a pending fact. */
|
|
43
71
|
export interface GateLedgerPort {
|
|
44
72
|
read(op: string): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }>;
|
|
45
|
-
appendPending(input: PendingGateInput): Promise<
|
|
73
|
+
appendPending(input: PendingGateInput): Promise<PendingGatePush>;
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
/**
|
|
49
|
-
* The real port: the `chant/lifecycle` orphan branch.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* a
|
|
77
|
+
* The real port: the `chant/lifecycle` orphan branch.
|
|
78
|
+
*
|
|
79
|
+
* Appending is always local-first and always lands (#2309's fetch-before-
|
|
80
|
+
* append). The push that follows is reported rather than swallowed (#2310):
|
|
81
|
+
* a pending fact that never reaches the remote is still a correct local
|
|
82
|
+
* answer — the run is right to gate, and does — but an operator elsewhere
|
|
83
|
+
* cannot approve a gate whose pending record they cannot see, so the caller
|
|
84
|
+
* gets `pushed: false` and a reason instead of silence. Mirrors the shape
|
|
85
|
+
* `chant approve` reports through (`../cli/handlers/operator.ts`'s
|
|
86
|
+
* `reportedPush`, #2309 review).
|
|
54
87
|
*/
|
|
55
88
|
export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
56
89
|
return {
|
|
@@ -68,8 +101,23 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
68
101
|
},
|
|
69
102
|
async appendPending(input) {
|
|
70
103
|
const { record } = await appendPendingGate(input, opts);
|
|
71
|
-
|
|
72
|
-
|
|
104
|
+
try {
|
|
105
|
+
const pushed = await pushLifecycle(opts);
|
|
106
|
+
return pushed
|
|
107
|
+
? { record, pushed }
|
|
108
|
+
: {
|
|
109
|
+
record,
|
|
110
|
+
pushed,
|
|
111
|
+
pushWarning:
|
|
112
|
+
"no remote is configured for chant/lifecycle — the pending fact was recorded locally only",
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return {
|
|
116
|
+
record,
|
|
117
|
+
pushed: false,
|
|
118
|
+
pushWarning: err instanceof Error ? err.message : String(err),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
73
121
|
},
|
|
74
122
|
};
|
|
75
123
|
}
|
|
@@ -90,7 +138,8 @@ export function memoryGateLedgerPort(
|
|
|
90
138
|
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
91
139
|
pending.push(record);
|
|
92
140
|
appended.push(record);
|
|
93
|
-
|
|
141
|
+
// No remote in an in-memory ledger — there is nothing to fail to reach.
|
|
142
|
+
return { record, pushed: true };
|
|
94
143
|
},
|
|
95
144
|
};
|
|
96
145
|
}
|
|
@@ -106,14 +155,82 @@ export interface GateCheckInput {
|
|
|
106
155
|
timeout?: string;
|
|
107
156
|
/** Identifies the run that reached the gate, when the caller has one. */
|
|
108
157
|
runId?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The plan this run reached the gate with (#2300) — `computePlanDigest`'s
|
|
160
|
+
* output (`../lifecycle/plan-digest.ts`), from the Plan phase that ran a
|
|
161
|
+
* moment ago.
|
|
162
|
+
*
|
|
163
|
+
* Supplying it makes the gate plan-bound: only a resolution recorded for
|
|
164
|
+
* this exact digest satisfies it, and one recorded for a different plan (or
|
|
165
|
+
* for no plan at all) is reported as a {@link GateDigestMismatch} instead.
|
|
166
|
+
* Omitting it keeps the pre-#2300 rule, where the newest resolution since
|
|
167
|
+
* the standing pending fact satisfies the gate whatever has changed since.
|
|
168
|
+
*/
|
|
169
|
+
planDigest?: string;
|
|
109
170
|
/** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
|
|
110
171
|
now?: string;
|
|
111
172
|
}
|
|
112
173
|
|
|
174
|
+
/**
|
|
175
|
+
* A standing resolution that answers this gate but not this plan (#2300) —
|
|
176
|
+
* what a refusal names.
|
|
177
|
+
*/
|
|
178
|
+
export interface GateDigestMismatch {
|
|
179
|
+
/** The plan that was approved. `undefined` for a resolution written before #2300, which recorded no plan at all. */
|
|
180
|
+
approved?: string;
|
|
181
|
+
/** The plan this run's Plan phase produced. */
|
|
182
|
+
planned: string;
|
|
183
|
+
/** Who recorded the approval that does not apply here. */
|
|
184
|
+
resolvedBy: string;
|
|
185
|
+
/** When they recorded it. */
|
|
186
|
+
timestamp: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The refusal line for a {@link GateDigestMismatch}: which plan was approved,
|
|
191
|
+
* which was planned, and what closes the gap. One function so the executor's
|
|
192
|
+
* step record, the human render and the CI summary say the same thing.
|
|
193
|
+
*
|
|
194
|
+
* The two cases get different prose because they are different facts. A
|
|
195
|
+
* resolution for another plan means something changed between the approval
|
|
196
|
+
* and this run. A resolution with no plan on it means nothing is known to
|
|
197
|
+
* have changed — the record simply never said what it approved, which is
|
|
198
|
+
* every record written before #2300.
|
|
199
|
+
*/
|
|
200
|
+
export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch): string {
|
|
201
|
+
const why =
|
|
202
|
+
mismatch.approved === undefined
|
|
203
|
+
? "That resolution predates plan-bound gates (#2300) and records no plan at all, so it cannot " +
|
|
204
|
+
"answer for this one. Approving again binds it:"
|
|
205
|
+
: "The configuration or the live system changed between that approval and this plan, so it needs " +
|
|
206
|
+
"a fresh one:";
|
|
207
|
+
return (
|
|
208
|
+
`Gate "${gate}" is approved, but not for this plan. ` +
|
|
209
|
+
`approved: ${describePlanDigest(mismatch.approved)} (by ${mismatch.resolvedBy} at ${mismatch.timestamp}); ` +
|
|
210
|
+
`planned: ${mismatch.planned}. ` +
|
|
211
|
+
`${why} ${approveCommand(op, gate)}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
113
215
|
/** Either the gate is answered, or it is a standing fact. */
|
|
114
216
|
export type GateCheck =
|
|
115
217
|
| { satisfied: true; resolution: GateResolutionRecord }
|
|
116
|
-
| {
|
|
218
|
+
| {
|
|
219
|
+
satisfied: false;
|
|
220
|
+
pending: PendingGateRecord;
|
|
221
|
+
recorded: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Whether this run's own append reached the remote — set only when
|
|
224
|
+
* `recorded` is true. A gate left standing from an earlier run pushed
|
|
225
|
+
* (or didn't) on that run; this one wrote nothing, so it has nothing
|
|
226
|
+
* new to report (#2310).
|
|
227
|
+
*/
|
|
228
|
+
pushed?: boolean;
|
|
229
|
+
/** Set when `pushed` is false. */
|
|
230
|
+
pushWarning?: string;
|
|
231
|
+
/** Present when a resolution stands for this gate but for another plan (#2300). */
|
|
232
|
+
mismatch?: GateDigestMismatch;
|
|
233
|
+
};
|
|
117
234
|
|
|
118
235
|
/** The beginning of time — the anchor for a gate that has never been recorded pending, so any resolution for it counts. */
|
|
119
236
|
const EPOCH = new Date(0).toISOString();
|
|
@@ -122,28 +239,61 @@ const EPOCH = new Date(0).toISOString();
|
|
|
122
239
|
* Decide a gate against the ledger, recording the pending fact when it isn't
|
|
123
240
|
* answered.
|
|
124
241
|
*
|
|
125
|
-
* - A resolution
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* -
|
|
130
|
-
*
|
|
131
|
-
*
|
|
242
|
+
* - A resolution recorded for this run's own plan, newer than the newest
|
|
243
|
+
* pending fact, satisfies the gate. On a gate that binds no plan
|
|
244
|
+
* (`input.planDigest` absent) the plan half of that drops out and the rule
|
|
245
|
+
* is the pre-#2300 one: any resolution newer than the pending fact.
|
|
246
|
+
* - A resolution that stands for the gate but for a different plan does not
|
|
247
|
+
* satisfy it (#2300). The run is gated, `mismatch` names both plans, and a
|
|
248
|
+
* fresh pending fact is recorded for the plan that actually ran — so
|
|
249
|
+
* `chant approve` has something current to approve and the loop closes in
|
|
250
|
+
* one more command rather than needing the digest typed out.
|
|
251
|
+
* - Otherwise, a pending fact that hasn't expired *and was recorded for this
|
|
252
|
+
* same plan* stands as it is: the run ends `gated` and the ledger is left
|
|
253
|
+
* alone, so an operator ticking every minute against an unapproved gate
|
|
254
|
+
* does not append a line a minute. A pending fact for a different plan is
|
|
255
|
+
* stale in the way that matters and is replaced.
|
|
256
|
+
* - Otherwise (no pending fact, or the newest one has expired, or it is for
|
|
257
|
+
* another plan) a fresh pending fact is appended and the run ends `gated`.
|
|
258
|
+
* `recorded` says which of the two happened, so a renderer can tell "just
|
|
259
|
+
* recorded" from "still standing".
|
|
132
260
|
*/
|
|
133
261
|
export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput): Promise<GateCheck> {
|
|
134
262
|
const now = input.now ?? new Date().toISOString();
|
|
135
263
|
const { resolutions, pending } = await port.read(input.op);
|
|
136
264
|
|
|
137
265
|
const standing = latestPendingGate(pending, input.gate);
|
|
138
|
-
const resolution
|
|
266
|
+
const { resolution, mismatched } = latestResolutionForPlan(
|
|
267
|
+
resolutions,
|
|
268
|
+
input.gate,
|
|
269
|
+
standing?.timestamp ?? EPOCH,
|
|
270
|
+
input.planDigest,
|
|
271
|
+
);
|
|
139
272
|
if (resolution) return { satisfied: true, resolution };
|
|
140
273
|
|
|
141
|
-
|
|
142
|
-
|
|
274
|
+
// `input.planDigest` is defined whenever `mismatched` is — `latestResolutionForPlan`
|
|
275
|
+
// returns a mismatch only on the plan-bound path.
|
|
276
|
+
const mismatch: GateDigestMismatch | undefined = mismatched
|
|
277
|
+
? {
|
|
278
|
+
...(mismatched.planDigest !== undefined ? { approved: mismatched.planDigest } : {}),
|
|
279
|
+
planned: input.planDigest!,
|
|
280
|
+
resolvedBy: mismatched.resolvedBy,
|
|
281
|
+
timestamp: mismatched.timestamp,
|
|
282
|
+
}
|
|
283
|
+
: undefined;
|
|
284
|
+
const asMismatch = mismatch ? { mismatch } : {};
|
|
285
|
+
|
|
286
|
+
// A standing fact only stands for the plan it was recorded against. When
|
|
287
|
+
// the plan has moved, re-recording is what gives `chant approve` (which
|
|
288
|
+
// defaults to the newest pending fact's digest) the current plan to
|
|
289
|
+
// approve; leaving the old fact standing would make the common path
|
|
290
|
+
// approve a plan that is no longer the one being run.
|
|
291
|
+
if (standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest) {
|
|
292
|
+
return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
|
|
143
293
|
}
|
|
144
294
|
|
|
145
295
|
const url = resolveApprovalUrl();
|
|
146
|
-
const record = await port.appendPending({
|
|
296
|
+
const { record, pushed, pushWarning } = await port.appendPending({
|
|
147
297
|
op: input.op,
|
|
148
298
|
gate: input.gate,
|
|
149
299
|
timestamp: now,
|
|
@@ -153,8 +303,16 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
153
303
|
...(input.description ? { description: input.description } : {}),
|
|
154
304
|
...(input.runId ? { runId: input.runId } : {}),
|
|
155
305
|
...(url ? { url } : {}),
|
|
306
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
156
307
|
});
|
|
157
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
satisfied: false,
|
|
310
|
+
pending: record,
|
|
311
|
+
recorded: true,
|
|
312
|
+
pushed,
|
|
313
|
+
...(pushWarning ? { pushWarning } : {}),
|
|
314
|
+
...asMismatch,
|
|
315
|
+
};
|
|
158
316
|
}
|
|
159
317
|
|
|
160
318
|
/** The line every renderer prints to say how a pending gate is cleared. */
|
package/src/op/index.ts
CHANGED
|
@@ -50,8 +50,11 @@ export type { ActivityProfile, ActivityProfileName } from "./activity-profiles";
|
|
|
50
50
|
export { NonRetryableActivityError, nonRetryableFailure } from "./activity-failure";
|
|
51
51
|
export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
|
|
52
52
|
export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
|
|
53
|
-
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand } from "./gate";
|
|
54
|
-
export type { GateLedgerPort, GateCheck, GateCheckInput } from "./gate";
|
|
53
|
+
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
|
|
54
|
+
export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch } from "./gate";
|
|
55
|
+
export {
|
|
56
|
+
computePlanDigest, isPlanDigest, describePlanDigest, PLAN_DIGEST_ALGORITHM,
|
|
57
|
+
} from "../lifecycle/plan-digest";
|
|
55
58
|
export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
|
|
56
59
|
export type { GateNamed } from "./gate-name";
|
|
57
60
|
export { createLocalOpRuntime } from "./runtimes/local";
|