@intentius/chant 0.72.3 → 0.72.4
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/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.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/discovery/sandbox/fork-diagnostic.test.ts +117 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- package/src/op/activities/converge.ts +31 -1
package/src/cli/mcp/server.ts
CHANGED
|
@@ -17,8 +17,12 @@ import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
|
|
|
17
17
|
* Protocol versions this server understands, newest first. `initialize` and
|
|
18
18
|
* `server/discover` both negotiate against this list rather than assuming
|
|
19
19
|
* the client's revision (#1194).
|
|
20
|
+
*
|
|
21
|
+
* Exported because `docs-parity.test.ts` reads it: `cli/mcp.mdx` states these
|
|
22
|
+
* revisions in prose, and stating them twice is how the page came to claim
|
|
23
|
+
* 2024-11-05 for two releases after this list moved past it (#2385).
|
|
20
24
|
*/
|
|
21
|
-
const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
|
|
25
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
|
|
22
26
|
const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
6
|
+
import { initCommand } from "./commands/init";
|
|
7
|
+
import { doctorCommand } from "./commands/doctor";
|
|
8
|
+
import { updateCommand } from "./commands/update";
|
|
9
|
+
import { parseArgs, commandRegistry } from "./main";
|
|
10
|
+
import { MCP_CONFIG_FILENAME, MCP_SETUP_COMMAND, mcpConfigPath } from "./mcp-config";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* chant #2383. `chant init` used to write `mcp.json` into the user's home
|
|
14
|
+
* directory while `chant doctor` looked for `<project>/.mcp.json` and told
|
|
15
|
+
* you to run `chant agent setup`, a command the registry never had. Three
|
|
16
|
+
* sites, three answers, and a project chant had just scaffolded failed
|
|
17
|
+
* chant's own doctor with an unrunnable fix.
|
|
18
|
+
*
|
|
19
|
+
* These tests are written against the agreement rather than against today's
|
|
20
|
+
* string: the round trip below fails if init and doctor ever pick different
|
|
21
|
+
* paths again, and the registry check fails if the remediation ever names a
|
|
22
|
+
* command that is not registered. Neither can be satisfied by editing one
|
|
23
|
+
* site in isolation.
|
|
24
|
+
*/
|
|
25
|
+
describe("MCP config: one location, three agreeing sites (#2383)", () => {
|
|
26
|
+
test("init then doctor round-trips: mcp-config passes with no manual step", async () => {
|
|
27
|
+
await withTestDir(async (testDir) => {
|
|
28
|
+
const result = await initCommand({
|
|
29
|
+
path: testDir,
|
|
30
|
+
lexicon: "aws",
|
|
31
|
+
skipInstall: true,
|
|
32
|
+
});
|
|
33
|
+
expect(result.success).toBe(true);
|
|
34
|
+
|
|
35
|
+
const report = await doctorCommand(testDir);
|
|
36
|
+
const check = report.checks.find((c) => c.name === "mcp-config");
|
|
37
|
+
expect(check).toBeDefined();
|
|
38
|
+
expect(check!.status).toBe("pass");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("init writes the MCP config inside the project it was pointed at", async () => {
|
|
43
|
+
await withTestDir(async (testDir) => {
|
|
44
|
+
const result = await initCommand({
|
|
45
|
+
path: testDir,
|
|
46
|
+
lexicon: "aws",
|
|
47
|
+
skipInstall: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(existsSync(mcpConfigPath(testDir))).toBe(true);
|
|
51
|
+
expect(result.createdFiles).toContain(MCP_CONFIG_FILENAME);
|
|
52
|
+
|
|
53
|
+
const config = JSON.parse(readFileSync(mcpConfigPath(testDir), "utf-8"));
|
|
54
|
+
expect(config.mcpServers.chant.args).toEqual(["chant", "serve", "mcp"]);
|
|
55
|
+
|
|
56
|
+
// Every path init reports as created is relative to the project. A
|
|
57
|
+
// `~/` or absolute entry means init reached outside the directory it
|
|
58
|
+
// was handed, which is the half of #2383 worth not regressing.
|
|
59
|
+
for (const file of result.createdFiles) {
|
|
60
|
+
expect(file.startsWith("~"), `${file} escapes the project`).toBe(false);
|
|
61
|
+
expect(file.startsWith("/"), `${file} escapes the project`).toBe(false);
|
|
62
|
+
expect(file.includes(homedir()), `${file} escapes the project`).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("--skip-mcp parses and suppresses the write", async () => {
|
|
68
|
+
// The flag was documented and typed long before anything parsed it, so
|
|
69
|
+
// assert the parse and the effect together — either one alone passed
|
|
70
|
+
// while `chant init --skip-mcp` still wrote the file.
|
|
71
|
+
expect(parseArgs(["init", ".", "--lexicon", "aws", "--skip-mcp"]).skipMcp).toBe(true);
|
|
72
|
+
expect(parseArgs(["init", ".", "--lexicon", "aws"]).skipMcp).toBeUndefined();
|
|
73
|
+
|
|
74
|
+
await withTestDir(async (testDir) => {
|
|
75
|
+
const result = await initCommand({
|
|
76
|
+
path: testDir,
|
|
77
|
+
lexicon: "aws",
|
|
78
|
+
skipMcp: true,
|
|
79
|
+
skipInstall: true,
|
|
80
|
+
});
|
|
81
|
+
expect(existsSync(mcpConfigPath(testDir))).toBe(false);
|
|
82
|
+
expect(result.createdFiles).not.toContain(MCP_CONFIG_FILENAME);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the doctor's remediation names a registered command", async () => {
|
|
87
|
+
// Reconstruct the warning the doctor actually emits, then check that the
|
|
88
|
+
// command inside it exists. `chant agent setup` satisfied neither.
|
|
89
|
+
await withTestDir(async (testDir) => {
|
|
90
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
91
|
+
const report = await doctorCommand(testDir);
|
|
92
|
+
const check = report.checks.find((c) => c.name === "mcp-config");
|
|
93
|
+
expect(check!.status).toBe("warn");
|
|
94
|
+
expect(check!.message).toContain(MCP_SETUP_COMMAND);
|
|
95
|
+
|
|
96
|
+
const named = check!.message!.match(/run ([a-z][a-z -]*)/)?.[1].trim();
|
|
97
|
+
expect(named).toBeDefined();
|
|
98
|
+
expect(named!.startsWith("chant ")).toBe(true);
|
|
99
|
+
const registered = commandRegistry.map((c) => c.name);
|
|
100
|
+
expect(registered).toContain(named!.slice("chant ".length));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the remediation command restores a deleted config", async () => {
|
|
105
|
+
await withTestDir(async (testDir) => {
|
|
106
|
+
// A project that has everything except the MCP config: exactly the
|
|
107
|
+
// state the doctor warns about. `chant init` refuses this directory
|
|
108
|
+
// without --force, so the remediation has to be something else.
|
|
109
|
+
mkdirSync(join(testDir, "src"), { recursive: true });
|
|
110
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
111
|
+
writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
|
|
112
|
+
|
|
113
|
+
expect(MCP_SETUP_COMMAND).toBe("chant update");
|
|
114
|
+
const result = await updateCommand({ path: testDir });
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
|
|
117
|
+
const report = await doctorCommand(testDir);
|
|
118
|
+
expect(report.checks.find((c) => c.name === "mcp-config")!.status).toBe("pass");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("update leaves an existing config alone", async () => {
|
|
123
|
+
await withTestDir(async (testDir) => {
|
|
124
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
125
|
+
writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
|
|
126
|
+
const hand = JSON.stringify({ mcpServers: { chant: { command: "custom" } } }, null, 2);
|
|
127
|
+
writeFileSync(mcpConfigPath(testDir), hand);
|
|
128
|
+
|
|
129
|
+
await updateCommand({ path: testDir });
|
|
130
|
+
|
|
131
|
+
expect(readFileSync(mcpConfigPath(testDir), "utf-8")).toBe(hand);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("every chant command a doctor check tells you to run is registered", async () => {
|
|
136
|
+
// Broader than the mcp-config check that prompted #2383: whatever the
|
|
137
|
+
// doctor prints as `run chant <something>`, the registry has to have it.
|
|
138
|
+
// `chant agent setup` shipped as advice for a command that never existed;
|
|
139
|
+
// this fails the moment any check does that again.
|
|
140
|
+
const registered = commandRegistry.map((c) => c.name);
|
|
141
|
+
expect(registered).not.toContain("agent");
|
|
142
|
+
expect(registered).not.toContain("agent setup");
|
|
143
|
+
|
|
144
|
+
const messages: string[] = [];
|
|
145
|
+
await withTestDir(async (empty) => {
|
|
146
|
+
messages.push(...(await doctorCommand(empty)).checks.flatMap((c) => c.message ?? []));
|
|
147
|
+
});
|
|
148
|
+
await withTestDir(async (partial) => {
|
|
149
|
+
writeFileSync(join(partial, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
150
|
+
writeFileSync(join(partial, "package.json"), JSON.stringify({ name: "p" }));
|
|
151
|
+
mkdirSync(join(partial, "src"), { recursive: true });
|
|
152
|
+
messages.push(...(await doctorCommand(partial)).checks.flatMap((c) => c.message ?? []));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const named = messages.flatMap((m) => [...m.matchAll(/run `?(chant [a-z][a-z0-9 -]*)/g)].map((x) => x[1]));
|
|
156
|
+
expect(named.length).toBeGreaterThan(0);
|
|
157
|
+
for (const advice of named) {
|
|
158
|
+
// Drop flags and the trailing prose the regex may have swept up, then
|
|
159
|
+
// keep the longest registered name that prefixes what was advised.
|
|
160
|
+
const words = advice.slice("chant ".length).split(/\s+/).filter((w) => w && !w.startsWith("-"));
|
|
161
|
+
const match = registered.find((name) => {
|
|
162
|
+
const parts = name.split(" ");
|
|
163
|
+
return parts.every((p, i) => words[i] === p);
|
|
164
|
+
});
|
|
165
|
+
expect(match, `doctor advises "${advice}", which no registered command matches`).toBeDefined();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place chant decides where a project's MCP server registration
|
|
6
|
+
* lives, what goes in it, and which command writes it (chant #2383).
|
|
7
|
+
*
|
|
8
|
+
* Before this module the three sites disagreed: `chant init` wrote
|
|
9
|
+
* `mcp.json` into the user's home directory (picking a harness directory
|
|
10
|
+
* that happened to exist), `chant doctor` looked for `<project>/.mcp.json`,
|
|
11
|
+
* and the doctor's remediation named `chant agent setup`, which was never
|
|
12
|
+
* registered in `./main.ts`. A project chant had just scaffolded therefore
|
|
13
|
+
* failed chant's own doctor, and the fix the warning named could not be run.
|
|
14
|
+
*
|
|
15
|
+
* Project scope wins. The file versions with the project it describes, it
|
|
16
|
+
* keeps `chant init <dir>` from writing outside `<dir>`, and it is already
|
|
17
|
+
* what `../agents/discover.ts` treats as the project-scope location when
|
|
18
|
+
* chant audits an agent installation it did not create. Every consumer must
|
|
19
|
+
* go through the constants here rather than rebuilding the path, so the
|
|
20
|
+
* three sites cannot drift apart again without the shared test in
|
|
21
|
+
* ./mcp-config.test.ts noticing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Filename of the project-scoped MCP server registration. */
|
|
25
|
+
export const MCP_CONFIG_FILENAME = ".mcp.json";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The command a user runs to (re)write a missing {@link MCP_CONFIG_FILENAME}
|
|
29
|
+
* in an existing project. `chant init` also writes it, but init refuses a
|
|
30
|
+
* non-empty directory without `--force`, so the remediation an already
|
|
31
|
+
* scaffolded project needs is `chant update` — which is also what the
|
|
32
|
+
* doctor's neighbouring skills check tells you to run for the same reason.
|
|
33
|
+
* Kept as a constant so ./mcp-config.test.ts can assert it is a command
|
|
34
|
+
* `commandRegistry` actually registers.
|
|
35
|
+
*/
|
|
36
|
+
export const MCP_SETUP_COMMAND = "chant update";
|
|
37
|
+
|
|
38
|
+
/** Absolute (or caller-relative) path to a project's MCP config. */
|
|
39
|
+
export function mcpConfigPath(projectDir: string): string {
|
|
40
|
+
return join(projectDir, MCP_CONFIG_FILENAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect whether a project uses bun or npm, from its lock file. Lives here
|
|
45
|
+
* because the package manager is the only variable in the generated config.
|
|
46
|
+
*/
|
|
47
|
+
export function detectPackageManager(dir?: string): "bun" | "npm" {
|
|
48
|
+
if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun";
|
|
49
|
+
return "npm";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The MCP registration chant writes: one stdio server named `chant`. */
|
|
53
|
+
export function generateMcpConfig(pm: "bun" | "npm"): string {
|
|
54
|
+
const config = {
|
|
55
|
+
mcpServers: {
|
|
56
|
+
chant: {
|
|
57
|
+
command: pm === "bun" ? "bunx" : "npx",
|
|
58
|
+
args: ["chant", "serve", "mcp"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return JSON.stringify(config, null, 2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write `<projectDir>/.mcp.json` unless one is already there. Returns the
|
|
68
|
+
* relative path when a file was created, `undefined` when an existing config
|
|
69
|
+
* was left alone — callers report that difference to the user.
|
|
70
|
+
*/
|
|
71
|
+
export function writeProjectMcpConfig(projectDir: string): string | undefined {
|
|
72
|
+
const path = mcpConfigPath(projectDir);
|
|
73
|
+
if (existsSync(path)) return undefined;
|
|
74
|
+
writeFileSync(path, generateMcpConfig(detectPackageManager(projectDir)));
|
|
75
|
+
return MCP_CONFIG_FILENAME;
|
|
76
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -101,6 +101,12 @@ export interface ParsedArgs {
|
|
|
101
101
|
reportFile?: string;
|
|
102
102
|
/** `chant init --skill <name>` filter (added in #95 commit) */
|
|
103
103
|
skill?: string;
|
|
104
|
+
/**
|
|
105
|
+
* `chant init --skip-mcp` (#2383) — scaffold without writing the project's
|
|
106
|
+
* `.mcp.json`. The option existed on `InitOptions` and was documented long
|
|
107
|
+
* before anything parsed it, so there was no way to decline the write.
|
|
108
|
+
*/
|
|
109
|
+
skipMcp?: boolean;
|
|
104
110
|
/** `chant import --type <ResourceType>` selector */
|
|
105
111
|
selectType?: string;
|
|
106
112
|
/** `chant import --name <name>` selector */
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
expect(message).toContain("drained its loop without sending");
|
|
112
|
+
expect(message).toContain("top-level");
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(root, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}, 120_000);
|
|
117
|
+
});
|
|
@@ -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/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
|
+
});
|