@intentius/chant 0.118.0 → 0.120.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 (89) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/emulator.d.ts +24 -0
  3. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +15 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/plugins.d.ts +9 -1
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +6 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/components/driver-output.d.ts.map +1 -1
  14. package/dist/deep-observation.d.ts +22 -1
  15. package/dist/deep-observation.d.ts.map +1 -1
  16. package/dist/detectLexicon.d.ts +2 -1
  17. package/dist/detectLexicon.d.ts.map +1 -1
  18. package/dist/lifecycle/deep-diff.d.ts +9 -1
  19. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  20. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  21. package/dist/lifecycle/git.d.ts +12 -0
  22. package/dist/lifecycle/git.d.ts.map +1 -1
  23. package/dist/lifecycle/local-note.d.ts +14 -0
  24. package/dist/lifecycle/local-note.d.ts.map +1 -0
  25. package/dist/op/activities/converge.d.ts.map +1 -1
  26. package/dist/op/activities/lifecycle.d.ts +6 -1
  27. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  28. package/dist/op/composites/watch-op.d.ts +8 -0
  29. package/dist/op/composites/watch-op.d.ts.map +1 -1
  30. package/dist/op/emulator-lifecycle.d.ts +31 -0
  31. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts.map +1 -1
  33. package/dist/op/gate.d.ts +4 -1
  34. package/dist/op/gate.d.ts.map +1 -1
  35. package/dist/op/index.d.ts +3 -3
  36. package/dist/op/index.d.ts.map +1 -1
  37. package/dist/op/local-output.d.ts.map +1 -1
  38. package/dist/op/op-ir.d.ts +2 -0
  39. package/dist/op/op-ir.d.ts.map +1 -1
  40. package/dist/op/types.d.ts +10 -0
  41. package/dist/op/types.d.ts.map +1 -1
  42. package/dist/serializer.d.ts +11 -0
  43. package/dist/serializer.d.ts.map +1 -1
  44. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/cli/commands/build.test.ts +29 -0
  47. package/src/cli/commands/build.ts +11 -2
  48. package/src/cli/handlers/emulator.test.ts +136 -4
  49. package/src/cli/handlers/emulator.ts +171 -14
  50. package/src/cli/handlers/lifecycle.ts +13 -2
  51. package/src/cli/handlers/operator.test.ts +9 -7
  52. package/src/cli/handlers/operator.ts +18 -23
  53. package/src/cli/handlers/run.test.ts +52 -0
  54. package/src/cli/handlers/run.ts +43 -3
  55. package/src/cli/lexicon-subpath-build.test.ts +129 -0
  56. package/src/cli/main.ts +24 -6
  57. package/src/cli/plugins.ts +26 -5
  58. package/src/cli/registry.ts +6 -0
  59. package/src/components/driver-output.ts +4 -1
  60. package/src/deep-observation.ts +29 -1
  61. package/src/detectLexicon.test.ts +38 -0
  62. package/src/detectLexicon.ts +7 -2
  63. package/src/lifecycle/deep-diff.test.ts +24 -1
  64. package/src/lifecycle/deep-diff.ts +15 -0
  65. package/src/lifecycle/deep-observe.ts +5 -2
  66. package/src/lifecycle/git.test.ts +18 -0
  67. package/src/lifecycle/git.ts +25 -0
  68. package/src/lifecycle/local-note.ts +17 -0
  69. package/src/op/activities/converge-push.test.ts +8 -6
  70. package/src/op/activities/converge.ts +2 -7
  71. package/src/op/activities/lifecycle-gate.test.ts +147 -0
  72. package/src/op/activities/lifecycle.test.ts +5 -0
  73. package/src/op/activities/lifecycle.ts +18 -6
  74. package/src/op/composites/composites.test.ts +5 -0
  75. package/src/op/composites/watch-op.ts +9 -0
  76. package/src/op/emulator-lifecycle.test.ts +13 -1
  77. package/src/op/emulator-lifecycle.ts +48 -0
  78. package/src/op/gate-summary.test.ts +6 -0
  79. package/src/op/gate-summary.ts +4 -1
  80. package/src/op/gate.ts +7 -25
  81. package/src/op/index.ts +3 -3
  82. package/src/op/local-output.test.ts +12 -0
  83. package/src/op/local-output.ts +5 -1
  84. package/src/op/op-ir.test.ts +9 -0
  85. package/src/op/op-ir.ts +4 -0
  86. package/src/op/types.ts +11 -0
  87. package/src/serializer.ts +11 -0
  88. package/src/workspace/ci-green-workflow.ts +1 -1
  89. package/src/workspace/lineage-upgrade-cli.ts +3 -1
@@ -20,11 +20,13 @@ vi.mock("node:child_process", () => ({
20
20
  cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
21
21
  }));
