@intentius/chant 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/lint.d.ts +13 -0
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/operator.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts +0 -16
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/plugins.d.ts +22 -0
  15. package/dist/cli/plugins.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/cli-support.d.ts +4 -1
  19. package/dist/components/cli-support.d.ts.map +1 -1
  20. package/dist/components/component.d.ts +19 -4
  21. package/dist/components/component.d.ts.map +1 -1
  22. package/dist/components/driver.d.ts +8 -2
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/verbs/run-agent.d.ts +1 -7
  25. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  26. package/dist/detectLexicon.d.ts +13 -0
  27. package/dist/detectLexicon.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +170 -3
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  31. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  32. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +2 -2
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/reconcile.d.ts +62 -2
  36. package/dist/op/activities/reconcile.d.ts.map +1 -1
  37. package/dist/op/builders.d.ts +2 -2
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/change-signal.d.ts +91 -0
  40. package/dist/op/change-signal.d.ts.map +1 -0
  41. package/dist/op/composites/apply-op.d.ts +7 -2
  42. package/dist/op/composites/apply-op.d.ts.map +1 -1
  43. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +40 -0
  45. package/dist/op/gate-name.d.ts.map +1 -0
  46. package/dist/op/gate-summary.d.ts +43 -0
  47. package/dist/op/gate-summary.d.ts.map +1 -0
  48. package/dist/op/index.d.ts +6 -2
  49. package/dist/op/index.d.ts.map +1 -1
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/op-ir.d.ts +2 -1
  52. package/dist/op/op-ir.d.ts.map +1 -1
  53. package/dist/op/operator.d.ts +63 -0
  54. package/dist/op/operator.d.ts.map +1 -1
  55. package/dist/op/types.d.ts +18 -3
  56. package/dist/op/types.d.ts.map +1 -1
  57. package/dist/terraform/bridge.d.ts +26 -7
  58. package/dist/terraform/bridge.d.ts.map +1 -1
  59. package/dist/terraform/carve-provider.d.ts +28 -2
  60. package/dist/terraform/carve-provider.d.ts.map +1 -1
  61. package/dist/terraform/data-source-shape.d.ts +77 -0
  62. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  63. package/dist/terraform/graph.d.ts.map +1 -1
  64. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  65. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  66. package/dist/terraform/tier-map.d.ts +16 -4
  67. package/dist/terraform/tier-map.d.ts.map +1 -1
  68. package/dist/terraform/types.d.ts +8 -0
  69. package/dist/terraform/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/audit/core.ts +25 -3
  72. package/src/audit/discover.ts +122 -14
  73. package/src/cli/commands/audit.test.ts +5 -3
  74. package/src/cli/commands/audit.ts +5 -1
  75. package/src/cli/commands/build.test.ts +39 -1
  76. package/src/cli/commands/build.ts +14 -0
  77. package/src/cli/commands/carve-bridge.test.ts +237 -14
  78. package/src/cli/commands/carve-bridge.ts +21 -17
  79. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  80. package/src/cli/commands/lint.test.ts +146 -1
  81. package/src/cli/commands/lint.ts +82 -7
  82. package/src/cli/handlers/operator.ts +81 -0
  83. package/src/cli/handlers/run.test.ts +132 -3
  84. package/src/cli/handlers/run.ts +87 -3
  85. package/src/cli/main.test.ts +9 -11
  86. package/src/cli/main.ts +5 -27
  87. package/src/cli/plugins.test.ts +68 -2
  88. package/src/cli/plugins.ts +39 -0
  89. package/src/cli/registry.ts +14 -0
  90. package/src/components/README.md +2 -2
  91. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  92. package/src/components/cli-support.test.ts +18 -7
  93. package/src/components/cli-support.ts +8 -3
  94. package/src/components/component-schema.test.ts +18 -2
  95. package/src/components/component.schema.json +17 -4
  96. package/src/components/component.test.ts +2 -2
  97. package/src/components/component.ts +25 -5
  98. package/src/components/config-defaults.test.ts +2 -2
  99. package/src/components/driver.test.ts +20 -1
  100. package/src/components/driver.ts +12 -5
  101. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  102. package/src/components/verbs/run-agent.test.ts +19 -0
  103. package/src/components/verbs/run-agent.ts +1 -7
  104. package/src/detectLexicon.ts +18 -1
  105. package/src/discovery/fold-import.test.ts +1 -1
  106. package/src/fold/foldable-helpers.ts +1 -1
  107. package/src/graph-ops.test.ts +1 -1
  108. package/src/lexicon.ts +177 -3
  109. package/src/lifecycle/gate-ledger.ts +12 -1
  110. package/src/lint/pipeline-change-gate.test.ts +2 -2
  111. package/src/lint/rules/comp/comp.test.ts +26 -0
  112. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  113. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  114. package/src/op/activities/index.ts +2 -2
  115. package/src/op/activities/reconcile.test.ts +82 -2
  116. package/src/op/activities/reconcile.ts +169 -2
  117. package/src/op/builders.ts +3 -3
  118. package/src/op/change-signal.test.ts +117 -0
  119. package/src/op/change-signal.ts +169 -0
  120. package/src/op/composites/apply-op.ts +14 -4
  121. package/src/op/composites/composites.test.ts +17 -4
  122. package/src/op/composites/reconcile-op.ts +7 -4
  123. package/src/op/effect-step.test.ts +3 -3
  124. package/src/op/gate-name.test.ts +65 -0
  125. package/src/op/gate-name.ts +60 -0
  126. package/src/op/gate-summary.ts +84 -0
  127. package/src/op/index.ts +9 -2
  128. package/src/op/local-executor.test.ts +16 -1
  129. package/src/op/local-executor.ts +4 -3
  130. package/src/op/op-ir.test.ts +12 -1
  131. package/src/op/op-ir.ts +5 -3
  132. package/src/op/op-verb-class.test.ts +2 -2
  133. package/src/op/op.test.ts +2 -2
  134. package/src/op/operator.test.ts +368 -0
  135. package/src/op/operator.ts +141 -5
  136. package/src/op/runtimes/local.test.ts +1 -1
  137. package/src/op/types.ts +23 -3
  138. package/src/terraform/aws-resources.test.ts +13 -4
  139. package/src/terraform/bridge.test.ts +22 -9
  140. package/src/terraform/bridge.ts +89 -28
  141. package/src/terraform/carve-provider.ts +38 -2
  142. package/src/terraform/data-source-shape.ts +95 -0
  143. package/src/terraform/graph.ts +35 -7
  144. package/src/terraform/providers/kubernetes.ts +48 -2
  145. package/src/terraform/tier-map.ts +21 -6
  146. package/src/terraform/types.ts +8 -0
