@bli-cockpit/cli 0.2.68 → 0.2.70
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/autostart-darwin-plist.js +15 -1
- package/dist/commands/autostart-reading.js +173 -0
- package/dist/commands/doctor-registration.js +25 -29
- package/dist/commands/heartbeat.js +29 -1
- package/dist/commands/memory-hook-counts.js +61 -0
- package/dist/commands/ops-render-memory.js +34 -0
- package/dist/commands/ops-render.js +1 -1
- package/dist/commands/ops.js +24 -6
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup-receipt.js +25 -44
- package/package.json +4 -4
|
@@ -81,8 +81,22 @@ export async function writeDarwinAutostartPlist(options) {
|
|
|
81
81
|
*/
|
|
82
82
|
export async function darwinAgentProblems(options, settings) {
|
|
83
83
|
const homeDir = options.homeDir ?? os.homedir();
|
|
84
|
-
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
85
84
|
const plist = await readFile(plistPathFor(homeDir), "utf8").catch(() => "");
|
|
85
|
+
if (!plist)
|
|
86
|
+
return ["plist could not be read"];
|
|
87
|
+
// A caller that named no roots gave us nothing to compare against:
|
|
88
|
+
// `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`, so the
|
|
89
|
+
// comparison below would reject a perfectly good plist for naming a
|
|
90
|
+
// workspace nobody asked us to expect (BLI-3793 — that is how the setup
|
|
91
|
+
// receipt printed `autostart_not_loaded` beside a green doctor row). The
|
|
92
|
+
// Windows arm has refused this comparison since it was written
|
|
93
|
+
// (`syncScriptProblem`); macOS refuses it now too. Whether launchd HAS the
|
|
94
|
+
// agent is still answered — by `launchctl list`, in the sibling module.
|
|
95
|
+
if (!options.repoRoots || options.repoRoots.length === 0) {
|
|
96
|
+
console.error("[autostart] registration not compared against expectations", JSON.stringify({ reason: "no_roots_supplied", label: AUTOSTART_LABEL }));
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
86
100
|
return darwinAgentRegistrationProblems(plist, {
|
|
87
101
|
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
88
102
|
workDirs: settings.work_dirs,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { autostartStatus, registeredRuntimePathProblems, } from "../autostart.js";
|
|
2
|
+
import { redactedHealthDetail } from "../health-detail.js";
|
|
3
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
4
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
5
|
+
/**
|
|
6
|
+
* "Is background collection actually registered and running on this host?" —
|
|
7
|
+
* asked ONCE, here (BLI-3793).
|
|
8
|
+
*
|
|
9
|
+
* Two surfaces print that answer: doctor's `autostart-alive` row and the setup
|
|
10
|
+
* receipt's `collector autostart` word. Until this module they asked the same
|
|
11
|
+
* function DIFFERENTLY, and so printed opposite answers in one run:
|
|
12
|
+
*
|
|
13
|
+
* ✅ autostart-alive background sync is running
|
|
14
|
+
* collector autostart ✗ (autostart_not_loaded) — Run `cockpit autostart install`.
|
|
15
|
+
*
|
|
16
|
+
* The doctor row handed `autostartStatus` the machine's saved collection
|
|
17
|
+
* roots; the receipt handed it `{homeDir, exec}` and nothing else. With no
|
|
18
|
+
* roots, `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`,
|
|
19
|
+
* the launchd read-back compares the plist against a registration nobody ever
|
|
20
|
+
* asked for, the comparison fails, and a perfectly loaded agent reads
|
|
21
|
+
* `not_loaded`. The receipt therefore said "✓" only when the operator happened
|
|
22
|
+
* to be standing in their own collection root. Proven on the reference Mac:
|
|
23
|
+
* the same plist read `loaded` with roots passed and `not_loaded` without,
|
|
24
|
+
* flipping back to `loaded` when the process chdir'd into the saved root.
|
|
25
|
+
*
|
|
26
|
+
* So: one reader, one state, ONE reason label. `autostartDoctorRow` and
|
|
27
|
+
* `autostartSetupPiece` below only choose words for a state this file already
|
|
28
|
+
* decided — they never re-ask the host. Both host families go through the same
|
|
29
|
+
* `autostartStatus` front door, so the Windows probe keeps its own
|
|
30
|
+
* implementation behind this same contract.
|
|
31
|
+
*/
|
|
32
|
+
const TAG = "[autostart reading]";
|
|
33
|
+
/**
|
|
34
|
+
* The machine's approved collection roots as the SCHEDULER should have them:
|
|
35
|
+
* an explicit `--workspace` when one was given, otherwise what the config on
|
|
36
|
+
* this host saved. Honours `homeDir` so a `--home` run reads the same machine
|
|
37
|
+
* its receipt reads.
|
|
38
|
+
*/
|
|
39
|
+
export async function autostartRegistrationRoots(homeDir, repoRoot) {
|
|
40
|
+
if (repoRoot)
|
|
41
|
+
return [repoRoot];
|
|
42
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
43
|
+
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
44
|
+
}
|
|
45
|
+
/** The ONE read of the host's scheduler. Never throws; an unaskable host is
|
|
46
|
+
* `unreadable` with a reason, never "absent" (BLI-2541: never report a state
|
|
47
|
+
* you did not observe). */
|
|
48
|
+
export async function readAutostartRegistration(options) {
|
|
49
|
+
const exec = options.exec;
|
|
50
|
+
if (!exec) {
|
|
51
|
+
return {
|
|
52
|
+
state: "unreadable",
|
|
53
|
+
reason: "autostart_runner_unavailable",
|
|
54
|
+
message: "the operating-system scheduler could not be asked: no process runner on this run",
|
|
55
|
+
roots: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const roots = await autostartRegistrationRoots(options.homeDir, options.repoRoot).catch(() => []);
|
|
59
|
+
const result = await autostartStatus({
|
|
60
|
+
exec,
|
|
61
|
+
repoRoots: roots,
|
|
62
|
+
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
63
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
64
|
+
...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
|
|
65
|
+
...(options.platform ? { platform: options.platform } : {}),
|
|
66
|
+
}).catch((error) => {
|
|
67
|
+
console.error(`${TAG} scheduler probe threw`, JSON.stringify({
|
|
68
|
+
reason: "autostart_probe_failed",
|
|
69
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
70
|
+
}));
|
|
71
|
+
return null;
|
|
72
|
+
});
|
|
73
|
+
const reading = await stateFor(result, roots, options);
|
|
74
|
+
console.error(`${TAG} read back`, JSON.stringify({
|
|
75
|
+
reason: reading.reason,
|
|
76
|
+
state: reading.state,
|
|
77
|
+
root_count: reading.roots.length,
|
|
78
|
+
platform: options.platform ?? process.platform,
|
|
79
|
+
}));
|
|
80
|
+
return reading;
|
|
81
|
+
}
|
|
82
|
+
async function stateFor(result, roots, options) {
|
|
83
|
+
if (!result) {
|
|
84
|
+
return {
|
|
85
|
+
state: "unreadable",
|
|
86
|
+
reason: "autostart_probe_failed",
|
|
87
|
+
message: "the operating-system scheduler could not be read this run",
|
|
88
|
+
roots,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const detail = result.message
|
|
92
|
+
? { detail: redactedHealthDetail(result.message) }
|
|
93
|
+
: {};
|
|
94
|
+
if (result.status === "unsupported") {
|
|
95
|
+
return {
|
|
96
|
+
state: "unsupported",
|
|
97
|
+
reason: "platform_unsupported",
|
|
98
|
+
message: result.message ?? "autostart is not supported on this host",
|
|
99
|
+
roots,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (result.status === "absent" || result.status === "uninstalled") {
|
|
103
|
+
return {
|
|
104
|
+
state: "absent",
|
|
105
|
+
reason: "autostart_absent",
|
|
106
|
+
message: "background sync is not installed on this machine",
|
|
107
|
+
roots,
|
|
108
|
+
...detail,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (result.status === "not_loaded") {
|
|
112
|
+
return {
|
|
113
|
+
state: "not_loaded",
|
|
114
|
+
reason: "autostart_not_loaded",
|
|
115
|
+
message: "background sync is not running",
|
|
116
|
+
roots,
|
|
117
|
+
...detail,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// BLI-3553: "loaded" only means the scheduler accepted the registration. It
|
|
121
|
+
// says nothing about whether the binary that registration names still exists
|
|
122
|
+
// — and `brew upgrade node` deletes exactly that. Ask the filesystem about
|
|
123
|
+
// the paths the PLATFORM holds, not the ones this process runs under.
|
|
124
|
+
const missing = options.exec
|
|
125
|
+
? await registeredRuntimePathProblems({
|
|
126
|
+
exec: options.exec,
|
|
127
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
128
|
+
...(options.platform ? { platform: options.platform } : {}),
|
|
129
|
+
}).catch(() => [])
|
|
130
|
+
: [];
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
state: "runtime_path_missing",
|
|
134
|
+
reason: "autostart_runtime_path_missing",
|
|
135
|
+
message: `background sync is registered but cannot run: ${missing.join("; ")}`,
|
|
136
|
+
roots,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
state: "loaded",
|
|
141
|
+
reason: "autostart_loaded",
|
|
142
|
+
message: "background sync is running",
|
|
143
|
+
roots,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The setup receipt's `collector autostart` word (BLI-3731).
|
|
148
|
+
*
|
|
149
|
+
* `ok` only when the host says it is registered AND loaded AND the runtime it
|
|
150
|
+
* names still exists. A plist nobody loaded runs nothing, which is exactly the
|
|
151
|
+
* failure that looks fine from the outside.
|
|
152
|
+
*/
|
|
153
|
+
export function autostartSetupPiece(reading) {
|
|
154
|
+
switch (reading.state) {
|
|
155
|
+
case "loaded":
|
|
156
|
+
return { status: "ok", reason: reading.reason };
|
|
157
|
+
case "unsupported":
|
|
158
|
+
return { status: "skipped", reason: reading.reason };
|
|
159
|
+
case "unreadable":
|
|
160
|
+
return { status: "unknown", reason: reading.reason };
|
|
161
|
+
default:
|
|
162
|
+
return { status: "missing", reason: reading.reason };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Doctor's `autostart-alive` row status for the same reading. The `code` a
|
|
166
|
+
* caller pairs with this is `reading.reason` — there is no second vocabulary. */
|
|
167
|
+
export function autostartDoctorStatus(reading) {
|
|
168
|
+
if (reading.state === "loaded")
|
|
169
|
+
return "ok";
|
|
170
|
+
if (reading.state === "unsupported")
|
|
171
|
+
return "skipped";
|
|
172
|
+
return "needs_fix";
|
|
173
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { installAutostartAgent } from "../autostart.js";
|
|
2
2
|
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
3
|
-
import {
|
|
3
|
+
import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
|
|
4
4
|
import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
|
|
5
5
|
/**
|
|
6
6
|
* The `autostart-alive` and `memory-registered` check family: registrations
|
|
@@ -8,44 +8,40 @@ import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-in
|
|
|
8
8
|
* background sync scheduler and BLI Memory's MCP/hook wiring. Both fixes
|
|
9
9
|
* write host configuration only; neither installs software.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* The row and the setup receipt's `collector autostart` word are the SAME
|
|
13
|
+
* reading now (BLI-3793) — `autostart-reading.ts` asks the host once and hands
|
|
14
|
+
* back one state and one reason label. This function only chooses the row
|
|
15
|
+
* status for it; it does not decide anything about the machine.
|
|
16
|
+
*/
|
|
11
17
|
export async function checkAutostartState(context) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
const roots = await doctorRoots(context);
|
|
17
|
-
const result = await autostartStatus({
|
|
18
|
-
repoRoot: roots[0],
|
|
19
|
-
repoRoots: roots,
|
|
18
|
+
const reading = await readAutostartRegistration({
|
|
19
|
+
exec: context.io.exec,
|
|
20
|
+
homeDir: context.command.homeDir,
|
|
20
21
|
dashboardUrl: context.command.dashboardUrl,
|
|
21
|
-
|
|
22
|
+
repoRoot: context.command.repoRoot,
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// happens to be running under.
|
|
29
|
-
const missing = await registeredRuntimePathProblems({ exec });
|
|
30
|
-
if (missing.length > 0) {
|
|
31
|
-
return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
|
|
32
|
-
}
|
|
33
|
-
return ok("autostart-alive", "already_installed", "background sync is running");
|
|
34
|
-
}
|
|
35
|
-
if (result.status === "unsupported") {
|
|
36
|
-
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
24
|
+
const status = autostartDoctorStatus(reading);
|
|
25
|
+
if (status === "ok")
|
|
26
|
+
return ok("autostart-alive", reading.reason, reading.message);
|
|
27
|
+
if (status === "skipped") {
|
|
28
|
+
return skipped("autostart-alive", reading.reason, reading.message);
|
|
37
29
|
}
|
|
38
|
-
return needsFix("autostart-alive",
|
|
30
|
+
return needsFix("autostart-alive", reading.reason, reading.message);
|
|
39
31
|
}
|
|
40
32
|
export async function fixAutostartState(context) {
|
|
41
33
|
const exec = context.io.exec;
|
|
42
34
|
if (!exec) {
|
|
43
35
|
return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
|
|
44
36
|
}
|
|
45
|
-
|
|
37
|
+
// The same roots the CHECK read against, resolved by the same function — a
|
|
38
|
+
// fix that registers a different boundary than the check inspected would
|
|
39
|
+
// leave the row red forever (BLI-3793).
|
|
40
|
+
const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
|
|
46
41
|
const result = await installAutostartAgent({
|
|
47
|
-
repoRoot:
|
|
48
|
-
repoRoots:
|
|
42
|
+
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
43
|
+
repoRoots: roots,
|
|
44
|
+
...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
|
|
49
45
|
dashboardUrl: context.command.dashboardUrl,
|
|
50
46
|
exec,
|
|
51
47
|
});
|
|
@@ -29,6 +29,7 @@ import { describeError } from "../health-detail.js";
|
|
|
29
29
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
30
30
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
31
31
|
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
32
|
+
import { readMemoryHookCounts } from "./memory-hook-counts.js";
|
|
32
33
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
33
34
|
/**
|
|
34
35
|
* The label form of one approved root.
|
|
@@ -123,6 +124,13 @@ export function buildCollectorHeartbeat(options) {
|
|
|
123
124
|
* reads and a `--print-config` spawn every fifteen minutes to re-prove a state
|
|
124
125
|
* that changes about once a month. The reason is returned rather than logged
|
|
125
126
|
* here so the ONE heartbeat log line carries it.
|
|
127
|
+
*
|
|
128
|
+
* BLI-3788: the hook COUNTS are merged in here, on the tick, and deliberately
|
|
129
|
+
* not baked into the cached receipt. The five words change about monthly; how
|
|
130
|
+
* often the prompt hook lost its deadline changes every hour, and a number
|
|
131
|
+
* cached for a day would answer yesterday's question. An install receipt this
|
|
132
|
+
* machine could not read means no counts either — there is nowhere to put
|
|
133
|
+
* them — and that case keeps the receipt's own reason.
|
|
126
134
|
*/
|
|
127
135
|
export async function readHeartbeatMemoryReceipt(options) {
|
|
128
136
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
@@ -142,7 +150,20 @@ export async function readHeartbeatMemoryReceipt(options) {
|
|
|
142
150
|
return parsed.success ? parsed.data : null;
|
|
143
151
|
},
|
|
144
152
|
});
|
|
145
|
-
|
|
153
|
+
if (!result.receipt)
|
|
154
|
+
return { receipt: null, reason: result.reason };
|
|
155
|
+
const hooks = readMemoryHookCounts({
|
|
156
|
+
homeDir: options.homeDir ?? os.homedir(),
|
|
157
|
+
...(options.now ? { now: options.now } : {}),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
receipt: {
|
|
161
|
+
...result.receipt,
|
|
162
|
+
...(hooks.counts ?? {}),
|
|
163
|
+
hook_stats_reason: hooks.reason,
|
|
164
|
+
},
|
|
165
|
+
reason: result.reason,
|
|
166
|
+
};
|
|
146
167
|
}
|
|
147
168
|
/**
|
|
148
169
|
* Sends the heartbeat. Returns whether it landed; never throws.
|
|
@@ -248,6 +269,13 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
248
269
|
// sync.err.log alone.
|
|
249
270
|
memory_receipt: memory.reason,
|
|
250
271
|
memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
|
|
272
|
+
// BLI-3788. The hooks are registered (above) AND they either worked or
|
|
273
|
+
// did not (here). A prompt hook that misses its deadline prints
|
|
274
|
+
// nothing to the person, so these counts are the only trace one leaves
|
|
275
|
+
// on this machine; `null` means no counter file, never zero misses.
|
|
276
|
+
memory_hook_runs_24h: memory.receipt?.hook_runs_24h ?? null,
|
|
277
|
+
memory_hook_timeouts_24h: memory.receipt?.hook_timeouts_24h ?? null,
|
|
278
|
+
memory_hook_stats: memory.receipt?.hook_stats_reason ?? null,
|
|
251
279
|
}));
|
|
252
280
|
return true;
|
|
253
281
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW OFTEN DID THE HOOKS MISS? — the reading half (BLI-3788).
|
|
3
|
+
*
|
|
4
|
+
* `bli-memory-mcp` counts every hook run into a counts-only file in this
|
|
5
|
+
* machine's state directory. This module reads it once a sync tick and hands
|
|
6
|
+
* the last 24 hours to the heartbeat, where it rides the memory receipt into
|
|
7
|
+
* `ambient_collector_devices.metadata.memory_install` and out onto `cockpit
|
|
8
|
+
* ops --memory`.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows the file's shape: the path, the schema and the windowing
|
|
11
|
+
* rule are `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, which the
|
|
12
|
+
* writing package spends too. That is the whole point of putting them there.
|
|
13
|
+
*
|
|
14
|
+
* ## Which event, and why only one
|
|
15
|
+
*
|
|
16
|
+
* The PROMPT hook. It is the one a person waits on with their sentence typed,
|
|
17
|
+
* the one whose budget QA tick 18 caught it losing, and the one that runs on
|
|
18
|
+
* every turn — so it is the only one whose miss rate means anything as a
|
|
19
|
+
* daily number. The file holds all three; a future gauge that wants
|
|
20
|
+
* SessionStart or Stop reads the same rows with the same function.
|
|
21
|
+
*
|
|
22
|
+
* ## Absent is not zero
|
|
23
|
+
*
|
|
24
|
+
* A machine with no file has no counts and says `hook_stats_absent`. A machine
|
|
25
|
+
* whose file will not parse says `hook_stats_unparseable`. Neither reports
|
|
26
|
+
* zeroes, because a zero here would read as "the hooks ran and never missed",
|
|
27
|
+
* which is the exact false green this ticket exists to remove.
|
|
28
|
+
*/
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import { memoryHookStatsFilePath, parseMemoryHookStats, summariseMemoryHookWindow, } from "@bli-cockpit/telemetry-core";
|
|
31
|
+
export function readMemoryHookCounts(options) {
|
|
32
|
+
const readText = options.readText ?? defaultReadText;
|
|
33
|
+
const file = memoryHookStatsFilePath(options.homeDir);
|
|
34
|
+
const raw = readText(file);
|
|
35
|
+
if (raw === null)
|
|
36
|
+
return { counts: null, reason: "hook_stats_absent" };
|
|
37
|
+
const parsed = parseMemoryHookStats(raw);
|
|
38
|
+
if (!parsed.ok)
|
|
39
|
+
return { counts: null, reason: parsed.reason };
|
|
40
|
+
const window = summariseMemoryHookWindow(parsed.file, "prompt", {
|
|
41
|
+
now: options.now ?? new Date(),
|
|
42
|
+
hours: 24,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
counts: {
|
|
46
|
+
hook_runs_24h: window.runs,
|
|
47
|
+
hook_timeouts_24h: window.timeouts,
|
|
48
|
+
hook_printed_24h: window.printed,
|
|
49
|
+
hook_failed_24h: window.failed,
|
|
50
|
+
},
|
|
51
|
+
reason: "ok",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function defaultReadText(file) {
|
|
55
|
+
try {
|
|
56
|
+
return fs.readFileSync(file, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -70,6 +70,40 @@ export function renderMemoryUsage(memory, dim) {
|
|
|
70
70
|
}
|
|
71
71
|
return lines;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The hook half: one line per machine, the server's own words.
|
|
75
|
+
*
|
|
76
|
+
* A machine that reports NO counts is printed too, dimmed and named. It is the
|
|
77
|
+
* whole point: "no measurement" and "no misses" are different facts, and the
|
|
78
|
+
* only reason this section exists is that they used to be indistinguishable.
|
|
79
|
+
*/
|
|
80
|
+
export function renderMemoryHooks(hooks, dim) {
|
|
81
|
+
const lines = ["", `HOOKS ${hooks.summary ?? "(no summary)"}`];
|
|
82
|
+
if (hooks.readError) {
|
|
83
|
+
lines.push(` the devices could not be read (${hooks.readError}); nothing is known about whether recalls are arriving`);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
const devices = hooks.devices ?? [];
|
|
87
|
+
if (devices.length === 0) {
|
|
88
|
+
lines.push(dim(" no live collector device to report on"));
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
// Worst first: the machine losing recalls is the one somebody acts on, and a
|
|
92
|
+
// machine that reports nothing sinks below both — it is a rollout gap, not a
|
|
93
|
+
// failure.
|
|
94
|
+
const ranked = [...devices].sort((left, right) => {
|
|
95
|
+
const leftKnown = typeof left.runs === "number" ? 0 : 1;
|
|
96
|
+
const rightKnown = typeof right.runs === "number" ? 0 : 1;
|
|
97
|
+
if (leftKnown !== rightKnown)
|
|
98
|
+
return leftKnown - rightKnown;
|
|
99
|
+
return (right.timeouts ?? 0) - (left.timeouts ?? 0);
|
|
100
|
+
});
|
|
101
|
+
for (const device of ranked) {
|
|
102
|
+
const line = ` ${device.line ?? `${device.displayName ?? "?"}: (no line)`}`;
|
|
103
|
+
lines.push((device.timeouts ?? 0) > 0 ? line : dim(line));
|
|
104
|
+
}
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
73
107
|
/** The two sources that mean a PERSON's agent used memory. Never the import. */
|
|
74
108
|
function agentSaves(counts) {
|
|
75
109
|
return (counts?.mcp ?? 0) + (counts?.stop_hook ?? 0);
|
|
@@ -12,7 +12,7 @@
|
|
|
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
|
-
export { renderMemoryUsage } from "./ops-render-memory.js";
|
|
15
|
+
export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
|
|
16
16
|
/** The word a person reads. Short, fixed width, and never a bare colour. */
|
|
17
17
|
export function verdictWord(verdict) {
|
|
18
18
|
switch (verdict) {
|
package/dist/commands/ops.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { colorEnabled, dim, writeLine } from "./cli-io.js";
|
|
27
27
|
import { renderOpsStatus } from "./ops-render.js";
|
|
28
|
-
import { renderMemoryUsage, } from "./ops-render-memory.js";
|
|
28
|
+
import { renderMemoryHooks, renderMemoryUsage, } from "./ops-render-memory.js";
|
|
29
29
|
import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
|
|
30
30
|
/**
|
|
31
31
|
* A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
|
|
@@ -79,7 +79,7 @@ async function runOpsStatus(command, io, tower) {
|
|
|
79
79
|
// BLI-3762: a job that ran and wrote less than it owed is not healthy.
|
|
80
80
|
row.verdict === "degraded");
|
|
81
81
|
if (command.json) {
|
|
82
|
-
writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section } : payload));
|
|
82
|
+
writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section, hooks: memory.hooks } : payload));
|
|
83
83
|
}
|
|
84
84
|
else {
|
|
85
85
|
const styled = colorEnabled(io);
|
|
@@ -91,7 +91,16 @@ async function runOpsStatus(command, io, tower) {
|
|
|
91
91
|
writeLine(io.stdout, line);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
// BLI-3788. Printed whenever the door sent it, including when the usage
|
|
95
|
+
// half could not be read: "are the recalls arriving" and "is anybody
|
|
96
|
+
// saving" are two questions, and one being unreadable does not silence
|
|
97
|
+
// the other.
|
|
98
|
+
if (memory?.hooks) {
|
|
99
|
+
for (const line of renderMemoryHooks(memory.hooks, (text) => dim(text, styled))) {
|
|
100
|
+
writeLine(io.stdout, line);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (memory && !memory.section) {
|
|
95
104
|
// Never a silent gap where a section was asked for: the reason the gauge
|
|
96
105
|
// could not be read is printed where the gauge would have been.
|
|
97
106
|
writeLine(io.stdout, "");
|
|
@@ -119,6 +128,12 @@ async function runOpsStatus(command, io, tower) {
|
|
|
119
128
|
memory_agent_saves: memory?.section
|
|
120
129
|
? (memory.section.counts?.mcp ?? 0) + (memory.section.counts?.stop_hook ?? 0)
|
|
121
130
|
: null,
|
|
131
|
+
// BLI-3788: whether the per-turn recalls arrived, on the same line. A
|
|
132
|
+
// null here is "this dashboard does not report it yet", never zero
|
|
133
|
+
// misses.
|
|
134
|
+
memory_hook_runs_24h: memory?.hooks?.totals?.runs ?? null,
|
|
135
|
+
memory_hook_timeouts_24h: memory?.hooks?.totals?.timeouts ?? null,
|
|
136
|
+
memory_hook_devices_reporting: memory?.hooks?.reporting ?? null,
|
|
122
137
|
})}`);
|
|
123
138
|
return unhealthy.length > 0 ? 1 : 0;
|
|
124
139
|
}
|
|
@@ -144,12 +159,15 @@ async function readMemoryUsage(command, io, tower) {
|
|
|
144
159
|
reason: result.reason,
|
|
145
160
|
http_status: result.httpStatus ?? null,
|
|
146
161
|
})}`);
|
|
147
|
-
return { section: null, reason: result.reason };
|
|
162
|
+
return { section: null, hooks: null, reason: result.reason };
|
|
148
163
|
}
|
|
149
164
|
const body = asRecord(result.body);
|
|
165
|
+
// BLI-3788: the hook counts ride the same answer, and their absence is a
|
|
166
|
+
// fact about the SERVER's version rather than about this fleet — an older
|
|
167
|
+
// dashboard sends no `hooks` key, and printing nothing is right there.
|
|
150
168
|
if (!body.memory)
|
|
151
|
-
return { section: null, reason: "memory_section_absent" };
|
|
152
|
-
return { section: body.memory, reason: "ok" };
|
|
169
|
+
return { section: null, hooks: body.hooks ?? null, reason: "memory_section_absent" };
|
|
170
|
+
return { section: body.memory, hooks: body.hooks ?? null, reason: "ok" };
|
|
153
171
|
}
|
|
154
172
|
async function runOpsRecompile(command, io, tower) {
|
|
155
173
|
const person = command.person ?? "";
|
|
@@ -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.70");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
* in, so the session file is the evidence — there is no
|
|
16
16
|
* second thing to ask.
|
|
17
17
|
* device that same session, still unexpired.
|
|
18
|
-
* collector.autostart `
|
|
19
|
-
* Scheduler what it actually has registered.
|
|
18
|
+
* collector.autostart `readAutostartRegistration`, which asks launchd or
|
|
19
|
+
* Task Scheduler what it actually has registered. That
|
|
20
|
+
* reader is SHARED with doctor's `autostart-alive` row
|
|
21
|
+
* (BLI-3793) — this module used to ask the scheduler
|
|
22
|
+
* itself, without the machine's saved roots, and so
|
|
23
|
+
* printed `autostart_not_loaded` under a doctor row
|
|
24
|
+
* that said the same agent was running.
|
|
20
25
|
*
|
|
21
26
|
* The five AGENT-HOST words in the printed block are NOT computed here. They
|
|
22
27
|
* are BLI-3729's `MemoryInstallReceipt`, already read back off
|
|
@@ -36,7 +41,7 @@
|
|
|
36
41
|
import os from "node:os";
|
|
37
42
|
import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
|
|
38
43
|
import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
|
|
39
|
-
import {
|
|
44
|
+
import { autostartSetupPiece, readAutostartRegistration, } from "./autostart-reading.js";
|
|
40
45
|
import { readLocalSessionReference } from "../local-state.js";
|
|
41
46
|
import { getCollectorRuntimePaths } from "../local-state-paths.js";
|
|
42
47
|
import { readJsonFile, writeJsonFile } from "../local-state-files.js";
|
|
@@ -63,14 +68,19 @@ export async function buildSetupReceipt(io, options = {}) {
|
|
|
63
68
|
});
|
|
64
69
|
const autostart = await probes.autostart().catch((error) => {
|
|
65
70
|
console.error(`${TAG} scheduler could not be read`, JSON.stringify({ reason: "autostart_probe_failed", error_name: errorName(error) }));
|
|
66
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
state: "unreadable",
|
|
73
|
+
reason: "autostart_probe_failed",
|
|
74
|
+
message: "the operating-system scheduler could not be read this run",
|
|
75
|
+
roots: [],
|
|
76
|
+
};
|
|
67
77
|
});
|
|
68
78
|
const receipt = {
|
|
69
79
|
schema_version: SETUP_RECEIPT_SCHEMA_VERSION,
|
|
70
80
|
checked_at: checkedAt,
|
|
71
81
|
browser: browserPiece(session),
|
|
72
82
|
device: devicePiece(session),
|
|
73
|
-
collector: { autostart:
|
|
83
|
+
collector: { autostart: autostartSetupPiece(autostart) },
|
|
74
84
|
};
|
|
75
85
|
const gaps = setupReceiptGaps(receipt, memory);
|
|
76
86
|
console.error(`${TAG} read back`, JSON.stringify({
|
|
@@ -97,19 +107,16 @@ function withDefaultProbes(io, homeDir, options) {
|
|
|
97
107
|
};
|
|
98
108
|
}),
|
|
99
109
|
autostart: options.probes?.autostart ??
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
...(result.message ? { message: result.message } : {}),
|
|
111
|
-
};
|
|
112
|
-
}),
|
|
110
|
+
// The one shared reader. It handles the no-process-runner host itself
|
|
111
|
+
// (`unreadable`, never "absent" — BLI-2541: never report a state you did
|
|
112
|
+
// not observe) and it hands the scheduler this machine's saved roots,
|
|
113
|
+
// which is what stopped this word from contradicting doctor's row
|
|
114
|
+
// (BLI-3793).
|
|
115
|
+
(async () => readAutostartRegistration({
|
|
116
|
+
exec: io.exec,
|
|
117
|
+
homeDir,
|
|
118
|
+
...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
|
|
119
|
+
})),
|
|
113
120
|
};
|
|
114
121
|
}
|
|
115
122
|
/**
|
|
@@ -139,32 +146,6 @@ function devicePiece(session) {
|
|
|
139
146
|
}
|
|
140
147
|
return { status: "missing", reason: `session_${session.state}` };
|
|
141
148
|
}
|
|
142
|
-
/**
|
|
143
|
-
* The scheduler is `ok` only when the host says it is registered AND loaded.
|
|
144
|
-
* `installed` on macOS means the plist is on disk; `loaded` means launchd has
|
|
145
|
-
* it. A plist nobody loaded runs nothing, which is exactly the failure that
|
|
146
|
-
* looks fine from the outside.
|
|
147
|
-
*/
|
|
148
|
-
function autostartPiece(result) {
|
|
149
|
-
if (result.status === "loaded" || result.status === "installed") {
|
|
150
|
-
return { status: "ok", reason: `autostart_${result.status}` };
|
|
151
|
-
}
|
|
152
|
-
if (result.status === "not_loaded") {
|
|
153
|
-
return { status: "missing", reason: "autostart_not_loaded" };
|
|
154
|
-
}
|
|
155
|
-
if (result.status === "absent" || result.status === "uninstalled") {
|
|
156
|
-
return { status: "missing", reason: "autostart_absent" };
|
|
157
|
-
}
|
|
158
|
-
if (result.status === "unsupported") {
|
|
159
|
-
return { status: "skipped", reason: "platform_unsupported" };
|
|
160
|
-
}
|
|
161
|
-
return { status: "unknown", reason: normalizeReason(result.status) };
|
|
162
|
-
}
|
|
163
|
-
/** Reason labels are `[a-z0-9_:.-]` by schema; anything else becomes one word. */
|
|
164
|
-
function normalizeReason(value) {
|
|
165
|
-
const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9_:.-]+/gu, "_");
|
|
166
|
-
return cleaned.slice(0, 120) || "unlabelled";
|
|
167
|
-
}
|
|
168
149
|
function errorName(error) {
|
|
169
150
|
return error instanceof Error ? error.name : typeof error;
|
|
170
151
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.70",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
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"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.10",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.11",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.31"
|
|
33
33
|
}
|
|
34
34
|
}
|