@bli-cockpit/cli 0.2.72 → 0.2.75
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/commands/doctor-mcp.js +98 -0
- package/dist/commands/doctor.js +29 -8
- package/dist/commands/local-args-tower-admin.js +12 -1
- package/dist/commands/local-help-commands.js +6 -1
- package/dist/commands/mcp-stdio-probe.js +267 -0
- package/dist/commands/ops-render-coverage.js +62 -0
- package/dist/commands/ops-render.js +20 -2
- package/dist/commands/ops.js +12 -1
- package/dist/commands/public-root.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mcp-answers` check (BLI-3804): does the `bli-tower` server this machine
|
|
3
|
+
* REGISTERED actually answer?
|
|
4
|
+
*
|
|
5
|
+
* `memory-registered` (`doctor-registration.ts`) proves the registration —
|
|
6
|
+
* the entry is in `~/.claude.json` and `~/.codex/config.toml` and the bin it
|
|
7
|
+
* names is on this machine. That is a file-contents check, and it stayed green
|
|
8
|
+
* for the whole life of a server that exited 1 on startup on every machine
|
|
9
|
+
* outside the bli-cockpit repo. Nothing on a laptop ever ASKED the server a
|
|
10
|
+
* question, so nothing could tell the difference between registered and
|
|
11
|
+
* working; five QA ticks recorded "no `mcp__bli-tower__*` in this session" as
|
|
12
|
+
* a client-side mystery.
|
|
13
|
+
*
|
|
14
|
+
* This row asks. It spawns the registered server the way an agent host does
|
|
15
|
+
* and calls the cheapest read on it — `docs_list` with `limit: 1`, the same
|
|
16
|
+
* first call `packages/bli-cockpit-mcp/scripts/smoke.ts` makes — and reports
|
|
17
|
+
* what came back.
|
|
18
|
+
*
|
|
19
|
+
* The three outcomes, and why each is spelled the way it is:
|
|
20
|
+
*
|
|
21
|
+
* ok the door answered. The server starts, speaks the protocol, and
|
|
22
|
+
* reaches Tower with this machine's device token.
|
|
23
|
+
* needs_fix the server answered and the DOOR refused, in its own words. The
|
|
24
|
+
* MCP half is alive; the refusal belongs to Tower and is named
|
|
25
|
+
* verbatim. No fix runs from here — the row reports, and `authed`
|
|
26
|
+
* above it already owns the pairing.
|
|
27
|
+
* fail nothing answered: no process, no protocol, no reply. This is the
|
|
28
|
+
* state that hid for five ticks, so it is a `fail` rather than a
|
|
29
|
+
* `needs_fix` — only a `fail` travels to the fleet's install-event
|
|
30
|
+
* ledger with its reason label (`doctor-report.ts`'s `doctorEvent`
|
|
31
|
+
* maps every other status to `ok`).
|
|
32
|
+
*
|
|
33
|
+
* `skipped` when the bin is not on this machine at all, for the same reason
|
|
34
|
+
* `memory-registered` skips: nothing to probe is not a broken machine.
|
|
35
|
+
*/
|
|
36
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
37
|
+
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
38
|
+
import { resolveMcpBin } from "./mcp-bin-resolve.js";
|
|
39
|
+
import { probeMcpTool, resolveServerEntry } from "./mcp-stdio-probe.js";
|
|
40
|
+
import { TOWER_DASHBOARD_URL_ENV, TOWER_MCP_BIN } from "./tower-mcp-contract.js";
|
|
41
|
+
/** The cheapest read on the server, and the smoke's own first call. */
|
|
42
|
+
export const MCP_ANSWERS_TOOL = "docs_list";
|
|
43
|
+
const MCP_ANSWERS_ARGUMENTS = { limit: 1 };
|
|
44
|
+
/** Where the server's JS lives behind a Windows `.cmd` shim, relative to `.bin`. */
|
|
45
|
+
const WINDOWS_ENTRY_FROM_BIN = ["..", "@bli-cockpit", "mcp", "dist", "index.js"];
|
|
46
|
+
export async function checkMcpAnswersState(context, deps = {}) {
|
|
47
|
+
const resolveBin = deps.resolveBin ?? resolveMcpBin;
|
|
48
|
+
const resolveEntry = deps.resolveEntry ?? resolveServerEntry;
|
|
49
|
+
const probe = deps.probe ?? probeMcpTool;
|
|
50
|
+
const bin = await resolveBin({
|
|
51
|
+
binName: TOWER_MCP_BIN,
|
|
52
|
+
env: envWithNodeRuntimeOnPath(process.env),
|
|
53
|
+
platform: process.platform,
|
|
54
|
+
});
|
|
55
|
+
if (!bin) {
|
|
56
|
+
return skipped("mcp-answers", "bin_missing", `${TOWER_MCP_BIN} is not installed beside this CLI or on PATH; nothing was asked, and the next daily run will try again`);
|
|
57
|
+
}
|
|
58
|
+
const entry = resolveEntry(bin.path, WINDOWS_ENTRY_FROM_BIN);
|
|
59
|
+
if (!entry) {
|
|
60
|
+
return skipped("mcp-answers", "server_entry_not_found", `${TOWER_MCP_BIN} is registered but its server file could not be found behind the shim; nothing was asked`);
|
|
61
|
+
}
|
|
62
|
+
const outcome = await probe({
|
|
63
|
+
entry,
|
|
64
|
+
toolName: MCP_ANSWERS_TOOL,
|
|
65
|
+
toolArguments: MCP_ANSWERS_ARGUMENTS,
|
|
66
|
+
env: probeEnv(context),
|
|
67
|
+
...(deps.timeoutMs === undefined ? {} : { timeoutMs: deps.timeoutMs }),
|
|
68
|
+
});
|
|
69
|
+
return mcpAnswersRow(outcome);
|
|
70
|
+
}
|
|
71
|
+
export function mcpAnswersRow(outcome) {
|
|
72
|
+
if (outcome.status === "answered") {
|
|
73
|
+
return ok("mcp-answers", "answered", `bli-tower answered ${MCP_ANSWERS_TOOL} in ${outcome.ms} ms`);
|
|
74
|
+
}
|
|
75
|
+
if (outcome.status === "refused") {
|
|
76
|
+
return needsFix("mcp-answers", outcome.reason, `bli-tower is alive (it answered in ${outcome.ms} ms) and Tower refused ${MCP_ANSWERS_TOOL}`
|
|
77
|
+
+ ` (${outcome.reason}): ${outcome.said}`);
|
|
78
|
+
}
|
|
79
|
+
return fail("mcp-answers", outcome.reason, `bli-tower is registered but did not answer ${MCP_ANSWERS_TOOL} (${outcome.reason}): ${outcome.said}.`
|
|
80
|
+
+ " No agent session on this machine can reach Tower's tools until it does."
|
|
81
|
+
+ " Take the newest CLI with `cockpit update` (the server ships with it), then"
|
|
82
|
+
+ " re-register with `cockpit memory install`.");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The child gets a URL and a HOME, never a token: the server reads this
|
|
86
|
+
* machine's device token itself, from the same session file `cockpit login`
|
|
87
|
+
* wrote, which is what makes this a probe of the REAL auth path.
|
|
88
|
+
*/
|
|
89
|
+
function probeEnv(context) {
|
|
90
|
+
const base = { ...process.env };
|
|
91
|
+
if (context.command.homeDir) {
|
|
92
|
+
base.HOME = context.command.homeDir;
|
|
93
|
+
base.USERPROFILE = context.command.homeDir;
|
|
94
|
+
}
|
|
95
|
+
if (context.command.dashboardUrl)
|
|
96
|
+
base[TOWER_DASHBOARD_URL_ENV] = context.command.dashboardUrl;
|
|
97
|
+
return base;
|
|
98
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
2
2
|
import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
|
|
3
|
+
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
3
4
|
import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
4
5
|
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
|
|
5
6
|
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
@@ -23,14 +24,8 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
23
24
|
}
|
|
24
25
|
const canFix = Boolean(invariant.fix) &&
|
|
25
26
|
(!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
|
|
26
|
-
if (!canFix) {
|
|
27
|
-
rows.push(checked
|
|
28
|
-
if (checked.hardStop)
|
|
29
|
-
break;
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (!invariant.fix) {
|
|
33
|
-
rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
|
|
27
|
+
if (!canFix || !invariant.fix) {
|
|
28
|
+
rows.push(withoutAFix(checked));
|
|
34
29
|
if (checked.hardStop)
|
|
35
30
|
break;
|
|
36
31
|
continue;
|
|
@@ -52,6 +47,21 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
52
47
|
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
53
48
|
return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
|
|
54
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* What a broken row looks like when nothing can repair it from here.
|
|
52
|
+
*
|
|
53
|
+
* A check that says `needs_fix` and has no fix is still `needs_fix` — the
|
|
54
|
+
* person is being told what to do. A check that says `fail` KEEPS that word
|
|
55
|
+
* (BLI-3804): it used to be quietly rewritten to `needs_fix`, which turned the
|
|
56
|
+
* run green and, because `doctor-report.ts` maps every non-`fail` row to `ok`
|
|
57
|
+
* in the install-event ledger, threw the reason label away on the way to the
|
|
58
|
+
* fleet as well. `hardStop` is untouched and still ends the walk.
|
|
59
|
+
*/
|
|
60
|
+
function withoutAFix(checked) {
|
|
61
|
+
if (checked.hardStop || checked.status === "fail")
|
|
62
|
+
return checked;
|
|
63
|
+
return { ...checked, status: "needs_fix" };
|
|
64
|
+
}
|
|
55
65
|
function doctorInvariants() {
|
|
56
66
|
return [
|
|
57
67
|
{ id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
|
|
@@ -84,6 +94,16 @@ function doctorInvariants() {
|
|
|
84
94
|
check: (context) => context.deps.checkMemory(context),
|
|
85
95
|
fix: (context, _state) => context.deps.fixMemory(context),
|
|
86
96
|
},
|
|
97
|
+
// BLI-3804. Right after the registration row, because it asks the second
|
|
98
|
+
// half of the same question: `memory-registered` proves the entry exists
|
|
99
|
+
// and names a bin, this one proves the server behind it starts, speaks the
|
|
100
|
+
// protocol and reaches Tower. Deliberately has NO fix — re-registering a
|
|
101
|
+
// server that is already registered repairs nothing, and the row names the
|
|
102
|
+
// command a person should run instead (`doctor-mcp.ts`).
|
|
103
|
+
{
|
|
104
|
+
id: "mcp-answers",
|
|
105
|
+
check: (context) => context.deps.checkMcpAnswers(context),
|
|
106
|
+
},
|
|
87
107
|
{
|
|
88
108
|
id: "backfill-complete",
|
|
89
109
|
check: (context) => context.deps.checkBackfill(context),
|
|
@@ -125,6 +145,7 @@ function defaultDoctorDeps(hooks) {
|
|
|
125
145
|
fixAutostart: fixAutostartState,
|
|
126
146
|
checkMemory: checkMemoryState,
|
|
127
147
|
fixMemory: fixMemoryState,
|
|
148
|
+
checkMcpAnswers: (context) => checkMcpAnswersState(context),
|
|
128
149
|
checkBackfill: checkBackfillState,
|
|
129
150
|
fixBackfill: fixBackfillState,
|
|
130
151
|
checkGc: checkGcState,
|
|
@@ -66,6 +66,8 @@ export function parseOpsArgs(args) {
|
|
|
66
66
|
"--skips",
|
|
67
67
|
"--memory",
|
|
68
68
|
"--memory-days",
|
|
69
|
+
// BLI-3798: the coverage half on its own, with the bucket table.
|
|
70
|
+
"--coverage",
|
|
69
71
|
"--person",
|
|
70
72
|
"--dry-run",
|
|
71
73
|
"--json",
|
|
@@ -91,10 +93,19 @@ export function parseOpsArgs(args) {
|
|
|
91
93
|
if (memoryDays !== undefined && !/^[0-9]{1,2}$/u.test(memoryDays)) {
|
|
92
94
|
throw new Error("ops --memory-days takes a whole number of days, 1 to 30.");
|
|
93
95
|
}
|
|
96
|
+
const coverageOnly = values.booleans.has("--coverage");
|
|
97
|
+
const job = optionalNonEmpty(values.flags.get("--job"));
|
|
98
|
+
if (coverageOnly && job !== undefined && job !== "collection-coverage") {
|
|
99
|
+
// Refused rather than silently preferring one: `--coverage --job
|
|
100
|
+
// daily-brief` is somebody asking two different questions in one
|
|
101
|
+
// command, and answering the wrong one is how a board loses trust.
|
|
102
|
+
throw new Error("ops --coverage IS --job collection-coverage. Pass one or the other, not a --job naming a different pipeline.");
|
|
103
|
+
}
|
|
94
104
|
return {
|
|
95
105
|
kind: "ops",
|
|
96
106
|
action: "status",
|
|
97
|
-
job:
|
|
107
|
+
job: coverageOnly ? "collection-coverage" : job,
|
|
108
|
+
coverage: coverageOnly,
|
|
98
109
|
skips: values.booleans.has("--skips"),
|
|
99
110
|
memory: values.booleans.has("--memory") || memoryDays !== undefined,
|
|
100
111
|
...(memoryDays === undefined ? {} : { memoryDays: Number(memoryDays) }),
|
|
@@ -295,7 +295,7 @@ export function localSubcommandHelp(command) {
|
|
|
295
295
|
[
|
|
296
296
|
"ops",
|
|
297
297
|
[
|
|
298
|
-
"Usage: cockpit ops [status [--job <id>] [--skips] [--memory [--memory-days N]] | recompile --person <email|name|id> [--dry-run]] [--json]",
|
|
298
|
+
"Usage: cockpit ops [status [--job <id>] [--coverage] [--skips] [--memory [--memory-days N]] | recompile --person <email|name|id> [--dry-run]] [--json]",
|
|
299
299
|
"",
|
|
300
300
|
" cockpit ops status",
|
|
301
301
|
" One line per scheduled job: when it last produced something, and whether that",
|
|
@@ -306,6 +306,11 @@ export function localSubcommandHelp(command) {
|
|
|
306
306
|
" week rather than a broken cron.",
|
|
307
307
|
" --job <id> asks about one; --skips adds the open Slack and external-sync skips,",
|
|
308
308
|
" grouped by reason. Exits 1 if anything is stale, empty or unreadable.",
|
|
309
|
+
" --coverage is the collection-coverage row on its own, with the BUCKET table:",
|
|
310
|
+
" which bucket the missing sessions are in (staged-not-committed with the upload",
|
|
311
|
+
" ledger's own reason, still pending upload, deliberately withheld, or no row at",
|
|
312
|
+
" all), whose machines they are on, which of their own days, and what to run for",
|
|
313
|
+
" each. That table also prints on the ordinary board whenever anything is missing.",
|
|
309
314
|
" Every machine's line also carries its BLI Memory column — whether the MCP",
|
|
310
315
|
" servers and the recall/save hooks are registered for Claude Code and Codex,",
|
|
311
316
|
" and whether Codex has been told to TRUST its hooks. That column never colours",
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask an MCP server one question over stdio and see whether it answers
|
|
3
|
+
* (BLI-3804).
|
|
4
|
+
*
|
|
5
|
+
* `cockpit doctor`'s `memory-registered` row proves a server is REGISTERED —
|
|
6
|
+
* that the entry exists in `~/.claude.json` and `~/.codex/config.toml` and
|
|
7
|
+
* points at a bin that is on this machine. It cannot prove the server starts,
|
|
8
|
+
* speaks the protocol, or reaches Tower, and for five QA ticks
|
|
9
|
+
* (`docs/reports/jarvis-qa-loop/tick15.md` … `tick19.md`) nothing else did
|
|
10
|
+
* either: the `bli-tower` tools were shipped, published, and never executed
|
|
11
|
+
* end to end. This module is the missing half — it spawns the server the way
|
|
12
|
+
* an agent host does and calls one cheap read.
|
|
13
|
+
*
|
|
14
|
+
* ## Why a hand-written client
|
|
15
|
+
*
|
|
16
|
+
* MCP's stdio transport is newline-delimited JSON-RPC 2.0 and nothing more:
|
|
17
|
+
* `initialize`, `notifications/initialized`, `tools/call`. Writing those three
|
|
18
|
+
* messages costs ~60 lines here, against pulling
|
|
19
|
+
* `@modelcontextprotocol/sdk` into the collector — the package every intern
|
|
20
|
+
* machine installs — for one diagnostic. The collector already declines a
|
|
21
|
+
* dependency for exactly this reason in `agent-door-session.ts`'s sibling on
|
|
22
|
+
* the MCP side.
|
|
23
|
+
*
|
|
24
|
+
* ## Never a shell
|
|
25
|
+
*
|
|
26
|
+
* The registered bin is an npm shim: a symlink to the server's own JS on
|
|
27
|
+
* POSIX, a `.cmd` on Windows. This module spawns `process.execPath` with the
|
|
28
|
+
* server's JS ENTRY — resolved from the shim, never run through it — so no
|
|
29
|
+
* `cmd.exe`, no quoting hazard, and the same code path on both required host
|
|
30
|
+
* families (`commands/editor.ts` refuses a Windows shim for the same reason).
|
|
31
|
+
* When the entry cannot be found behind the shim the probe says so by name
|
|
32
|
+
* rather than guessing.
|
|
33
|
+
*/
|
|
34
|
+
import { spawn } from "node:child_process";
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 25_000;
|
|
38
|
+
/**
|
|
39
|
+
* The server's JS entry behind an npm bin shim.
|
|
40
|
+
*
|
|
41
|
+
* POSIX: `node_modules/.bin/<name>` is a symlink to the file itself, so one
|
|
42
|
+
* realpath is the whole answer. Windows: the shim is a `.cmd` and the entry
|
|
43
|
+
* lives at `<.bin>/../<scope>/<pkg>/dist/index.js`, which the caller names.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveServerEntry(binPath, windowsFallbackRelative, deps = {}) {
|
|
46
|
+
const realpath = deps.realpath ?? defaultRealpath;
|
|
47
|
+
const exists = deps.exists ?? defaultExists;
|
|
48
|
+
const resolved = realpath(binPath);
|
|
49
|
+
if (resolved.endsWith(".js") || resolved.endsWith(".mjs"))
|
|
50
|
+
return resolved;
|
|
51
|
+
const beside = path.resolve(path.dirname(binPath), ...windowsFallbackRelative);
|
|
52
|
+
return exists(beside) ? beside : null;
|
|
53
|
+
}
|
|
54
|
+
/** One `initialize` + one `tools/call`, then the child is killed. */
|
|
55
|
+
export async function probeMcpTool(request) {
|
|
56
|
+
const now = request.now ?? Date.now;
|
|
57
|
+
const started = now();
|
|
58
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
59
|
+
const spawnProcess = request.spawnProcess ?? defaultSpawn;
|
|
60
|
+
const elapsed = () => now() - started;
|
|
61
|
+
let child;
|
|
62
|
+
try {
|
|
63
|
+
child = spawnProcess(process.execPath, [request.entry], { env: request.env });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return { status: "no_answer", ms: elapsed(), reason: "spawn_failed", said: firstLine(errorText(error)) };
|
|
67
|
+
}
|
|
68
|
+
const pending = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* A reply that arrived before anyone asked for it. Nothing in the protocol
|
|
71
|
+
* forbids it and a stream can deliver one synchronously with the write that
|
|
72
|
+
* provoked it, so the answer is held rather than dropped — a dropped reply
|
|
73
|
+
* would read as a silent server, which is the exact verdict this module
|
|
74
|
+
* exists to make trustworthy.
|
|
75
|
+
*/
|
|
76
|
+
const early = new Map();
|
|
77
|
+
let ended = null;
|
|
78
|
+
const endWaiters = [];
|
|
79
|
+
const end = (reason, said) => {
|
|
80
|
+
if (!ended)
|
|
81
|
+
ended = { reason, said };
|
|
82
|
+
for (const wake of endWaiters.splice(0))
|
|
83
|
+
wake();
|
|
84
|
+
};
|
|
85
|
+
child.on("error", ((error) => end("spawn_failed", firstLine(errorText(error)))));
|
|
86
|
+
child.on("exit", ((code) => end("server_exited", `the server exited with code ${code ?? "null"} before answering`)));
|
|
87
|
+
let buffer = "";
|
|
88
|
+
child.stdout?.on("data", (chunk) => {
|
|
89
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
90
|
+
let newline = buffer.indexOf("\n");
|
|
91
|
+
while (newline !== -1) {
|
|
92
|
+
const line = buffer.slice(0, newline).trim();
|
|
93
|
+
buffer = buffer.slice(newline + 1);
|
|
94
|
+
newline = buffer.indexOf("\n");
|
|
95
|
+
if (line === "")
|
|
96
|
+
continue;
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not our protocol. A server that writes prose to stdout has already
|
|
103
|
+
// broken the transport for every real client too, so it is named.
|
|
104
|
+
end("protocol_error", "the server wrote something to stdout that is not JSON-RPC");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const message = parsed;
|
|
108
|
+
const id = typeof message["id"] === "number" ? message["id"] : null;
|
|
109
|
+
if (id === null)
|
|
110
|
+
continue;
|
|
111
|
+
const waiter = pending.get(id);
|
|
112
|
+
if (!waiter) {
|
|
113
|
+
early.set(id, message);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
pending.delete(id);
|
|
117
|
+
waiter(message);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const send = (message) => {
|
|
121
|
+
child.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
122
|
+
};
|
|
123
|
+
const awaitReply = (id) => new Promise((resolve) => {
|
|
124
|
+
const alreadyHere = early.get(id);
|
|
125
|
+
if (alreadyHere) {
|
|
126
|
+
early.delete(id);
|
|
127
|
+
resolve(alreadyHere);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (ended) {
|
|
131
|
+
resolve(null);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
pending.delete(id);
|
|
136
|
+
end("timed_out", `the server did not answer within ${timeoutMs} ms`);
|
|
137
|
+
resolve(null);
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
endWaiters.push(() => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
pending.delete(id);
|
|
143
|
+
resolve(null);
|
|
144
|
+
});
|
|
145
|
+
pending.set(id, (message) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
resolve(message);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
send({
|
|
152
|
+
jsonrpc: "2.0",
|
|
153
|
+
id: 1,
|
|
154
|
+
method: "initialize",
|
|
155
|
+
params: {
|
|
156
|
+
protocolVersion: "2025-06-18",
|
|
157
|
+
capabilities: {},
|
|
158
|
+
clientInfo: { name: "cockpit-doctor", version: "1" },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const initialized = await awaitReply(1);
|
|
162
|
+
if (!initialized)
|
|
163
|
+
return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
|
|
164
|
+
if (initialized["error"]) {
|
|
165
|
+
return {
|
|
166
|
+
status: "no_answer",
|
|
167
|
+
ms: elapsed(),
|
|
168
|
+
reason: "protocol_error",
|
|
169
|
+
said: firstLine(rpcErrorMessage(initialized)),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
173
|
+
send({
|
|
174
|
+
jsonrpc: "2.0",
|
|
175
|
+
id: 2,
|
|
176
|
+
method: "tools/call",
|
|
177
|
+
params: { name: request.toolName, arguments: request.toolArguments },
|
|
178
|
+
});
|
|
179
|
+
const called = await awaitReply(2);
|
|
180
|
+
if (!called)
|
|
181
|
+
return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
|
|
182
|
+
if (called["error"]) {
|
|
183
|
+
return {
|
|
184
|
+
status: "no_answer",
|
|
185
|
+
ms: elapsed(),
|
|
186
|
+
reason: "protocol_error",
|
|
187
|
+
said: firstLine(rpcErrorMessage(called)),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const result = (called["result"] ?? {});
|
|
191
|
+
const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
|
|
192
|
+
if (result.isError !== true)
|
|
193
|
+
return { status: "answered", ms: elapsed(), said };
|
|
194
|
+
return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
try {
|
|
198
|
+
child.stdin?.end();
|
|
199
|
+
child.kill();
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
// The verdict is already decided; a child that will not close changes
|
|
203
|
+
// none of it. Not silent: the reason travels in the caller's log.
|
|
204
|
+
void error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The DOOR's own reason label, lifted from the sentence the MCP server's
|
|
210
|
+
* `tool-result.ts` built. Deliberately not rephrased — that file's header is
|
|
211
|
+
* explicit that a second wording in a second place is a second thing to keep
|
|
212
|
+
* in step. `packages/bli-cockpit-mcp/scripts/smoke.ts` reads the same shapes
|
|
213
|
+
* for the same reason; the two live in packages that do not depend on each
|
|
214
|
+
* other, which is why the reading exists twice and nothing else does.
|
|
215
|
+
*/
|
|
216
|
+
export function refusalReason(said) {
|
|
217
|
+
if (/could not be reached for [^(]*\(/u.test(said))
|
|
218
|
+
return "door_unreachable";
|
|
219
|
+
const refused = /refused [^(]*\(([^)]+)\)/u.exec(said);
|
|
220
|
+
if (refused?.[1])
|
|
221
|
+
return refused[1];
|
|
222
|
+
const unpaired = /not paired with Tower \(([^)]+)\)/u.exec(said);
|
|
223
|
+
if (unpaired?.[1])
|
|
224
|
+
return unpaired[1];
|
|
225
|
+
return "unlabelled_refusal";
|
|
226
|
+
}
|
|
227
|
+
function endedDetail(ended) {
|
|
228
|
+
return ended ?? { reason: "protocol_error", said: "the server stopped answering for an unnamed reason" };
|
|
229
|
+
}
|
|
230
|
+
function rpcErrorMessage(message) {
|
|
231
|
+
const error = message["error"];
|
|
232
|
+
if (error && typeof error === "object") {
|
|
233
|
+
const detail = error;
|
|
234
|
+
if (typeof detail.message === "string")
|
|
235
|
+
return detail.message;
|
|
236
|
+
return `JSON-RPC error ${String(detail.code ?? "?")}`;
|
|
237
|
+
}
|
|
238
|
+
return "JSON-RPC error";
|
|
239
|
+
}
|
|
240
|
+
function defaultSpawn(command, args, options) {
|
|
241
|
+
return spawn(command, args, {
|
|
242
|
+
env: options.env,
|
|
243
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function defaultRealpath(value) {
|
|
247
|
+
try {
|
|
248
|
+
return fs.realpathSync.native(value);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function defaultExists(file) {
|
|
255
|
+
try {
|
|
256
|
+
return fs.statSync(file).isFile();
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function errorText(error) {
|
|
263
|
+
return error instanceof Error ? error.message : String(error);
|
|
264
|
+
}
|
|
265
|
+
function firstLine(value) {
|
|
266
|
+
return (value.split("\n")[0] ?? value).slice(0, 240);
|
|
267
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit ops --coverage` — WHICH sessions never arrived (BLI-3798).
|
|
3
|
+
*
|
|
4
|
+
* The board's COVERAGE block already prints a line per person. What it never
|
|
5
|
+
* printed was the BUCKET table: the reasons were computed on the server, sat
|
|
6
|
+
* on `people[].days[].reasons` in every response, and were never rendered
|
|
7
|
+
* anywhere — so `42 sessions seen and never arrived` was the whole of what a
|
|
8
|
+
* person saw, ten QA ticks running, and reading further meant writing a script
|
|
9
|
+
* against production.
|
|
10
|
+
*
|
|
11
|
+
* Same rule as the rest of this file's family: every sentence in here was
|
|
12
|
+
* written on the SERVER (`lib/ops/coverage-rollup.ts` and
|
|
13
|
+
* `lib/ops/coverage-buckets.ts`) and is printed VERBATIM. This module decides
|
|
14
|
+
* order, indentation and column widths and nothing else. A terminal that
|
|
15
|
+
* composed its own fix sentence would eventually tell somebody to run a
|
|
16
|
+
* command the board does not believe in, and the one that is wrong is always
|
|
17
|
+
* the one somebody is reading.
|
|
18
|
+
*
|
|
19
|
+
* A CLI too old to know this section simply does not print it: the buckets
|
|
20
|
+
* ride the ordinary `coverage` section of the same response, so nothing here
|
|
21
|
+
* is load-bearing for delivery. What is load-bearing is that the SERVER also
|
|
22
|
+
* puts the dominant bucket in the `collection-coverage` row's `detail`, which
|
|
23
|
+
* every version prints when the row is `failing`.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The bucket table, largest first, each with its people and its fix.
|
|
27
|
+
*
|
|
28
|
+
* Printed after the person lines, because "who" comes before "which bucket"
|
|
29
|
+
* when somebody is scanning — but the HEADLINE goes first, since it is the one
|
|
30
|
+
* line that answers the question the row has been failing to answer.
|
|
31
|
+
*/
|
|
32
|
+
export function renderCoverageBuckets(table, dim) {
|
|
33
|
+
if (!table) {
|
|
34
|
+
// Never a silent gap where a section was asked for: an older Tower that
|
|
35
|
+
// does not send the table says so, rather than looking like a clean fleet.
|
|
36
|
+
return [
|
|
37
|
+
"",
|
|
38
|
+
"BUCKETS not sent by this Tower — it predates the coverage bucket table; the per-person lines above are all it knows",
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
const buckets = table.buckets ?? [];
|
|
42
|
+
const lines = ["", `BUCKETS ${table.headline ?? "(no headline)"}`];
|
|
43
|
+
if (buckets.length === 0) {
|
|
44
|
+
lines.push(dim(" nothing is unaccounted for, so there are no buckets to show"));
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
for (const bucket of buckets) {
|
|
48
|
+
lines.push(` ${String(bucket.count ?? 0).padStart(5)} ${bucket.reason ?? "unlabelled"}`);
|
|
49
|
+
for (const person of bucket.people ?? []) {
|
|
50
|
+
const days = (person.days ?? []).join(", ");
|
|
51
|
+
lines.push(dim(` ${String(person.count ?? 0).padStart(4)} ${person.displayName ?? "(person)"}` +
|
|
52
|
+
(days ? ` ${days}` : "")));
|
|
53
|
+
}
|
|
54
|
+
// The fix is NOT dimmed. It is the only line on this board that tells
|
|
55
|
+
// somebody to do something, and a board whose one action is grey is a
|
|
56
|
+
// board that gets scrolled past — which is what ten ticks of `failing`
|
|
57
|
+
// with no named bucket already proved.
|
|
58
|
+
if (bucket.owner)
|
|
59
|
+
lines.push(` fix: ${bucket.owner}`);
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* artifact does and does not prove — because that is exactly the moment
|
|
13
13
|
* somebody is about to conclude something from it.
|
|
14
14
|
*/
|
|
15
|
+
import { renderCoverageBuckets, } from "./ops-render-coverage.js";
|
|
15
16
|
export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
|
|
17
|
+
export { renderCoverageBuckets };
|
|
16
18
|
/** The word a person reads. Short, fixed width, and never a bare colour. */
|
|
17
19
|
export function verdictWord(verdict) {
|
|
18
20
|
switch (verdict) {
|
|
@@ -64,7 +66,15 @@ export function intervalWord(row) {
|
|
|
64
66
|
return `every ${Math.round(hours / 24)}d`;
|
|
65
67
|
return `every ${Math.round(hours)}h`;
|
|
66
68
|
}
|
|
67
|
-
export function renderOpsStatus(payload, dim
|
|
69
|
+
export function renderOpsStatus(payload, dim,
|
|
70
|
+
/**
|
|
71
|
+
* BLI-3798. `alwaysShowBuckets` is what `cockpit ops --coverage` passes: the
|
|
72
|
+
* board hides the table on a clean fleet (it would be nineteen rows of
|
|
73
|
+
* nothing), but somebody who asked for the coverage view specifically is
|
|
74
|
+
* owed the answer "nothing is unaccounted for" out loud rather than an
|
|
75
|
+
* absent section they have to interpret.
|
|
76
|
+
*/
|
|
77
|
+
options = {}) {
|
|
68
78
|
const rows = payload.pipelines ?? [];
|
|
69
79
|
const lines = [];
|
|
70
80
|
const counts = new Map();
|
|
@@ -104,8 +114,16 @@ export function renderOpsStatus(payload, dim) {
|
|
|
104
114
|
}
|
|
105
115
|
if (payload.fleet)
|
|
106
116
|
lines.push(...renderFleet(payload.fleet, dim));
|
|
107
|
-
if (payload.coverage)
|
|
117
|
+
if (payload.coverage) {
|
|
108
118
|
lines.push(...renderCoverage(payload.coverage, dim));
|
|
119
|
+
// BLI-3798. The bucket table rides EVERY board, not only `--coverage`:
|
|
120
|
+
// ten QA ticks of `42 sessions seen and never arrived` is what happens
|
|
121
|
+
// when the reasons are only reachable behind a flag nobody knew about.
|
|
122
|
+
// Skipped entirely on a clean fleet, so it costs a healthy board nothing.
|
|
123
|
+
if (options.alwaysShowBuckets || (payload.coverage.counts?.gapSessions ?? 0) > 0) {
|
|
124
|
+
lines.push(...renderCoverageBuckets(payload.coverage.buckets, dim));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
109
127
|
const slack = payload.skips?.slack;
|
|
110
128
|
const external = payload.skips?.external;
|
|
111
129
|
if (slack || external) {
|
package/dist/commands/ops.js
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* the command, and it does not pass silently either: it prints as `n/a` with
|
|
17
17
|
* the reason there is nothing to read.
|
|
18
18
|
*
|
|
19
|
+
* `ops status --coverage` (BLI-3798) is the same door narrowed to the
|
|
20
|
+
* collection-coverage row, with the BUCKET table under it: which bucket the
|
|
21
|
+
* missing sessions are in, whose machines they are on, which of their own days,
|
|
22
|
+
* and one fix sentence per bucket. It exists because the board said
|
|
23
|
+
* `42 sessions seen and never arrived` for ten consecutive QA ticks while the
|
|
24
|
+
* reasons sat one field deeper in the same response, and reading them meant
|
|
25
|
+
* writing a script against production. The table also rides the ordinary board
|
|
26
|
+
* whenever anything is missing, so nobody has to know this flag exists.
|
|
27
|
+
*
|
|
19
28
|
* `ops recompile` spends a real provider call, so it says what it is about to
|
|
20
29
|
* do and reports the ceiling by name. A run that outlives Tower's own 800-second
|
|
21
30
|
* budget produces no response at all, which is indistinguishable from a network
|
|
@@ -83,7 +92,9 @@ async function runOpsStatus(command, io, tower) {
|
|
|
83
92
|
}
|
|
84
93
|
else {
|
|
85
94
|
const styled = colorEnabled(io);
|
|
86
|
-
for (const line of renderOpsStatus(payload, (text) => dim(text, styled)
|
|
95
|
+
for (const line of renderOpsStatus(payload, (text) => dim(text, styled), {
|
|
96
|
+
alwaysShowBuckets: command.coverage === true,
|
|
97
|
+
})) {
|
|
87
98
|
writeLine(io.stdout, line);
|
|
88
99
|
}
|
|
89
100
|
if (memory?.section) {
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.75");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.75",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
|
|
27
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.11",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.13",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.32"
|
|
33
33
|
}
|
|
34
34
|
}
|