22
22
 
23
- const pushLifecycle = vi.fn();
23
+ // The outcomes `pushLifecycleReport` maps git's push status to (#3677).
24
+ const pushLifecycleReport = vi.fn();
24
25
  vi.mock("../../lifecycle/git", () => ({
25
26
  fetchLifecycle: vi.fn(async () => undefined),
26
- pushLifecycle: (...args: unknown[]) => pushLifecycle(...args) as Promise<boolean>,
27
+ pushLifecycleReport: (...args: unknown[]) => pushLifecycleReport(...args),
27
28
  }));
29
+ const { LIFECYCLE_LOCAL_NOTE } = await import("../../lifecycle/local-note");
28
30
 
29
31
  vi.mock("../../lifecycle/converge-ledger", async (orig) => {
30
32
  const actual = (await orig()) as Record<string, unknown>;
@@ -55,7 +57,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
55
57
  });
56
58
 
57
59
  test("a push that lands reports pushed, with no warning", async () => {
58
- pushLifecycle.mockResolvedValue(true);
60
+ pushLifecycleReport.mockResolvedValue({ pushed: true });
59
61
 
60
62
  const result = await tick();
61
63
 
@@ -66,7 +68,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
66
68
  test("a REJECTED push is reported, not swallowed", async () => {
67
69
  // Red before chant#2337: `.catch(() => undefined)` meant the tick returned
68
70
  // the same shape whether or not the record left the machine.
69
- pushLifecycle.mockRejectedValue(new Error("remote rejected: chant/lifecycle has moved"));
71
+ pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: "remote rejected: chant/lifecycle has moved" });
70
72
 
71
73
  const result = await tick();
72
74
 
@@ -78,7 +80,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
78
80
  // `pushLifecycle` returns false rather than throwing when there is no
79
81
  // remote. That is not an error, but it is not a push either, and the two
80
82
  // reasons are worth telling apart in the warning.
81
- pushLifecycle.mockResolvedValue(false);
83
+ pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE });
82
84
 
83
85
  const result = await tick();
84
86
 
@@ -90,7 +92,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
90
92
  // The append is local-first and always lands. A push failure must not make
91
93
  // the tick misreport what it observed, or the fix would have traded one
92
94
  // wrong answer for another.
93
- pushLifecycle.mockRejectedValue(new Error("nope"));
95
+ pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: "nope" });
94
96
 
95
97
  const result = await tick();
96
98
 
@@ -58,7 +58,7 @@ import {
58
58
  type ConvergeTickRecord,
59
59
  } from "../../lifecycle/converge-ledger";
60
60
  import { resolveApprovalUrl } from "../../lifecycle/gate-ledger";
61
- import { fetchLifecycle, pushLifecycle } from "../../lifecycle/git";
61
+ import { fetchLifecycle, pushLifecycleReport } from "../../lifecycle/git";
62
62
  import type { ChangeSet } from "../../lifecycle/change-set";
63
63
  import type { ComponentStatusRow } from "../../lifecycle/status";
64
64
 
