@intentius/chant 0.118.1 → 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 (87) 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.map +1 -1
  27. package/dist/op/composites/watch-op.d.ts +8 -0
  28. package/dist/op/composites/watch-op.d.ts.map +1 -1
  29. package/dist/op/emulator-lifecycle.d.ts +31 -0
  30. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  31. package/dist/op/gate-summary.d.ts.map +1 -1
  32. package/dist/op/gate.d.ts +4 -1
  33. package/dist/op/gate.d.ts.map +1 -1
  34. package/dist/op/index.d.ts +3 -3
  35. package/dist/op/index.d.ts.map +1 -1
  36. package/dist/op/local-output.d.ts.map +1 -1
  37. package/dist/op/op-ir.d.ts +2 -0
  38. package/dist/op/op-ir.d.ts.map +1 -1
  39. package/dist/op/types.d.ts +10 -0
  40. package/dist/op/types.d.ts.map +1 -1
  41. package/dist/serializer.d.ts +11 -0
  42. package/dist/serializer.d.ts.map +1 -1
  43. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/commands/build.test.ts +29 -0
  46. package/src/cli/commands/build.ts +11 -2
  47. package/src/cli/handlers/emulator.test.ts +136 -4
  48. package/src/cli/handlers/emulator.ts +171 -14
  49. package/src/cli/handlers/lifecycle.ts +13 -2
  50. package/src/cli/handlers/operator.test.ts +9 -7
  51. package/src/cli/handlers/operator.ts +18 -23
  52. package/src/cli/handlers/run.test.ts +52 -0
  53. package/src/cli/handlers/run.ts +43 -3
  54. package/src/cli/lexicon-subpath-build.test.ts +129 -0
  55. package/src/cli/main.ts +24 -6
  56. package/src/cli/plugins.ts +26 -5
  57. package/src/cli/registry.ts +6 -0
  58. package/src/components/driver-output.ts +4 -1
  59. package/src/deep-observation.ts +29 -1
  60. package/src/detectLexicon.test.ts +38 -0
  61. package/src/detectLexicon.ts +7 -2
  62. package/src/lifecycle/deep-diff.test.ts +24 -1
  63. package/src/lifecycle/deep-diff.ts +15 -0
  64. package/src/lifecycle/deep-observe.ts +5 -2
  65. package/src/lifecycle/git.test.ts +18 -0
  66. package/src/lifecycle/git.ts +25 -0
  67. package/src/lifecycle/local-note.ts +17 -0
  68. package/src/op/activities/converge-push.test.ts +8 -6
  69. package/src/op/activities/converge.ts +2 -7
  70. package/src/op/activities/lifecycle.test.ts +5 -0
  71. package/src/op/activities/lifecycle.ts +3 -0
  72. package/src/op/composites/composites.test.ts +5 -0
  73. package/src/op/composites/watch-op.ts +9 -0
  74. package/src/op/emulator-lifecycle.test.ts +13 -1
  75. package/src/op/emulator-lifecycle.ts +48 -0
  76. package/src/op/gate-summary.test.ts +6 -0
  77. package/src/op/gate-summary.ts +4 -1
  78. package/src/op/gate.ts +7 -25
  79. package/src/op/index.ts +3 -3
  80. package/src/op/local-output.test.ts +12 -0
  81. package/src/op/local-output.ts +5 -1
  82. package/src/op/op-ir.test.ts +9 -0
  83. package/src/op/op-ir.ts +4 -0
  84. package/src/op/types.ts +11 -0
  85. package/src/serializer.ts +11 -0
  86. package/src/workspace/ci-green-workflow.ts +1 -1
  87. package/src/workspace/lineage-upgrade-cli.ts +3 -1
