@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.
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts +24 -0
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +15 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +22 -1
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +2 -1
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +9 -1
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +12 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/local-note.d.ts +14 -0
- package/dist/lifecycle/local-note.d.ts.map +1 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +6 -1
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/composites/watch-op.d.ts +8 -0
- package/dist/op/composites/watch-op.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.d.ts +31 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +4 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +10 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +29 -0
- package/src/cli/commands/build.ts +11 -2
- package/src/cli/handlers/emulator.test.ts +136 -4
- package/src/cli/handlers/emulator.ts +171 -14
- package/src/cli/handlers/lifecycle.ts +13 -2
- package/src/cli/handlers/operator.test.ts +9 -7
- package/src/cli/handlers/operator.ts +18 -23
- package/src/cli/handlers/run.test.ts +52 -0
- package/src/cli/handlers/run.ts +43 -3
- package/src/cli/lexicon-subpath-build.test.ts +129 -0
- package/src/cli/main.ts +24 -6
- package/src/cli/plugins.ts +26 -5
- package/src/cli/registry.ts +6 -0
- package/src/components/driver-output.ts +4 -1
- package/src/deep-observation.ts +29 -1
- package/src/detectLexicon.test.ts +38 -0
- package/src/detectLexicon.ts +7 -2
- package/src/lifecycle/deep-diff.test.ts +24 -1
- package/src/lifecycle/deep-diff.ts +15 -0
- package/src/lifecycle/deep-observe.ts +5 -2
- package/src/lifecycle/git.test.ts +18 -0
- package/src/lifecycle/git.ts +25 -0
- package/src/lifecycle/local-note.ts +17 -0
- package/src/op/activities/converge-push.test.ts +8 -6
- package/src/op/activities/converge.ts +2 -7
- package/src/op/activities/lifecycle-gate.test.ts +147 -0
- package/src/op/activities/lifecycle.test.ts +5 -0
- package/src/op/activities/lifecycle.ts +18 -6
- package/src/op/composites/composites.test.ts +5 -0
- package/src/op/composites/watch-op.ts +9 -0
- package/src/op/emulator-lifecycle.test.ts +13 -1
- package/src/op/emulator-lifecycle.ts +48 -0
- package/src/op/gate-summary.test.ts +6 -0
- package/src/op/gate-summary.ts +4 -1
- package/src/op/gate.ts +7 -25
- package/src/op/index.ts +3 -3
- package/src/op/local-output.test.ts +12 -0
- package/src/op/local-output.ts +5 -1
- package/src/op/op-ir.test.ts +9 -0
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/serializer.ts +11 -0
- package/src/workspace/ci-green-workflow.ts +1 -1
- 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
|
-
/**
|
|
23
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|