@@ -9,7 +9,11 @@ import {
9
9
  runOperatorRound,
10
10
  runOperatorForever,
11
11
  formatRoundLine,
12
+ formatSignalLine,
12
13
  DEFAULT_OPERATOR_INTERVAL_MS,
14
+ type ChangeSubscriber,
15
+ type OperatorSignalEvent,
16
+ type OperatorTickEvent,
13
17
  } from "./operator";
14
18
  import { readLease } from "../lifecycle/lease";
15
19
  import { appendConvergeRecord, readConvergeLedger } from "../lifecycle/converge-ledger";
@@ -513,3 +517,367 @@ describe("formatRoundLine", () => {
513
517
  .toContain("not-due:*/10 * * * *");
514
518
  });
515
519
  });
520
+
521
+ describe("runOperatorForever: a substrate change signal wakes a tick (#1981)", () => {
522
+ async function waitFor(predicate: () => boolean, maxWaitMs = 5_000): Promise<void> {
523
+ const deadline = Date.now() + maxWaitMs;
524
+ while (!predicate() && Date.now() < deadline) {
525
+ await new Promise((r) => setTimeout(r, 5));
526
+ }
527
+ }
528
+
529
+ /**
530
+ * A stub `subscribeChanges` seam. Hands the loop's `onChange`/`onError` back
531
+ * to the test so it can fabricate signals and kill the subscription, with no
532
+ * substrate anywhere near it, which is the whole point of the seam being a
533
+ * plain callback pair.
534
+ */
535
+ function stubSubscriber(lexicon = "stub") {
536
+ const handle = {
537
+ lexicon,
538
+ subscribes: 0,
539
+ closes: 0,
540
+ onChange: undefined as undefined | (() => void),
541
+ onError: undefined as undefined | ((message: string) => void),
542
+ signal: undefined as undefined | AbortSignal,
543
+ failNextSubscribe: undefined as undefined | string,
544
+ };
545
+ const subscriber: ChangeSubscriber = {
546
+ lexicon,
547
+ async subscribe(ctx) {
548
+ handle.subscribes++;
549
+ if (handle.failNextSubscribe) {
550
+ const message = handle.failNextSubscribe;
551
+ handle.failNextSubscribe = undefined;
552
+ throw new Error(message);
553
+ }
554
+ handle.onChange = ctx.onChange;
555
+ handle.onError = ctx.onError;
556
+ handle.signal = ctx.signal;
557
+ return {
558
+ async close() {
559
+ handle.closes++;
560
+ },
561
+ };
562
+ },
563
+ };
564
+ return { handle, subscriber };
565
+ }
566
+
567
+ test("a signal wakes the next round early, well inside the interval", async () => {
568
+ await withTestDir(async (dir) => {
569
+ await initRepo(dir);
570
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
571
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
572
+ const { handle, subscriber } = stubSubscriber("k8s");
573
+
574
+ const controller = new AbortController();
575
+ const rounds: number[] = [];
576
+ const signalEvents: OperatorSignalEvent[] = [];
577
+ const loop = runOperatorForever({
578
+ cwd: dir,
579
+ holder: "op-a",
580
+ intervalMs: 60_000, // a full minute: nothing but a signal can produce a second round
581
+ signalFloorMs: 0,
582
+ activities,
583
+ profiles: PROFILES,
584
+ signal: controller.signal,
585
+ subscribers: [subscriber],
586
+ onRound: () => rounds.push(Date.now()),
587
+ onSignalEvent: (e) => signalEvents.push(e),
588
+ });
589
+
590
+ await waitFor(() => handle.onChange !== undefined);
591
+ expect(rounds.length).toBe(1);
592
+ handle.onChange!();
593
+ await waitFor(() => rounds.length >= 2);
594
+ expect(rounds.length).toBeGreaterThanOrEqual(2);
595
+
596
+ controller.abort();
597
+ await loop;
598
+ expect(signalEvents.some((e) => e.kind === "subscribed" && e.lexicon === "k8s")).toBe(true);
599
+ expect(signalEvents.some((e) => e.kind === "woken")).toBe(true);
600
+ });
601
+ }, 15_000);
602
+
603
+ test("a fabricated signal cannot change what the tick reports, only when it runs", async () => {
604
+ await withTestDir(async (dir) => {
605
+ await initRepo(dir);
606
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
607
+
608
+ /** One round's events, run to completion on a timer alone. */
609
+ async function roundsWithoutSignal(): Promise<string> {
610
+ const controller = new AbortController();
611
+ const seen: OperatorTickEvent[][] = [];
612
+ const loop = runOperatorForever({
613
+ cwd: dir,
614
+ holder: "op-a",
615
+ intervalMs: 60_000,
616
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
617
+ profiles: PROFILES,
618
+ signal: controller.signal,
619
+ onRound: (events) => {
620
+ seen.push(events);
621
+ controller.abort();
622
+ },
623
+ });
624
+ await loop;
625
+ return JSON.stringify(seen[0].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
626
+ }
627
+
628
+ /** The same, woken by a signal carrying a fabricated payload. */
629
+ async function roundsWithFabricatedSignal(): Promise<string> {
630
+ const { handle, subscriber } = stubSubscriber("liar");
631
+ const controller = new AbortController();
632
+ const seen: OperatorTickEvent[][] = [];
633
+ const loop = runOperatorForever({
634
+ cwd: dir,
635
+ holder: "op-a",
636
+ intervalMs: 60_000,
637
+ signalFloorMs: 0,
638
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
639
+ profiles: PROFILES,
640
+ signal: controller.signal,
641
+ subscribers: [subscriber],
642
+ onRound: (events) => {
643
+ seen.push(events);
644
+ if (seen.length >= 2) controller.abort();
645
+ },
646
+ });
647
+ await waitFor(() => handle.onChange !== undefined);
648
+ // `onChange` takes no arguments; a caller that fabricates a resource
649
+ // has nowhere to put it, and the extra arguments go nowhere.
650
+ (handle.onChange as unknown as (...args: unknown[]) => void)(
651
+ { kind: "Deployment", name: "ghost", status: "Drifted" },
652
+ "fabricated",
653
+ );
654
+ await waitFor(() => seen.length >= 2);
655
+ controller.abort();
656
+ await loop;
657
+ return JSON.stringify(seen[1].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
658
+ }
659
+
660
+ const timerOnly = await roundsWithoutSignal();
661
+ const signalled = await roundsWithFabricatedSignal();
662
+ // Byte for byte the same verdict: the signal moved the clock, nothing else.
663
+ expect(signalled).toBe(timerOnly);
664
+ // And the seam itself carries no payload channel to begin with.
665
+ const { handle } = stubSubscriber();
666
+ expect(handle.onChange?.length ?? 0).toBe(0);
667
+ });
668
+ }, 20_000);
669
+
670
+ test("a storm of signals wakes at most one round per floor", async () => {
671
+ await withTestDir(async (dir) => {
672
+ await initRepo(dir);
673
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
674
+ const { handle, subscriber } = stubSubscriber();
675
+
676
+ const controller = new AbortController();
677
+ const rounds: number[] = [];
678
+ const loop = runOperatorForever({
679
+ cwd: dir,
680
+ holder: "op-a",
681
+ intervalMs: 60_000,
682
+ signalFloorMs: 400,
683
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
684
+ profiles: PROFILES,
685
+ signal: controller.signal,
686
+ subscribers: [subscriber],
687
+ onRound: () => rounds.push(Date.now()),
688
+ });
689
+
690
+ await waitFor(() => handle.onChange !== undefined);
691
+ const before = rounds.length;
692
+ for (let i = 0; i < 300; i++) handle.onChange!();
693
+ // Well past a floor, but nowhere near the 60s interval: at most one
694
+ // extra round may have happened, and a per-signal wake would have
695
+ // produced hundreds.
696
+ await waitFor(() => rounds.length > before, 2_000);
697
+ expect(rounds.length - before).toBeLessThanOrEqual(1);
698
+
699
+ controller.abort();
700
+ await loop;
701
+ });
702
+ }, 15_000);
703
+
704
+ test("a killed subscription is reported once, the loop keeps ticking, and the next round re-subscribes", async () => {
705
+ await withTestDir(async (dir) => {
706
+ await initRepo(dir);
707
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
708
+ const { handle, subscriber } = stubSubscriber("k8s");
709
+
710
+ const controller = new AbortController();
711
+ const rounds: number[] = [];
712
+ const signalEvents: OperatorSignalEvent[] = [];
713
+ const loop = runOperatorForever({
714
+ cwd: dir,
715
+ holder: "op-a",
716
+ intervalMs: 60,
717
+ signalFloorMs: 0,
718
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
719
+ profiles: PROFILES,
720
+ signal: controller.signal,
721
+ subscribers: [subscriber],
722
+ onRound: () => rounds.push(Date.now()),
723
+ onSignalEvent: (e) => signalEvents.push(e),
724
+ });
725
+
726
+ await waitFor(() => handle.onError !== undefined);
727
+ const subscribesBefore = handle.subscribes;
728
+ // The watch dies. Twice, because a dead subscription reports once.
729
+ handle.onError!("connection reset by peer");
730
+ handle.onError!("connection reset by peer");
731
+
732
+ await waitFor(() => handle.subscribes > subscribesBefore);
733
+ const roundsAtDeath = rounds.length;
734
+ await waitFor(() => rounds.length > roundsAtDeath + 1);
735
+
736
+ controller.abort();
737
+ await loop;
738
+
739
+ const lost = signalEvents.filter((e) => e.kind === "subscription-lost");
740
+ expect(lost).toHaveLength(1);
741
+ expect(formatSignalLine(lost[0])).toContain("polling on the timer");
742
+ // The loop kept ticking on the timer, and re-subscribed.
743
+ expect(rounds.length).toBeGreaterThan(roundsAtDeath + 1);
744
+ expect(handle.subscribes).toBeGreaterThan(subscribesBefore);
745
+ });
746
+ }, 15_000);
747
+
748
+ test("a subscription that cannot be established degrades to polling with one line, never a crash", async () => {
749
+ await withTestDir(async (dir) => {
750
+ await initRepo(dir);
751
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
752
+ const failing: ChangeSubscriber = {
753
+ lexicon: "k8s",
754
+ async subscribe() {
755
+ throw new Error("no cluster binding for env staging");
756
+ },
757
+ };
758
+
759
+ const controller = new AbortController();
760
+ const rounds: number[] = [];
761
+ const signalEvents: OperatorSignalEvent[] = [];
762
+ const loop = runOperatorForever({
763
+ cwd: dir,
764
+ holder: "op-a",
765
+ intervalMs: 40,
766
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
767
+ profiles: PROFILES,
768
+ signal: controller.signal,
769
+ subscribers: [failing],
770
+ onRound: () => rounds.push(Date.now()),
771
+ onSignalEvent: (e) => signalEvents.push(e),
772
+ });
773
+
774
+ await waitFor(() => rounds.length >= 3);
775
+ controller.abort();
776
+ await loop; // resolves: a failing subscription never rejects the loop
777
+
778
+ const failed = signalEvents.filter((e) => e.kind === "subscribe-failed");
779
+ expect(failed.length).toBeGreaterThanOrEqual(1);
780
+ expect(formatSignalLine(failed[0])).toContain("no cluster binding for env staging");
781
+ expect(rounds.length).toBeGreaterThanOrEqual(3);
782
+ });
783
+ }, 15_000);
784
+
785
+ test("stopping the operator closes every subscription and aborts its signal", async () => {
786
+ await withTestDir(async (dir) => {
787
+ await initRepo(dir);
788
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
789
+ const a = stubSubscriber("k8s");
790
+ const b = stubSubscriber("docker");
791
+
792
+ const controller = new AbortController();
793
+ const loop = runOperatorForever({
794
+ cwd: dir,
795
+ holder: "op-a",
796
+ intervalMs: 60_000,
797
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
798
+ profiles: PROFILES,
799
+ signal: controller.signal,
800
+ subscribers: [a.subscriber, b.subscriber],
801
+ });
802
+
803
+ await waitFor(() => a.handle.onChange !== undefined && b.handle.onChange !== undefined);
804
+ expect(a.handle.signal!.aborted).toBe(false);
805
+
806
+ controller.abort();
807
+ await loop;
808
+
809
+ expect(a.handle.closes).toBe(1);
810
+ expect(b.handle.closes).toBe(1);
811
+ expect(a.handle.signal!.aborted).toBe(true);
812
+ expect(b.handle.signal!.aborted).toBe(true);
813
+ });
814
+ }, 15_000);
815
+
816
+ test("a close() that throws still unwinds the loop cleanly", async () => {
817
+ await withTestDir(async (dir) => {
818
+ await initRepo(dir);
819
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
820
+ let subscribed = false;
821
+ const hostile: ChangeSubscriber = {
822
+ lexicon: "k8s",
823
+ async subscribe() {
824
+ subscribed = true;
825
+ return { close: async () => { throw new Error("close blew up"); } };
826
+ },
827
+ };
828
+
829
+ const controller = new AbortController();
830
+ const loop = runOperatorForever({
831
+ cwd: dir,
832
+ holder: "op-a",
833
+ intervalMs: 60_000,
834
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
835
+ profiles: PROFILES,
836
+ signal: controller.signal,
837
+ subscribers: [hostile],
838
+ });
839
+
840
+ await waitFor(() => subscribed);
841
+ controller.abort();
842
+ await expect(loop).resolves.toBeUndefined();
843
+ });
844
+ }, 15_000);
845
+
846
+ test("no subscribers: the loop is exactly the timer it always was", async () => {
847
+ await withTestDir(async (dir) => {
848
+ await initRepo(dir);
849
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
850
+ const controller = new AbortController();
851
+ const rounds: number[] = [];
852
+ const signalEvents: OperatorSignalEvent[] = [];
853
+ const loop = runOperatorForever({
854
+ cwd: dir,
855
+ holder: "op-a",
856
+ intervalMs: 40,
857
+ activities: fakeTickActivities(dir, "staging", "staging-converge"),
858
+ profiles: PROFILES,
859
+ signal: controller.signal,
860
+ subscribers: [], // a project whose lexicons implement no signal seam
861
+ onRound: () => rounds.push(Date.now()),
862
+ onSignalEvent: (e) => signalEvents.push(e),
863
+ });
864
+
865
+ await waitFor(() => rounds.length >= 3);
866
+ controller.abort();
867
+ await loop;
868
+ expect(rounds.length).toBeGreaterThanOrEqual(3);
869
+ expect(signalEvents).toEqual([]);
870
+ });
871
+ }, 15_000);
872
+ });
873
+
874
+ describe("formatSignalLine", () => {
875
+ test("renders each signal event kind as one line, none of them an observation", () => {
876
+ expect(formatSignalLine({ kind: "subscribed", lexicon: "k8s" })).toContain("the timer still runs");
877
+ expect(formatSignalLine({ kind: "subscribe-failed", lexicon: "k8s", error: "no binding" }))
878
+ .toContain("polling on the timer");
879
+ expect(formatSignalLine({ kind: "subscription-lost", lexicon: "k8s", error: "410 Gone" }))
880
+ .toContain("re-subscribes");
881
+ expect(formatSignalLine({ kind: "woken", afterMs: 120 })).toContain("running an ordinary round");
882
+ });
883
+ });
@@ -41,6 +41,8 @@ import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
41
41
  import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
42
  import { StaleLockError } from "../lifecycle/git";
43
43
  import { cronMatches, cronDueBetween } from "./cron";
44
+ import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS, type WakeReason } from "./change-signal";
45
+ import type { ChangeSubscription } from "../lexicon";
44
46
 
45
47
  /**
46
48
  * Poll interval between rounds — the operator's own cadence, and the fallback
@@ -211,11 +213,67 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
211
213
  });
212
214
  }
213
215
 
216
+ /**
217
+ * One lexicon's change-signal seam, already bound to an environment (#1981).
218
+ *
219
+ * The loop never sees a `LexiconPlugin`: core's operator does not import
220
+ * plugins, and binding happens where plugins are already loaded
221
+ * (`collectChangeSubscribers` in `../cli/plugins.ts`), the same extract-then-
222
+ * thread shape `collectBuildRootContributors` uses for build roots.
223
+ */
224
+ export interface ChangeSubscriber {
225
+ /** Which lexicon supplies the signal. Log lines only; never a fact. */
226
+ lexicon: string;
227
+ subscribe(ctx: {
228
+ onChange: () => void;
229
+ onError: (message: string) => void;
230
+ signal: AbortSignal;
231
+ }): Promise<ChangeSubscription>;
232
+ }
233
+
234
+ /**
235
+ * What the loop's change-signal machinery reports, alongside the per-op tick
236
+ * events. Separate from {@link OperatorTickEvent} on purpose: none of this is
237
+ * about an Op, and none of it is an observation.
238
+ */
239
+ export type OperatorSignalEvent =
240
+ /** A lexicon's subscription is live and the loop may now wake early. */
241
+ | { kind: "subscribed"; lexicon: string }
242
+ /** Establishing it failed. The loop keeps its timer and retries next round. */
243
+ | { kind: "subscribe-failed"; lexicon: string; error: string }
244
+ /** A live subscription died. Reported once; the next round re-subscribes. */
245
+ | { kind: "subscription-lost"; lexicon: string; error: string }
246
+ /** A signal shortened the sleep. The round that follows is an ordinary round. */
247
+ | { kind: "woken"; afterMs: number };
248
+
249
+ /** Render one signal event as `chant operator`'s log line shape. */
250
+ export function formatSignalLine(event: OperatorSignalEvent): string {
251
+ switch (event.kind) {
252
+ case "subscribed":
253
+ return `operator: ${event.lexicon} change signal subscribed (the timer still runs)`;
254
+ case "subscribe-failed":
255
+ return `operator: ${event.lexicon} change signal unavailable, polling on the timer (${event.error})`;
256
+ case "subscription-lost":
257
+ return `operator: ${event.lexicon} change signal lost, polling on the timer until the next round re-subscribes (${event.error})`;
258
+ case "woken":
259
+ return `operator: woken by a change signal after ${event.afterMs}ms, running an ordinary round`;
260
+ }
261
+ }
262
+
214
263
  export interface OperatorLoopOptions extends OperatorRoundOptions {
215
264
  /** @default DEFAULT_OPERATOR_INTERVAL_MS */
216
265
  intervalMs?: number;
217
266
  /** Called after every round completes — the CLI's one-line-per-tick log lives here, not inside the loop itself, so a test can drive rounds without capturing stdout. */
218
267
  onRound?: (events: OperatorTickEvent[]) => void;
268
+ /**
269
+ * Change-signal seams to keep subscribed for the life of the loop (#1981).
270
+ * Omitted, or empty, and the loop is exactly what it was: a timer.
271
+ */
272
+ subscribers?: readonly ChangeSubscriber[];
273
+ /** @default DEFAULT_SIGNAL_FLOOR_MS. The shortest gap a signal may force between two rounds. */
274
+ signalFloorMs?: number;
275
+ /** Called for every subscription and wake event. The CLI logs one line each. */
276
+ onSignalEvent?: (event: OperatorSignalEvent) => void;
219
277
  }
220
278
 
221
279
  /**
@@ -224,6 +282,12 @@ export interface OperatorLoopOptions extends OperatorRoundOptions {
224
282
  * (issue: "if it dies, nothing breaks and nothing is lost") — this function
225
283
  * is exactly `while (!aborted) { round(); sleep(); }`, nothing durable lives
226
284
  * in its own memory that a restart would need to recover.
285
+ *
286
+ * With `subscribers` (#1981) the sleep gains a second wake source, and only a
287
+ * wake source. A signal aborts the current sleep; the round that follows is
288
+ * the same round the timer would have run, deriving everything from a fresh
289
+ * observation. Nothing a subscription reports reaches a tick, because the
290
+ * seam has nowhere to put it: `onChange` takes no arguments.
227
291
  */
228
292
  export async function runOperatorForever(opts: OperatorLoopOptions): Promise<void> {
229
293
  const holder = opts.holder ?? currentHolderId();
@@ -232,11 +296,83 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
232
296
  // lands on a firing minute — a fresh operator owes no catch-up for the ticks
233
297
  // it was not running for.
234
298
  const scheduleState = opts.scheduleState ?? new Map<string, Date>();
235
- while (!opts.signal?.aborted) {
236
- const events = await runOperatorRound({ ...opts, holder, scheduleState });
237
- opts.onRound?.(events);
238
- if (opts.signal?.aborted) break;
239
- await sleepAbortable(opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS, opts.signal);
299
+ const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
300
+ const subscribers = opts.subscribers ?? [];
301
+
302
+ // Nothing to subscribe to: the loop below is byte for byte the loop that
303
+ // shipped before #1981, and takes none of the machinery's cost.
304
+ if (subscribers.length === 0) {
305
+ while (!opts.signal?.aborted) {
306
+ const events = await runOperatorRound({ ...opts, holder, scheduleState });
307
+ opts.onRound?.(events);
308
+ if (opts.signal?.aborted) break;
309
+ await sleepAbortable(intervalMs, opts.signal);
310
+ }
311
+ return;
312
+ }
313
+
314
+ const gate = createChangeSignalGate({ floorMs: opts.signalFloorMs ?? DEFAULT_SIGNAL_FLOOR_MS });
315
+ // The subscriptions the loop currently holds, by lexicon. A lexicon absent
316
+ // from this map is one to (re-)subscribe on the next round, which is how a
317
+ // killed watch comes back without a special retry path.
318
+ const live = new Map<string, ChangeSubscription>();
319
+ // Stops every subscription at once when the loop ends, whatever ends it.
320
+ const subscriptionsAbort = new AbortController();
321
+
322
+ const closeAll = async (): Promise<void> => {
323
+ subscriptionsAbort.abort();
324
+ const open = [...live.values()];
325
+ live.clear();
326
+ // A close that throws must not mask why the loop is unwinding.
327
+ await Promise.allSettled(open.map((s) => s.close()));
328
+ };
329
+
330
+ const ensureSubscribed = async (): Promise<void> => {
331
+ for (const subscriber of subscribers) {
332
+ if (live.has(subscriber.lexicon) || subscriptionsAbort.signal.aborted) continue;
333
+ try {
334
+ const subscription = await subscriber.subscribe({
335
+ onChange: () => gate.signal(),
336
+ onError: (error) => {
337
+ // Reported once, then forgotten: dropping it from `live` is what
338
+ // makes the next round re-subscribe. The loop itself is untouched.
339
+ if (!live.delete(subscriber.lexicon)) return;
340
+ opts.onSignalEvent?.({ kind: "subscription-lost", lexicon: subscriber.lexicon, error });
341
+ },
342
+ signal: subscriptionsAbort.signal,
343
+ });
344
+ live.set(subscriber.lexicon, subscription);
345
+ opts.onSignalEvent?.({ kind: "subscribed", lexicon: subscriber.lexicon });
346
+ } catch (err) {
347
+ // A subscription that cannot be established is a slower loop, never a
348
+ // stopped one: report it and fall through to the timer.
349
+ opts.onSignalEvent?.({
350
+ kind: "subscribe-failed",
351
+ lexicon: subscriber.lexicon,
352
+ error: err instanceof Error ? err.message : String(err),
353
+ });
354
+ }
355
+ }
356
+ };
357
+
358
+ try {
359
+ while (!opts.signal?.aborted) {
360
+ gate.roundStarted();
361
+ const startedAt = Date.now();
362
+ const events = await runOperatorRound({ ...opts, holder, scheduleState });
363
+ opts.onRound?.(events);
364
+ if (opts.signal?.aborted) break;
365
+
366
+ await ensureSubscribed();
367
+ if (opts.signal?.aborted) break;
368
+
369
+ const reason: WakeReason = await gate.wait(intervalMs, opts.signal);
370
+ if (reason === "signal") {
371
+ opts.onSignalEvent?.({ kind: "woken", afterMs: Date.now() - startedAt });
372
+ }
373
+ }
374
+ } finally {
375
+ await closeAll();
240
376
  }
241
377
  }
242
378
 
@@ -123,7 +123,7 @@ describe("the local op runtime", () => {
123
123
 
124
124
  const handle = await runtime.start(
125
125
  op("gated", [
126
- { kind: "gate", signalName: "approve-prod" },
126
+ { kind: "gate", gate: "approve-prod" },
127
127
  { kind: "activity", fn: "after", args: {} },
128
128
  ]),
129
129
  {},
package/src/op/types.ts CHANGED
@@ -170,13 +170,33 @@ export interface EffectStep {
170
170
  description?: string;
171
171
  }
172
172
 
173
- export interface GateStep {
173
+ /** Everything on a gate step except the key that names it. */
174
+ export interface GateStepBase {
174
175
  kind: "gate";
175
- /** The gate's name — what `chant approve <op> <gate>` resolves. */
176
- signalName: string;
177
176
  /** How long a recorded pending gate stays valid, as a duration string. Default: "48h". */
178
177
  timeout?: string;
179
178
  /** Human-readable description of the action required to unblock this gate. */
180
179
  description?: string;
181
180
  }
182
181
 
182
+ /**
183
+ * A human approval decided against the gate ledger. The name lives on `gate`;
184
+ * `signalName` is the key it carried through 0.58.0 and is still accepted
185
+ * (#2202) — read both through `gateName()` in `./gate-name.ts` rather than
186
+ * reaching for either key directly.
187
+ */
188
+ export type GateStep = GateStepBase &
189
+ (
190
+ | {
191
+ /** The gate's name — what `chant approve <op> <gate>` resolves. */
192
+ gate: string;
193
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
194
+ signalName?: string;
195
+ }
196
+ | {
197
+ gate?: undefined;
198
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
199
+ signalName: string;
200
+ }
201
+ );
202
+
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
3
- import { tierMap, foldParentOf, identityAttrOf, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
3
+ import { tierMap, foldParentOf, identityAttrOf, canBridge, dataSourceShapeOf, canCarveEmit, carveEmitTypes } from "./tier-map";
4
4
  import { canAdoptFromState } from "./adopt-state";
5
5
 
6
6
  describe("AWS carve-out table", () => {
@@ -27,13 +27,22 @@ describe("AWS carve-out table", () => {
27
27
  for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
28
28
  });
29
29
 
30
- test("canBridge rejects a dotted identity attribute (#2015)", () => {
31
- // A data source body is flat `attr = value`; a dotted path is not HCL.
30
+ test("canBridge follows the data-source shape, not the identity attribute (#2015, #2034)", () => {
31
+ // A data source body is flat `attr = value`; a dotted path is not HCL. So a
32
+ // dotted identity attribute bridges only through a declared shape, which is
33
+ // what `kubernetes_manifest` has: `data "kubernetes_resource"`.
32
34
  expect(identityAttrOf("kubernetes_manifest")).toContain(".");
33
- expect(canBridge("kubernetes_manifest")).toBe(false);
35
+ expect(canBridge("kubernetes_manifest")).toBe(true);
36
+ expect(dataSourceShapeOf("kubernetes_manifest")!.type).toBe("kubernetes_resource");
37
+ // A plain identity attribute implies its own same-type shape.
34
38
  expect(canBridge("aws_s3_bucket")).toBe(true);
39
+ expect(dataSourceShapeOf("aws_s3_bucket")).toEqual({
40
+ type: "aws_s3_bucket",
41
+ args: [{ name: "bucket", from: "bucket", required: true }],
42
+ });
35
43
  // No identity entry at all is fine — the bridge writes a TODO comment.
36
44
  expect(canBridge("random_pet")).toBe(true);
45
+ expect(dataSourceShapeOf("random_pet")).toEqual({ type: "random_pet" });
37
46
  for (const t of AWS_CARVE_TYPES) expect(canBridge(t.tfType)).toBe(true);
38
47
  });
39
48