@@ -535,12 +535,7 @@ async function dispatchPlanned(
535
535
 
536
536
  /** Push the tick's ledger record, and say whether it reached the remote (chant#2337). */
537
537
  async function pushTick(): Promise<{ pushed: boolean; pushWarning?: string }> {
538
- try {
539
- const pushed = await pushLifecycle();
540
- return pushed ? { pushed } : { pushed, pushWarning: "no remote is configured for chant/lifecycle — the tick record was recorded locally only" };
541
- } catch (err) {
542
- return { pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
543
- }
538
+ return pushLifecycleReport();
544
539
  }
545
540
 
546
541
  /** A tick of a ConvergeOp with an observer step (#2778): the observation is already in `args.observed`. */
@@ -0,0 +1,147 @@
1
+ /**
2
+ * #3712: a `chant lifecycle diff` that fails yields no digest, so an approval
3
+ * cannot pass a gate for a plan nobody read.
4
+ *
5
+ * Before the fix the activity caught the diff's non-zero exit and digested
6
+ * its error output. Every run whose build failed printed the same lines, so
7
+ * an approval of one such run's digest matched the next, whatever the
8
+ * declarations had become, and the Apply phase (which builds on its own)
9
+ * applied the change.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, test, vi } from "vitest";
13
+
14
+ /** What the next `chant lifecycle diff` prints and how it exits. */
15
+ const next = { stdout: "", stderr: "", code: 0 };
16
+
17
+ vi.mock(import("node:child_process"), async (importOriginal) => ({
18
+ ...(await importOriginal()),
19
+ exec: ((_command: string, _options: unknown, callback: (err: unknown, out?: { stdout: string; stderr: string }) => void) => {
20
+ if (next.code === 0) callback(null, { stdout: next.stdout, stderr: next.stderr });
21
+ else callback(Object.assign(new Error("exit"), { code: next.code, stdout: next.stdout, stderr: next.stderr }));
22
+ }) as never,
23
+ }));
24
+
25
+ const { lifecycleDiff, lifecycleDiffDigest } = await import("./lifecycle");
26
+ const { OpRunFailure, runOpLocally } = await import("../local-executor");
27
+ const { stepOutput } = await import("../step-output-ref");
28
+ type ActivityFn = import("../activity-registry").ActivityFn;
29
+ type ActivityProfile = import("../activity-registry").ActivityProfile;
30
+ type GateLedgerPort = import("../gate").GateLedgerPort;
31
+ type GateResolutionRecord = import("../../lifecycle/gate-ledger").GateResolutionRecord;
32
+ type PendingGateRecord = import("../../lifecycle/gate-ledger").PendingGateRecord;
33
+ type OpConfig = import("../types").OpConfig;
34
+
35
+ const PROFILES: Record<string, ActivityProfile> = {
36
+ fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
37
+ };
38
+
39
+ const BUILD_FAILED = "error: Build failed for project — fix errors before diffing";
40
+ const ARGS = { env: "dev", live: true };
41
+
42
+ function failDiff(): void {
43
+ Object.assign(next, { stdout: "", stderr: BUILD_FAILED, code: 1 });
44
+ }
45
+
46
+ function diffPrints(render: string): void {
47
+ Object.assign(next, { stdout: render, stderr: "", code: 0 });
48
+ }
49
+
50
+ beforeEach(() => {
51
+ diffPrints("");
52
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
53
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
54
+ });
55
+
56
+ describe("lifecycleDiff on a failed diff (#3712)", () => {
57
+ test("a build error rejects, naming the command and the error, with no digest", async () => {
58
+ failDiff();
59
+ await expect(lifecycleDiff(ARGS)).rejects.toThrow(/chant lifecycle diff dev --live failed \(exit 1\); no plan to digest:\n.*Build failed for project/);
60
+ });
61
+
62
+ test("a diff that succeeds still returns its digest", async () => {
63
+ diffPrints("MISSING (declared, provider reports not in cloud):\n - events");
64
+ const result = await lifecycleDiff(ARGS);
65
+ expect(result.exitCode).toBe(0);
66
+ expect(result.drifted).toBe(true);
67
+ expect(result.planDigest).toBe(lifecycleDiffDigest(ARGS, "MISSING (declared, provider reports not in cloud):\n - events"));
68
+ });
69
+ });
70
+
71
+ describe("ApplyOp's gate over a diff whose build fails (#3712)", () => {
72
+ function ledger(): GateLedgerPort & { approve(planDigest: string): void } {
73
+ const resolutions: GateResolutionRecord[] = [];
74
+ const pending: PendingGateRecord[] = [];
75
+ return {
76
+ approve(planDigest) {
77
+ resolutions.push({ version: 1, op: "apply-dev", gate: "approve-apply-dev", resolvedBy: "alex", timestamp: "2026-10-10T12:05:00.000Z", planDigest });
78
+ },
79
+ async read() {
80
+ return { resolutions: [...resolutions], pending: [...pending] };
81
+ },
82
+ async appendPending(input) {
83
+ const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
84
+ pending.push(record);
85
+ return { record, pushed: true };
86
+ },
87
+ };
88
+ }
89
+
90
+ const statuses = (failure: unknown) =>
91
+ (failure as InstanceType<typeof OpRunFailure>).result.records.map((r) => [r.fn, r.status]);
92
+
93
+ /** ApplyOp's Plan, Approve and Apply phases, with the real `lifecycleDiff` and an apply that records what it applied. */
94
+ function harness() {
95
+ const declared = { schema: "A" };
96
+ const applied: string[] = [];
97
+ const activities = new Map<string, ActivityFn>([
98
+ ["lifecycleDiff", (args, signal) => lifecycleDiff(args as { env: string; live?: boolean }, signal)],
99
+ ["nativeApply", async () => {
100
+ applied.push(declared.schema);
101
+ }],
102
+ ]);
103
+ const config: OpConfig = {
104
+ name: "apply-dev",
105
+ overview: "",
106
+ phases: [
107
+ { name: "Plan", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "plan", args: ARGS }] },
108
+ { name: "Approve", steps: [{ kind: "gate", gate: "approve-apply-dev", plan: stepOutput("plan", "planDigest") }] },
109
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply" }] },
110
+ ],
111
+ };
112
+ return { declared, applied, activities, config };
113
+ }
114
+
115
+ test("an approval of plan A does not pass for plan B when the diff's build fails both times", async () => {
116
+ const { declared, applied, activities, config } = harness();
117
+ const gates = ledger();
118
+
119
+ // Plan A: the diff's build fails. The run stops at Plan: nothing reaches the gate.
120
+ failDiff();
121
+ const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" }).catch((e: unknown) => e);
122
+ expect(first).toBeInstanceOf(OpRunFailure);
123
+ expect(statuses(first)).toEqual([["lifecycleDiff", "fail"]]);
124
+ expect(applied).toEqual([]);
125
+
126
+ // The digest the diff's error output used to give: someone approves it.
127
+ gates.approve(lifecycleDiffDigest(ARGS, BUILD_FAILED));
128
+
129
+ // Plan B: the declarations change, and the diff's build still fails.
130
+ declared.schema = "B";
131
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:10:00.000Z" }).catch((e: unknown) => e);
132
+ expect(second).toBeInstanceOf(OpRunFailure);
133
+ expect(statuses(second)).toEqual([["lifecycleDiff", "fail"]]);
134
+ expect(applied).toEqual([]);
135
+ });
136
+
137
+ test("with the build fixed, the run stops at the gate for the plan it read", async () => {
138
+ const { applied, activities, config } = harness();
139
+ const gates = ledger();
140
+ const render = "MISSING (declared, provider reports not in cloud):\n - events";
141
+ diffPrints(render);
142
+ const run = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" });
143
+ expect(run.status).toBe("gated");
144
+ expect(run.gate?.planDigest).toBe(lifecycleDiffDigest(ARGS, render));
145
+ expect(applied).toEqual([]);
146
+ });
147
+ });
@@ -28,6 +28,11 @@ describe("detectDrift", () => {
28
28
  expect(detectDrift(MISSING)).toBe(true);
29
29
  });
