@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
@@ -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`. */
@@ -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
  });
@@ -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",
@@ -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
@@ -190,7 +190,7 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
190
190
  `name: ${CI_GREEN_WORKFLOW_NAME}`,
191
191
  "",
192
192
  "# Tags each commit on the branch that passed its required phases ci/green/<sha>,",
193
- "# and a green commit that later fails one ci/revoked/<sha> (#3573).",
193
+ "# and a green commit that later fails one ci/revoked/<sha>.",
194
194
  "on:",
195
195
  " workflow_run:",
196
196
  " workflows:",
@@ -17,6 +17,7 @@ import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/fo
17
17
  import type { CommandContext } from "../cli/registry";
18
18
  import { approveCommand, describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
19
19
  import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
20
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
20
21
  import { LockError } from "./lineage-lock";
21
22
  import { applyStagedUpgrade, describeStaged, stageUpgrade, type ChantRunner, type StagedUpgrade } from "./lineage-upgrade";
22
23
 
@@ -106,7 +107,8 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
106
107
  if (!opts.json) {
107
108
  const sealed = await gateIsSealed(gate);
108
109
  if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_UPGRADE_GATE_OP, gate, check.mismatch, sealed) }));
109
- if (check.pushWarning) console.error(formatWarning({ message: check.pushWarning }));
110
+ if (isLifecycleLocalNote(check.pushWarning)) console.error(formatInfo(LIFECYCLE_LOCAL_NOTE));
111
+ else if (check.pushWarning) console.error(formatWarning({ message: check.pushWarning }));
110
112
  if (check.quorum) console.error(formatInfo(`approvals so far: ${check.quorum.approvers.length} of ${check.quorum.need}`));
111
113
  console.error(
112
114
  formatInfo(