@intentius/chant 0.72.3 → 0.72.5
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/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/declarable.d.ts +46 -1
- package/dist/declarable.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -3
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/main.ts +5 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -0
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +6 -0
- package/src/declarable-host-marker.test.ts +88 -0
- package/src/declarable.ts +72 -7
- package/src/discovery/sandbox/driver.ts +25 -0
- package/src/discovery/sandbox/fork-diagnostic.test.ts +125 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/fold/subset.ts +15 -3
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- package/src/op/activities/converge.ts +31 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { runFallbackFilesSandboxed } from "./run";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* chant#2461 — a child that ends without a usable result says which way it did.
|
|
10
|
+
*
|
|
11
|
+
* The parent had one sentence for three unrelated situations: `child exited
|
|
12
|
+
* before reporting results (code N, signal S)`. The one seen in the wild is the
|
|
13
|
+
* hardest to read — exit code 0, empty stderr, no message — because it reads as
|
|
14
|
+
* if the child was cut off, and it was not. It ran to completion and sent
|
|
15
|
+
* nothing.
|
|
16
|
+
*
|
|
17
|
+
* These tests pin the two mechanisms that produce that signature, against real
|
|
18
|
+
* forked children rather than a mock, because the whole question is what Node
|
|
19
|
+
* actually does:
|
|
20
|
+
*
|
|
21
|
+
* - an `await` that never settles, which drains the loop and exits 0
|
|
22
|
+
* - a payload the parent's `isResponse` refuses, which used to be dropped in
|
|
23
|
+
* silence and then reported as though nothing had been sent
|
|
24
|
+
*
|
|
25
|
+
* A third mechanism was proposed in the issue and is NOT covered, because it
|
|
26
|
+
* was measured and does not happen: `process.send` racing the child's reap so
|
|
27
|
+
* that `exit` is dispatched before an already-queued `message`. With the parent
|
|
28
|
+
* blocked so both were certainly pending, `message` won 60 times out of 60. The
|
|
29
|
+
* live IPC channel keeps the child alive until the payload flushes, and the
|
|
30
|
+
* driver has no `process.exit()` to cut that short. The last test here records
|
|
31
|
+
* that, so the disproof is not lost with the transcript.
|
|
32
|
+
*/
|
|
33
|
+
function runChild(source: string): Promise<{ code: number | null; message: unknown; stderr: string }> {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-fork-diag-"));
|
|
35
|
+
const file = join(dir, "child.mjs");
|
|
36
|
+
writeFileSync(file, source);
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = fork(file, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
|
|
39
|
+
let stderr = "";
|
|
40
|
+
let message: unknown;
|
|
41
|
+
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
42
|
+
child.on("message", (m) => { message = m; });
|
|
43
|
+
child.on("exit", (code) => {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
resolve({ code, message, stderr });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("why a sandboxed child ends without a result (chant#2461)", () => {
|
|
51
|
+
test("an await that never settles exits 0, silently, having sent nothing", async () => {
|
|
52
|
+
// The observed signature, reproduced. `main().catch(...)` catches a
|
|
53
|
+
// REJECTION; a promise that never settles is not one, so the driver's own
|
|
54
|
+
// fatal-payload path never runs either.
|
|
55
|
+
const r = await runChild(
|
|
56
|
+
'async function main() { await new Promise(() => {}); process.send({ ok: true }); }\n' +
|
|
57
|
+
"main().catch((e) => process.send({ fatal: String(e) }));\n",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(r.code).toBe(0);
|
|
61
|
+
expect(r.message).toBeUndefined();
|
|
62
|
+
expect(r.stderr.trim()).toBe("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a child that sends and falls off the end always gets its message through", async () => {
|
|
66
|
+
// The disproof, kept as a test. If this ever fails, the race the issue
|
|
67
|
+
// proposed is real after all and the diagnostic's wording needs revisiting.
|
|
68
|
+
const results = await Promise.all(
|
|
69
|
+
Array.from({ length: 12 }, () => runChild("process.send({ ok: true });\n")),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
for (const r of results) {
|
|
73
|
+
expect(r.code).toBe(0);
|
|
74
|
+
expect(r.message).toEqual({ ok: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a rejection still reaches the parent, so the fatal path is not what broke", async () => {
|
|
79
|
+
// Establishes the contrast: the driver's catch works. It is specifically an
|
|
80
|
+
// unsettled promise that escapes it.
|
|
81
|
+
const r = await runChild(
|
|
82
|
+
'async function main() { throw new Error("boom"); }\n' +
|
|
83
|
+
"main().catch((e) => process.send({ fatal: String(e) }));\n",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(r.message).toEqual({ fatal: "Error: boom" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a project file with an unsettled top-level await reproduces it end to end", async () => {
|
|
90
|
+
// The run path's real mechanism, not a stand-in. `main()` does
|
|
91
|
+
// `await import(<project file>)` per run-fallback file, module scope is
|
|
92
|
+
// arbitrary project source, and a top level that awaits something which
|
|
93
|
+
// never settles makes that import never complete. Nothing keeps the loop
|
|
94
|
+
// alive, so Node exits 0 having sent nothing.
|
|
95
|
+
//
|
|
96
|
+
// This is what the diagnostic's wording is checked against. Before
|
|
97
|
+
// chant#2461 it said only "child exited before reporting results", which
|
|
98
|
+
// gave a reader nothing to look at.
|
|
99
|
+
const root = mkdtempSync(join(tmpdir(), "chant-tla-"));
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(root, "src", "hangs.ts"),
|
|
104
|
+
"await new Promise<void>(() => {});\nexport const never = { reached: true };\n",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const result = await runFallbackFilesSandboxed([join(root, "src", "hangs.ts")], root);
|
|
108
|
+
|
|
109
|
+
const message = result.errors.map((e) => e.message).join("\n");
|
|
110
|
+
expect(message).toContain("child exited before reporting results (code 0, signal null)");
|
|
111
|
+
|
|
112
|
+
// chant#2461's user-facing half: the child names the file it was
|
|
113
|
+
// importing when its loop drained. It cannot `process.send` from an exit
|
|
114
|
+
// handler — that is asynchronous and the channel will never be serviced —
|
|
115
|
+
// so it writes synchronously to stderr, which the parent captures and
|
|
116
|
+
// forwards. Without this the build dies silently and nothing anywhere
|
|
117
|
+
// says which file.
|
|
118
|
+
expect(message).toContain("never finished evaluating");
|
|
119
|
+
expect(message).toContain("hangs.ts");
|
|
120
|
+
expect(message).toContain("top-level");
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(root, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}, 120_000);
|
|
125
|
+
});
|
|
@@ -125,6 +125,86 @@ function lineBuffered(emit: (line: string) => void) {
|
|
|
125
125
|
* resolve with the first IPC message that satisfies `isResponse` (or reject
|
|
126
126
|
* on crash / timeout / fork error).
|
|
127
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* Why a child ended without a usable result, in terms a reader can act on.
|
|
130
|
+
*
|
|
131
|
+
* chant#2461 — this used to be one sentence, `child exited before reporting
|
|
132
|
+
* results (code N, signal S)`, for three unrelated situations. The one that
|
|
133
|
+
* was observed in the wild is the hardest to read: exit code 0, empty stderr,
|
|
134
|
+
* no message. That reads like the child was cut off, and it was not — it ran
|
|
135
|
+
* to completion and sent nothing.
|
|
136
|
+
*
|
|
137
|
+
* Two mechanisms produce it, and the driver decides which is possible.
|
|
138
|
+
*
|
|
139
|
+
* The first is an `await` that never settles. `main().catch(...)` catches a
|
|
140
|
+
* REJECTION, and a promise that never settles is not one, so if nothing keeps
|
|
141
|
+
* the loop alive Node drains it and exits 0 having sent nothing and written
|
|
142
|
+
* nothing. Nothing else in the process reports that.
|
|
143
|
+
*
|
|
144
|
+
* On the RUN path that is not hypothetical, and it is reproducible: the driver
|
|
145
|
+
* does `await import(<project file>)` for each run-fallback file, module scope
|
|
146
|
+
* is arbitrary project source, and a file whose top level awaits something that
|
|
147
|
+
* never settles makes that import never complete. A fixture doing exactly that
|
|
148
|
+
* yields this error, which is how the wording here was checked rather than
|
|
149
|
+
* guessed.
|
|
150
|
+
*
|
|
151
|
+
* The second is the payload being lost between `process.send` and exit.
|
|
152
|
+
*
|
|
153
|
+
* For the CONFIG driver the first is impossible, which is worth stating because
|
|
154
|
+
* it was the working hypothesis until the bundle was read. Its `await
|
|
155
|
+
* import(configPath)` bundles to `await Promise.resolve().then(() =>
|
|
156
|
+
* (init_chant_config(), chant_config_exports))` — one microtask over
|
|
157
|
+
* synchronous code — and the bundle has no runtime imports, no dynamic
|
|
158
|
+
* `import(`, and exactly one `process.send`. There is nothing there to hang on.
|
|
159
|
+
* So a config child that exits 0 with nothing sent DID send, and the payload
|
|
160
|
+
* did not arrive.
|
|
161
|
+
*
|
|
162
|
+
* The exit/message race originally proposed in chant#2461 is a third thing and
|
|
163
|
+
* is not it: with the parent blocked so a queued payload and the reap were both
|
|
164
|
+
* pending, `message` was dispatched first 60 times out of 60, because the live
|
|
165
|
+
* IPC channel keeps the child alive until the payload flushes.
|
|
166
|
+
*/
|
|
167
|
+
function describeSilentExit(
|
|
168
|
+
label: string,
|
|
169
|
+
code: number | null,
|
|
170
|
+
signal: NodeJS.Signals | null,
|
|
171
|
+
stderrBuf: string,
|
|
172
|
+
unrecognised: readonly unknown[],
|
|
173
|
+
): string {
|
|
174
|
+
const stderr = stderrBuf.trim();
|
|
175
|
+
const head = `${label}: child exited before reporting results (code ${code}, signal ${signal})`;
|
|
176
|
+
|
|
177
|
+
if (unrecognised.length > 0) {
|
|
178
|
+
// It DID send. The parent refused the shape, which is a bug in one of them
|
|
179
|
+
// and not the child dying early.
|
|
180
|
+
const shapes = unrecognised
|
|
181
|
+
.map((m) => (m && typeof m === "object" ? `{${Object.keys(m as object).join(", ")}}` : typeof m))
|
|
182
|
+
.join(", ");
|
|
183
|
+
return (
|
|
184
|
+
`${head}. It sent ${unrecognised.length} message(s) the parent did not recognise (${shapes}), ` +
|
|
185
|
+
`so the payload shape and the parent's check disagree` +
|
|
186
|
+
(stderr ? `: ${stderr}` : "")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (stderr) return `${head}: ${stderr}`;
|
|
191
|
+
if (code !== 0 || signal !== null) return head;
|
|
192
|
+
|
|
193
|
+
// Exit 0, nothing on stderr, nothing sent. The child finished normally and
|
|
194
|
+
// the parent has nothing. Two mechanisms produce exactly this, and which one
|
|
195
|
+
// it is depends on the driver — see this function's doc.
|
|
196
|
+
return (
|
|
197
|
+
`${head}. It exited cleanly with nothing on stderr and sent no message, so the child drained ` +
|
|
198
|
+
`its loop without sending: something it awaited never settled. On the run path the usual ` +
|
|
199
|
+
`cause is a project file with a top-level \`await\` that does not settle — module scope is ` +
|
|
200
|
+
`arbitrary project source, and an import of such a file never completes. A promise that ` +
|
|
201
|
+
`never settles is not a rejection, so the driver's own \`main().catch\` does not see it ` +
|
|
202
|
+
`either, which is why nothing is written anywhere. The config driver bundles to one ` +
|
|
203
|
+
`microtask over synchronous code with no runtime I/O, so on THAT path nothing can hang and ` +
|
|
204
|
+
`the payload was lost between \`process.send\` and exit instead (chant#2461).`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
128
208
|
export function forkSandboxed<T>(
|
|
129
209
|
options: SandboxForkOptions,
|
|
130
210
|
isResponse: (value: unknown) => value is T,
|
|
@@ -144,6 +224,8 @@ export function forkSandboxed<T>(
|
|
|
144
224
|
|
|
145
225
|
let settled = false;
|
|
146
226
|
let stderrBuf = "";
|
|
227
|
+
/** Messages the child sent that `isResponse` refused — see the `message` handler. */
|
|
228
|
+
const unrecognised: unknown[] = [];
|
|
147
229
|
|
|
148
230
|
const timeout = setTimeout(() => {
|
|
149
231
|
if (settled) return;
|
|
@@ -185,7 +267,15 @@ export function forkSandboxed<T>(
|
|
|
185
267
|
child.stderr?.on("end", () => stderrForwarder.flush());
|
|
186
268
|
|
|
187
269
|
child.on("message", (msg: unknown) => {
|
|
188
|
-
if (settled
|
|
270
|
+
if (settled) return;
|
|
271
|
+
if (!isResponse(msg)) {
|
|
272
|
+
// chant#2461 — remember it rather than dropping it. A child that sent
|
|
273
|
+
// something the parent does not recognise is a different failure from
|
|
274
|
+
// a child that sent nothing, and both used to arrive as "exited before
|
|
275
|
+
// reporting results" with no way to tell them apart.
|
|
276
|
+
unrecognised.push(msg);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
189
279
|
settled = true;
|
|
190
280
|
clearTimeout(timeout);
|
|
191
281
|
// chant #1131 — the child's entire job is to send this one message, so
|
|
@@ -212,11 +302,7 @@ export function forkSandboxed<T>(
|
|
|
212
302
|
if (settled) return;
|
|
213
303
|
settled = true;
|
|
214
304
|
clearTimeout(timeout);
|
|
215
|
-
reject(
|
|
216
|
-
new Error(
|
|
217
|
-
`${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
|
|
218
|
-
),
|
|
219
|
-
);
|
|
305
|
+
reject(new Error(describeSilentExit(label, code, signal, stderrBuf, unrecognised)));
|
|
220
306
|
});
|
|
221
307
|
});
|
|
222
308
|
}
|
package/src/fold/subset.ts
CHANGED
|
@@ -210,13 +210,25 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
210
210
|
* binding whatever an invoked factory returns is what chant never checked
|
|
211
211
|
* (`L8.19`), and a declarator reaching a call through a const alias is what
|
|
212
212
|
* chant resolves while refusing a call nested elsewhere (`L2.18`).
|
|
213
|
+
* - **1.7** put `F-Eval-CallLocal` before the two registered call shapes, so a
|
|
214
|
+
* call through a name the project bound is the project function's call
|
|
215
|
+
* whatever the registry says. That is what chant's whole-build fold already
|
|
216
|
+
* did, and the reason the specification moved rather than chant: probes with
|
|
217
|
+
* chant's own `output` showed the binding-first order living in
|
|
218
|
+
* `resolveCallExpression`, while `L2.11` described the expression
|
|
219
|
+
* classifier. The two were never required to share an order.
|
|
220
|
+
* - **1.8** added `ι = executing`, and chant carries it as of `0.72.3`
|
|
221
|
+
* (#2455) — `FoldProjectOptions.executing`, mutually exclusive with
|
|
222
|
+
* `sandbox`. `open` stays the default and stays strict (#2453).
|
|
213
223
|
*
|
|
214
224
|
* `scripts/check-docs-citations.ts` refuses to run when this constant and the
|
|
215
225
|
* pinned specification disagree, so `.github/workflows/docs-check.yml` pins the
|
|
216
|
-
* `spec-1.
|
|
217
|
-
* is what stops the docs being gated against a rule set nobody writes against
|
|
226
|
+
* `spec-1.8` commit and the two move together. That coupling is deliberate: it
|
|
227
|
+
* is what stops the docs being gated against a rule set nobody writes against
|
|
228
|
+
* — which is exactly what happened while this sat at `1.0` through six
|
|
229
|
+
* versions, passing because both sides were equally stale.
|
|
218
230
|
*/
|
|
219
|
-
export const SPEC_VERSION = "1.
|
|
231
|
+
export const SPEC_VERSION = "1.8";
|
|
220
232
|
|
|
221
233
|
/** The two EVL rule ids a shape violation can be attributed to. */
|
|
222
234
|
export type SubsetRuleId = "EVL001" | "EVL003";
|
package/src/observation.ts
CHANGED
|
@@ -57,14 +57,33 @@ export type UnobservedReason =
|
|
|
57
57
|
| "unsupported-kind"
|
|
58
58
|
| "filtered";
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Every legal {@link UnobservedReason}, for validation and conformance checks.
|
|
62
|
+
*
|
|
63
|
+
* Derived from a witness keyed off the union rather than written out beside it
|
|
64
|
+
* (chant#2366). A hand-maintained array is only ever checked for holding legal
|
|
65
|
+
* members, never for holding ALL of them, so a reason added to the type left
|
|
66
|
+
* the array silently short — `tsc` clean, every observation test green, and a
|
|
67
|
+
* value the type permits that `observation-conformance.ts` refuses. A lexicon
|
|
68
|
+
* could construct a value its own suite rejected.
|
|
69
|
+
*
|
|
70
|
+
* Keying a `Record` off the union makes the omission a compile error at the
|
|
71
|
+
* point of the omission. This is the construction #2365 applied to all four
|
|
72
|
+
* closed sets in `./behaviour.ts`; `BEHAVIOUR_UNPREDICTED_REASONS` derives from
|
|
73
|
+
* this very type, so that module was already protected against a change here
|
|
74
|
+
* while this module was not.
|
|
75
|
+
*/
|
|
76
|
+
const UNOBSERVED_REASON_WITNESS: Record<UnobservedReason, true> = {
|
|
77
|
+
"read-failed": true,
|
|
78
|
+
"no-credentials": true,
|
|
79
|
+
"no-binding": true,
|
|
80
|
+
"unsupported-kind": true,
|
|
81
|
+
filtered: true,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const UNOBSERVED_REASONS: readonly UnobservedReason[] = Object.keys(
|
|
85
|
+
UNOBSERVED_REASON_WITNESS,
|
|
86
|
+
) as UnobservedReason[];
|
|
68
87
|
|
|
69
88
|
/** True when `value` is a legal {@link UnobservedReason}. */
|
|
70
89
|
export function isUnobservedReason(value: unknown): value is UnobservedReason {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chant#2337 — a converge tick says whether its record reached the remote.
|
|
5
|
+
*
|
|
6
|
+
* `convergeTick` ended with `await pushLifecycle().catch(() => undefined)`, the
|
|
7
|
+
* last instance of the idiom #2310 was filed about. The append is local-first
|
|
8
|
+
* and always lands, so the tick's own result was correct either way — what was
|
|
9
|
+
* missing is whether anyone else can see it.
|
|
10
|
+
*
|
|
11
|
+
* Softer than the gate's version of the same bug, which #2336 fixed: a lost
|
|
12
|
+
* tick record is an informational log rather than an approval, so nobody is
|
|
13
|
+
* left waiting on a fact they cannot see. It is still not success, and a
|
|
14
|
+
* converge loop reporting a clean tick while its ledger never leaves the
|
|
15
|
+
* machine is telling an operator something untrue.
|
|
16
|
+
*/
|
|
17
|
+
const execMock = vi.fn();
|
|
18
|
+
vi.mock("node:child_process", () => ({
|
|
19
|
+
exec: (cmd: string, _opts: unknown, cb: (e: Error | null, r: { stdout: string; stderr: string }) => void) =>
|
|
20
|
+
cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const pushLifecycle = vi.fn();
|
|
24
|
+
vi.mock("../../lifecycle/git", () => ({
|
|
25
|
+
fetchLifecycle: vi.fn(async () => undefined),
|
|
26
|
+
pushLifecycle: (...args: unknown[]) => pushLifecycle(...args) as Promise<boolean>,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../../lifecycle/converge-ledger", async (orig) => {
|
|
30
|
+
const actual = (await orig()) as Record<string, unknown>;
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
readConvergeLedger: vi.fn(async () => ({ records: [] })),
|
|
34
|
+
appendConvergeRecord: vi.fn(async (record: Record<string, unknown>) => ({
|
|
35
|
+
record: { ...record, id: "tick-1" },
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { convergeTick } = await import("./converge");
|
|
41
|
+
|
|
42
|
+
/** A tick with nothing to do: no drift, no rules, so only the ledger write matters. */
|
|
43
|
+
async function tick(): Promise<Awaited<ReturnType<typeof convergeTick>>> {
|
|
44
|
+
return convergeTick({ opName: "demo", env: "prod", rules: [], dial: "report" } as never);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("a converge tick reports its push outcome (chant#2337)", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
execMock.mockImplementation((cmd: string) =>
|
|
51
|
+
cmd.includes("lifecycle plan")
|
|
52
|
+
? JSON.stringify({ env: "prod", entries: [] })
|
|
53
|
+
: JSON.stringify([]),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a push that lands reports pushed, with no warning", async () => {
|
|
58
|
+
pushLifecycle.mockResolvedValue(true);
|
|
59
|
+
|
|
60
|
+
const result = await tick();
|
|
61
|
+
|
|
62
|
+
expect(result.pushed).toBe(true);
|
|
63
|
+
expect(result.pushWarning).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a REJECTED push is reported, not swallowed", async () => {
|
|
67
|
+
// Red before chant#2337: `.catch(() => undefined)` meant the tick returned
|
|
68
|
+
// the same shape whether or not the record left the machine.
|
|
69
|
+
pushLifecycle.mockRejectedValue(new Error("remote rejected: chant/lifecycle has moved"));
|
|
70
|
+
|
|
71
|
+
const result = await tick();
|
|
72
|
+
|
|
73
|
+
expect(result.pushed).toBe(false);
|
|
74
|
+
expect(result.pushWarning).toContain("remote rejected");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("no remote configured is reported too, and says which it is", async () => {
|
|
78
|
+
// `pushLifecycle` returns false rather than throwing when there is no
|
|
79
|
+
// remote. That is not an error, but it is not a push either, and the two
|
|
80
|
+
// reasons are worth telling apart in the warning.
|
|
81
|
+
pushLifecycle.mockResolvedValue(false);
|
|
82
|
+
|
|
83
|
+
const result = await tick();
|
|
84
|
+
|
|
85
|
+
expect(result.pushed).toBe(false);
|
|
86
|
+
expect(result.pushWarning).toMatch(/no remote/i);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the tick's own findings are unaffected by a failed push", async () => {
|
|
90
|
+
// The append is local-first and always lands. A push failure must not make
|
|
91
|
+
// the tick misreport what it observed, or the fix would have traded one
|
|
92
|
+
// wrong answer for another.
|
|
93
|
+
pushLifecycle.mockRejectedValue(new Error("nope"));
|
|
94
|
+
|
|
95
|
+
const result = await tick();
|
|
96
|
+
|
|
97
|
+
expect(result.id).toBe("tick-1");
|
|
98
|
+
expect(result.drifted).toBe(false);
|
|
99
|
+
expect(typeof result.log).toBe("string");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -104,6 +104,22 @@ export interface ConvergeTickResult {
|
|
|
104
104
|
gated: number;
|
|
105
105
|
/** The one human-readable summary line this tick produced. */
|
|
106
106
|
log: string;
|
|
107
|
+
/**
|
|
108
|
+
* Whether this tick's ledger record reached the remote (chant#2337).
|
|
109
|
+
*
|
|
110
|
+
* The append is local-first and always lands, so the tick's own result is
|
|
111
|
+
* correct either way — this says whether anyone else can see it. Before
|
|
112
|
+
* this, the push was `.catch(() => undefined)` and the tick reported success
|
|
113
|
+
* whether or not the record left the machine, which is the last instance of
|
|
114
|
+
* the idiom #2310 was filed about.
|
|
115
|
+
*
|
|
116
|
+
* Softer than the gate's version of the same bug (#2336): a lost tick record
|
|
117
|
+
* is an informational log rather than an approval, so nobody is stranded
|
|
118
|
+
* waiting on a fact they cannot see. It is still not success.
|
|
119
|
+
*/
|
|
120
|
+
pushed: boolean;
|
|
121
|
+
/** Why the record did not reach the remote, when `pushed` is false. */
|
|
122
|
+
pushWarning?: string;
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
function shellQuote(s: string): string {
|
|
@@ -442,7 +458,19 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
|
|
|
442
458
|
},
|
|
443
459
|
log,
|
|
444
460
|
});
|
|
445
|
-
|
|
461
|
+
// chant#2337 — reported rather than swallowed, the way #2336 does it for the
|
|
462
|
+
// gate. A push that did not land leaves a correct local record nobody else
|
|
463
|
+
// can read, and the caller is the only one positioned to say so.
|
|
464
|
+
let pushed = false;
|
|
465
|
+
let pushWarning: string | undefined;
|
|
466
|
+
try {
|
|
467
|
+
pushed = await pushLifecycle();
|
|
468
|
+
if (!pushed) {
|
|
469
|
+
pushWarning = "no remote is configured for chant/lifecycle — the tick record was recorded locally only";
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
pushWarning = err instanceof Error ? err.message : String(err);
|
|
473
|
+
}
|
|
446
474
|
|
|
447
475
|
return {
|
|
448
476
|
id: record.id,
|
|
@@ -455,5 +483,7 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
|
|
|
455
483
|
unobserved: record.summary.unobserved,
|
|
456
484
|
adopted: record.summary.adopted,
|
|
457
485
|
log: record.log,
|
|
486
|
+
pushed,
|
|
487
|
+
...(pushWarning !== undefined ? { pushWarning } : {}),
|
|
458
488
|
};
|
|
459
489
|
}
|