@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
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
parseDuration,
|
|
7
7
|
OpRunFailure,
|
|
8
8
|
} from "./local-executor";
|
|
9
|
-
import { memoryGateLedgerPort } from "./gate";
|
|
9
|
+
import { memoryGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
10
|
+
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
10
11
|
import { renderHuman } from "./local-output";
|
|
11
|
-
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
12
|
+
import type { GateResolutionRecord, PendingGateRecord, PendingGateInput } from "../lifecycle/gate-ledger";
|
|
12
13
|
import { stepOutput } from "./step-output-ref";
|
|
13
14
|
|
|
14
15
|
// Fast profiles so retry/timeout tests run in milliseconds.
|
|
@@ -325,6 +326,33 @@ describe("runOpLocally — gate as fact (#2119)", () => {
|
|
|
325
326
|
]);
|
|
326
327
|
});
|
|
327
328
|
|
|
329
|
+
// #2310: the gate still ends the run `gated` — an append that only reaches
|
|
330
|
+
// the local branch is still a correct local answer, and the #2309 author
|
|
331
|
+
// was right that this must not become a failure. But the run used to say
|
|
332
|
+
// nothing at all about the rejected push; now it does.
|
|
333
|
+
test("a pending fact whose push was rejected still ends the run gated, and says the push failed", async () => {
|
|
334
|
+
const { calls, activities } = tracked();
|
|
335
|
+
const port = {
|
|
336
|
+
async read() {
|
|
337
|
+
return { resolutions: [] as GateResolutionRecord[], pending: [] as PendingGateRecord[] };
|
|
338
|
+
},
|
|
339
|
+
async appendPending(input: PendingGateInput) {
|
|
340
|
+
return {
|
|
341
|
+
record: { version: 1 as const, kind: "pending" as const, ...input },
|
|
342
|
+
pushed: false,
|
|
343
|
+
pushWarning: "chant/lifecycle remote branch has moved since this run started",
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
const result = await runOpLocally(gatedOp(), activities, PROFILES, undefined, { gates: port, now: NOW });
|
|
348
|
+
|
|
349
|
+
expect(result.status).toBe("gated");
|
|
350
|
+
expect(calls).toEqual(["before"]);
|
|
351
|
+
expect(result.gate).toMatchObject({ op: "test-op", gate: "approve-prod" });
|
|
352
|
+
expect(result.gatePushed).toBe(false);
|
|
353
|
+
expect(result.gatePushWarning).toBe("chant/lifecycle remote branch has moved since this run started");
|
|
354
|
+
});
|
|
355
|
+
|
|
328
356
|
// #2202: the gate step's name key is `gate`; `signalName` is read through
|
|
329
357
|
// 0.59.0, so a step still spelling it that way reaches the same ledger entry.
|
|
330
358
|
test("a gate step still using the deprecated `signalName` key names the same gate", async () => {
|
|
@@ -523,7 +551,7 @@ describe("runOpLocally — a failure inside the run says what it was (#2301)", (
|
|
|
523
551
|
async read() {
|
|
524
552
|
return { resolutions: [] as GateResolutionRecord[], pending: [] as PendingGateRecord[] };
|
|
525
553
|
},
|
|
526
|
-
async appendPending(): Promise<
|
|
554
|
+
async appendPending(): Promise<never> {
|
|
527
555
|
throw new Error(message);
|
|
528
556
|
},
|
|
529
557
|
};
|
|
@@ -612,3 +640,198 @@ describe("runOpLocally — a failure inside the run says what it was (#2301)", (
|
|
|
612
640
|
expect(lines.some((l) => l.includes("progress sink exploded"))).toBe(true);
|
|
613
641
|
});
|
|
614
642
|
});
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* #2300, measured on INTENTIUS/choudoufu#1026.
|
|
646
|
+
*
|
|
647
|
+
* The exact sequence that issue ran against a floci-backed root: `chant run
|
|
648
|
+
* live-apply` returns `gated`, `chant approve` writes the resolution, a
|
|
649
|
+
* resource in the root is renamed, and `chant run live-apply` runs again. It
|
|
650
|
+
* applied, with no refusal, because the resolution carried the op, the gate,
|
|
651
|
+
* the approver and a timestamp — and nothing about the plan. The approval had
|
|
652
|
+
* authorised the next run rather than the plan its approver read.
|
|
653
|
+
*
|
|
654
|
+
* These drive the same four steps through the executor, with a Plan phase
|
|
655
|
+
* whose digest moves when the root does.
|
|
656
|
+
*/
|
|
657
|
+
describe("runOpLocally — a gate approves a plan, not the next run (#2300)", () => {
|
|
658
|
+
const NOW = "2026-09-09T12:00:00.000Z";
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A gate ledger a test can approve against, which `memoryGateLedgerPort`
|
|
662
|
+
* cannot: `chant approve` appends a resolution *between* two runs, and the
|
|
663
|
+
* shared port has to carry it into the second one.
|
|
664
|
+
*/
|
|
665
|
+
function approvableLedger(): GateLedgerPort & {
|
|
666
|
+
approve(gate: string, planDigest: string | undefined, at: string): void;
|
|
667
|
+
pending: PendingGateRecord[];
|
|
668
|
+
} {
|
|
669
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
670
|
+
const pending: PendingGateRecord[] = [];
|
|
671
|
+
return {
|
|
672
|
+
pending,
|
|
673
|
+
approve(gate, planDigest, at) {
|
|
674
|
+
resolutions.push({
|
|
675
|
+
version: 1, op: "live-apply", gate, resolvedBy: "alex", timestamp: at,
|
|
676
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
677
|
+
});
|
|
678
|
+
},
|
|
679
|
+
async read() {
|
|
680
|
+
return { resolutions: [...resolutions], pending: [...pending] };
|
|
681
|
+
},
|
|
682
|
+
async appendPending(input) {
|
|
683
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
684
|
+
pending.push(record);
|
|
685
|
+
// No remote in an in-memory ledger — nothing to fail to reach (#2310).
|
|
686
|
+
return { record, pushed: true };
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The root under test, and the Op over it. `terraformPlan` digests whatever
|
|
693
|
+
* `root.address` currently is, so renaming a resource between runs is one
|
|
694
|
+
* assignment — the test's stand-in for editing the `.tf` file.
|
|
695
|
+
*/
|
|
696
|
+
function harness() {
|
|
697
|
+
const root = { address: "aws_s3_bucket.original" };
|
|
698
|
+
const applied: string[] = [];
|
|
699
|
+
const activities = new Map<string, ActivityFn>([
|
|
700
|
+
["terraformPlan", async () => ({
|
|
701
|
+
planFile: "chant.tfplan",
|
|
702
|
+
planDigest: computePlanDigest("terraform-plan", { resourceChanges: [{ address: root.address }] }),
|
|
703
|
+
})],
|
|
704
|
+
["terraformApply", async (args) => {
|
|
705
|
+
applied.push(String(args.planFile));
|
|
706
|
+
return { applied: true };
|
|
707
|
+
}],
|
|
708
|
+
]);
|
|
709
|
+
const config = op({
|
|
710
|
+
name: "live-apply",
|
|
711
|
+
phases: [
|
|
712
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "terraformPlan", id: "plan", args: { root: "app" } }] },
|
|
713
|
+
{
|
|
714
|
+
name: "Gate",
|
|
715
|
+
steps: [{ kind: "gate", gate: "approve-live-apply", plan: stepOutput("plan", "planDigest") }],
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: "Apply",
|
|
719
|
+
steps: [{ kind: "activity", fn: "terraformApply", args: { planFile: stepOutput("plan", "planFile") } }],
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
const digestOf = (address: string) =>
|
|
724
|
+
computePlanDigest("terraform-plan", { resourceChanges: [{ address }] });
|
|
725
|
+
return { root, applied, activities, config, digestOf };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
test("approve, rename a resource in the root, re-run — the Apply phase refuses, naming both digests", async () => {
|
|
729
|
+
const { root, applied, activities, config, digestOf } = harness();
|
|
730
|
+
const gates = approvableLedger();
|
|
731
|
+
|
|
732
|
+
// 1. `chant run live-apply` — gated, and the pending fact names the plan.
|
|
733
|
+
const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW });
|
|
734
|
+
expect(first.status).toBe("gated");
|
|
735
|
+
expect(first.gate?.planDigest).toBe(digestOf("aws_s3_bucket.original"));
|
|
736
|
+
expect(applied).toEqual([]);
|
|
737
|
+
|
|
738
|
+
// 2. `chant approve live-apply approve-live-apply` — for the plan above.
|
|
739
|
+
gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
|
|
740
|
+
|
|
741
|
+
// 3. A resource in the root is renamed.
|
|
742
|
+
root.address = "aws_s3_bucket.renamed";
|
|
743
|
+
|
|
744
|
+
// 4. `chant run live-apply` again. On the pre-#2300 rule this applied.
|
|
745
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
746
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(second.status).toBe("gated");
|
|
750
|
+
expect(applied).toEqual([]);
|
|
751
|
+
|
|
752
|
+
const gateRecord = second.records.find((r) => r.fn === "gate:approve-live-apply");
|
|
753
|
+
expect(gateRecord?.refusal).toContain(`approved: ${digestOf("aws_s3_bucket.original")}`);
|
|
754
|
+
expect(gateRecord?.refusal).toContain(`planned: ${digestOf("aws_s3_bucket.renamed")}`);
|
|
755
|
+
expect(gateRecord?.refusal).toContain("chant approve live-apply approve-live-apply");
|
|
756
|
+
expect(gateRecord?.refusal).toContain("changed between that approval and this plan");
|
|
757
|
+
// The Apply step never ran, and says so.
|
|
758
|
+
expect(second.records.find((r) => r.fn === "terraformApply")?.status).toBe("skipped");
|
|
759
|
+
|
|
760
|
+
// The refusal reaches a reader, not just the record.
|
|
761
|
+
const lines: string[] = [];
|
|
762
|
+
renderHuman(second, (line) => lines.push(line));
|
|
763
|
+
expect(lines.some((l) => l.includes("[refused]") && l.includes("not for this plan"))).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("approve, re-run with the root unchanged — it applies", async () => {
|
|
767
|
+
const { applied, activities, config, digestOf } = harness();
|
|
768
|
+
const gates = approvableLedger();
|
|
769
|
+
|
|
770
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
771
|
+
gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
|
|
772
|
+
|
|
773
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
774
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
expect(second.status).toBe("ok");
|
|
778
|
+
expect(applied).toEqual(["chant.tfplan"]);
|
|
779
|
+
expect(second.records.find((r) => r.fn === "gate:approve-live-apply")?.approval?.resolvedBy).toBe("alex");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The migration (#2300). Every resolution written before this change has no
|
|
784
|
+
* `planDigest` at all, and the safe reading is that it matches nothing: such
|
|
785
|
+
* a record proves someone approved something and nothing about what.
|
|
786
|
+
* Accepting it once — the other candidate reading — would apply exactly the
|
|
787
|
+
* unreviewed change this check exists to catch, on the estates that have
|
|
788
|
+
* been running longest.
|
|
789
|
+
*/
|
|
790
|
+
test("a resolution recorded before plan-bound gates does not pass, and the refusal says so", async () => {
|
|
791
|
+
const { applied, activities, config, digestOf } = harness();
|
|
792
|
+
const gates = approvableLedger();
|
|
793
|
+
|
|
794
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
795
|
+
// A pre-#2300 resolution: newer than the pending fact, no plan on it.
|
|
796
|
+
gates.approve("approve-live-apply", undefined, "2026-09-09T12:05:00.000Z");
|
|
797
|
+
|
|
798
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
799
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
expect(second.status).toBe("gated");
|
|
803
|
+
expect(applied).toEqual([]);
|
|
804
|
+
const gateRecord = second.records.find((r) => r.fn === "gate:approve-live-apply");
|
|
805
|
+
expect(gateRecord?.refusal).toContain("approved: (none — recorded before plan-bound gates)");
|
|
806
|
+
expect(gateRecord?.refusal).toContain(`planned: ${digestOf("aws_s3_bucket.original")}`);
|
|
807
|
+
// ...and says why, which is not "something changed": nothing is known to
|
|
808
|
+
// have changed, the record just never said what it approved.
|
|
809
|
+
expect(gateRecord?.refusal).toContain("records no plan at all");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* A gate with no `plan` is every gate that existed before #2300 — a
|
|
814
|
+
* component gate, an authored `gate()` with nothing to bind. Those decide
|
|
815
|
+
* exactly as they did: newest resolution since the pending fact wins.
|
|
816
|
+
*/
|
|
817
|
+
test("a gate that binds no plan is unchanged", async () => {
|
|
818
|
+
const activities = new Map<string, ActivityFn>([["deploy", async () => ({ ok: true })]]);
|
|
819
|
+
const config = op({
|
|
820
|
+
name: "live-apply",
|
|
821
|
+
phases: [
|
|
822
|
+
{ name: "Gate", steps: [{ kind: "gate", gate: "approve-live-apply" }] },
|
|
823
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
824
|
+
],
|
|
825
|
+
});
|
|
826
|
+
const gates = approvableLedger();
|
|
827
|
+
|
|
828
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
829
|
+
expect(gates.pending[0].planDigest).toBeUndefined();
|
|
830
|
+
gates.approve("approve-live-apply", undefined, "2026-09-09T12:05:00.000Z");
|
|
831
|
+
|
|
832
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
833
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
834
|
+
});
|
|
835
|
+
expect(second.status).toBe("ok");
|
|
836
|
+
});
|
|
837
|
+
});
|
package/src/op/local-executor.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activi
|
|
|
25
25
|
import type { ReceiptReadResult } from "./receipt-store";
|
|
26
26
|
import { isStepOutputRef } from "./step-output-ref";
|
|
27
27
|
import { parseDuration } from "./duration";
|
|
28
|
-
import { evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
28
|
+
import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
29
29
|
import { gateName } from "./gate-name";
|
|
30
30
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
31
31
|
import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
|
|
@@ -58,6 +58,14 @@ export interface StepRecord {
|
|
|
58
58
|
error?: string;
|
|
59
59
|
/** Set on a `gate` step that passed (#2119): who resolved it, when, and at what address. */
|
|
60
60
|
approval?: { gate: string; resolvedBy: string; timestamp: string; url?: string };
|
|
61
|
+
/**
|
|
62
|
+
* Why a step declined to proceed on something that is not a failure
|
|
63
|
+
* (#2300): a gate holding a standing approval for a *different* plan. Names
|
|
64
|
+
* both digests and the `chant approve` line that closes the gap. The step's
|
|
65
|
+
* status is `skipped` and the run's is `gated` — nothing broke, and nothing
|
|
66
|
+
* was applied.
|
|
67
|
+
*/
|
|
68
|
+
refusal?: string;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export interface OpRunResult {
|
|
@@ -76,6 +84,15 @@ export interface OpRunResult {
|
|
|
76
84
|
startedAt: string;
|
|
77
85
|
/** Present when `status === "gated"`: the pending fact the run ended on. */
|
|
78
86
|
gate?: PendingGateRecord;
|
|
87
|
+
/**
|
|
88
|
+
* Present when `status === "gated"` and this run's own append tried to push:
|
|
89
|
+
* whether it reached the remote (#2310). Absent when the run stopped on a
|
|
90
|
+
* pending fact an earlier run had already recorded — nothing was pushed
|
|
91
|
+
* this run.
|
|
92
|
+
*/
|
|
93
|
+
gatePushed?: boolean;
|
|
94
|
+
/** Set when `gatePushed` is false: why, in one line. */
|
|
95
|
+
gatePushWarning?: string;
|
|
79
96
|
/**
|
|
80
97
|
* The run's ledger record (#2118) — always built, whether or not it was
|
|
81
98
|
* appended. `chant run <op> --json` prints exactly this, so what a caller
|
|
@@ -129,6 +146,9 @@ class GateStop extends Error {
|
|
|
129
146
|
public readonly records: StepRecord[],
|
|
130
147
|
public readonly pending: PendingGateRecord,
|
|
131
148
|
public readonly phase: string,
|
|
149
|
+
/** Whether this run's own append reached the remote — see {@link OpRunResult.gatePushed}. */
|
|
150
|
+
public readonly pushed?: boolean,
|
|
151
|
+
public readonly pushWarning?: string,
|
|
132
152
|
) {
|
|
133
153
|
super(`gate "${pending.gate}" is pending approval`);
|
|
134
154
|
this.name = "GateStop";
|
|
@@ -384,8 +404,16 @@ async function runGateStep(
|
|
|
384
404
|
step: GateStep,
|
|
385
405
|
phaseName: string,
|
|
386
406
|
gates: GateContext,
|
|
387
|
-
|
|
407
|
+
resultsById: ReadonlyMap<string, unknown> = new Map(),
|
|
408
|
+
): Promise<{ record: StepRecord; pending?: PendingGateRecord; pushed?: boolean; pushWarning?: string }> {
|
|
388
409
|
const start = Date.now();
|
|
410
|
+
// #2300: `plan` is authored as a reference into the Plan phase's result
|
|
411
|
+
// (`plan.out.planDigest`), and is resolved here through the same walk an
|
|
412
|
+
// activity's args go through. A reference that resolves to anything but a
|
|
413
|
+
// string leaves the gate unbound — the pre-#2300 rule — rather than failing
|
|
414
|
+
// a run over a missing digest.
|
|
415
|
+
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
416
|
+
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
389
417
|
let check: GateCheck;
|
|
390
418
|
try {
|
|
391
419
|
check = await evaluateGate(gates.port, {
|
|
@@ -394,6 +422,7 @@ async function runGateStep(
|
|
|
394
422
|
...(step.description ? { description: step.description } : {}),
|
|
395
423
|
...(step.timeout ? { timeout: step.timeout } : {}),
|
|
396
424
|
...(gates.runId ? { runId: gates.runId } : {}),
|
|
425
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
397
426
|
...(gates.now ? { now: gates.now } : {}),
|
|
398
427
|
});
|
|
399
428
|
} catch (err) {
|
|
@@ -436,9 +465,21 @@ async function runGateStep(
|
|
|
436
465
|
};
|
|
437
466
|
}
|
|
438
467
|
|
|
468
|
+
const refusal = check.mismatch
|
|
469
|
+
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch) }
|
|
470
|
+
: {};
|
|
439
471
|
return {
|
|
440
|
-
record: {
|
|
472
|
+
record: {
|
|
473
|
+
phase: phaseName,
|
|
474
|
+
fn: gateFn(step),
|
|
475
|
+
args: {},
|
|
476
|
+
status: "skipped",
|
|
477
|
+
durationMs: Date.now() - start,
|
|
478
|
+
...refusal,
|
|
479
|
+
},
|
|
441
480
|
pending: check.pending,
|
|
481
|
+
pushed: check.pushed,
|
|
482
|
+
...(check.pushWarning ? { pushWarning: check.pushWarning } : {}),
|
|
442
483
|
};
|
|
443
484
|
}
|
|
444
485
|
|
|
@@ -463,7 +504,13 @@ async function runEffectStep(
|
|
|
463
504
|
resultsById: Map<string, unknown>,
|
|
464
505
|
gates: GateContext,
|
|
465
506
|
signal?: AbortSignal,
|
|
466
|
-
): Promise<{
|
|
507
|
+
): Promise<{
|
|
508
|
+
records: StepRecord[];
|
|
509
|
+
failed: boolean;
|
|
510
|
+
pending?: PendingGateRecord;
|
|
511
|
+
pushed?: boolean;
|
|
512
|
+
pushWarning?: string;
|
|
513
|
+
}> {
|
|
467
514
|
const records: StepRecord[] = [];
|
|
468
515
|
|
|
469
516
|
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
|
|
@@ -510,7 +557,7 @@ async function runEffectStep(
|
|
|
510
557
|
for (let i = 0; i < step.steps.length; i++) {
|
|
511
558
|
const nested = step.steps[i];
|
|
512
559
|
if (isGate(nested)) {
|
|
513
|
-
const { record, pending } = await runGateStep(nested, phaseName, gates);
|
|
560
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(nested, phaseName, gates, resultsById);
|
|
514
561
|
pushRecord(records, gates, record);
|
|
515
562
|
if (record.status === "fail") {
|
|
516
563
|
// Receipt left untouched, as for any other failing nested step
|
|
@@ -522,7 +569,7 @@ async function runEffectStep(
|
|
|
522
569
|
// Receipt left untouched — the next run re-proposes the effect and
|
|
523
570
|
// re-evaluates the gate against whatever the ledger says by then.
|
|
524
571
|
skipRest(i + 1);
|
|
525
|
-
return { records, failed: false, pending };
|
|
572
|
+
return { records, failed: false, pending, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
526
573
|
}
|
|
527
574
|
continue;
|
|
528
575
|
}
|
|
@@ -577,7 +624,7 @@ async function runPhase(
|
|
|
577
624
|
// gate is about to strand would defeat the point of stopping at it.
|
|
578
625
|
const gateRecords: StepRecord[] = [];
|
|
579
626
|
for (const step of phase.steps.filter(isGate)) {
|
|
580
|
-
const { record, pending } = await runGateStep(step, phase.name, gates);
|
|
627
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
581
628
|
pushRecord(gateRecords, gates, record);
|
|
582
629
|
if (record.status === "fail") {
|
|
583
630
|
// The gate could not be decided at all (#2301). Same treatment as a
|
|
@@ -591,7 +638,7 @@ async function runPhase(
|
|
|
591
638
|
for (const skipped of phase.steps.filter(isActivity)) {
|
|
592
639
|
pushRecord(gateRecords, gates, skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
593
640
|
}
|
|
594
|
-
throw new GateStop(gateRecords, pending, phase.name);
|
|
641
|
+
throw new GateStop(gateRecords, pending, phase.name, pushed, pushWarning);
|
|
595
642
|
}
|
|
596
643
|
}
|
|
597
644
|
const steps = phase.steps.filter(isActivity);
|
|
@@ -622,7 +669,7 @@ async function runPhase(
|
|
|
622
669
|
for (let i = 0; i < steps.length; i++) {
|
|
623
670
|
const step = steps[i];
|
|
624
671
|
if (isGate(step)) {
|
|
625
|
-
const { record, pending } = await runGateStep(step, phase.name, gates);
|
|
672
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
626
673
|
pushRecord(records, gates, record);
|
|
627
674
|
if (record.status === "fail") {
|
|
628
675
|
// A gate that could not be decided is a failed step, not a pending
|
|
@@ -632,18 +679,18 @@ async function runPhase(
|
|
|
632
679
|
}
|
|
633
680
|
if (pending) {
|
|
634
681
|
skipRemaining(i + 1);
|
|
635
|
-
throw new GateStop(records, pending, phase.name);
|
|
682
|
+
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
636
683
|
}
|
|
637
684
|
continue;
|
|
638
685
|
}
|
|
639
686
|
if (isEffect(step)) {
|
|
640
|
-
const { records: effRecords, failed, pending } = await runEffectStep(
|
|
687
|
+
const { records: effRecords, failed, pending, pushed, pushWarning } = await runEffectStep(
|
|
641
688
|
step, phase.name, activities, profiles, resultsById, gates, signal,
|
|
642
689
|
);
|
|
643
690
|
records.push(...effRecords); // already emitted by runEffectStep
|
|
644
691
|
if (pending) {
|
|
645
692
|
skipRemaining(i + 1);
|
|
646
|
-
throw new GateStop(records, pending, phase.name);
|
|
693
|
+
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
647
694
|
}
|
|
648
695
|
if (failed) {
|
|
649
696
|
skipRemaining(i + 1);
|
|
@@ -813,6 +860,8 @@ export async function runOpLocally(
|
|
|
813
860
|
status: "gated",
|
|
814
861
|
startedAt,
|
|
815
862
|
gate: err.pending,
|
|
863
|
+
...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
|
|
864
|
+
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
816
865
|
record: await settle(records, "gated", err.pending),
|
|
817
866
|
};
|
|
818
867
|
}
|
|
@@ -101,6 +101,27 @@ describe("renderHuman — gated (#2119)", () => {
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("renderHuman — a gated run whose own push never reached the remote (#2310)", () => {
|
|
105
|
+
test("warns that an operator elsewhere cannot see the pending fact, rather than staying silent", () => {
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
renderHuman(
|
|
108
|
+
{ ...GATED, gatePushed: false, gatePushWarning: "chant/lifecycle remote branch has moved since this run started" },
|
|
109
|
+
(l) => lines.push(l),
|
|
110
|
+
);
|
|
111
|
+
const out = lines.join("\n");
|
|
112
|
+
expect(out).toContain('Op "prod-apply" is gated on "rollout-gate"');
|
|
113
|
+
expect(out).toContain("was not pushed to the remote");
|
|
114
|
+
expect(out).toContain("chant/lifecycle remote branch has moved since this run started");
|
|
115
|
+
expect(out).toContain("An operator elsewhere cannot approve it");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("stays as it was when the push landed", () => {
|
|
119
|
+
const lines: string[] = [];
|
|
120
|
+
renderHuman({ ...GATED, gatePushed: true }, (l) => lines.push(l));
|
|
121
|
+
expect(lines.join("\n")).not.toContain("was not pushed");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
104
125
|
describe("renderJson", () => {
|
|
105
126
|
test("prints the run's ledger record, not the raw result (#2118)", () => {
|
|
106
127
|
const lines: string[] = [];
|
|
@@ -135,4 +156,21 @@ describe("renderJson", () => {
|
|
|
135
156
|
expect(parsed.gate).toEqual({ name: "rollout-gate", since: "2026-09-05T12:00:00.000Z" });
|
|
136
157
|
expect(parsed.approve).toBe("chant approve prod-apply rollout-gate");
|
|
137
158
|
});
|
|
159
|
+
|
|
160
|
+
// #2310: a JSON consumer (CI tooling reading `chant run --json`) needs the
|
|
161
|
+
// same fact the human render shows — not just a silent success.
|
|
162
|
+
test("a gated record whose push failed carries pushed:false and why", () => {
|
|
163
|
+
const lines: string[] = [];
|
|
164
|
+
renderJson({ ...GATED, gatePushed: false, gatePushWarning: "no remote is configured" }, (l) => lines.push(l));
|
|
165
|
+
const parsed = JSON.parse(lines[0]) as { pushed?: boolean; pushWarning?: string };
|
|
166
|
+
expect(parsed.pushed).toBe(false);
|
|
167
|
+
expect(parsed.pushWarning).toBe("no remote is configured");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a gated record whose push landed carries no pushed field", () => {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
renderJson({ ...GATED, gatePushed: true }, (l) => lines.push(l));
|
|
173
|
+
const parsed = JSON.parse(lines[0]) as { pushed?: boolean };
|
|
174
|
+
expect(parsed.pushed).toBeUndefined();
|
|
175
|
+
});
|
|
138
176
|
});
|
package/src/op/local-output.ts
CHANGED
|
@@ -47,6 +47,9 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
47
47
|
write(` [approved] ${record.approval.resolvedBy} at ${record.approval.timestamp}` +
|
|
48
48
|
(record.approval.url ? ` (${record.approval.url})` : ""));
|
|
49
49
|
}
|
|
50
|
+
if (record.refusal) {
|
|
51
|
+
write(` [refused] ${record.refusal}`);
|
|
52
|
+
}
|
|
50
53
|
if (record.error) {
|
|
51
54
|
write(` ${record.error}`);
|
|
52
55
|
}
|
|
@@ -68,9 +71,23 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
68
71
|
const { gate } = result;
|
|
69
72
|
write(`Op "${result.op}" is gated on "${gate.gate}" after ${total}`);
|
|
70
73
|
if (gate.description) write(` ${gate.description}`);
|
|
74
|
+
// #2300: the plan the approval will be bound to. Printed before the
|
|
75
|
+
// command, because it is what the command approves.
|
|
76
|
+
if (gate.planDigest) write(` plan : ${gate.planDigest}`);
|
|
71
77
|
write(` approve : ${approveCommand(gate.op, gate.gate)}`);
|
|
72
78
|
if (gate.url) write(` approve at: ${gate.url}`);
|
|
73
79
|
write(` expires : ${gate.expiresAt}`);
|
|
80
|
+
// #2310: this run's own append reached only the local chant/lifecycle
|
|
81
|
+
// branch, not the remote. The pending fact is still correct — the gate is
|
|
82
|
+
// still right to stand — but an operator working from a clone of the
|
|
83
|
+
// remote cannot see it to approve it, and nothing else here says so.
|
|
84
|
+
if (result.gatePushed === false) {
|
|
85
|
+
write(
|
|
86
|
+
` warning : the pending fact was not pushed to the remote — ` +
|
|
87
|
+
(result.gatePushWarning ?? "it exists only in this checkout") +
|
|
88
|
+
`. An operator elsewhere cannot approve it until it does.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/**
|
|
@@ -86,7 +103,13 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
86
103
|
*/
|
|
87
104
|
export function renderJson(result: OpRunResult, write: Writer = stdout): void {
|
|
88
105
|
const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate) } : {};
|
|
89
|
-
|
|
106
|
+
// #2310: whether this run's own append reached the remote — not part of
|
|
107
|
+
// the persisted ledger record (a replay has nothing new to report), but a
|
|
108
|
+
// live run's JSON consumer needs it exactly where the human render shows it.
|
|
109
|
+
const push = result.gatePushed === false
|
|
110
|
+
? { pushed: false, pushWarning: result.gatePushWarning ?? "the pending fact was recorded locally only" }
|
|
111
|
+
: {};
|
|
112
|
+
write(JSON.stringify({ ...result.record, ...approve, ...push }));
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
export type { OpRunResult, StepRecord };
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -222,6 +222,28 @@ describe("op.json IR", () => {
|
|
|
222
222
|
expect(reconstructed.labels).toEqual({ Team: "infra", Env: "staging" });
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
// #2300 — a gate can bind a plan by referencing the Plan step's digest, and
|
|
226
|
+
// the reference has to survive the IR: a foreign runtime resolves it the
|
|
227
|
+
// same way it resolves one in an activity's args.
|
|
228
|
+
it("carries a gate's plan reference through op.json and back", () => {
|
|
229
|
+
const config: OpConfig = {
|
|
230
|
+
name: "live-apply",
|
|
231
|
+
overview: "o",
|
|
232
|
+
phases: [
|
|
233
|
+
phase("Plan", [{ kind: "activity", fn: "shellCmd", id: "plan", args: { cmd: "plan" } }]),
|
|
234
|
+
phase("Gate", [{ kind: "gate", gate: "approve-live-apply", plan: stepOutput("plan", "planDigest") }]),
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
const text = serializeOpIR(config);
|
|
238
|
+
const ir = JSON.parse(text) as OpIR;
|
|
239
|
+
const gate = ir.phases[1].steps[0];
|
|
240
|
+
expect(gate.kind).toBe("gate");
|
|
241
|
+
expect((gate as { plan?: unknown }).plan).toMatchObject({ step: "plan", path: "planDigest" });
|
|
242
|
+
|
|
243
|
+
const reconstructed = opConfigFromIR(ir);
|
|
244
|
+
expect(serializeOpIR(reconstructed)).toBe(text);
|
|
245
|
+
});
|
|
246
|
+
|
|
225
247
|
it("round-trips a minimal Op (no gate, effect, onFailure or labels) too", () => {
|
|
226
248
|
const original: OpConfig = { name: "minimal", overview: "o", phases: [phase("Only", [shell("echo hi")])] };
|
|
227
249
|
const text = serializeOpIR(original);
|
package/src/op/op-ir.ts
CHANGED
|
@@ -137,6 +137,13 @@ export interface OpIRGateStep {
|
|
|
137
137
|
/** Resolved to its effective value — `GateStep.timeout ?? "48h"`. */
|
|
138
138
|
timeout: string;
|
|
139
139
|
description?: string;
|
|
140
|
+
/**
|
|
141
|
+
* The plan this gate approves (#2300) — a digest string, or a
|
|
142
|
+
* {@link StepOutputRef} placeholder a foreign runtime resolves from the
|
|
143
|
+
* named step's result the same way it resolves one in an activity's args.
|
|
144
|
+
* Absent on a gate that binds no plan.
|
|
145
|
+
*/
|
|
146
|
+
plan?: GateStep["plan"];
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
export interface OpIREffectStep {
|
|
@@ -232,6 +239,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
232
239
|
gate: gateName(step),
|
|
233
240
|
timeout: step.timeout ?? "48h",
|
|
234
241
|
...(step.description ? { description: step.description } : {}),
|
|
242
|
+
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
235
243
|
};
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -380,6 +388,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
380
388
|
gate: step.gate,
|
|
381
389
|
timeout: step.timeout,
|
|
382
390
|
...(step.description ? { description: step.description } : {}),
|
|
391
|
+
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
383
392
|
};
|
|
384
393
|
}
|
|
385
394
|
return {
|
package/src/op/runtime.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface OpRunStepRecord {
|
|
|
49
49
|
approval?: { gate: string; resolvedBy: string; timestamp: string; url?: string };
|
|
50
50
|
/** The failure message, for `status: "fail"`. */
|
|
51
51
|
error?: string;
|
|
52
|
+
/** Why a gate declined a standing approval that was for another plan (#2300) — see {@link StepRecord.refusal}. */
|
|
53
|
+
refusal?: string;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** One phase's steps and the verdict they add up to. */
|
package/src/op/types.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { EffectReceiptRef } from "./receipt-store";
|
|
9
9
|
import type { ActivityProfileName } from "./activity-profiles";
|
|
10
|
+
import type { StepOutputRef } from "./step-output-ref";
|
|
10
11
|
|
|
11
12
|
export interface OpConfig {
|
|
12
13
|
/** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
|
|
@@ -177,6 +178,24 @@ export interface GateStepBase {
|
|
|
177
178
|
timeout?: string;
|
|
178
179
|
/** Human-readable description of the action required to unblock this gate. */
|
|
179
180
|
description?: string;
|
|
181
|
+
/**
|
|
182
|
+
* The plan this gate approves (#2300), normally a {@link StepOutputRef}
|
|
183
|
+
* into the Plan phase's own digest — `plan.out.planDigest`. The executor
|
|
184
|
+
* resolves it the same way it resolves an activity step's args, and hands
|
|
185
|
+
* the result to `evaluateGate` as `planDigest`.
|
|
186
|
+
*
|
|
187
|
+
* With it, a resolution satisfies this gate only when it was recorded for
|
|
188
|
+
* that exact plan; a resolution for another plan is refused by name.
|
|
189
|
+
* Without it the gate authorises the next run rather than a plan, which is
|
|
190
|
+
* what every gate did before #2300 and what
|
|
191
|
+
* INTENTIUS/choudoufu#1026 measured.
|
|
192
|
+
*
|
|
193
|
+
* A `string` here is a digest computed elsewhere; anything else is a
|
|
194
|
+
* reference resolved at run time. A reference that resolves to no string
|
|
195
|
+
* leaves the gate unbound rather than failing the run — an Op whose Plan
|
|
196
|
+
* phase publishes no digest is not thereby unrunnable.
|
|
197
|
+
*/
|
|
198
|
+
plan?: string | StepOutputRef;
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
/**
|