30
30
 
31
+ test("a privilege an apply would revoke is drift (PENDING, #3706)", () => {
32
+ const pending = `${HEAD}0 missing, 0 orphan, 0 disappeared, 0 newly observed, 0 drifted, 3 unchanged\n${"-".repeat(80)}\n\n${BOLD}sql (properties)${RESET}\n0 property drift across 0 resource(s), 0 accepted, 2 unchanged, 1 pending\n${"-".repeat(80)}\n${BOLD}\nPENDING (changes an apply would make that no declared property shows):${RESET}\n ~ relation app.orders TO writer: REVOKE INSERT ON TABLE app.orders FROM writer`;
33
+ expect(detectDrift(pending)).toBe(true);
34
+ });
35
+
31
36
  test("the counts line alone, lower case, is not drift", () => {
32
37
  expect(detectDrift("1 property drift across 1 resource(s)")).toBe(false);
33
38
  });
@@ -32,7 +32,7 @@ export interface LifecycleDiffArgs {
32
32
  export interface LifecycleDiffResult {
33
33
  /** Combined stdout + stderr from the chant command. */
34
34
  output: string;
35
- /** Process exit code (0 = success). */
35
+ /** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
36
36
  exitCode: number;
37
37
  /**
38
38
  * True when the diff output contains any drift indicators
@@ -88,6 +88,9 @@ const DRIFT_HEADERS = [
88
88
  // An owned resource whose live properties differ from its declaration (the
89
89
  // deep read): a sql table's TTL changed out of band, say (#3642).
90
90
  "PROPERTY DRIFT",
91
+ // A change an apply would make that no declared property shows: a
92
+ // privilege granted by hand on a Postgres table (#3706).
93
+ "PENDING",
91
94
  "ARTIFACTS ADDED",
92
95
  "ARTIFACTS REMOVED",
93
96
  "ARTIFACTS CHANGED",
@@ -121,18 +124,27 @@ export function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): st
121
124
  * MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
122
125
  * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
123
126
  * on a WatchOp activity step to surface drift as the run's `Drift` outcome.
127
+ *
128
+ * A diff that exits non-zero (#3712) fails the step and yields no digest.
129
+ * Its output is an error, not a change set: every failed run printed the
130
+ * same lines, so a digest over them was one an approval matched whatever
131
+ * the declarations had become since.
124
132
  */
125
133
  export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult> {
126
134
  const liveFlag = args.live ? " --live" : "";
135
+ const command = `chant lifecycle diff ${args.env}${liveFlag}`;
136
+ let stdout: string;
137
+ let stderr: string;
127
138
  try {
128
- const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
129
- const output = `${stdout}${stderr}`.trim();
130
- if (output) console.log(output);
131
- return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
139
+ ({ stdout, stderr } = await execAsync(command, { signal }));
132
140
  } catch (err) {
133
141
  const e = err as { code?: number; stdout?: string; stderr?: string };
134
142
  const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
135
143
  if (output) console.error(output);
136
- return { output, exitCode: e.code ?? 1, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
144
+ const message = `${command} failed (exit ${String(e.code ?? 1)}); no plan to digest${output ? `:\n${output}` : ""}`;
145
+ throw new Error(message);
137
146
  }
147
+ const output = `${stdout}${stderr}`.trim();
148
+ if (output) console.log(output);
149
+ return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
138
150
  }
@@ -25,6 +25,11 @@ function getEntityType(entity: unknown): string {
25
25
  // ── WatchOp ──────────────────────────────────────────────────────────
26
26
 
27
27
  describe("WatchOp: shape", () => {
28
+ test("failOn: \"drift\" rides on the op; omitted, the op has none (#3675)", () => {
29
+ expect(getProps(WatchOp({ name: "w", env: "prod", failOn: "drift" }).op).failOn).toBe("drift");
30
+ expect(getProps(WatchOp({ name: "w", env: "prod" }).op)).not.toHaveProperty("failOn");
31
+ });
32
+
28
33
  test("returns { op } — the cadence is on the op, not a second resource (#2120)", () => {
29
34
  const result = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
30
35
  expect(result.op).toBeDefined();
@@ -55,6 +55,14 @@ export interface WatchOpConfig {
55
55
  * a receipt — the `effect()` step is the sole writer.
56
56
  */
57
57
  receipts?: EffectReceiptDeclaration[];
58
+ /**
59
+ * `"drift"` makes `chant run <name>` exit 2 when the watch finds drift
60
+ * (#3675), so a scheduled drift job fails without a wrapper that reads the
61
+ * run ledger. Omitted, a run that finds drift still exits 0 and records
62
+ * `Drift=true`; `chant run <name> --fail-on-drift` asks for the same exit
63
+ * code on one invocation.
64
+ */
65
+ failOn?: "drift";
58
66
  }
59
67
 
60
68
  export interface WatchOpResources {
@@ -73,6 +81,7 @@ export function WatchOp(config: WatchOpConfig): WatchOpResources {
73
81
  Env: config.env,
74
82
  },
75
83
  ...(config.schedule ? { schedule: { cron: config.schedule, overlap: "skip" as const } } : {}),
84
+ ...(config.failOn ? { failOn: config.failOn } : {}),
76
85
  phases: [
77
86
  phase("Snapshot", [activity("lifecycleSnapshot", { env: config.env })]),
78
87
  phase("Diff", [
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { emulatorLifecycle } from "./emulator-lifecycle";
2
+ import { createServer } from "node:net";
3
+ import { emulatorLifecycle, hostPortInUse } from "./emulator-lifecycle";
3
4
 
4
5
  describe("emulatorLifecycle command builders", () => {
5
6
  const emu = emulatorLifecycle({
@@ -62,3 +63,14 @@ describe("an emulator reached by its own client, not over HTTP", () => {
62
63
  expect(() => emulatorLifecycle({ name: "x", image: "x:1", containerPort: 1 })).toThrow(/neither a healthPath nor a readyCommand/);
63
64
  });
64
65
  });
66
+
67
+ describe("hostPortInUse (#3673)", () => {
68
+ test("is true while a process listens on 127.0.0.1:<port>, false once it stops", async () => {
69
+ const server = createServer();
70
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
71
+ const { port } = server.address() as { port: number };
72
+ expect(await hostPortInUse(port)).toBe(true);
73
+ await new Promise<void>((r) => server.close(() => r()));
74
+ expect(await hostPortInUse(port)).toBe(false);
75
+ });
76
+ });
@@ -1,4 +1,5 @@
1
1
  import { exec } from "node:child_process";
2
+ import { connect } from "node:net";
2
3
  import { promisify } from "node:util";
3
4
  import { sleep } from "./activity-runtime";
4
5
 
@@ -53,6 +54,47 @@ export interface EmulatorSpec {
53
54
  /** `owner/repo` whose latest GitHub release names the current version. */
54
55
  repo: string;
55
56
  };
57
+ /**
58
+ * How to tell this emulator's server from another one answering on the
59
+ * same host port (#3673).
60
+ *
61
+ * On macOS, Docker publishes `0.0.0.0:<port>` even while a native server
62
+ * holds `127.0.0.1:<port>`, and `localhost` then reaches the native one.
63
+ * `chant emulator status` runs {@link EmulatorIdentity.command} in the
64
+ * container and {@link EmulatorIdentity.probe} through the printed endpoint,
65
+ * and reports a mismatch instead of an endpoint that reaches the wrong server.
66
+ */
67
+ identity?: EmulatorIdentity;
68
+ /** How a client signs in, for `chant emulator --help` (e.g. `user postgres, password chant`). */
69
+ credentials?: string;
70
+ }
71
+
72
+ /** A server identity check: one value read inside the container and through the endpoint (#3673). */
73
+ export interface EmulatorIdentity {
74
+ /** What kind of server it is, for messages: `Postgres`, `ClickHouse`. */
75
+ server: string;
76
+ /** Run as `docker exec <name> <command...>`; its trimmed stdout is the server's identity. */
77
+ command: readonly string[];
78
+ /** The identity of the server answering at `endpoint`, and a label for it (e.g. `PostgreSQL 16.14`). */
79
+ probe(endpoint: string): Promise<{ id: string; label: string }>;
80
+ }
81
+
82
+ /**
83
+ * True when something accepts TCP connections on 127.0.0.1:`port` (#3673).
84
+ * `up` asks before `docker run`: a native server there would take every
85
+ * `localhost` connection meant for the emulator.
86
+ */
87
+ export function hostPortInUse(port: number, timeoutMs = 1_000): Promise<boolean> {
88
+ return new Promise((resolve) => {
89
+ const socket = connect({ host: "127.0.0.1", port });
90
+ const done = (inUse: boolean): void => {
91
+ socket.destroy();
92
+ resolve(inUse);
93
+ };
94
+ socket.setTimeout(timeoutMs, () => done(false));
95
+ socket.once("connect", () => done(true));
96
+ socket.once("error", () => done(false));
97
+ });
56
98
  }
57
99
 
58
100
  /**
@@ -194,6 +236,12 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
194
236
  // clean JSON on stdout. runOp/Op activities capture both streams regardless.
195
237
  console.error(`emulator container "${name}" already running — reusing`);
196
238
  } else {
239
+ if (await hostPortInUse(port)) {
240
+ throw new Error(
241
+ `emulator "${name}": 127.0.0.1:${port} is already held by another process, so localhost:${port} would not reach the emulator. ` +
242
+ `Stop that process, or publish the emulator on another port (chant emulator up --port ${spec.containerPort}=<free port>).`,
243
+ );
244
+ }
197
245
  await execAsync(runCommand({ ...args, name, port }), { signal });
198
246
  }
199
247
 
@@ -44,6 +44,12 @@ describe("gatedRunSummaryMarkdown (#2243)", () => {
44
44
  expect(md).toContain("cannot see it to approve it");
45
45
  });
46
46
 
47
+ test("says it was recorded locally, without the warning, when chant/lifecycle has no remote (#3677)", () => {
48
+ const md = gatedRunSummaryMarkdown({ ...summary, pushed: false, pushWarning: "recorded locally (no remote for chant/lifecycle)" });
49
+ expect(md).toContain("The pending fact was recorded locally (no remote for chant/lifecycle).");
50
+ expect(md).not.toContain("was not pushed");
51
+ });
52
+
47
53
  test("says nothing extra when the push landed or nothing was pushed this run", () => {
48
54
  const md = gatedRunSummaryMarkdown(summary);
49
55
  expect(md).not.toContain("was not pushed");
@@ -22,6 +22,7 @@ import { appendFileSync } from "node:fs";
22
22
  import { gateLedgerPath } from "../lifecycle/gate-ledger";
23
23
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
24
24
  import { approveCommand } from "./gate";
25
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
25
26
 
26
27
  /** What a gated run knows about the gate it stopped on. */
27
28
  export interface GatedRunSummary {
@@ -110,7 +111,9 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
110
111
  );
111
112
  if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
112
113
  if (summary.url) lines.push(`Approve at: ${summary.url}`);
113
- if (summary.pushed === false) {
114
+ if (summary.pushed === false && isLifecycleLocalNote(summary.pushWarning)) {
115
+ lines.push("", `The pending fact was ${LIFECYCLE_LOCAL_NOTE}.`);
116
+ } else if (summary.pushed === false) {
114
117
  lines.push(
115
118
  "",
116
119
  `**This pending fact was not pushed to the remote** (${summary.pushWarning ?? "no reason given"}). ` +
package/src/op/gate.ts CHANGED
@@ -50,7 +50,7 @@ import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
50
50
  import { isModelAuthored } from "../lifecycle/gate-origin";
51
51
  import { sortedJsonReplacer } from "../utils";
52
52
  import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
53
- import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
53
+ import { pushLifecycleReport, requireLifecycleLedger } from "../lifecycle/git";
54
54
  import { parseDuration } from "./duration";
55
55
  import { resolveGateRunLocator } from "./gate-resume";
56
56
 
@@ -69,7 +69,10 @@ export interface PendingGatePush {
69
69
  * configured, or the push was rejected — `pushWarning` says which.
70
70
  */
71
71
  pushed: boolean;
72
- /** Set when `pushed` is false. */
72
+ /**
73
+ * Set when `pushed` is false. With no remote it is the lifecycle module's
74
+ * `LIFECYCLE_LOCAL_NOTE`, which renderers show as a note, not a warning (#3677).
75
+ */
73
76
  pushWarning?: string;
74
77
  }
75
78
 
@@ -131,32 +134,11 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
131
134
  },
132
135
  async appendResolution(input) {
133
136
  const { record } = await appendGateResolution(input, opts);
134
- try {
135
- const pushed = await pushLifecycle(opts);
136
- return pushed ? { record, pushed } : { record, pushed, pushWarning: "no remote is configured for chant/lifecycle — the resolution was recorded locally only" };
137
- } catch (err) {
138
- return { record, pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
139
- }
137
+ return { record, ...(await pushLifecycleReport(opts)) };
140
138
  },
141
139
  async appendPending(input) {
142
140
  const { record } = await appendPendingGate(input, opts);
143
- try {
144
- const pushed = await pushLifecycle(opts);
145
- return pushed
146
- ? { record, pushed }
147
- : {
148
- record,
149
- pushed,
150
- pushWarning:
151
- "no remote is configured for chant/lifecycle — the pending fact was recorded locally only",
152
- };
153
- } catch (err) {
154
- return {
155
- record,
156
- pushed: false,
157
- pushWarning: err instanceof Error ? err.message : String(err),
158
- };
159
- }
141
+ return { record, ...(await pushLifecycleReport(opts)) };
160
142
  },
161
143
  };
162
144
  }
package/src/op/index.ts CHANGED
@@ -11,12 +11,12 @@ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, he
11
11
  spritesUp, spritesDown } from "./builders";
12
12
  export { OpResource } from "./resource";
13
13
  export { sleep } from "./activity-runtime";
14
- export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-lifecycle";
15
- export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
14
+ export { emulatorLifecycle, emulatorsOf, endpointEnvVars, hostPortInUse } from "./emulator-lifecycle";
15
+ export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorIdentity, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
16
16
  export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
17
17
  export type { FreshnessResult } from "./emulator-freshness";
18
18
  export type { OpConfig, OpSchedule, OpWorkLease, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
19
- export { outcomeAttributesOf, WORK_LEASE_STEP_ID } from "./types";
19
+ export { outcomeAttributesOf, WORK_LEASE_STEP_ID, DRIFT_EXIT_CODE } from "./types";
20
20
  export {
21
21
  RunWorkLease, workLeaseOutput, stewardWorkHolder, workLeaseProblems, workLeaseNeedsRunItem, LEASE_LOST, WORK_BRANCH_PREFIX,
22
22
  } from "./work-lease-run";
@@ -3,6 +3,7 @@ import type { OpRunResult } from "./local-executor";
3
3
  import { buildRunRecord } from "../lifecycle/run-ledger";
4
4
  import type { OpRunRecord } from "./runtime";
5
5
  import { renderHuman, renderJson } from "./local-output";
6
+ import { LIFECYCLE_LOCAL_NOTE } from "../lifecycle/local-note";
6
7
 
7
8
  /**
8
9
  * Attach the ledger record the executor would have built (#2118), so a fixture
@@ -145,6 +146,17 @@ describe("renderHuman — a gated run whose own push never reached the remote (#
145
146
  renderHuman({ ...GATED, gatePushed: true }, (l) => lines.push(l));
146
147
  expect(lines.join("\n")).not.toContain("was not pushed");
147
148
  });
149
+
150
+ // #3677: with no remote at all, the person who approves is the one at this
151
+ // machine; one quiet line, no warning on every local run.
152
+ test("prints one quiet note, not a warning, when chant/lifecycle has no remote", () => {
153
+ const lines: string[] = [];
154
+ renderHuman({ ...GATED, gatePushed: false, gatePushWarning: LIFECYCLE_LOCAL_NOTE }, (l) => lines.push(l));
155
+ const out = lines.join("\n");
156
+ expect(out).toContain(" note : recorded locally (no remote for chant/lifecycle)");
157
+ expect(out).not.toContain("warning");
158
+ expect(out).not.toContain("was not pushed");
159
+ });
148
160
  });
149
161
 
150
162
  describe("renderJson", () => {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { OpRunResult, StepRecord } from "./local-executor";
8
8
  import { approveCommand } from "./gate";
9
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
9
10
 
10
11
  type Writer = (line: string) => void;
11
12
 
@@ -107,7 +108,10 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
107
108
  // branch, not the remote. The pending fact is still correct — the gate is
108
109
  // still right to stand — but an operator working from a clone of the
109
110
  // remote cannot see it to approve it, and nothing else here says so.
110
- if (result.gatePushed === false) {
111
+ // #3677: with no remote at all, nobody elsewhere approves; one quiet line.
112
+ if (result.gatePushed === false && isLifecycleLocalNote(result.gatePushWarning)) {
113
+ write(` note : ${LIFECYCLE_LOCAL_NOTE}`);
114
+ } else if (result.gatePushed === false) {
111
115
  write(
112
116
  ` warning : the pending fact was not pushed to the remote — ` +
113
117
  (result.gatePushWarning ?? "it exists only in this checkout") +
@@ -334,3 +334,12 @@ describe("neverOverMcp (chant#3447)", () => {
334
334
  expect(opConfigFromIR(buildOpIR({ ...base, neverOverMcp: true })).neverOverMcp).toBe(true);
335
335
  });
336
336
  });
337
+
338
+ describe("failOn (chant#3675)", () => {
339
+ it("survives the IR round trip, and is absent when not declared", () => {
340
+ const base = { name: "prod-watch", overview: "watch", phases: [] };
341
+ expect(buildOpIR({ ...base, failOn: "drift" }).failOn).toBe("drift");
342
+ expect(buildOpIR(base)).not.toHaveProperty("failOn");
343
+ expect(opConfigFromIR(buildOpIR({ ...base, failOn: "drift" })).failOn).toBe("drift");
344
+ });
345
+ });
package/src/op/op-ir.ts CHANGED
@@ -203,6 +203,8 @@ export interface OpIR {
203
203
  changesCheckout?: true;
204
204
  /** `OpConfig.neverOverMcp`, present only when true. */
205
205
  neverOverMcp?: true;
206
+ /** `OpConfig.failOn` (#3675), present only when set. */
207
+ failOn?: "drift";
206
208
  phases: OpIRPhase[];
207
209
  onFailure: OpIRPhase[];
208
210
  /** Every activity profile referenced by a step in this Op, keyed by profile name — from the registry passed to {@link buildOpIR}, empty when none was. */
@@ -371,6 +373,7 @@ export function buildOpIR(
371
373
  ...(config.workLease ? { workLease: config.workLease } : {}),
372
374
  ...(config.changesCheckout ? { changesCheckout: true as const } : {}),
373
375
  ...(config.neverOverMcp ? { neverOverMcp: true as const } : {}),
376
+ ...(config.failOn ? { failOn: config.failOn } : {}),
374
377
  phases: config.phases.map((p) => irPhase(p, contractRegistry)),
375
378
  onFailure: (config.onFailure ?? []).map((p) => irPhase(p, contractRegistry)),
376
379
  activityProfiles: sortedEntries(profiles),
@@ -454,5 +457,6 @@ export function opConfigFromIR(ir: OpIR): OpConfig {
454
457
  ...(ir.workLease ? { workLease: ir.workLease } : {}),
455
458
  ...(ir.changesCheckout ? { changesCheckout: true } : {}),
456
459
  ...(ir.neverOverMcp ? { neverOverMcp: true } : {}),
460
+ ...(ir.failOn ? { failOn: ir.failOn } : {}),
457
461
  };
458
462
  }
package/src/op/types.ts CHANGED
@@ -68,8 +68,19 @@ export interface OpConfig {
68
68
  * `chant run` and `chant approve` are unchanged.
69
69
  */
70
70
  neverOverMcp?: boolean;
71
+ /**
72
+ * An outcome that makes `chant run` exit non-zero although every step
73
+ * succeeded (#3675). `"drift"`: a completed run whose `Drift` outcome is
74
+ * true exits {@link DRIFT_EXIT_CODE}, so a scheduled drift job turns red
75
+ * without a wrapper script. Same as passing `chant run <op> --fail-on-drift`.
76
+ * The run itself, and what it records on the ledger, are unchanged.
77
+ */
78
+ failOn?: "drift";
71
79
  }
72
80
 
81
+ /** The exit code `chant run` returns for a completed run that found drift, under `failOn: "drift"` or `--fail-on-drift` (#3675). */
82
+ export const DRIFT_EXIT_CODE = 2;
83
+
73
84
  /**
74
85
  * The id under which a run's work lease is published to its steps (#2748):
75
86
  * `stepOutput(WORK_LEASE_STEP_ID, "token")`, or `workLeaseOutput("token")`
package/src/serializer.ts CHANGED
@@ -69,6 +69,17 @@ export interface SerializerResult {
69
69
  * superset).
70
70
  */
71
71
  verbatimFiles?: string[];
72
+ /**
73
+ * Basenames from `files` that hold a secret (committed ciphertext) and so
74
+ * must be written to disk, never echoed to a terminal. A build with no
75
+ * `--output` refuses when this is non-empty, naming the flag (chant#3738).
76
+ *
77
+ * Separate from {@link verbatimFiles}: "copy these bytes exactly" says
78
+ * nothing about secrecy. A verbatim file that is not listed here, such as
79
+ * the sql lexicon's DDL, prints like any other additional file when there
80
+ * is no `--output`.
81
+ */
82
+ secretFiles?: string[];
72
83
  /**
73
84
  * Non-fatal diagnostics produced during serialization (e.g. a dialect
74
85
  * dropping keys the target platform ignores). The build pipeline collects