@@ -1,10 +1,12 @@
1
- import { exec } from "node:child_process";
1
+ import { exec, execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { emulatorLifecycle, emulatorsOf } from "../../op";
3
+ import { emulatorLifecycle, emulatorsOf, type EmulatorCapability } from "../../op";
4
+ import type { LexiconPlugin } from "../../lexicon";
4
5
  import { formatError, formatWarning, formatSuccess } from "../format";
5
6
  import type { CommandContext } from "../registry";
6
7
 
7
8
  const execAsync = promisify(exec);
9
+ const execFileAsync = promisify(execFile);
8
10
 
9
11
  /** One emulator's state — the machine-readable `--json` unit (#920). */
10
12
  export interface EmulatorReport {
@@ -15,18 +17,143 @@ export interface EmulatorReport {
15
17
  endpoint: string;
16
18
  /** Env that points the SDK / `chant graph --live` / a triggered Op at it. */
17
19
  env: Record<string, string>;
20
+ /**
21
+ * `status` only (#3673): the container runs, but its endpoint reaches
22
+ * another server. `endpoint` and `env` are then empty, so a consumer does
23
+ * not point tooling at the wrong server.
24
+ */
25
+ conflict?: string;
18
26
  }
19
27
 
20
28
  const ACTIONS = new Set(["up", "down", "status"]);
21
29
 
22
- /** True if a container of this name is running. */
23
- async function isRunning(name: string): Promise<boolean> {
30
+ /** A running container, a stopped one, or docker could not be asked (#3673). */
31
+ type ContainerState = { running: boolean } | { error: string };
32
+
33
+ /** Whether a container of this name is running, or why docker could not say. */
34
+ async function containerState(name: string): Promise<ContainerState> {
24
35
  try {
25
36
  const { stdout } = await execAsync(`docker ps -q -f name=${name}`);
26
- return Boolean(stdout.trim());
37
+ return { running: Boolean(stdout.trim()) };
38
+ } catch (err) {
39
+ // The shell exits 127 when the command is not on PATH. Reporting that as
40
+ // "down" told a user with running containers that nothing was running.
41
+ const e = err as { code?: number | string; stderr?: string; message?: string };
42
+ if (e.code === 127 || e.code === "ENOENT") return { error: "docker not found on PATH; cannot check the emulator" };
43
+ const why = (e.stderr || e.message || "").trim().split("\n")[0];
44
+ return { error: `docker could not list containers${why ? ` (${why})` : ""}; cannot check the emulator` };
45
+ }
46
+ }
47
+
48
+ /** The host port Docker publishes `containerPort` on, or undefined when it cannot tell. */
49
+ async function publishedPort(name: string, containerPort: number): Promise<number | undefined> {
50
+ try {
51
+ const { stdout } = await execFileAsync("docker", ["port", name, `${containerPort}/tcp`]);
52
+ const match = /:(\d+)\s*$/m.exec(stdout.trim().split("\n")[0] ?? "");
53
+ return match ? Number(match[1]) : undefined;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Whether `endpoint` reaches this container's server (#3673): the identity
61
+ * read inside the container against the one read through the endpoint.
62
+ * Returns the problem, or undefined when they agree or the check cannot run.
63
+ */
64
+ export async function endpointConflict(
65
+ lexicon: string,
66
+ cap: EmulatorCapability,
67
+ endpoint: string,
68
+ port: number,
69
+ ): Promise<string | undefined> {
70
+ const identity = cap.spec.identity;
71
+ if (!identity) return undefined;
72
+ let inside: string;
73
+ try {
74
+ inside = (await execFileAsync("docker", ["exec", cap.spec.name, ...identity.command])).stdout.trim();
27
75
  } catch {
28
- return false; // docker missing / not running — treat as down
76
+ return undefined; // the container cannot answer for itself; nothing to compare
77
+ }
78
+ const hint =
79
+ `Run \`chant emulator down --lexicon ${lexicon}\`, then \`chant emulator up --lexicon ${lexicon} --port ${cap.spec.containerPort}=<free port>\`.`;
80
+ let answered: { id: string; label: string };
81
+ try {
82
+ answered = await identity.probe(endpoint);
83
+ } catch (err) {
84
+ const why = err instanceof Error ? err.message : String(err);
85
+ return `localhost:${port} does not answer as the emulator (${why}); another ${identity.server} may hold 127.0.0.1:${port}. ${hint}`;
86
+ }
87
+ if (answered.id.trim() === inside) return undefined;
88
+ return `localhost:${port} answers as ${answered.label}, not the emulator; another ${identity.server} holds 127.0.0.1:${port}. ${hint}`;
89
+ }
90
+
91
+ /**
92
+ * `--port` values as container port to host port (#3673). A bare number
93
+ * applies when exactly one emulator is selected.
94
+ */
95
+ export function parsePortMappings(values: readonly string[], targets: readonly EmulatorCapability[]): Map<number, number> {
96
+ const ports = new Map<number, number>();
97
+ for (const value of values) {
98
+ const pair = /^(\d+)=(\d+)$/.exec(value);
99
+ const bare = /^(\d+)$/.exec(value);
100
+ if (pair) {
101
+ const containerPort = Number(pair[1]);
102
+ if (!targets.some((t) => t.spec.containerPort === containerPort)) {
103
+ const known = targets.map((t) => `${t.spec.containerPort} (${t.spec.name})`).join(", ");
104
+ throw new Error(`--port ${value}: no selected emulator listens on ${containerPort}; the selected ones use ${known || "none"}`);
105
+ }
106
+ ports.set(containerPort, Number(pair[2]));
107
+ } else if (bare) {
108
+ if (targets.length !== 1) {
109
+ throw new Error(`--port ${value} is ambiguous with ${targets.length} emulators selected; use --port <container-port>=<host-port>`);
110
+ }
111
+ ports.set(targets[0]!.spec.containerPort, Number(bare[1]));
112
+ } else {
113
+ throw new Error(`--port ${value}: expected <container-port>=<host-port>, e.g. --port 5432=15432`);
114
+ }
115
+ }
116
+ return ports;
117
+ }
118
+
119
+ /**
120
+ * `chant emulator --help` (#3673): the subcommands and flags, and for each
121
+ * configured lexicon's emulator the port, endpoint and sign-in a client uses.
122
+ */
123
+ export function formatEmulatorHelp(plugins: readonly LexiconPlugin[]): string {
124
+ const lines = [
125
+ "Usage: chant emulator <up|down|status> [--lexicon <name>] [--port <container-port>=<host-port>] [--json]",
126
+ "",
127
+ "Boot, stop or inspect the local emulators of the project's configured lexicons.",
128
+ "",
129
+ "Subcommands:",
130
+ " up Boot each emulator, or reuse a running one, and wait until it is ready.",
131
+ " Refuses when another process already holds the host port on 127.0.0.1.",
132
+ " down Stop and remove the containers.",
133
+ " status Report whether each emulator runs, and check that its endpoint reaches it.",
134
+ " Exits 1 when docker cannot be asked or an endpoint reaches another server.",
135
+ "",
136
+ "Flags:",
137
+ " --lexicon <name> Act on one lexicon's emulators only.",
138
+ " --port <c>=<h> up: publish container port <c> on host port <h>; repeatable.",
139
+ " A bare <h> works when one emulator is selected.",
140
+ " --json Print the machine-readable report on stdout.",
141
+ "",
142
+ ];
143
+ const caps = plugins.flatMap((p) => emulatorsOf(p.emulator).map((cap) => ({ lexicon: p.name, cap })));
144
+ if (caps.length === 0) {
145
+ lines.push("No lexicon configured for this project has a local emulator.");
146
+ return lines.join("\n");
29
147
  }
148
+ lines.push("Emulators in this project (default ports):");
149
+ for (const { lexicon, cap } of caps) {
150
+ const endpoint = emulatorLifecycle(cap.spec).endpoint(cap.spec.containerPort);
151
+ lines.push(` ${lexicon}: ${cap.spec.name}, port ${cap.spec.containerPort}, ${endpoint}`);
152
+ if (cap.spec.credentials) lines.push(` sign in: ${cap.spec.credentials}`);
153
+ const env = Object.entries(cap.env(endpoint)).map(([k, v]) => `${k}=${v}`);
154
+ if (env.length) lines.push(` env: ${env.join(" ")}`);
155
+ }
156
+ return lines.join("\n");
30
157
  }
31
158
 
32
159
  /**
@@ -44,7 +171,7 @@ export async function runEmulator(ctx: CommandContext): Promise<number> {
44
171
  // in args.path; the bare `emulator` fallback leaves it as the "." default.
45
172
  const action = args.path;
46
173
  if (!action || !ACTIONS.has(action)) {
47
- console.error(formatError({ message: "Usage: chant emulator <up|down|status> [--lexicon <name>] [--json]" }));
174
+ console.error(formatError({ message: "Usage: chant emulator <up|down|status> [--lexicon <name>] [--port <container-port>=<host-port>] [--json]; chant emulator --help lists the ports and sign-in" }));
48
175
  return 1;
49
176
  }
50
177
 
@@ -67,30 +194,60 @@ export async function runEmulator(ctx: CommandContext): Promise<number> {
67
194
  return 0;
68
195
  }
69
196
 
197
+ let ports: Map<number, number>;
198
+ try {
199
+ if (args.port?.length && action !== "up") throw new Error("--port applies to `chant emulator up` only");
200
+ ports = parsePortMappings(args.port ?? [], targets.map((t) => t.cap));
201
+ } catch (err) {
202
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
203
+ return 1;
204
+ }
205
+
70
206
  const reports: EmulatorReport[] = [];
71
207
  for (const { lexicon, cap } of targets) {
72
208
  const lc = emulatorLifecycle(cap.spec);
73
209
  if (action === "up") {
74
- const { endpoint } = await lc.up();
75
- reports.push({ lexicon, name: cap.spec.name, endpoint, env: cap.env(endpoint) });
210
+ const port = ports.get(cap.spec.containerPort);
211
+ try {
212
+ const { endpoint } = await lc.up(port === undefined ? {} : { port });
213
+ reports.push({ lexicon, name: cap.spec.name, endpoint, env: cap.env(endpoint) });
214
+ } catch (err) {
215
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
216
+ return 1;
217
+ }
76
218
  } else if (action === "down") {
77
219
  await lc.down();
78
220
  reports.push({ lexicon, name: cap.spec.name, endpoint: "", env: {} });
79
221
  } else {
80
- const running = await isRunning(cap.spec.name);
81
- const endpoint = running ? lc.endpoint(cap.spec.containerPort) : "";
82
- reports.push({ lexicon, name: cap.spec.name, endpoint, env: endpoint ? cap.env(endpoint) : {} });
222
+ const state = await containerState(cap.spec.name);
223
+ if ("error" in state) {
224
+ console.error(formatError({ message: state.error }));
225
+ return 1;
226
+ }
227
+ if (!state.running) {
228
+ reports.push({ lexicon, name: cap.spec.name, endpoint: "", env: {} });
229
+ continue;
230
+ }
231
+ // The port `up` published, which `--port` may have moved off the default.
232
+ const port = (await publishedPort(cap.spec.name, cap.spec.containerPort)) ?? cap.spec.containerPort;
233
+ const endpoint = lc.endpoint(port);
234
+ const conflict = await endpointConflict(lexicon, cap, endpoint, port);
235
+ reports.push(conflict
236
+ ? { lexicon, name: cap.spec.name, endpoint: "", env: {}, conflict }
237
+ : { lexicon, name: cap.spec.name, endpoint, env: cap.env(endpoint) });
83
238
  }
84
239
  }
85
240
 
241
+ const exit = reports.some((r) => r.conflict) ? 1 : 0;
86
242
  if (args.json) {
87
243
  console.log(JSON.stringify({ emulators: reports }));
88
- return 0;
244
+ return exit;
89
245
  }
90
246
  for (const r of reports) {
91
247
  if (action === "up") console.error(formatSuccess(`${r.lexicon}: ${r.name} up on ${r.endpoint}`));
92
248
  else if (action === "down") console.error(formatSuccess(`${r.lexicon}: ${r.name} down`));
249
+ else if (r.conflict) console.error(formatError({ message: `${r.lexicon}: ${r.name} runs, but ${r.conflict}` }));
93
250
  else console.error(`${r.lexicon}: ${r.name} — ${r.endpoint ? `up on ${r.endpoint}` : "down"}`);
94
251
  }
95
- return 0;
252
+ return exit;
96
253
  }
@@ -5,7 +5,7 @@ import { build } from "../../build";
5
5
  import { takeSnapshot } from "../../lifecycle/snapshot";
6
6
  import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
7
7
  import { deepDiffForLexicon, type DeclaredEntities } from "../../lifecycle/deep-observe";
8
- import { countUnclaimed, countPropertyDrift, countHeld, suspiciousHeld, type DeepDiffResult, type DeepEntityHeld } from "../../lifecycle/deep-diff";
8
+ import { countUnclaimed, countPending, countPropertyDrift, countHeld, suspiciousHeld, type DeepDiffResult, type DeepEntityHeld } from "../../lifecycle/deep-diff";
9
9
  import { describePathOrigin, getPathProvenance, getProvenance, type EntityProvenance } from "../../provenance";
10
10
  import { resolveDeepDrift, resolveDriftedField, type FieldReconcile } from "../../fold-provenance";
11
11
  import {
@@ -913,7 +913,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
913
913
  componentStacks: args.componentStacks,
914
914
  baseline: baselineForLexicon(args.baseline, lexiconName),
915
915
  });
916
- totalDrift += countPropertyDrift(deep);
916
+ totalDrift += countPropertyDrift(deep) + countPending(deep);
917
917
  // Only count a deep hole for an entity the thin read *did* resolve —
918
918
  // otherwise one unreadable entity is counted twice.
919
919
  totalUnobserved += deep.unobserved.filter((u) => !observed.unobserved[u.name]).length;
@@ -1001,8 +1001,10 @@ function renderDeepDiff(
1001
1001
  const drift = countPropertyDrift(deep);
1002
1002
  const unclaimedCount = countUnclaimed(deep);
1003
1003
  const heldCount = countHeld(deep);
1004
+ const pendingCount = countPending(deep);
1004
1005
  if (
1005
1006
  drift === 0 &&
1007
+ pendingCount === 0 &&
1006
1008
  heldCount === 0 &&
1007
1009
  unclaimedCount === 0 &&
1008
1010
  deep.accepted.length === 0 &&
@@ -1017,6 +1019,7 @@ function renderDeepDiff(
1017
1019
  console.log(
1018
1020
  `${drift} property drift across ${deep.drifted.length} resource(s), ` +
1019
1021
  `${acceptedCount} accepted, ${deep.unchanged.length} unchanged` +
1022
+ (pendingCount > 0 ? `, ${pendingCount} pending` : "") +
1020
1023
  (heldCount > 0 ? `, ${heldCount} held` : "") +
1021
1024
  (unclaimedCount > 0 ? `, ${unclaimedCount} unclaimed` : "") +
1022
1025
  (deep.unobserved.length > 0 ? `, ${deep.unobserved.length} unobserved` : ""),
@@ -1064,6 +1067,14 @@ function renderDeepDiff(
1064
1067
  }
1065
1068
  }
1066
1069
  }
1070
+ if (pendingCount > 0) {
1071
+ // #3706: changes an apply would make that no declared property shows
1072
+ // (privileges granted by hand). Printed, so the plan digest moves with them.
1073
+ console.log(formatBold("\nPENDING (changes an apply would make that no declared property shows):"));
1074
+ for (const p of deep.pending ?? []) {
1075
+ console.log(` ~ ${p.subject}: ${p.change}${p.entities && p.entities.length > 0 ? ` [from: ${p.entities.join(", ")}]` : ""}`);
1076
+ }
1077
+ }
1067
1078
  if (deep.unclaimed.length > 0) {
1068
1079
  // Not drift, and printed after it for that reason (#2160): chant never set
1069
1080
  // these fields, so nothing here is a change chant proposes to make.
@@ -63,7 +63,7 @@ vi.mock("../../lifecycle/git", async () => {
63
63
  const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
64
64
  return {
65
65
  ...actual,
66
- pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args),
66
+ pushLifecycleReport: (...args: unknown[]) => pushLifecycleMock(...args),
67
67
  // Mocked, not merely defaulted: the real one shells to `git fetch`, and
68
68
  // these tests run in the chant checkout itself (#2303).
69
69
  requireLifecycleLedger: (...args: unknown[]) => requireLifecycleLedgerMock(...args),
@@ -92,7 +92,7 @@ beforeEach(() => {
92
92
  loadActivitiesMock.mockResolvedValue(new Map());
93
93
  loadProfilesMock.mockResolvedValue({});
94
94
  discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
95
- pushLifecycleMock.mockResolvedValue(true);
95
+ pushLifecycleMock.mockResolvedValue({ pushed: true });
96
96
  requireLifecycleLedgerMock.mockResolvedValue(undefined);
97
97
  readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
98
98
  readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
@@ -1046,31 +1046,33 @@ describe("runApprove — a push that does not land is reported (#2309 review)",
1046
1046
  commit: "sha",
1047
1047
  record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
1048
1048
  });
1049
- pushLifecycleMock.mockRejectedValue(new Error("Another snapshot completed for chant/lifecycle"));
1049
+ pushLifecycleMock.mockResolvedValue({ pushed: false, pushWarning: "Another snapshot completed for chant/lifecycle" });
1050
1050
  const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1051
1051
 
1052
1052
  const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
1053
1053
 
1054
1054
  const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
1055
- expect(out).toMatch(/push to the remote was rejected/);
1055
+ expect(out).toMatch(/push to the remote did not land: Another snapshot completed/);
1056
1056
  expect(out).toMatch(/local only . the push did not land/);
1057
1057
  // Still exit 0: the local append is a correct local fact.
1058
1058
  expect(code).toBe(0);
1059
1059
  errSpy.mockRestore();
1060
1060
  });
1061
1061
 
1062
- test("a project with no remote says nothing was pushed", async () => {
1062
+ test("a project with no remote says so quietly, with no warning (#3677)", async () => {
1063
1063
  seedPending("fountain-apply", "rollout-gate", PLAN_A);
1064
1064
  appendGateResolutionMock.mockResolvedValue({
1065
1065
  commit: "sha",
1066
1066
  record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
1067
1067
  });
1068
- pushLifecycleMock.mockResolvedValue(false);
1068
+ pushLifecycleMock.mockResolvedValue({ pushed: false, pushWarning: "recorded locally (no remote for chant/lifecycle)" });
1069
1069
  const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1070
1070
 
1071
1071
  await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
1072
1072
 
1073
- expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(/No remote is configured/);
1073
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
1074
+ expect(out).toMatch(/resolved by alex .*\(recorded locally \(no remote for chant\/lifecycle\)\)/);
1075
+ expect(out).not.toMatch(/push did not land|did not land|No remote is configured/);
1074
1076
  errSpy.mockRestore();
1075
1077
  });
1076
1078
  });
@@ -50,7 +50,8 @@ import {
50
50
  import { approverOf, tallyGateApprovals } from "../../op/gate";
51
51
  import { FAN_OUT_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
52
52
  import { PR_GATE_OP } from "../../pr-loop";
53
- import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
53
+ import { pushLifecycleReport, requireLifecycleLedger } from "../../lifecycle/git";
54
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../../lifecycle/local-note";
54
55
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
55
56
  import type { CommandContext } from "../registry";
56
57
 
@@ -818,23 +819,18 @@ const LIFECYCLE_BRANCH = "chant/lifecycle";
818
819
  * only in their own checkout.
819
820
  *
820
821
  * The append is still a correct *local* fact, so this is a warning and not a
821
- * failure; the exit code is unchanged.
822
+ * failure; the exit code is unchanged. With no remote at all there is nobody
823
+ * elsewhere to tell, so that case is a quiet suffix and no warning (#3677).
822
824
  */
823
- async function reportedPush(consequence: string): Promise<boolean> {
824
- try {
825
- const pushed = await pushLifecycle();
826
- if (pushed) return true;
827
- console.error(formatWarning({
828
- message: `No remote is configured, so nothing was pushed. ${consequence}`,
829
- }));
830
- return false;
831
- } catch (err) {
832
- console.error(formatWarning({
833
- message: `The push to the remote was rejected: ${err instanceof Error ? err.message : String(err)}`,
834
- hint: consequence,
835
- }));
836
- return false;
837
- }
825
+ async function reportedPush(consequence: string): Promise<{ pushed: boolean; suffix: string }> {
826
+ const { pushed, pushWarning } = await pushLifecycleReport();
827
+ if (pushed) return { pushed, suffix: "" };
828
+ if (isLifecycleLocalNote(pushWarning)) return { pushed, suffix: ` (${LIFECYCLE_LOCAL_NOTE})` };
829
+ console.error(formatWarning({
830
+ message: `The push to the remote did not land: ${pushWarning ?? "no reason given"}`,
831
+ hint: consequence,
832
+ }));
833
+ return { pushed, suffix: " (local only — the push did not land)" };
838
834
  }
839
835
 
840
836
  export async function runApprove(ctx: CommandContext): Promise<number> {
@@ -873,13 +869,12 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
873
869
  expiresAt: now,
874
870
  ...(standing.description ? { description: standing.description } : {}),
875
871
  });
876
- const pushed = await reportedPush(
872
+ const push = await reportedPush(
877
873
  `The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
878
874
  `a run in another checkout still sees the old pending fact.`,
879
875
  );
880
876
  console.error(formatSuccess(
881
- `Gate "${gate}" on "${opName}" expired at ${now} — not approved` +
882
- (pushed ? "" : " (local only — the push did not land)"),
877
+ `Gate "${gate}" on "${opName}" expired at ${now} — not approved` + push.suffix,
883
878
  ));
884
879
  console.error(formatInfo(
885
880
  opName === FAN_OUT_GATE_OP
@@ -1294,7 +1289,7 @@ export async function recordGateApproval(
1294
1289
  origin,
1295
1290
  ...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
1296
1291
  });
1297
- const pushed = await reportedPush(
1292
+ const push = await reportedPush(
1298
1293
  `The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
1299
1294
  `a run in another checkout will not see it.`,
1300
1295
  );
@@ -1305,7 +1300,7 @@ export async function recordGateApproval(
1305
1300
  ` at ${record.timestamp}` +
1306
1301
  (record.url ? ` (${record.url})` : "") +
1307
1302
  (record.seal ? `, signed with ${record.seal.key}` : "") +
1308
- (pushed ? "" : " (local only — the push did not land)"),
1303
+ push.suffix,
1309
1304
  ));
1310
1305
  if (record.planDigest) {
1311
1306
  console.error(formatInfo(
@@ -1319,7 +1314,7 @@ export async function recordGateApproval(
1319
1314
  console.error(formatInfo(line));
1320
1315
  }
1321
1316
  }
1322
- return { ok: true, record, pushed };
1317
+ return { ok: true, record, pushed: push.pushed };
1323
1318
  }
1324
1319
 
1325
1320
  /**
@@ -1877,3 +1877,55 @@ describe("runOpComponents", () => {
1877
1877
  });
1878
1878
  });
1879
1879
  });
1880
+
1881
+ /**
1882
+ * chant #3675 — a completed run that found drift exits 0 unless asked to
1883
+ * fail: `--fail-on-drift` on the command line, or `failOn: "drift"` on the Op.
1884
+ * The drift arrives the way a WatchOp's does, as a `Drift` outcome attribute.
1885
+ */
1886
+ describe("runOp: --fail-on-drift (#3675)", () => {
1887
+ function driftOp(name: string, drifted: boolean, extra: Record<string, unknown> = {}) {
1888
+ const step = {
1889
+ kind: "activity",
1890
+ fn: "shellCmd",
1891
+ args: { cmd: `echo '{"drifted":${drifted}}'`, json: true },
1892
+ outcomeAttribute: { name: "Drift", from: "json.drifted" },
1893
+ };
1894
+ const [opName, discovered] = localOp(name, [step]);
1895
+ return [opName, { config: { ...discovered.config, ...extra } }] as const;
1896
+ }
1897
+
1898
+ beforeEach(() => {
1899
+ discoverOpsMock.mockReset();
1900
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
1901
+ loadPluginsMock.mockReset().mockResolvedValue([]);
1902
+ });
1903
+
1904
+ afterEach(() => vi.restoreAllMocks());
1905
+
1906
+ test("drift without the flag or failOn still exits 0", async () => {
1907
+ discoverOpsMock.mockResolvedValue({ ops: new Map([driftOp("watch", true)]), errors: [] });
1908
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1909
+ expect(await runOp({ args: makeArgs({ path: "watch" }), plugins: [], serializers: [] })).toBe(0);
1910
+ });
1911
+
1912
+ test("--fail-on-drift exits 2 when the Drift outcome is true, and says why", async () => {
1913
+ discoverOpsMock.mockResolvedValue({ ops: new Map([driftOp("watch", true)]), errors: [] });
1914
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1915
+ const stderr = makeStderrSpy();
1916
+ expect(await runOp({ args: makeArgs({ path: "watch", failOnDrift: true }), plugins: [], serializers: [] })).toBe(2);
1917
+ expect(stderr.join("\n")).toContain("exiting 2 because --fail-on-drift");
1918
+ });
1919
+
1920
+ test("--fail-on-drift exits 0 when the run found no drift", async () => {
1921
+ discoverOpsMock.mockResolvedValue({ ops: new Map([driftOp("watch", false)]), errors: [] });
1922
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1923
+ expect(await runOp({ args: makeArgs({ path: "watch", failOnDrift: true }), plugins: [], serializers: [] })).toBe(0);
1924
+ });
1925
+
1926
+ test("an Op declaring failOn: \"drift\" exits 2 with no flag", async () => {
1927
+ discoverOpsMock.mockResolvedValue({ ops: new Map([driftOp("watch", true, { failOn: "drift" })]), errors: [] });
1928
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1929
+ expect(await runOp({ args: makeArgs({ path: "watch" }), plugins: [], serializers: [] })).toBe(2);
1930
+ });
1931
+ });
@@ -4,7 +4,7 @@ import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
4
4
  import { loadChantConfig, resolveAutoReleaseDisabled, InvalidChantConfigError, type ChantConfig } from "../../config";
5
5
  import { discoverOps, discoverStewards } from "../../op/discover";
6
6
  import { stewardWorkHolder } from "../../op/work-lease-run";
7
- import type { OpConfig } from "../../op/types";
7
+ import { DRIFT_EXIT_CODE, type OpConfig } from "../../op/types";
8
8
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
9
9
  import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
10
10
  import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
@@ -22,6 +22,7 @@ import { holdBesideLease } from "../../op/steward-beside";
22
22
  import { acquireStewardTurn, STEWARD_TURN_WAIT_MS } from "../../op/operator";
23
23
  import { releaseLease, currentHolderId, type AcquireLeaseResult } from "../../lifecycle/lease";
24
24
  import { StaleLockError } from "../../lifecycle/git";
25
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../../lifecycle/local-note";
25
26
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
26
27
  import { ndjsonProgressSink } from "../../components/run-progress";
27
28
  import { maybeRecordAutoRelease } from "../../components/auto-release";
@@ -67,6 +68,40 @@ function resolveGatedExitCode(ctx: CommandContext): number | undefined {
67
68
  return raw;
68
69
  }
69
70
 
71
+ /**
72
+ * Whether a settled run's records carry a true `Drift` outcome (#3675) — the
73
+ * one `lifecycleDiff` surfaces through `outcomeAttribute` on a WatchOp,
74
+ * ApplyOp, ReconcileOp or ConvergeOp step.
75
+ */
76
+ export function runFoundDrift(records: readonly StepRecord[] | undefined): boolean {
77
+ for (const record of records ?? []) {
78
+ const outcomes = record.outcomes ?? (record.outcome ? [record.outcome] : []);
79
+ if (outcomes.some((o) => o.name === "Drift" && o.value === true)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * The exit code for a completed run (#3675): 0, or {@link DRIFT_EXIT_CODE}
86
+ * when the run found drift and either `--fail-on-drift` or the Op's own
87
+ * `failOn: "drift"` asked for drift to fail the job. Says why on stderr when
88
+ * it remaps, since every step in the run succeeded.
89
+ */
90
+ export function completedRunExitCode(
91
+ opName: string,
92
+ config: Pick<OpConfig, "failOn">,
93
+ failOnDriftFlag: boolean | undefined,
94
+ records: readonly StepRecord[] | undefined,
95
+ ): number {
96
+ if (!(failOnDriftFlag || config.failOn === "drift")) return 0;
97
+ if (!runFoundDrift(records)) return 0;
98
+ const why = failOnDriftFlag ? "--fail-on-drift" : `the Op declares failOn: "drift"`;
99
+ console.error(formatWarning({
100
+ message: `Op "${opName}" found drift; exiting ${DRIFT_EXIT_CODE} because ${why}`,
101
+ }));
102
+ return DRIFT_EXIT_CODE;
103
+ }
104
+
70
105
  /**
71
106
  * What a gated run leaves behind for CI, beyond the stderr block the renderers
72
107
  * already print (#2243): the same gate, approve command and ledger path
@@ -976,7 +1011,9 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
976
1011
  // branch. The gate is still right to stand, but an operator elsewhere
977
1012
  // cannot see the pending fact to approve it, and nothing else here says
978
1013
  // why not.
979
- if (result.gated.pushed === false) {
1014
+ if (result.gated.pushed === false && isLifecycleLocalNote(result.gated.pushWarning)) {
1015
+ console.error(formatInfo(LIFECYCLE_LOCAL_NOTE));
1016
+ } else if (result.gated.pushed === false) {
980
1017
  console.error(formatWarning({
981
1018
  message: `the pending fact was not pushed to the remote: ${result.gated.pushWarning ?? "recorded locally only"}`,
982
1019
  hint: "an operator working from a clone of the remote cannot approve it until it does",
@@ -1182,7 +1219,10 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1182
1219
  }
1183
1220
  return gatedExit;
1184
1221
  }
1185
- return status.state === "completed" ? 0 : 1;
1222
+ if (status.state !== "completed") return 1;
1223
+ // A completed run that found drift exits 2 when asked to (#3675), so a
1224
+ // scheduled drift job turns red without a script that reads the ledger.
1225
+ return completedRunExitCode(opName, config, ctx.args.failOnDrift, status.result?.records ?? status.records);
1186
1226
  } catch (err) {
1187
1227
  if (err instanceof OpRunFailure) {
1188
1228
  const sealed = err.result.gate ? await gateIsSealed(err.result.gate.gate) : false;