@intentius/chant 0.61.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 -18
- 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/lexicon.d.ts +51 -0
- package/dist/lexicon.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/git.d.ts +117 -0
- package/dist/lifecycle/git.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 +33 -3
- 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 +394 -14
- 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 +34 -2
- 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/operator.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/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.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 +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- 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/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- 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 +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- 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 +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- 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/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- 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
|
@@ -16,6 +16,7 @@ const readGateResolutionsMock = vi.fn();
|
|
|
16
16
|
const readGateLedgerMock = vi.fn();
|
|
17
17
|
const readRunLedgerMock = vi.fn();
|
|
18
18
|
const pushLifecycleMock = vi.fn();
|
|
19
|
+
const requireLifecycleLedgerMock = vi.fn();
|
|
19
20
|
|
|
20
21
|
vi.mock("../../op/operator", async () => {
|
|
21
22
|
const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
|
|
@@ -59,7 +60,13 @@ vi.mock("../../lifecycle/gate-ledger", async () => {
|
|
|
59
60
|
});
|
|
60
61
|
vi.mock("../../lifecycle/git", async () => {
|
|
61
62
|
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
62
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
...actual,
|
|
65
|
+
pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args),
|
|
66
|
+
// Mocked, not merely defaulted: the real one shells to `git fetch`, and
|
|
67
|
+
// these tests run in the chant checkout itself (#2303).
|
|
68
|
+
requireLifecycleLedger: (...args: unknown[]) => requireLifecycleLedgerMock(...args),
|
|
69
|
+
};
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
// Imported after the mocks above are registered.
|
|
@@ -80,6 +87,7 @@ beforeEach(() => {
|
|
|
80
87
|
loadProfilesMock.mockResolvedValue({});
|
|
81
88
|
discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
|
|
82
89
|
pushLifecycleMock.mockResolvedValue(true);
|
|
90
|
+
requireLifecycleLedgerMock.mockResolvedValue(undefined);
|
|
83
91
|
readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
|
|
84
92
|
readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
|
|
85
93
|
});
|
|
@@ -341,6 +349,29 @@ describe("runOperatorStatus", () => {
|
|
|
341
349
|
});
|
|
342
350
|
});
|
|
343
351
|
|
|
352
|
+
/**
|
|
353
|
+
* A digest shaped the way `computePlanDigest` produces them (#2300).
|
|
354
|
+
*/
|
|
355
|
+
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The standing pending fact `chant approve` reads the plan off since #2300.
|
|
359
|
+
* Every approve test seeds one, because a gate with nothing pending has no
|
|
360
|
+
* plan to approve and the command refuses rather than recording an approval
|
|
361
|
+
* of whatever runs next — which is its own test below.
|
|
362
|
+
*/
|
|
363
|
+
function seedPending(op: string, gate: string, planDigest: string | undefined): void {
|
|
364
|
+
readGateLedgerMock.mockResolvedValue({
|
|
365
|
+
resolutions: [],
|
|
366
|
+
pending: [{
|
|
367
|
+
version: 1, kind: "pending", op, gate,
|
|
368
|
+
timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
|
|
369
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
370
|
+
}],
|
|
371
|
+
malformed: 0,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
344
375
|
describe("runApprove", () => {
|
|
345
376
|
test("requires both <op> and <gate>", async () => {
|
|
346
377
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
@@ -350,6 +381,7 @@ describe("runApprove", () => {
|
|
|
350
381
|
});
|
|
351
382
|
|
|
352
383
|
test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
|
|
384
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
353
385
|
appendGateResolutionMock.mockResolvedValue({
|
|
354
386
|
commit: "sha",
|
|
355
387
|
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
@@ -372,6 +404,7 @@ describe("runApprove", () => {
|
|
|
372
404
|
test("--approver wins over --actor and over the CI/shell identity", async () => {
|
|
373
405
|
vi.stubEnv("GITHUB_ACTOR", "ci-bot");
|
|
374
406
|
vi.stubEnv("USER", "alex");
|
|
407
|
+
seedPending("deploy-gated", "approve-deploy", PLAN_A);
|
|
375
408
|
appendGateResolutionMock.mockResolvedValue({
|
|
376
409
|
commit: "sha",
|
|
377
410
|
record: { version: 1, op: "deploy-gated", gate: "approve-deploy", resolvedBy: "you", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
@@ -396,6 +429,7 @@ describe("runApprove", () => {
|
|
|
396
429
|
vi.stubEnv("GITHUB_REF_NAME", "");
|
|
397
430
|
vi.stubEnv("GITHUB_REPOSITORY", "");
|
|
398
431
|
vi.stubEnv("CI_MERGE_REQUEST_PROJECT_URL", "");
|
|
432
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
399
433
|
appendGateResolutionMock.mockResolvedValue({
|
|
400
434
|
commit: "sha",
|
|
401
435
|
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", url: "https://github.com/org/repo/pull/9" },
|
|
@@ -419,6 +453,7 @@ describe("runApprove", () => {
|
|
|
419
453
|
vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
|
|
420
454
|
vi.stubEnv("GITHUB_REPOSITORY", "INTENTIUS/chant");
|
|
421
455
|
vi.stubEnv("GITHUB_REF_NAME", "2028/merge");
|
|
456
|
+
seedPending("fountain-apply", "g", PLAN_A);
|
|
422
457
|
appendGateResolutionMock.mockResolvedValue({
|
|
423
458
|
commit: "sha",
|
|
424
459
|
record: { version: 1, op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
@@ -480,6 +515,7 @@ describe("runApprove", () => {
|
|
|
480
515
|
|
|
481
516
|
test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
|
|
482
517
|
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
518
|
+
seedPending("unknown-op", "g", PLAN_A);
|
|
483
519
|
appendGateResolutionMock.mockResolvedValue({
|
|
484
520
|
commit: "sha",
|
|
485
521
|
record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
@@ -726,3 +762,198 @@ describe("runOperatorLog", () => {
|
|
|
726
762
|
errSpy.mockRestore();
|
|
727
763
|
});
|
|
728
764
|
});
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* #2303 finding 2, at the handler. The mechanism is proved against real git
|
|
768
|
+
* in `op/gate.test.ts`; what these hold is that `chant approve` actually goes
|
|
769
|
+
* through the guard, and stops rather than writing when it refuses — the
|
|
770
|
+
* append must not be reached, because reaching it is what discarded the
|
|
771
|
+
* pending fact in the first place.
|
|
772
|
+
*/
|
|
773
|
+
/**
|
|
774
|
+
* #2300 — an approval is for a plan. `chant approve` records the plan it
|
|
775
|
+
* approves, so the resolution says what was reviewed rather than only who
|
|
776
|
+
* reviewed and when.
|
|
777
|
+
*/
|
|
778
|
+
describe("runApprove — the resolution names the plan it approves (#2300)", () => {
|
|
779
|
+
test("by default it approves the standing pending fact's plan, so the common path stays one command", async () => {
|
|
780
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
781
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
782
|
+
commit: "sha",
|
|
783
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
|
|
784
|
+
});
|
|
785
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
786
|
+
|
|
787
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
|
|
788
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A }));
|
|
789
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain(`approves the plan ${PLAN_A}`);
|
|
790
|
+
errSpy.mockRestore();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("--plan names one explicitly, and wins over the standing fact's", async () => {
|
|
794
|
+
const other = `sha256:${"b".repeat(64)}`;
|
|
795
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
796
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
797
|
+
commit: "sha",
|
|
798
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: other },
|
|
799
|
+
});
|
|
800
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
801
|
+
|
|
802
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: other }))).toBe(0);
|
|
803
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: other }));
|
|
804
|
+
errSpy.mockRestore();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// With nothing pending there is no plan on the ledger to approve, and
|
|
808
|
+
// recording a resolution anyway is exactly how an approval came to mean
|
|
809
|
+
// "the next run" (INTENTIUS/choudoufu#1026).
|
|
810
|
+
test("with no pending record it refuses, writes nothing, and names the fix", async () => {
|
|
811
|
+
readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
|
|
812
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
813
|
+
|
|
814
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
|
|
815
|
+
|
|
816
|
+
expect(code).toBe(1);
|
|
817
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
818
|
+
expect(pushLifecycleMock).not.toHaveBeenCalled();
|
|
819
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
820
|
+
expect(out).toContain("has no pending fact, so there is no plan to approve");
|
|
821
|
+
expect(out).toContain("chant run fountain-apply");
|
|
822
|
+
expect(out).toContain("--plan <digest>");
|
|
823
|
+
errSpy.mockRestore();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ...but `--plan` is the escape hatch, so approving a plan whose digest you
|
|
827
|
+
// already hold does not need a run to have recorded a pending fact first.
|
|
828
|
+
test("--plan approves with nothing pending", async () => {
|
|
829
|
+
readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
|
|
830
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
831
|
+
commit: "sha",
|
|
832
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
|
|
833
|
+
});
|
|
834
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
835
|
+
|
|
836
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: PLAN_A }))).toBe(0);
|
|
837
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A }));
|
|
838
|
+
errSpy.mockRestore();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("a --plan that is not a digest is refused before anything is written", async () => {
|
|
842
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
843
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
844
|
+
|
|
845
|
+
const code = await runApprove(ctx({
|
|
846
|
+
path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: "chant.tfplan",
|
|
847
|
+
}));
|
|
848
|
+
|
|
849
|
+
expect(code).toBe(1);
|
|
850
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
851
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("--plan must be a plan digest");
|
|
852
|
+
errSpy.mockRestore();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// A gate that binds no plan records no digest on its pending fact, so the
|
|
856
|
+
// resolution carries none either — and nothing refuses, because nothing
|
|
857
|
+
// ever claimed to bind a plan there.
|
|
858
|
+
test("a gate that binds no plan approves as it always did", async () => {
|
|
859
|
+
seedPending("fountain-apply", "rollout-gate", undefined);
|
|
860
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
861
|
+
commit: "sha",
|
|
862
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
863
|
+
});
|
|
864
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
865
|
+
|
|
866
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
|
|
867
|
+
expect(appendGateResolutionMock.mock.calls[0][0]).not.toHaveProperty("planDigest");
|
|
868
|
+
errSpy.mockRestore();
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe("runApprove — the ledger branch is read before it is appended to (#2303)", () => {
|
|
873
|
+
test("reads the branch before recording the resolution", async () => {
|
|
874
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
875
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
876
|
+
commit: "sha",
|
|
877
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
878
|
+
});
|
|
879
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
880
|
+
|
|
881
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
|
|
882
|
+
|
|
883
|
+
expect(requireLifecycleLedgerMock).toHaveBeenCalled();
|
|
884
|
+
expect(requireLifecycleLedgerMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
885
|
+
appendGateResolutionMock.mock.invocationCallOrder[0],
|
|
886
|
+
);
|
|
887
|
+
errSpy.mockRestore();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("refuses by name, and writes nothing, when the branch cannot be read", async () => {
|
|
891
|
+
requireLifecycleLedgerMock.mockRejectedValue(
|
|
892
|
+
new Error(
|
|
893
|
+
'the chant/lifecycle ledger branch is not in this checkout and could not be fetched from "origin"',
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
897
|
+
|
|
898
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
|
|
899
|
+
|
|
900
|
+
expect(code).toBe(1);
|
|
901
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
902
|
+
expect(pushLifecycleMock).not.toHaveBeenCalled();
|
|
903
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("chant/lifecycle ledger branch");
|
|
904
|
+
errSpy.mockRestore();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("--expire is guarded the same way", async () => {
|
|
908
|
+
requireLifecycleLedgerMock.mockRejectedValue(new Error("the chant/lifecycle ledger branch has diverged"));
|
|
909
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
910
|
+
|
|
911
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", expire: true }));
|
|
912
|
+
|
|
913
|
+
expect(code).toBe(1);
|
|
914
|
+
expect(appendPendingGateMock).not.toHaveBeenCalled();
|
|
915
|
+
errSpy.mockRestore();
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* #2309 review. `pushLifecycle().catch(() => undefined)` at both write sites
|
|
921
|
+
* meant a rejected push printed unconditional success and exited 0 — the
|
|
922
|
+
* operator walks away believing the gate is answered for everybody, while the
|
|
923
|
+
* resolution exists only in their own checkout.
|
|
924
|
+
*/
|
|
925
|
+
describe("runApprove — a push that does not land is reported (#2309 review)", () => {
|
|
926
|
+
test("a rejected push warns and marks the success line local-only", async () => {
|
|
927
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
928
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
929
|
+
commit: "sha",
|
|
930
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
931
|
+
});
|
|
932
|
+
pushLifecycleMock.mockRejectedValue(new Error("Another snapshot completed for chant/lifecycle"));
|
|
933
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
934
|
+
|
|
935
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
|
|
936
|
+
|
|
937
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
938
|
+
expect(out).toMatch(/push to the remote was rejected/);
|
|
939
|
+
expect(out).toMatch(/local only . the push did not land/);
|
|
940
|
+
// Still exit 0: the local append is a correct local fact.
|
|
941
|
+
expect(code).toBe(0);
|
|
942
|
+
errSpy.mockRestore();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test("a project with no remote says nothing was pushed", async () => {
|
|
946
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
947
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
948
|
+
commit: "sha",
|
|
949
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
950
|
+
});
|
|
951
|
+
pushLifecycleMock.mockResolvedValue(false);
|
|
952
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
953
|
+
|
|
954
|
+
await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
|
|
955
|
+
|
|
956
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(/No remote is configured/);
|
|
957
|
+
errSpy.mockRestore();
|
|
958
|
+
});
|
|
959
|
+
});
|
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
35
35
|
resolveApprovalUrl, isApprovalUrl,
|
|
36
36
|
} from "../../lifecycle/gate-ledger";
|
|
37
|
-
import {
|
|
37
|
+
import { isPlanDigest } from "../../lifecycle/plan-digest";
|
|
38
|
+
import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
|
|
38
39
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
39
40
|
import type { CommandContext } from "../registry";
|
|
40
41
|
|
|
@@ -598,6 +599,39 @@ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
|
|
|
598
599
|
* is already expired, which supersedes the standing one on an append-only
|
|
599
600
|
* ledger, so the next run decides the gate from scratch.
|
|
600
601
|
*/
|
|
602
|
+
/** The ledger branch, named in the warnings below so a reader can go look at it. */
|
|
603
|
+
const LIFECYCLE_BRANCH = "chant/lifecycle";
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Push the ledger and say so when it does not land (#2309 review, refs #2310).
|
|
607
|
+
*
|
|
608
|
+
* Both write paths here used `pushLifecycle().catch(() => undefined)` and then
|
|
609
|
+
* printed unconditional success and exited 0. A rejected push — a stale lease,
|
|
610
|
+
* no credentials, no network — therefore read as a completed approval, which
|
|
611
|
+
* is the one thing an approval must never do: the operator walks away
|
|
612
|
+
* believing the gate is answered for everybody, while the resolution exists
|
|
613
|
+
* only in their own checkout.
|
|
614
|
+
*
|
|
615
|
+
* The append is still a correct *local* fact, so this is a warning and not a
|
|
616
|
+
* failure; the exit code is unchanged.
|
|
617
|
+
*/
|
|
618
|
+
async function reportedPush(consequence: string): Promise<boolean> {
|
|
619
|
+
try {
|
|
620
|
+
const pushed = await pushLifecycle();
|
|
621
|
+
if (pushed) return true;
|
|
622
|
+
console.error(formatWarning({
|
|
623
|
+
message: `No remote is configured, so nothing was pushed. ${consequence}`,
|
|
624
|
+
}));
|
|
625
|
+
return false;
|
|
626
|
+
} catch (err) {
|
|
627
|
+
console.error(formatWarning({
|
|
628
|
+
message: `The push to the remote was rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
629
|
+
hint: consequence,
|
|
630
|
+
}));
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
601
635
|
export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
602
636
|
const opName = ctx.args.path;
|
|
603
637
|
const gate = ctx.args.extraPositional;
|
|
@@ -607,6 +641,18 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
607
641
|
}
|
|
608
642
|
|
|
609
643
|
if (ctx.args.expire) {
|
|
644
|
+
// Same guard as the approve path below (#2303): `--expire` reads the
|
|
645
|
+
// standing fact and appends beside it, so a clone that never fetched the
|
|
646
|
+
// branch would both fail to see the fact it is expiring and replace the
|
|
647
|
+
// branch with the expiry alone.
|
|
648
|
+
try {
|
|
649
|
+
await requireLifecycleLedger();
|
|
650
|
+
} catch (err) {
|
|
651
|
+
console.error(formatError({
|
|
652
|
+
message: `Cannot expire the gate: ${err instanceof Error ? err.message : String(err)}`,
|
|
653
|
+
}));
|
|
654
|
+
return 1;
|
|
655
|
+
}
|
|
610
656
|
const now = new Date().toISOString();
|
|
611
657
|
const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
|
|
612
658
|
if (!standing || isPendingGateExpired(standing, now)) {
|
|
@@ -622,8 +668,14 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
622
668
|
expiresAt: now,
|
|
623
669
|
...(standing.description ? { description: standing.description } : {}),
|
|
624
670
|
});
|
|
625
|
-
await
|
|
626
|
-
|
|
671
|
+
const pushed = await reportedPush(
|
|
672
|
+
`The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
673
|
+
`a run in another checkout still sees the old pending fact.`,
|
|
674
|
+
);
|
|
675
|
+
console.error(formatSuccess(
|
|
676
|
+
`Gate "${gate}" on "${opName}" expired at ${now} — not approved` +
|
|
677
|
+
(pushed ? "" : " (local only — the push did not land)"),
|
|
678
|
+
));
|
|
627
679
|
console.error(formatInfo(
|
|
628
680
|
`The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
|
|
629
681
|
));
|
|
@@ -638,6 +690,7 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
638
690
|
actor: ctx.args.approver ?? ctx.args.actor,
|
|
639
691
|
note: ctx.args.note,
|
|
640
692
|
url: ctx.args.url,
|
|
693
|
+
plan: ctx.args.plan,
|
|
641
694
|
});
|
|
642
695
|
if (!outcome.ok) return 1;
|
|
643
696
|
|
|
@@ -656,6 +709,12 @@ export interface GateApprovalOptions {
|
|
|
656
709
|
note?: string;
|
|
657
710
|
/** `--url` — the approval surface, validated as absolute http/https. */
|
|
658
711
|
url?: string;
|
|
712
|
+
/**
|
|
713
|
+
* `--plan <digest>` (#2300) — the plan this approval is for. Omitted, the
|
|
714
|
+
* digest is read off the gate's standing pending fact, so the common path
|
|
715
|
+
* stays one command: run, read what it planned, approve it.
|
|
716
|
+
*/
|
|
717
|
+
plan?: string;
|
|
659
718
|
}
|
|
660
719
|
|
|
661
720
|
export type GateApprovalOutcome =
|
|
@@ -669,6 +728,13 @@ export type GateApprovalOutcome =
|
|
|
669
728
|
*
|
|
670
729
|
* Prints the same warning for an undiscovered op and the same success line as
|
|
671
730
|
* `chant approve` always did; the caller adds whatever it does next.
|
|
731
|
+
*
|
|
732
|
+
* Since #2300 the resolution names the plan it approves. By default that is
|
|
733
|
+
* the standing pending fact's plan — the one the gated run produced — so the
|
|
734
|
+
* common path is still `chant run`, read, `chant approve`. With no pending
|
|
735
|
+
* fact and no `--plan`, this refuses: there is nothing on the ledger saying
|
|
736
|
+
* what would be approved, and writing a resolution anyway is what made an
|
|
737
|
+
* approval mean "the next run" instead of "this plan".
|
|
672
738
|
*/
|
|
673
739
|
export async function recordGateApproval(
|
|
674
740
|
opName: string,
|
|
@@ -682,7 +748,20 @@ export async function recordGateApproval(
|
|
|
682
748
|
}));
|
|
683
749
|
}
|
|
684
750
|
|
|
685
|
-
|
|
751
|
+
// Read the branch before appending to it (#2303 finding 2). Without this,
|
|
752
|
+
// an approve in a clone that never fetched `chant/lifecycle` builds its
|
|
753
|
+
// commit from an empty tree with no parent and replaces the branch with a
|
|
754
|
+
// single commit holding only this resolution — the pending fact it is
|
|
755
|
+
// answering is discarded rather than appended to.
|
|
756
|
+
try {
|
|
757
|
+
await requireLifecycleLedger();
|
|
758
|
+
} catch (err) {
|
|
759
|
+
console.error(formatError({
|
|
760
|
+
message: `Cannot record the approval: ${err instanceof Error ? err.message : String(err)}`,
|
|
761
|
+
hint: "The pending fact this answers lives on that branch; appending without it would drop it.",
|
|
762
|
+
}));
|
|
763
|
+
return { ok: false };
|
|
764
|
+
}
|
|
686
765
|
|
|
687
766
|
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
688
767
|
// inside the PR/MR job that carries the change is itself the address, the
|
|
@@ -696,6 +775,40 @@ export async function recordGateApproval(
|
|
|
696
775
|
return { ok: false };
|
|
697
776
|
}
|
|
698
777
|
|
|
778
|
+
// #2300: an approval is for a plan, so this command has to know which one.
|
|
779
|
+
// `--plan` names it outright; otherwise it comes off the gate's standing
|
|
780
|
+
// pending fact — the plan the run that stopped at this gate produced, which
|
|
781
|
+
// is the plan whoever is typing this just read. There is deliberately no
|
|
782
|
+
// third fallback: approving with no plan in sight is the behaviour
|
|
783
|
+
// INTENTIUS/choudoufu#1026 measured, where the resolution authorised the
|
|
784
|
+
// next run rather than anything anyone had seen.
|
|
785
|
+
let planDigest: string | undefined;
|
|
786
|
+
if (opts.plan !== undefined) {
|
|
787
|
+
if (!isPlanDigest(opts.plan)) {
|
|
788
|
+
console.error(formatError({
|
|
789
|
+
message: `--plan must be a plan digest ("sha256:" and 64 hex characters), got "${opts.plan}"`,
|
|
790
|
+
hint: "Copy it from the gated run's `plan :` line, or from the pending-gate summary. It is not a plan file path.",
|
|
791
|
+
}));
|
|
792
|
+
return { ok: false };
|
|
793
|
+
}
|
|
794
|
+
planDigest = opts.plan;
|
|
795
|
+
} else {
|
|
796
|
+
const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
|
|
797
|
+
if (!standing) {
|
|
798
|
+
console.error(formatError({
|
|
799
|
+
message: `Gate "${gate}" on "${opName}" has no pending fact, so there is no plan to approve`,
|
|
800
|
+
hint:
|
|
801
|
+
`Run \`chant run ${opName}\` first — it plans, stops at the gate, and records the plan this ` +
|
|
802
|
+
`approval would be for. To approve a plan you already have the digest for, pass ` +
|
|
803
|
+
`\`--plan <digest>\`.`,
|
|
804
|
+
}));
|
|
805
|
+
return { ok: false };
|
|
806
|
+
}
|
|
807
|
+
planDigest = standing.planDigest;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
|
|
811
|
+
|
|
699
812
|
const { record } = await appendGateResolution({
|
|
700
813
|
op: opName,
|
|
701
814
|
gate,
|
|
@@ -703,12 +816,23 @@ export async function recordGateApproval(
|
|
|
703
816
|
timestamp: new Date().toISOString(),
|
|
704
817
|
...(opts.note ? { note: opts.note } : {}),
|
|
705
818
|
...(url ? { url } : {}),
|
|
819
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
706
820
|
});
|
|
707
|
-
await
|
|
821
|
+
const pushed = await reportedPush(
|
|
822
|
+
`The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
823
|
+
`a run in another checkout will not see it.`,
|
|
824
|
+
);
|
|
708
825
|
|
|
709
826
|
console.error(formatSuccess(
|
|
710
827
|
`Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
|
|
711
|
-
(record.url ? ` (${record.url})` : "")
|
|
828
|
+
(record.url ? ` (${record.url})` : "") +
|
|
829
|
+
(pushed ? "" : " (local only — the push did not land)"),
|
|
712
830
|
));
|
|
831
|
+
if (record.planDigest) {
|
|
832
|
+
console.error(formatInfo(
|
|
833
|
+
`This approves the plan ${record.planDigest}, and only that plan. A run whose fresh plan ` +
|
|
834
|
+
"differs refuses rather than applying it.",
|
|
835
|
+
));
|
|
836
|
+
}
|
|
713
837
|
return { ok: true, record };
|
|
714
838
|
}
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -385,6 +385,7 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
|
|
|
385
385
|
actor: ctx.args.approver ?? ctx.args.actor,
|
|
386
386
|
note: ctx.args.note,
|
|
387
387
|
url: ctx.args.url,
|
|
388
|
+
plan: ctx.args.plan,
|
|
388
389
|
});
|
|
389
390
|
if (!outcome.ok) return 1;
|
|
390
391
|
|
|
@@ -744,6 +745,16 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
744
745
|
console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate)}`));
|
|
745
746
|
if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
|
|
746
747
|
console.error(formatInfo(`expires : ${gate.expiresAt}`));
|
|
748
|
+
// #2310: this run's own append reached only the local chant/lifecycle
|
|
749
|
+
// branch. The gate is still right to stand, but an operator elsewhere
|
|
750
|
+
// cannot see the pending fact to approve it, and nothing else here says
|
|
751
|
+
// why not.
|
|
752
|
+
if (result.gated.pushed === false) {
|
|
753
|
+
console.error(formatWarning({
|
|
754
|
+
message: `the pending fact was not pushed to the remote: ${result.gated.pushWarning ?? "recorded locally only"}`,
|
|
755
|
+
hint: "an operator working from a clone of the remote cannot approve it until it does",
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
747
758
|
reportGatedRun(
|
|
748
759
|
{
|
|
749
760
|
op: gate.op,
|
|
@@ -751,6 +762,8 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
751
762
|
...(gate.description ? { description: gate.description } : {}),
|
|
752
763
|
expiresAt: gate.expiresAt,
|
|
753
764
|
...(gate.url ? { url: gate.url } : {}),
|
|
765
|
+
...(result.gated.pushed === false ? { pushed: false, pushWarning: result.gated.pushWarning } : {}),
|
|
766
|
+
...(gate.planDigest ? { planDigest: gate.planDigest } : {}),
|
|
754
767
|
},
|
|
755
768
|
gatedExit,
|
|
756
769
|
);
|
|
@@ -866,6 +879,12 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
866
879
|
...(pending?.description ? { description: pending.description } : {}),
|
|
867
880
|
...(pending?.expiresAt ? { expiresAt: pending.expiresAt } : {}),
|
|
868
881
|
...(pending?.url ? { url: pending.url } : {}),
|
|
882
|
+
// #2310: the local runtime knows whether this run's own append
|
|
883
|
+
// reached the remote; a runtime that reports only a state does not.
|
|
884
|
+
...(status.result?.gatePushed === false
|
|
885
|
+
? { pushed: false, pushWarning: status.result.gatePushWarning }
|
|
886
|
+
: {}),
|
|
887
|
+
...(pending?.planDigest ? { planDigest: pending.planDigest } : {}),
|
|
869
888
|
},
|
|
870
889
|
gatedExit,
|
|
871
890
|
);
|
package/src/cli/main.ts
CHANGED
|
@@ -404,6 +404,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
404
404
|
result.note = args[++i];
|
|
405
405
|
} else if (arg === "--expire") {
|
|
406
406
|
result.expire = true;
|
|
407
|
+
} else if (arg === "--plan") {
|
|
408
|
+
result.plan = args[++i];
|
|
407
409
|
} else if (arg === "--url") {
|
|
408
410
|
result.url = args[++i];
|
|
409
411
|
} else if (arg === "--op") {
|
package/src/cli/registry.ts
CHANGED
|
@@ -318,6 +318,8 @@ export interface ParsedArgs {
|
|
|
318
318
|
note?: string;
|
|
319
319
|
/** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
|
|
320
320
|
expire?: boolean;
|
|
321
|
+
/** `chant approve <op> <gate> --plan <digest>` (#2300) — the plan this approval is for, as `sha256:<64 hex>`. Omitted, the digest is taken from the gate's standing pending fact, which is the plan the run that stopped at the gate actually produced; pass it to approve a plan explicitly, or to approve one before any run has recorded a pending fact. */
|
|
322
|
+
plan?: string;
|
|
321
323
|
/** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
|
|
322
324
|
op?: string;
|
|
323
325
|
/** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
|
|
@@ -425,7 +425,13 @@ export interface RunComponentsResult {
|
|
|
425
425
|
selected: string[];
|
|
426
426
|
error?: string;
|
|
427
427
|
/** Set when the run stopped at a `gate` nobody has approved (#2119): the component it stopped at and the pending fact recorded for it. */
|
|
428
|
-
gated?: {
|
|
428
|
+
gated?: {
|
|
429
|
+
component: string;
|
|
430
|
+
gate: PendingGateRecord;
|
|
431
|
+
/** Whether this run's own append reached the remote (#2310); absent when the pending fact was already standing. */
|
|
432
|
+
pushed?: boolean;
|
|
433
|
+
pushWarning?: string;
|
|
434
|
+
};
|
|
429
435
|
/** This run's resolved build-time parameters (chant #1108) — the component-driver counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Present only once the run actually reached dispatch (mirrors `BuildResult.buildParams`, which is likewise absent on an early-error return). */
|
|
430
436
|
buildParams?: BuildParamProvenance[];
|
|
431
437
|
}
|
|
@@ -571,7 +577,14 @@ export async function runComponents(
|
|
|
571
577
|
selected,
|
|
572
578
|
buildParams: options.buildParams,
|
|
573
579
|
...(run.status === "gated" && run.gate
|
|
574
|
-
? {
|
|
580
|
+
? {
|
|
581
|
+
gated: {
|
|
582
|
+
component: run.gatedComponent ?? selected[0],
|
|
583
|
+
gate: run.gate,
|
|
584
|
+
...(run.gatePushed !== undefined ? { pushed: run.gatePushed } : {}),
|
|
585
|
+
...(run.gatePushWarning ? { pushWarning: run.gatePushWarning } : {}),
|
|
586
|
+
},
|
|
587
|
+
}
|
|
575
588
|
: {}),
|
|
576
589
|
};
|
|
577
590
|
}
|
|
@@ -613,7 +626,12 @@ export async function runComponents(
|
|
|
613
626
|
status: componentResult.status,
|
|
614
627
|
failedComponent: componentResult.status === "fail" ? componentResult.component : undefined,
|
|
615
628
|
...(componentResult.status === "gated"
|
|
616
|
-
? {
|
|
629
|
+
? {
|
|
630
|
+
gatedComponent: componentResult.component,
|
|
631
|
+
gate: componentResult.gate,
|
|
632
|
+
...(componentResult.gatePushed !== undefined ? { gatePushed: componentResult.gatePushed } : {}),
|
|
633
|
+
...(componentResult.gatePushWarning ? { gatePushWarning: componentResult.gatePushWarning } : {}),
|
|
634
|
+
}
|
|
617
635
|
: {}),
|
|
618
636
|
componentOutputs,
|
|
619
637
|
};
|
|
@@ -623,7 +641,14 @@ export async function runComponents(
|
|
|
623
641
|
selected,
|
|
624
642
|
buildParams: options.buildParams,
|
|
625
643
|
...(componentResult.status === "gated" && componentResult.gate
|
|
626
|
-
? {
|
|
644
|
+
? {
|
|
645
|
+
gated: {
|
|
646
|
+
component: componentResult.component,
|
|
647
|
+
gate: componentResult.gate,
|
|
648
|
+
...(componentResult.gatePushed !== undefined ? { pushed: componentResult.gatePushed } : {}),
|
|
649
|
+
...(componentResult.gatePushWarning ? { pushWarning: componentResult.gatePushWarning } : {}),
|
|
650
|
+
},
|
|
651
|
+
}
|
|
627
652
|
: {}),
|
|
628
653
|
};
|
|
629
654
|
} catch (err) {
|
|
@@ -72,6 +72,16 @@ export function renderDriverHuman(result: DriverRunResult, write: Writer = stder
|
|
|
72
72
|
write(`interpret run completed (${result.order.length} component(s))`);
|
|
73
73
|
} else if (result.status === "gated") {
|
|
74
74
|
write(`interpret run is gated at component "${result.gatedComponent}"`);
|
|
75
|
+
// #2310: this run's own append reached only the local chant/lifecycle
|
|
76
|
+
// branch. The gate still stands correctly — but an operator elsewhere
|
|
77
|
+
// cannot see the pending fact to approve it.
|
|
78
|
+
if (result.gatePushed === false) {
|
|
79
|
+
write(
|
|
80
|
+
` warning : the pending fact was not pushed to the remote — ` +
|
|
81
|
+
(result.gatePushWarning ?? "it exists only in this checkout") +
|
|
82
|
+
`. An operator elsewhere cannot approve it until it does.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
75
85
|
} else {
|
|
76
86
|
write(`interpret run failed at component "${result.failedComponent}"`);
|
|
77
87
|
}
|